gardesk/tarmac / 704e1be

Browse files

add settings window with General tab controls

- NSWindow with sliders for gaps, bar height, border width/radius
- NSColorWell for focused/unfocused border colors
- NSButton checkboxes for FFM and mouse-follows-focus
- NSPopUpButton dropdown for modifier key selection
- SettingsHandler ObjC class dispatches control actions via mpsc
- changes apply immediately (apply_layout + update_borders)
- "Settings..." menu item in tray dropdown
- value labels update in real-time as sliders move
- enable NSSlider, NSSwitch, NSTextField, NSColorWell, NSPopUpButton
features in objc2-app-kit
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
704e1be7796b058d0d44fd1e02596e4acf27372e
Parents
6eedf9b
Tree
89b7748

6 changed files

StatusFile+-
M tarmac/Cargo.toml 10 0
M tarmac/src/main.rs 119 1
M tarmac/src/platform/border.rs 1 1
M tarmac/src/ui/mod.rs 1 0
A tarmac/src/ui/settings.rs 578 0
M tarmac/src/ui/tray.rs 10 0
tarmac/Cargo.tomlmodified
@@ -28,6 +28,16 @@ objc2-app-kit = { version = "0.3", default-features = false, features = [
2828
     "NSStatusBarButton",
2929
     "NSStatusItem",
3030
     "NSButton",
31
+    "NSSwitch",
32
+    "NSSlider",
33
+    "NSSliderCell",
34
+    "NSTextField",
35
+    "NSText",
36
+    "NSFont",
37
+    "NSColorWell",
38
+    "NSColorSpace",
39
+    "NSPopUpButton",
40
+    "NSPopUpButtonCell",
3141
     "NSControl",
3242
     "NSActionCell",
3343
     "NSCell",
tarmac/src/main.rsmodified
@@ -19,6 +19,7 @@ thread_local! {
1919
     static HOTKEY_MGR: RefCell<Option<HotkeyManager>> = const { RefCell::new(None) };
2020
     static CONFIG_PATH: RefCell<Option<std::path::PathBuf>> = const { RefCell::new(None) };
2121
     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) };
2223
 }
2324
 
2425
 fn main() {
@@ -514,6 +515,9 @@ unsafe extern "C" fn poll_timer_callback(_timer: *const c_void) {
514515
             update_tray(tray);
515516
         }
516517
     });
518
+
519
+    // Process settings window actions
520
+    poll_settings_actions();
517521
 }
518522
 
