diff --git a/rift.default.toml b/rift.default.toml index bad4854b..d63f7e13 100644 --- a/rift.default.toml +++ b/rift.default.toml @@ -146,6 +146,9 @@ fade_enabled = false # native macos mission control fade is about 180ms fade_duration_ms = 180.0 +[settings.ui.command_switcher] +enabled = true + # Trackpad gestures [settings.gestures] # Enable horizontal swipes to switch virtual workspaces @@ -320,6 +323,9 @@ comb1 = "Alt + Shift" "Alt + Tab" = "switch_to_last_workspace" +"Ctrl + Right" = { switcher = "all_windows" } +"Ctrl + Alt + Tab" = { switcher = "current_workspace" } + "Alt + Shift + Left" = { join_window = "left" } "Alt + Shift + Right" = { join_window = "right" } "Alt + Shift + Up" = { join_window = "up" } diff --git a/src/actor.rs b/src/actor.rs index bdd593e7..0a8961a1 100644 --- a/src/actor.rs +++ b/src/actor.rs @@ -4,6 +4,7 @@ use tracing::Span; pub mod app; pub mod broadcast; +pub mod command_switcher; pub mod config; pub mod config_watcher; pub mod drag_swap; diff --git a/src/actor/command_switcher.rs b/src/actor/command_switcher.rs new file mode 100644 index 00000000..b580b1a2 --- /dev/null +++ b/src/actor/command_switcher.rs @@ -0,0 +1,221 @@ +use std::rc::Rc; +use std::time::Duration; + +use r#continue::continuation; +use objc2_app_kit::NSScreen; +use objc2_core_foundation::{CGPoint, CGRect, CGSize}; +use objc2_foundation::MainThreadMarker; +use tracing::{instrument, warn}; + +use crate::actor::{self, reactor}; +use crate::common::config::{CommandSwitcherDisplayMode, CommandSwitcherSettings, Config}; +use crate::model::server::{WindowData, WorkspaceData, WorkspaceQueryResponse}; +use crate::sys::dispatch::block_on; +use crate::sys::screen; +use crate::ui::command_switcher::{ + CommandSwitcherAction, CommandSwitcherMode, CommandSwitcherOverlay, +}; + +#[derive(Debug)] +pub enum Event { + Show(CommandSwitcherDisplayMode), + Dismiss, + UpdateConfig(Config), +} + +pub type Sender = actor::Sender; +pub type Receiver = actor::Receiver; + +pub struct CommandSwitcherActor { + config: Config, + settings: CommandSwitcherSettings, + rx: Receiver, + reactor_tx: reactor::Sender, + overlay: Option, + mtm: MainThreadMarker, + active: bool, + last_mode: Option, +} + +impl CommandSwitcherActor { + pub fn new( + config: Config, + rx: Receiver, + reactor_tx: reactor::Sender, + mtm: MainThreadMarker, + ) -> Self { + let settings = config.settings.ui.command_switcher.clone(); + Self { + config, + settings, + rx, + reactor_tx, + overlay: None, + mtm, + active: false, + last_mode: None, + } + } + + pub async fn run(mut self) { + while let Some((span, event)) = self.rx.recv().await { + let _guard = span.enter(); + self.handle_event(event); + } + } + + #[instrument(skip(self))] + fn handle_event(&mut self, event: Event) { + match event { + Event::UpdateConfig(config) => self.apply_config(config), + Event::Dismiss => self.hide_overlay(), + Event::Show(mode) => { + if self.settings.enabled { + let _ = self.show_contents(mode); + } + } + } + } + + fn apply_config(&mut self, config: Config) { + self.config = config.clone(); + self.settings = config.settings.ui.command_switcher.clone(); + if !self.settings.enabled { + self.hide_overlay(); + self.overlay = None; + } else if self.active { + if let Some(mode) = self.last_mode { + let _ = self.show_contents(mode); + } + } + } + + fn show_contents(&mut self, mode: CommandSwitcherDisplayMode) -> bool { + let Some(payload) = self.fetch_mode_data(mode) else { + self.hide_overlay(); + return false; + }; + let Some(overlay) = self.ensure_overlay() else { + return false; + }; + overlay.update(payload); + self.active = true; + self.last_mode = Some(mode); + true + } + + fn ensure_overlay(&mut self) -> Option<&CommandSwitcherOverlay> { + if self.overlay.is_none() { + let (frame, scale) = if let Some(screen) = NSScreen::mainScreen(self.mtm) { + (screen.frame(), screen.backingScaleFactor()) + } else { + ( + CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(1280.0, 800.0)), + 1.0, + ) + }; + let overlay = CommandSwitcherOverlay::new(self.config.clone(), self.mtm, frame, scale); + let self_ptr: *mut CommandSwitcherActor = self; + overlay.set_action_handler(Rc::new(move |action| unsafe { + let this: &mut CommandSwitcherActor = &mut *self_ptr; + this.handle_overlay_action(action); + })); + self.overlay = Some(overlay); + } + self.overlay.as_ref() + } + + fn fetch_mode_data(&mut self, mode: CommandSwitcherDisplayMode) -> Option { + match mode { + CommandSwitcherDisplayMode::CurrentWorkspace => { + let active_space = screen::get_active_space_number(); + let (tx, fut) = continuation::>(); + let _ = self.reactor_tx.try_send(reactor::Event::QueryWindows { + space_id: active_space, + response: tx, + }); + match block_on(fut, Duration::from_millis(750)) { + Ok(windows) => Some(CommandSwitcherMode::CurrentWorkspace(windows)), + Err(_) => { + warn!("command switcher: windows query timed out"); + None + } + } + } + CommandSwitcherDisplayMode::AllWindows => { + let (tx, fut) = continuation::(); + let _ = self.reactor_tx.try_send(reactor::Event::QueryWorkspaces(tx)); + match block_on(fut, Duration::from_millis(750)) { + Ok(resp) => { + Some(CommandSwitcherMode::AllWindows(flatten_windows(resp.workspaces))) + } + Err(_) => { + warn!("command switcher: workspace query timed out"); + None + } + } + } + CommandSwitcherDisplayMode::Workspaces => { + let (tx, fut) = continuation::(); + let _ = self.reactor_tx.try_send(reactor::Event::QueryWorkspaces(tx)); + match block_on(fut, Duration::from_millis(750)) { + Ok(resp) => Some(CommandSwitcherMode::Workspaces(filter_workspaces( + resp.workspaces, + ))), + Err(_) => { + warn!("command switcher: workspace query timed out"); + None + } + } + } + } + } + + fn handle_overlay_action(&mut self, action: CommandSwitcherAction) { + match action { + CommandSwitcherAction::Dismiss => self.hide_overlay(), + CommandSwitcherAction::SwitchToWorkspace(index) => { + let _ = + self.reactor_tx.try_send(reactor::Event::Command(reactor::Command::Layout( + crate::layout_engine::LayoutCommand::SwitchToWorkspace(index), + ))); + self.hide_overlay(); + } + CommandSwitcherAction::FocusWindow { window_id, window_server_id } => { + let _ = + self.reactor_tx.try_send(reactor::Event::Command(reactor::Command::Reactor( + reactor::ReactorCommand::FocusWindow { window_id, window_server_id }, + ))); + self.hide_overlay(); + } + } + } + + fn hide_overlay(&mut self) { + if let Some(overlay) = self.overlay.as_ref() { + overlay.hide(); + } + self.active = false; + } +} + +fn flatten_windows(workspaces: Vec) -> Vec { + let mut active = Vec::new(); + let mut others = Vec::new(); + for mut workspace in workspaces { + if workspace.is_active { + active.append(&mut workspace.windows); + } else { + others.append(&mut workspace.windows); + } + } + active.extend(others); + active +} + +fn filter_workspaces(workspaces: Vec) -> Vec { + workspaces + .into_iter() + .filter(|ws| ws.is_active || ws.is_last_active || !ws.windows.is_empty()) + .collect() +} diff --git a/src/actor/reactor.rs b/src/actor/reactor.rs index 3b16f7ea..c4b9c6ef 100644 --- a/src/actor/reactor.rs +++ b/src/actor/reactor.rs @@ -46,7 +46,7 @@ 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::common::collections::{BTreeMap, HashMap, HashSet}; -use crate::common::config::Config; +use crate::common::config::{CommandSwitcherDisplayMode, Config}; use crate::common::log::MetricsCommand; use crate::layout_engine::{self as layout, Direction, LayoutCommand, LayoutEngine, LayoutEvent}; use crate::model::VirtualWorkspaceId; @@ -261,6 +261,10 @@ pub enum ReactorCommand { ShowMissionControlAll, ShowMissionControlCurrent, DismissMissionControl, + ShowCommandSwitcher { + mode: CommandSwitcherDisplayMode, + }, + CommandSwitcherDismiss, } #[derive(Default, Debug, Clone)] @@ -788,6 +792,12 @@ impl Reactor { Event::Command(Command::Reactor(ReactorCommand::DismissMissionControl)) => { CommandEventHandler::handle_command_reactor_dismiss_mission_control(self); } + Event::Command(Command::Reactor(ReactorCommand::ShowCommandSwitcher { mode })) => { + CommandEventHandler::handle_command_reactor_show_command_switcher(self, mode); + } + Event::Command(Command::Reactor(ReactorCommand::CommandSwitcherDismiss)) => { + CommandEventHandler::handle_command_reactor_command_switcher_dismiss(self); + } _ => (), } if let Some(raised_window) = raised_window { diff --git a/src/actor/reactor/events/command.rs b/src/actor/reactor/events/command.rs index ddfe86f9..d422a05c 100644 --- a/src/actor/reactor/events/command.rs +++ b/src/actor/reactor/events/command.rs @@ -6,7 +6,7 @@ use crate::actor::reactor::{Reactor, WorkspaceSwitchState}; use crate::actor::stack_line::Event as StackLineEvent; use crate::actor::wm_controller::WmEvent; use crate::common::collections::HashMap; -use crate::common::config::{self as config, Config}; +use crate::common::config::{self as config, CommandSwitcherDisplayMode, Config}; use crate::common::log::{MetricsCommand, handle_command}; use crate::layout_engine::{EventResponse, LayoutCommand, LayoutEvent}; use crate::sys::screen::{SpaceId, order_visible_spaces_by_position}; @@ -221,4 +221,27 @@ impl CommandEventHandler { reactor.set_mission_control_active(false); } } + + pub fn handle_command_reactor_show_command_switcher( + reactor: &mut Reactor, + mode: CommandSwitcherDisplayMode, + ) { + if let Some(wm) = reactor.communication_manager.wm_sender.as_ref() { + let _ = wm.send(crate::actor::wm_controller::WmEvent::Command( + crate::actor::wm_controller::WmCommand::Wm( + crate::actor::wm_controller::WmCmd::Switcher(mode), + ), + )); + } + } + + pub fn handle_command_reactor_command_switcher_dismiss(reactor: &mut Reactor) { + if let Some(wm) = reactor.communication_manager.wm_sender.as_ref() { + let _ = wm.send(crate::actor::wm_controller::WmEvent::Command( + crate::actor::wm_controller::WmCommand::Wm( + crate::actor::wm_controller::WmCmd::CommandSwitcherDismiss, + ), + )); + } + } } diff --git a/src/actor/reactor/query.rs b/src/actor/reactor/query.rs index f4199ed8..466d7b93 100644 --- a/src/actor/reactor/query.rs +++ b/src/actor/reactor/query.rs @@ -89,6 +89,13 @@ impl Reactor { Vec::new() }; + let last_workspace_id = space_id.and_then(|space| { + self.layout_manager + .layout_engine + .virtual_workspace_manager() + .last_workspace(space) + }); + for (index, (workspace_id, workspace_name)) in workspace_list.iter().enumerate() { let is_active = if let Some(space) = space_id { self.layout_manager.layout_engine.active_workspace(space) == Some(*workspace_id) @@ -169,10 +176,13 @@ impl Reactor { } } + let is_last_active = last_workspace_id == Some(*workspace_id); + workspaces.push(WorkspaceData { id: format!("{:?}", workspace_id), name: workspace_name.to_string(), is_active, + is_last_active, window_count: windows.len(), windows, index, diff --git a/src/actor/wm_controller.rs b/src/actor/wm_controller.rs index 3c1f36f4..682c241c 100644 --- a/src/actor/wm_controller.rs +++ b/src/actor/wm_controller.rs @@ -16,7 +16,7 @@ use serde_json; use strum::VariantNames; use tracing::{debug, error, info, instrument}; -use crate::common::config::WorkspaceSelector; +use crate::common::config::{CommandSwitcherDisplayMode, WorkspaceSelector}; use crate::sys::app::{NSRunningApplicationExt, pid_t}; pub type Sender = actor::Sender; @@ -24,7 +24,7 @@ pub type Sender = actor::Sender; type Receiver = actor::Receiver; use crate::actor::app::AppInfo; -use crate::actor::{self, event_tap, mission_control, reactor}; +use crate::actor::{self, command_switcher, event_tap, mission_control, reactor}; use crate::common::collections::{HashMap, HashSet}; use crate::sys::dispatch::DispatchExt; use crate::sys::event::Hotkey; @@ -72,6 +72,8 @@ pub enum WmCmd { ShowMissionControlAll, ShowMissionControlCurrent, + Switcher(CommandSwitcherDisplayMode), + CommandSwitcherDismiss, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -123,6 +125,7 @@ pub struct WmController { event_tap_tx: event_tap::Sender, stack_line_tx: Option, mission_control_tx: Option, + command_switcher_tx: Option, receiver: Receiver, sender: Sender, starting_space: Option, @@ -149,6 +152,7 @@ impl WmController { event_tap_tx: event_tap::Sender, stack_line_tx: crate::actor::stack_line::Sender, mission_control_tx: crate::actor::mission_control::Sender, + command_switcher_tx: command_switcher::Sender, ) -> (Self, actor::Sender) { let (sender, receiver) = actor::channel(); sys::app::set_activation_policy_callback({ @@ -165,6 +169,7 @@ impl WmController { event_tap_tx, stack_line_tx: Some(stack_line_tx), mission_control_tx: Some(mission_control_tx), + command_switcher_tx: Some(command_switcher_tx), receiver, sender: sender.clone(), starting_space: None, @@ -281,6 +286,12 @@ impl WmController { self.config.config = new_cfg; + if let Some(tx) = &self.command_switcher_tx { + let _ = tx.try_send(command_switcher::Event::UpdateConfig( + self.config.config.clone(), + )); + } + if let Some(old_ser) = old_keys_ser { if serde_json::to_string(&self.config.config.keys).ok().as_deref() != Some(&old_ser) @@ -365,12 +376,14 @@ impl WmController { } Command(Wm(NextWorkspace)) => { self.dismiss_mission_control(); + self.dismiss_command_switcher(); self.events_tx.send(reactor::Event::Command(reactor::Command::Layout( layout::LayoutCommand::NextWorkspace(None), ))); } Command(Wm(PrevWorkspace)) => { self.dismiss_mission_control(); + self.dismiss_command_switcher(); self.events_tx.send(reactor::Event::Command(reactor::Command::Layout( layout::LayoutCommand::PrevWorkspace(None), ))); @@ -389,6 +402,7 @@ impl WmController { if let Some(workspace_index) = maybe_index { self.dismiss_mission_control(); + self.dismiss_command_switcher(); self.events_tx.send(reactor::Event::Command(reactor::Command::Layout( layout::LayoutCommand::SwitchToWorkspace(workspace_index), ))); @@ -412,6 +426,7 @@ impl WmController { }; if let Some(workspace_index) = maybe_index { + self.dismiss_command_switcher(); self.events_tx.send(reactor::Event::Command(reactor::Command::Layout( layout::LayoutCommand::MoveWindowToWorkspace { workspace: workspace_index, @@ -432,20 +447,33 @@ impl WmController { } Command(Wm(SwitchToLastWorkspace)) => { self.dismiss_mission_control(); + self.dismiss_command_switcher(); self.events_tx.send(reactor::Event::Command(reactor::Command::Layout( layout::LayoutCommand::SwitchToLastWorkspace, ))); } Command(Wm(ShowMissionControlAll)) => { if let Some(tx) = &self.mission_control_tx { + self.dismiss_command_switcher(); let _ = tx.try_send(mission_control::Event::ShowAll); } } Command(Wm(ShowMissionControlCurrent)) => { if let Some(tx) = &self.mission_control_tx { + self.dismiss_command_switcher(); let _ = tx.try_send(mission_control::Event::ShowCurrent); } } + Command(Wm(Switcher(mode))) => { + if let Some(tx) = &self.command_switcher_tx { + let _ = tx.try_send(command_switcher::Event::Show(mode)); + } + } + Command(Wm(CommandSwitcherDismiss)) => { + if let Some(tx) = &self.command_switcher_tx { + let _ = tx.try_send(command_switcher::Event::Dismiss); + } + } Command(Wm(Exec(cmd))) => { self.exec_cmd(cmd); } @@ -461,6 +489,12 @@ impl WmController { } } + fn dismiss_command_switcher(&self) { + if let Some(tx) = &self.command_switcher_tx { + let _ = tx.try_send(command_switcher::Event::Dismiss); + } + } + fn new_app(&mut self, pid: pid_t, info: AppInfo) { if info.bundle_id.as_deref() == Some("com.apple.loginwindow") { self.login_window_pid = Some(pid); diff --git a/src/bin/rift-cli.rs b/src/bin/rift-cli.rs index 1e55a552..b267e09f 100644 --- a/src/bin/rift-cli.rs +++ b/src/bin/rift-cli.rs @@ -2,6 +2,7 @@ use std::process::{self}; use clap::{Parser, Subcommand}; use rift_wm::actor::reactor; +use rift_wm::common::config::CommandSwitcherDisplayMode; use rift_wm::ipc::{RiftCommand, RiftMachClient, RiftRequest, RiftResponse}; use rift_wm::layout_engine as layout; use rift_wm::model::server::{ApplicationData, LayoutStateData, WindowData, WorkspaceData}; @@ -104,6 +105,11 @@ enum ExecuteCommands { #[command(subcommand)] mission_cmd: MissionControlCommands, }, + /// Command switcher commands + CommandSwitcher { + #[command(subcommand)] + switcher_cmd: CommandSwitcherCommands, + }, /// Save current state and exit rift SaveAndExit, /// Show timing metrics @@ -260,6 +266,17 @@ enum MissionControlCommands { Dismiss, } +#[derive(Subcommand)] +enum CommandSwitcherCommands { + /// Show the command switcher using a specific mode + Show { + #[arg(value_name = "mode")] + switcher: String, + }, + /// Dismiss the command switcher + Dismiss, +} + #[derive(Subcommand)] enum SubscribeCommands { /// Subscribe to Mach IPC events @@ -394,6 +411,9 @@ fn build_execute_request(execute: ExecuteCommands) -> Result { map_mission_control_command(mission_cmd)? } + ExecuteCommands::CommandSwitcher { switcher_cmd } => { + map_command_switcher_command(switcher_cmd)? + } ExecuteCommands::SaveAndExit => { RiftCommand::Reactor(reactor::Command::Reactor(reactor::ReactorCommand::SaveAndExit)) } @@ -610,6 +630,35 @@ fn map_mission_control_command(cmd: MissionControlCommands) -> Result Result { + use reactor::ReactorCommand; + match cmd { + CommandSwitcherCommands::Show { switcher } => { + let mode = parse_switcher_mode(&switcher)?; + Ok(RiftCommand::Reactor(reactor::Command::Reactor( + ReactorCommand::ShowCommandSwitcher { mode }, + ))) + } + CommandSwitcherCommands::Dismiss => Ok(RiftCommand::Reactor(reactor::Command::Reactor( + ReactorCommand::CommandSwitcherDismiss, + ))), + } +} + +fn parse_switcher_mode(value: &str) -> Result { + match value.to_lowercase().as_str() { + "current_workspace" | "current" | "workspace" | "workspaces_current" => { + Ok(CommandSwitcherDisplayMode::CurrentWorkspace) + } + "all_windows" | "all" | "windows" => Ok(CommandSwitcherDisplayMode::AllWindows), + "workspaces" | "spaces" => Ok(CommandSwitcherDisplayMode::Workspaces), + other => Err(format!( + "Unknown switcher mode `{}`. Expected one of: current_workspace, all_windows, workspaces", + other + )), + } +} + fn handle_success_response(request: &RiftRequest, data: serde_json::Value) -> Result<(), String> { match request { RiftRequest::GetWorkspaces { .. } => { diff --git a/src/bin/rift.rs b/src/bin/rift.rs index a96991a9..53cb7e53 100644 --- a/src/bin/rift.rs +++ b/src/bin/rift.rs @@ -4,6 +4,7 @@ use std::process; use clap::{Parser, Subcommand}; use objc2::MainThreadMarker; use objc2_application_services::AXUIElement; +use rift_wm::actor::command_switcher::CommandSwitcherActor; use rift_wm::actor::config::ConfigActor; use rift_wm::actor::config_watcher::ConfigWatcher; use rift_wm::actor::event_tap::EventTap; @@ -213,12 +214,14 @@ Enable it in System Settings > Desktop & Dock (Mission Control) and restart Rift }; let (mc_tx, mc_rx) = rift_wm::actor::channel(); let (_mc_native_tx, mc_native_rx) = rift_wm::actor::channel(); + let (cs_tx, cs_rx) = rift_wm::actor::channel(); let (wm_controller, wm_controller_sender) = WmController::new( wm_config, events_tx.clone(), event_tap_tx.clone(), stack_line_tx.clone(), mc_tx.clone(), + cs_tx.clone(), ); let _ = events_tx.send(reactor::Event::RegisterWmSender(wm_controller_sender.clone())); @@ -244,6 +247,7 @@ Enable it in System Settings > Desktop & Dock (Mission Control) and restart Rift 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); + let command_switcher = CommandSwitcherActor::new(config.clone(), cs_rx, events_tx.clone(), mtm); println!( "NOTICE: by default rift starts in a deactivated state. @@ -263,6 +267,7 @@ Enable it in System Settings > Desktop & Dock (Mission Control) and restart Rift wn_actor.run(), mission_control_native.run(), mission_control.run(), + command_switcher.run(), process_actor.run() ); }); diff --git a/src/common/config.rs b/src/common/config.rs index fee7b843..907d8bba 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -362,6 +362,8 @@ pub struct UiSettings { pub stack_line: StackLineSettings, #[serde(default)] pub mission_control: MissionControlSettings, + #[serde(default)] + pub command_switcher: CommandSwitcherSettings, } #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] @@ -452,6 +454,36 @@ pub struct MissionControlSettings { pub fade_duration_ms: f64, } +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[serde(deny_unknown_fields)] +pub struct CommandSwitcherSettings { + #[serde(default = "no")] + pub enabled: bool, + #[serde(default)] + pub default_mode: Option, +} + +impl Default for CommandSwitcherSettings { + fn default() -> Self { + Self { + enabled: false, + default_mode: None, + } + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CommandSwitcherDisplayMode { + CurrentWorkspace, + AllWindows, + Workspaces, +} + +impl Default for CommandSwitcherDisplayMode { + fn default() -> Self { CommandSwitcherDisplayMode::CurrentWorkspace } +} + fn default_mission_control_fade_duration_ms() -> f64 { 180.0 } fn default_drag_swap_fraction() -> f64 { 0.3 } diff --git a/src/model/server.rs b/src/model/server.rs index eb769449..51abb3f8 100644 --- a/src/model/server.rs +++ b/src/model/server.rs @@ -11,6 +11,8 @@ pub struct WorkspaceData { pub index: usize, pub name: String, pub is_active: bool, + #[serde(default)] + pub is_last_active: bool, pub window_count: usize, pub windows: Vec, } diff --git a/src/sys/window_server.rs b/src/sys/window_server.rs index 97b7e297..153e2b14 100644 --- a/src/sys/window_server.rs +++ b/src/sys/window_server.rs @@ -780,37 +780,35 @@ pub unsafe fn switch_space(direction: Direction) { queue::main().after_f_s( Time::new_after(Time::NOW, 15 * 1000000), (magnitude, magnitude_bits), - |(magnitude, magnitude_bits)| { - unsafe { - let gesture = 200.0 * magnitude; - - let event2a = CGEventCreate(std::ptr::null_mut()); - CGEventSetIntegerValueField(event2a, 0x37, 29); - CGEventSetIntegerValueField(event2a, 0x29, 33231); - - let event2b = CGEventCreate(std::ptr::null_mut()); - CGEventSetIntegerValueField(event2b, 0x37, 30); - CGEventSetIntegerValueField(event2b, 0x6E, 23); - CGEventSetIntegerValueField(event2b, 0x84, 4); - CGEventSetIntegerValueField(event2b, 0x86, 4); - CGEventSetDoubleValueField(event2b, 0x7C, magnitude); - CGEventSetIntegerValueField(event2b, 0x87, magnitude_bits); - CGEventSetIntegerValueField(event2b, 0x7B, 1); - CGEventSetIntegerValueField(event2b, 0xA5, 1); - CGEventSetDoubleValueField(event2b, 0x77, 1.401298464324817e-45); - CGEventSetDoubleValueField(event2b, 0x8B, 1.401298464324817e-45); - CGEventSetIntegerValueField(event2b, 0x29, 33231); - CGEventSetIntegerValueField(event2b, 0x88, 0); - - CGEventSetDoubleValueField(event2b, 0x81, gesture); - CGEventSetDoubleValueField(event2b, 0x82, gesture); - - CGEventPost(CGEventTapLocation::HID, event2b); - CGEventPost(CGEventTapLocation::HID, event2a); - - CFRelease(event2a); - CFRelease(event2b); - }; + |(magnitude, magnitude_bits)| unsafe { + let gesture = 200.0 * magnitude; + + let event2a = CGEventCreate(std::ptr::null_mut()); + CGEventSetIntegerValueField(event2a, 0x37, 29); + CGEventSetIntegerValueField(event2a, 0x29, 33231); + + let event2b = CGEventCreate(std::ptr::null_mut()); + CGEventSetIntegerValueField(event2b, 0x37, 30); + CGEventSetIntegerValueField(event2b, 0x6E, 23); + CGEventSetIntegerValueField(event2b, 0x84, 4); + CGEventSetIntegerValueField(event2b, 0x86, 4); + CGEventSetDoubleValueField(event2b, 0x7C, magnitude); + CGEventSetIntegerValueField(event2b, 0x87, magnitude_bits); + CGEventSetIntegerValueField(event2b, 0x7B, 1); + CGEventSetIntegerValueField(event2b, 0xA5, 1); + CGEventSetDoubleValueField(event2b, 0x77, 1.401298464324817e-45); + CGEventSetDoubleValueField(event2b, 0x8B, 1.401298464324817e-45); + CGEventSetIntegerValueField(event2b, 0x29, 33231); + CGEventSetIntegerValueField(event2b, 0x88, 0); + + CGEventSetDoubleValueField(event2b, 0x81, gesture); + CGEventSetDoubleValueField(event2b, 0x82, gesture); + + CGEventPost(CGEventTapLocation::HID, event2b); + CGEventPost(CGEventTapLocation::HID, event2a); + + CFRelease(event2a); + CFRelease(event2b); }, ); } diff --git a/src/ui.rs b/src/ui.rs index 7a686255..cf9cb340 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,3 +1,5 @@ +pub mod command_switcher; pub mod menu_bar; pub mod mission_control; +pub mod overlay_common; pub mod stack_line; diff --git a/src/ui/command_switcher.rs b/src/ui/command_switcher.rs new file mode 100644 index 00000000..cd78b97f --- /dev/null +++ b/src/ui/command_switcher.rs @@ -0,0 +1,1777 @@ +use core::ffi::c_void; +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use dispatchr::queue; +use dispatchr::time::Time; +use objc2::msg_send; +use objc2::rc::{Retained, autoreleasepool}; +use objc2::runtime::AnyObject; +use objc2_app_kit::{NSApplication, NSColor, NSPopUpMenuWindowLevel}; +use objc2_core_foundation::{CFString, CFType, CGPoint, CGRect, CGSize}; +use objc2_core_graphics::{ + CGColor, CGContext, CGEvent, CGEventField, CGEventTapOptions, CGEventTapProxy, CGEventType, +}; +use objc2_foundation::MainThreadMarker; +use objc2_quartz_core::{CALayer, CATextLayer, CATransaction}; +use once_cell::sync::Lazy; +use parking_lot::RwLock; + +use crate::actor::app::WindowId; +use crate::common::collections::{HashMap, HashSet}; +use crate::common::config::Config; +use crate::model::server::{WindowData, WorkspaceData}; +use crate::sys::cgs_window::CgsWindow; +use crate::sys::dispatch::DispatchExt; +use crate::sys::skylight::{ + CFRelease, G_CONNECTION, SLSFlushWindowContentRegion, SLWindowContextCreate, +}; +use crate::sys::window_server::{CapturedWindowImage, WindowServerId}; +use crate::ui::overlay_common::{ + CachedText, CaptureJob, CaptureManager, CaptureTask, EnqueueResult, ItemLayerStyle, RefreshCtx, +}; + +unsafe extern "C" { + fn CGContextFlush(ctx: *mut CGContext); + fn CGContextClearRect(ctx: *mut CGContext, rect: CGRect); + fn CGContextSaveGState(ctx: *mut CGContext); + fn CGContextRestoreGState(ctx: *mut CGContext); + fn CGContextTranslateCTM(ctx: *mut CGContext, tx: f64, ty: f64); + fn CGContextScaleCTM(ctx: *mut CGContext, sx: f64, sy: f64); +} + +static OVERLAY_BACKGROUND_COLOR: Lazy> = + Lazy::new(|| CGColor::new_generic_gray(0.0, 0.25).into()); +static SELECTED_BORDER_COLOR: Lazy> = + Lazy::new(|| CGColor::new_generic_rgb(0.2, 0.45, 1.0, 0.85).into()); +static WORKSPACE_BORDER_COLOR: Lazy> = + Lazy::new(|| CGColor::new_generic_gray(1.0, 0.12).into()); +static WINDOW_BORDER_COLOR: Lazy> = + Lazy::new(|| CGColor::new_generic_gray(0.0, 0.65).into()); + +// Switcher-specific aliases (kept for clarity) +static ITEM_BG_COLOR: Lazy> = + Lazy::new(|| CGColor::new_generic_gray(1.0, 0.03).into()); +static ITEM_LABEL_COLOR: Lazy> = Lazy::new(|| NSColor::labelColor().CGColor()); +const BASE_ITEM_WIDTH: f64 = 240.0; +const BASE_ITEM_HEIGHT: f64 = 170.0; +const ITEM_SPACING: f64 = 28.0; +const CONTAINER_PADDING: f64 = 32.0; +const LABEL_HEIGHT: f64 = 20.0; +const MAX_CONTAINER_WIDTH_RATIO: f64 = 0.82; +const MAX_CONTAINER_HEIGHT_RATIO: f64 = 0.88; +const WINDOW_TILE_INSET: f64 = 5.0; +const WINDOW_TILE_GAP: f64 = 1.0; +const WINDOW_TILE_MIN_SIZE: f64 = 2.0; +const WINDOW_TILE_SCALE_FACTOR: f64 = 1.0; // 0.75; +const WINDOW_TILE_MAX_SCALE: f64 = 1.0; +const PREVIEW_MAX_EDGE: f64 = 420.0; +const PREVIEW_MIN_EDGE: f64 = 96.0; + +const SYNC_PREWARM_LIMIT: usize = 3; +static CAPTURE_MANAGER: Lazy = Lazy::new(CaptureManager::default); + +unsafe fn command_switcher_refresh(bits: usize) { + if bits == 0 { + return; + } + let overlay = unsafe { &*(bits as *const CommandSwitcherOverlay) }; + overlay.request_refresh(); +} + +#[derive(Clone)] +enum SwitcherItemKind { + Window(WindowData), + Workspace(WorkspaceData), +} + +#[derive(Clone)] +struct SwitcherItem { + key: ItemKey, + label: String, + kind: SwitcherItemKind, + is_primary: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum ItemKey { + Window(WindowId), + Workspace(String), +} + +type PreviewLayerKey = (ItemKey, Option); + +struct PreviewLayerEntry { + layer: Retained, + window_id: Option, +} + +impl PreviewLayerEntry { + fn new(layer: Retained, window_id: Option) -> Self { + Self { layer, window_id } + } + + fn layer(&self) -> &Retained { &self.layer } + + fn window_id(&self) -> Option<&WindowId> { self.window_id.as_ref() } + + fn set_window_id(&mut self, window_id: Option) { self.window_id = window_id; } +} + +#[derive(Debug, Clone)] +pub enum CommandSwitcherMode { + CurrentWorkspace(Vec), + AllWindows(Vec), + Workspaces(Vec), +} + +#[derive(Debug, Clone)] +pub enum CommandSwitcherAction { + FocusWindow { + window_id: WindowId, + window_server_id: Option, + }, + SwitchToWorkspace(usize), + Dismiss, +} + +struct CommandSwitcherState { + mode: Option, + items: Vec, + selection: Option, + on_action: Option>, + preview_cache: Arc>>, + preview_layers: HashMap, + label_layers: HashMap>, + + label_strings: HashMap, + item_layers: HashMap>, + + item_styles: HashMap, + ready_previews: HashSet, + item_frames: Vec<(ItemKey, CGRect)>, + grid_columns: usize, + grid_rows: usize, +} + +impl Default for CommandSwitcherState { + fn default() -> Self { + Self { + mode: None, + items: Vec::new(), + selection: None, + on_action: None, + preview_cache: Arc::new(RwLock::new(HashMap::default())), + preview_layers: HashMap::default(), + label_layers: HashMap::default(), + label_strings: HashMap::default(), + item_layers: HashMap::default(), + item_styles: HashMap::default(), + ready_previews: HashSet::default(), + item_frames: Vec::new(), + grid_columns: 0, + grid_rows: 0, + } + } +} + +pub struct CommandSwitcherOverlay { + cgs_window: CgsWindow, + root_layer: Retained, + container_layer: Retained, + frame: CGRect, + scale: f64, + mtm: MainThreadMarker, + state: RefCell, + key_tap: RefCell>, + refresh_pending: AtomicBool, + has_shown: RefCell, + fade_enabled: bool, + fade_duration_ms: f64, +} + +impl CommandSwitcherState { + fn set_mode(&mut self, mode: CommandSwitcherMode) { + self.mode = Some(mode.clone()); + self.items.clear(); + self.selection = None; + self.item_frames.clear(); + self.ready_previews.clear(); + CAPTURE_MANAGER.bump_generation(); + let mut preselection: Option = None; + + self.item_layers.retain(|_, layer| { + layer.removeFromSuperlayer(); + false + }); + self.label_layers.retain(|_, layer| { + layer.removeFromSuperlayer(); + false + }); + self.label_strings.clear(); + self.preview_layers.retain(|_, entry| { + entry.layer().removeFromSuperlayer(); + false + }); + self.item_styles.clear(); + self.grid_columns = 0; + self.grid_rows = 0; + + match mode { + CommandSwitcherMode::CurrentWorkspace(windows) + | CommandSwitcherMode::AllWindows(windows) => { + for window in windows { + let key = ItemKey::Window(window.id); + let label = format_window_label(&window); + let is_primary = window.is_focused; + self.items.push(SwitcherItem { + key, + label, + kind: SwitcherItemKind::Window(window), + is_primary, + }); + } + } + CommandSwitcherMode::Workspaces(workspaces) => { + for workspace in workspaces { + let idx = self.items.len(); + let key = ItemKey::Workspace(workspace.id.clone()); + let label = format_workspace_label(&workspace); + let is_primary = workspace.is_active; + let is_last_active = workspace.is_last_active; + self.items.push(SwitcherItem { + key, + label, + kind: SwitcherItemKind::Workspace(workspace), + is_primary, + }); + if is_last_active && !is_primary { + preselection = Some(idx); + } + } + } + } + self.selection = preselection; + self.prune_preview_cache(); + self.ensure_selection(); + } + + fn purge(&mut self) { + CAPTURE_MANAGER.bump_generation(); + + self.mode = None; + self.items.clear(); + self.selection = None; + self.item_frames.clear(); + self.item_styles.clear(); + self.ready_previews.clear(); + self.grid_columns = 0; + self.grid_rows = 0; + + { + let mut cache = self.preview_cache.write(); + cache.clear(); + } + + for layer in self.item_layers.values() { + layer.removeFromSuperlayer(); + } + self.item_layers.clear(); + + for layer in self.label_layers.values() { + layer.removeFromSuperlayer(); + } + self.label_layers.clear(); + self.label_strings.clear(); + + for entry in self.preview_layers.values() { + entry.layer().removeFromSuperlayer(); + } + self.preview_layers.clear(); + } + + fn ensure_selection(&mut self) { + if let Some(idx) = self.selection { + if idx < self.items.len() { + return; + } + } + + let count = self.items.len(); + if count == 0 { + self.selection = None; + return; + } + + let desired = self + .items + .iter() + .enumerate() + .find_map(|(idx, item)| item.is_primary.then_some(idx)) + .and_then(|primary_idx| { + if count == 1 { + return Some(primary_idx); + } + let next_idx = primary_idx + 1; + if next_idx < count { + Some(next_idx) + } else if primary_idx > 0 { + Some(0) + } else { + Some(primary_idx) + } + }) + .or(Some(0)); + + self.selection = desired; + } + + fn prune_preview_cache(&mut self) { + let mut cache = self.preview_cache.write(); + if cache.is_empty() && self.preview_layers.is_empty() && self.ready_previews.is_empty() { + return; + } + + let mut valid: HashSet = HashSet::default(); + for item in &self.items { + match &item.kind { + SwitcherItemKind::Window(window) => { + valid.insert(window.id); + } + SwitcherItemKind::Workspace(workspace) => { + for window in &workspace.windows { + valid.insert(window.id); + } + } + } + } + + cache.retain(|wid, _| valid.contains(wid)); + + let mut to_remove: Vec = Vec::new(); + for (key, entry) in self.preview_layers.iter() { + if let Some(wid) = entry.window_id() { + if !valid.contains(wid) { + entry.layer().removeFromSuperlayer(); + to_remove.push(key.clone()); + } + } + } + for key in to_remove { + self.preview_layers.remove(&key); + } + + self.ready_previews.retain(|wid| valid.contains(wid)); + } + + fn selection(&self) -> Option { self.selection } + + fn set_selection(&mut self, idx: usize) { + if idx < self.items.len() { + self.selection = Some(idx); + } + } + + fn selected_item(&self) -> Option<&SwitcherItem> { + self.selection.and_then(|idx| self.items.get(idx)) + } +} + +fn format_window_label(window: &WindowData) -> String { + let mut title = window.title.trim().to_string(); + if title.is_empty() { + if let Some(bundle) = &window.bundle_id { + title = bundle.clone(); + } else { + title = "Untitled Window".into(); + } + } + title +} + +fn format_workspace_label(workspace: &WorkspaceData) -> String { + let label = workspace.name.trim(); + if label.is_empty() { + format!("Workspace {}", workspace.index + 1) + } else { + label.to_string() + } +} + +extern "C" fn refresh_coalesced_cb(ctx: *mut c_void) { + if ctx.is_null() { + return; + } + let overlay = unsafe { &*(ctx as *const CommandSwitcherOverlay) }; + overlay.refresh_pending.store(false, Ordering::Release); + overlay.refresh_from_capture(); +} + +impl CommandSwitcherOverlay { + pub fn new(_config: Config, mtm: MainThreadMarker, frame: CGRect, scale: f64) -> Self { + let root_layer = CALayer::layer(); + root_layer.setGeometryFlipped(true); + root_layer.setFrame(CGRect::new(CGPoint::new(0.0, 0.0), frame.size)); + root_layer.setContentsScale(scale); + root_layer.setBackgroundColor(None); + root_layer.setMasksToBounds(false); + + let container = CALayer::layer(); + container.setGeometryFlipped(true); + + container.setCornerRadius(10.0); + container.setMasksToBounds(false); + container.setBackgroundColor(Some(&**OVERLAY_BACKGROUND_COLOR)); + container.setBorderWidth(1.2); + container.setBorderColor(Some(&**WINDOW_BORDER_COLOR)); + root_layer.addSublayer(&container); + + let cgs_window = CgsWindow::new(frame).expect("failed to create CGS window"); + let _ = cgs_window.set_resolution(scale); + let _ = cgs_window.set_opacity(false); + let _ = cgs_window.set_alpha(0.0); + let _ = cgs_window.set_level(NSPopUpMenuWindowLevel as i32); + let _ = cgs_window.set_blur(30, None); + + Self { + cgs_window, + root_layer, + container_layer: container, + frame, + scale, + mtm, + state: RefCell::new(CommandSwitcherState::default()), + key_tap: RefCell::new(None), + refresh_pending: AtomicBool::new(false), + has_shown: RefCell::new(false), + // Simple fade-in to appear smoothly + fade_enabled: true, + fade_duration_ms: 160.0, + } + } + + fn request_refresh(&self) { + if !self.refresh_pending.swap(true, Ordering::AcqRel) { + let ptr = self as *const _ as usize; + queue::main().after_f( + Time::new_after(Time::NOW, 6000000), + ptr as *mut c_void, + refresh_coalesced_cb, + ); + } + } + + pub fn set_action_handler(&self, f: Rc) { + self.state.borrow_mut().on_action = Some(f); + } + + pub fn update(&self, mode: CommandSwitcherMode) { + { + let (new_frame, new_scale) = + if let Some(screen) = objc2_app_kit::NSScreen::mainScreen(self.mtm) { + (screen.frame(), screen.backingScaleFactor()) + } else { + (self.frame, self.scale) + }; + + let frame_changed = new_frame.origin.x != self.frame.origin.x + || new_frame.origin.y != self.frame.origin.y + || new_frame.size.width != self.frame.size.width + || new_frame.size.height != self.frame.size.height; + let scale_changed = (new_scale - self.scale).abs() > f64::EPSILON; + + if frame_changed || scale_changed { + let _ = self.cgs_window.set_shape(new_frame); + let _ = self.cgs_window.set_resolution(new_scale); + + unsafe { + let me = self as *const _ as *mut CommandSwitcherOverlay; + (*me).frame = new_frame; + (*me).scale = new_scale; + } + + self.root_layer.setFrame(CGRect::new(CGPoint::new(0.0, 0.0), self.frame.size)); + self.root_layer.setContentsScale(self.scale); + } + } + + { + let mut state = self.state.borrow_mut(); + state.set_mode(mode); + } + self.prewarm_previews(); + // Start transparent if we're about to fade in + if self.fade_enabled && !*self.has_shown.borrow() { + let _ = self.cgs_window.set_alpha(0.0); + } + let _ = self.cgs_window.set_alpha(1.0); + let _ = self.cgs_window.order_above(None); + let app = NSApplication::sharedApplication(self.mtm); + let _ = app.activate(); + self.ensure_key_tap(); + self.draw_and_present(); + + if self.fade_enabled && !*self.has_shown.borrow() { + self.fade_in(); + } + *self.has_shown.borrow_mut() = true; + } + + pub fn hide(&self) { + { + let mut state = self.state.borrow_mut(); + state.purge(); + } + + self.refresh_pending.store(false, Ordering::Release); + + let was_shown = { + let mut shown = self.has_shown.borrow_mut(); + let prev = *shown; + *shown = false; + prev + }; + + if let Some(tap) = self.key_tap.borrow_mut().take() { + drop(tap); + } + + if was_shown { + let _ = self.cgs_window.set_alpha(0.0); + let _ = self.cgs_window.order_out(); + } + } + + pub fn select_next(&self) { + if self.adjust_selection(1) { + self.present_root_layer(); + } + } + + pub fn select_prev(&self) { + if self.adjust_selection(-1) { + self.present_root_layer(); + } + } + + pub fn activate_selection(&self) { + let action = { + let state = self.state.borrow(); + match state.selected_item() { + Some(item) => match &item.kind { + SwitcherItemKind::Window(window) => { + let wsid = window.window_server_id.map(WindowServerId::new); + CommandSwitcherAction::FocusWindow { + window_id: window.id, + window_server_id: wsid, + } + } + SwitcherItemKind::Workspace(workspace) => { + CommandSwitcherAction::SwitchToWorkspace(workspace.index) + } + }, + None => CommandSwitcherAction::Dismiss, + } + }; + self.emit_action(action); + } + + pub fn dismiss(&self) { self.emit_action(CommandSwitcherAction::Dismiss); } + + fn adjust_selection(&self, delta: isize) -> bool { + let (len, current) = { + let state = match self.state.try_borrow() { + Ok(s) => s, + Err(_) => return false, + }; + if state.items.is_empty() { + return false; + } + (state.items.len(), state.selection().unwrap_or(0)) + }; + + let len_isize = len as isize; + if len_isize == 0 { + return false; + } + + let mut idx = (current as isize + delta) % len_isize; + if idx < 0 { + idx += len_isize; + } + + self.set_selection_index(idx as usize) + } + + fn adjust_selection_vertical(&self, delta_rows: isize) -> bool { + let (len, current, columns, rows) = { + let state = match self.state.try_borrow() { + Ok(s) => s, + Err(_) => return false, + }; + if state.items.is_empty() || state.grid_columns == 0 { + return false; + } + ( + state.items.len(), + state.selection().unwrap_or(0), + state.grid_columns, + state.grid_rows.max(1), + ) + }; + + if columns == 0 { + return false; + } + + let current_row = current / columns; + let current_col = current % columns; + let target_row = current_row as isize + delta_rows; + if target_row < 0 || target_row as usize >= rows { + return false; + } + + let target_row_usize = target_row as usize; + let row_start = target_row_usize * columns; + if row_start >= len { + return false; + } + let row_end = ((target_row_usize + 1) * columns).min(len); + let target_idx = (row_start + current_col).min(row_end.saturating_sub(1)); + + self.set_selection_index(target_idx) + } + + fn set_selection_index(&self, new_idx: usize) -> bool { + let (old_key, new_key) = { + let mut state = match self.state.try_borrow_mut() { + Ok(s) => s, + Err(_) => return false, + }; + if state.items.is_empty() || new_idx >= state.items.len() { + return false; + } + let previous = state.selection(); + if previous == Some(new_idx) { + return false; + } + let new_key = state.items[new_idx].key.clone(); + let old_key = previous.and_then(|idx| state.items.get(idx).map(|it| it.key.clone())); + state.set_selection(new_idx); + (old_key, new_key) + }; + + if let Some(ok) = old_key { + self.update_item_selected_style(&ok, false); + } + self.update_item_selected_style(&new_key, true); + true + } + + fn emit_action(&self, action: CommandSwitcherAction) { + let handler = self.state.borrow().on_action.clone(); + let Some(cb) = handler else { + return; + }; + + type Ctx = (Rc, CommandSwitcherAction); + + extern "C" fn action_callback(ctx: *mut c_void) { + if ctx.is_null() { + return; + } + unsafe { + let boxed = Box::from_raw(ctx as *mut Ctx); + let (cb, action) = *boxed; + cb(action); + } + } + + let ctx: Box = Box::new((cb, action)); + queue::main().after_f(Time::NOW, Box::into_raw(ctx) as *mut c_void, action_callback); + } + + fn refresh_from_capture(&self) { + if !*self.has_shown.borrow() { + return; + } + self.refresh_previews(); + } + + fn draw_and_present(&self) { + CATransaction::begin(); + CATransaction::setDisableActions(true); + self.root_layer.setFrame(CGRect::new(CGPoint::new(0.0, 0.0), self.frame.size)); + self.root_layer.setContentsScale(self.scale); + self.root_layer.setGeometryFlipped(true); + + self.draw_items(); + + CATransaction::commit(); + + self.present_root_layer(); + } + + fn present_root_layer(&self) { + let ctx: *mut CGContext = unsafe { + SLWindowContextCreate( + *G_CONNECTION, + self.cgs_window.id(), + core::ptr::null_mut() as *mut CFType, + ) + }; + if !ctx.is_null() { + unsafe { + let clear = CGRect::new(CGPoint::new(0.0, 0.0), self.frame.size); + CGContextClearRect(ctx, clear); + CGContextSaveGState(ctx); + CGContextTranslateCTM(ctx, 0.0, self.frame.size.height); + CGContextScaleCTM(ctx, 1.0, -1.0); + self.root_layer.renderInContext(&*ctx); + CGContextRestoreGState(ctx); + CGContextFlush(ctx); + SLSFlushWindowContentRegion( + *G_CONNECTION, + self.cgs_window.id(), + std::ptr::null_mut(), + ); + CFRelease(ctx as *mut CFType); + } + } + } + + fn fade_in(&self) { + let duration_ms = self.fade_duration_ms.max(0.0); + if duration_ms <= 0.0 { + return; + } + CATransaction::begin(); + CATransaction::setAnimationDuration(duration_ms / 1000.0); + self.root_layer.setOpacity(0.0); + self.root_layer.setOpacity(1.0); + CATransaction::commit(); + } + + fn refresh_previews(&self) { + if !*self.has_shown.borrow() { + return; + } + + let (layers, cache_arc) = { + let state = match self.state.try_borrow() { + Ok(s) => s, + Err(_) => return, + }; + let pairs: Vec<(WindowId, Retained)> = state + .preview_layers + .iter() + .filter_map(|(_, entry)| { + entry.window_id().copied().map(|wid| (wid, entry.layer().clone())) + }) + .collect(); + (pairs, state.preview_cache.clone()) + }; + + if layers.is_empty() { + return; + } + + let mut ready_ids: Vec = Vec::with_capacity(layers.len()); + + CATransaction::begin(); + CATransaction::setDisableActions(true); + { + let cache = cache_arc.read(); + for (wid, layer) in layers.iter() { + if let Some(img) = cache.get(wid) { + unsafe { + let img_ptr = img.as_ptr() as *mut AnyObject; + let _: () = msg_send![&**layer, setContents: img_ptr]; + } + ready_ids.push(*wid); + } + } + } + CATransaction::commit(); + + if ready_ids.is_empty() { + return; + } + + if let Ok(mut state) = self.state.try_borrow_mut() { + for wid in ready_ids.iter().copied() { + state.ready_previews.insert(wid); + } + } + + self.present_root_layer(); + } + + fn prewarm_previews(&self) { + let mut tasks: Vec<(u8, i64, CaptureTask)> = { + let state = self.state.borrow(); + let mut pending = Vec::with_capacity(state.items.len().saturating_mul(2)); + for item in &state.items { + match &item.kind { + SwitcherItemKind::Window(window) => { + if let Some(wsid) = window.window_server_id { + let priority: u8 = if item.is_primary || window.is_focused { + 0 + } else { + 1 + }; + let area = (window.frame.size.width * window.frame.size.height) as i64; + let (target_w, target_h) = capture_target_for_window(window); + pending.push((priority, area, CaptureTask { + window_id: window.id, + window_server_id: wsid, + target_w, + target_h, + })); + } + } + SwitcherItemKind::Workspace(workspace) => { + let base_priority: u8 = if item.is_primary { 0 } else { 1 }; + for window in &workspace.windows { + if let Some(wsid) = window.window_server_id { + let focus_bonus: u8 = if window.is_focused { 0 } else { 1 }; + let priority = base_priority.saturating_add(focus_bonus); + let area = + (window.frame.size.width * window.frame.size.height) as i64; + let (target_w, target_h) = capture_target_for_window(window); + pending.push((priority, area, CaptureTask { + window_id: window.id, + window_server_id: wsid, + target_w, + target_h, + })); + } + } + } + } + } + pending + }; + + if tasks.is_empty() { + return; + } + + // Prioritize lower priority value first, then larger area first + tasks.sort_unstable_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1))); + + let generation = CAPTURE_MANAGER.bump_generation(); + + let (cache, refresh_ctx) = { + let state = self.state.borrow(); + ( + state.preview_cache.clone(), + RefreshCtx::new(self as *const _ as *const c_void, command_switcher_refresh), + ) + }; + + let sync_limit = SYNC_PREWARM_LIMIT.min(tasks.len()); + let mut async_tasks = tasks.split_off(sync_limit); + let sync_tasks = tasks; // first N by priority/area + + // Synchronous prewarm: capture a few highest-priority previews immediately + for (_, _, task) in sync_tasks.into_iter() { + { + let cache_read = cache.read(); + if cache_read.contains_key(&task.window_id) { + continue; + } + } + if !CAPTURE_MANAGER.try_mark_in_flight(generation, task.window_id) { + continue; + } + + let result = crate::sys::window_server::capture_window_image( + WindowServerId::new(task.window_server_id), + task.target_w, + task.target_h, + ); + + match result { + Some(img) => { + { + let mut cache_write = cache.write(); + cache_write.insert(task.window_id, img); + } + CAPTURE_MANAGER.clear_in_flight(generation, task.window_id); + if let Ok(mut state) = self.state.try_borrow_mut() { + state.ready_previews.insert(task.window_id); + } + self.request_refresh(); + } + None => { + CAPTURE_MANAGER.clear_in_flight(generation, task.window_id); + } + } + } + + // Remaining tasks: dispatch to background workers + for (_, _, task) in async_tasks.drain(..) { + { + let cache_read = cache.read(); + if cache_read.contains_key(&task.window_id) { + continue; + } + } + let job = CaptureJob { + task, + cache: cache.clone(), + generation, + refresh: refresh_ctx, + }; + match CAPTURE_MANAGER.enqueue(job) { + EnqueueResult::Enqueued | EnqueueResult::Duplicate => {} + EnqueueResult::ChannelClosed => break, + } + } + } + + fn draw_items(&self) { + let mut state = self.state.borrow_mut(); + let item_count = state.items.len(); + let layout = compute_layout(item_count, self.frame.size); + state.grid_columns = layout.columns; + state.grid_rows = layout.rows; + self.container_layer.setFrame(layout.container_frame); + // follow mission_control style: subtle tinted backdrop card with light border & gentle shadow + self.container_layer.setBackgroundColor(Some(&**OVERLAY_BACKGROUND_COLOR)); + self.container_layer.setBorderWidth(1.2); + self.container_layer.setBorderColor(Some(&**WORKSPACE_BORDER_COLOR)); + self.container_layer.setMasksToBounds(false); + self.container_layer.setContentsScale(self.scale); + + state.item_frames.clear(); + state.item_frames.reserve(item_count); + + let container_origin = layout.container_frame.origin; + let mut visible_items: HashSet = HashSet::default(); //with_capacity(item_count); + let mut active_preview_keys: HashSet<(ItemKey, Option)> = HashSet::default(); //with_capacity(item_count.saturating_mul(2)); + let label_color = &**ITEM_LABEL_COLOR; + // Reuse CFStrings within this pass to avoid redundant allocations + let align_left = CFString::from_static_str("left"); + let trunc_end = CFString::from_static_str("end"); + + for idx in 0..item_count { + autoreleasepool(|_| { + let item = state.items[idx].clone(); + let Some(item_frame) = layout.item_frames.get(idx) else { + return; + }; + let is_selected = state.selection() == Some(idx); + let key = item.key.clone(); + visible_items.insert(key.clone()); + + let item_layer = state + .item_layers + .entry(key.clone()) + .or_insert_with(|| { + let layer = CALayer::layer(); + layer.setGeometryFlipped(true); + layer.setMasksToBounds(false); + self.container_layer.addSublayer(&layer); + layer + }) + .clone(); + item_layer.setFrame(item_frame.item_frame); + item_layer.setCornerRadius(12.0); + item_layer.setContentsScale(self.scale); + item_layer.setZPosition(0.0); + // Only update style when selection changed for this key + let style_changed = state + .item_styles + .entry(key.clone()) + .or_insert_with(Default::default) + .update_selected(is_selected); + if style_changed { + item_layer.setBackgroundColor(Some(&**ITEM_BG_COLOR)); + item_layer.setBorderWidth(if is_selected { 3.0 } else { 1.0 }); + item_layer.setBorderColor(Some(if is_selected { + &**SELECTED_BORDER_COLOR + } else { + &**WORKSPACE_BORDER_COLOR + })); + } + + let label_layer = state + .label_layers + .entry(key.clone()) + .or_insert_with(|| { + let layer = CATextLayer::layer(); + layer.setContentsScale(self.scale); + layer.setGeometryFlipped(true); + self.container_layer.addSublayer(&layer); + layer + }) + .clone(); + label_layer.setFrame(item_frame.label_frame); + label_layer.setAlignmentMode(align_left.as_ref()); + label_layer.setForegroundColor(Some(label_color)); + label_layer.setFontSize((12.0 * layout.scale).clamp(10.5, 13.0)); + label_layer.setTruncationMode(trunc_end.as_ref()); + label_layer.setWrapped(false); + label_layer.setZPosition(3.0); + // Cache CFString content; only update when changed + self.update_text_layer_cached(&mut state, &key, &label_layer, &item.label); + + match &item.kind { + SwitcherItemKind::Window(window) => { + let key = self.draw_window_preview( + &mut state, + &key, + window, + item_frame.preview_frame, + is_selected, + ); + active_preview_keys.insert(key); + } + SwitcherItemKind::Workspace(workspace) => { + for key in self.draw_workspace_preview( + &mut state, + &key, + workspace, + item_frame.preview_frame, + is_selected, + ) { + active_preview_keys.insert(key); + } + } + } + + let stored_frame = CGRect::new( + CGPoint::new( + container_origin.x + item_frame.item_frame.origin.x, + container_origin.y + item_frame.item_frame.origin.y, + ), + item_frame.item_frame.size, + ); + state.item_frames.push((key.clone(), stored_frame)); + }); + } + + state.item_layers.retain(|key, layer| { + if visible_items.contains(key) { + true + } else { + layer.removeFromSuperlayer(); + false + } + }); + state.label_layers.retain(|key, layer| { + if visible_items.contains(key) { + true + } else { + layer.removeFromSuperlayer(); + false + } + }); + state.label_strings.retain(|key, _| visible_items.contains(key)); + state.preview_layers.retain(|key, entry| { + if active_preview_keys.contains(key) { + true + } else { + entry.layer().removeFromSuperlayer(); + false + } + }); + } + + fn update_text_layer_cached( + &self, + state: &mut CommandSwitcherState, + key: &ItemKey, + layer: &CATextLayer, + text: &str, + ) { + use crate::common::collections::hash_map; + match state.label_strings.entry(key.clone()) { + hash_map::Entry::Occupied(mut occ) => { + if occ.get_mut().update(text) { + occ.get().apply_to(layer); + } + } + hash_map::Entry::Vacant(v) => { + let cache = CachedText::new(text); + cache.apply_to(layer); + v.insert(cache); + } + } + } + + fn draw_window_preview( + &self, + state: &mut CommandSwitcherState, + item_key: &ItemKey, + window: &WindowData, + frame: CGRect, + selected: bool, + ) -> (ItemKey, Option) { + let key = (item_key.clone(), None); + let entry = state.preview_layers.entry(key.clone()).or_insert_with(|| { + let layer = CALayer::layer(); + layer.setGeometryFlipped(true); + layer.setMasksToBounds(true); + self.container_layer.addSublayer(&layer); + PreviewLayerEntry::new(layer, Some(window.id)) + }); + entry.set_window_id(Some(window.id)); + let layer = entry.layer().clone(); + layer.setFrame(frame); + layer.setCornerRadius(if selected { 9.0 } else { 8.0 }); + // Keep a subtle background while previews load; focus indication happens on the outer card + let bg_color = &**ITEM_BG_COLOR; + layer.setBackgroundColor(Some(bg_color)); + layer.setBorderWidth(0.4); + layer.setBorderColor(Some(&**WINDOW_BORDER_COLOR)); + layer.setContentsScale(self.scale); + layer.setZPosition(2.0); + + let maybe_img_ptr = { + let cache = state.preview_cache.read(); + cache.get(&window.id).map(|img| img.as_ptr() as *mut AnyObject) + }; + + let mut had_image = false; + if let Some(img_ptr) = maybe_img_ptr { + unsafe { + let _: () = msg_send![&**layer, setContents: img_ptr]; + } + state.ready_previews.insert(window.id); + had_image = true; + } else if state.ready_previews.contains(&window.id) { + had_image = true; + } + + if !had_image { + let (tw, th) = capture_target_for_rect(frame); + self.schedule_capture(state, window, tw, th); + } + key + } + + fn draw_workspace_preview( + &self, + state: &mut CommandSwitcherState, + item_key: &ItemKey, + workspace: &WorkspaceData, + frame: CGRect, + selected: bool, + ) -> Vec<(ItemKey, Option)> { + let key = (item_key.clone(), None); + let container_entry = state.preview_layers.entry(key.clone()).or_insert_with(|| { + let layer = CALayer::layer(); + layer.setGeometryFlipped(true); + layer.setMasksToBounds(true); + self.container_layer.addSublayer(&layer); + PreviewLayerEntry::new(layer, None) + }); + container_entry.set_window_id(None); + let container = container_entry.layer().clone(); + container.setFrame(frame); + container.setCornerRadius(if selected { 9.0 } else { 8.0 }); + container.setBorderWidth(0.0); + container.setBorderColor(None); + + container.setBackgroundColor(Some(&**ITEM_BG_COLOR)); + container.setContentsScale(self.scale); + container.setZPosition(1.0); + + let mut keys = Vec::with_capacity(1 + workspace.windows.len()); + keys.push(key.clone()); + + let Some(layout) = compute_workspace_window_layout(&workspace.windows, frame) else { + return keys; + }; + + // Disable implicit animations for sublayer updates in this pass + CATransaction::begin(); + CATransaction::setDisableActions(true); + for (idx, window) in workspace.windows.iter().enumerate() { + let rect = layout[idx]; + let window_id = window.id; + let wk = (item_key.clone(), Some(window_id)); + let entry = state.preview_layers.entry(wk.clone()).or_insert_with(|| { + let layer = CALayer::layer(); + layer.setGeometryFlipped(true); + layer.setMasksToBounds(true); + self.container_layer.addSublayer(&layer); + PreviewLayerEntry::new(layer, Some(window_id)) + }); + entry.set_window_id(Some(window_id)); + let layer = entry.layer().clone(); + layer.setFrame(rect); + layer.setCornerRadius(4.0); + layer.setBorderWidth(0.3); + layer.setBorderColor(Some(&**WINDOW_BORDER_COLOR)); + layer.setContentsScale(self.scale); + + let maybe_img_ptr = { + let cache = state.preview_cache.read(); + cache.get(&window_id).map(|img| img.as_ptr() as *mut AnyObject) + }; + let mut had_image = false; + if let Some(img_ptr) = maybe_img_ptr { + unsafe { + let _: () = msg_send![&**layer, setContents: img_ptr]; + } + state.ready_previews.insert(window_id); + had_image = true; + } else if state.ready_previews.contains(&window_id) { + had_image = true; + } + if !had_image { + let (tw, th) = capture_target_for_rect(rect); + self.schedule_capture(state, window, tw, th); + } + keys.push(wk); + } + CATransaction::commit(); + + keys + } + + fn schedule_capture( + &self, + state: &CommandSwitcherState, + window: &WindowData, + target_w: usize, + target_h: usize, + ) { + let Some(wsid) = window.window_server_id else { return }; + if state.ready_previews.contains(&window.id) { + return; + } + { + let cache = state.preview_cache.read(); + if cache.contains_key(&window.id) { + return; + } + } + let generation = CAPTURE_MANAGER.current_generation(); + let refresh = RefreshCtx::new(self as *const _ as *const c_void, command_switcher_refresh); + let job = CaptureJob { + task: CaptureTask { + window_id: window.id, + window_server_id: wsid, + target_w, + target_h, + }, + cache: state.preview_cache.clone(), + generation, + refresh, + }; + let _ = CAPTURE_MANAGER.enqueue(job); + } + + fn ensure_key_tap(&self) { + if self.key_tap.borrow().is_some() { + return; + } + + #[repr(C)] + struct KeyCtx { + overlay: *const CommandSwitcherOverlay, + consumes: bool, + } + + unsafe fn drop_ctx(ptr: *mut c_void) { + unsafe { + drop(Box::from_raw(ptr as *mut KeyCtx)); + } + } + + unsafe extern "C-unwind" fn key_callback( + _proxy: CGEventTapProxy, + etype: CGEventType, + event: core::ptr::NonNull, + user_info: *mut c_void, + ) -> *mut CGEvent { + let ctx = unsafe { &*(user_info as *const KeyCtx) }; + let mut handled = false; + if let Some(overlay) = unsafe { ctx.overlay.as_ref() } { + match etype { + CGEventType::KeyDown => { + let keycode = unsafe { + CGEvent::integer_value_field( + Some(event.as_ref()), + CGEventField::KeyboardEventKeycode, + ) as u16 + }; + overlay.handle_keycode(keycode); + handled = true; + } + CGEventType::LeftMouseDown => { + let loc = unsafe { CGEvent::location(Some(event.as_ref())) }; + overlay.handle_click_global(loc); + handled = true; + } + CGEventType::MouseMoved => { + let loc = unsafe { CGEvent::location(Some(event.as_ref())) }; + overlay.handle_move_global(loc); + handled = true; + } + CGEventType::LeftMouseUp => handled = true, + _ => {} + } + } + if handled && ctx.consumes { + core::ptr::null_mut() + } else { + event.as_ptr() + } + } + + let mask = (1u64 << CGEventType::KeyDown.0 as u64) + | (1u64 << CGEventType::LeftMouseDown.0 as u64) + | (1u64 << CGEventType::LeftMouseUp.0 as u64) + | (1u64 << CGEventType::MouseMoved.0 as u64); + + let overlay_ptr = self as *const _; + + let tap = unsafe { + let ctx_ptr = Box::into_raw(Box::new(KeyCtx { + overlay: overlay_ptr, + consumes: true, + })) as *mut c_void; + match crate::sys::event_tap::EventTap::new_with_options( + CGEventTapOptions::Default, + mask, + Some(key_callback), + ctx_ptr, + Some(drop_ctx), + ) { + Some(tap) => Some(tap), + None => { + drop_ctx(ctx_ptr); + let ctx_ptr = Box::into_raw(Box::new(KeyCtx { + overlay: overlay_ptr, + consumes: false, + })) as *mut c_void; + match crate::sys::event_tap::EventTap::new_listen_only( + mask, + Some(key_callback), + ctx_ptr, + Some(drop_ctx), + ) { + Some(tap) => Some(tap), + None => { + drop_ctx(ctx_ptr); + None + } + } + } + } + }; + + if let Some(tap) = tap { + self.key_tap.borrow_mut().replace(tap); + } + } + + fn handle_keycode(&self, keycode: u16) { + match keycode { + 53 => self.emit_action(CommandSwitcherAction::Dismiss), + 36 | 76 => self.activate_selection(), + 48 | 124 => { + if self.adjust_selection(1) { + self.present_root_layer(); + } + } + 123 => { + if self.adjust_selection(-1) { + self.present_root_layer(); + } + } + 126 => { + if self.adjust_selection_vertical(-1) { + self.present_root_layer(); + } + } + 125 => { + if self.adjust_selection_vertical(1) { + self.present_root_layer(); + } + } + _ => {} + } + } + + fn handle_click_global(&self, g_pt: CGPoint) { + let pt = self.global_to_local_point(g_pt); + let mut state = match self.state.try_borrow_mut() { + Ok(s) => s, + Err(_) => return, + }; + let Some((idx, _)) = state + .item_frames + .iter() + .enumerate() + .find(|(_, (_, frame))| point_in_rect(pt, *frame)) + else { + drop(state); + self.emit_action(CommandSwitcherAction::Dismiss); + return; + }; + state.set_selection(idx); + drop(state); + self.draw_and_present(); + self.activate_selection(); + } + + fn handle_move_global(&self, g_pt: CGPoint) { + let pt = self.global_to_local_point(g_pt); + let mut state = match self.state.try_borrow_mut() { + Ok(s) => s, + Err(_) => return, + }; + let maybe_idx = state + .item_frames + .iter() + .enumerate() + .find(|(_, (_, frame))| point_in_rect(pt, *frame)) + .map(|(idx, _)| idx); + if let Some(idx) = maybe_idx { + if state.selection() != Some(idx) { + let prev = state.selection(); + state.set_selection(idx); + let new_key = state.items[idx].key.clone(); + let old_key = prev.and_then(|p| state.items.get(p).map(|it| it.key.clone())); + drop(state); + if let Some(ok) = old_key.as_ref() { + self.update_item_selected_style(ok, false); + } + self.update_item_selected_style(&new_key, true); + self.present_root_layer(); + } + } + } + + fn global_to_local_point(&self, g_pt: CGPoint) -> CGPoint { + let lx = g_pt.x - self.frame.origin.x; + let ly = (self.frame.origin.y + self.frame.size.height) - g_pt.y; + CGPoint::new(lx, ly) + } +} + +#[derive(Clone)] +struct LayoutFrame { + item_frame: CGRect, + preview_frame: CGRect, + label_frame: CGRect, +} + +struct LayoutResult { + container_frame: CGRect, + item_frames: Vec, + scale: f64, + columns: usize, + rows: usize, +} + +fn compute_layout(count: usize, bounds: CGSize) -> LayoutResult { + if count == 0 { + return LayoutResult { + container_frame: CGRect::new( + CGPoint::new(bounds.width / 2.0, bounds.height / 2.0), + CGSize::new(0.0, 0.0), + ), + item_frames: Vec::new(), + scale: 1.0, + columns: 0, + rows: 0, + }; + } + let max_container_width = (bounds.width * MAX_CONTAINER_WIDTH_RATIO).max(420.0); + let max_container_height = (bounds.height * MAX_CONTAINER_HEIGHT_RATIO) + .max(BASE_ITEM_HEIGHT + 2.0 * CONTAINER_PADDING); + let available_width = (max_container_width - 2.0 * CONTAINER_PADDING).max(1.0); + let available_height = (max_container_height - 2.0 * CONTAINER_PADDING).max(1.0); + + struct Candidate { + scale: f64, + columns: usize, + rows: usize, + container_width: f64, + container_height: f64, + } + + let mut best: Option = None; + for columns in 1..=count { + let rows = (count + columns - 1) / columns; + let spacing_cols = (columns.saturating_sub(1)) as f64; + let spacing_rows = (rows.saturating_sub(1)) as f64; + let content_width = columns as f64 * BASE_ITEM_WIDTH + spacing_cols * ITEM_SPACING; + let content_height = rows as f64 * BASE_ITEM_HEIGHT + spacing_rows * ITEM_SPACING; + if content_width <= 0.0 || content_height <= 0.0 { + continue; + } + + let width_scale = (available_width / content_width).min(1.0); + let height_scale = (available_height / content_height).min(1.0); + let scale = width_scale.min(height_scale); + if scale <= 0.0 { + continue; + } + + let container_width = content_width * scale + 2.0 * CONTAINER_PADDING; + let container_height = content_height * scale + 2.0 * CONTAINER_PADDING; + + let better = match &best { + None => true, + Some(current) => { + if (scale - current.scale).abs() > f64::EPSILON { + scale > current.scale + } else if (container_height - current.container_height).abs() > f64::EPSILON { + container_height < current.container_height + } else { + container_width < current.container_width + } + } + }; + + if better { + best = Some(Candidate { + scale, + columns, + rows, + container_width, + container_height, + }); + } + } + + let best = best.unwrap_or_else(|| Candidate { + scale: 1.0, + columns: count, + rows: 1, + container_width: (BASE_ITEM_WIDTH * count as f64) + + (ITEM_SPACING * (count.saturating_sub(1) as f64)) + + 2.0 * CONTAINER_PADDING, + container_height: BASE_ITEM_HEIGHT + 2.0 * CONTAINER_PADDING, + }); + + let item_width = BASE_ITEM_WIDTH * best.scale; + let item_height = BASE_ITEM_HEIGHT * best.scale; + let h_spacing = if best.columns > 1 { + ITEM_SPACING * best.scale + } else { + 0.0 + }; + let v_spacing = if best.rows > 1 { + ITEM_SPACING * best.scale + } else { + 0.0 + }; + let preview_width = (item_width - 16.0 * best.scale).max(40.0); + let preview_height = (item_height - LABEL_HEIGHT * best.scale - 18.0 * best.scale).max(48.0); + let label_height = LABEL_HEIGHT * best.scale; + + let origin_x = (bounds.width - best.container_width).max(0.0) / 2.0; + let origin_y = (bounds.height - best.container_height).max(0.0) / 2.0; + + let container_frame = CGRect::new( + CGPoint::new(origin_x, origin_y), + CGSize::new(best.container_width, best.container_height), + ); + + let mut item_frames = Vec::with_capacity(count); + for idx in 0..count { + let row = idx / best.columns; + let col = idx % best.columns; + let offset_x = CONTAINER_PADDING + col as f64 * (item_width + h_spacing); + let visual_row = if best.rows > 0 { + best.rows - 1 - row + } else { + 0 + }; + let offset_y = CONTAINER_PADDING + visual_row as f64 * (item_height + v_spacing); + + let item_frame = CGRect::new( + CGPoint::new(offset_x, offset_y), + CGSize::new(item_width, item_height), + ); + + let preview_frame = CGRect::new( + CGPoint::new( + offset_x + (item_width - preview_width) / 2.0, + offset_y + 8.0 * best.scale, + ), + CGSize::new(preview_width, preview_height), + ); + + let label_frame = CGRect::new( + CGPoint::new( + offset_x + 8.0 * best.scale, + preview_frame.origin.y + preview_frame.size.height + 6.0 * best.scale, + ), + CGSize::new(item_width - 16.0 * best.scale, label_height), + ); + + item_frames.push(LayoutFrame { + item_frame, + preview_frame, + label_frame, + }); + } + + LayoutResult { + container_frame, + item_frames, + scale: best.scale, + columns: best.columns, + rows: best.rows, + } +} + +fn point_in_rect(pt: CGPoint, rect: CGRect) -> bool { + pt.x >= rect.origin.x + && pt.x <= rect.origin.x + rect.size.width + && pt.y >= rect.origin.y + && pt.y <= rect.origin.y + rect.size.height +} + +struct WorkspaceLayoutMetrics { + scale: f64, + x_offset: f64, + y_offset: f64, + min_x: f64, + min_y: f64, + span_h: f64, +} + +impl WorkspaceLayoutMetrics { + fn new(windows: &[WindowData], bounds: CGRect) -> Option { + if windows.is_empty() { + return None; + } + + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + + for window in windows { + let x0 = window.frame.origin.x; + let y0 = window.frame.origin.y; + let x1 = x0 + window.frame.size.width; + let y1 = y0 + window.frame.size.height; + if x0 < min_x { + min_x = x0; + } + if y0 < min_y { + min_y = y0; + } + if x1 > max_x { + max_x = x1; + } + if y1 > max_y { + max_y = y1; + } + } + + let disp_w = (max_x - min_x).max(1.0); + let disp_h = (max_y - min_y).max(1.0); + + let content_w = (bounds.size.width - 2.0 * WINDOW_TILE_INSET).max(1.0); + let content_h = (bounds.size.height - 2.0 * WINDOW_TILE_INSET).max(1.0); + + let scale = (content_w / disp_w).min(content_h / disp_h).min(WINDOW_TILE_MAX_SCALE) + * WINDOW_TILE_SCALE_FACTOR; + + if !scale.is_finite() || scale <= 0.0 { + return None; + } + + let x_offset = bounds.origin.x + WINDOW_TILE_INSET + (content_w - disp_w * scale) / 2.0; + let y_offset = bounds.origin.y + WINDOW_TILE_INSET + (content_h - disp_h * scale) / 2.0; + + Some(Self { + scale, + x_offset, + y_offset, + min_x, + min_y, + span_h: disp_h, + }) + } + + fn rect_for(&self, window: &WindowData) -> CGRect { + let wx = window.frame.origin.x - self.min_x; + let ww = window.frame.size.width; + let wh = window.frame.size.height; + + let mut rx = self.x_offset + wx * self.scale; + let mut rw = (ww * self.scale).max(WINDOW_TILE_MIN_SIZE); + + let bottom_rel = window.frame.origin.y - self.min_y; + let top_rel = bottom_rel + wh; + let inverted_y = (self.span_h - top_rel).max(0.0); + let mut ry = self.y_offset + inverted_y * self.scale; + let mut rh = (wh * self.scale).max(WINDOW_TILE_MIN_SIZE); + + if rw > (WINDOW_TILE_MIN_SIZE + WINDOW_TILE_GAP) { + rx += WINDOW_TILE_GAP / 2.0; + rw -= WINDOW_TILE_GAP; + } + if rh > (WINDOW_TILE_MIN_SIZE + WINDOW_TILE_GAP) { + ry += WINDOW_TILE_GAP / 2.0; + rh -= WINDOW_TILE_GAP; + } + + CGRect::new(CGPoint::new(rx, ry), CGSize::new(rw, rh)) + } +} + +fn compute_workspace_window_layout(windows: &[WindowData], frame: CGRect) -> Option> { + let metrics = WorkspaceLayoutMetrics::new(windows, frame)?; + Some(windows.iter().map(|window| metrics.rect_for(window)).collect()) +} + +fn capture_target_for_window(window: &WindowData) -> (usize, usize) { + capture_target_for_dims(window.frame.size.width, window.frame.size.height) +} + +fn capture_target_for_rect(rect: CGRect) -> (usize, usize) { + capture_target_for_dims(rect.size.width, rect.size.height) +} + +fn capture_target_for_dims(width: f64, height: f64) -> (usize, usize) { + let width = width.max(1.0); + let height = height.max(1.0); + let max_edge = PREVIEW_MAX_EDGE; + let scale = (max_edge / width.max(height)).min(1.0); + let min_w = width.min(PREVIEW_MIN_EDGE); + let min_h = height.min(PREVIEW_MIN_EDGE); + let scaled_w = (width * scale).max(min_w); + let scaled_h = (height * scale).max(min_h); + (scaled_w.round() as usize, scaled_h.round() as usize) +} + +impl CommandSwitcherOverlay { + fn update_item_selected_style(&self, key: &ItemKey, selected: bool) { + if let Ok(mut state) = self.state.try_borrow_mut() { + if let Some(layer) = state.item_layers.get(key).cloned() { + let style_changed = state + .item_styles + .entry(key.clone()) + .or_insert_with(Default::default) + .update_selected(selected); + if style_changed { + layer.setBackgroundColor(Some(&**ITEM_BG_COLOR)); + layer.setBorderWidth(if selected { 3.0 } else { 1.0 }); + layer.setBorderColor(Some(if selected { + &**SELECTED_BORDER_COLOR + } else { + &**WORKSPACE_BORDER_COLOR + })); + } + } + } + } +} diff --git a/src/ui/mission_control.rs b/src/ui/mission_control.rs index a8dc2622..8408aec8 100644 --- a/src/ui/mission_control.rs +++ b/src/ui/mission_control.rs @@ -4,7 +4,6 @@ use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use crossbeam_channel::{Sender, unbounded}; use dispatchr::queue; use dispatchr::time::Time; use objc2::msg_send; @@ -19,7 +18,7 @@ use objc2_core_graphics::{ use objc2_foundation::MainThreadMarker; use objc2_quartz_core::{CALayer, CATextLayer, CATransaction}; use once_cell::sync::Lazy; -use parking_lot::{Mutex, RwLock}; +use parking_lot::RwLock; use tracing::info; use crate::actor::app::WindowId; @@ -34,6 +33,9 @@ use crate::sys::skylight::{ CFRelease, G_CONNECTION, SLSFlushWindowContentRegion, SLWindowContextCreate, }; use crate::sys::window_server::{CapturedWindowImage, WindowServerId}; +use crate::ui::overlay_common::{ + CachedText, CaptureJob, CaptureManager, CaptureTask, EnqueueResult, ItemLayerStyle, RefreshCtx, +}; unsafe extern "C" { fn CGContextFlush(ctx: *mut CGContext); @@ -44,78 +46,15 @@ unsafe extern "C" { fn CGContextScaleCTM(ctx: *mut CGContext, sx: f64, sy: f64); } -#[derive(Debug, Clone)] -struct CaptureTask { - window_id: WindowId, - window_server_id: u32, - target_w: usize, - target_h: usize, -} - -struct CaptureJob { - task: CaptureTask, - cache: Arc>>, - generation: u64, - overlay_ptr_bits: usize, -} - -struct CapturePool { - sender: Sender, -} - -static CURRENT_GENERATION: AtomicU64 = AtomicU64::new(1); -static IN_FLIGHT: Lazy>> = - Lazy::new(|| Mutex::new(HashSet::default())); - -static CAPTURE_POOL: Lazy = Lazy::new(|| { - use std::thread; - let (tx, rx) = unbounded::(); - - let mut worker_count = std::thread::available_parallelism() - .map(|n| n.get().saturating_sub(1)) - .unwrap_or(2); - worker_count = worker_count.max(2).min(6); - for _ in 0..worker_count { - let rx = rx.clone(); - thread::spawn(move || { - while let Ok(job) = rx.recv() { - if job.generation != CURRENT_GENERATION.load(Ordering::Acquire) { - if let Some(mut set) = IN_FLIGHT.try_lock() { - set.remove(&(job.generation, job.task.window_id)); - } else { - // best-effort; skip if contended - } - continue; - } +static CAPTURE_MANAGER: Lazy = Lazy::new(CaptureManager::default); - if let Some(img) = crate::sys::window_server::capture_window_image( - WindowServerId::new(job.task.window_server_id), - job.task.target_w, - job.task.target_h, - ) { - { - let mut cache_lock = job.cache.write(); - cache_lock.insert(job.task.window_id, img); - } - if let Some(mut set) = IN_FLIGHT.try_lock() { - set.remove(&(job.generation, job.task.window_id)); - } - if let Some(overlay) = - unsafe { (job.overlay_ptr_bits as *const MissionControlOverlay).as_ref() } - { - overlay.request_refresh(); - } - } else { - if let Some(mut set) = IN_FLIGHT.try_lock() { - set.remove(&(job.generation, job.task.window_id)); - } - } - } - }); +unsafe fn mission_control_refresh(bits: usize) { + if bits == 0 { + return; } - - CapturePool { sender: tx } -}); + let overlay = unsafe { &*(bits as *const MissionControlOverlay) }; + overlay.request_refresh(); +} extern "C" fn refresh_coalesced_cb(ctx: *mut c_void) { if ctx.is_null() { @@ -190,65 +129,16 @@ pub enum MissionControlAction { Dismiss, } -struct WorkspaceLabelText { - text: String, - attributed: CFRetained, -} - -impl WorkspaceLabelText { - fn new(text: &str) -> Self { - let cf_string = CFString::from_str(text); - Self { - text: text.to_owned(), - attributed: cf_string, - } - } - - fn update(&mut self, text: &str) -> bool { - if self.text == text { - return false; - } - - self.text.clear(); - self.text.push_str(text); - self.attributed = CFString::from_str(text); - true - } - - unsafe fn apply_to(&self, layer: &CATextLayer) { - let raw = self.attributed.as_ref() as *const AnyObject; - unsafe { - layer.setString(Some(&*raw)); - } - } -} - -#[derive(Default)] -struct PreviewLayerStyle { - is_selected: Option, -} - -impl PreviewLayerStyle { - fn update_selected(&mut self, selected: bool) -> bool { - if self.is_selected == Some(selected) { - false - } else { - self.is_selected = Some(selected); - true - } - } -} - pub struct MissionControlState { mode: Option, on_action: Option>, selection: Option, preview_cache: Arc>>, preview_layers: HashMap>, - preview_layer_styles: HashMap, + preview_layer_styles: HashMap, workspace_layers: HashMap>, workspace_label_layers: HashMap>, - workspace_label_strings: HashMap, + workspace_label_strings: HashMap, ready_previews: HashSet, render_root: Option>, render_window_id: Option, @@ -282,7 +172,7 @@ impl MissionControlState { fn set_mode(&mut self, mode: MissionControlMode) { self.mode = Some(mode); self.selection = None; - let _new_gen = CURRENT_GENERATION.fetch_add(1, Ordering::AcqRel) + 1; + CAPTURE_MANAGER.bump_generation(); self.ready_previews.clear(); self.prune_preview_cache(); self.ensure_selection(); @@ -295,7 +185,7 @@ impl MissionControlState { self.selection = None; self.on_action = None; - let _new_gen = CURRENT_GENERATION.fetch_add(1, Ordering::AcqRel) + 1; + CAPTURE_MANAGER.bump_generation(); let mut cache = self.preview_cache.write(); cache.clear(); @@ -1053,16 +943,12 @@ impl MissionControlOverlay { match st.workspace_label_strings.entry(ws.id.clone()) { hash_map::Entry::Occupied(mut occ) => { if occ.get_mut().update(&ws.name) { - unsafe { - occ.get().apply_to(&label_layer); - } + occ.get().apply_to(&label_layer); } } hash_map::Entry::Vacant(vac) => { - let cache = WorkspaceLabelText::new(&ws.name); - unsafe { - cache.apply_to(&label_layer); - } + let cache = CachedText::new(&ws.name); + cache.apply_to(&label_layer); vac.insert(cache); } } @@ -1177,9 +1063,7 @@ impl MissionControlOverlay { .update_selected(is_selected); let maybe_img_ptr = { let cache = s.preview_cache.read(); - cache - .get(&window.id) - .map(|img| img.as_ptr() as *mut objc2::runtime::AnyObject) + cache.get(&window.id).map(|img| img.as_ptr() as *mut AnyObject) }; let mut had_image = false; if let Some(img_ptr) = maybe_img_ptr { @@ -1251,13 +1135,8 @@ impl MissionControlOverlay { return; } } - let generation = CURRENT_GENERATION.load(Ordering::Acquire); - { - let mut set = IN_FLIGHT.lock(); - if !set.insert((generation, window.id)) { - return; - } - } + let generation = CAPTURE_MANAGER.current_generation(); + let refresh = RefreshCtx::new(self as *const _ as *const c_void, mission_control_refresh); let job = CaptureJob { task: CaptureTask { window_id: window.id, @@ -1267,9 +1146,9 @@ impl MissionControlOverlay { }, cache: st.preview_cache.clone(), generation, - overlay_ptr_bits: self as *const _ as usize, + refresh, }; - let _ = CAPTURE_POOL.sender.send(job); + let _ = CAPTURE_MANAGER.enqueue(job); } fn prewarm_previews(&self) { @@ -1326,11 +1205,14 @@ impl MissionControlOverlay { return; } - let generation = CURRENT_GENERATION.fetch_add(1, Ordering::AcqRel) + 1; + let generation = CAPTURE_MANAGER.bump_generation(); - let (preview_cache, overlay_ptr_bits) = { + let (preview_cache, refresh_ctx) = { let st = state_cell.borrow(); - (st.preview_cache.clone(), self as *const _ as usize) + ( + st.preview_cache.clone(), + RefreshCtx::new(self as *const _ as *const c_void, mission_control_refresh), + ) }; let sync_limit = SYNC_PREWARM_LIMIT.min(tasks.len()); @@ -1344,11 +1226,8 @@ impl MissionControlOverlay { continue; } } - { - let mut set = IN_FLIGHT.lock(); - if !set.insert((generation, task.window_id)) { - continue; - } + if !CAPTURE_MANAGER.try_mark_in_flight(generation, task.window_id) { + continue; } let result = crate::sys::window_server::capture_window_image( @@ -1363,22 +1242,14 @@ impl MissionControlOverlay { let mut cache = preview_cache.write(); cache.insert(task.window_id, img); } - { - let mut set = IN_FLIGHT.lock(); - set.remove(&(generation, task.window_id)); - } + CAPTURE_MANAGER.clear_in_flight(generation, task.window_id); if let Ok(mut st) = state_cell.try_borrow_mut() { st.ready_previews.insert(task.window_id); } - if let Some(overlay) = - unsafe { (overlay_ptr_bits as *const MissionControlOverlay).as_ref() } - { - overlay.request_refresh(); - } + refresh_ctx.call(); } None => { - let mut set = IN_FLIGHT.lock(); - set.remove(&(generation, task.window_id)); + CAPTURE_MANAGER.clear_in_flight(generation, task.window_id); } } } @@ -1390,21 +1261,15 @@ impl MissionControlOverlay { continue; } } - { - let mut set = IN_FLIGHT.lock(); - if !set.insert((generation, task.window_id)) { - continue; - } - } - let job = CaptureJob { task, cache: preview_cache.clone(), generation, - overlay_ptr_bits, + refresh: refresh_ctx, }; - if CAPTURE_POOL.sender.send(job).is_err() { - break; + match CAPTURE_MANAGER.enqueue(job) { + EnqueueResult::Enqueued | EnqueueResult::Duplicate => {} + EnqueueResult::ChannelClosed => break, } } } @@ -1432,7 +1297,7 @@ impl MissionControlOverlay { for (wid, layer) in layers.iter() { if let Some(img) = cache.get(wid) { unsafe { - let img_ptr = img.as_ptr() as *mut objc2::runtime::AnyObject; + let img_ptr = img.as_ptr() as *mut AnyObject; let _: () = msg_send![&**layer, setContents: img_ptr]; } ready_ids.push(*wid); diff --git a/src/ui/overlay_common.rs b/src/ui/overlay_common.rs new file mode 100644 index 00000000..ee04da64 --- /dev/null +++ b/src/ui/overlay_common.rs @@ -0,0 +1,222 @@ +use core::ffi::c_void; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread; + +use crossbeam_channel::{Receiver, Sender, unbounded}; +use objc2::runtime::AnyObject; +use objc2_core_foundation::{CFRetained, CFString}; +use objc2_quartz_core::CATextLayer; +use parking_lot::{Mutex, RwLock}; + +use crate::actor::app::WindowId; +use crate::common::collections::{HashMap, HashSet}; +use crate::sys::window_server::{CapturedWindowImage, WindowServerId, capture_window_image}; + +#[derive(Debug, Clone)] +pub struct CaptureTask { + pub window_id: WindowId, + pub window_server_id: u32, + pub target_w: usize, + pub target_h: usize, +} + +#[derive(Clone, Copy)] +pub struct RefreshCtx { + overlay_bits: usize, + callback: unsafe fn(usize), +} + +impl RefreshCtx { + pub fn new(overlay_ptr: *const c_void, callback: unsafe fn(usize)) -> Self { + Self { + overlay_bits: overlay_ptr as usize, + callback, + } + } + + pub fn call(&self) { + if self.overlay_bits == 0 { + return; + } + unsafe { (self.callback)(self.overlay_bits) }; + } +} + +#[derive(Clone)] +pub struct CaptureJob { + pub task: CaptureTask, + pub cache: Arc>>, + pub generation: u64, + pub refresh: RefreshCtx, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnqueueResult { + Enqueued, + Duplicate, + ChannelClosed, +} + +pub struct CaptureManager { + sender: Sender, + current_generation: Arc, + in_flight: Arc>>, +} + +impl CaptureManager { + pub fn new() -> Self { + let (tx, rx) = unbounded(); + let current_generation = Arc::new(AtomicU64::new(1)); + let in_flight = Arc::new(Mutex::new(HashSet::default())); + + let worker_count = std::thread::available_parallelism() + .map(|n| n.get().saturating_sub(1)) + .unwrap_or(2) + .max(2) + .min(6); + + for _ in 0..worker_count { + let rx = rx.clone(); + let current_generation = current_generation.clone(); + let in_flight = in_flight.clone(); + thread::spawn(move || worker_loop(rx, current_generation, in_flight)); + } + + Self { + sender: tx, + current_generation, + in_flight, + } + } + + pub fn bump_generation(&self) -> u64 { + self.current_generation.fetch_add(1, Ordering::AcqRel) + 1 + } + + pub fn current_generation(&self) -> u64 { self.current_generation.load(Ordering::Acquire) } + + pub fn try_mark_in_flight(&self, generation: u64, window_id: WindowId) -> bool { + let mut set = self.in_flight.lock(); + set.insert((generation, window_id)) + } + + pub fn clear_in_flight(&self, generation: u64, window_id: WindowId) { + let mut set = self.in_flight.lock(); + set.remove(&(generation, window_id)); + } + + pub fn enqueue(&self, job: CaptureJob) -> EnqueueResult { + if !self.try_mark_in_flight(job.generation, job.task.window_id) { + return EnqueueResult::Duplicate; + } + + let generation = job.generation; + let window_id = job.task.window_id; + + if self.sender.send(job).is_ok() { + EnqueueResult::Enqueued + } else { + self.clear_in_flight(generation, window_id); + EnqueueResult::ChannelClosed + } + } +} + +impl Default for CaptureManager { + fn default() -> Self { Self::new() } +} + +fn worker_loop( + rx: Receiver, + current_generation: Arc, + in_flight: Arc>>, +) { + while let Ok(job) = rx.recv() { + let CaptureJob { + task, + cache, + generation, + refresh, + } = job; + let CaptureTask { + window_id, + window_server_id, + target_w, + target_h, + } = task; + + if generation != current_generation.load(Ordering::Acquire) { + if let Some(mut set) = in_flight.try_lock() { + set.remove(&(generation, window_id)); + } + continue; + } + + let img = capture_window_image(WindowServerId::new(window_server_id), target_w, target_h); + + match img { + Some(img) => { + { + let mut cache = cache.write(); + cache.insert(window_id, img); + } + if let Some(mut set) = in_flight.try_lock() { + set.remove(&(generation, window_id)); + } + refresh.call(); + } + None => { + if let Some(mut set) = in_flight.try_lock() { + set.remove(&(generation, window_id)); + } + } + } + } +} + +pub struct CachedText { + text: String, + attributed: CFRetained, +} + +impl CachedText { + pub fn new(text: &str) -> Self { + let cf = CFString::from_str(text); + Self { + text: text.to_owned(), + attributed: cf, + } + } + + pub fn update(&mut self, text: &str) -> bool { + if self.text == text { + return false; + } + self.text.clear(); + self.text.push_str(text); + self.attributed = CFString::from_str(text); + true + } + + pub fn apply_to(&self, layer: &CATextLayer) { + let raw = self.attributed.as_ref() as *const AnyObject; + unsafe { layer.setString(Some(&*raw)) }; + } +} + +#[derive(Default)] +pub struct ItemLayerStyle { + is_selected: Option, +} + +impl ItemLayerStyle { + pub fn update_selected(&mut self, selected: bool) -> bool { + if self.is_selected == Some(selected) { + false + } else { + self.is_selected = Some(selected); + true + } + } +}