gardesk/tarmac / 44ab62f

Browse files

revert all session changes to restore stable baseline

reverts tarmac and ers to pre-session state (d422773 / 6acfaa6).
all sprint 14/15 features (IPC enrichment, Lua callbacks, tray,
settings window, floating window z-order changes) removed to
restore stability. these features destabilized tiling layout,
border rendering, and multi-monitor workspace management.
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
44ab62f264c93fd8a15021f8c686cc6487ea0f5f
Parents
2e7264e
Tree
9ad0167

16 changed files

StatusFile+-
M .github/workflows/ci.yml 2 4
M .gitignore 1 0
M ers 1 1
M tarmac/Cargo.toml 0 27
D tarmac/assets/tray_icon.svg 0 26
D tarmac/assets/tray_icon_16.png bin
D tarmac/assets/tray_icon_32.png bin
M tarmac/src/config/lua.rs 2 147
M tarmac/src/core/state.rs 3 134
M tarmac/src/ipc/events.rs 6 49
M tarmac/src/main.rs 38 421
M tarmac/src/platform/border.rs 8 26
M tarmac/src/platform/skylight.rs 0 12
M tarmac/src/ui/mod.rs 1 2
D tarmac/src/ui/settings.rs 0 900
D tarmac/src/ui/tray.rs 0 240
.github/workflows/ci.ymlmodified
@@ -14,8 +14,6 @@ jobs:
1414
     runs-on: macos-latest
1515
     steps:
1616
       - uses: actions/checkout@v5
17
-        with:
18
-          submodules: true
1917
 
2018
       - name: Install Rust
2119
         uses: dtolnay/rust-toolchain@stable
@@ -38,7 +36,7 @@ jobs:
3836
         run: cargo test --workspace
3937
 
4038
       - name: Clippy
41
-        run: cargo clippy -p tarmac -p tarmacctl -- -D warnings -A clippy::wrong_self_convention -A clippy::too_many_arguments
39
+        run: cargo clippy --workspace -- -D warnings
4240
 
4341
       - name: Format check
44
-        run: cargo fmt -p tarmac -p tarmacctl -- --check
42
+        run: cargo fmt --all -- --check
.gitignoremodified
@@ -16,3 +16,4 @@ docs/
1616
 CLAUDE.md
1717
 .ref/
1818
 AGENTS.md