519523
 fn process_ipc_command(
@@ -990,12 +994,15 @@ fn poll_tray_actions() {
990994
         let borrow = t.borrow();
991995
         let Some(tray) = borrow.as_ref() else { return };
992996
         let actions = tray.poll_actions();
993
-        drop(borrow); // release borrow before handling actions
997
+        drop(borrow);
994998
         for action in actions {
995999
             match action {
9961000
                 tarmac::ui::tray::TrayAction::SwitchWorkspace(num) => {
9971001
                     handle_action(tarmac::core::input::Action::Workspace(num));
9981002
                 }
1003
+                tarmac::ui::tray::TrayAction::OpenSettings => {
1004
+                    open_settings();
1005
+                }
9991006
                 tarmac::ui::tray::TrayAction::Reload => {
10001007
                     reload_config();
10011008
                 }
@@ -1009,6 +1016,117 @@ fn poll_tray_actions() {
10091016
     });
10101017
 }
10111018
 
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.show();
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
+    }
1070
+}
1071
+
1072
+fn poll_settings_actions() {
1073
+    SETTINGS_WIN.with(|sw| {
1074
+        let borrow = sw.borrow();
1075
+        let Some(win) = borrow.as_ref() else { return };
1076
+        let actions = win.poll_actions();
1077
+        if actions.is_empty() {
1078
+            return;
1079
+        }
1080
+        win.refresh_labels();
1081
+        drop(borrow);
1082
+
1083
+        WM_STATE.with(|s| {
1084
+            if let Some(state) = s.borrow_mut().as_mut() {
1085
+                for action in actions {
1086
+                    match action {
1087
+                        tarmac::ui::settings::SettingsAction::GapInner(v) => {
1088
+                            state.gap_inner = v;
1089
+                        }
1090
+                        tarmac::ui::settings::SettingsAction::GapOuter(v) => {
1091
+                            state.gap_outer = v;
1092
+                        }
1093
+                        tarmac::ui::settings::SettingsAction::BarHeight(v) => {
1094
+                            state.bar_height = v;
1095
+                        }
1096
+                        tarmac::ui::settings::SettingsAction::BorderWidth(v) => {
1097
+                            state.borders.border_width = v;
1098
+                        }
1099
+                        tarmac::ui::settings::SettingsAction::BorderRadius(v) => {
1100
+                            state.borders.radius = v;
1101
+                        }
1102
+                        tarmac::ui::settings::SettingsAction::BorderColorFocused(hex) => {
1103
+                            state.borders.focused_color =
1104
+                                tarmac::platform::border::BorderColor::from_hex(&hex);
1105
+                        }
1106
+                        tarmac::ui::settings::SettingsAction::BorderColorUnfocused(hex) => {
1107
+                            state.borders.unfocused_color =
1108
+                                tarmac::platform::border::BorderColor::from_hex(&hex);
1109
+                        }
1110
+                        tarmac::ui::settings::SettingsAction::FocusFollowsMouse(v) => {
1111
+                            state.focus_follows_mouse = v;
1112
+                        }
1113
+                        tarmac::ui::settings::SettingsAction::MouseFollowsFocus(v) => {
1114
+                            state.mouse_follows_focus = v;
1115
+                        }
1116
+                        tarmac::ui::settings::SettingsAction::ModKey(_) => {
1117
+                            // Mod key changes require re-registering hotkeys,
1118
+                            // which needs a full config reload. Skip for now.
1119
+                        }
1120
+                        tarmac::ui::settings::SettingsAction::Open => {}
1121
+                    }
1122
+                }
1123
+                state.apply_layout();
1124
+                state.update_borders();
1125
+            }
1126
+        });
1127
+    });
1128
+}
1129
+
10121130
 fn run_app() {
10131131
     use objc2::MainThreadMarker;
10141132
     use objc2_app_kit::NSApplication;
tarmac/src/platform/border.rsmodified
@@ -28,7 +28,7 @@ impl BorderColor {
2828
         Self { r, g, b, a }
2929
     }
3030
 
31
-    fn to_hex(&self) -> String {
31
+    pub 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;
tarmac/src/ui/mod.rsmodified
@@ -1,1 +1,2 @@
1
+pub mod settings;
12
 pub mod tray;
tarmac/src/ui/settings.rsadded
@@ -0,0 +1,578 @@
1
+use std::sync::mpsc;
2
+
3
+use objc2::rc::Retained;
4
+use objc2::{
5
+    define_class, msg_send, sel, ClassType, DefinedClass, MainThreadMarker, MainThreadOnly,
6
+};
7
+use objc2_app_kit::{
8
+    NSBackingStoreType, NSButton, NSColorWell, NSPopUpButton, NSSlider, NSTextField, NSView,
9
+    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
+}
43
+
44
+// --- ObjC action handler ---
45
+
46
+struct SettingsHandlerIvars {
47
+    tx: mpsc::Sender<SettingsAction>,
48
+}
49
+
50
+impl SettingsHandler {
51
+    fn new(mtm: MainThreadMarker, tx: mpsc::Sender<SettingsAction>) -> Retained<Self> {
52
+        let this = mtm.alloc().set_ivars(SettingsHandlerIvars { tx });
53
+        unsafe { msg_send![super(this), init] }
54
+    }
55
+
56
+    fn emit(&self, action: SettingsAction) {
57
+        let _ = self.ivars().tx.send(action);
58
+    }
59
+}
60
+
61
+define_class!(
62
+    #[unsafe(super(NSObject))]
63
+    #[thread_kind = MainThreadOnly]
64
+    #[name = "TarmacSettingsHandler"]
65
+    #[ivars = SettingsHandlerIvars]
66
+    struct SettingsHandler;
67
+
68
+    impl SettingsHandler {
69
+        #[unsafe(method(onGapInnerChanged:))]
70
+        fn on_gap_inner(&self, sender: Option<&NSSlider>) {
71
+            if let Some(s) = sender {
72
+                self.emit(SettingsAction::GapInner(s.doubleValue()));
73
+            }
74
+        }
75
+
76
+        #[unsafe(method(onGapOuterChanged:))]
77
+        fn on_gap_outer(&self, sender: Option<&NSSlider>) {
78
+            if let Some(s) = sender {
79
+                self.emit(SettingsAction::GapOuter(s.doubleValue()));
80
+            }
81
+        }
82
+
83
+        #[unsafe(method(onBarHeightChanged:))]
84
+        fn on_bar_height(&self, sender: Option<&NSSlider>) {
85
+            if let Some(s) = sender {
86
+                self.emit(SettingsAction::BarHeight(s.doubleValue()));
87
+            }
88
+        }
89
+
90
+        #[unsafe(method(onBorderWidthChanged:))]
91
+        fn on_border_width(&self, sender: Option<&NSSlider>) {
92
+            if let Some(s) = sender {
93
+                self.emit(SettingsAction::BorderWidth(s.doubleValue()));
94
+            }
95
+        }
96
+
97
+        #[unsafe(method(onBorderRadiusChanged:))]
98
+        fn on_border_radius(&self, sender: Option<&NSSlider>) {
99
+            if let Some(s) = sender {
100
+                self.emit(SettingsAction::BorderRadius(s.doubleValue()));
101
+            }
102
+        }
103
+
104
+        #[unsafe(method(onBorderColorFocusedChanged:))]
105
+        fn on_border_color_focused(&self, sender: Option<&NSColorWell>) {
106
+            if let Some(well) = sender {
107
+                let hex = color_well_to_hex(well);
108
+                self.emit(SettingsAction::BorderColorFocused(hex));
109
+            }
110
+        }
111
+
112
+        #[unsafe(method(onBorderColorUnfocusedChanged:))]
113
+        fn on_border_color_unfocused(&self, sender: Option<&NSColorWell>) {
114
+            if let Some(well) = sender {
115
+                let hex = color_well_to_hex(well);
116
+                self.emit(SettingsAction::BorderColorUnfocused(hex));
117
+            }
118
+        }
119
+
120
+        #[unsafe(method(onFfmToggled:))]
121
+        fn on_ffm_toggled(&self, sender: Option<&NSButton>) {
122
+            if let Some(btn) = sender {
123
+                self.emit(SettingsAction::FocusFollowsMouse(btn.state() == 1));
124
+            }
125
+        }
126
+
127
+        #[unsafe(method(onMffToggled:))]
128
+        fn on_mff_toggled(&self, sender: Option<&NSButton>) {
129
+            if let Some(btn) = sender {
130
+                self.emit(SettingsAction::MouseFollowsFocus(btn.state() == 1));
131
+            }
132
+        }
133
+
134
+        #[unsafe(method(onModKeyChanged:))]
135
+        fn on_mod_key(&self, sender: Option<&NSPopUpButton>) {
136
+            if let Some(popup) = sender {
137
+                let idx = popup.indexOfSelectedItem();
138
+                let key = match idx {
139
+                    0 => "command",
140
+                    1 => "option",
141
+                    2 => "control",
142
+                    _ => "command",
143
+                };
144
+                self.emit(SettingsAction::ModKey(key.to_string()));
145
+            }
146
+        }
147
+    }
148
+);
149
+
150
+// --- SettingsWindow ---
151
+
152
+pub struct SettingsWindow {
153
+    window: Retained<NSWindow>,
154
+    _handler: Retained<SettingsHandler>,
155
+    action_rx: mpsc::Receiver<SettingsAction>,
156
+    // Controls we need to update when populating
157
+    gap_inner_slider: Retained<NSSlider>,
158
+    gap_outer_slider: Retained<NSSlider>,
159
+    bar_height_slider: Retained<NSSlider>,
160
+    border_width_slider: Retained<NSSlider>,
161
+    border_radius_slider: Retained<NSSlider>,
162
+    focused_color_well: Retained<NSColorWell>,
163
+    unfocused_color_well: Retained<NSColorWell>,
164
+    ffm_checkbox: Retained<NSButton>,
165
+    mff_checkbox: Retained<NSButton>,
166
+    mod_key_popup: Retained<NSPopUpButton>,
167
+    // Value labels for sliders
168
+    gap_inner_label: Retained<NSTextField>,
169
+    gap_outer_label: Retained<NSTextField>,
170
+    bar_height_label: Retained<NSTextField>,
171
+    border_width_label: Retained<NSTextField>,
172
+    border_radius_label: Retained<NSTextField>,
173
+}
174
+
175
+impl SettingsWindow {
176
+    pub fn new(mtm: MainThreadMarker) -> Self {
177
+        let (tx, rx) = mpsc::channel();
178
+        let handler = SettingsHandler::new(mtm, tx);
179
+
180
+        let style = NSWindowStyleMask::Titled
181
+            | NSWindowStyleMask::Closable
182
+            | NSWindowStyleMask::Miniaturizable;
183
+
184
+        let frame = CGRect::new(CGPoint::new(200.0, 200.0), CGSize::new(420.0, 520.0));
185
+        let window: Retained<NSWindow> = unsafe {
186
+            msg_send![
187
+                NSWindow::alloc(mtm),
188
+                initWithContentRect: frame,
189
+                styleMask: style,
190
+                backing: NSBackingStoreType::Buffered,
191
+                defer: false
192
+            ]
193
+        };
194
+
195
+        let title = NSString::from_str("tarmac Settings");
196
+        window.setTitle(&title);
197
+        window.center();
198
+        unsafe { window.setReleasedWhenClosed(false) };
199
+
200
+        // Flipped content view for top-down layout
201
+        let content: Retained<NSView> = unsafe {
202
+            msg_send![NSView::alloc(mtm), initWithFrame: frame]
203
+        };
204
+        window.setContentView(Some(&content));
205
+
206
+        // Build controls top-down (macOS default coords: 0,0 = bottom-left)
207
+        // So we position from the top by computing y = height - offset
208
+        let h = 520.0_f64;
209
+        let lx = 20.0_f64; // label x
210
+        let cx = 180.0_f64; // control x
211
+        let cw = 200.0_f64; // control width
212
+        let row = 32.0_f64; // row height
213
+
214
+        let mut y = h - 40.0; // start from top
215
+
216
+        // --- Section: Layout ---
217
+        add_section_label(mtm, &content, "Layout", lx, y);
218
+        y -= row;
219
+
220
+        let gap_inner_label = add_value_label(mtm, &content, "0", cx + cw + 8.0, y);
221
+        let gap_inner_slider = add_slider(
222
+            mtm, &content, &handler, "Inner Gap", lx, cx, y, cw,
223
+            0.0, 50.0, 0.0, sel!(onGapInnerChanged:),
224
+        );
225
+        y -= row;
226
+
227
+        let gap_outer_label = add_value_label(mtm, &content, "0", cx + cw + 8.0, y);
228
+        let gap_outer_slider = add_slider(
229
+            mtm, &content, &handler, "Outer Gap", lx, cx, y, cw,
230
+            0.0, 50.0, 0.0, sel!(onGapOuterChanged:),
231
+        );
232
+        y -= row;
233
+
234
+        let bar_height_label = add_value_label(mtm, &content, "0", cx + cw + 8.0, y);
235
+        let bar_height_slider = add_slider(
236
+            mtm, &content, &handler, "Bar Height", lx, cx, y, cw,
237
+            0.0, 60.0, 0.0, sel!(onBarHeightChanged:),
238
+        );
239
+        y -= row + 12.0;
240
+
241
+        // --- Section: Borders ---
242
+        add_section_label(mtm, &content, "Borders", lx, y);
243
+        y -= row;
244
+
245
+        let border_width_label = add_value_label(mtm, &content, "0", cx + cw + 8.0, y);
246
+        let border_width_slider = add_slider(
247
+            mtm, &content, &handler, "Width", lx, cx, y, cw,
248
+            0.0, 10.0, 0.0, sel!(onBorderWidthChanged:),
249
+        );
250
+        y -= row;
251
+
252
+        let border_radius_label = add_value_label(mtm, &content, "0", cx + cw + 8.0, y);
253
+        let border_radius_slider = add_slider(
254
+            mtm, &content, &handler, "Radius", lx, cx, y, cw,
255
+            0.0, 30.0, 10.0, sel!(onBorderRadiusChanged:),
256
+        );
257
+        y -= row;
258
+
259
+        add_label(mtm, &content, "Focused Color", lx, y);
260
+        let focused_color_well = add_color_well(
261
+            mtm, &content, &handler, cx, y, sel!(onBorderColorFocusedChanged:),
262
+        );
263
+        y -= row;
264
+
265
+        add_label(mtm, &content, "Unfocused Color", lx, y);
266
+        let unfocused_color_well = add_color_well(
267
+            mtm, &content, &handler, cx, y, sel!(onBorderColorUnfocusedChanged:),
268
+        );
269
+        y -= row + 12.0;
270
+
271
+        // --- Section: Behavior ---
272
+        add_section_label(mtm, &content, "Behavior", lx, y);
273
+        y -= row;
274
+
275
+        let ffm_checkbox = add_checkbox(
276
+            mtm, &content, &handler, "Focus follows mouse", lx, y,
277
+            sel!(onFfmToggled:),
278
+        );
279
+        y -= row;
280
+
281
+        let mff_checkbox = add_checkbox(
282
+            mtm, &content, &handler, "Mouse follows focus", lx, y,
283
+            sel!(onMffToggled:),
284
+        );
285
+        y -= row + 12.0;
286
+
287
+        // --- Section: Modifier Key ---
288
+        add_section_label(mtm, &content, "Modifier Key", lx, y);
289
+        y -= row;
290
+
291
+        let mod_key_popup = add_popup(
292
+            mtm, &content, &handler, lx, y, cw,
293
+            &["Command", "Option", "Control"],
294
+            sel!(onModKeyChanged:),
295
+        );
296
+        let _ = y; // suppress unused
297
+
298
+        Self {
299
+            window,
300
+            _handler: handler,
301
+            action_rx: rx,
302
+            gap_inner_slider,
303
+            gap_outer_slider,
304
+            bar_height_slider,
305
+            border_width_slider,
306
+            border_radius_slider,
307
+            focused_color_well,
308
+            unfocused_color_well,
309
+            ffm_checkbox,
310
+            mff_checkbox,
311
+            mod_key_popup,
312
+            gap_inner_label,
313
+            gap_outer_label,
314
+            bar_height_label,
315
+            border_width_label,
316
+            border_radius_label,
317
+        }
318
+    }
319
+
320
+    /// Show the settings window (or bring to front).
321
+    pub fn show(&self) {
322
+        self.window.makeKeyAndOrderFront(None);
323
+    }
324
+
325
+    /// Populate controls from current settings.
326
+    pub fn populate(&self, snap: &SettingsSnapshot) {
327
+        self.gap_inner_slider.setDoubleValue(snap.gap_inner);
328
+        self.gap_outer_slider.setDoubleValue(snap.gap_outer);
329
+        self.bar_height_slider.setDoubleValue(snap.bar_height);
330
+        self.border_width_slider.setDoubleValue(snap.border_width);
331
+        self.border_radius_slider.setDoubleValue(snap.border_radius);
332
+
333
+        set_value_label(&self.gap_inner_label, snap.gap_inner);
334
+        set_value_label(&self.gap_outer_label, snap.gap_outer);
335
+        set_value_label(&self.bar_height_label, snap.bar_height);
336
+        set_value_label(&self.border_width_label, snap.border_width);
337
+        set_value_label(&self.border_radius_label, snap.border_radius);
338
+
339
+        set_color_well(&self.focused_color_well, &snap.border_color_focused);
340
+        set_color_well(&self.unfocused_color_well, &snap.border_color_unfocused);
341
+
342
+        self.ffm_checkbox.setState(if snap.focus_follows_mouse { 1 } else { 0 });
343
+        self.mff_checkbox.setState(if snap.mouse_follows_focus { 1 } else { 0 });
344
+
345
+        let mod_idx: isize = match snap.mod_key.as_str() {
346
+            "command" | "cmd" => 0,
347
+            "option" | "alt" => 1,
348
+            "control" | "ctrl" => 2,
349
+            _ => 0,
350
+        };
351
+        self.mod_key_popup.selectItemAtIndex(mod_idx);
352
+    }
353
+
354
+    /// Drain pending actions.
355
+    pub fn poll_actions(&self) -> Vec<SettingsAction> {
356
+        let mut actions = Vec::new();
357
+        while let Ok(action) = self.action_rx.try_recv() {
358
+            actions.push(action);
359
+        }
360
+        actions
361
+    }
362
+
363
+    /// Update value labels when sliders change (called from main loop after processing actions).
364
+    pub fn refresh_labels(&self) {
365
+        set_value_label(&self.gap_inner_label, self.gap_inner_slider.doubleValue());
366
+        set_value_label(&self.gap_outer_label, self.gap_outer_slider.doubleValue());
367
+        set_value_label(&self.bar_height_label, self.bar_height_slider.doubleValue());
368
+        set_value_label(&self.border_width_label, self.border_width_slider.doubleValue());
369
+        set_value_label(&self.border_radius_label, self.border_radius_slider.doubleValue());
370
+    }
371
+}
372
+
373
+// --- Helper functions ---
374
+
375
+fn add_section_label(mtm: MainThreadMarker, parent: &NSView, text: &str, x: f64, y: f64) {
376
+    let frame = CGRect::new(CGPoint::new(x, y), CGSize::new(360.0, 20.0));
377
+    let label: Retained<NSTextField> = unsafe {
378
+        msg_send![NSTextField::alloc(mtm), initWithFrame: frame]
379
+    };
380
+    let ns = NSString::from_str(text);
381
+    label.setStringValue(&ns);
382
+    label.setEditable(false);
383
+    label.setBordered(false);
384
+    label.setDrawsBackground(false);
385
+    unsafe {
386
+        let bold_font: Retained<objc2_app_kit::NSFont> =
387
+            msg_send![objc2_app_kit::NSFont::class(), boldSystemFontOfSize: 13.0_f64];
388
+        label.setFont(Some(&bold_font));
389
+    }
390
+    parent.addSubview(&label);
391
+}
392
+
393
+fn add_label(mtm: MainThreadMarker, parent: &NSView, text: &str, x: f64, y: f64) {
394
+    let frame = CGRect::new(CGPoint::new(x, y + 2.0), CGSize::new(150.0, 18.0));
395
+    let label: Retained<NSTextField> = unsafe {
396
+        msg_send![NSTextField::alloc(mtm), initWithFrame: frame]
397
+    };
398
+    let ns = NSString::from_str(text);
399
+    label.setStringValue(&ns);
400
+    label.setEditable(false);
401
+    label.setBordered(false);
402
+    label.setDrawsBackground(false);
403
+    parent.addSubview(&label);
404
+}
405
+
406
+fn add_value_label(
407
+    mtm: MainThreadMarker,
408
+    parent: &NSView,
409
+    text: &str,
410
+    x: f64,
411
+    y: f64,
412
+) -> Retained<NSTextField> {
413
+    let frame = CGRect::new(CGPoint::new(x, y + 2.0), CGSize::new(30.0, 18.0));
414
+    let label: Retained<NSTextField> = unsafe {
415
+        msg_send![NSTextField::alloc(mtm), initWithFrame: frame]
416
+    };
417
+    let ns = NSString::from_str(text);
418
+    label.setStringValue(&ns);
419
+    label.setEditable(false);
420
+    label.setBordered(false);
421
+    label.setDrawsBackground(false);
422
+    parent.addSubview(&label);
423
+    label
424
+}
425
+
426
+fn set_value_label(label: &NSTextField, val: f64) {
427
+    let text = format!("{}", val as i32);
428
+    let ns = NSString::from_str(&text);
429
+    label.setStringValue(&ns);
430
+}
431
+
432
+#[allow(clippy::too_many_arguments)]
433
+fn add_slider(
434
+    mtm: MainThreadMarker,
435
+    parent: &NSView,
436
+    handler: &SettingsHandler,
437
+    label_text: &str,
438
+    label_x: f64,
439
+    control_x: f64,
440
+    y: f64,
441
+    width: f64,
442
+    min: f64,
443
+    max: f64,
444
+    initial: f64,
445
+    action: objc2::runtime::Sel,
446
+) -> Retained<NSSlider> {
447
+    add_label(mtm, parent, label_text, label_x, y);
448
+    let frame = CGRect::new(CGPoint::new(control_x, y), CGSize::new(width, 20.0));
449
+    let slider: Retained<NSSlider> = unsafe {
450
+        msg_send![NSSlider::alloc(mtm), initWithFrame: frame]
451
+    };
452
+    slider.setMinValue(min);
453
+    slider.setMaxValue(max);
454
+    slider.setDoubleValue(initial);
455
+    unsafe {
456
+        slider.setTarget(Some(handler));
457
+        slider.setAction(Some(action));
458
+        slider.setContinuous(true);
459
+    }
460
+    parent.addSubview(&slider);
461
+    slider
462
+}
463
+
464
+fn add_color_well(
465
+    mtm: MainThreadMarker,
466
+    parent: &NSView,
467
+    handler: &SettingsHandler,
468
+    x: f64,
469
+    y: f64,
470
+    action: objc2::runtime::Sel,
471
+) -> Retained<NSColorWell> {
472
+    let frame = CGRect::new(CGPoint::new(x, y), CGSize::new(44.0, 24.0));
473
+    let well: Retained<NSColorWell> = unsafe {
474
+        msg_send![NSColorWell::alloc(mtm), initWithFrame: frame]
475
+    };
476
+    unsafe {
477
+        well.setTarget(Some(handler));
478
+        well.setAction(Some(action));
479
+    }
480
+    parent.addSubview(&well);
481
+    well
482
+}
483
+
484
+fn add_checkbox(
485
+    mtm: MainThreadMarker,
486
+    parent: &NSView,
487
+    handler: &SettingsHandler,
488
+    title: &str,
489
+    x: f64,
490
+    y: f64,
491
+    action: objc2::runtime::Sel,
492
+) -> Retained<NSButton> {
493
+    let frame = CGRect::new(CGPoint::new(x, y), CGSize::new(250.0, 22.0));
494
+    let btn: Retained<NSButton> = unsafe {
495
+        msg_send![NSButton::alloc(mtm), initWithFrame: frame]
496
+    };
497
+    let ns_title = NSString::from_str(title);
498
+    unsafe {
499
+        let _: () = msg_send![&*btn, setButtonType: 3_isize]; // NSSwitchButton
500
+    }
501
+    btn.setTitle(&ns_title);
502
+    unsafe {
503
+        btn.setTarget(Some(handler));
504
+        btn.setAction(Some(action));
505
+    }
506
+    parent.addSubview(&btn);
507
+    btn
508
+}
509
+
510
+fn add_popup(
511
+    mtm: MainThreadMarker,
512
+    parent: &NSView,
513
+    handler: &SettingsHandler,
514
+    x: f64,
515
+    y: f64,
516
+    width: f64,
517
+    items: &[&str],
518
+    action: objc2::runtime::Sel,
519
+) -> Retained<NSPopUpButton> {
520
+    let frame = CGRect::new(CGPoint::new(x, y), CGSize::new(width, 26.0));
521
+    let popup: Retained<NSPopUpButton> = unsafe {
522
+        msg_send![NSPopUpButton::alloc(mtm), initWithFrame: frame, pullsDown: false]
523
+    };
524
+    for item in items {
525
+        let ns = NSString::from_str(item);
526
+        popup.addItemWithTitle(&ns);
527
+    }
528
+    unsafe {
529
+        popup.setTarget(Some(handler));
530
+        popup.setAction(Some(action));
531
+    }
532
+    parent.addSubview(&popup);
533
+    popup
534
+}
535
+
536
+fn color_well_to_hex(well: &NSColorWell) -> String {
537
+    unsafe {
538
+        let color = well.color();
539
+        // Convert to sRGB color space
540
+        let rgb: Option<Retained<objc2_app_kit::NSColor>> = msg_send![
541
+            &*color,
542
+            colorUsingColorSpaceName: &*NSString::from_str("NSCalibratedRGBColorSpace")
543
+        ];
544
+        if let Some(rgb) = rgb {
545
+            let r: f64 = msg_send![&*rgb, redComponent];
546
+            let g: f64 = msg_send![&*rgb, greenComponent];
547
+            let b: f64 = msg_send![&*rgb, blueComponent];
548
+            format!(
549
+                "#{:02x}{:02x}{:02x}",
550
+                (r * 255.0) as u8,
551
+                (g * 255.0) as u8,
552
+                (b * 255.0) as u8,
553
+            )
554
+        } else {
555
+            "#000000".to_string()
556
+        }
557
+    }
558
+}
559
+
560
+fn set_color_well(well: &NSColorWell, hex: &str) {
561
+    let hex = hex.trim_start_matches('#');
562
+    if hex.len() < 6 {
563
+        return;
564
+    }
565
+    let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0) as f64 / 255.0;
566
+    let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0) as f64 / 255.0;
567
+    let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0) as f64 / 255.0;
568
+    unsafe {
569
+        let color: Retained<objc2_app_kit::NSColor> = msg_send![
570
+            objc2_app_kit::NSColor::class(),
571
+            colorWithCalibratedRed: r,
572
+            green: g,
573
+            blue: b,
574
+            alpha: 1.0_f64
575
+        ];
576
+        well.setColor(&color);
577
+    }
578
+}
tarmac/src/ui/tray.rsmodified
@@ -18,6 +18,7 @@ const TRAY_ICON_PNG: &[u8] = include_bytes!("../../assets/tray_icon_32.png");
1818
 #[derive(Debug)]
