macOS: show the window on the current desktop

Opening the launcher from the menu bar used to switch to the desktop
(Space) its window was last on, which is jarring when you're working on
another one. A new setting, "Show the window on the current desktop"
(on by default), sets AppKit's "move to active space" behavior on the
window whenever it's shown, so it comes to the desktop you're on instead.
Turning it off restores macOS's default of switching to the window.

macOS only: the code and its dependencies compile only for macOS, and
the setting appears only where the host has desktops. objc2,
objc2-foundation and objc2-app-kit are already in Cargo.lock through
winit and tray-icon; they're pinned to those versions, and only safe
AppKit calls are used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-10 03:03:12 -04:00
1 parent e8c4b3d355
commit 8766e29547
8 files changed
+81 -1

No files matched your search

Generated
+3
View File
@@ -287,6 +287,9 @@ dependencies = [
"image",
"log",
"notify-rust",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"tray-icon",
"winresource",
]
+7
View File
@@ -28,6 +28,13 @@ notify-rust = "4"
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
tray-icon = "0.25"
# macOS only: which desktop (Space) the window shows on (spaces.rs). Safe AppKit calls only, and
# already in Cargo.lock through winit and tray-icon; pinned exactly to those versions.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "=0.6.5"
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))'.dependencies]
tray-icon = { version = "0.25", default-features = false, features = ["ksni"] }
+24 -1
View File
@@ -5,6 +5,8 @@
#![cfg_attr(windows, windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_os = "macos")]
mod spaces;
mod tray;
use std::process::ExitCode;
@@ -91,6 +93,10 @@ impl Host for DesktopHost {
self.tray
}
fn has_desktops(&self) -> bool {
cfg!(target_os = "macos")
}
fn login_item(&self) -> Option<bool> {
Some(login::is_enabled())
}
@@ -111,6 +117,9 @@ struct Shell {
show_requests: Receiver<()>,
visible: bool,
quitting: bool,
/// The "show on the current desktop" setting last applied to the window (macOS).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
applied_follow: Option<bool>,
}
impl Shell {
@@ -135,11 +144,19 @@ impl Shell {
} else if hidden {
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
}
Self { ui: LauncherUi::new(&ctx, Box::new(backend), settings), tray, host, show_requests, visible, quitting: false }
Self { ui: LauncherUi::new(&ctx, Box::new(backend), settings), tray, host, show_requests, visible, quitting: false, applied_follow: None }
}
fn show(&mut self, ctx: &egui::Context) {
self.visible = true;
// Before the window is ordered front, so it comes to this desktop rather than taking the
// user to its own (macOS; the setting doesn't exist elsewhere).
#[cfg(target_os = "macos")]
{
let on = self.ui.settings.show_on_current_desktop;
spaces::follow_current_desktop(on);
self.applied_follow = Some(on);
}
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
@@ -159,6 +176,12 @@ impl Shell {
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.ui.logic(ctx, &mut self.host);
#[cfg(target_os = "macos")]
if self.applied_follow != Some(self.ui.settings.show_on_current_desktop) {
let on = self.ui.settings.show_on_current_desktop;
spaces::follow_current_desktop(on);
self.applied_follow = Some(on);
}
if self.show_requests.try_iter().count() > 0 {
self.show(ctx);
}
+22
View File
@@ -0,0 +1,22 @@
//! macOS only: which desktop (Space) the launcher's window appears on when it's shown.
//!
//! By default macOS switches to the desktop the window was last on, which is jarring when the
//! launcher is opened from the menu bar on another desktop. With "move to active space" set, the
//! window comes to the desktop you're on instead (and leaves the one it was on). Other platforms
//! show a hidden window on the current desktop anyway, so this module only exists on macOS.
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSWindowCollectionBehavior};
/// Makes the launcher's windows follow the current desktop when shown (`true`), or stay on their
/// own desktop and switch to it (`false`, macOS's default). Must run on the main thread, as the
/// UI does; elsewhere it does nothing.
pub fn follow_current_desktop(on: bool) {
let Some(mtm) = MainThreadMarker::new() else { return };
let app = NSApplication::sharedApplication(mtm);
for window in app.windows().iter() {
let mut behavior = window.collectionBehavior();
behavior.set(NSWindowCollectionBehavior::MoveToActiveSpace, on);
window.setCollectionBehavior(behavior);
}
}
+5
View File
@@ -19,6 +19,11 @@ pub trait Host {
fn has_tray(&self) -> bool {
false
}
/// The OS has desktops (macOS Spaces) the window can be shown on, so the "show on the current
/// desktop" setting applies.
fn has_desktops(&self) -> bool {
false
}
/// `Some(enabled)` where starting at login is supported.
fn login_item(&self) -> Option<bool> {
None
+4
View File
@@ -153,6 +153,10 @@ impl LauncherUi {
if host.has_tray() {
ui.checkbox(&mut self.settings.close_to_tray, "Keep running in the tray when the window is closed");
}
if host.has_desktops() {
ui.checkbox(&mut self.settings.show_on_current_desktop, "Show the window on the current desktop")
.on_hover_text("When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on.");
}
if let Some(mut on) = host.login_item()
&& ui.checkbox(&mut on, "Start ArtCraft Launcher when I log in").changed()
&& let Err(e) = host.set_login_item(on)
+4
View File
@@ -89,6 +89,9 @@ pub struct Settings {
pub theme: ThemeChoice,
/// Closing the window keeps the launcher in the tray (menu bar) instead of quitting.
pub close_to_tray: bool,
/// macOS: showing the window brings it to the current desktop (Space) instead of switching to
/// the desktop it was on.
pub show_on_current_desktop: bool,
pub check_every: CheckEvery,
/// Show a system notification when an installed app has an update.
pub notify: bool,
@@ -110,6 +113,7 @@ impl Default for Settings {
sort_descending: false,
theme: ThemeChoice::System,
close_to_tray: true,
show_on_current_desktop: true,
check_every: CheckEvery::Hour,
notify: true,
prereleases: false,
+12
View File
@@ -446,6 +446,18 @@ fn the_launcher_updates_itself_and_quits_to_restart() {
assert!(quit.get());
}
#[test]
fn the_current_desktop_setting_is_only_offered_where_the_os_has_desktops() {
// NoHost has no desktops (Windows, Linux, web): no setting.
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(6);
h.get_by_label("⚙").click();
h.run_steps(4);
assert!(h.query_by_label("Show the window on the current desktop").is_none());
// On by default for everyone; macOS shows it in Settings (DesktopHost::has_desktops).
assert!(Settings::default().show_on_current_desktop);
}
#[test]
fn web_build_offers_downloads() {
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, false);