@@ -10,14 +10,16 @@ use objc2_app_kit::{ |
| 10 | 10 | NSBackingStoreType, NSButton, NSColorWell, NSImage, NSPopUpButton, NSScrollView, NSSlider, |
| 11 | 11 | NSSplitView, NSSplitViewDividerStyle, NSTabViewController, NSTabViewControllerTabStyle, |
| 12 | 12 | NSTabViewItem, NSTableColumn, NSTableView, NSTableViewRowSizeStyle, NSTableViewStyle, |
| 13 | | - NSTextField, NSTextView, NSView, NSViewController, NSWindow, NSWindowStyleMask, |
| 14 | | - NSWindowToolbarStyle, |
| 13 | + NSTextField, NSView, NSViewController, NSWindow, NSWindowStyleMask, NSWindowToolbarStyle, |
| 15 | 14 | }; |
| 16 | 15 | use objc2_core_foundation::{CGFloat, CGPoint, CGRect, CGSize}; |
| 17 | 16 | use objc2_foundation::{NSIndexSet, NSInteger, NSNotification, NSObject, NSString}; |
| 18 | 17 | |
| 19 | | -use crate::config::document::{KeybindRow, RuleRow}; |
| 20 | | -use crate::config::lua::{RuleMatchMode, RulePattern, WindowRule}; |
| 18 | +use crate::config::document::{KeybindRow, RuleRow, WorkspaceRow}; |
| 19 | +use crate::config::lua::{RuleMatchMode, RulePattern, SpecialWorkspaceConfig, WindowRule}; |
| 20 | +use crate::core::workspace::{ |
| 21 | + MonitorAssignment, WorkspaceDefinition, WorkspaceKind, WorkspaceLayout, |
| 22 | +}; |
| 21 | 23 | |
| 22 | 24 | const WIN_W: f64 = 920.0; |
| 23 | 25 | const WIN_H: f64 = 660.0; |
@@ -30,6 +32,11 @@ const RULE_COLUMN_SOURCE: &str = "source"; |
| 30 | 32 | const KEYBIND_COLUMN_SHORTCUT: &str = "shortcut"; |
| 31 | 33 | const KEYBIND_COLUMN_ACTION: &str = "action"; |
| 32 | 34 | const KEYBIND_COLUMN_SOURCE: &str = "source"; |
| 35 | +const WORKSPACE_COLUMN_ID: &str = "id"; |
| 36 | +const WORKSPACE_COLUMN_KIND: &str = "kind"; |
| 37 | +const WORKSPACE_COLUMN_MONITOR: &str = "monitor"; |
| 38 | +const WORKSPACE_COLUMN_SUMMARY: &str = "summary"; |
| 39 | +const WORKSPACE_COLUMN_SOURCE: &str = "source"; |
| 33 | 40 | |
| 34 | 41 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 35 | 42 | pub struct KeybindDraft { |
@@ -46,6 +53,76 @@ impl KeybindDraft { |
| 46 | 53 | } |
| 47 | 54 | } |
| 48 | 55 | |
| 56 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 57 | +pub struct WorkspaceDisplayOption { |
| 58 | + pub display_id: u32, |
| 59 | + pub label: String, |
| 60 | +} |
| 61 | + |
| 62 | +#[derive(Debug, Clone, PartialEq)] |
| 63 | +pub struct WorkspaceDraft { |
| 64 | + pub id: String, |
| 65 | + pub kind: WorkspaceKind, |
| 66 | + pub monitor_display_id: Option<u32>, |
| 67 | + pub layout: WorkspaceLayout, |
| 68 | + pub gap_inner: Option<f64>, |
| 69 | + pub gap_outer: Option<f64>, |
| 70 | + pub overlay_position: String, |
| 71 | + pub overlay_width: f64, |
| 72 | + pub overlay_height: f64, |
| 73 | +} |
| 74 | + |
| 75 | +impl WorkspaceDraft { |
| 76 | + fn from_row(row: &WorkspaceRow) -> Self { |
| 77 | + let special = row |
| 78 | + .special |
| 79 | + .clone() |
| 80 | + .unwrap_or_else(|| SpecialWorkspaceConfig::default_for(&row.id)); |
| 81 | + Self { |
| 82 | + id: row.id.clone(), |
| 83 | + kind: row.kind, |
| 84 | + monitor_display_id: row.definition.prefs.monitor.as_ref().map(|m| m.display_id), |
| 85 | + layout: row.definition.prefs.default_layout, |
| 86 | + gap_inner: row.definition.prefs.gap_inner, |
| 87 | + gap_outer: row.definition.prefs.gap_outer, |
| 88 | + overlay_position: special.position, |
| 89 | + overlay_width: special.width, |
| 90 | + overlay_height: special.height, |
| 91 | + } |
| 92 | + } |
| 93 | + |
| 94 | + pub fn to_definition(&self) -> WorkspaceDefinition { |
| 95 | + let mut definition = WorkspaceDefinition::new( |
| 96 | + crate::core::workspace::WorkspaceId::parse(&self.id) |
| 97 | + .unwrap_or_else(|| crate::core::workspace::WorkspaceId::Special(self.id.clone())), |
| 98 | + ); |
| 99 | + definition.kind = self.kind; |
| 100 | + definition.prefs.monitor = self |
| 101 | + .monitor_display_id |
| 102 | + .map(|display_id| MonitorAssignment { display_id }); |
| 103 | + definition.prefs.default_layout = self.layout; |
| 104 | + definition.prefs.gap_inner = self.gap_inner; |
| 105 | + definition.prefs.gap_outer = self.gap_outer; |
| 106 | + definition |
| 107 | + } |
| 108 | + |
| 109 | + pub fn special_config(&self) -> Option<SpecialWorkspaceConfig> { |
| 110 | + if self.kind != WorkspaceKind::Special { |
| 111 | + return None; |
| 112 | + } |
| 113 | + Some(SpecialWorkspaceConfig { |
| 114 | + name: self |
| 115 | + .id |
| 116 | + .strip_prefix("special:") |
| 117 | + .unwrap_or(&self.id) |
| 118 | + .to_string(), |
| 119 | + position: self.overlay_position.clone(), |
| 120 | + width: self.overlay_width, |
| 121 | + height: self.overlay_height, |
| 122 | + }) |
| 123 | + } |
| 124 | +} |
| 125 | + |
| 49 | 126 | #[derive(Debug)] |
| 50 | 127 | pub enum SettingsAction { |
| 51 | 128 | GapInner(f64), |
@@ -74,7 +151,13 @@ pub enum SettingsAction { |
| 74 | 151 | UpdateRuleDraft(WindowRule), |
| 75 | 152 | ApplyRule(String), |
| 76 | 153 | ToggleRuleEnabled(String, bool), |
| 77 | | - ApplyManagedWorkspaces(String), |
| 154 | + SelectWorkspace(String), |
| 155 | + AddLetteredWorkspace(String), |
| 156 | + AddSpecialWorkspace(String), |
| 157 | + CopyWorkspaceToManaged(String), |
| 158 | + DeleteWorkspace(String), |
| 159 | + UpdateWorkspaceDraft(WorkspaceDraft), |
| 160 | + ApplyWorkspace(String), |
| 78 | 161 | } |
| 79 | 162 | |
| 80 | 163 | pub struct SettingsSnapshot { |
@@ -92,8 +175,9 @@ pub struct SettingsSnapshot { |
| 92 | 175 | pub selected_keybind_id: Option<String>, |
| 93 | 176 | pub rules: Vec<RuleRow>, |
| 94 | 177 | pub selected_rule_id: Option<String>, |
| 95 | | - pub workspaces_external_text: String, |
| 96 | | - pub workspaces_managed_text: String, |
| 178 | + pub workspaces: Vec<WorkspaceRow>, |
| 179 | + pub selected_workspace_id: Option<String>, |
| 180 | + pub displays: Vec<WorkspaceDisplayOption>, |
| 97 | 181 | } |
| 98 | 182 | |
| 99 | 183 | #[derive(Debug, Clone, PartialEq, Eq)] |
@@ -120,6 +204,19 @@ struct RuleInspectorState { |
| 120 | 204 | rule: Option<WindowRule>, |
| 121 | 205 | } |
| 122 | 206 | |
| 207 | +#[derive(Debug, Clone, PartialEq)] |
| 208 | +struct WorkspaceInspectorState { |
| 209 | + editable: bool, |
| 210 | + source_label: String, |
| 211 | + kind_label: String, |
| 212 | + can_copy_to_managed: bool, |
| 213 | + can_delete: bool, |
| 214 | + delete_label: String, |
| 215 | + can_apply: bool, |
| 216 | + is_special: bool, |
| 217 | + draft: Option<WorkspaceDraft>, |
| 218 | +} |
| 219 | + |
| 123 | 220 | struct KeybindingsUiRefs { |
| 124 | 221 | table: Retained<NSTableView>, |
| 125 | 222 | placeholder_label: Retained<NSTextField>, |
@@ -157,9 +254,27 @@ struct RulesUiRefs { |
| 157 | 254 | apply_button: Retained<NSButton>, |
| 158 | 255 | } |
| 159 | 256 | |
| 257 | +struct WorkspacesUiRefs { |
| 258 | + table: Retained<NSTableView>, |
| 259 | + placeholder_label: Retained<NSTextField>, |
| 260 | + source_value: Retained<NSTextField>, |
| 261 | + kind_value: Retained<NSTextField>, |
| 262 | + workspace_id_value: Retained<NSTextField>, |
| 263 | + monitor_popup: Retained<NSPopUpButton>, |
| 264 | + layout_popup: Retained<NSPopUpButton>, |
| 265 | + gap_inner_field: Retained<NSTextField>, |
| 266 | + gap_outer_field: Retained<NSTextField>, |
| 267 | + overlay_position_popup: Retained<NSPopUpButton>, |
| 268 | + overlay_width_field: Retained<NSTextField>, |
| 269 | + overlay_height_field: Retained<NSTextField>, |
| 270 | + overlay_section_label: Retained<NSTextField>, |
| 271 | + copy_button: Retained<NSButton>, |
| 272 | + delete_button: Retained<NSButton>, |
| 273 | + apply_button: Retained<NSButton>, |
| 274 | +} |
| 275 | + |
| 160 | 276 | struct SettingsHandlerIvars { |
| 161 | 277 | tx: mpsc::Sender<SettingsAction>, |
| 162 | | - workspaces_editor: RefCell<Option<Retained<NSTextView>>>, |
| 163 | 278 | keybind_rows: RefCell<Vec<KeybindRow>>, |
| 164 | 279 | selected_keybind_id: RefCell<Option<String>>, |
| 165 | 280 | draft_keybind: RefCell<Option<KeybindDraft>>, |
@@ -170,13 +285,18 @@ struct SettingsHandlerIvars { |
| 170 | 285 | draft_rule: RefCell<Option<WindowRule>>, |
| 171 | 286 | suppress_rule_selection_change: RefCell<bool>, |
| 172 | 287 | rules_ui: RefCell<Option<RulesUiRefs>>, |
| 288 | + workspace_rows: RefCell<Vec<WorkspaceRow>>, |
| 289 | + selected_workspace_id: RefCell<Option<String>>, |
| 290 | + draft_workspace: RefCell<Option<WorkspaceDraft>>, |
| 291 | + workspace_displays: RefCell<Vec<WorkspaceDisplayOption>>, |
| 292 | + suppress_workspace_selection_change: RefCell<bool>, |
| 293 | + workspaces_ui: RefCell<Option<WorkspacesUiRefs>>, |
| 173 | 294 | } |
| 174 | 295 | |
| 175 | 296 | impl SettingsHandler { |
| 176 | 297 | fn new(mtm: MainThreadMarker, tx: mpsc::Sender<SettingsAction>) -> Retained<Self> { |
| 177 | 298 | let this = mtm.alloc().set_ivars(SettingsHandlerIvars { |
| 178 | 299 | tx, |
| 179 | | - workspaces_editor: RefCell::new(None), |
| 180 | 300 | keybind_rows: RefCell::new(Vec::new()), |
| 181 | 301 | selected_keybind_id: RefCell::new(None), |
| 182 | 302 | draft_keybind: RefCell::new(None), |
@@ -187,6 +307,12 @@ impl SettingsHandler { |
| 187 | 307 | draft_rule: RefCell::new(None), |
| 188 | 308 | suppress_rule_selection_change: RefCell::new(false), |
| 189 | 309 | rules_ui: RefCell::new(None), |
| 310 | + workspace_rows: RefCell::new(Vec::new()), |
| 311 | + selected_workspace_id: RefCell::new(None), |
| 312 | + draft_workspace: RefCell::new(None), |
| 313 | + workspace_displays: RefCell::new(Vec::new()), |
| 314 | + suppress_workspace_selection_change: RefCell::new(false), |
| 315 | + workspaces_ui: RefCell::new(None), |
| 190 | 316 | }); |
| 191 | 317 | unsafe { msg_send![super(this), init] } |
| 192 | 318 | } |
@@ -199,14 +325,14 @@ impl SettingsHandler { |
| 199 | 325 | *self.ivars().keybindings_ui.borrow_mut() = Some(ui); |
| 200 | 326 | } |
| 201 | 327 | |
| 202 | | - fn set_workspaces_editor(&self, editor: Retained<NSTextView>) { |
| 203 | | - *self.ivars().workspaces_editor.borrow_mut() = Some(editor); |
| 204 | | - } |
| 205 | | - |
| 206 | 328 | fn set_rules_ui(&self, ui: RulesUiRefs) { |
| 207 | 329 | *self.ivars().rules_ui.borrow_mut() = Some(ui); |
| 208 | 330 | } |
| 209 | 331 | |
| 332 | + fn set_workspaces_ui(&self, ui: WorkspacesUiRefs) { |
| 333 | + *self.ivars().workspaces_ui.borrow_mut() = Some(ui); |
| 334 | + } |
| 335 | + |
| 210 | 336 | fn load_keybinds(&self, rows: Vec<KeybindRow>, selected_keybind_id: Option<String>) { |
| 211 | 337 | let resolved_selection = if let Some(selected) = selected_keybind_id { |
| 212 | 338 | rows.iter().find(|row| row.id == selected).map(|_| selected) |
@@ -245,6 +371,31 @@ impl SettingsHandler { |
| 245 | 371 | self.refresh_rule_inspector(); |
| 246 | 372 | } |
| 247 | 373 | |
| 374 | + fn load_workspaces( |
| 375 | + &self, |
| 376 | + rows: Vec<WorkspaceRow>, |
| 377 | + selected_workspace_id: Option<String>, |
| 378 | + displays: Vec<WorkspaceDisplayOption>, |
| 379 | + ) { |
| 380 | + let resolved_selection = if let Some(selected) = selected_workspace_id { |
| 381 | + rows.iter().find(|row| row.id == selected).map(|_| selected) |
| 382 | + } else { |
| 383 | + None |
| 384 | + }; |
| 385 | + let draft = resolved_selection |
| 386 | + .as_deref() |
| 387 | + .and_then(|id| rows.iter().find(|row| row.id == id)) |
| 388 | + .and_then(|row| row.editable.then(|| WorkspaceDraft::from_row(row))); |
| 389 | + |
| 390 | + *self.ivars().workspace_rows.borrow_mut() = rows; |
| 391 | + *self.ivars().selected_workspace_id.borrow_mut() = resolved_selection; |
| 392 | + *self.ivars().draft_workspace.borrow_mut() = draft; |
| 393 | + *self.ivars().workspace_displays.borrow_mut() = displays; |
| 394 | + |
| 395 | + self.reload_workspace_table(); |
| 396 | + self.refresh_workspace_inspector(); |
| 397 | + } |
| 398 | + |
| 248 | 399 | fn selected_keybind_row(&self) -> Option<KeybindRow> { |
| 249 | 400 | let selected = self.ivars().selected_keybind_id.borrow().clone()?; |
| 250 | 401 | self.ivars() |
@@ -283,6 +434,25 @@ impl SettingsHandler { |
| 283 | 434 | .position(|row| row.id == selected) |
| 284 | 435 | } |
| 285 | 436 | |
| 437 | + fn selected_workspace_row(&self) -> Option<WorkspaceRow> { |
| 438 | + let selected = self.ivars().selected_workspace_id.borrow().clone()?; |
| 439 | + self.ivars() |
| 440 | + .workspace_rows |
| 441 | + .borrow() |
| 442 | + .iter() |
| 443 | + .find(|row| row.id == selected) |
| 444 | + .cloned() |
| 445 | + } |
| 446 | + |
| 447 | + fn selected_workspace_row_index(&self) -> Option<usize> { |
| 448 | + let selected = self.ivars().selected_workspace_id.borrow().clone()?; |
| 449 | + self.ivars() |
| 450 | + .workspace_rows |
| 451 | + .borrow() |
| 452 | + .iter() |
| 453 | + .position(|row| row.id == selected) |
| 454 | + } |
| 455 | + |
| 286 | 456 | fn current_keybind_inspector_state(&self) -> KeybindInspectorState { |
| 287 | 457 | let rows = self.ivars().keybind_rows.borrow().clone(); |
| 288 | 458 | let selected_keybind_id = self.ivars().selected_keybind_id.borrow().clone(); |
@@ -301,6 +471,17 @@ impl SettingsHandler { |
| 301 | 471 | derive_rule_inspector_state(&rows, selected_rule_id.as_deref(), draft_rule.as_ref()) |
| 302 | 472 | } |
| 303 | 473 | |
| 474 | + fn current_workspace_inspector_state(&self) -> WorkspaceInspectorState { |
| 475 | + let rows = self.ivars().workspace_rows.borrow().clone(); |
| 476 | + let selected_workspace_id = self.ivars().selected_workspace_id.borrow().clone(); |
| 477 | + let draft_workspace = self.ivars().draft_workspace.borrow().clone(); |
| 478 | + derive_workspace_inspector_state( |
| 479 | + &rows, |
| 480 | + selected_workspace_id.as_deref(), |
| 481 | + draft_workspace.as_ref(), |
| 482 | + ) |
| 483 | + } |
| 484 | + |
| 304 | 485 | fn reload_keybind_table(&self) { |
| 305 | 486 | let selected_index = self.selected_keybind_row_index(); |
| 306 | 487 | let ui_borrow = self.ivars().keybindings_ui.borrow(); |
@@ -339,6 +520,31 @@ impl SettingsHandler { |
| 339 | 520 | *self.ivars().suppress_rule_selection_change.borrow_mut() = false; |
| 340 | 521 | } |
| 341 | 522 | |
| 523 | + fn reload_workspace_table(&self) { |
| 524 | + let selected_index = self.selected_workspace_row_index(); |
| 525 | + let ui_borrow = self.ivars().workspaces_ui.borrow(); |
| 526 | + let Some(ui) = ui_borrow.as_ref() else { return }; |
| 527 | + ui.table.reloadData(); |
| 528 | + |
| 529 | + *self |
| 530 | + .ivars() |
| 531 | + .suppress_workspace_selection_change |
| 532 | + .borrow_mut() = true; |
| 533 | + if let Some(index) = selected_index { |
| 534 | + let indexes = NSIndexSet::indexSetWithIndex(index); |
| 535 | + ui.table |
| 536 | + .selectRowIndexes_byExtendingSelection(&indexes, false); |
| 537 | + } else { |
| 538 | + let empty = NSIndexSet::indexSet(); |
| 539 | + ui.table |
| 540 | + .selectRowIndexes_byExtendingSelection(&empty, false); |
| 541 | + } |
| 542 | + *self |
| 543 | + .ivars() |
| 544 | + .suppress_workspace_selection_change |
| 545 | + .borrow_mut() = false; |
| 546 | + } |
| 547 | + |
| 342 | 548 | fn refresh_keybind_inspector(&self) { |
| 343 | 549 | let state = self.current_keybind_inspector_state(); |
| 344 | 550 | let ui_borrow = self.ivars().keybindings_ui.borrow(); |
@@ -353,6 +559,14 @@ impl SettingsHandler { |
| 353 | 559 | apply_rule_inspector_state(ui, &state); |
| 354 | 560 | } |
| 355 | 561 | |
| 562 | + fn refresh_workspace_inspector(&self) { |
| 563 | + let state = self.current_workspace_inspector_state(); |
| 564 | + let displays = self.ivars().workspace_displays.borrow().clone(); |
| 565 | + let ui_borrow = self.ivars().workspaces_ui.borrow(); |
| 566 | + let Some(ui) = ui_borrow.as_ref() else { return }; |
| 567 | + apply_workspace_inspector_state(ui, &state, &displays); |
| 568 | + } |
| 569 | + |
| 356 | 570 | fn select_keybind_by_row_index(&self, row_index: usize, emit_action: bool) { |
| 357 | 571 | let Some(row) = self.ivars().keybind_rows.borrow().get(row_index).cloned() else { |
| 358 | 572 | return; |
@@ -378,6 +592,19 @@ impl SettingsHandler { |
| 378 | 592 | } |
| 379 | 593 | } |
| 380 | 594 | |
| 595 | + fn select_workspace_by_row_index(&self, row_index: usize, emit_action: bool) { |
| 596 | + let Some(row) = self.ivars().workspace_rows.borrow().get(row_index).cloned() else { |
| 597 | + return; |
| 598 | + }; |
| 599 | + *self.ivars().selected_workspace_id.borrow_mut() = Some(row.id.clone()); |
| 600 | + *self.ivars().draft_workspace.borrow_mut() = |
| 601 | + row.editable.then(|| WorkspaceDraft::from_row(&row)); |
| 602 | + self.refresh_workspace_inspector(); |
| 603 | + if emit_action { |
| 604 | + self.emit(SettingsAction::SelectWorkspace(row.id)); |
| 605 | + } |
| 606 | + } |
| 607 | + |
| 381 | 608 | fn sync_keybind_draft_from_controls(&self) -> Option<KeybindDraft> { |
| 382 | 609 | let row = self.selected_keybind_row()?; |
| 383 | 610 | if !row.editable { |
@@ -515,6 +742,39 @@ impl SettingsHandler { |
| 515 | 742 | self.refresh_rule_inspector(); |
| 516 | 743 | } |
| 517 | 744 | } |
| 745 | + |
| 746 | + fn sync_workspace_draft_from_controls(&self) -> Option<WorkspaceDraft> { |
| 747 | + let row = self.selected_workspace_row()?; |
| 748 | + if !row.editable { |
| 749 | + return None; |
| 750 | + } |
| 751 | + let ui_borrow = self.ivars().workspaces_ui.borrow(); |
| 752 | + let ui = ui_borrow.as_ref()?; |
| 753 | + |
| 754 | + let mut draft = self |
| 755 | + .ivars() |
| 756 | + .draft_workspace |
| 757 | + .borrow() |
| 758 | + .clone() |
| 759 | + .unwrap_or_else(|| WorkspaceDraft::from_row(&row)); |
| 760 | + draft.monitor_display_id = popup_selected_display_id(&ui.monitor_popup); |
| 761 | + draft.layout = WorkspaceLayout::Bsp; |
| 762 | + draft.gap_inner = parse_optional_f64_field(&ui.gap_inner_field); |
| 763 | + draft.gap_outer = parse_optional_f64_field(&ui.gap_outer_field); |
| 764 | + draft.overlay_position = popup_overlay_position(&ui.overlay_position_popup); |
| 765 | + draft.overlay_width = parse_f64_field(&ui.overlay_width_field, draft.overlay_width); |
| 766 | + draft.overlay_height = parse_f64_field(&ui.overlay_height_field, draft.overlay_height); |
| 767 | + |
| 768 | + *self.ivars().draft_workspace.borrow_mut() = Some(draft.clone()); |
| 769 | + Some(draft) |
| 770 | + } |
| 771 | + |
| 772 | + fn emit_current_workspace_draft(&self) { |
| 773 | + if let Some(draft) = self.sync_workspace_draft_from_controls() { |
| 774 | + self.emit(SettingsAction::UpdateWorkspaceDraft(draft)); |
| 775 | + self.refresh_workspace_inspector(); |
| 776 | + } |
| 777 | + } |
| 518 | 778 | } |
| 519 | 779 | |
| 520 | 780 | define_class!( |
@@ -642,14 +902,76 @@ define_class!( |
| 642 | 902 | self.emit(SettingsAction::ResetManagedKeybinds); |
| 643 | 903 | } |
| 644 | 904 | |
| 645 | | - #[unsafe(method(onApplyManagedWorkspaces:))] |
| 646 | | - fn on_apply_managed_workspaces(&self, _sender: Option<&AnyObject>) { |
| 647 | | - let text = { |
| 648 | | - let editor = self.ivars().workspaces_editor.borrow(); |
| 649 | | - let Some(editor) = editor.as_ref() else { return }; |
| 650 | | - editor.string().to_string() |
| 905 | + #[unsafe(method(onWorkspaceAddLettered:))] |
| 906 | + fn on_workspace_add_lettered(&self, _sender: Option<&AnyObject>) { |
| 907 | + let existing = self |
| 908 | + .ivars() |
| 909 | + .workspace_rows |
| 910 | + .borrow() |
| 911 | + .iter() |
| 912 | + .map(|row| row.id.clone()) |
| 913 | + .collect::<Vec<_>>(); |
| 914 | + if let Some(letter) = prompt_for_workspace_text( |
| 915 | + self.mtm(), |
| 916 | + "Add Lettered Workspace", |
| 917 | + "Enter a single unused letter.", |
| 918 | + "W", |
| 919 | + |value| validate_lettered_workspace_id(value, &existing), |
| 920 | + ) { |
| 921 | + self.emit(SettingsAction::AddLetteredWorkspace(letter)); |
| 922 | + } |
| 923 | + } |
| 924 | + |
| 925 | + #[unsafe(method(onWorkspaceAddSpecial:))] |
| 926 | + fn on_workspace_add_special(&self, _sender: Option<&AnyObject>) { |
| 927 | + let existing = self |
| 928 | + .ivars() |
| 929 | + .workspace_rows |
| 930 | + .borrow() |
| 931 | + .iter() |
| 932 | + .map(|row| row.id.clone()) |
| 933 | + .collect::<Vec<_>>(); |
| 934 | + if let Some(name) = prompt_for_workspace_text( |
| 935 | + self.mtm(), |
| 936 | + "Add Special Workspace", |
| 937 | + "Enter a non-empty special workspace name.", |
| 938 | + "terminal", |
| 939 | + |value| validate_special_workspace_name(value, &existing), |
| 940 | + ) { |
| 941 | + self.emit(SettingsAction::AddSpecialWorkspace(name)); |
| 942 | + } |
| 943 | + } |
| 944 | + |
| 945 | + #[unsafe(method(onWorkspaceCopyToManaged:))] |
| 946 | + fn on_workspace_copy_to_managed(&self, _sender: Option<&AnyObject>) { |
| 947 | + let Some(id) = self.ivars().selected_workspace_id.borrow().clone() else { |
| 948 | + return; |
| 949 | + }; |
| 950 | + self.emit(SettingsAction::CopyWorkspaceToManaged(id)); |
| 951 | + } |
| 952 | + |
| 953 | + #[unsafe(method(onWorkspaceDelete:))] |
| 954 | + fn on_workspace_delete(&self, _sender: Option<&AnyObject>) { |
| 955 | + let Some(id) = self.ivars().selected_workspace_id.borrow().clone() else { |
| 956 | + return; |
| 957 | + }; |
| 958 | + self.emit(SettingsAction::DeleteWorkspace(id)); |
| 959 | + } |
| 960 | + |
| 961 | + #[unsafe(method(onWorkspaceApply:))] |
| 962 | + fn on_workspace_apply(&self, _sender: Option<&AnyObject>) { |
| 963 | + let Some(id) = self.ivars().selected_workspace_id.borrow().clone() else { |
| 964 | + return; |
| 651 | 965 | }; |
| 652 | | - self.emit(SettingsAction::ApplyManagedWorkspaces(text)); |
| 966 | + if let Some(draft) = self.sync_workspace_draft_from_controls() { |
| 967 | + self.emit(SettingsAction::UpdateWorkspaceDraft(draft)); |
| 968 | + self.emit(SettingsAction::ApplyWorkspace(id)); |
| 969 | + } |
| 970 | + } |
| 971 | + |
| 972 | + #[unsafe(method(onWorkspaceDraftChanged:))] |
| 973 | + fn on_workspace_draft_changed(&self, _sender: Option<&AnyObject>) { |
| 974 | + self.emit_current_workspace_draft(); |
| 653 | 975 | } |
| 654 | 976 | |
| 655 | 977 | #[unsafe(method(onRuleAdd:))] |
@@ -729,7 +1051,13 @@ define_class!( |
| 729 | 1051 | usize::from(std::ptr::eq(_table_view, &*ui.table)) |
| 730 | 1052 | }); |
| 731 | 1053 | if rule_count == 1 { |
| 732 | | - self.ivars().rule_rows.borrow().len() as NSInteger |
| 1054 | + return self.ivars().rule_rows.borrow().len() as NSInteger; |
| 1055 | + } |
| 1056 | + let workspace_count = self.ivars().workspaces_ui.borrow().as_ref().map_or(0, |ui| { |
| 1057 | + usize::from(std::ptr::eq(_table_view, &*ui.table)) |
| 1058 | + }); |
| 1059 | + if workspace_count == 1 { |
| 1060 | + self.ivars().workspace_rows.borrow().len() as NSInteger |
| 733 | 1061 | } else { |
| 734 | 1062 | 0 |
| 735 | 1063 | } |
@@ -780,6 +1108,33 @@ define_class!( |
| 780 | 1108 | return Retained::into_raw(container); |
| 781 | 1109 | } |
| 782 | 1110 | |
| 1111 | + if let Some(row_data) = self |
| 1112 | + .ivars() |
| 1113 | + .workspaces_ui |
| 1114 | + .borrow() |
| 1115 | + .as_ref() |
| 1116 | + .filter(|ui| std::ptr::eq(table_view, &*ui.table)) |
| 1117 | + .and_then(|_| self.ivars().workspace_rows.borrow().get(row).cloned()) |
| 1118 | + { |
| 1119 | + match identifier.as_str() { |
| 1120 | + WORKSPACE_COLUMN_ID => add_table_label(mtm, &container, &row_data.id, width), |
| 1121 | + WORKSPACE_COLUMN_KIND => { |
| 1122 | + add_table_label(mtm, &container, row_data.kind_label(), width); |
| 1123 | + } |
| 1124 | + WORKSPACE_COLUMN_MONITOR => { |
| 1125 | + add_table_label(mtm, &container, &row_data.monitor_summary(), width); |
| 1126 | + } |
| 1127 | + WORKSPACE_COLUMN_SUMMARY => { |
| 1128 | + add_table_label(mtm, &container, &row_data.summary(), width); |
| 1129 | + } |
| 1130 | + WORKSPACE_COLUMN_SOURCE => { |
| 1131 | + add_table_label(mtm, &container, row_data.source_label(), width); |
| 1132 | + } |
| 1133 | + _ => {} |
| 1134 | + } |
| 1135 | + return Retained::into_raw(container); |
| 1136 | + } |
| 1137 | + |
| 783 | 1138 | let Some(row_data) = self.ivars().rule_rows.borrow().get(row).cloned() else { |
| 784 | 1139 | return std::ptr::null_mut(); |
| 785 | 1140 | }; |
@@ -843,6 +1198,34 @@ define_class!( |
| 843 | 1198 | } |
| 844 | 1199 | } |
| 845 | 1200 | |
| 1201 | + if !*self.ivars().suppress_workspace_selection_change.borrow() { |
| 1202 | + let selected_row = { |
| 1203 | + let ui_borrow = self.ivars().workspaces_ui.borrow(); |
| 1204 | + match ui_borrow.as_ref() { |
| 1205 | + Some(ui) => ui.table.selectedRow(), |
| 1206 | + None => -1, |
| 1207 | + } |
| 1208 | + }; |
| 1209 | + if selected_row >= 0 { |
| 1210 | + let row_index = selected_row as usize; |
| 1211 | + let should_select = self |
| 1212 | + .ivars() |
| 1213 | + .workspace_rows |
| 1214 | + .borrow() |
| 1215 | + .get(row_index) |
| 1216 | + .is_some_and(|row| { |
| 1217 | + self.ivars() |
| 1218 | + .selected_workspace_id |
| 1219 | + .borrow() |
| 1220 | + .as_deref() |
| 1221 | + != Some(row.id.as_str()) |
| 1222 | + }); |
| 1223 | + if should_select { |
| 1224 | + self.select_workspace_by_row_index(row_index, true); |
| 1225 | + } |
| 1226 | + } |
| 1227 | + } |
| 1228 | + |
| 846 | 1229 | if *self.ivars().suppress_rule_selection_change.borrow() { |
| 847 | 1230 | return; |
| 848 | 1231 | } |
@@ -879,8 +1262,6 @@ pub struct SettingsWindow { |
| 879 | 1262 | bar_height_label: Retained<NSTextField>, |
| 880 | 1263 | border_width_label: Retained<NSTextField>, |
| 881 | 1264 | border_radius_label: Retained<NSTextField>, |
| 882 | | - workspaces_external: Retained<NSTextView>, |
| 883 | | - workspaces_editor: Retained<NSTextView>, |
| 884 | 1265 | } |
| 885 | 1266 | |
| 886 | 1267 | impl SettingsWindow { |
@@ -920,18 +1301,8 @@ impl SettingsWindow { |
| 920 | 1301 | let rules = build_rules_tab(mtm, &handler); |
| 921 | 1302 | handler.set_rules_ui(rules.ui); |
| 922 | 1303 | |
| 923 | | - let workspaces = build_editor_tab( |
| 924 | | - mtm, |
| 925 | | - &handler, |
| 926 | | - "Use `id | monitor=<display_id> layout=bsp gap_inner=<n> gap_outer=<n>` or `special:name | ... overlay.position=<pos> overlay.width=<f> overlay.height=<f>`.", |
| 927 | | - "Read-only rows (effective config)", |
| 928 | | - "Managed rows", |
| 929 | | - Some(sel!(onApplyManagedWorkspaces:)), |
| 930 | | - None, |
| 931 | | - "Apply", |
| 932 | | - "", |
| 933 | | - ); |
| 934 | | - handler.set_workspaces_editor(workspaces.editor.clone()); |
| 1304 | + let workspaces = build_workspaces_tab(mtm, &handler); |
| 1305 | + handler.set_workspaces_ui(workspaces.ui); |
| 935 | 1306 | |
| 936 | 1307 | let about_view = build_about_view(mtm); |
| 937 | 1308 | |
@@ -981,8 +1352,6 @@ impl SettingsWindow { |
| 981 | 1352 | bar_height_label: general.bar_height_label, |
| 982 | 1353 | border_width_label: general.border_width_label, |
| 983 | 1354 | border_radius_label: general.border_radius_label, |
| 984 | | - workspaces_external: workspaces.read_only, |
| 985 | | - workspaces_editor: workspaces.editor, |
| 986 | 1355 | } |
| 987 | 1356 | } |
| 988 | 1357 | |
@@ -1027,11 +1396,11 @@ impl SettingsWindow { |
| 1027 | 1396 | ); |
| 1028 | 1397 | self.handler |
| 1029 | 1398 | .load_rules(snapshot.rules.clone(), snapshot.selected_rule_id.clone()); |
| 1030 | | - set_text_view( |
| 1031 | | - &self.workspaces_external, |
| 1032 | | - &snapshot.workspaces_external_text, |
| 1399 | + self.handler.load_workspaces( |
| 1400 | + snapshot.workspaces.clone(), |
| 1401 | + snapshot.selected_workspace_id.clone(), |
| 1402 | + snapshot.displays.clone(), |
| 1033 | 1403 | ); |
| 1034 | | - set_text_view(&self.workspaces_editor, &snapshot.workspaces_managed_text); |
| 1035 | 1404 | } |
| 1036 | 1405 | |
| 1037 | 1406 | pub fn poll_actions(&self) -> Vec<SettingsAction> { |
@@ -1076,12 +1445,6 @@ struct GeneralTab { |
| 1076 | 1445 | border_radius_label: Retained<NSTextField>, |
| 1077 | 1446 | } |
| 1078 | 1447 | |
| 1079 | | -struct EditorTab { |
| 1080 | | - root: Retained<NSView>, |
| 1081 | | - read_only: Retained<NSTextView>, |
| 1082 | | - editor: Retained<NSTextView>, |
| 1083 | | -} |
| 1084 | | - |
| 1085 | 1448 | struct RulesTab { |
| 1086 | 1449 | root: Retained<NSView>, |
| 1087 | 1450 | ui: RulesUiRefs, |
@@ -1092,6 +1455,11 @@ struct KeybindingsTab { |
| 1092 | 1455 | ui: KeybindingsUiRefs, |
| 1093 | 1456 | } |
| 1094 | 1457 | |
| 1458 | +struct WorkspacesTab { |
| 1459 | + root: Retained<NSView>, |
| 1460 | + ui: WorkspacesUiRefs, |
| 1461 | +} |
| 1462 | + |
| 1095 | 1463 | fn add_tab( |
| 1096 | 1464 | mtm: MainThreadMarker, |
| 1097 | 1465 | tab_controller: &NSTabViewController, |
@@ -1260,95 +1628,298 @@ fn build_general_view(mtm: MainThreadMarker, handler: &SettingsHandler) -> Gener |
| 1260 | 1628 | y -= row; |
| 1261 | 1629 | let mod_key_popup = add_popup( |
| 1262 | 1630 | mtm, |
| 1263 | | - &view, |
| 1631 | + &view, |
| 1632 | + handler, |
| 1633 | + lx, |
| 1634 | + y, |
| 1635 | + 220.0, |
| 1636 | + &["Command", "Option", "Control"], |
| 1637 | + sel!(onModKeyChanged:), |
| 1638 | + ); |
| 1639 | + |
| 1640 | + GeneralTab { |
| 1641 | + root: view, |
| 1642 | + gap_inner_slider, |
| 1643 | + gap_outer_slider, |
| 1644 | + bar_height_slider, |
| 1645 | + border_width_slider, |
| 1646 | + border_radius_slider, |
| 1647 | + focused_color_well, |
| 1648 | + unfocused_color_well, |
| 1649 | + ffm_checkbox, |
| 1650 | + mff_checkbox, |
| 1651 | + mod_key_popup, |
| 1652 | + gap_inner_label, |
| 1653 | + gap_outer_label, |
| 1654 | + bar_height_label, |
| 1655 | + border_width_label, |
| 1656 | + border_radius_label, |
| 1657 | + } |
| 1658 | +} |
| 1659 | + |
| 1660 | +fn build_workspaces_tab(mtm: MainThreadMarker, handler: &SettingsHandler) -> WorkspacesTab { |
| 1661 | + let frame = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(WIN_W, WIN_H)); |
| 1662 | + let root: Retained<NSView> = unsafe { msg_send![NSView::alloc(mtm), initWithFrame: frame] }; |
| 1663 | + |
| 1664 | + let split: Retained<NSSplitView> = |
| 1665 | + unsafe { msg_send![NSSplitView::alloc(mtm), initWithFrame: frame] }; |
| 1666 | + split.setVertical(true); |
| 1667 | + split.setDividerStyle(NSSplitViewDividerStyle::Thin); |
| 1668 | + split.setAutosaveName(Some(&NSString::from_str("TarmacWorkspacesSplitView"))); |
| 1669 | + |
| 1670 | + let left_width = 372.0; |
| 1671 | + let left_frame = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(left_width, WIN_H)); |
| 1672 | + let right_frame = CGRect::new( |
| 1673 | + CGPoint::new(left_width + 1.0, 0.0), |
| 1674 | + CGSize::new(WIN_W - left_width - 1.0, WIN_H), |
| 1675 | + ); |
| 1676 | + let left: Retained<NSView> = |
| 1677 | + unsafe { msg_send![NSView::alloc(mtm), initWithFrame: left_frame] }; |
| 1678 | + let right: Retained<NSView> = |
| 1679 | + unsafe { msg_send![NSView::alloc(mtm), initWithFrame: right_frame] }; |
| 1680 | + |
| 1681 | + add_section_label(mtm, &left, "Workspaces", 24.0, WIN_H - 46.0); |
| 1682 | + add_wrapped_label( |
| 1683 | + mtm, |
| 1684 | + &left, |
| 1685 | + "Numbered workspaces 1 through 10 are always shown. Copy a default or Lua-authored workspace into managed settings before editing it here.", |
| 1686 | + 24.0, |
| 1687 | + WIN_H - 76.0, |
| 1688 | + left_width - 48.0, |
| 1689 | + 40.0, |
| 1690 | + ); |
| 1691 | + |
| 1692 | + let table_scroll_frame = CGRect::new( |
| 1693 | + CGPoint::new(20.0, 96.0), |
| 1694 | + CGSize::new(left_width - 40.0, WIN_H - 196.0), |
| 1695 | + ); |
| 1696 | + let table_scroll: Retained<NSScrollView> = |
| 1697 | + unsafe { msg_send![NSScrollView::alloc(mtm), initWithFrame: table_scroll_frame] }; |
| 1698 | + table_scroll.setHasVerticalScroller(true); |
| 1699 | + table_scroll.setBorderType(objc2_app_kit::NSBorderType(2)); |
| 1700 | + |
| 1701 | + let table_frame = CGRect::new( |
| 1702 | + CGPoint::new(0.0, 0.0), |
| 1703 | + CGSize::new(left_width - 40.0, WIN_H - 196.0), |
| 1704 | + ); |
| 1705 | + let table: Retained<NSTableView> = |
| 1706 | + unsafe { msg_send![NSTableView::alloc(mtm), initWithFrame: table_frame] }; |
| 1707 | + table.setUsesAlternatingRowBackgroundColors(true); |
| 1708 | + table.setAllowsEmptySelection(true); |
| 1709 | + table.setColumnAutoresizingStyle( |
| 1710 | + objc2_app_kit::NSTableViewColumnAutoresizingStyle::SequentialColumnAutoresizingStyle, |
| 1711 | + ); |
| 1712 | + table.setStyle(NSTableViewStyle::Inset); |
| 1713 | + table.setRowSizeStyle(NSTableViewRowSizeStyle::Medium); |
| 1714 | + table.setRowHeight(28.0); |
| 1715 | + table.setIntercellSpacing(CGSize::new(8.0, 4.0)); |
| 1716 | + |
| 1717 | + add_workspace_table_column(mtm, &table, WORKSPACE_COLUMN_ID, "ID", 64.0); |
| 1718 | + add_workspace_table_column(mtm, &table, WORKSPACE_COLUMN_KIND, "Kind", 84.0); |
| 1719 | + add_workspace_table_column(mtm, &table, WORKSPACE_COLUMN_MONITOR, "Monitor", 112.0); |
| 1720 | + add_workspace_table_column(mtm, &table, WORKSPACE_COLUMN_SUMMARY, "Summary", 210.0); |
| 1721 | + add_workspace_table_column(mtm, &table, WORKSPACE_COLUMN_SOURCE, "Source", 72.0); |
| 1722 | + |
| 1723 | + unsafe { |
| 1724 | + let _: () = msg_send![&*table, setDataSource: handler]; |
| 1725 | + let _: () = msg_send![&*table, setDelegate: handler]; |
| 1726 | + } |
| 1727 | + |
| 1728 | + table_scroll.setDocumentView(Some(&table)); |
| 1729 | + left.addSubview(&table_scroll); |
| 1730 | + |
| 1731 | + let _add_lettered_button = add_button( |
| 1732 | + mtm, |
| 1733 | + &left, |
| 1734 | + handler, |
| 1735 | + "Add Lettered…", |
| 1736 | + 20.0, |
| 1737 | + 34.0, |
| 1738 | + 114.0, |
| 1739 | + sel!(onWorkspaceAddLettered:), |
| 1740 | + ); |
| 1741 | + let _add_special_button = add_button( |
| 1742 | + mtm, |
| 1743 | + &left, |
| 1744 | + handler, |
| 1745 | + "Add Special…", |
| 1746 | + 142.0, |
| 1747 | + 34.0, |
| 1748 | + 108.0, |
| 1749 | + sel!(onWorkspaceAddSpecial:), |
| 1750 | + ); |
| 1751 | + |
| 1752 | + add_section_label(mtm, &right, "Workspace Inspector", 28.0, WIN_H - 46.0); |
| 1753 | + let placeholder_label = add_wrapped_label_field( |
| 1754 | + mtm, |
| 1755 | + &right, |
| 1756 | + "Select a workspace to inspect. Managed rows can be edited here and applied back into the managed config block.", |
| 1757 | + 28.0, |
| 1758 | + WIN_H - 122.0, |
| 1759 | + 460.0, |
| 1760 | + 44.0, |
| 1761 | + ); |
| 1762 | + |
| 1763 | + let mut y = WIN_H - 96.0; |
| 1764 | + add_section_label(mtm, &right, "General", 28.0, y); |
| 1765 | + y -= 34.0; |
| 1766 | + add_label(mtm, &right, "Source", 28.0, y); |
| 1767 | + let source_value = add_value_label(mtm, &right, "", 198.0, y); |
| 1768 | + y -= 34.0; |
| 1769 | + add_label(mtm, &right, "Kind", 28.0, y); |
| 1770 | + let kind_value = add_value_label(mtm, &right, "", 198.0, y); |
| 1771 | + y -= 34.0; |
| 1772 | + add_label(mtm, &right, "Workspace ID", 28.0, y); |
| 1773 | + let workspace_id_value = add_value_label(mtm, &right, "", 198.0, y); |
| 1774 | + |
| 1775 | + y -= 50.0; |
| 1776 | + add_section_label(mtm, &right, "Placement", 28.0, y); |
| 1777 | + y -= 34.0; |
| 1778 | + add_label(mtm, &right, "Monitor", 28.0, y); |
| 1779 | + let monitor_popup = add_popup( |
| 1780 | + mtm, |
| 1781 | + &right, |
| 1782 | + handler, |
| 1783 | + 198.0, |
| 1784 | + y - 3.0, |
| 1785 | + 250.0, |
| 1786 | + &["No preference"], |
| 1787 | + sel!(onWorkspaceDraftChanged:), |
| 1788 | + ); |
| 1789 | + y -= 36.0; |
| 1790 | + add_label(mtm, &right, "Layout", 28.0, y); |
| 1791 | + let layout_popup = add_popup( |
| 1792 | + mtm, |
| 1793 | + &right, |
| 1794 | + handler, |
| 1795 | + 198.0, |
| 1796 | + y - 3.0, |
| 1797 | + 180.0, |
| 1798 | + &["Bsp"], |
| 1799 | + sel!(onWorkspaceDraftChanged:), |
| 1800 | + ); |
| 1801 | + |
| 1802 | + y -= 50.0; |
| 1803 | + add_section_label(mtm, &right, "Gaps", 28.0, y); |
| 1804 | + y -= 34.0; |
| 1805 | + add_label(mtm, &right, "Inner Gap", 28.0, y); |
| 1806 | + let gap_inner_field = add_input_field( |
| 1807 | + mtm, |
| 1808 | + &right, |
| 1809 | + handler, |
| 1810 | + 198.0, |
| 1811 | + y - 3.0, |
| 1812 | + 96.0, |
| 1813 | + "default", |
| 1814 | + sel!(onWorkspaceDraftChanged:), |
| 1815 | + ); |
| 1816 | + add_label(mtm, &right, "Outer Gap", 312.0, y); |
| 1817 | + let gap_outer_field = add_input_field( |
| 1818 | + mtm, |
| 1819 | + &right, |
| 1820 | + handler, |
| 1821 | + 392.0, |
| 1822 | + y - 3.0, |
| 1823 | + 96.0, |
| 1824 | + "default", |
| 1825 | + sel!(onWorkspaceDraftChanged:), |
| 1826 | + ); |
| 1827 | + |
| 1828 | + y -= 54.0; |
| 1829 | + let overlay_section_label = add_section_label_field(mtm, &right, "Special Overlay", 28.0, y); |
| 1830 | + y -= 34.0; |
| 1831 | + add_label(mtm, &right, "Position", 28.0, y); |
| 1832 | + let overlay_position_popup = add_popup( |
| 1833 | + mtm, |
| 1834 | + &right, |
| 1835 | + handler, |
| 1836 | + 198.0, |
| 1837 | + y - 3.0, |
| 1838 | + 140.0, |
| 1839 | + &["Center", "Top", "Bottom"], |
| 1840 | + sel!(onWorkspaceDraftChanged:), |
| 1841 | + ); |
| 1842 | + y -= 36.0; |
| 1843 | + add_label(mtm, &right, "Width", 28.0, y); |
| 1844 | + let overlay_width_field = add_input_field( |
| 1845 | + mtm, |
| 1846 | + &right, |
| 1847 | + handler, |
| 1848 | + 198.0, |
| 1849 | + y - 3.0, |
| 1850 | + 96.0, |
| 1851 | + "0.7", |
| 1852 | + sel!(onWorkspaceDraftChanged:), |
| 1853 | + ); |
| 1854 | + add_label(mtm, &right, "Height", 312.0, y); |
| 1855 | + let overlay_height_field = add_input_field( |
| 1856 | + mtm, |
| 1857 | + &right, |
| 1858 | + handler, |
| 1859 | + 392.0, |
| 1860 | + y - 3.0, |
| 1861 | + 96.0, |
| 1862 | + "0.7", |
| 1863 | + sel!(onWorkspaceDraftChanged:), |
| 1864 | + ); |
| 1865 | + |
| 1866 | + let copy_button = add_button( |
| 1867 | + mtm, |
| 1868 | + &right, |
| 1869 | + handler, |
| 1870 | + "Copy To Managed", |
| 1871 | + right_frame.size.width - 338.0, |
| 1872 | + 34.0, |
| 1873 | + 142.0, |
| 1874 | + sel!(onWorkspaceCopyToManaged:), |
| 1875 | + ); |
| 1876 | + let delete_button = add_button( |
| 1877 | + mtm, |
| 1878 | + &right, |
| 1264 | 1879 | handler, |
| 1265 | | - lx, |
| 1266 | | - y, |
| 1267 | | - 220.0, |
| 1268 | | - &["Command", "Option", "Control"], |
| 1269 | | - sel!(onModKeyChanged:), |
| 1880 | + "Delete", |
| 1881 | + right_frame.size.width - 188.0, |
| 1882 | + 34.0, |
| 1883 | + 82.0, |
| 1884 | + sel!(onWorkspaceDelete:), |
| 1270 | 1885 | ); |
| 1271 | | - |
| 1272 | | - GeneralTab { |
| 1273 | | - root: view, |
| 1274 | | - gap_inner_slider, |
| 1275 | | - gap_outer_slider, |
| 1276 | | - bar_height_slider, |
| 1277 | | - border_width_slider, |
| 1278 | | - border_radius_slider, |
| 1279 | | - focused_color_well, |
| 1280 | | - unfocused_color_well, |
| 1281 | | - ffm_checkbox, |
| 1282 | | - mff_checkbox, |
| 1283 | | - mod_key_popup, |
| 1284 | | - gap_inner_label, |
| 1285 | | - gap_outer_label, |
| 1286 | | - bar_height_label, |
| 1287 | | - border_width_label, |
| 1288 | | - border_radius_label, |
| 1289 | | - } |
| 1290 | | -} |
| 1291 | | - |
| 1292 | | -#[allow(clippy::too_many_arguments)] |
| 1293 | | -fn build_editor_tab( |
| 1294 | | - mtm: MainThreadMarker, |
| 1295 | | - handler: &SettingsHandler, |
| 1296 | | - description: &str, |
| 1297 | | - read_only_title: &str, |
| 1298 | | - editor_title: &str, |
| 1299 | | - primary_action: Option<objc2::runtime::Sel>, |
| 1300 | | - secondary_action: Option<objc2::runtime::Sel>, |
| 1301 | | - primary_label: &str, |
| 1302 | | - secondary_label: &str, |
| 1303 | | -) -> EditorTab { |
| 1304 | | - let frame = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(WIN_W, WIN_H)); |
| 1305 | | - let root: Retained<NSView> = unsafe { msg_send![NSView::alloc(mtm), initWithFrame: frame] }; |
| 1306 | | - |
| 1307 | | - add_wrapped_label( |
| 1886 | + let apply_button = add_button( |
| 1308 | 1887 | mtm, |
| 1309 | | - &root, |
| 1310 | | - description, |
| 1311 | | - 28.0, |
| 1312 | | - WIN_H - 56.0, |
| 1313 | | - WIN_W - 56.0, |
| 1314 | | - 32.0, |
| 1888 | + &right, |
| 1889 | + handler, |
| 1890 | + "Apply Workspace", |
| 1891 | + right_frame.size.width - 144.0, |
| 1892 | + 34.0, |
| 1893 | + 120.0, |
| 1894 | + sel!(onWorkspaceApply:), |
| 1315 | 1895 | ); |
| 1316 | | - add_section_label(mtm, &root, read_only_title, 28.0, WIN_H - 108.0); |
| 1317 | | - let read_only = add_text_editor(mtm, &root, 28.0, WIN_H - 324.0, WIN_W - 56.0, 190.0, false); |
| 1318 | | - add_section_label(mtm, &root, editor_title, 28.0, WIN_H - 360.0); |
| 1319 | | - let editor = add_text_editor(mtm, &root, 28.0, 90.0, WIN_W - 56.0, 230.0, true); |
| 1320 | 1896 | |
| 1321 | | - if let Some(primary_action) = primary_action { |
| 1322 | | - let button = add_button( |
| 1323 | | - mtm, |
| 1324 | | - &root, |
| 1325 | | - handler, |
| 1326 | | - primary_label, |
| 1327 | | - WIN_W - 160.0, |
| 1328 | | - 34.0, |
| 1329 | | - 120.0, |
| 1330 | | - primary_action, |
| 1331 | | - ); |
| 1332 | | - let _ = button; |
| 1333 | | - } |
| 1334 | | - if let Some(secondary_action) = secondary_action { |
| 1335 | | - let button = add_button( |
| 1336 | | - mtm, |
| 1337 | | - &root, |
| 1338 | | - handler, |
| 1339 | | - secondary_label, |
| 1340 | | - WIN_W - 300.0, |
| 1341 | | - 34.0, |
| 1342 | | - 120.0, |
| 1343 | | - secondary_action, |
| 1344 | | - ); |
| 1345 | | - let _ = button; |
| 1346 | | - } |
| 1897 | + split.addSubview(&left); |
| 1898 | + split.addSubview(&right); |
| 1899 | + split.adjustSubviews(); |
| 1900 | + split.setPosition_ofDividerAtIndex(left_width, 0); |
| 1901 | + root.addSubview(&split); |
| 1347 | 1902 | |
| 1348 | | - EditorTab { |
| 1903 | + WorkspacesTab { |
| 1349 | 1904 | root, |
| 1350 | | - read_only, |
| 1351 | | - editor, |
| 1905 | + ui: WorkspacesUiRefs { |
| 1906 | + table, |
| 1907 | + placeholder_label, |
| 1908 | + source_value, |
| 1909 | + kind_value, |
| 1910 | + workspace_id_value, |
| 1911 | + monitor_popup, |
| 1912 | + layout_popup, |
| 1913 | + gap_inner_field, |
| 1914 | + gap_outer_field, |
| 1915 | + overlay_position_popup, |
| 1916 | + overlay_width_field, |
| 1917 | + overlay_height_field, |
| 1918 | + overlay_section_label, |
| 1919 | + copy_button, |
| 1920 | + delete_button, |
| 1921 | + apply_button, |
| 1922 | + }, |
| 1352 | 1923 | } |
| 1353 | 1924 | } |
| 1354 | 1925 | |
@@ -2184,6 +2755,140 @@ fn set_rule_inspector_enabled(ui: &RulesUiRefs, editable: bool, geometry_enabled |
| 2184 | 2755 | .setEnabled(editable && geometry_enabled); |
| 2185 | 2756 | } |
| 2186 | 2757 | |
| 2758 | +fn derive_workspace_inspector_state( |
| 2759 | + rows: &[WorkspaceRow], |
| 2760 | + selected_workspace_id: Option<&str>, |
| 2761 | + draft_workspace: Option<&WorkspaceDraft>, |
| 2762 | +) -> WorkspaceInspectorState { |
| 2763 | + let Some(selected_workspace_id) = selected_workspace_id else { |
| 2764 | + return WorkspaceInspectorState { |
| 2765 | + editable: false, |
| 2766 | + source_label: String::new(), |
| 2767 | + kind_label: String::new(), |
| 2768 | + can_copy_to_managed: false, |
| 2769 | + can_delete: false, |
| 2770 | + delete_label: "Delete".to_string(), |
| 2771 | + can_apply: false, |
| 2772 | + is_special: false, |
| 2773 | + draft: None, |
| 2774 | + }; |
| 2775 | + }; |
| 2776 | + |
| 2777 | + let Some(row) = rows.iter().find(|row| row.id == selected_workspace_id) else { |
| 2778 | + return WorkspaceInspectorState { |
| 2779 | + editable: false, |
| 2780 | + source_label: String::new(), |
| 2781 | + kind_label: String::new(), |
| 2782 | + can_copy_to_managed: false, |
| 2783 | + can_delete: false, |
| 2784 | + delete_label: "Delete".to_string(), |
| 2785 | + can_apply: false, |
| 2786 | + is_special: false, |
| 2787 | + draft: None, |
| 2788 | + }; |
| 2789 | + }; |
| 2790 | + |
| 2791 | + let is_special = row.kind == WorkspaceKind::Special; |
| 2792 | + let delete_label = match row.kind { |
| 2793 | + WorkspaceKind::Numbered if row.editable => "Reset Override".to_string(), |
| 2794 | + _ => "Delete".to_string(), |
| 2795 | + }; |
| 2796 | + |
| 2797 | + WorkspaceInspectorState { |
| 2798 | + editable: row.editable, |
| 2799 | + source_label: row.source_label().to_string(), |
| 2800 | + kind_label: row.kind_label().to_string(), |
| 2801 | + can_copy_to_managed: !row.editable, |
| 2802 | + can_delete: row.editable, |
| 2803 | + delete_label, |
| 2804 | + can_apply: row.editable, |
| 2805 | + is_special, |
| 2806 | + draft: if row.editable { |
| 2807 | + draft_workspace |
| 2808 | + .cloned() |
| 2809 | + .or_else(|| Some(WorkspaceDraft::from_row(row))) |
| 2810 | + } else { |
| 2811 | + Some(WorkspaceDraft::from_row(row)) |
| 2812 | + }, |
| 2813 | + } |
| 2814 | +} |
| 2815 | + |
| 2816 | +fn apply_workspace_inspector_state( |
| 2817 | + ui: &WorkspacesUiRefs, |
| 2818 | + state: &WorkspaceInspectorState, |
| 2819 | + displays: &[WorkspaceDisplayOption], |
| 2820 | +) { |
| 2821 | + let show_placeholder = state.draft.is_none(); |
| 2822 | + ui.placeholder_label.setHidden(!show_placeholder); |
| 2823 | + ui.source_value |
| 2824 | + .setStringValue(&NSString::from_str(&state.source_label)); |
| 2825 | + ui.kind_value |
| 2826 | + .setStringValue(&NSString::from_str(&state.kind_label)); |
| 2827 | + ui.copy_button.setEnabled(state.can_copy_to_managed); |
| 2828 | + ui.delete_button.setEnabled(state.can_delete); |
| 2829 | + ui.delete_button |
| 2830 | + .setTitle(&NSString::from_str(&state.delete_label)); |
| 2831 | + ui.apply_button.setEnabled(state.can_apply); |
| 2832 | + |
| 2833 | + let Some(draft) = state.draft.as_ref() else { |
| 2834 | + ui.workspace_id_value |
| 2835 | + .setStringValue(&NSString::from_str("")); |
| 2836 | + rebuild_monitor_popup(&ui.monitor_popup, displays, None); |
| 2837 | + ui.layout_popup.selectItemAtIndex(0); |
| 2838 | + set_text_field_value(&ui.gap_inner_field, ""); |
| 2839 | + set_text_field_value(&ui.gap_outer_field, ""); |
| 2840 | + ui.overlay_position_popup.selectItemAtIndex(0); |
| 2841 | + set_text_field_value(&ui.overlay_width_field, ""); |
| 2842 | + set_text_field_value(&ui.overlay_height_field, ""); |
| 2843 | + set_workspace_inspector_enabled(ui, false, false); |
| 2844 | + return; |
| 2845 | + }; |
| 2846 | + |
| 2847 | + ui.workspace_id_value |
| 2848 | + .setStringValue(&NSString::from_str(&draft.id)); |
| 2849 | + rebuild_monitor_popup(&ui.monitor_popup, displays, draft.monitor_display_id); |
| 2850 | + ui.layout_popup.selectItemAtIndex(match draft.layout { |
| 2851 | + WorkspaceLayout::Bsp => 0, |
| 2852 | + }); |
| 2853 | + set_text_field_value( |
| 2854 | + &ui.gap_inner_field, |
| 2855 | + &draft.gap_inner.map(format_number_field).unwrap_or_default(), |
| 2856 | + ); |
| 2857 | + set_text_field_value( |
| 2858 | + &ui.gap_outer_field, |
| 2859 | + &draft.gap_outer.map(format_number_field).unwrap_or_default(), |
| 2860 | + ); |
| 2861 | + ui.overlay_position_popup |
| 2862 | + .selectItemAtIndex(match draft.overlay_position.as_str() { |
| 2863 | + "top" => 1, |
| 2864 | + "bottom" => 2, |
| 2865 | + _ => 0, |
| 2866 | + }); |
| 2867 | + set_text_field_value( |
| 2868 | + &ui.overlay_width_field, |
| 2869 | + &format_number_field(draft.overlay_width), |
| 2870 | + ); |
| 2871 | + set_text_field_value( |
| 2872 | + &ui.overlay_height_field, |
| 2873 | + &format_number_field(draft.overlay_height), |
| 2874 | + ); |
| 2875 | + set_workspace_inspector_enabled(ui, state.editable, state.is_special); |
| 2876 | +} |
| 2877 | + |
| 2878 | +fn set_workspace_inspector_enabled(ui: &WorkspacesUiRefs, editable: bool, is_special: bool) { |
| 2879 | + ui.monitor_popup.setEnabled(editable); |
| 2880 | + ui.layout_popup.setEnabled(editable); |
| 2881 | + ui.gap_inner_field.setEnabled(editable); |
| 2882 | + ui.gap_outer_field.setEnabled(editable); |
| 2883 | + ui.overlay_position_popup.setEnabled(editable && is_special); |
| 2884 | + ui.overlay_width_field.setEnabled(editable && is_special); |
| 2885 | + ui.overlay_height_field.setEnabled(editable && is_special); |
| 2886 | + ui.overlay_section_label.setHidden(!is_special); |
| 2887 | + ui.overlay_position_popup.setHidden(!is_special); |
| 2888 | + ui.overlay_width_field.setHidden(!is_special); |
| 2889 | + ui.overlay_height_field.setHidden(!is_special); |
| 2890 | +} |
| 2891 | + |
| 2187 | 2892 | fn add_keybind_table_column( |
| 2188 | 2893 | mtm: MainThreadMarker, |
| 2189 | 2894 | table: &NSTableView, |
@@ -2200,6 +2905,22 @@ fn add_keybind_table_column( |
| 2200 | 2905 | table.addTableColumn(&column); |
| 2201 | 2906 | } |
| 2202 | 2907 | |
| 2908 | +fn add_workspace_table_column( |
| 2909 | + mtm: MainThreadMarker, |
| 2910 | + table: &NSTableView, |
| 2911 | + identifier: &str, |
| 2912 | + title: &str, |
| 2913 | + width: CGFloat, |
| 2914 | +) { |
| 2915 | + let identifier = NSString::from_str(identifier); |
| 2916 | + let column: Retained<NSTableColumn> = |
| 2917 | + unsafe { msg_send![NSTableColumn::alloc(mtm), initWithIdentifier: &*identifier] }; |
| 2918 | + column.setTitle(&NSString::from_str(title)); |
| 2919 | + column.setWidth(width); |
| 2920 | + column.setMinWidth(width.min(180.0)); |
| 2921 | + table.addTableColumn(&column); |
| 2922 | +} |
| 2923 | + |
| 2203 | 2924 | fn add_rule_table_column( |
| 2204 | 2925 | mtm: MainThreadMarker, |
| 2205 | 2926 | table: &NSTableView, |
@@ -2321,7 +3042,13 @@ fn add_wrapped_label_field( |
| 2321 | 3042 | label |
| 2322 | 3043 | } |
| 2323 | 3044 | |
| 2324 | | -fn add_section_label(mtm: MainThreadMarker, parent: &NSView, text: &str, x: f64, y: f64) { |
| 3045 | +fn add_section_label_field( |
| 3046 | + mtm: MainThreadMarker, |
| 3047 | + parent: &NSView, |
| 3048 | + text: &str, |
| 3049 | + x: f64, |
| 3050 | + y: f64, |
| 3051 | +) -> Retained<NSTextField> { |
| 2325 | 3052 | let frame = CGRect::new(CGPoint::new(x, y), CGSize::new(260.0, 24.0)); |
| 2326 | 3053 | let label: Retained<NSTextField> = |
| 2327 | 3054 | unsafe { msg_send![NSTextField::alloc(mtm), initWithFrame: frame] }; |
@@ -2335,6 +3062,11 @@ fn add_section_label(mtm: MainThreadMarker, parent: &NSView, text: &str, x: f64, |
| 2335 | 3062 | label.setFont(Some(&font)); |
| 2336 | 3063 | } |
| 2337 | 3064 | parent.addSubview(&label); |
| 3065 | + label |
| 3066 | +} |
| 3067 | + |
| 3068 | +fn add_section_label(mtm: MainThreadMarker, parent: &NSView, text: &str, x: f64, y: f64) { |
| 3069 | + let _ = add_section_label_field(mtm, parent, text, x, y); |
| 2338 | 3070 | } |
| 2339 | 3071 | |
| 2340 | 3072 | fn add_value_label( |
@@ -2474,39 +3206,6 @@ fn add_input_field( |
| 2474 | 3206 | field |
| 2475 | 3207 | } |
| 2476 | 3208 | |
| 2477 | | -fn add_text_editor( |
| 2478 | | - mtm: MainThreadMarker, |
| 2479 | | - parent: &NSView, |
| 2480 | | - x: f64, |
| 2481 | | - y: f64, |
| 2482 | | - width: f64, |
| 2483 | | - height: f64, |
| 2484 | | - editable: bool, |
| 2485 | | -) -> Retained<NSTextView> { |
| 2486 | | - let scroll_frame = CGRect::new(CGPoint::new(x, y), CGSize::new(width, height)); |
| 2487 | | - let scroll: Retained<NSScrollView> = |
| 2488 | | - unsafe { msg_send![NSScrollView::alloc(mtm), initWithFrame: scroll_frame] }; |
| 2489 | | - scroll.setHasVerticalScroller(true); |
| 2490 | | - scroll.setBorderType(objc2_app_kit::NSBorderType(2)); |
| 2491 | | - |
| 2492 | | - let text_frame = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(width - 24.0, height)); |
| 2493 | | - let text: Retained<NSTextView> = |
| 2494 | | - unsafe { msg_send![NSTextView::alloc(mtm), initWithFrame: text_frame] }; |
| 2495 | | - text.setEditable(editable); |
| 2496 | | - unsafe { |
| 2497 | | - let mono: Retained<objc2_app_kit::NSFont> = msg_send![ |
| 2498 | | - objc2_app_kit::NSFont::class(), |
| 2499 | | - monospacedSystemFontOfSize: 11.0_f64, |
| 2500 | | - weight: 0.0_f64 |
| 2501 | | - ]; |
| 2502 | | - text.setFont(Some(&mono)); |
| 2503 | | - } |
| 2504 | | - |
| 2505 | | - scroll.setDocumentView(Some(&text)); |
| 2506 | | - parent.addSubview(&scroll); |
| 2507 | | - text |
| 2508 | | -} |
| 2509 | | - |
| 2510 | 3209 | #[allow(clippy::too_many_arguments)] |
| 2511 | 3210 | fn add_button( |
| 2512 | 3211 | mtm: MainThreadMarker, |
@@ -2530,10 +3229,6 @@ fn add_button( |
| 2530 | 3229 | button |
| 2531 | 3230 | } |
| 2532 | 3231 | |
| 2533 | | -fn set_text_view(text_view: &NSTextView, content: &str) { |
| 2534 | | - text_view.setString(&NSString::from_str(content)); |
| 2535 | | -} |
| 2536 | | - |
| 2537 | 3232 | fn set_text_field_value(text_field: &NSTextField, content: &str) { |
| 2538 | 3233 | text_field.setStringValue(&NSString::from_str(content)); |
| 2539 | 3234 | } |
@@ -2632,6 +3327,16 @@ fn parse_f64_field(field: &NSTextField, fallback: f64) -> f64 { |
| 2632 | 3327 | .unwrap_or(fallback) |
| 2633 | 3328 | } |
| 2634 | 3329 | |
| 3330 | +fn parse_optional_f64_field(field: &NSTextField) -> Option<f64> { |
| 3331 | + let value = field.stringValue().to_string(); |
| 3332 | + let trimmed = value.trim(); |
| 3333 | + if trimmed.is_empty() { |
| 3334 | + None |
| 3335 | + } else { |
| 3336 | + trimmed.parse::<f64>().ok() |
| 3337 | + } |
| 3338 | +} |
| 3339 | + |
| 2635 | 3340 | fn format_number_field(value: f64) -> String { |
| 2636 | 3341 | if (value.fract()).abs() < f64::EPSILON { |
| 2637 | 3342 | format!("{value:.0}") |
@@ -2640,6 +3345,115 @@ fn format_number_field(value: f64) -> String { |
| 2640 | 3345 | } |
| 2641 | 3346 | } |
| 2642 | 3347 | |
| 3348 | +fn reset_popup_items(popup: &NSPopUpButton, items: &[String], selected_index: NSInteger) { |
| 3349 | + popup.removeAllItems(); |
| 3350 | + for item in items { |
| 3351 | + popup.addItemWithTitle(&NSString::from_str(item)); |
| 3352 | + } |
| 3353 | + popup.selectItemAtIndex(selected_index.max(0)); |
| 3354 | +} |
| 3355 | + |
| 3356 | +fn rebuild_monitor_popup( |
| 3357 | + popup: &NSPopUpButton, |
| 3358 | + displays: &[WorkspaceDisplayOption], |
| 3359 | + selected_display_id: Option<u32>, |
| 3360 | +) { |
| 3361 | + let mut items = vec!["No preference".to_string()]; |
| 3362 | + items.extend(displays.iter().map(|display| display.label.clone())); |
| 3363 | + |
| 3364 | + let selected_index = if let Some(display_id) = selected_display_id { |
| 3365 | + if let Some(index) = displays |
| 3366 | + .iter() |
| 3367 | + .position(|display| display.display_id == display_id) |
| 3368 | + { |
| 3369 | + index as NSInteger + 1 |
| 3370 | + } else { |
| 3371 | + items.push(format!("Disconnected ({display_id})")); |
| 3372 | + items.len() as NSInteger - 1 |
| 3373 | + } |
| 3374 | + } else { |
| 3375 | + 0 |
| 3376 | + }; |
| 3377 | + |
| 3378 | + reset_popup_items(popup, &items, selected_index); |
| 3379 | +} |
| 3380 | + |
| 3381 | +fn popup_selected_display_id(popup: &NSPopUpButton) -> Option<u32> { |
| 3382 | + let title = popup.titleOfSelectedItem()?.to_string(); |
| 3383 | + let start = title.rfind('(')? + 1; |
| 3384 | + let end = title.rfind(')')?; |
| 3385 | + title[start..end].parse::<u32>().ok() |
| 3386 | +} |
| 3387 | + |
| 3388 | +fn popup_overlay_position(popup: &NSPopUpButton) -> String { |
| 3389 | + match popup.indexOfSelectedItem() { |
| 3390 | + 1 => "top".to_string(), |
| 3391 | + 2 => "bottom".to_string(), |
| 3392 | + _ => "center".to_string(), |
| 3393 | + } |
| 3394 | +} |
| 3395 | + |
| 3396 | +fn validate_lettered_workspace_id(value: &str, existing_ids: &[String]) -> Option<String> { |
| 3397 | + let trimmed = value.trim(); |
| 3398 | + let mut chars = trimmed.chars(); |
| 3399 | + let ch = chars.next()?; |
| 3400 | + if chars.next().is_some() || !ch.is_ascii_alphabetic() { |
| 3401 | + return None; |
| 3402 | + } |
| 3403 | + let letter = ch.to_ascii_uppercase().to_string(); |
| 3404 | + (!existing_ids |
| 3405 | + .iter() |
| 3406 | + .any(|id| id.eq_ignore_ascii_case(&letter))) |
| 3407 | + .then_some(letter) |
| 3408 | +} |
| 3409 | + |
| 3410 | +fn validate_special_workspace_name(value: &str, existing_ids: &[String]) -> Option<String> { |
| 3411 | + let trimmed = value |
| 3412 | + .trim() |
| 3413 | + .strip_prefix("special:") |
| 3414 | + .unwrap_or(value.trim()); |
| 3415 | + if trimmed.is_empty() { |
| 3416 | + return None; |
| 3417 | + } |
| 3418 | + let id = format!("special:{trimmed}"); |
| 3419 | + (!existing_ids.iter().any(|existing| existing == &id)).then(|| trimmed.to_string()) |
| 3420 | +} |
| 3421 | + |
| 3422 | +fn prompt_for_workspace_text( |
| 3423 | + mtm: MainThreadMarker, |
| 3424 | + title: &str, |
| 3425 | + message: &str, |
| 3426 | + placeholder: &str, |
| 3427 | + validate: impl Fn(&str) -> Option<String>, |
| 3428 | +) -> Option<String> { |
| 3429 | + let alert: Retained<objc2_app_kit::NSAlert> = |
| 3430 | + unsafe { msg_send![objc2_app_kit::NSAlert::alloc(mtm), init] }; |
| 3431 | + let title = NSString::from_str(title); |
| 3432 | + let message = NSString::from_str(message); |
| 3433 | + unsafe { |
| 3434 | + let _: () = msg_send![&*alert, setMessageText: &*title]; |
| 3435 | + let _: () = msg_send![&*alert, setInformativeText: &*message]; |
| 3436 | + let _: Retained<NSButton> = |
| 3437 | + msg_send![&*alert, addButtonWithTitle: &*NSString::from_str("OK")]; |
| 3438 | + let _: Retained<NSButton> = |
| 3439 | + msg_send![&*alert, addButtonWithTitle: &*NSString::from_str("Cancel")]; |
| 3440 | + } |
| 3441 | + |
| 3442 | + let frame = CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(240.0, 24.0)); |
| 3443 | + let input: Retained<NSTextField> = |
| 3444 | + unsafe { msg_send![NSTextField::alloc(mtm), initWithFrame: frame] }; |
| 3445 | + input.setPlaceholderString(Some(&NSString::from_str(placeholder))); |
| 3446 | + unsafe { |
| 3447 | + let _: () = msg_send![&*alert, setAccessoryView: &*input]; |
| 3448 | + let response: NSInteger = msg_send![&*alert, runModal]; |
| 3449 | + if response != 1000 { |
| 3450 | + return None; |
| 3451 | + } |
| 3452 | + } |
| 3453 | + |
| 3454 | + validate(&input.stringValue().to_string()) |
| 3455 | +} |
| 3456 | + |
| 2643 | 3457 | #[cfg(test)] |
| 2644 | 3458 | mod tests { |
| 2645 | 3459 | use super::*; |
@@ -2719,6 +3533,31 @@ mod tests { |
| 2719 | 3533 | } |
| 2720 | 3534 | } |
| 2721 | 3535 | |
| 3536 | + fn workspace_row(id: &str, source: ConfigSource, kind: WorkspaceKind) -> WorkspaceRow { |
| 3537 | + WorkspaceRow { |
| 3538 | + id: id.to_string(), |
| 3539 | + kind, |
| 3540 | + source, |
| 3541 | + editable: source == ConfigSource::Managed, |
| 3542 | + definition: WorkspaceDefinition { |
| 3543 | + id: crate::core::workspace::WorkspaceId::parse(id).expect("invalid workspace id"), |
| 3544 | + kind, |
| 3545 | + prefs: crate::core::workspace::WorkspacePrefs { |
| 3546 | + monitor: None, |
| 3547 | + default_layout: WorkspaceLayout::Bsp, |
| 3548 | + gap_inner: None, |
| 3549 | + gap_outer: None, |
| 3550 | + }, |
| 3551 | + }, |
| 3552 | + special: (kind == WorkspaceKind::Special).then(|| SpecialWorkspaceConfig { |
| 3553 | + name: id.trim_start_matches("special:").to_string(), |
| 3554 | + position: "center".to_string(), |
| 3555 | + width: 0.7, |
| 3556 | + height: 0.7, |
| 3557 | + }), |
| 3558 | + } |
| 3559 | + } |
| 3560 | + |
| 2722 | 3561 | #[test] |
| 2723 | 3562 | fn selecting_lua_rule_is_read_only() { |
| 2724 | 3563 | let rows = vec![managed_row("rule_001", None), lua_row("lua_rule")]; |
@@ -2785,4 +3624,70 @@ mod tests { |
| 2785 | 3624 | let state = derive_keybind_inspector_state(&rows, Some("managed:0"), Some(&draft)); |
| 2786 | 3625 | assert_eq!(state.draft, Some(draft)); |
| 2787 | 3626 | } |
| 3627 | + |
| 3628 | + #[test] |
| 3629 | + fn selecting_default_workspace_is_read_only() { |
| 3630 | + let rows = vec![ |
| 3631 | + workspace_row("1", ConfigSource::Default, WorkspaceKind::Numbered), |
| 3632 | + workspace_row("A", ConfigSource::Managed, WorkspaceKind::Lettered), |
| 3633 | + ]; |
| 3634 | + let state = derive_workspace_inspector_state(&rows, Some("1"), None); |
| 3635 | + assert!(!state.editable); |
| 3636 | + assert!(state.can_copy_to_managed); |
| 3637 | + assert!(!state.can_apply); |
| 3638 | + assert_eq!(state.source_label, "Default"); |
| 3639 | + } |
| 3640 | + |
| 3641 | + #[test] |
| 3642 | + fn selecting_managed_workspace_is_editable() { |
| 3643 | + let rows = vec![ |
| 3644 | + workspace_row("1", ConfigSource::Default, WorkspaceKind::Numbered), |
| 3645 | + workspace_row("A", ConfigSource::Managed, WorkspaceKind::Lettered), |
| 3646 | + ]; |
| 3647 | + let state = derive_workspace_inspector_state(&rows, Some("A"), None); |
| 3648 | + assert!(state.editable); |
| 3649 | + assert!(state.can_delete); |
| 3650 | + assert!(state.can_apply); |
| 3651 | + assert_eq!(state.source_label, "Managed"); |
| 3652 | + } |
| 3653 | + |
| 3654 | + #[test] |
| 3655 | + fn special_workspace_draft_enables_overlay_fields() { |
| 3656 | + let rows = vec![workspace_row( |
| 3657 | + "special:term", |
| 3658 | + ConfigSource::Managed, |
| 3659 | + WorkspaceKind::Special, |
| 3660 | + )]; |
| 3661 | + let state = derive_workspace_inspector_state(&rows, Some("special:term"), None); |
| 3662 | + assert!(state.is_special); |
| 3663 | + assert_eq!( |
| 3664 | + state.draft.expect("draft missing").overlay_position, |
| 3665 | + "center".to_string() |
| 3666 | + ); |
| 3667 | + } |
| 3668 | + |
| 3669 | + #[test] |
| 3670 | + fn validate_lettered_workspace_normalizes_and_rejects_duplicates() { |
| 3671 | + assert_eq!( |
| 3672 | + validate_lettered_workspace_id("w", &["A".to_string()]), |
| 3673 | + Some("W".to_string()) |
| 3674 | + ); |
| 3675 | + assert_eq!( |
| 3676 | + validate_lettered_workspace_id("a", &["A".to_string()]), |
| 3677 | + None |
| 3678 | + ); |
| 3679 | + } |
| 3680 | + |
| 3681 | + #[test] |
| 3682 | + fn validate_special_workspace_name_rejects_empty_and_duplicates() { |
| 3683 | + assert_eq!( |
| 3684 | + validate_special_workspace_name("terminal", &[]), |
| 3685 | + Some("terminal".to_string()) |
| 3686 | + ); |
| 3687 | + assert_eq!( |
| 3688 | + validate_special_workspace_name("special:terminal", &["special:terminal".to_string()]), |
| 3689 | + None |
| 3690 | + ); |
| 3691 | + assert_eq!(validate_special_workspace_name(" ", &[]), None); |
| 3692 | + } |
| 2788 | 3693 | } |