From 530737be96f9c6a3d83c213f7c43d6c5c3d466f7 Mon Sep 17 00:00:00 2001 From: Barut Date: Fri, 21 Nov 2025 20:23:00 -0500 Subject: [PATCH] Early experimental stage of menu and workspace bar Very early stage of workspace app icon gui bar and menu settings, currently tied to config file so any changes in menu get written to a config file for persistance --- Cargo.toml | 5 + rift.default.toml | 18 + src/actor.rs | 1 + src/actor/centered_bar.rs | 691 ++++++++++++++++++++++++++++ src/actor/config.rs | 10 + src/actor/menu_bar.rs | 33 +- src/actor/reactor.rs | 10 +- src/actor/reactor/events/command.rs | 10 +- src/actor/reactor/events/space.rs | 1 + src/actor/reactor/managers.rs | 5 +- src/actor/reactor/query.rs | 36 +- src/actor/reactor/testing.rs | 1 + src/actor/wm_controller.rs | 1 + src/bin/rift.rs | 13 +- src/common/config.rs | 42 ++ src/sys/screen.rs | 7 +- src/ui.rs | 1 + src/ui/centered_bar.rs | 2 + src/ui/menu_bar.rs | 350 +++++++++++++- 19 files changed, 1219 insertions(+), 18 deletions(-) create mode 100644 src/actor/centered_bar.rs create mode 100644 src/ui/centered_bar.rs 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(); + } + } } );