1919
 pub enum TrayAction {
2020
     SwitchWorkspace(u8),
21
+    OpenSettings,
2122
     Reload,
2223
     Quit,
2324
 }
@@ -64,6 +65,11 @@ define_class!(
6465
             }
6566
         }
6667
 
68
+        #[unsafe(method(onOpenSettings:))]
69
+        fn on_open_settings(&self, _sender: Option<&AnyObject>) {
70
+            self.emit(TrayAction::OpenSettings);
71
+        }
72
+
6773
         #[unsafe(method(onReload:))]
6874
         fn on_reload(&self, _sender: Option<&AnyObject>) {
6975
             self.emit(TrayAction::Reload);
@@ -191,6 +197,10 @@ fn build_menu(
191197
     let sep: Retained<NSMenuItem> = unsafe { msg_send![NSMenuItem::class(), separatorItem] };
192198
     menu.addItem(&sep);
193199
 
200
+    // Settings
201
+    let settings = make_item(mtm, "Settings\u{2026}", Some(sel!(onOpenSettings:)), Some(handler));
202
+    menu.addItem(&settings);
203
+
194204
     // Reload Config
195205
     let reload = make_item(mtm, "Reload Config", Some(sel!(onReload:)), Some(handler));
196206
     menu.addItem(&reload);