@@ -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 | +} |