19
+nohup.out
ersmodified
@@ -1,1 +1,1 @@
1
-Subproject commit a4db4749ab94425779b7b76cf463e4c0dd2c0cc2
1
+Subproject commit 61dc57ab641933f70b9f8046e8ba0f025b17faaa
tarmac/Cargo.tomlmodified
@@ -19,33 +19,6 @@ objc2-app-kit = { version = "0.3", default-features = false, features = [
1919
     "NSGraphics",
2020
     "NSGraphicsContext",
2121
     "NSBezierPath",
22
-    "NSImage",
23
-    "NSImageRep",
24
-    "NSBitmapImageRep",
25
-    "NSMenu",
26
-    "NSMenuItem",
27
-    "NSStatusBar",
28
-    "NSStatusBarButton",
29
-    "NSStatusItem",
30
-    "NSButton",
31
-    "NSSwitch",
32
-    "NSSlider",
33
-    "NSSliderCell",
34
-    "NSTextField",
35
-    "NSText",
36
-    "NSFont",
37
-    "NSColorWell",
38
-    "NSColorSpace",
39
-    "NSPopUpButton",
40
-    "NSPopUpButtonCell",
41
-    "NSScrollView",
42
-    "NSClipView",
43
-    "NSTextView",
44
-    "NSSegmentedControl",
45
-    "NSSegmentedCell",
46
-    "NSControl",
47
-    "NSActionCell",
48
-    "NSCell",
4922
     "libc",
5023
     "block2",
5124
     "objc2-core-foundation",
tarmac/assets/tray_icon.svgdeleted
@@ -1,26 +0,0 @@
1
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
2
-  <!-- Runway strip -->
3
-  <rect x="2" y="24" width="28" height="2" rx="1" fill="black" opacity="0.6"/>
4
-  <!-- Runway markings -->
5
-  <rect x="6" y="24.5" width="3" height="1" rx="0.5" fill="black" opacity="0.3"/>
6
-  <rect x="14" y="24.5" width="3" height="1" rx="0.5" fill="black" opacity="0.3"/>
7
-  <rect x="22" y="24.5" width="3" height="1" rx="0.5" fill="black" opacity="0.3"/>
8
-  <!-- Airplane fuselage - descending at ~15 deg from left to right -->
9
-  <g transform="translate(14, 14) rotate(-12)">
10
-    <!-- Fuselage -->
11
-    <ellipse cx="0" cy="0" rx="9" ry="1.8" fill="black"/>
12
-    <!-- Nose -->
13
-    <path d="M 8.5 0 Q 11 -0.3 11 0 Q 11 0.3 8.5 0" fill="black"/>
14
-    <!-- Main wing -->
15
-    <path d="M -1 -1.5 L -4 -7.5 L 0 -7 L 4 -1.5 Z" fill="black"/>
16
-    <!-- Tail fin -->
17
-    <path d="M -7.5 -1.5 L -9.5 -5.5 L -7 -4.5 L -6 -1.5 Z" fill="black"/>
18
-    <!-- Horizontal stabilizer -->
19
-    <path d="M -7 -0.5 L -9.5 -2.5 L -7 -2 L -5.5 -0.5 Z" fill="black"/>
20
-    <!-- Landing gear -->
21
-    <line x1="-2" y1="1.8" x2="-2.5" y2="4" stroke="black" stroke-width="0.6"/>
22
-    <line x1="2" y1="1.8" x2="1.5" y2="4" stroke="black" stroke-width="0.6"/>
23
-    <circle cx="-2.5" cy="4.2" r="0.7" fill="black"/>
24
-    <circle cx="1.5" cy="4.2" r="0.7" fill="black"/>
25
-  </g>
26
-</svg>
tarmac/assets/tray_icon_16.pngdeleted
Image file changed (preview rendering wires once /raw URLs are threaded into the diff renderer).
tarmac/assets/tray_icon_32.pngdeleted
Image file changed (preview rendering wires once /raw URLs are threaded into the diff renderer).
tarmac/src/config/lua.rsmodified
@@ -332,14 +332,14 @@ fn register_gar_api(
332332
 }
333333
 
334334
 impl LuaConfig {
335
-    /// Fire all callbacks registered for a given event with string args.
336
-    /// Used for events like workspace_changed(old, new).
335
+    /// Fire all callbacks registered for a given event.
337336
     pub fn fire_event(&self, event: &str, args: &[&str]) {
338337
         let Some(lua) = &self.lua else { return };
339338
         for cb in &self.callbacks {
340339
             if cb.event == event {
341340
                 match lua.registry_value::<mlua::Function>(&cb.func_key) {
342341
                     Ok(func) => {
342
+                        // Build args as Lua strings
343343
                         let lua_args: Vec<mlua::Value> = args
344344
                             .iter()
345345
                             .filter_map(|a| lua.create_string(a).ok().map(mlua::Value::String))
@@ -355,64 +355,6 @@ impl LuaConfig {
355355
             }
356356
         }
357357
     }
358
-
359
-    /// Fire all callbacks registered for a given event with a structured data table.
360
-    /// Converts a serde_json::Value to a Lua table, enabling callbacks like:
361
-    ///   gar.on("window_focused", function(info) print(info.title) end)
362
-    pub fn fire_event_with_data(&self, event: &str, data: &serde_json::Value) {
363
-        let Some(lua) = &self.lua else { return };
364
-        for cb in &self.callbacks {
365
-            if cb.event == event {
366
-                match lua.registry_value::<mlua::Function>(&cb.func_key) {
367
-                    Ok(func) => {
368
-                        let lua_val = match json_to_lua(lua, data) {
369
-                            Ok(v) => v,
370
-                            Err(e) => {
371
-                                tracing::warn!(event, err = %e, "failed to convert event data");
372
-                                continue;
373
-                            }
374
-                        };
375
-                        if let Err(e) = func.call::<()>(lua_val) {
376
-                            tracing::warn!(event, err = %e, "callback error");
377
-                        }
378
-                    }
379
-                    Err(e) => {
380
-                        tracing::warn!(event, err = %e, "failed to retrieve callback");
381
-                    }
382
-                }
383
-            }
384
-        }
385
-    }
386
-}
387
-
388
-/// Convert a serde_json::Value to a mlua::Value.
389
-fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<mlua::Value> {
390
-    match value {
391
-        serde_json::Value::Null => Ok(mlua::Value::Nil),
392
-        serde_json::Value::Bool(b) => Ok(mlua::Value::Boolean(*b)),
393
-        serde_json::Value::Number(n) => {
394
-            if let Some(i) = n.as_i64() {
395
-                Ok(mlua::Value::Integer(i))
396
-            } else {
397
-                Ok(mlua::Value::Number(n.as_f64().unwrap_or(0.0)))
398
-            }
399
-        }
400
-        serde_json::Value::String(s) => Ok(mlua::Value::String(lua.create_string(s)?)),
401
-        serde_json::Value::Array(arr) => {
402
-            let table = lua.create_table()?;
403
-            for (i, v) in arr.iter().enumerate() {
404
-                table.set(i + 1, json_to_lua(lua, v)?)?;
405
-            }
406
-            Ok(mlua::Value::Table(table))
407
-        }
408
-        serde_json::Value::Object(map) => {
409
-            let table = lua.create_table()?;
410
-            for (k, v) in map {
411
-                table.set(k.as_str(), json_to_lua(lua, v)?)?;
412
-            }
413
-            Ok(mlua::Value::Table(table))
414
-        }
415
-    }
416358
 }
417359
 
418360
 fn parse_keybind_and_action(
@@ -740,48 +682,6 @@ pub fn default_keybinds(settings: &Settings) -> Vec<LuaKeybind> {
740682
     binds
741683
 }
742684
 
743
-/// Update a single `gar.set("key", ...)` line in a Lua config file.
744
-/// Preserves all other content (comments, bindings, rules, etc.).
745
-/// `value` should be the Lua literal: a number like `8` or a quoted string like `"#5294e2"`.
746
-pub fn update_lua_setting(path: &std::path::Path, key: &str, value: &str) -> Result<(), String> {
747
-    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
748
-
749
-    // Match: gar.set("key", <anything>) or gar.set("key", <anything>)
750
-    // The value can be a number, a quoted string, or a boolean string
751
-    let pattern = format!(
752
-        r#"(gar\.set\(\s*"{key}"\s*,\s*).+?(\s*\))"#,
753
-        key = regex::escape(key),
754
-    );
755
-    let re = regex::Regex::new(&pattern).map_err(|e| e.to_string())?;
756
-
757
-    if !re.is_match(&content) {
758
-        tracing::debug!(key, "gar.set line not found in config, skipping write-back");
759
-        return Ok(());
760
-    }
761
-
762
-    let replacement = format!("${{1}}{value}${{2}}");
763
-    let updated = re.replace(&content, replacement.as_str());
764
-
765
-    std::fs::write(path, updated.as_bytes()).map_err(|e| e.to_string())?;
766
-    tracing::debug!(key, value, "config write-back");
767
-    Ok(())
768
-}
769
-
770
-/// Format a numeric value for Lua config write-back.
771
-pub fn lua_number(v: f64) -> String {
772
-    let i = v as i64;
773
-    if (v - i as f64).abs() < 0.01 {
774
-        i.to_string()
775
-    } else {
776
-        format!("{v:.1}")
777
-    }
778
-}
779
-
780
-/// Format a string value for Lua config write-back (quoted).
781
-pub fn lua_string(s: &str) -> String {
782
-    format!("\"{s}\"")
783
-}
784
-
785685
 #[cfg(test)]
786686
 mod tests {
787687
     use super::*;
@@ -832,49 +732,4 @@ mod tests {
832732
         // 4 basic + 12 focus + 12 swap + 4 resize + 20 workspaces = 52
833733
         assert!(binds.len() >= 40);
834734
     }
835
-
836
-    #[test]
837
-    fn write_back_number() {
838
-        let dir = std::env::temp_dir().join("tarmac_test_wb");
839
-        let _ = std::fs::create_dir_all(&dir);
840
-        let path = dir.join("test.lua");
841
-        std::fs::write(&path, "gar.set(\"gap_inner\", 8)\n").unwrap();
842
-        update_lua_setting(&path, "gap_inner", "20").unwrap();
843
-        let content = std::fs::read_to_string(&path).unwrap();
844
-        assert!(
845
-            content.contains("gar.set(\"gap_inner\", 20)"),
846
-            "got: {content}"
847
-        );
848
-        let _ = std::fs::remove_dir_all(&dir);
849
-    }
850
-
851
-    #[test]
852
-    fn write_back_string() {
853
-        let dir = std::env::temp_dir().join("tarmac_test_wb2");
854
-        let _ = std::fs::create_dir_all(&dir);
855
-        let path = dir.join("test.lua");
856
-        std::fs::write(&path, "gar.set(\"border_color_focused\", \"#5294e2\")\n").unwrap();
857
-        update_lua_setting(&path, "border_color_focused", "\"#ff0000\"").unwrap();
858
-        let content = std::fs::read_to_string(&path).unwrap();
859
-        assert!(content.contains("\"#ff0000\""), "got: {content}");
860
-        let _ = std::fs::remove_dir_all(&dir);
861
-    }
862
-
863
-    #[test]
864
-    fn write_back_preserves_other_lines() {
865
-        let dir = std::env::temp_dir().join("tarmac_test_wb3");
866
-        let _ = std::fs::create_dir_all(&dir);
867
-        let path = dir.join("test.lua");
868
-        std::fs::write(
869
-            &path,
870
-            "-- comment\ngar.set(\"gap_inner\", 8)\ngar.bind(\"mod+h\", \"focus left\")\n",
871
-        )
872
-        .unwrap();
873
-        update_lua_setting(&path, "gap_inner", "12").unwrap();
874
-        let content = std::fs::read_to_string(&path).unwrap();
875
-        assert!(content.contains("-- comment"));
876
-        assert!(content.contains("gar.set(\"gap_inner\", 12)"));
877
-        assert!(content.contains("gar.bind(\"mod+h\", \"focus left\")"));
878
-        let _ = std::fs::remove_dir_all(&dir);
879
-    }
880735
 }
tarmac/src/core/state.rsmodified
@@ -317,18 +317,14 @@ impl WmState {
317317
         for (mi, monitor) in self.monitors.iter().enumerate() {
318318
             let ws = self.workspaces.get(monitor.active_workspace);
319319
             let sr = self.monitor_rect(mi);
320
-            let geoms =
321
-                ws.tree
322
-                    .calculate_geometries_with_gaps(sr, self.gap_inner, self.gap_outer, true);
320
+            let geoms = ws.tree.calculate_geometries_with_gaps(sr, self.gap_inner, self.gap_outer, true);
323321
             let focused = ws.focused;
324322
 
325323
             for (wid, rect) in &geoms {
326
-                self.borders
327
-                    .update_border(*wid, *rect, focused == Some(*wid));
324
+                self.borders.update_border(*wid, *rect, focused == Some(*wid));
328325
             }
329326
             for fw in &ws.floating {
330
-                self.borders
331
-                    .update_border(fw.id, fw.geometry, focused == Some(fw.id));
327
+                self.borders.update_border(fw.id, fw.geometry, focused == Some(fw.id));
332328
             }
333329
         }
334330
     }
@@ -2395,133 +2391,6 @@ impl WmState {
23952391
         }
23962392
     }
23972393
 
2398
-    // --- IPC event snapshot builders ---
2399
-
2400
-    /// Build a workspace snapshot for IPC events.
2401
-    pub fn workspace_snapshot(&self) -> Vec<crate::ipc::events::WorkspaceInfo> {
2402
-        self.workspaces
2403
-            .iter()
2404
-            .map(|ws| {
2405
-                let id_str = ws.id.to_string();
2406
-                let monitor = self
2407
-                    .monitors
2408
-                    .iter()
2409
-                    .position(|m| m.active_workspace == self.ws_index_by_id(&ws.id));
2410
-                crate::ipc::events::WorkspaceInfo {
2411
-                    id: id_str,
2412
-                    active: monitor.is_some(),
2413
-                    windows: ws.all_window_ids().len(),
2414
-                    monitor,
2415
-                    urgent: false,
2416
-                }
2417
-            })
2418
-            .collect()
2419
-    }
2420
-
2421
-    /// Resolve the workspace index for a WorkspaceId.
2422
-    fn ws_index_by_id(&self, id: &super::workspace::WorkspaceId) -> usize {
2423
-        self.workspaces
2424
-            .iter()
2425
-            .position(|ws| ws.id == *id)
2426
-            .unwrap_or(0)
2427
-    }
2428
-
2429
-    /// Build a WindowFocused event for the given window, or None if not found.
2430
-    pub fn window_focused_event(&self, wid: WindowId) -> Option<crate::ipc::events::WmEvent> {
2431
-        let w = self.registry.get(wid)?;
2432
-        let ws_id = self
2433
-            .workspaces
2434
-            .find_window(wid)
2435
-            .map(|idx| self.workspaces.get(idx).id.to_string())
2436
-            .unwrap_or_default();
2437
-        Some(crate::ipc::events::WmEvent::WindowFocused {
2438
-            window_id: wid,
2439
-            title: w.title.clone(),
2440
-            app_name: w.app_name.clone(),
2441
-            app_bundle: w.app_bundle_id.clone(),
2442
-            workspace: ws_id,
2443
-        })
2444
-    }
2445
-
2446
-    /// Build a WorkspaceChanged event with full snapshot.
2447
-    pub fn workspace_changed_event(&self, old: String, new: String) -> crate::ipc::events::WmEvent {
2448
-        crate::ipc::events::WmEvent::WorkspaceChanged {
2449
-            old,
2450
-            new: new.clone(),
2451
-            workspaces: self.workspace_snapshot(),
2452
-            focused_workspace: new,
2453
-            focused_monitor: self.focused_monitor,
2454
-        }
2455
-    }
2456
-
2457
-    /// Build a LayoutChanged event for the current active workspace.
2458
-    pub fn layout_changed_event(&self) -> crate::ipc::events::WmEvent {
2459
-        let ws = self.active_workspace();
2460
-        let (tiled, floating, layout_type) = Self::layout_info(ws);
2461
-        crate::ipc::events::WmEvent::LayoutChanged {
2462
-            workspace: ws.id.to_string(),
2463
-            window_count: tiled + floating,
2464
-            layout_type: layout_type.to_string(),
2465
-        }
2466
-    }
2467
-
2468
-    fn layout_info(ws: &super::workspace::Workspace) -> (usize, usize, &'static str) {
2469
-        let tiled = ws.tree.windows().len();
2470
-        let floating = ws.floating.len();
2471
-        let layout_type = if floating > 0 && tiled > 0 {
2472
-            "mixed"
2473
-        } else if floating > 0 {
2474
-            "floating"
2475
-        } else {
2476
-            "tiled"
2477
-        };
2478
-        (tiled, floating, layout_type)
2479
-    }
2480
-
2481
-    // --- Lua event data builders ---
2482
-
2483
-    /// Build JSON data for a window_focused Lua callback.
2484
-    pub fn window_focus_data(&self, wid: WindowId) -> Option<serde_json::Value> {
2485
-        let w = self.registry.get(wid)?;
2486
-        let ws_id = self
2487
-            .workspaces
2488
-            .find_window(wid)
2489
-            .map(|idx| self.workspaces.get(idx).id.to_string())
2490
-            .unwrap_or_default();
2491
-        Some(serde_json::json!({
2492
-            "window_id": wid,
2493
-            "title": w.title,
2494
-            "app_name": w.app_name,
2495
-            "app_bundle": w.app_bundle_id,
2496
-            "workspace": ws_id,
2497
-        }))
2498
-    }
2499
-
2500
-    /// Build JSON data for a window_created Lua callback.
2501
-    pub fn window_created_data(&self, wid: WindowId) -> Option<serde_json::Value> {
2502
-        self.window_focus_data(wid) // same fields
2503
-    }
2504
-
2505
-    /// Build JSON data for a layout_changed Lua callback.
2506
-    pub fn layout_changed_data(&self) -> serde_json::Value {
2507
-        let ws = self.active_workspace();
2508
-        let (tiled, floating, layout_type) = Self::layout_info(ws);
2509
-        serde_json::json!({
2510
-            "workspace": ws.id.to_string(),
2511
-            "window_count": tiled + floating,
2512
-            "layout_type": layout_type,
2513
-        })
2514
-    }
2515
-
2516
-    /// Build JSON data for a monitor_changed Lua callback.
2517
-    pub fn monitor_changed_data(&self) -> serde_json::Value {
2518
-        serde_json::json!({
2519
-            "index": self.focused_monitor,
2520
-            "monitor_count": self.monitors.len(),
2521
-            "focused_workspace": self.active_workspace().id.to_string(),
2522
-        })
2523
-    }
2524
-
25252394
     // --- Helpers ---
25262395
 
25272396
     #[allow(clippy::too_many_arguments)]
tarmac/src/ipc/events.rsmodified
@@ -2,59 +2,16 @@ use serde::Serialize;
22
 use std::sync::Mutex;
33
 use std::sync::mpsc;
44
 
5
-/// Per-workspace summary included in workspace_changed events.
6
-#[derive(Debug, Clone, Serialize)]
7
-pub struct WorkspaceInfo {
8
-    pub id: String,
9
-    pub active: bool,
10
-    pub windows: usize,
11
-    pub monitor: Option<usize>,
12
-    pub urgent: bool,
13
-}
14
-
155
 /// Events emitted by the window manager for IPC subscribers.
166
 #[derive(Debug, Clone, Serialize)]
177
 #[serde(tag = "event", content = "data")]
188
 pub enum WmEvent {
19
-    #[serde(rename = "workspace_changed")]
20
-    WorkspaceChanged {
21
-        old: String,
22
-        new: String,
23
-        workspaces: Vec<WorkspaceInfo>,
24
-        focused_workspace: String,
25
-        focused_monitor: usize,
26
-    },
27
-    #[serde(rename = "window_focused")]
28
-    WindowFocused {
29
-        window_id: u32,
30
-        title: String,
31
-        app_name: String,
32
-        app_bundle: String,
33
-        workspace: String,
34
-    },
35
-    #[serde(rename = "window_created")]
36
-    WindowCreated {
37
-        window_id: u32,
38
-        title: String,
39
-        app_name: String,
40
-        app_bundle: String,
41
-        workspace: String,
42
-    },
43
-    #[serde(rename = "window_closed")]
44
-    WindowClosed { window_id: u32, app_name: String },
45
-    #[serde(rename = "monitor_changed")]
46
-    MonitorChanged {
47
-        index: usize,
48
-        monitor_count: usize,
49
-        focused_workspace: String,
50
-    },
51
-    #[serde(rename = "layout_changed")]
52
-    LayoutChanged {
53
-        workspace: String,
54
-        window_count: usize,
55
-        layout_type: String,
56
-    },
57
-    #[serde(rename = "mode_changed")]
9
+    WorkspaceChanged { old: String, new: String },
10
+    WindowFocused { window_id: u32, app_name: String },
11
+    WindowCreated { window_id: u32, app_name: String },
12
+    WindowClosed { window_id: u32 },
13
+    MonitorChanged { index: usize },
14
+    LayoutChanged { workspace: String },
5815
     ModeChanged { mode: String },
5916
 }
6017
 
tarmac/src/main.rsmodified
@@ -18,8 +18,6 @@ thread_local! {
1818
     static LUA_CONFIG: RefCell<Option<tarmac::config::lua::LuaConfig>> = const { RefCell::new(None) };
1919
     static HOTKEY_MGR: RefCell<Option<HotkeyManager>> = const { RefCell::new(None) };
2020
     static CONFIG_PATH: RefCell<Option<std::path::PathBuf>> = const { RefCell::new(None) };
21
-    static TRAY: RefCell<Option<tarmac::ui::tray::TrayWidget>> = const { RefCell::new(None) };
22
-    static SETTINGS_WIN: RefCell<Option<tarmac::ui::settings::SettingsWindow>> = const { RefCell::new(None) };
2321
 }
2422
 
2523
 fn main() {
@@ -82,7 +80,6 @@ fn main() {
8280
         use tarmac::platform::event_tap::MouseEvent;
8381
         WM_STATE.with(|s| {
8482
             if let Some(state) = s.borrow_mut().as_mut() {
85
-                let prev_focused = state.active_workspace().focused;
8683
                 match event {
8784
                     MouseEvent::Click { x, y } => state.click_to_focus(x, y),
8885
                     MouseEvent::Moved { x, y } => {
@@ -116,18 +113,6 @@ fn main() {
116113
                     MouseEvent::RightUp { .. } => state.end_drag(),
117114
                     _ => {}
118115
                 }
119
-                // Publish focus change from FFM or click-to-focus
120
-                let new_focused = state.active_workspace().focused;
121
-                if new_focused != prev_focused
122
-                    && let Some(id) = new_focused
123
-                {
124
-                    if let Some(data) = state.window_focus_data(id) {
125
-                        fire_lua_event_data("window_focused", &data);
126
-                    }
127
-                    if let Some(evt) = state.window_focused_event(id) {
128
-                        publish_event(evt);
129
-                    }
130
-                }
131116
             }
132117
         });
133118
     }));
@@ -138,54 +123,16 @@ fn main() {
138123
             WM_STATE.with(|s| {
139124
                 if let Some(state) = s.borrow_mut().as_mut() {
140125
                     state.on_new_window_detected(pid, &owner, wid);
141
-                    if let Some(w) = state.registry.get(wid) {
142
-                        let ws_id = state
143
-                            .workspaces
144
-                            .find_window(wid)
145
-                            .map(|idx| state.workspaces.get(idx).id.to_string())
146
-                            .unwrap_or_default();
147
-                        publish_event(tarmac::ipc::events::WmEvent::WindowCreated {
148
-                            window_id: wid,
149
-                            title: w.title.clone(),
150
-                            app_name: w.app_name.clone(),
151
-                            app_bundle: w.app_bundle_id.clone(),
152
-                            workspace: ws_id,
153
-                        });
154
-                        if let Some(data) = state.window_created_data(wid) {
155
-                            fire_lua_event_data("window_created", &data);
156
-                        }
157
-                        let layout_data = state.layout_changed_data();
158
-                        publish_event(state.layout_changed_event());
159
-                        fire_lua_event_data("layout_changed", &layout_data);
160
-                    }
161126
                 }
162127
             });
163128
         }),
164129
         Box::new(move |wid, _pid| {
165
-            // Capture app_name before the window is removed from the registry
166
-            let app_name = WM_STATE.with(|s| {
167
-                s.borrow()
168
-                    .as_ref()
169
-                    .and_then(|state| state.registry.get(wid).map(|w| w.app_name.clone()))
170
-                    .unwrap_or_default()
171
-            });
172130
             WM_STATE.with(|s| {
173131
                 if let Some(state) = s.borrow_mut().as_mut() {
174132
                     state.on_window_closed(wid);
175
-                    let layout_data = state.layout_changed_data();
176
-                    publish_event(state.layout_changed_event());
177
-                    fire_lua_event_data("layout_changed", &layout_data);
178133
                 }
179134
             });
180
-            let closed_data = serde_json::json!({
181
-                "window_id": wid,
182
-                "app_name": &app_name,
183
-            });
184
-            publish_event(tarmac::ipc::events::WmEvent::WindowClosed {
185
-                window_id: wid,
186
-                app_name,
187
-            });
188
-            fire_lua_event_data("window_closed", &closed_data);
135
+            publish_event(tarmac::ipc::events::WmEvent::WindowClosed { window_id: wid });
189136
         }),
190137
         Box::new(move |pid| {
191138
             WM_STATE.with(|s| {
@@ -235,118 +182,78 @@ fn handle_action(action: Action) {
235182
         if let Some(state) = s.borrow_mut().as_mut() {
236183
             match action {
237184
                 Action::SpawnTerminal => spawn_terminal(),
238
-                Action::CloseWindow => {
239
-                    state.close_focused();
240
-                    let data = state.layout_changed_data();
241
-                    publish_event(state.layout_changed_event());
242
-                    fire_lua_event_data("layout_changed", &data);
243
-                }
185
+                Action::CloseWindow => state.close_focused(),
244186
                 Action::Focus(dir) => {
245187
                     state.focus_direction(dir);
246188
                     if let Some(id) = state.active_workspace().focused {
247
-                        if let Some(data) = state.window_focus_data(id) {
248
-                            fire_lua_event_data("window_focused", &data);
249
-                        }
250
-                        if let Some(evt) = state.window_focused_event(id) {
251
-                            publish_event(evt);
252
-                        }
189
+                        let id_str = id.to_string();
190
+                        fire_lua_event("window_focused", &[&id_str]);
191
+                        let app_name = state
192
+                            .registry
193
+                            .get(id)
194
+                            .map(|w| w.app_name.clone())
195
+                            .unwrap_or_default();
196
+                        publish_event(tarmac::ipc::events::WmEvent::WindowFocused {
197
+                            window_id: id,
198
+                            app_name,
199
+                        });
253200
                     }
254201
                 }
255
-                Action::Swap(dir) => {
256
-                    state.swap_direction(dir);
257
-                    let data = state.layout_changed_data();
258
-                    publish_event(state.layout_changed_event());
259
-                    fire_lua_event_data("layout_changed", &data);
260
-                }
261
-                Action::Resize(dir) => {
262
-                    state.resize_direction(dir);
263
-                    let data = state.layout_changed_data();
264
-                    publish_event(state.layout_changed_event());
265
-                    fire_lua_event_data("layout_changed", &data);
266
-                }
267
-                Action::Equalize => {
268
-                    state.equalize();
269
-                    let data = state.layout_changed_data();
270
-                    publish_event(state.layout_changed_event());
271
-                    fire_lua_event_data("layout_changed", &data);
272
-                }
202
+                Action::Swap(dir) => state.swap_direction(dir),
203
+                Action::Resize(dir) => state.resize_direction(dir),
204
+                Action::Equalize => state.equalize(),
273205
                 Action::Workspace(num) => {
274206
                     let old = state.active_workspace().id.to_string();
275207
                     state.switch_workspace(num);
276208
                     let new = state.active_workspace().id.to_string();
277209
                     fire_lua_event("workspace_changed", &[&old, &new]);
278
-                    publish_event(state.workspace_changed_event(old, new));
279
-                }
280
-                Action::MoveToWorkspace(num) => {
281
-                    state.move_to_workspace(num);
282
-                    let data = state.layout_changed_data();
283
-                    publish_event(state.layout_changed_event());
284
-                    fire_lua_event_data("layout_changed", &data);
210
+                    publish_event(tarmac::ipc::events::WmEvent::WorkspaceChanged {
211
+                        old: old.clone(),
212
+                        new: new.clone(),
213
+                    });
285214
                 }
215
+                Action::MoveToWorkspace(num) => state.move_to_workspace(num),
286216
                 Action::WorkspaceNext => {
287217
                     let old = state.active_workspace().id.to_string();
288218
                     state.workspace_next();
289219
                     let new = state.active_workspace().id.to_string();
290220
                     fire_lua_event("workspace_changed", &[&old, &new]);
291
-                    publish_event(state.workspace_changed_event(old, new));
221
+                    publish_event(tarmac::ipc::events::WmEvent::WorkspaceChanged {
222
+                        old: old.clone(),
223
+                        new: new.clone(),
224
+                    });
292225
                 }
293226
                 Action::WorkspacePrev => {
294227
                     let old = state.active_workspace().id.to_string();
295228
                     state.workspace_prev();
296229
                     let new = state.active_workspace().id.to_string();
297230
                     fire_lua_event("workspace_changed", &[&old, &new]);
298
-                    publish_event(state.workspace_changed_event(old, new));
299
-                }
300
-                Action::ToggleFloat => {
301
-                    state.toggle_float();
302
-                    let data = state.layout_changed_data();
303
-                    publish_event(state.layout_changed_event());
304
-                    fire_lua_event_data("layout_changed", &data);
305
-                }
306
-                Action::ToggleSpecial(ref name) => {
307
-                    state.toggle_special(name);
308
-                    let data = state.layout_changed_data();
309
-                    publish_event(state.layout_changed_event());
310
-                    fire_lua_event_data("layout_changed", &data);
311
-                }
312
-                Action::MoveToSpecial(ref name) => {
313
-                    state.move_to_special(name);
314
-                    let data = state.layout_changed_data();
315
-                    publish_event(state.layout_changed_event());
316
-                    fire_lua_event_data("layout_changed", &data);
231
+                    publish_event(tarmac::ipc::events::WmEvent::WorkspaceChanged {
232
+                        old: old.clone(),
233
+                        new: new.clone(),
234
+                    });
317235
                 }
236
+                Action::ToggleFloat => state.toggle_float(),
237
+                Action::ToggleSpecial(ref name) => state.toggle_special(name),
238
+                Action::MoveToSpecial(ref name) => state.move_to_special(name),
318239
                 Action::FocusMonitorNext => {
319240
                     state.focus_monitor_next();
320
-                    let data = state.monitor_changed_data();
321
-                    fire_lua_event_data("monitor_changed", &data);
241
+                    let mid = state.focused_monitor.to_string();
242
+                    fire_lua_event("monitor_focused", &[&mid]);
322243
                     publish_event(tarmac::ipc::events::WmEvent::MonitorChanged {
323244
                         index: state.focused_monitor,
324
-                        monitor_count: state.monitors.len(),
325
-                        focused_workspace: state.active_workspace().id.to_string(),
326245
                     });
327246
                 }
328247
                 Action::FocusMonitorPrev => {
329248
                     state.focus_monitor_prev();
330
-                    let data = state.monitor_changed_data();
331
-                    fire_lua_event_data("monitor_changed", &data);
249
+                    let mid = state.focused_monitor.to_string();
250
+                    fire_lua_event("monitor_focused", &[&mid]);
332251
                     publish_event(tarmac::ipc::events::WmEvent::MonitorChanged {
333252
                         index: state.focused_monitor,
334
-                        monitor_count: state.monitors.len(),
335
-                        focused_workspace: state.active_workspace().id.to_string(),
336253
                     });
337254
                 }
338
-                Action::MoveToMonitorNext => {
339
-                    state.move_to_monitor_next();
340
-                    let data = state.layout_changed_data();
341
-                    publish_event(state.layout_changed_event());
342
-                    fire_lua_event_data("layout_changed", &data);
343
-                }
344
-                Action::MoveToMonitorPrev => {
345
-                    state.move_to_monitor_prev();
346
-                    let data = state.layout_changed_data();
347
-                    publish_event(state.layout_changed_event());
348
-                    fire_lua_event_data("layout_changed", &data);
349
-                }
255
+                Action::MoveToMonitorNext => state.move_to_monitor_next(),
256
+                Action::MoveToMonitorPrev => state.move_to_monitor_prev(),
350257
                 Action::Reload => unreachable!("handled above"),
351258
                 Action::Exit => {
352259
                     tracing::info!("exit requested");
@@ -507,17 +414,6 @@ unsafe extern "C" fn poll_timer_callback(_timer: *const c_void) {
507414
             }
508415
         }
509416
     });
510
-
511
-    // Process tray menu actions and refresh tray state
512
-    poll_tray_actions();
513
-    TRAY.with(|t| {
514
-        if let Some(tray) = t.borrow().as_ref() {
515
-            update_tray(tray);
516
-        }
517
-    });
518
-
519
-    // Process settings window actions
520
-    poll_settings_actions();
521417
 }
522418
 
523419
 fn process_ipc_command(
@@ -541,14 +437,6 @@ fn process_ipc_command(
541437
             "focus" => {
542438
                 if let Some(dir) = request.args.first().and_then(|a| parse_dir(a)) {
543439
                     state.focus_direction(dir);
544
-                    if let Some(id) = state.active_workspace().focused {
545
-                        if let Some(data) = state.window_focus_data(id) {
546
-                            fire_lua_event_data("window_focused", &data);
547
-                        }
548
-                        if let Some(evt) = state.window_focused_event(id) {
549
-                            publish_event(evt);
550
-                        }
551
-                    }
552440
                     Response::ok_empty()
553441
                 } else {
554442
                     Response::err("usage: focus left|right|up|down")
@@ -557,9 +445,6 @@ fn process_ipc_command(
557445
             "swap" => {
558446
                 if let Some(dir) = request.args.first().and_then(|a| parse_dir(a)) {
559447
                     state.swap_direction(dir);
560
-                    let data = state.layout_changed_data();
561
-                    publish_event(state.layout_changed_event());
562
-                    fire_lua_event_data("layout_changed", &data);
563448
                     Response::ok_empty()
564449
                 } else {
565450
                     Response::err("usage: swap left|right|up|down")
@@ -568,9 +453,6 @@ fn process_ipc_command(
568453
             "resize" => {
569454
                 if let Some(dir) = request.args.first().and_then(|a| parse_dir(a)) {
570455
                     state.resize_direction(dir);
571
-                    let data = state.layout_changed_data();
572
-                    publish_event(state.layout_changed_event());
573
-                    fire_lua_event_data("layout_changed", &data);
574456
                     Response::ok_empty()
575457
                 } else {
576458
                     Response::err("usage: resize left|right|up|down")
@@ -578,25 +460,15 @@ fn process_ipc_command(
578460
             }
579461
             "close" => {
580462
                 state.close_focused();
581
-                let data = state.layout_changed_data();
582
-                publish_event(state.layout_changed_event());
583
-                fire_lua_event_data("layout_changed", &data);
584463
                 Response::ok_empty()
585464
             }
586465
             "equalize" => {
587466
                 state.equalize();
588
-                let data = state.layout_changed_data();
589
-                publish_event(state.layout_changed_event());
590
-                fire_lua_event_data("layout_changed", &data);
591467
                 Response::ok_empty()
592468
             }
593469
             "workspace" => {
594470
                 if let Some(n) = request.args.first().and_then(|a| a.parse::<u8>().ok()) {
595
-                    let old = state.active_workspace().id.to_string();
596471
                     state.switch_workspace(n);
597
-                    let new = state.active_workspace().id.to_string();
598
-                    fire_lua_event("workspace_changed", &[&old, &new]);
599
-                    publish_event(state.workspace_changed_event(old, new));
600472
                     Response::ok_empty()
601473
                 } else {
602474
                     Response::err("usage: workspace <1-10>")
@@ -605,9 +477,6 @@ fn process_ipc_command(
605477
             "move-to-workspace" => {
606478
                 if let Some(n) = request.args.first().and_then(|a| a.parse::<u8>().ok()) {
607479
                     state.move_to_workspace(n);
608
-                    let data = state.layout_changed_data();
609
-                    publish_event(state.layout_changed_event());
610
-                    fire_lua_event_data("layout_changed", &data);
611480
                     Response::ok_empty()
612481
                 } else {
613482
                     Response::err("usage: move-to-workspace <1-10>")
@@ -615,17 +484,11 @@ fn process_ipc_command(
615484
             }
616485
             "toggle-floating" => {
617486
                 state.toggle_float();
618
-                let data = state.layout_changed_data();
619
-                publish_event(state.layout_changed_event());
620
-                fire_lua_event_data("layout_changed", &data);
621487
                 Response::ok_empty()
622488
             }
623489
             "toggle-special" => {
624490
                 if let Some(name) = request.args.first() {
625491
                     state.toggle_special(name);
626
-                    let data = state.layout_changed_data();
627
-                    publish_event(state.layout_changed_event());
628
-                    fire_lua_event_data("layout_changed", &data);
629492
                     Response::ok_empty()
630493
                 } else {
631494
                     Response::err("usage: toggle-special <name>")
@@ -634,9 +497,6 @@ fn process_ipc_command(
634497
             "move-to-special" => {
635498
                 if let Some(name) = request.args.first() {
636499
                     state.move_to_special(name);
637
-                    let data = state.layout_changed_data();
638
-                    publish_event(state.layout_changed_event());
639
-                    fire_lua_event_data("layout_changed", &data);
640500
                     Response::ok_empty()
641501
                 } else {
642502
                     Response::err("usage: move-to-special <name>")
@@ -950,14 +810,6 @@ fn fire_lua_event(event: &str, args: &[&str]) {
950810
     });
951811
 }
952812
 
953
-fn fire_lua_event_data(event: &str, data: &serde_json::Value) {
954
-    LUA_CONFIG.with(|c| {
955
-        if let Some(config) = c.borrow().as_ref() {
956
-            config.fire_event_with_data(event, data);
957
-        }
958
-    });
959
-}
960
-
961813
 fn publish_event(event: tarmac::ipc::events::WmEvent) {
962814
     EVENT_BUS.with(|r| {
963815
         if let Some(bus) = r.borrow().as_ref() {
@@ -971,236 +823,6 @@ fn cleanup_socket() {
971823
     let _ = std::fs::remove_file(&path);
972824
 }
973825
 
974
-fn update_tray(tray: &tarmac::ui::tray::TrayWidget) {
975
-    WM_STATE.with(|s| {
976
-        if let Some(state) = s.borrow().as_ref() {
977
-            let active_id = state.active_workspace().id.to_string();
978
-            let workspaces: Vec<tarmac::ui::tray::TrayWorkspace> = state
979
-                .workspaces
980
-                .iter()
981
-                .map(|ws| tarmac::ui::tray::TrayWorkspace {
982
-                    id: ws.id.to_string(),
983
-                    active: ws.visible,
984
-                    windows: ws.all_window_ids().len(),
985
-                })
986
-                .collect();
987
-            tray.update(&workspaces, &active_id);
988
-        }
989
-    });
990
-}
991
-
992
-fn poll_tray_actions() {
993
-    TRAY.with(|t| {
994
-        let borrow = t.borrow();
995
-        let Some(tray) = borrow.as_ref() else { return };
996
-        let actions = tray.poll_actions();
997
-        drop(borrow);
998
-        for action in actions {
999
-            match action {
1000
-                tarmac::ui::tray::TrayAction::SwitchWorkspace(num) => {
1001
-                    handle_action(tarmac::core::input::Action::Workspace(num));
1002
-                }
1003
-                tarmac::ui::tray::TrayAction::OpenSettings => {
1004
-                    open_settings();
1005
-                }
1006
-                tarmac::ui::tray::TrayAction::Reload => {
1007
-                    reload_config();
1008
-                }
1009
-                tarmac::ui::tray::TrayAction::Quit => {
1010
-                    tracing::info!("quit from tray");
1011
-                    cleanup_socket();
1012
-                    std::process::exit(0);
1013
-                }
1014
-            }
1015
-        }
1016
-    });
1017
-}
1018
-
1019
-fn open_settings() {
1020
-    SETTINGS_WIN.with(|sw| {
1021
-        if sw.borrow().is_none() {
1022
-            let mtm = unsafe { objc2::MainThreadMarker::new_unchecked() };
1023
-            let win = tarmac::ui::settings::SettingsWindow::new(mtm);
1024
-            // Populate from current state
1025
-            WM_STATE.with(|s| {
1026
-                if let Some(state) = s.borrow().as_ref() {
1027
-                    let snap = build_settings_snapshot(state);
1028
-                    win.populate(&snap);
1029
-                }
1030
-            });
1031
-            *sw.borrow_mut() = Some(win);
1032
-        }
1033
-        if let Some(win) = sw.borrow().as_ref() {
1034
-            win.toggle();
1035
-        }
1036
-    });
1037
-}
1038
-
1039
-fn build_settings_snapshot(
1040
-    state: &tarmac::core::state::WmState,
1041
-) -> tarmac::ui::settings::SettingsSnapshot {
1042
-    use tarmac::core::input::Modifiers;
1043
-    let mod_key = LUA_CONFIG.with(|c| {
1044
-        c.borrow()
1045
-            .as_ref()
1046
-            .map(|cfg| {
1047
-                if cfg.settings.mod_key == Modifiers::OPTION {
1048
-                    "option"
1049
-                } else if cfg.settings.mod_key == Modifiers::CONTROL {
1050
-                    "control"
1051
-                } else {
1052
-                    "command"
1053
-                }
1054
-            })
1055
-            .unwrap_or("command")
1056
-            .to_string()
1057
-    });
1058
-    tarmac::ui::settings::SettingsSnapshot {
1059
-        gap_inner: state.gap_inner,
1060
-        gap_outer: state.gap_outer,
1061
-        bar_height: state.bar_height,
1062
-        border_width: state.borders.border_width,
1063
-        border_radius: state.borders.radius,
1064
-        border_color_focused: state.borders.focused_color.to_hex(),
1065
-        border_color_unfocused: state.borders.unfocused_color.to_hex(),
1066
-        focus_follows_mouse: state.focus_follows_mouse,
1067
-        mouse_follows_focus: state.mouse_follows_focus,
1068
-        mod_key,
1069
-        keybinds: LUA_CONFIG.with(|c| {
1070
-            c.borrow()
1071
-                .as_ref()
1072
-                .map(|cfg| {
1073
-                    cfg.keybinds
1074
-                        .iter()
1075
-                        .map(|kb| {
1076
-                            (
1077
-                                format!("{:?}+{:?}", kb.modifiers, kb.key),
1078
-                                format!("{:?}", kb.action),
1079
-                            )
1080
-                        })
1081
-                        .collect()
1082
-                })
1083
-                .unwrap_or_default()
1084
-        }),
1085
-        rules: LUA_CONFIG.with(|c| {
1086
-            c.borrow()
1087
-                .as_ref()
1088
-                .map(|cfg| {
1089
-                    cfg.rules
1090
-                        .iter()
1091
-                        .map(|r| {
1092
-                            let match_str = r.app_name.as_deref().unwrap_or("*").to_string();
1093
-                            let mut actions = Vec::new();
1094
-                            if let Some(true) = r.floating {
1095
-                                actions.push("float".to_string());
1096
-                            }
1097
-                            if let Some(ws) = &r.workspace {
1098
-                                actions.push(format!("workspace {ws}"));
1099
-                            }
1100
-                            (match_str, actions.join(", "))
1101
-                        })
1102
-                        .collect()
1103
-                })
1104
-                .unwrap_or_default()
1105
-        }),
1106
-    }
1107
-}
1108
-
1109
-fn poll_settings_actions() {
1110
-    use tarmac::config::lua::{lua_number, lua_string};
1111
-    use tarmac::ui::settings::SettingsAction;
1112
-
1113
-    SETTINGS_WIN.with(|sw| {
1114
-        let borrow = sw.borrow();
1115
-        let Some(win) = borrow.as_ref() else { return };
1116
-        let actions = win.poll_actions();
1117
-        if actions.is_empty() {
1118
-            return;
1119
-        }
1120
-        win.refresh_labels();
1121
-        drop(borrow);
1122
-
1123
-        let config_path = CONFIG_PATH.with(|p| p.borrow().clone());
1124
-
1125
-        WM_STATE.with(|s| {
1126
-            if let Some(state) = s.borrow_mut().as_mut() {
1127
-                for action in actions {
1128
-                    match action {
1129
-                        SettingsAction::GapInner(v) => {
1130
-                            state.gap_inner = v;
1131
-                            write_setting(&config_path, "gap_inner", &lua_number(v));
1132
-                        }
1133
-                        SettingsAction::GapOuter(v) => {
1134
-                            state.gap_outer = v;
1135
-                            write_setting(&config_path, "gap_outer", &lua_number(v));
1136
-                        }
1137
-                        SettingsAction::BarHeight(v) => {
1138
-                            state.bar_height = v;
1139
-                            write_setting(&config_path, "bar_height", &lua_number(v));
1140
-                        }
1141
-                        SettingsAction::BorderWidth(v) => {
1142
-                            state.borders.border_width = v;
1143
-                            write_setting(
1144
-                                &config_path,
1145
-                                "border_width",
1146
-                                &lua_string(&lua_number(v)),
1147
-                            );
1148
-                        }
1149
-                        SettingsAction::BorderRadius(v) => {
1150
-                            state.borders.radius = v;
1151
-                            write_setting(
1152
-                                &config_path,
1153
-                                "border_radius",
1154
-                                &lua_string(&lua_number(v)),
1155
-                            );
1156
-                        }
1157
-                        SettingsAction::BorderColorFocused(ref hex) => {
1158
-                            state.borders.focused_color =
1159
-                                tarmac::platform::border::BorderColor::from_hex(hex);
1160
-                            write_setting(&config_path, "border_color_focused", &lua_string(hex));
1161
-                        }
1162
-                        SettingsAction::BorderColorUnfocused(ref hex) => {
1163
-                            state.borders.unfocused_color =
1164
-                                tarmac::platform::border::BorderColor::from_hex(hex);
1165
-                            write_setting(&config_path, "border_color_unfocused", &lua_string(hex));
1166
-                        }
1167
-                        SettingsAction::FocusFollowsMouse(v) => {
1168
-                            state.focus_follows_mouse = v;
1169
-                            write_setting(
1170
-                                &config_path,
1171
-                                "focus_follows_mouse",
1172
-                                &lua_string(if v { "true" } else { "false" }),
1173
-                            );
1174
-                        }
1175
-                        SettingsAction::MouseFollowsFocus(v) => {
1176
-                            state.mouse_follows_focus = v;
1177
-                            write_setting(
1178
-                                &config_path,
1179
-                                "mouse_follows_focus",
1180
-                                &lua_string(if v { "true" } else { "false" }),
1181
-                            );
1182
-                        }
1183
-                        SettingsAction::ModKey(ref key) => {
1184
-                            write_setting(&config_path, "mod_key", &lua_string(key));
1185
-                        }
1186
-                        SettingsAction::Open => {}
1187
-                    }
1188
-                }
1189
-                state.apply_layout();
1190
-                state.update_borders();
1191
-            }
1192
-        });
1193
-    });
1194
-}
1195
-
1196
-fn write_setting(config_path: &Option<std::path::PathBuf>, key: &str, value: &str) {
1197
-    if let Some(path) = config_path
1198
-        && let Err(e) = tarmac::config::lua::update_lua_setting(path, key, value)
1199
-    {
1200
-        tracing::warn!(key, err = %e, "failed to write setting to config");
1201
-    }
1202
-}
1203
-
1204826
 fn run_app() {
1205827
     use objc2::MainThreadMarker;
1206828
     use objc2_app_kit::NSApplication;
@@ -1210,11 +832,6 @@ fn run_app() {
1210832
     let app = NSApplication::sharedApplication(mtm);
1211833
     app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
1212834
 
1213
-    // Initialize system tray
1214
-    let tray = tarmac::ui::tray::TrayWidget::new(mtm);
1215
-    update_tray(&tray);
1216
-    TRAY.with(|t| *t.borrow_mut() = Some(tray));
1217
-
1218835
     tracing::info!("entering main event loop");
1219836
     app.run();
1220837
 }
tarmac/src/platform/border.rsmodified
@@ -28,7 +28,7 @@ impl BorderColor {
2828
         Self { r, g, b, a }
2929
     }
3030
 
31
-    pub fn to_hex(&self) -> String {
31
+    fn to_hex(&self) -> String {
3232
         let r = (self.r * 255.0) as u8;
3333
         let g = (self.g * 255.0) as u8;
3434
         let b = (self.b * 255.0) as u8;
@@ -67,36 +67,19 @@ impl BorderManager {
6767
     }
6868
 
6969
     /// Spawn ers with current settings. Kills any existing instance first.
70
-    /// Looks for ers next to the tarmac binary first, then falls back to PATH.
7170
     pub fn spawn(&mut self) {
7271
         self.kill();
73
-        if !self.is_enabled() {
74
-            return;
75
-        }
76
-
77
-        let ers_bin = std::env::current_exe()
78
-            .ok()
79
-            .and_then(|p| p.parent().map(|d| d.join("ers")))
80
-            .filter(|p| p.exists())
81
-            .map(|p| p.to_string_lossy().to_string())
82
-            .unwrap_or_else(|| "ers".to_string());
72
+        if !self.is_enabled() { return; }
8373
 
8474
         let cmd = format!(
85
-            "{} --active-only --width {} --radius {} --color '{}' --inactive '{}'",
86
-            ers_bin,
87
-            self.border_width,
88
-            self.radius,
89
-            self.focused_color.to_hex(),
90
-            self.unfocused_color.to_hex(),
75
+            "ers --active-only --width {} --radius {} --color '{}' --inactive '{}'",
76
+            self.border_width, self.radius,
77
+            self.focused_color.to_hex(), self.unfocused_color.to_hex(),
9178
         );
9279
         tracing::debug!(cmd, "spawning ers");
9380
         match Command::new("/bin/sh").args(["-c", &cmd]).spawn() {
94
-            Ok(child) => {
95
-                self.child = Some(child);
96
-            }
97
-            Err(e) => {
98
-                tracing::warn!(err = %e, "failed to spawn ers");
99
-            }
81
+            Ok(child) => { self.child = Some(child); }
82
+            Err(e) => { tracing::warn!(err = %e, "failed to spawn ers"); }
10083
         }
10184
     }
10285
 
@@ -123,8 +106,7 @@ impl BorderManager {
123106
         _old: Option<u32>,
124107
         _new: Option<u32>,
125108
         _get_rect: impl Fn(u32) -> Option<crate::core::tree::Rect>,
126
-    ) {
127
-    }
109
+    ) {}
128110
 }
129111
 
130112
 impl Drop for BorderManager {
tarmac/src/platform/skylight.rsmodified
@@ -15,10 +15,6 @@ type CGError = i32;
1515
 pub const K_CG_NORMAL_WINDOW_LEVEL: c_int = 0;
1616
 /// Floating window level (above normal windows, below menus)
1717
 pub const K_CG_FLOATING_WINDOW_LEVEL: c_int = 3;
18
-/// Modal panel level — above all normal and floating windows.
19
-/// Used for tarmac floating windows to prevent any app activation
20
-/// from pushing them behind tiled windows.
21
-pub const K_CG_MODAL_WINDOW_LEVEL: c_int = 8;
2218
 
2319
 // SkyLight is a private framework, linked via build.rs
2420
 unsafe extern "C" {
@@ -32,7 +28,6 @@ unsafe extern "C" {
3228
         cid: CGSConnectionID,
3329
         window_list: &CFArray,
3430
     ) -> CGError;
35
-    fn SLSOrderWindow(cid: CGSConnectionID, wid: u32, mode: c_int, relative_to: u32) -> CGError;
3631
     fn SLSTransactionCreate(cid: CGSConnectionID) -> *const c_void;
3732
     fn SLSTransactionSetWindowSystemAlpha(
3833
         transaction: *const c_void,
@@ -178,10 +173,3 @@ pub fn set_window_level(window_id: u32, level: c_int) -> bool {
178173
     }
179174
     result == 0
180175
 }
181
-
182
-/// Order a window above all other windows at its level.
183
-/// mode 1 = above (kCGSOrderAbove).
184
-pub fn order_window_front(window_id: u32) {
185
-    let cid = unsafe { SLSMainConnectionID() };
186
-    unsafe { SLSOrderWindow(cid, window_id, 1, 0) };
187
-}
tarmac/src/ui/mod.rsmodified
@@ -1,2 +1,1 @@
1
-pub mod settings;
2
-pub mod tray;
1
+// UI components (tray, settings) — Sprint 14-15
tarmac/src/ui/settings.rsdeleted
@@ -1,900 +0,0 @@
1
-use std::sync::mpsc;
2
-
3
-use objc2::rc::Retained;
4
-use objc2::{
5
-    ClassType, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel,
6
-};
7
-use objc2_app_kit::{
8
-    NSBackingStoreType, NSButton, NSColorWell, NSPopUpButton, NSScrollView, NSSegmentedControl,
9
-    NSSlider, NSTextField, NSTextView, NSView, NSWindow, NSWindowStyleMask,
10
-};
11
-use objc2_core_foundation::{CGPoint, CGRect, CGSize};
12
-use objc2_foundation::{NSObject, NSString};
13
-
14
-/// Actions emitted by settings controls.
15
-#[derive(Debug)]
16
-pub enum SettingsAction {
17
-    GapInner(f64),
18
-    GapOuter(f64),
19
-    BarHeight(f64),
20
-    BorderWidth(f64),
21
-    BorderRadius(f64),
22
-    BorderColorFocused(String),
23
-    BorderColorUnfocused(String),
24
-    FocusFollowsMouse(bool),
25
-    MouseFollowsFocus(bool),
26
-    ModKey(String),
27
-    Open,
28
-}
29
-
30
-/// Current settings snapshot for populating controls.
31
-pub struct SettingsSnapshot {
32
-    pub gap_inner: f64,
33
-    pub gap_outer: f64,
34
-    pub bar_height: f64,
35
-    pub border_width: f64,
36
-    pub border_radius: f64,
37
-    pub border_color_focused: String,
38
-    pub border_color_unfocused: String,
39
-    pub focus_follows_mouse: bool,
40
-    pub mouse_follows_focus: bool,
41
-    pub mod_key: String,
42
-    pub keybinds: Vec<(String, String)>, // (key combo, action)
43
-    pub rules: Vec<(String, String)>,    // (match, action)
44
-}
45
-
46
-// --- ObjC action handler ---
47
-
48
-struct SettingsHandlerIvars {
49
-    tx: mpsc::Sender<SettingsAction>,
50
-    tab_views: std::cell::RefCell<Vec<Retained<NSView>>>,
51
-    content_container: std::cell::RefCell<Option<Retained<NSView>>>,
52
-}
53
-
54
-impl SettingsHandler {
55
-    fn new(mtm: MainThreadMarker, tx: mpsc::Sender<SettingsAction>) -> Retained<Self> {
56
-        let this = mtm.alloc().set_ivars(SettingsHandlerIvars {
57
-            tx,
58
-            tab_views: std::cell::RefCell::new(Vec::new()),
59
-            content_container: std::cell::RefCell::new(None),
60
-        });
61
-        unsafe { msg_send![super(this), init] }
62
-    }
63
-
64
-    fn emit(&self, action: SettingsAction) {
65
-        let _ = self.ivars().tx.send(action);
66
-    }
67
-
68
-    fn set_tab_views(&self, views: Vec<Retained<NSView>>, container: Retained<NSView>) {
69
-        *self.ivars().tab_views.borrow_mut() = views;
70
-        *self.ivars().content_container.borrow_mut() = Some(container);
71
-    }
72
-
73
-    fn switch_to_tab(&self, index: usize) {
74
-        let views = self.ivars().tab_views.borrow();
75
-        let container_ref = self.ivars().content_container.borrow();
76
-        let Some(container) = container_ref.as_ref() else {
77
-            return;
78
-        };
79
-        // Remove all subviews from container
80
-        let subviews = container.subviews();
81
-        for sv in subviews.iter() {
82
-            sv.removeFromSuperview();
83
-        }
84
-        // Add the selected tab view
85
-        if let Some(view) = views.get(index) {
86
-            container.addSubview(view);
87
-        }
88
-    }
89
-}
90
-
91
-define_class!(
92
-    #[unsafe(super(NSObject))]
93
-    #[thread_kind = MainThreadOnly]
94
-    #[name = "TarmacSettingsHandler"]
95
-    #[ivars = SettingsHandlerIvars]
96
-    struct SettingsHandler;
97
-
98
-    impl SettingsHandler {
99
-        #[unsafe(method(onTabChanged:))]
100
-        fn on_tab_changed(&self, sender: Option<&NSSegmentedControl>) {
101
-            if let Some(seg) = sender {
102
-                let idx = seg.selectedSegment() as usize;
103
-                self.switch_to_tab(idx);
104
-            }
105
-        }
106
-
107
-        #[unsafe(method(onGapInnerChanged:))]
108
-        fn on_gap_inner(&self, sender: Option<&NSSlider>) {
109
-            if let Some(s) = sender {
110
-                self.emit(SettingsAction::GapInner(s.doubleValue()));
111
-            }
112
-        }
113
-
114
-        #[unsafe(method(onGapOuterChanged:))]
115
-        fn on_gap_outer(&self, sender: Option<&NSSlider>) {
116
-            if let Some(s) = sender {
117
-                self.emit(SettingsAction::GapOuter(s.doubleValue()));
118
-            }
119
-        }
120
-
121
-        #[unsafe(method(onBarHeightChanged:))]
122
-        fn on_bar_height(&self, sender: Option<&NSSlider>) {
123
-            if let Some(s) = sender {
124
-                self.emit(SettingsAction::BarHeight(s.doubleValue()));
125
-            }
126
-        }
127
-
128
-        #[unsafe(method(onBorderWidthChanged:))]
129
-        fn on_border_width(&self, sender: Option<&NSSlider>) {
130
-            if let Some(s) = sender {
131
-                self.emit(SettingsAction::BorderWidth(s.doubleValue()));
132
-            }
133
-        }
134
-
135
-        #[unsafe(method(onBorderRadiusChanged:))]
136
-        fn on_border_radius(&self, sender: Option<&NSSlider>) {
137
-            if let Some(s) = sender {
138
-                self.emit(SettingsAction::BorderRadius(s.doubleValue()));
139
-            }
140
-        }
141
-
142
-        #[unsafe(method(onBorderColorFocusedChanged:))]
143
-        fn on_border_color_focused(&self, sender: Option<&NSColorWell>) {
144
-            if let Some(well) = sender {
145
-                let hex = color_well_to_hex(well);
146
-                self.emit(SettingsAction::BorderColorFocused(hex));
147
-            }
148
-        }
149
-
150
-        #[unsafe(method(onBorderColorUnfocusedChanged:))]
151
-        fn on_border_color_unfocused(&self, sender: Option<&NSColorWell>) {
152
-            if let Some(well) = sender {
153
-                let hex = color_well_to_hex(well);
154
-                self.emit(SettingsAction::BorderColorUnfocused(hex));
155
-            }
156
-        }
157
-
158
-        #[unsafe(method(onFfmToggled:))]
159
-        fn on_ffm_toggled(&self, sender: Option<&NSButton>) {
160
-            if let Some(btn) = sender {
161
-                self.emit(SettingsAction::FocusFollowsMouse(btn.state() == 1));
162
-            }
163
-        }
164
-
165
-        #[unsafe(method(onMffToggled:))]
166
-        fn on_mff_toggled(&self, sender: Option<&NSButton>) {
167
-            if let Some(btn) = sender {
168
-                self.emit(SettingsAction::MouseFollowsFocus(btn.state() == 1));
169
-            }
170
-        }
171
-
172
-        #[unsafe(method(onModKeyChanged:))]
173
-        fn on_mod_key(&self, sender: Option<&NSPopUpButton>) {
174
-            if let Some(popup) = sender {
175
-                let idx = popup.indexOfSelectedItem();
176
-                let key = match idx {
177
-                    0 => "command",
178
-                    1 => "option",
179
-                    2 => "control",
180
-                    _ => "command",
181
-                };
182
-                self.emit(SettingsAction::ModKey(key.to_string()));
183
-            }
184
-        }
185
-    }
186
-);
187
-
188
-// --- Constants ---
189
-
190
-const WIN_W: f64 = 480.0;
191
-const WIN_H: f64 = 520.0;
192
-const TAB_BAR_H: f64 = 36.0;
193
-const CONTENT_H: f64 = WIN_H - TAB_BAR_H;
194
-
195
-// --- SettingsWindow ---
196
-
197
-pub struct SettingsWindow {
198
-    window: Retained<NSWindow>,
199
-    _handler: Retained<SettingsHandler>,
200
-    action_rx: mpsc::Receiver<SettingsAction>,
201
-    // General tab controls
202
-    gap_inner_slider: Retained<NSSlider>,
203
-    gap_outer_slider: Retained<NSSlider>,
204
-    bar_height_slider: Retained<NSSlider>,
205
-    border_width_slider: Retained<NSSlider>,
206
-    border_radius_slider: Retained<NSSlider>,
207
-    focused_color_well: Retained<NSColorWell>,
208
-    unfocused_color_well: Retained<NSColorWell>,
209
-    ffm_checkbox: Retained<NSButton>,
210
-    mff_checkbox: Retained<NSButton>,
211
-    mod_key_popup: Retained<NSPopUpButton>,
212
-    gap_inner_label: Retained<NSTextField>,
213
-    gap_outer_label: Retained<NSTextField>,
214
-    bar_height_label: Retained<NSTextField>,
215
-    border_width_label: Retained<NSTextField>,
216
-    border_radius_label: Retained<NSTextField>,
217
-    // Keybindings/Rules text views for repopulating
218
-    keybinds_text: Retained<NSTextView>,
219
-    rules_text: Retained<NSTextView>,
220
-}
221
-
222
-impl SettingsWindow {
223
-    pub fn new(mtm: MainThreadMarker) -> Self {
224
-        let (tx, rx) = mpsc::channel();
225
-        let handler = SettingsHandler::new(mtm, tx);
226
-
227
-        let style = NSWindowStyleMask::Titled
228
-            | NSWindowStyleMask::Closable
229
-            | NSWindowStyleMask::Miniaturizable;
230
-
231
-        let frame = CGRect::new(CGPoint::new(200.0, 200.0), CGSize::new(WIN_W, WIN_H));
232
-        let window: Retained<NSWindow> = unsafe {
233
-            msg_send![
234
-                NSWindow::alloc(mtm),
235
-                initWithContentRect: frame,
236
-                styleMask: style,
237
-                backing: NSBackingStoreType::Buffered,
238
-                defer: false
239
-            ]
240
-        };
241
-        let title = NSString::from_str("tarmac Settings");
242
-        window.setTitle(&title);
243
-        window.center();
244
-        unsafe { window.setReleasedWhenClosed(false) };
245
-
246
-        // Root content view
247
-        let root: Retained<NSView> = unsafe { msg_send![NSView::alloc(mtm), initWithFrame: frame] };
248
-        window.setContentView(Some(&root));
249
-
250
-        // Segmented control (tab bar) at top
251
-        let seg_frame = CGRect::new(
252
-            CGPoint::new(20.0, WIN_H - TAB_BAR_H + 4.0),
253
-            CGSize::new(WIN_W - 40.0, 24.0),
254
-        );
255
-        let seg: Retained<NSSegmentedControl> =
256
-            unsafe { msg_send![NSSegmentedControl::alloc(mtm), initWithFrame: seg_frame] };
257
-        seg.setSegmentCount(4);
258
-        set_segment_label(&seg, 0, "General");
259
-        set_segment_label(&seg, 1, "Keybindings");
260
-        set_segment_label(&seg, 2, "Rules");
261
-        set_segment_label(&seg, 3, "About");
262
-        for i in 0..4 {
263
-            seg.setWidth_forSegment(100.0, i);
264
-        }
265
-        seg.setSelectedSegment(0);
266
-        unsafe {
267
-            seg.setTarget(Some(&*handler));
268
-            seg.setAction(Some(sel!(onTabChanged:)));
269
-        }
270
-        root.addSubview(&seg);
271
-
272
-        // Content container (below tab bar)
273
-        let container_frame = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(WIN_W, CONTENT_H));
274
-        let container: Retained<NSView> =
275
-            unsafe { msg_send![NSView::alloc(mtm), initWithFrame: container_frame] };
276
-        root.addSubview(&container);
277
-
278
-        // Build tab views
279
-        let general = build_general_view(mtm, &handler);
280
-        let (keybinds_view, keybinds_text) = build_text_tab(mtm, "No keybindings loaded.");
281
-        let (rules_view, rules_text) = build_text_tab(mtm, "No rules loaded.");
282
-        let about_view = build_about_view(mtm);
283
-
284
-        // Register tab views with handler for switching
285
-        handler.set_tab_views(
286
-            vec![general.view.clone(), keybinds_view, rules_view, about_view],
287
-            container.clone(),
288
-        );
289
-
290
-        // Show General tab initially
291
-        container.addSubview(&general.view);
292
-
293
-        Self {
294
-            window,
295
-            _handler: handler,
296
-            action_rx: rx,
297
-            gap_inner_slider: general.gap_inner_slider,
298
-            gap_outer_slider: general.gap_outer_slider,
299
-            bar_height_slider: general.bar_height_slider,
300
-            border_width_slider: general.border_width_slider,
301
-            border_radius_slider: general.border_radius_slider,
302
-            focused_color_well: general.focused_color_well,
303
-            unfocused_color_well: general.unfocused_color_well,
304
-            ffm_checkbox: general.ffm_checkbox,
305
-            mff_checkbox: general.mff_checkbox,
306
-            mod_key_popup: general.mod_key_popup,
307
-            gap_inner_label: general.gap_inner_label,
308
-            gap_outer_label: general.gap_outer_label,
309
-            bar_height_label: general.bar_height_label,
310
-            border_width_label: general.border_width_label,
311
-            border_radius_label: general.border_radius_label,
312
-            keybinds_text,
313
-            rules_text,
314
-        }
315
-    }
316
-
317
-    pub fn toggle(&self) {
318
-        if self.window.isVisible() {
319
-            self.window.orderOut(None);
320
-        } else {
321
-            self.window.makeKeyAndOrderFront(None);
322
-        }
323
-    }
324
-
325
-    pub fn show(&self) {
326
-        self.window.makeKeyAndOrderFront(None);
327
-    }
328
-
329
-    pub fn populate(&self, snap: &SettingsSnapshot) {
330
-        // General tab
331
-        self.gap_inner_slider.setDoubleValue(snap.gap_inner);
332
-        self.gap_outer_slider.setDoubleValue(snap.gap_outer);
333
-        self.bar_height_slider.setDoubleValue(snap.bar_height);
334
-        self.border_width_slider.setDoubleValue(snap.border_width);
335
-        self.border_radius_slider.setDoubleValue(snap.border_radius);
336
-
337
-        set_value_label(&self.gap_inner_label, snap.gap_inner);
338
-        set_value_label(&self.gap_outer_label, snap.gap_outer);
339
-        set_value_label(&self.bar_height_label, snap.bar_height);
340
-        set_value_label(&self.border_width_label, snap.border_width);
341
-        set_value_label(&self.border_radius_label, snap.border_radius);
342
-
343
-        set_color_well(&self.focused_color_well, &snap.border_color_focused);
344
-        set_color_well(&self.unfocused_color_well, &snap.border_color_unfocused);
345
-
346
-        self.ffm_checkbox
347
-            .setState(if snap.focus_follows_mouse { 1 } else { 0 });
348
-        self.mff_checkbox
349
-            .setState(if snap.mouse_follows_focus { 1 } else { 0 });
350
-
351
-        let mod_idx: isize = match snap.mod_key.as_str() {
352
-            "command" | "cmd" => 0,
353
-            "option" | "alt" => 1,
354
-            "control" | "ctrl" => 2,
355
-            _ => 0,
356
-        };
357
-        self.mod_key_popup.selectItemAtIndex(mod_idx);
358
-
359
-        // Keybindings tab
360
-        let mut kb_text = String::new();
361
-        for (keys, action) in &snap.keybinds {
362
-            kb_text.push_str(&format!("{:<28} {}\n", keys, action));
363
-        }
364
-        if kb_text.is_empty() {
365
-            kb_text.push_str("No keybindings configured.");
366
-        }
367
-        set_text_view(&self.keybinds_text, &kb_text);
368
-
369
-        // Rules tab
370
-        let mut rules_text = String::new();
371
-        for (match_str, action_str) in &snap.rules {
372
-            rules_text.push_str(&format!("{:<28} {}\n", match_str, action_str));
373
-        }
374
-        if rules_text.is_empty() {
375
-            rules_text.push_str("No window rules configured.");
376
-        }
377
-        set_text_view(&self.rules_text, &rules_text);
378
-    }
379
-
380
-    pub fn poll_actions(&self) -> Vec<SettingsAction> {
381
-        let mut actions = Vec::new();
382
-        while let Ok(action) = self.action_rx.try_recv() {
383
-            actions.push(action);
384
-        }
385
-        actions
386
-    }
387
-
388
-    pub fn refresh_labels(&self) {
389
-        set_value_label(&self.gap_inner_label, self.gap_inner_slider.doubleValue());
390
-        set_value_label(&self.gap_outer_label, self.gap_outer_slider.doubleValue());
391
-        set_value_label(&self.bar_height_label, self.bar_height_slider.doubleValue());
392
-        set_value_label(
393
-            &self.border_width_label,
394
-            self.border_width_slider.doubleValue(),
395
-        );
396
-        set_value_label(
397
-            &self.border_radius_label,
398
-            self.border_radius_slider.doubleValue(),
399
-        );
400
-    }
401
-}
402
-
403
-// --- General tab builder ---
404
-
405
-struct GeneralTab {
406
-    view: Retained<NSView>,
407
-    gap_inner_slider: Retained<NSSlider>,
408
-    gap_outer_slider: Retained<NSSlider>,
409
-    bar_height_slider: Retained<NSSlider>,
410
-    border_width_slider: Retained<NSSlider>,
411
-    border_radius_slider: Retained<NSSlider>,
412
-    focused_color_well: Retained<NSColorWell>,
413
-    unfocused_color_well: Retained<NSColorWell>,
414
-    ffm_checkbox: Retained<NSButton>,
415
-    mff_checkbox: Retained<NSButton>,
416
-    mod_key_popup: Retained<NSPopUpButton>,
417
-    gap_inner_label: Retained<NSTextField>,
418
-    gap_outer_label: Retained<NSTextField>,
419
-    bar_height_label: Retained<NSTextField>,
420
-    border_width_label: Retained<NSTextField>,
421
-    border_radius_label: Retained<NSTextField>,
422
-}
423
-
424
-fn build_general_view(mtm: MainThreadMarker, handler: &SettingsHandler) -> GeneralTab {
425
-    let frame = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(WIN_W, CONTENT_H));
426
-    let view: Retained<NSView> = unsafe { msg_send![NSView::alloc(mtm), initWithFrame: frame] };
427
-
428
-    let lx = 20.0_f64;
429
-    let cx = 180.0_f64;
430
-    let cw = 200.0_f64;
431
-    let row = 32.0_f64;
432
-    let mut y = CONTENT_H - 30.0;
433
-
434
-    // Layout
435
-    add_section_label(mtm, &view, "Layout", lx, y);
436
-    y -= row;
437
-    let gap_inner_label = add_value_label(mtm, &view, "0", cx + cw + 8.0, y);
438
-    let gap_inner_slider = add_slider(
439
-        mtm,
440
-        &view,
441
-        handler,
442
-        "Inner Gap",
443
-        lx,
444
-        cx,
445
-        y,
446
-        cw,
447
-        0.0,
448
-        50.0,
449
-        0.0,
450
-        sel!(onGapInnerChanged:),
451
-    );
452
-    y -= row;
453
-    let gap_outer_label = add_value_label(mtm, &view, "0", cx + cw + 8.0, y);
454
-    let gap_outer_slider = add_slider(
455
-        mtm,
456
-        &view,
457
-        handler,
458
-        "Outer Gap",
459
-        lx,
460
-        cx,
461
-        y,
462
-        cw,
463
-        0.0,
464
-        50.0,
465
-        0.0,
466
-        sel!(onGapOuterChanged:),
467
-    );
468
-    y -= row;
469
-    let bar_height_label = add_value_label(mtm, &view, "0", cx + cw + 8.0, y);
470
-    let bar_height_slider = add_slider(
471
-        mtm,
472
-        &view,
473
-        handler,
474
-        "Bar Height",
475
-        lx,
476
-        cx,
477
-        y,
478
-        cw,
479
-        0.0,
480
-        60.0,
481
-        0.0,
482
-        sel!(onBarHeightChanged:),
483
-    );
484
-    y -= row + 12.0;
485
-
486
-    // Borders
487
-    add_section_label(mtm, &view, "Borders", lx, y);
488
-    y -= row;
489
-    let border_width_label = add_value_label(mtm, &view, "0", cx + cw + 8.0, y);
490
-    let border_width_slider = add_slider(
491
-        mtm,
492
-        &view,
493
-        handler,
494
-        "Width",
495
-        lx,
496
-        cx,
497
-        y,
498
-        cw,
499
-        0.0,
500
-        10.0,
501
-        0.0,
502
-        sel!(onBorderWidthChanged:),
503
-    );
504
-    y -= row;
505
-    let border_radius_label = add_value_label(mtm, &view, "0", cx + cw + 8.0, y);
506
-    let border_radius_slider = add_slider(
507
-        mtm,
508
-        &view,
509
-        handler,
510
-        "Radius",
511
-        lx,
512
-        cx,
513
-        y,
514
-        cw,
515
-        0.0,
516
-        30.0,
517
-        10.0,
518
-        sel!(onBorderRadiusChanged:),
519
-    );
520
-    y -= row;
521
-    add_label(mtm, &view, "Focused Color", lx, y);
522
-    let focused_color_well = add_color_well(
523
-        mtm,
524
-        &view,
525
-        handler,
526
-        cx,
527
-        y,
528
-        sel!(onBorderColorFocusedChanged:),
529
-    );
530
-    y -= row;
531
-    add_label(mtm, &view, "Unfocused Color", lx, y);
532
-    let unfocused_color_well = add_color_well(
533
-        mtm,
534
-        &view,
535
-        handler,
536
-        cx,
537
-        y,
538
-        sel!(onBorderColorUnfocusedChanged:),
539
-    );
540
-    y -= row + 12.0;
541
-
542
-    // Behavior
543
-    add_section_label(mtm, &view, "Behavior", lx, y);
544
-    y -= row;
545
-    let ffm_checkbox = add_checkbox(
546
-        mtm,
547
-        &view,
548
-        handler,
549
-        "Focus follows mouse",
550
-        lx,
551
-        y,
552
-        sel!(onFfmToggled:),
553
-    );
554
-    y -= row;
555
-    let mff_checkbox = add_checkbox(
556
-        mtm,
557
-        &view,
558
-        handler,
559
-        "Mouse follows focus",
560
-        lx,
561
-        y,
562
-        sel!(onMffToggled:),
563
-    );
564
-    y -= row + 12.0;
565
-
566
-    // Modifier Key
567
-    add_section_label(mtm, &view, "Modifier Key", lx, y);
568
-    y -= row;
569
-    let mod_key_popup = add_popup(
570
-        mtm,
571
-        &view,
572
-        handler,
573
-        lx,
574
-        y,
575
-        cw,
576
-        &["Command", "Option", "Control"],
577
-        sel!(onModKeyChanged:),
578
-    );
579
-    let _ = y;
580
-
581
-    GeneralTab {
582
-        view,
583
-        gap_inner_slider,
584
-        gap_outer_slider,
585
-        bar_height_slider,
586
-        border_width_slider,
587
-        border_radius_slider,
588
-        focused_color_well,
589
-        unfocused_color_well,
590
-        ffm_checkbox,
591
-        mff_checkbox,
592
-        mod_key_popup,
593
-        gap_inner_label,
594
-        gap_outer_label,
595
-        bar_height_label,
596
-        border_width_label,
597
-        border_radius_label,
598
-    }
599
-}
600
-
601
-// --- Text-based tabs (Keybindings, Rules) ---
602
-
603
-fn build_text_tab(
604
-    mtm: MainThreadMarker,
605
-    placeholder: &str,
606
-) -> (Retained<NSView>, Retained<NSTextView>) {
607
-    let frame = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(WIN_W, CONTENT_H));
608
-    let view: Retained<NSView> = unsafe { msg_send![NSView::alloc(mtm), initWithFrame: frame] };
609
-
610
-    let scroll_frame = CGRect::new(
611
-        CGPoint::new(15.0, 15.0),
612
-        CGSize::new(WIN_W - 30.0, CONTENT_H - 30.0),
613
-    );
614
-    let scroll: Retained<NSScrollView> =
615
-        unsafe { msg_send![NSScrollView::alloc(mtm), initWithFrame: scroll_frame] };
616
-    scroll.setHasVerticalScroller(true);
617
-    scroll.setBorderType(objc2_app_kit::NSBorderType(2)); // NSBezelBorder
618
-
619
-    let text_frame = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(WIN_W - 50.0, CONTENT_H));
620
-    let text: Retained<NSTextView> =
621
-        unsafe { msg_send![NSTextView::alloc(mtm), initWithFrame: text_frame] };
622
-    text.setEditable(false);
623
-    unsafe {
624
-        let mono: Retained<objc2_app_kit::NSFont> = msg_send![objc2_app_kit::NSFont::class(), monospacedSystemFontOfSize: 11.0_f64, weight: 0.0_f64];
625
-        text.setFont(Some(&mono));
626
-    }
627
-    set_text_view(&text, placeholder);
628
-
629
-    scroll.setDocumentView(Some(&text));
630
-    view.addSubview(&scroll);
631
-
632
-    (view, text)
633
-}
634
-
635
-fn set_text_view(tv: &NSTextView, content: &str) {
636
-    let ns = NSString::from_str(content);
637
-    tv.setString(&ns);
638
-}
639
-
640
-// --- About tab ---
641
-
642
-fn build_about_view(mtm: MainThreadMarker) -> Retained<NSView> {
643
-    let frame = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(WIN_W, CONTENT_H));
644
-    let view: Retained<NSView> = unsafe { msg_send![NSView::alloc(mtm), initWithFrame: frame] };
645
-
646
-    let center_x = WIN_W / 2.0 - 150.0;
647
-    let mut y = CONTENT_H - 60.0;
648
-
649
-    // App name
650
-    let name_frame = CGRect::new(CGPoint::new(center_x, y), CGSize::new(300.0, 28.0));
651
-    let name: Retained<NSTextField> =
652
-        unsafe { msg_send![NSTextField::alloc(mtm), initWithFrame: name_frame] };
653
-    name.setStringValue(&NSString::from_str("tarmac"));
654
-    name.setEditable(false);
655
-    name.setBordered(false);
656
-    name.setDrawsBackground(false);
657
-    unsafe {
658
-        let _: () = msg_send![&*name, setAlignment: 1_isize]; // NSTextAlignmentCenter
659
-        let font: Retained<objc2_app_kit::NSFont> =
660
-            msg_send![objc2_app_kit::NSFont::class(), boldSystemFontOfSize: 24.0_f64];
661
-        name.setFont(Some(&font));
662
-    }
663
-    view.addSubview(&name);
664
-    y -= 28.0;
665
-
666
-    // Subtitle
667
-    add_centered_label(mtm, &view, "a macOS tiling window manager", center_x, y);
668
-    y -= 36.0;
669
-
670
-    // Version
671
-    let version = format!("Version {}", env!("CARGO_PKG_VERSION"));
672
-    add_centered_label(mtm, &view, &version, center_x, y);
673
-    y -= 28.0;
674
-
675
-    // Build info
676
-    add_centered_label(
677
-        mtm,
678
-        &view,
679
-        "Rust 2024 edition · objc2 + SkyLight",
680
-        center_x,
681
-        y,
682
-    );
683
-    y -= 36.0;
684
-
685
-    // Links
686
-    add_centered_label(mtm, &view, "github.com/gardesk/tarmac", center_x, y);
687
-    y -= 28.0;
688
-    add_centered_label(mtm, &view, "config: ~/.config/tarmac/init.lua", center_x, y);
689
-    let _ = y;
690
-
691
-    view
692
-}
693
-
694
-fn add_centered_label(mtm: MainThreadMarker, parent: &NSView, text: &str, x: f64, y: f64) {
695
-    let frame = CGRect::new(CGPoint::new(x, y), CGSize::new(300.0, 20.0));
696
-    let label: Retained<NSTextField> =
697
-        unsafe { msg_send![NSTextField::alloc(mtm), initWithFrame: frame] };
698
-    label.setStringValue(&NSString::from_str(text));
699
-    label.setEditable(false);
700
-    label.setBordered(false);
701
-    label.setDrawsBackground(false);
702
-    unsafe {
703
-        let _: () = msg_send![&*label, setAlignment: 1_isize]; // NSTextAlignmentCenter
704
-    }
705
-    parent.addSubview(&label);
706
-}
707
-
708
-// --- Segment helper ---
709
-
710
-fn set_segment_label(seg: &NSSegmentedControl, index: isize, label: &str) {
711
-    let ns = NSString::from_str(label);
712
-    seg.setLabel_forSegment(&ns, index);
713
-}
714
-
715
-// --- Shared control helpers ---
716
-
717
-fn add_section_label(mtm: MainThreadMarker, parent: &NSView, text: &str, x: f64, y: f64) {
718
-    let frame = CGRect::new(CGPoint::new(x, y), CGSize::new(360.0, 20.0));
719
-    let label: Retained<NSTextField> =
720
-        unsafe { msg_send![NSTextField::alloc(mtm), initWithFrame: frame] };
721
-    label.setStringValue(&NSString::from_str(text));
722
-    label.setEditable(false);
723
-    label.setBordered(false);
724
-    label.setDrawsBackground(false);
725
-    unsafe {
726
-        let bold: Retained<objc2_app_kit::NSFont> =
727
-            msg_send![objc2_app_kit::NSFont::class(), boldSystemFontOfSize: 13.0_f64];
728
-        label.setFont(Some(&bold));
729
-    }
730
-    parent.addSubview(&label);
731
-}
732
-
733
-fn add_label(mtm: MainThreadMarker, parent: &NSView, text: &str, x: f64, y: f64) {
734
-    let frame = CGRect::new(CGPoint::new(x, y + 2.0), CGSize::new(150.0, 18.0));
735
-    let label: Retained<NSTextField> =
736
-        unsafe { msg_send![NSTextField::alloc(mtm), initWithFrame: frame] };
737
-    label.setStringValue(&NSString::from_str(text));
738
-    label.setEditable(false);
739
-    label.setBordered(false);
740
-    label.setDrawsBackground(false);
741
-    parent.addSubview(&label);
742
-}
743
-
744
-fn add_value_label(
745
-    mtm: MainThreadMarker,
746
-    parent: &NSView,
747
-    text: &str,
748
-    x: f64,
749
-    y: f64,
750
-) -> Retained<NSTextField> {
751
-    let frame = CGRect::new(CGPoint::new(x, y + 2.0), CGSize::new(30.0, 18.0));
752
-    let label: Retained<NSTextField> =
753
-        unsafe { msg_send![NSTextField::alloc(mtm), initWithFrame: frame] };
754
-    label.setStringValue(&NSString::from_str(text));
755
-    label.setEditable(false);
756
-    label.setBordered(false);
757
-    label.setDrawsBackground(false);
758
-    parent.addSubview(&label);
759
-    label
760
-}
761
-
762
-fn set_value_label(label: &NSTextField, val: f64) {
763
-    let ns = NSString::from_str(&format!("{}", val as i32));
764
-    label.setStringValue(&ns);
765
-}
766
-
767
-#[allow(clippy::too_many_arguments)]
768
-fn add_slider(
769
-    mtm: MainThreadMarker,
770
-    parent: &NSView,
771
-    handler: &SettingsHandler,
772
-    label_text: &str,
773
-    label_x: f64,
774
-    control_x: f64,
775
-    y: f64,
776
-    width: f64,
777
-    min: f64,
778
-    max: f64,
779
-    initial: f64,
780
-    action: objc2::runtime::Sel,
781
-) -> Retained<NSSlider> {
782
-    add_label(mtm, parent, label_text, label_x, y);
783
-    let frame = CGRect::new(CGPoint::new(control_x, y), CGSize::new(width, 20.0));
784
-    let slider: Retained<NSSlider> =
785
-        unsafe { msg_send![NSSlider::alloc(mtm), initWithFrame: frame] };
786
-    slider.setMinValue(min);
787
-    slider.setMaxValue(max);
788
-    slider.setDoubleValue(initial);
789
-    unsafe {
790
-        slider.setTarget(Some(handler));
791
-        slider.setAction(Some(action));
792
-        slider.setContinuous(true);
793
-    }
794
-    parent.addSubview(&slider);
795
-    slider
796
-}
797
-
798
-fn add_color_well(
799
-    mtm: MainThreadMarker,
800
-    parent: &NSView,
801
-    handler: &SettingsHandler,
802
-    x: f64,
803
-    y: f64,
804
-    action: objc2::runtime::Sel,
805
-) -> Retained<NSColorWell> {
806
-    let frame = CGRect::new(CGPoint::new(x, y), CGSize::new(44.0, 24.0));
807
-    let well: Retained<NSColorWell> =
808
-        unsafe { msg_send![NSColorWell::alloc(mtm), initWithFrame: frame] };
809
-    unsafe {
810
-        well.setTarget(Some(handler));
811
-        well.setAction(Some(action));
812
-    }
813
-    parent.addSubview(&well);
814
-    well
815
-}
816
-
817
-fn add_checkbox(
818
-    mtm: MainThreadMarker,
819
-    parent: &NSView,
820
-    handler: &SettingsHandler,
821
-    title: &str,
822
-    x: f64,
823
-    y: f64,
824
-    action: objc2::runtime::Sel,
825
-) -> Retained<NSButton> {
826
-    let frame = CGRect::new(CGPoint::new(x, y), CGSize::new(250.0, 22.0));
827
-    let btn: Retained<NSButton> = unsafe { msg_send![NSButton::alloc(mtm), initWithFrame: frame] };
828
-    btn.setTitle(&NSString::from_str(title));
829
-    unsafe {
830
-        let _: () = msg_send![&*btn, setButtonType: 3_isize]; // NSSwitchButton
831
-        btn.setTarget(Some(handler));
832
-        btn.setAction(Some(action));
833
-    }
834
-    parent.addSubview(&btn);
835
-    btn
836
-}
837
-
838
-fn add_popup(
839
-    mtm: MainThreadMarker,
840
-    parent: &NSView,
841
-    handler: &SettingsHandler,
842
-    x: f64,
843
-    y: f64,
844
-    width: f64,
845
-    items: &[&str],
846
-    action: objc2::runtime::Sel,
847
-) -> Retained<NSPopUpButton> {
848
-    let frame = CGRect::new(CGPoint::new(x, y), CGSize::new(width, 26.0));
849
-    let popup: Retained<NSPopUpButton> =
850
-        unsafe { msg_send![NSPopUpButton::alloc(mtm), initWithFrame: frame, pullsDown: false] };
851
-    for item in items {
852
-        popup.addItemWithTitle(&NSString::from_str(item));
853
-    }
854
-    unsafe {
855
-        popup.setTarget(Some(handler));
856
-        popup.setAction(Some(action));
857
-    }
858
-    parent.addSubview(&popup);
859
-    popup
860
-}
861
-
862
-fn color_well_to_hex(well: &NSColorWell) -> String {
863
-    unsafe {
864
-        let color = well.color();
865
-        let rgb: Option<Retained<objc2_app_kit::NSColor>> = msg_send![
866
-            &*color,
867
-            colorUsingColorSpaceName: &*NSString::from_str("NSCalibratedRGBColorSpace")
868
-        ];
869
-        if let Some(rgb) = rgb {
870
-            let r: f64 = msg_send![&*rgb, redComponent];
871
-            let g: f64 = msg_send![&*rgb, greenComponent];
872
-            let b: f64 = msg_send![&*rgb, blueComponent];
873
-            format!(
874
-                "#{:02x}{:02x}{:02x}",
875
-                (r * 255.0) as u8,
876
-                (g * 255.0) as u8,
877
-                (b * 255.0) as u8
878
-            )
879
-        } else {
880
-            "#000000".to_string()
881
-        }
882
-    }
883
-}
884
-
885
-fn set_color_well(well: &NSColorWell, hex: &str) {
886
-    let hex = hex.trim_start_matches('#');
887
-    if hex.len() < 6 {
888
-        return;
889
-    }
890
-    let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0) as f64 / 255.0;
891
-    let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0) as f64 / 255.0;
892
-    let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0) as f64 / 255.0;
893
-    unsafe {
894
-        let color: Retained<objc2_app_kit::NSColor> = msg_send![
895
-            objc2_app_kit::NSColor::class(),
896
-            colorWithCalibratedRed: r, green: g, blue: b, alpha: 1.0_f64
897
-        ];
898
-        well.setColor(&color);
899
-    }
900
-}
tarmac/src/ui/tray.rsdeleted
@@ -1,240 +0,0 @@
1
-use std::sync::mpsc;
2
-
3
-use objc2::rc::Retained;
4
-use objc2::runtime::AnyObject;
5
-use objc2::{
6
-    AnyThread, ClassType, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send,
7
-    sel,
8
-};
9
-use objc2_app_kit::{
10
-    NSImage, NSMenu, NSMenuItem, NSStatusBar, NSStatusItem, NSVariableStatusItemLength,
11
-};
12
-use objc2_foundation::{NSData, NSObject, NSSize, NSString};
13
-
14
-/// Embedded 32x32 PNG template image (airplane landing on strip).
15
-const TRAY_ICON_PNG: &[u8] = include_bytes!("../../assets/tray_icon_32.png");
16
-
17
-/// Actions dispatched from tray menu items.
18
-#[derive(Debug)]
19
-pub enum TrayAction {
20
-    SwitchWorkspace(u8),
21
-    OpenSettings,
22
-    Reload,
23
-    Quit,
24
-}
25
-
26
-/// Workspace info for building the tray menu.
27
-pub struct TrayWorkspace {
28
-    pub id: String,
29
-    pub active: bool,
30
-    pub windows: usize,
31
-}
32
-
33
-// --- MenuActionHandler: ObjC target for menu item selectors ---
34
-
35
-struct TrayHandlerIvars {
36
-    tx: mpsc::Sender<TrayAction>,
37
-}
38
-
39
-impl TrayHandler {
40
-    fn new(mtm: MainThreadMarker, tx: mpsc::Sender<TrayAction>) -> Retained<Self> {
41
-        let this = mtm.alloc().set_ivars(TrayHandlerIvars { tx });
42
-        unsafe { msg_send![super(this), init] }
43
-    }
44
-
45
-    fn emit(&self, action: TrayAction) {
46
-        let _ = self.ivars().tx.send(action);
47
-    }
48
-}
49
-
50
-define_class!(
51
-    #[unsafe(super(NSObject))]
52
-    #[thread_kind = MainThreadOnly]
53
-    #[name = "TarmacTrayHandler"]
54
-    #[ivars = TrayHandlerIvars]
55
-    struct TrayHandler;
56
-
57
-    impl TrayHandler {
58
-        #[unsafe(method(onSwitchWorkspace:))]
59
-        fn on_switch_workspace(&self, sender: Option<&NSMenuItem>) {
60
-            if let Some(sender) = sender {
61
-                let tag = sender.tag();
62
-                if tag > 0 {
63
-                    self.emit(TrayAction::SwitchWorkspace(tag as u8));
64
-                }
65
-            }
66
-        }
67
-
68
-        #[unsafe(method(onOpenSettings:))]
69
-        fn on_open_settings(&self, _sender: Option<&AnyObject>) {
70
-            self.emit(TrayAction::OpenSettings);
71
-        }
72
-
73
-        #[unsafe(method(onReload:))]
74
-        fn on_reload(&self, _sender: Option<&AnyObject>) {
75
-            self.emit(TrayAction::Reload);
76
-        }
77
-
78
-        #[unsafe(method(onQuit:))]
79
-        fn on_quit(&self, _sender: Option<&AnyObject>) {
80
-            self.emit(TrayAction::Quit);
81
-        }
82
-    }
83
-);
84
-
85
-// --- TrayWidget ---
86
-
87
-pub struct TrayWidget {
88
-    _status_item: Retained<NSStatusItem>,
89
-    handler: Retained<TrayHandler>,
90
-    mtm: MainThreadMarker,
91
-    action_rx: mpsc::Receiver<TrayAction>,
92
-}
93
-
94
-impl TrayWidget {
95
-    pub fn new(mtm: MainThreadMarker) -> Self {
96
-        let status_bar = NSStatusBar::systemStatusBar();
97
-        let status_item = status_bar.statusItemWithLength(NSVariableStatusItemLength);
98
-
99
-        // Set template image
100
-        if let Some(btn) = status_item.button(mtm) {
101
-            let image = load_template_image();
102
-            btn.setImage(Some(&image));
103
-        }
104
-
105
-        let (tx, rx) = mpsc::channel();
106
-        let handler = TrayHandler::new(mtm, tx);
107
-
108
-        // Build initial empty menu
109
-        let menu = build_menu(mtm, &handler, &[], "–");
110
-        status_item.setMenu(Some(&menu));
111
-        status_item.setVisible(true);
112
-
113
-        Self {
114
-            _status_item: status_item,
115
-            handler,
116
-            mtm,
117
-            action_rx: rx,
118
-        }
119
-    }
120
-
121
-    /// Rebuild the tray menu with current workspace state.
122
-    pub fn update(&self, workspaces: &[TrayWorkspace], active_id: &str) {
123
-        let menu = build_menu(self.mtm, &self.handler, workspaces, active_id);
124
-        self._status_item.setMenu(Some(&menu));
125
-
126
-        // Update button title to show active workspace
127
-        if let Some(btn) = self._status_item.button(self.mtm) {
128
-            let title = NSString::from_str(active_id);
129
-            btn.setTitle(&title);
130
-        }
131
-    }
132
-
133
-    /// Drain any pending menu actions. Call from the main thread polling timer.
134
-    pub fn poll_actions(&self) -> Vec<TrayAction> {
135
-        let mut actions = Vec::new();
136
-        while let Ok(action) = self.action_rx.try_recv() {
137
-            actions.push(action);
138
-        }
139
-        actions
140
-    }
141
-}
142
-
143
-impl Drop for TrayWidget {
144
-    fn drop(&mut self) {
145
-        let status_bar = NSStatusBar::systemStatusBar();
146
-        status_bar.removeStatusItem(&self._status_item);
147
-    }
148
-}
149
-
150
-// --- Helpers ---
151
-
152
-fn load_template_image() -> Retained<NSImage> {
153
-    unsafe {
154
-        let data = NSData::with_bytes(TRAY_ICON_PNG);
155
-        let image: Retained<NSImage> = msg_send![NSImage::alloc(), initWithData: &*data];
156
-        image.setTemplate(true);
157
-        image.setSize(NSSize::new(16.0, 16.0)); // point size (retina handled by 32px source)
158
-        image
159
-    }
160
-}
161
-
162
-fn build_menu(
163
-    mtm: MainThreadMarker,
164
-    handler: &TrayHandler,
165
-    workspaces: &[TrayWorkspace],
166
-    active_id: &str,
167
-) -> Retained<NSMenu> {
168
-    let title = NSString::from_str("tarmac");
169
-    let menu: Retained<NSMenu> = unsafe { msg_send![NSMenu::alloc(mtm), initWithTitle: &*title] };
170
-
171
-    // Workspace items
172
-    for ws in workspaces {
173
-        if ws.windows == 0 && !ws.active {
174
-            continue; // skip empty non-active workspaces
175
-        }
176
-        let label = if ws.windows > 0 {
177
-            format!(
178
-                "{}  ({} window{})",
179
-                ws.id,
180
-                ws.windows,
181
-                if ws.windows == 1 { "" } else { "s" }
182
-            )
183
-        } else {
184
-            ws.id.clone()
185
-        };
186
-        let item = make_item(mtm, &label, Some(sel!(onSwitchWorkspace:)), Some(handler));
187
-
188
-        // Parse workspace ID as tag for the action handler
189
-        if let Ok(n) = ws.id.parse::<isize>() {
190
-            item.setTag(n);
191
-        }
192
-
193
-        // Checkmark on active workspace
194
-        if ws.id == active_id {
195
-            item.setState(1); // NSControlStateValueOn
196
-        }
197
-
198
-        menu.addItem(&item);
199
-    }
200
-
201
-    // Separator
202
-    let sep: Retained<NSMenuItem> = unsafe { msg_send![NSMenuItem::class(), separatorItem] };
203
-    menu.addItem(&sep);
204
-
205
-    // Settings
206
-    let settings = make_item(
207
-        mtm,
208
-        "Settings\u{2026}",
209
-        Some(sel!(onOpenSettings:)),
210
-        Some(handler),
211
-    );
212
-    menu.addItem(&settings);
213
-
214
-    // Reload Config
215
-    let reload = make_item(mtm, "Reload Config", Some(sel!(onReload:)), Some(handler));
216
-    menu.addItem(&reload);
217
-
218
-    // Quit
219
-    let quit = make_item(mtm, "Quit tarmac", Some(sel!(onQuit:)), Some(handler));
220
-    menu.addItem(&quit);
221
-
222
-    menu
223
-}
224
-
225
-fn make_item(
226
-    mtm: MainThreadMarker,
227
-    title: &str,
228
-    action: Option<objc2::runtime::Sel>,
229
-    target: Option<&TrayHandler>,
230
-) -> Retained<NSMenuItem> {
231
-    let ns_title = NSString::from_str(title);
232
-    let key_eq = NSString::from_str("");
233
-    let item: Retained<NSMenuItem> = unsafe {
234
-        msg_send![NSMenuItem::alloc(mtm), initWithTitle: &*ns_title, action: action, keyEquivalent: &*key_eq]
235
-    };
236
-    if let Some(target) = target {
237
-        unsafe { item.setTarget(Some(target)) };
238
-    }
239
-    item
240
-}