diff --git a/Cargo.toml b/Cargo.toml index d2192727..ce6bbdd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ objc2-app-kit = { version = "0.3.1", default-features = false, features = [ "NSApplication", "NSAttributedString", "NSButton", + "NSCell", "NSColor", "NSControl", "NSEvent", @@ -50,12 +51,16 @@ objc2-app-kit = { version = "0.3.1", default-features = false, features = [ "NSGraphicsContext", "NSImage", "NSStringDrawing", + "NSMenu", + "NSMenuItem", "NSResponder", "NSRunningApplication", "NSScreen", "NSStatusBar", "NSStatusBarButton", "NSStatusItem", + "NSWindow", + "NSPanel", "NSTouch", "NSView", "NSWindow", diff --git a/rift.default.toml b/rift.default.toml index 0b699a28..1d73bd93 100644 --- a/rift.default.toml +++ b/rift.default.toml @@ -137,6 +137,24 @@ active_label = "index" # options are "layout" or "label" display_style = "layout" +[settings.ui.centered_bar] +# enable centered workspace/app bar overlay +enabled = false +# show workspace numbers in the bar +show_numbers = true +# show the current mode indicator next to the workspace label when active +show_mode_indicator = false +# group windows by app and show a count badge +deduplicate_icons = false +# skip drawing empty workspaces +hide_empty_workspaces = false +# place the bar overlapping the menu bar or just below it +position = "overlapping_menu_bar" +# shift bar to avoid the notch area when present +notch_aware = false +# z-order for the bar; options: normal, floating, status, popup, screensaver +window_level = "popup" + [settings.ui.stack_line] # experimental stack line indicator (defaults to off) enabled = false diff --git a/src/actor.rs b/src/actor.rs index bdd593e7..0eeea29a 100644 --- a/src/actor.rs +++ b/src/actor.rs @@ -4,6 +4,7 @@ use tracing::Span; pub mod app; pub mod broadcast; +pub mod centered_bar; pub mod config; pub mod config_watcher; pub mod drag_swap; diff --git a/src/actor/centered_bar.rs b/src/actor/centered_bar.rs new file mode 100644 index 00000000..fab624fe --- /dev/null +++ b/src/actor/centered_bar.rs @@ -0,0 +1,691 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use objc2::rc::Retained; +use objc2::runtime::AnyObject; +use objc2::{DefinedClass, MainThreadOnly, Message, define_class, msg_send}; +use objc2_app_kit::{ + NSBackingStoreType, NSColor, NSEvent, NSImage, NSStatusWindowLevel, NSView, NSWindow, + NSWindowCollectionBehavior, NSWindowLevel, NSWindowStyleMask, +}; +use objc2_core_foundation::{CFAttributedString, CFDictionary, CFRetained, CGFloat, CGPoint, CGRect, CGSize}; +use objc2_core_graphics::CGContext; +use objc2_core_text::CTLine; +use objc2_foundation::{ + MainThreadMarker, NSAttributedStringKey, NSDictionary, NSMutableDictionary, NSPoint, NSRect, + NSSize, NSString, +}; +use objc2::runtime::ProtocolObject; + +use crate::actor; +use crate::actor::app::{WindowId, pid_t}; +use crate::actor::config as config_actor; +use crate::actor::reactor; +use crate::common::config::{ + CenteredBarPosition, CenteredBarSettings, CenteredBarWindowLevel, Config, +}; +use crate::model::server::{WindowData, WorkspaceData}; +use crate::sys::app::NSRunningApplicationExt; +use crate::sys::screen::SpaceId; +use crate::sys::window_server::WindowServerId; + +#[derive(Debug, Clone)] +pub struct UpdateDisplay { + pub screen_id: u32, + pub frame: CGRect, + pub visible_frame: CGRect, + pub space: Option, + pub workspaces: Vec, + pub active_workspace_idx: Option, +} + +#[derive(Debug, Clone)] +pub struct Update { + pub displays: Vec, +} + +pub enum Event { + Update(Update), + ConfigUpdated(Config), +} + +pub type Sender = actor::Sender; +pub type Receiver = actor::Receiver; + +const BAR_HEIGHT: f64 = 26.0; +const WORKSPACE_SPACING: f64 = 8.0; +const CONTENT_PADDING: f64 = 6.0; +const ICON_SIZE: f64 = 14.0; +const ICON_SPACING: f64 = 4.0; +const CORNER_RADIUS: f64 = 6.0; +const FONT_SIZE: f64 = 12.0; + +pub struct CenteredBar { + config: Config, + rx: Receiver, + mtm: MainThreadMarker, + panels: HashMap, + reactor_tx: reactor::Sender, + _config_tx: config_actor::Sender, + icon_cache: HashMap>, + last_signature: Option, +} + +struct DisplayPanel { + view: Retained, + panel: Retained, +} + +impl CenteredBar { + pub fn new( + config: Config, + rx: Receiver, + mtm: MainThreadMarker, + reactor_tx: reactor::Sender, + config_tx: config_actor::Sender, + ) -> Self { + Self { + config, + rx, + mtm, + panels: HashMap::new(), + reactor_tx, + _config_tx: config_tx, + icon_cache: HashMap::new(), + last_signature: None, + } + } + + pub async fn run(mut self) { + while let Some((span, event)) = self.rx.recv().await { + let _guard = span.enter(); + match event { + Event::Update(update) => self.handle_update(update), + Event::ConfigUpdated(cfg) => self.handle_config_updated(cfg), + } + } + self.dispose_panels(); + } + + fn handle_config_updated(&mut self, cfg: Config) { + self.config = cfg; + self.last_signature = None; + if !self.config.settings.ui.centered_bar.enabled { + self.dispose_panels(); + } + } + + fn handle_update(&mut self, update: Update) { + if !self.config.settings.ui.centered_bar.enabled { + self.dispose_panels(); + self.last_signature = None; + return; + } + + let sig = self.calc_signature(&update); + if self.last_signature == Some(sig) { + return; + } + self.last_signature = Some(sig); + + self.prune_panels(&update); + + for display in update.displays { + if display.space.is_none() { + continue; + } + if !self.panels.contains_key(&display.screen_id) { + let panel = self.make_panel(); + self.panels.insert(display.screen_id, panel); + } + if let Some(mut panel) = self.panels.remove(&display.screen_id) { + self.apply_display(&mut panel, &display); + self.panels.insert(display.screen_id, panel); + } + } + } + + fn dispose_panels(&mut self) { + for (_, disp) in self.panels.drain() { + disp.panel.orderOut(None::<&AnyObject>); + disp.panel.close(); + } + } + + fn prune_panels(&mut self, update: &Update) { + let keep: std::collections::HashSet = + update.displays.iter().map(|d| d.screen_id).collect(); + self.panels.retain(|screen_id, panel| { + if keep.contains(screen_id) { + true + } else { + panel.panel.orderOut(None::<&AnyObject>); + panel.panel.close(); + false + } + }); + } + + fn make_panel(&self) -> DisplayPanel { + let frame = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(0.0, 0.0)); + let style = NSWindowStyleMask::Borderless | NSWindowStyleMask::NonactivatingPanel; + let panel: Retained = unsafe { + let obj = NSWindow::alloc(self.mtm); + msg_send![obj, initWithContentRect: frame, styleMask: style, backing: NSBackingStoreType::Buffered, defer: false] + }; + panel.setHasShadow(false); + panel.setIgnoresMouseEvents(false); + panel.setOpaque(false); + panel.setBackgroundColor(Some(NSColor::clearColor().as_ref())); + panel.setCollectionBehavior( + NSWindowCollectionBehavior::CanJoinAllSpaces + | NSWindowCollectionBehavior::FullScreenAuxiliary + | NSWindowCollectionBehavior::Stationary, + ); + + let view = CenteredBarView::new(self.mtm); + panel.setContentView(Some(&*view)); + DisplayPanel { view, panel } + } + + fn apply_display(&mut self, display: &mut DisplayPanel, inputs: &UpdateDisplay) { + let settings = &self.config.settings.ui.centered_bar; + let layout = build_layout(inputs, settings, &mut self.icon_cache, self.mtm); + + if layout.workspaces.is_empty() { + display.panel.orderOut(None); + return; + } + + display.view.set_layout(layout.clone()); + let size = NSSize::new(layout.total_width, layout.total_height); + display.view.setFrameSize(size); + + let (x, y, width) = self.position_panel(inputs, size.width, settings); + let frame = NSRect::new(NSPoint::new(x, y), NSSize::new(width, size.height)); + + display.panel.setFrame_display(frame, true); + let inset_x = ((width - size.width) / 2.0).max(0.0); + display.view.setFrameOrigin(NSPoint::new(inset_x, 0.0)); + display.panel.setLevel(window_level_for(settings.window_level)); + display.panel.orderFrontRegardless(); + + let reactor_tx = self.reactor_tx.clone(); + display.view.set_action_handler(Rc::new(move |action| match action { + BarAction::Workspace(idx) => { + let _ = reactor_tx.try_send(reactor::Event::Command( + reactor::Command::Layout(crate::layout_engine::LayoutCommand::SwitchToWorkspace( + idx, + )), + )); + } + BarAction::Window { window_id, window_server_id } => { + let _ = reactor_tx.try_send(reactor::Event::Command( + reactor::Command::Reactor(reactor::ReactorCommand::FocusWindow { + window_id, + window_server_id: window_server_id.map(WindowServerId::new), + }), + )); + } + })); + } + + fn position_panel( + &self, + inputs: &UpdateDisplay, + content_width: f64, + settings: &CenteredBarSettings, + ) -> (f64, f64, f64) { + let usable = inputs.frame; + let full = inputs.visible_frame; + let bar_w = content_width + 8.0; + let target_height = BAR_HEIGHT; + let mut x = (full.origin.x + full.size.width / 2.0) - (bar_w / 2.0); + let y = match settings.position { + CenteredBarPosition::BelowMenuBar => usable.origin.y + usable.size.height - target_height, + CenteredBarPosition::OverlappingMenuBar => full.origin.y + full.size.height - target_height, + }; + + if settings.notch_aware && (full.size.width - usable.size.width).abs() > 40.0 { + x = (usable.origin.x + usable.size.width / 2.0) - (bar_w / 2.0); + } + + (x, y, bar_w) + } + + fn calc_signature(&self, update: &Update) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.config.settings.ui.centered_bar.hash(&mut h); + for display in &update.displays { + display.screen_id.hash(&mut h); + display.workspaces.len().hash(&mut h); + for ws in &display.workspaces { + ws.index.hash(&mut h); + ws.is_active.hash(&mut h); + ws.window_count.hash(&mut h); + for win in &ws.windows { + win.id.hash(&mut h); + win.is_focused.hash(&mut h); + } + } + } + h.finish() + } +} + +fn window_level_for(level: CenteredBarWindowLevel) -> NSWindowLevel { + match level { + CenteredBarWindowLevel::Normal => objc2_app_kit::NSNormalWindowLevel, + CenteredBarWindowLevel::Floating => objc2_app_kit::NSFloatingWindowLevel, + CenteredBarWindowLevel::Status => NSStatusWindowLevel, + CenteredBarWindowLevel::Screensaver => objc2_app_kit::NSScreenSaverWindowLevel, + CenteredBarWindowLevel::Popup => objc2_app_kit::NSPopUpMenuWindowLevel, + } +} + +#[derive(Clone)] +struct WorkspaceRenderData { + rect: CGRect, + label: Option, + windows: Vec, + is_active: bool, +} + +#[derive(Clone)] +struct WindowRenderData { + rect: CGRect, + rel_x: f64, + icon: Option>, + window_id: WindowId, + window_server_id: Option, + count: usize, +} + +#[derive(Clone, Default)] +struct BarLayout { + total_width: f64, + total_height: f64, + workspaces: Vec, + hits: Vec, +} + +#[derive(Clone)] +struct HitTarget { + rect: CGRect, + target: BarAction, +} + +#[derive(Clone)] +enum BarAction { + Workspace(usize), + Window { window_id: WindowId, window_server_id: Option }, +} + +#[derive(Clone)] +struct CachedTextLine { + line: CFRetained, + width: f64, + ascent: f64, + descent: f64, +} + +struct CenteredBarViewIvars { + layout: RefCell, + action_handler: RefCell>>, +} + +fn build_text_attrs(font: &objc2_app_kit::NSFont, color: &NSColor) -> Retained> { + let dict = NSMutableDictionary::::new(); + unsafe { + dict.setObject_forKeyedSubscript( + Some(as_any_object(font)), + ProtocolObject::from_ref(objc2_app_kit::NSFontAttributeName), + ); + dict.setObject_forKeyedSubscript( + Some(as_any_object(color)), + ProtocolObject::from_ref(objc2_app_kit::NSForegroundColorAttributeName), + ); + } + unsafe { Retained::cast_unchecked(dict) } +} + +fn build_cached_text_line( + label: &str, + attrs: &NSDictionary, +) -> Option { + if label.is_empty() { + return None; + } + + let label_ns = NSString::from_str(label); + let cf_string: &objc2_core_foundation::CFString = label_ns.as_ref(); + let cf_dict_ref: &CFDictionary = attrs.as_ref(); + let cf_dict: &CFDictionary = cf_dict_ref.as_opaque(); + let attr_string = unsafe { CFAttributedString::new(None, Some(cf_string), Some(cf_dict)) }?; + let line: CFRetained = + unsafe { CTLine::with_attributed_string(attr_string.as_ref()) }; + + let mut ascent: CGFloat = 0.0; + let mut descent: CGFloat = 0.0; + let mut leading: CGFloat = 0.0; + let width = unsafe { line.typographic_bounds(&mut ascent, &mut descent, &mut leading) }; + + Some(CachedTextLine { + line, + width: width as f64, + ascent: ascent as f64, + descent: descent as f64, + }) +} + +fn as_any_object(obj: &T) -> &AnyObject { + unsafe { &*(obj as *const T as *const AnyObject) } +} + +fn build_layout( + display: &UpdateDisplay, + settings: &CenteredBarSettings, + icon_cache: &mut HashMap>, + mtm: MainThreadMarker, +) -> BarLayout { + let font = objc2_app_kit::NSFont::menuBarFontOfSize(FONT_SIZE); + let color = NSColor::whiteColor(); + let text_attrs = build_text_attrs(font.as_ref(), color.as_ref()); + + let mut layout = BarLayout::default(); + layout.total_height = BAR_HEIGHT; + let mut offset_x = 0.0; + + for ws in display.workspaces.iter() { + if settings.hide_empty_workspaces && ws.window_count == 0 { + continue; + } + + let label_text = if settings.show_numbers { + if settings.show_mode_indicator && ws.is_active { + format!("[M] {}", ws.index + 1) + } else { + format!("{}", ws.index + 1) + } + } else { + ws.name.clone() + }; + + let label = build_cached_text_line(&label_text, &text_attrs); + let label_width = label.as_ref().map(|l| l.width).unwrap_or(0.0); + let mut windows = build_windows(ws, settings, icon_cache, mtm); + let icons_width = if windows.is_empty() { + 0.0 + } else { + (windows.len() as f64 * ICON_SIZE) + + (windows.len().saturating_sub(1) as f64) * ICON_SPACING + }; + + let label_spacing = if label_width > 0.0 && icons_width > 0.0 { + ICON_SPACING + } else { + 0.0 + }; + let content_width = label_width + label_spacing + icons_width; + let width = content_width.max(42.0) + (CONTENT_PADDING * 2.0); + + let rect = CGRect::new(CGPoint::new(offset_x, 0.0), CGSize::new(width, BAR_HEIGHT)); + let start_x = rect.origin.x + CONTENT_PADDING + label_width + label_spacing; + for window in windows.iter_mut() { + window.rect.origin.x = start_x + window.rel_x; + window.rect.origin.y = rect.origin.y + (BAR_HEIGHT - ICON_SIZE) / 2.0; + } + layout.hits.push(HitTarget { + rect, + target: BarAction::Workspace(ws.index), + }); + for window in &windows { + layout.hits.push(HitTarget { + rect: window.rect, + target: BarAction::Window { + window_id: window.window_id, + window_server_id: window.window_server_id, + }, + }); + } + layout.workspaces.push(WorkspaceRenderData { + rect, + label, + windows, + is_active: ws.is_active, + }); + offset_x += width + WORKSPACE_SPACING; + } + + layout.total_width = if offset_x > 0.0 { offset_x - WORKSPACE_SPACING } else { 0.0 }; + layout +} + +fn build_windows( + ws: &WorkspaceData, + settings: &CenteredBarSettings, + icon_cache: &mut HashMap>, + mtm: MainThreadMarker, +) -> Vec { + let mut windows = Vec::new(); + let mut arrangement: Vec<(Option>, usize, WindowId, Option, bool)> = + Vec::new(); + if settings.deduplicate_icons { + let mut grouped: HashMap<(Option, pid_t), Vec<&WindowData>> = HashMap::new(); + for w in &ws.windows { + grouped + .entry((w.bundle_id.clone(), w.id.pid)) + .or_default() + .push(w); + } + for ((_bundle, pid), group) in grouped { + let icon = icon_for_pid(pid, icon_cache, mtm); + let first = group[0]; + let _focused = group.iter().any(|w| w.is_focused); + arrangement.push((icon, group.len(), first.id, first.window_server_id, false)); + } + } else { + for w in &ws.windows { + arrangement.push(( + icon_for_pid(w.id.pid, icon_cache, mtm), + 1, + w.id, + w.window_server_id, + false, + )); + } + } + + let mut x = 0.0; + for (icon, count, window_id, window_server_id, _focused) in arrangement { + let rect = CGRect::new( + CGPoint::new(x, (BAR_HEIGHT - ICON_SIZE) / 2.0), + CGSize::new(ICON_SIZE, ICON_SIZE), + ); + windows.push(WindowRenderData { + rect, + rel_x: x, + icon, + window_id, + window_server_id, + count, + }); + x += ICON_SIZE + ICON_SPACING; + } + windows +} + +fn icon_for_pid( + pid: pid_t, + cache: &mut HashMap>, + mtm: MainThreadMarker, +) -> Option> { + if cache.contains_key(&pid) { + return cache.get(&pid).cloned(); + } + + let icon: Option> = NSRunningApplicationExt::with_process_id(pid) + .and_then(|app: Retained| app.icon()); + if let Some(ic) = icon { + cache.insert(pid, ic.clone()); + Some(ic) + } else { + let _ = mtm; // keep for consistency; nothing to do + None + } +} + +define_class!( + #[unsafe(super(NSView))] + #[thread_kind = MainThreadOnly] + #[name = "RiftCenteredBarView"] + #[ivars = CenteredBarViewIvars] + struct CenteredBarView; + + impl CenteredBarView { + #[unsafe(method(drawRect:))] + fn draw_rect(&self, _dirty: NSRect) { + let ivars = self.ivars(); + let layout = ivars.layout.borrow(); + let bounds = self.bounds(); + if let Some(ctx) = objc2_app_kit::NSGraphicsContext::currentContext() { + let cg_ctx = ctx.CGContext(); + let cg = cg_ctx.as_ref(); + CGContext::save_g_state(Some(cg)); + CGContext::clear_rect(Some(cg), bounds); + + let y_offset = (bounds.size.height - layout.total_height) / 2.0; + for workspace in layout.workspaces.iter() { + let rect = workspace.rect; + let y = rect.origin.y + y_offset; + add_rounded_rect(cg, rect.origin.x, y, rect.size.width, rect.size.height, CORNER_RADIUS); + let (fill_r, fill_g, fill_b, fill_a) = if workspace.is_active { + (1.0, 1.0, 1.0, 0.18) + } else { + (0.1, 0.1, 0.1, 0.55) + }; + CGContext::set_rgb_fill_color(Some(cg), fill_r, fill_g, fill_b, fill_a); + CGContext::fill_path(Some(cg)); + + add_rounded_rect(cg, rect.origin.x, y, rect.size.width, rect.size.height, CORNER_RADIUS); + let border_alpha = if workspace.is_active { 0.9 } else { 0.4 }; + CGContext::set_rgb_stroke_color(Some(cg), 1.0, 1.0, 1.0, border_alpha); + CGContext::set_line_width(Some(cg), 1.0); + CGContext::stroke_path(Some(cg)); + + if let Some(label) = &workspace.label { + let text_center_y = y + rect.size.height / 2.0; + let baseline_y = text_center_y - (label.ascent - label.descent) / 2.0; + let text_x = rect.origin.x + CONTENT_PADDING; + CGContext::save_g_state(Some(cg)); + CGContext::set_rgb_fill_color(Some(cg), 1.0, 1.0, 1.0, 0.9); + CGContext::set_text_position(Some(cg), text_x as CGFloat, baseline_y as CGFloat); + let line_ref: &CTLine = label.line.as_ref(); + unsafe { line_ref.draw(cg) }; + CGContext::restore_g_state(Some(cg)); + + for window in workspace.windows.iter() { + let rect = CGRect::new( + CGPoint::new(window.rect.origin.x, window.rect.origin.y + y_offset), + window.rect.size, + ); + draw_window_icon(cg, window, rect); + } + } else { + for window in workspace.windows.iter() { + let rect = CGRect::new( + CGPoint::new(window.rect.origin.x, window.rect.origin.y + y_offset), + window.rect.size, + ); + draw_window_icon(cg, window, rect); + } + } + } + + CGContext::restore_g_state(Some(cg)); + } + } + + #[unsafe(method(mouseDown:))] + fn mouse_down(&self, event: &NSEvent) { + let pt_window = event.locationInWindow(); + let local = self.convertPoint_fromView(pt_window, None); + let layout = self.ivars().layout.borrow(); + for hit in layout.hits.iter() { + let rect = hit.rect; + if rect_contains(rect, CGPoint::new(local.x, local.y)) { + if let Some(handler) = self.ivars().action_handler.borrow().as_ref() { + handler(hit.target.clone()); + } + break; + } + } + } + } +); + +impl CenteredBarView { + fn new(mtm: MainThreadMarker) -> Retained { + let frame = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(0.0, 0.0)); + let view = mtm.alloc().set_ivars(CenteredBarViewIvars { + layout: RefCell::new(BarLayout::default()), + action_handler: RefCell::new(None), + }); + unsafe { msg_send![super(view), initWithFrame: frame] } + } + + fn set_layout(&self, layout: BarLayout) { + *self.ivars().layout.borrow_mut() = layout; + self.setNeedsDisplay(true); + } + + fn set_action_handler(&self, handler: Rc) { + *self.ivars().action_handler.borrow_mut() = Some(handler); + } +} + +fn draw_window_icon(ctx: &CGContext, window: &WindowRenderData, rect: CGRect) { + if let Some(icon) = &window.icon { + icon.drawInRect(rect); + } else { + add_rounded_rect(ctx, rect.origin.x, rect.origin.y, rect.size.width, rect.size.height, 3.0); + CGContext::set_rgb_fill_color(Some(ctx), 1.0, 1.0, 1.0, 0.6); + CGContext::fill_path(Some(ctx)); + } + + if window.count > 1 { + let badge_size = 12.0_f64; + let badge_rect = CGRect::new( + CGPoint::new(rect.origin.x + rect.size.width - (badge_size / 2.0), rect.origin.y + rect.size.height - (badge_size / 2.0)), + CGSize::new(badge_size, badge_size), + ); + add_rounded_rect(ctx, badge_rect.origin.x, badge_rect.origin.y, badge_rect.size.width, badge_rect.size.height, badge_size / 2.0); + CGContext::set_rgb_fill_color(Some(ctx), 0.8, 0.2, 0.2, 1.0); + CGContext::fill_path(Some(ctx)); + } +} + +fn add_rounded_rect(ctx: &CGContext, x: f64, y: f64, w: f64, h: f64, r: f64) { + let ctx = Some(ctx); + let r = r.min(w / 2.0).min(h / 2.0); + CGContext::begin_path(ctx); + CGContext::move_to_point(ctx, x + r, y + h); + CGContext::add_line_to_point(ctx, x + w - r, y + h); + CGContext::add_arc_to_point(ctx, x + w, y + h, x + w, y + h - r, r); + CGContext::add_line_to_point(ctx, x + w, y + r); + CGContext::add_arc_to_point(ctx, x + w, y, x + w - r, y, r); + CGContext::add_line_to_point(ctx, x + r, y); + CGContext::add_arc_to_point(ctx, x, y, x, y + r, r); + CGContext::add_line_to_point(ctx, x, y + h - r); + CGContext::add_arc_to_point(ctx, x, y + h, x + r, y + h, r); + CGContext::close_path(ctx); +} + +fn rect_contains(rect: CGRect, point: CGPoint) -> bool { + point.x >= rect.origin.x + && point.x <= rect.origin.x + rect.size.width + && point.y >= rect.origin.y + && point.y <= rect.origin.y + rect.size.height +} diff --git a/src/actor/config.rs b/src/actor/config.rs index f21255c3..4990428a 100644 --- a/src/actor/config.rs +++ b/src/actor/config.rs @@ -19,6 +19,10 @@ pub enum Event { #[serde(skip)] response: r#continue::Sender>, }, + #[serde(skip)] + ApplyConfigFireAndForget { + cmd: ConfigCommand, + }, } pub struct ConfigActor { @@ -63,6 +67,12 @@ impl ConfigActor { let res = self.handle_config_command(cmd); let _ = response.send(res); } + Event::ApplyConfigFireAndForget { cmd } => { + let res = self.handle_config_command(cmd); + if let Err(e) = res { + tracing::warn!("config apply (fire-and-forget) failed: {}", e); + } + } } } } diff --git a/src/actor/menu_bar.rs b/src/actor/menu_bar.rs index eb47615b..905c13d7 100644 --- a/src/actor/menu_bar.rs +++ b/src/actor/menu_bar.rs @@ -3,6 +3,8 @@ use objc2::MainThreadMarker; use tokio::sync::mpsc::UnboundedSender; use crate::actor; +use crate::actor::centered_bar; +use crate::actor::config as config_actor; use crate::common::config::Config; use crate::model::VirtualWorkspaceId; use crate::model::server::{WindowData, WorkspaceData}; @@ -28,6 +30,8 @@ pub struct Menu { rx: Receiver, icon: Option, mtm: MainThreadMarker, + _config_tx: config_actor::Sender, + centered_bar_tx: centered_bar::Sender, last_signature: Option, last_update: Option, } @@ -36,12 +40,25 @@ pub type Sender = actor::Sender; pub type Receiver = actor::Receiver; impl Menu { - pub fn new(config: Config, rx: Receiver, mtm: MainThreadMarker) -> Self { + pub fn new( + config: Config, + rx: Receiver, + mtm: MainThreadMarker, + config_tx: config_actor::Sender, + centered_bar_tx: centered_bar::Sender, + ) -> Self { Self { - icon: config.settings.ui.menu_bar.enabled.then(|| MenuIcon::new(mtm)), + icon: config + .settings + .ui + .menu_bar + .enabled + .then(|| MenuIcon::new(mtm, config_tx.clone())), config, rx, mtm, + _config_tx: config_tx, + centered_bar_tx, last_signature: None, last_update: None, } @@ -118,6 +135,7 @@ impl Menu { self.last_signature = Some(sig); let menu_bar_settings = &self.config.settings.ui.menu_bar; + icon.update_menu(&self.config); icon.update( update.active_space, update.workspaces.clone(), @@ -125,6 +143,13 @@ impl Menu { update.windows.clone(), menu_bar_settings, ); + + // keep centered bar in sync (e.g., enable/disable via config) + if let Err(e) = + self.centered_bar_tx.try_send(centered_bar::Event::ConfigUpdated(self.config.clone())) + { + tracing::warn!("Failed to notify centered bar of config: {}", e); + } } fn handle_config_updated(&mut self, new_config: Config) { @@ -133,7 +158,7 @@ impl Menu { self.config = new_config; if should_enable && self.icon.is_none() { - self.icon = Some(MenuIcon::new(self.mtm)); + self.icon = Some(MenuIcon::new(self.mtm, self._config_tx.clone())); } else if !should_enable && self.icon.is_some() { self.icon = None; } @@ -141,6 +166,8 @@ impl Menu { self.last_signature = None; if let Some(update) = self.last_update.take() { self.handle_update(update); + } else if let Some(icon) = &mut self.icon { + icon.update_menu(&self.config); } } diff --git a/src/actor/reactor.rs b/src/actor/reactor.rs index 3c5e82d6..b8bee82d 100644 --- a/src/actor/reactor.rs +++ b/src/actor/reactor.rs @@ -44,7 +44,7 @@ use crate::actor::app::{AppInfo, AppThreadHandle, Quiet, Request, WindowId, Wind use crate::actor::broadcast::{BroadcastEvent, BroadcastSender}; use crate::actor::raise_manager::{self, RaiseManager, RaiseRequest}; use crate::actor::reactor::events::window_discovery::WindowDiscoveryHandler; -use crate::actor::{self, menu_bar, stack_line}; +use crate::actor::{self, centered_bar, menu_bar, stack_line}; use crate::common::collections::{BTreeMap, HashMap, HashSet}; use crate::common::config::Config; use crate::common::log::MetricsCommand; @@ -74,6 +74,8 @@ use crate::model::server::{ pub struct ScreenSnapshot { #[serde(with = "CGRectDef")] pub frame: CGRect, + #[serde(with = "CGRectDef")] + pub visible_frame: CGRect, pub space: Option, pub display_uuid: String, pub name: Option, @@ -382,6 +384,7 @@ struct PendingSpaceChange { #[derive(Clone, Debug)] struct Screen { frame: CGRect, + visible_frame: CGRect, space: Option, display_uuid: String, name: Option, @@ -445,6 +448,7 @@ impl Reactor { broadcast_tx: BroadcastSender, menu_tx: menu_bar::Sender, stack_line_tx: stack_line::Sender, + centered_bar_tx: centered_bar::Sender, window_notify: Option<(crate::actor::window_notify::Sender, WindowTxStore)>, ) -> Sender { let (events_tx, events) = actor::channel(); @@ -456,6 +460,7 @@ impl Reactor { Reactor::new(config, layout_engine, record, broadcast_tx, window_notify); reactor.communication_manager.event_tap_tx = Some(event_tap_tx); reactor.menu_manager.menu_tx = Some(menu_tx); + reactor.menu_manager.centered_bar_tx = Some(centered_bar_tx); reactor.communication_manager.stack_line_tx = Some(stack_line_tx); reactor.communication_manager.events_tx = Some(events_tx_clone.clone()); Executor::run(reactor.run(events, events_tx_clone)); @@ -531,6 +536,7 @@ impl Reactor { menu_manager: managers::MenuManager { menu_state: MenuState::Closed, menu_tx: None, + centered_bar_tx: None, }, mission_control_manager: managers::MissionControlManager { mission_control_state: MissionControlState::Inactive, @@ -852,6 +858,7 @@ impl Reactor { false }); self.maybe_send_menu_update(); + self.maybe_send_centered_bar_update(); } self.workspace_switch_manager.workspace_switch_state = WorkspaceSwitchState::Inactive; @@ -2244,6 +2251,7 @@ impl Reactor { false }); self.maybe_send_menu_update(); + self.maybe_send_centered_bar_update(); } fn force_refresh_all_windows(&mut self) { diff --git a/src/actor/reactor/events/command.rs b/src/actor/reactor/events/command.rs index ed8743fb..d97ce6e5 100644 --- a/src/actor/reactor/events/command.rs +++ b/src/actor/reactor/events/command.rs @@ -5,7 +5,7 @@ use crate::actor::app::{AppThreadHandle, WindowId}; use crate::actor::reactor::{DisplaySelector, FocusDisplaySelector, Reactor, WorkspaceSwitchState}; use crate::actor::stack_line::Event as StackLineEvent; use crate::actor::wm_controller::WmEvent; -use crate::actor::{menu_bar, raise_manager}; +use crate::actor::{centered_bar, menu_bar, raise_manager}; use crate::common::collections::HashMap; use crate::common::config::{self as config, Config}; use crate::common::log::{MetricsCommand, handle_command}; @@ -137,11 +137,19 @@ impl CommandEventHandler { warn!("Failed to send config update to menu bar: {}", e); } } + if let Some(tx) = &reactor.menu_manager.centered_bar_tx { + if let Err(e) = tx.try_send(centered_bar::Event::ConfigUpdated( + reactor.config_manager.config.clone(), + )) { + warn!("Failed to send config update to centered bar: {}", e); + } + } let _ = reactor.update_layout(false, true).unwrap_or_else(|e| { warn!("Layout update failed: {}", e); false }); + reactor.maybe_send_centered_bar_update(); if old_keys != reactor.config_manager.config.keys { if let Some(wm) = &reactor.communication_manager.wm_sender { diff --git a/src/actor/reactor/events/space.rs b/src/actor/reactor/events/space.rs index 4d2760d9..ca3f306e 100644 --- a/src/actor/reactor/events/space.rs +++ b/src/actor/reactor/events/space.rs @@ -213,6 +213,7 @@ impl SpaceEventHandler { .into_iter() .map(|snapshot| Screen { frame: snapshot.frame, + visible_frame: snapshot.visible_frame, space: snapshot.space, display_uuid: snapshot.display_uuid, name: snapshot.name, diff --git a/src/actor/reactor/managers.rs b/src/actor/reactor/managers.rs index 5dfd4984..c1e8370c 100644 --- a/src/actor/reactor/managers.rs +++ b/src/actor/reactor/managers.rs @@ -14,7 +14,9 @@ use crate::actor::broadcast::BroadcastSender; use crate::actor::drag_swap::DragManager as DragSwapManager; use crate::actor::reactor::Reactor; use crate::actor::reactor::animation::AnimationManager; -use crate::actor::{event_tap, menu_bar, raise_manager, stack_line, window_notify, wm_controller}; +use crate::actor::{ + centered_bar, event_tap, menu_bar, raise_manager, stack_line, window_notify, wm_controller, +}; use crate::common::collections::{HashMap, HashSet}; use crate::common::config::{Config, WindowSnappingSettings}; use crate::layout_engine::LayoutEngine; @@ -131,6 +133,7 @@ pub struct NotificationManager { pub struct MenuManager { pub menu_state: super::MenuState, pub menu_tx: Option, + pub centered_bar_tx: Option, } /// Manages Mission Control state diff --git a/src/actor/reactor/query.rs b/src/actor/reactor/query.rs index e8230c1d..cec97fd4 100644 --- a/src/actor/reactor/query.rs +++ b/src/actor/reactor/query.rs @@ -1,7 +1,7 @@ use objc2_core_foundation::CGRect; use crate::actor::app::WindowId; -use crate::actor::menu_bar; +use crate::actor::{centered_bar, menu_bar}; use crate::actor::reactor::{Event, Reactor}; use crate::common::collections::HashSet; use crate::model::server::{ @@ -73,6 +73,40 @@ impl Reactor { })); } + pub(super) fn maybe_send_centered_bar_update(&mut self) { + let bar_tx = match self.menu_manager.centered_bar_tx.as_ref() { + Some(tx) => tx.clone(), + None => return, + }; + + if !self.config_manager.config.settings.ui.centered_bar.enabled { + return; + } + + let mut displays = Vec::new(); + for screen in self.space_manager.screens.clone() { + let Some(space_id) = self.space_manager.space_for_screen(&screen) else { + continue; + }; + let workspaces = self.handle_workspace_query(Some(space_id)); + let active_idx = self.layout_manager.layout_engine.active_workspace_idx(space_id); + displays.push(centered_bar::UpdateDisplay { + screen_id: screen.screen_id.as_u32(), + frame: screen.frame, + visible_frame: screen.visible_frame, + space: Some(space_id), + workspaces, + active_workspace_idx: active_idx, + }); + } + + if displays.is_empty() { + return; + } + + bar_tx.send(centered_bar::Event::Update(centered_bar::Update { displays })); + } + fn handle_workspace_query(&mut self, space_id_param: Option) -> Vec { let mut workspaces = Vec::new(); diff --git a/src/actor/reactor/testing.rs b/src/actor/reactor/testing.rs index c8ee9568..28306445 100644 --- a/src/actor/reactor/testing.rs +++ b/src/actor/reactor/testing.rs @@ -40,6 +40,7 @@ pub fn make_screen_snapshots( .enumerate() .map(|(idx, (frame, space))| ScreenSnapshot { frame, + visible_frame: frame, space, display_uuid: format!("test-display-{idx}"), name: None, diff --git a/src/actor/wm_controller.rs b/src/actor/wm_controller.rs index b5014c89..ebbbbe28 100644 --- a/src/actor/wm_controller.rs +++ b/src/actor/wm_controller.rs @@ -315,6 +315,7 @@ impl WmController { .map(|(descriptor, space)| reactor::ScreenSnapshot { screen_id: descriptor.id.as_u32(), frame: descriptor.frame, + visible_frame: descriptor.visible_frame, space, display_uuid: descriptor.display_uuid, name: descriptor.name, diff --git a/src/bin/rift.rs b/src/bin/rift.rs index 7282ef6c..8a2b63ee 100644 --- a/src/bin/rift.rs +++ b/src/bin/rift.rs @@ -7,6 +7,7 @@ use objc2_application_services::AXUIElement; use rift_wm::actor::config::ConfigActor; use rift_wm::actor::config_watcher::ConfigWatcher; use rift_wm::actor::event_tap::EventTap; +use rift_wm::actor::centered_bar::CenteredBar; use rift_wm::actor::menu_bar::Menu; use rift_wm::actor::mission_control::MissionControlActor; use rift_wm::actor::mission_control_observer::NativeMissionControl; @@ -153,6 +154,7 @@ Enable it in System Settings > Desktop & Dock (Mission Control) and restart Rift let (event_tap_tx, event_tap_rx) = rift_wm::actor::channel(); let (menu_tx, menu_rx) = rift_wm::actor::channel(); let (stack_line_tx, stack_line_rx) = rift_wm::actor::channel(); + let (centered_bar_tx, centered_bar_rx) = rift_wm::actor::channel(); let (wnd_tx, wnd_rx) = rift_wm::actor::channel(); let window_tx_store = WindowTxStore::new(); let events_tx = Reactor::spawn( @@ -163,6 +165,7 @@ Enable it in System Settings > Desktop & Dock (Mission Control) and restart Rift broadcast_tx.clone(), menu_tx.clone(), stack_line_tx.clone(), + centered_bar_tx.clone(), Some((wnd_tx.clone(), window_tx_store.clone())), ); @@ -239,7 +242,7 @@ Enable it in System Settings > Desktop & Dock (Mission Control) and restart Rift event_tap_rx, Some(wm_controller_sender.clone()), ); - let menu = Menu::new(config.clone(), menu_rx, mtm); + let menu = Menu::new(config.clone(), menu_rx, mtm, config_tx.clone(), centered_bar_tx.clone()); let stack_line = StackLine::new( config.clone(), stack_line_rx, @@ -247,6 +250,13 @@ Enable it in System Settings > Desktop & Dock (Mission Control) and restart Rift events_tx.clone(), CoordinateConverter::default(), ); + let centered_bar = CenteredBar::new( + config.clone(), + centered_bar_rx, + mtm, + events_tx.clone(), + config_tx.clone(), + ); let mission_control = MissionControlActor::new(config.clone(), mc_rx, events_tx.clone(), mtm); let mission_control_native = NativeMissionControl::new(events_tx.clone(), mc_native_rx); @@ -266,6 +276,7 @@ Enable it in System Settings > Desktop & Dock (Mission Control) and restart Rift event_tap.run(), menu.run(), stack_line.run(), + centered_bar.run(), wn_actor.run(), mission_control_native.run(), mission_control.run(), diff --git a/src/common/config.rs b/src/common/config.rs index e573b2d3..ffbd9f02 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -378,6 +378,8 @@ pub struct UiSettings { pub stack_line: StackLineSettings, #[serde(default)] pub mission_control: MissionControlSettings, + #[serde(default)] + pub centered_bar: CenteredBarSettings, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] @@ -470,6 +472,46 @@ pub struct MenuBarSettings { pub display_style: WorkspaceDisplayStyle, } +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy, Default, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum CenteredBarPosition { + #[default] + OverlappingMenuBar, + BelowMenuBar, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy, Default, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum CenteredBarWindowLevel { + #[default] + Popup, + Normal, + Floating, + Status, + Screensaver, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default, Eq, Hash)] +#[serde(deny_unknown_fields)] +pub struct CenteredBarSettings { + #[serde(default = "no")] + pub enabled: bool, + #[serde(default = "yes")] + pub show_numbers: bool, + #[serde(default = "no")] + pub show_mode_indicator: bool, + #[serde(default = "no")] + pub deduplicate_icons: bool, + #[serde(default = "no")] + pub hide_empty_workspaces: bool, + #[serde(default)] + pub position: CenteredBarPosition, + #[serde(default = "no")] + pub notch_aware: bool, + #[serde(default)] + pub window_level: CenteredBarWindowLevel, +} + #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)] #[serde(deny_unknown_fields)] pub struct StackLineSettings { diff --git a/src/sys/screen.rs b/src/sys/screen.rs index e0bb57ea..f1de1d63 100644 --- a/src/sys/screen.rs +++ b/src/sys/screen.rs @@ -46,6 +46,7 @@ pub struct ScreenCache { pub struct ScreenDescriptor { pub id: ScreenId, pub frame: CGRect, + pub visible_frame: CGRect, pub display_uuid: String, pub name: Option, } @@ -103,11 +104,13 @@ impl ScreenCache { warn!("Can't find NSScreen corresponding to {cg_id:?}"); return None; }; - let converted = converter.convert_rect(ns_screen.visible_frame).unwrap(); + let visible = converter.convert_rect(ns_screen.visible_frame).unwrap(); + let frame_full = converter.convert_rect(ns_screen.frame).unwrap_or(visible); let display_uuid = uuid_strings.get(idx).cloned(); let descriptor = ScreenDescriptor { id: cg_id, - frame: converted, + frame: visible, + visible_frame: frame_full, display_uuid: display_uuid.unwrap_or_else(|| { warn!("Missing cached UUID for {:?}", cg_id); String::new() diff --git a/src/ui.rs b/src/ui.rs index 7a686255..54b207c8 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,3 +1,4 @@ pub mod menu_bar; pub mod mission_control; +pub mod centered_bar; pub mod stack_line; diff --git a/src/ui/centered_bar.rs b/src/ui/centered_bar.rs new file mode 100644 index 00000000..17cbee29 --- /dev/null +++ b/src/ui/centered_bar.rs @@ -0,0 +1,2 @@ +// Centered bar rendering lives in actor/centered_bar.rs, which defines the +// AppKit view and layout logic. This module exists to mirror the ui tree. diff --git a/src/ui/menu_bar.rs b/src/ui/menu_bar.rs index e983394c..da7c173c 100644 --- a/src/ui/menu_bar.rs +++ b/src/ui/menu_bar.rs @@ -2,11 +2,12 @@ use std::cell::RefCell; use objc2::rc::Retained; -use objc2::runtime::{AnyObject, ProtocolObject}; -use objc2::{DefinedClass, MainThreadOnly, Message, define_class, msg_send}; +use objc2::runtime::{AnyObject, NSObject, ProtocolObject, Sel}; +use objc2::{DefinedClass, MainThreadOnly, Message, define_class, msg_send, sel}; use objc2_app_kit::{ - NSColor, NSFont, NSFontAttributeName, NSForegroundColorAttributeName, NSGraphicsContext, - NSStatusBar, NSStatusItem, NSVariableStatusItemLength, NSView, + NSColor, NSControlStateValueOff, NSControlStateValueOn, NSFont, NSFontAttributeName, + NSForegroundColorAttributeName, NSGraphicsContext, NSMenu, NSMenuItem, NSStatusBar, + NSStatusItem, NSVariableStatusItemLength, NSView, }; use objc2_core_foundation::{ CFAttributedString, CFDictionary, CFRetained, CFString, CGFloat, CGPoint, CGRect, CGSize, @@ -17,10 +18,13 @@ use objc2_foundation::{ MainThreadMarker, NSAttributedStringKey, NSDictionary, NSMutableDictionary, NSRect, NSSize, NSString, }; +use serde_json::json; use tracing::debug; +use crate::actor::config as config_actor; use crate::common::config::{ - ActiveWorkspaceLabel, MenuBarDisplayMode, MenuBarSettings, WorkspaceDisplayStyle, + ActiveWorkspaceLabel, CenteredBarPosition, CenteredBarWindowLevel, Config, ConfigCommand, + MenuBarDisplayMode, MenuBarSettings, WorkspaceDisplayStyle, }; use crate::model::VirtualWorkspaceId; use crate::model::server::{WindowData, WorkspaceData}; @@ -34,32 +38,349 @@ const BORDER_WIDTH: f64 = 1.0; const CONTENT_INSET: f64 = 2.0; const FONT_SIZE: f64 = 12.0; +struct MenuHandlerIvars { + config_tx: config_actor::Sender, +} + +define_class! { + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "RiftCenteredBarMenuHandler"] + #[ivars = MenuHandlerIvars] + struct MenuHandler; + + impl MenuHandler { + #[unsafe(method(toggleCenteredBar:))] + fn toggle_centered_bar(&self, sender: &NSMenuItem) { + let new_val = sender.state() != NSControlStateValueOn; + self.apply_bool("settings.ui.centered_bar.enabled", new_val); + } + + #[unsafe(method(toggleCenteredBarShowNumbers:))] + fn toggle_show_numbers(&self, sender: &NSMenuItem) { + let new_val = sender.state() != NSControlStateValueOn; + self.apply_bool("settings.ui.centered_bar.show_numbers", new_val); + } + + #[unsafe(method(toggleCenteredBarShowMode:))] + fn toggle_show_mode(&self, sender: &NSMenuItem) { + let new_val = sender.state() != NSControlStateValueOn; + self.apply_bool("settings.ui.centered_bar.show_mode_indicator", new_val); + } + + #[unsafe(method(toggleCenteredBarDedup:))] + fn toggle_dedup(&self, sender: &NSMenuItem) { + let new_val = sender.state() != NSControlStateValueOn; + self.apply_bool("settings.ui.centered_bar.deduplicate_icons", new_val); + } + + #[unsafe(method(toggleCenteredBarHideEmpty:))] + fn toggle_hide_empty(&self, sender: &NSMenuItem) { + let new_val = sender.state() != NSControlStateValueOn; + self.apply_bool("settings.ui.centered_bar.hide_empty_workspaces", new_val); + } + + #[unsafe(method(toggleCenteredBarNotchAware:))] + fn toggle_notch(&self, sender: &NSMenuItem) { + let new_val = sender.state() != NSControlStateValueOn; + self.apply_bool("settings.ui.centered_bar.notch_aware", new_val); + } + + #[unsafe(method(setCenteredBarPositionOverlap:))] + fn set_position_overlap(&self, _sender: &NSMenuItem) { + self.apply_str("settings.ui.centered_bar.position", "overlapping_menu_bar"); + } + + #[unsafe(method(setCenteredBarPositionBelow:))] + fn set_position_below(&self, _sender: &NSMenuItem) { + self.apply_str("settings.ui.centered_bar.position", "below_menu_bar"); + } + + #[unsafe(method(setCenteredBarLevelNormal:))] + fn set_level_normal(&self, _sender: &NSMenuItem) { + self.apply_str("settings.ui.centered_bar.window_level", "normal"); + } + + #[unsafe(method(setCenteredBarLevelFloating:))] + fn set_level_floating(&self, _sender: &NSMenuItem) { + self.apply_str("settings.ui.centered_bar.window_level", "floating"); + } + + #[unsafe(method(setCenteredBarLevelStatus:))] + fn set_level_status(&self, _sender: &NSMenuItem) { + self.apply_str("settings.ui.centered_bar.window_level", "status"); + } + + #[unsafe(method(setCenteredBarLevelPopup:))] + fn set_level_popup(&self, _sender: &NSMenuItem) { + self.apply_str("settings.ui.centered_bar.window_level", "popup"); + } + + #[unsafe(method(setCenteredBarLevelScreensaver:))] + fn set_level_screensaver(&self, _sender: &NSMenuItem) { + self.apply_str("settings.ui.centered_bar.window_level", "screensaver"); + } + } +} + +impl MenuHandler { + fn new(mtm: MainThreadMarker, config_tx: config_actor::Sender) -> Retained { + let obj = mtm.alloc().set_ivars(MenuHandlerIvars { config_tx }); + unsafe { msg_send![super(obj), init] } + } + + fn apply_bool(&self, key: &str, value: bool) { + self.apply_config(ConfigCommand::Set { key: key.to_string(), value: json!(value) }); + } + + fn apply_str(&self, key: &str, value: &str) { + self.apply_config(ConfigCommand::Set { key: key.to_string(), value: json!(value) }); + } + + fn apply_config(&self, cmd: ConfigCommand) { + let tx = &self.ivars().config_tx; + tx.send(config_actor::Event::ApplyConfigFireAndForget { cmd }); + } +} + pub struct MenuIcon { status_item: Retained, view: Retained, mtm: MainThreadMarker, prev_width: f64, + menu: Retained, + handler: Retained, } impl MenuIcon { - pub fn new(mtm: MainThreadMarker) -> Self { + pub fn new(mtm: MainThreadMarker, config_tx: config_actor::Sender) -> Self { let status_bar = NSStatusBar::systemStatusBar(); let status_item = status_bar.statusItemWithLength(NSVariableStatusItemLength); let view = MenuIconView::new(mtm); + let menu: Retained = unsafe { msg_send![NSMenu::alloc(mtm), init] }; + let handler = MenuHandler::new(mtm, config_tx.clone()); + status_item.setMenu(Some(&menu)); if let Some(btn) = status_item.button(mtm) { btn.addSubview(&*view); view.setFrameSize(NSSize::new(0.0, 0.0)); status_item.setVisible(true); } - Self { + let this = Self { status_item, view, mtm, prev_width: 0.0, - } + menu, + handler, + }; + + this.refresh_click_handler(); + this } + pub fn update_menu(&mut self, config: &Config) { + let centered = &config.settings.ui.centered_bar; + let new_menu: Retained = unsafe { msg_send![NSMenu::alloc(self.mtm), init] }; + let add_toggle = |menu: &NSMenu, + title: &str, + selector: Sel, + checked: bool, + handler: &MenuHandler, + mtm: MainThreadMarker| { + let item: Retained = unsafe { + let title_ns = NSString::from_str(title); + let empty_ns = NSString::from_str(""); + let title_ref: &NSString = title_ns.as_ref(); + let empty_ref: &NSString = empty_ns.as_ref(); + msg_send![ + NSMenuItem::alloc(mtm), + initWithTitle: title_ref, + action: Some(selector), + keyEquivalent: empty_ref + ] + }; + unsafe { item.setTarget(Some(handler)) }; + item.setState(if checked { NSControlStateValueOn } else { NSControlStateValueOff }); + menu.addItem(&item); + }; + + add_toggle( + &new_menu, + "Centered bar enabled", + sel!(toggleCenteredBar:), + centered.enabled, + &self.handler, + self.mtm, + ); + add_toggle( + &new_menu, + "Show workspace numbers", + sel!(toggleCenteredBarShowNumbers:), + centered.show_numbers, + &self.handler, + self.mtm, + ); + add_toggle( + &new_menu, + "Show mode indicator", + sel!(toggleCenteredBarShowMode:), + centered.show_mode_indicator, + &self.handler, + self.mtm, + ); + add_toggle( + &new_menu, + "Deduplicate app icons", + sel!(toggleCenteredBarDedup:), + centered.deduplicate_icons, + &self.handler, + self.mtm, + ); + add_toggle( + &new_menu, + "Hide empty workspaces", + sel!(toggleCenteredBarHideEmpty:), + centered.hide_empty_workspaces, + &self.handler, + self.mtm, + ); + add_toggle( + &new_menu, + "Notch-aware positioning", + sel!(toggleCenteredBarNotchAware:), + centered.notch_aware, + &self.handler, + self.mtm, + ); + + new_menu.addItem(&NSMenuItem::separatorItem(self.mtm)); + + let position_sub: Retained = unsafe { msg_send![NSMenu::alloc(self.mtm), init] }; + let add_radio = |menu: &NSMenu, + title: &str, + selector: Sel, + on: bool, + handler: &MenuHandler, + mtm: MainThreadMarker| { + let item: Retained = unsafe { + let title_ns = NSString::from_str(title); + let empty_ns = NSString::from_str(""); + let title_ref: &NSString = title_ns.as_ref(); + let empty_ref: &NSString = empty_ns.as_ref(); + msg_send![ + NSMenuItem::alloc(mtm), + initWithTitle: title_ref, + action: Some(selector), + keyEquivalent: empty_ref + ] + }; + unsafe { item.setTarget(Some(handler)) }; + item.setState(if on { NSControlStateValueOn } else { NSControlStateValueOff }); + menu.addItem(&item); + }; + add_radio( + &position_sub, + "Overlap menu bar", + sel!(setCenteredBarPositionOverlap:), + matches!(centered.position, CenteredBarPosition::OverlappingMenuBar), + &self.handler, + self.mtm, + ); + add_radio( + &position_sub, + "Below menu bar", + sel!(setCenteredBarPositionBelow:), + matches!(centered.position, CenteredBarPosition::BelowMenuBar), + &self.handler, + self.mtm, + ); + let pos_item: Retained = unsafe { + let title_ns = NSString::from_str("Bar position"); + let empty_ns = NSString::from_str(""); + let title_ref: &NSString = title_ns.as_ref(); + let empty_ref: &NSString = empty_ns.as_ref(); + msg_send![ + NSMenuItem::alloc(self.mtm), + initWithTitle: title_ref, + action: None::, + keyEquivalent: empty_ref + ] + }; + pos_item.setSubmenu(Some(&position_sub)); + new_menu.addItem(&pos_item); + + let level_sub: Retained = unsafe { msg_send![NSMenu::alloc(self.mtm), init] }; + let level_matches = |lvl: CenteredBarWindowLevel| centered.window_level == lvl; + add_radio( + &level_sub, + "Popup (default)", + sel!(setCenteredBarLevelPopup:), + level_matches(CenteredBarWindowLevel::Popup), + &self.handler, + self.mtm, + ); + add_radio( + &level_sub, + "Normal", + sel!(setCenteredBarLevelNormal:), + level_matches(CenteredBarWindowLevel::Normal), + &self.handler, + self.mtm, + ); + add_radio( + &level_sub, + "Floating", + sel!(setCenteredBarLevelFloating:), + level_matches(CenteredBarWindowLevel::Floating), + &self.handler, + self.mtm, + ); + add_radio( + &level_sub, + "Status", + sel!(setCenteredBarLevelStatus:), + level_matches(CenteredBarWindowLevel::Status), + &self.handler, + self.mtm, + ); + add_radio( + &level_sub, + "Screensaver (highest)", + sel!(setCenteredBarLevelScreensaver:), + level_matches(CenteredBarWindowLevel::Screensaver), + &self.handler, + self.mtm, + ); + let level_item: Retained = unsafe { + let title_ns = NSString::from_str("Window level"); + let empty_ns = NSString::from_str(""); + let title_ref: &NSString = title_ns.as_ref(); + let empty_ref: &NSString = empty_ns.as_ref(); + msg_send![ + NSMenuItem::alloc(self.mtm), + initWithTitle: title_ref, + action: None::, + keyEquivalent: empty_ref + ] + }; + level_item.setSubmenu(Some(&level_sub)); + new_menu.addItem(&level_item); + + self.menu = new_menu.clone(); + self.status_item.setMenu(Some(&self.menu)); + self.refresh_click_handler(); + } + + fn refresh_click_handler(&self) { + let status_item_clone = self.status_item.clone(); + let mtm = self.mtm; + self.view.set_click_handler(Box::new(move || { + if let Some(btn) = status_item_clone.button(mtm) { + unsafe { btn.performClick(None::<&AnyObject>) }; + } + })); + } pub fn update( &mut self, _active_space: SpaceId, @@ -227,6 +548,7 @@ struct MenuIconViewIvars { layout: RefCell, active_text_attrs: Retained>, inactive_text_attrs: Retained>, + click_handler: RefCell>>, } fn as_any_object(obj: &T) -> &AnyObject { @@ -293,6 +615,7 @@ impl MenuIconView { layout: RefCell::new(MenuIconLayout::default()), active_text_attrs: active_attrs, inactive_text_attrs: inactive_attrs, + click_handler: RefCell::new(None), }); unsafe { msg_send![super(view), initWithFrame: frame] } } @@ -301,6 +624,10 @@ impl MenuIconView { *self.ivars().layout.borrow_mut() = layout; self.setNeedsDisplay(true); } + + fn set_click_handler(&self, handler: Box) { + *self.ivars().click_handler.borrow_mut() = Some(handler); + } } fn build_layout( @@ -562,5 +889,12 @@ define_class!( CGContext::restore_g_state(Some(cg)); } } + + #[unsafe(method(mouseDown:))] + fn mouse_down(&self, _event: &objc2_app_kit::NSEvent) { + if let Some(handler) = self.ivars().click_handler.borrow().as_ref() { + handler(); + } + } } );