gardesk/tarmac / e4de1c8

Browse files

replace workspace text editor

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
e4de1c8967743ee8a4c0f4402a87480bc07e5814
Parents
2ccd192
Tree
6adaae9

4 changed files

StatusFile+-
M tarmac/Cargo.toml 1 0
M tarmac/src/config/document.rs 431 11
M tarmac/src/main.rs 317 160
M tarmac/src/ui/settings.rs 1072 167
tarmac/Cargo.tomlmodified
@@ -7,6 +7,7 @@ edition = "2024"
77
 # macOS interop
88
 objc2 = { version = "0.6", default-features = false, features = ["exception"] }
99
 objc2-app-kit = { version = "0.3", default-features = false, features = [
10
+    "NSAlert",
1011
     "NSApplication",
1112
     "NSButton",
1213
     "NSButtonCell",
tarmac/src/config/document.rsmodified
@@ -1,4 +1,4 @@
1
-use std::collections::HashSet;
1
+use std::collections::{BTreeSet, HashSet};
22
 use std::path::{Path, PathBuf};
33
 
44
 use super::lua::{
@@ -77,6 +77,77 @@ impl RuleRow {
7777
     }
7878
 }
7979
 
80
+#[derive(Debug, Clone, PartialEq)]
81
+pub struct WorkspaceRow {
82
+    pub id: String,
83
+    pub kind: WorkspaceKind,
84
+    pub source: ConfigSource,
85
+    pub editable: bool,
86
+    pub definition: WorkspaceDefinition,
87
+    pub special: Option<SpecialWorkspaceConfig>,
88
+}
89
+
90
+impl WorkspaceRow {
91
+    pub fn source_label(&self) -> &'static str {
92
+        self.source.label()
93
+    }
94
+
95
+    pub fn kind_label(&self) -> &'static str {
96
+        match self.kind {
97
+            WorkspaceKind::Numbered => "Numbered",
98
+            WorkspaceKind::Lettered => "Lettered",
99
+            WorkspaceKind::Special => "Special",
100
+        }
101
+    }
102
+
103
+    pub fn monitor_summary(&self) -> String {
104
+        self.definition
105
+            .prefs
106
+            .monitor
107
+            .as_ref()
108
+            .map(|monitor| format!("Display {}", monitor.display_id))
109
+            .unwrap_or_else(|| "No preference".to_string())
110
+    }
111
+
112
+    pub fn summary(&self) -> String {
113
+        let mut parts = vec![format!(
114
+            "Layout {}",
115
+            self.definition
116
+                .prefs
117
+                .default_layout
118
+                .as_str()
119
+                .to_ascii_uppercase()
120
+        )];
121
+
122
+        if self.definition.prefs.gap_inner.is_some() || self.definition.prefs.gap_outer.is_some() {
123
+            let gap_inner = self
124
+                .definition
125
+                .prefs
126
+                .gap_inner
127
+                .map(lua_number)
128
+                .unwrap_or_else(|| "default".to_string());
129
+            let gap_outer = self
130
+                .definition
131
+                .prefs
132
+                .gap_outer
133
+                .map(lua_number)
134
+                .unwrap_or_else(|| "default".to_string());
135
+            parts.push(format!("Gaps {gap_inner}/{gap_outer}"));
136
+        }
137
+
138
+        if let Some(special) = &self.special {
139
+            parts.push(format!(
140
+                "{} {}×{}",
141
+                special.position,
142
+                lua_number(special.width),
143
+                lua_number(special.height)
144
+            ));
145
+        }
146
+
147
+        parts.join(" · ")
148
+    }
149
+}
150
+
80151
 #[derive(Debug, Clone)]
81152
 pub struct ManagedConfig {
82153
     pub settings: Settings,
@@ -228,6 +299,166 @@ impl ManagedConfigDocument {
228299
         rows.extend(self.external_rule_rows());
229300
         rows
230301
     }
302
+
303
+    pub fn resolved_workspace_defs_and_specials(
304
+        &self,
305
+    ) -> (Vec<WorkspaceDefinition>, Vec<SpecialWorkspaceConfig>) {
306
+        let managed_defs = self
307
+            .managed
308
+            .workspace_defs
309
+            .iter()
310
+            .cloned()
311
+            .map(|def| (def.id.clone(), def))
312
+            .collect::<std::collections::HashMap<_, _>>();
313
+        let effective_defs = self
314
+            .effective
315
+            .workspace_defs
316
+            .iter()
317
+            .cloned()
318
+            .map(|def| (def.id.clone(), def))
319
+            .collect::<std::collections::HashMap<_, _>>();
320
+        let managed_specials = self
321
+            .managed
322
+            .special_configs
323
+            .iter()
324
+            .cloned()
325
+            .map(|special| (special.name.clone(), special))
326
+            .collect::<std::collections::HashMap<_, _>>();
327
+        let effective_specials = self
328
+            .effective
329
+            .special_configs
330
+            .iter()
331
+            .cloned()
332
+            .map(|special| (special.name.clone(), special))
333
+            .collect::<std::collections::HashMap<_, _>>();
334
+
335
+        let mut defs = Vec::new();
336
+        for default_def in super::lua::default_workspace_definitions() {
337
+            let resolved = managed_defs
338
+                .get(&default_def.id)
339
+                .cloned()
340
+                .or_else(|| effective_defs.get(&default_def.id).cloned())
341
+                .unwrap_or(default_def);
342
+            defs.push(resolved);
343
+        }
344
+
345
+        let mut extra_ids = Vec::new();
346
+        let mut seen_extra_ids = HashSet::new();
347
+        for id in managed_defs.keys().chain(effective_defs.keys()) {
348
+            match id {
349
+                WorkspaceId::Numbered(1..=10) => {}
350
+                _ => {
351
+                    if seen_extra_ids.insert(id.clone()) {
352
+                        extra_ids.push(id.clone());
353
+                    }
354
+                }
355
+            }
356
+        }
357
+        for name in managed_specials.keys().chain(effective_specials.keys()) {
358
+            let id = WorkspaceId::Special(name.clone());
359
+            if seen_extra_ids.insert(id.clone()) {
360
+                extra_ids.push(id);
361
+            }
362
+        }
363
+
364
+        extra_ids.sort_by_key(workspace_id_sort_key);
365
+        for id in extra_ids {
366
+            let resolved = managed_defs
367
+                .get(&id)
368
+                .cloned()
369
+                .or_else(|| effective_defs.get(&id).cloned())
370
+                .unwrap_or_else(|| WorkspaceDefinition::new(id));
371
+            defs.push(resolved);
372
+        }
373
+
374
+        let mut special_names = BTreeSet::new();
375
+        for def in &defs {
376
+            if let WorkspaceId::Special(name) = &def.id {
377
+                special_names.insert(name.clone());
378
+            }
379
+        }
380
+        for name in managed_specials.keys().chain(effective_specials.keys()) {
381
+            special_names.insert(name.clone());
382
+        }
383
+
384
+        let mut specials = special_names
385
+            .into_iter()
386
+            .filter_map(|name| {
387
+                managed_specials
388
+                    .get(&name)
389
+                    .cloned()
390
+                    .or_else(|| effective_specials.get(&name).cloned())
391
+            })
392
+            .collect::<Vec<_>>();
393
+        specials.sort_by(|a, b| a.name.cmp(&b.name));
394
+
395
+        (defs, specials)
396
+    }
397
+
398
+    pub fn workspace_rows(&self) -> Vec<WorkspaceRow> {
399
+        let managed_defs = self
400
+            .managed
401
+            .workspace_defs
402
+            .iter()
403
+            .cloned()
404
+            .map(|def| (def.id.clone(), def))
405
+            .collect::<std::collections::HashMap<_, _>>();
406
+        let effective_defs = self
407
+            .effective
408
+            .workspace_defs
409
+            .iter()
410
+            .cloned()
411
+            .map(|def| (def.id.clone(), def))
412
+            .collect::<std::collections::HashMap<_, _>>();
413
+        let managed_specials = self
414
+            .managed
415
+            .special_configs
416
+            .iter()
417
+            .cloned()
418
+            .map(|special| (special.name.clone(), special))
419
+            .collect::<std::collections::HashMap<_, _>>();
420
+        let effective_specials = self
421
+            .effective
422
+            .special_configs
423
+            .iter()
424
+            .cloned()
425
+            .map(|special| (special.name.clone(), special))
426
+            .collect::<std::collections::HashMap<_, _>>();
427
+        let (resolved_defs, _) = self.resolved_workspace_defs_and_specials();
428
+
429
+        let mut rows = resolved_defs
430
+            .into_iter()
431
+            .map(|definition| {
432
+                let source = workspace_row_source(
433
+                    &definition.id,
434
+                    &definition,
435
+                    &managed_defs,
436
+                    &effective_defs,
437
+                    &managed_specials,
438
+                    &effective_specials,
439
+                );
440
+                let special = match &definition.id {
441
+                    WorkspaceId::Special(name) => managed_specials
442
+                        .get(name)
443
+                        .cloned()
444
+                        .or_else(|| effective_specials.get(name).cloned())
445
+                        .or_else(|| Some(SpecialWorkspaceConfig::default_for(name))),
446
+                    _ => None,
447
+                };
448
+
449
+                WorkspaceRow {
450
+                    id: definition.id.to_string(),
451
+                    kind: definition.kind,
452
+                    source,
453
+                    editable: source == ConfigSource::Managed,
454
+                    definition,
455
+                    special,
456
+                }
457
+            })
458
+            .collect::<Vec<_>>();
459
+        rows.sort_by_key(|row| workspace_id_sort_key(&row.definition.id));
460
+        rows
461
+    }
231462
 }
232463
 
233464
 fn build_rule_rows(rules: &[WindowRule], source: ConfigSource) -> Vec<RuleRow> {
@@ -313,6 +544,51 @@ fn resolve_keybind_rows(
313544
     resolved
314545
 }
315546
 
547
+fn workspace_row_source(
548
+    id: &WorkspaceId,
549
+    resolved: &WorkspaceDefinition,
550
+    managed_defs: &std::collections::HashMap<WorkspaceId, WorkspaceDefinition>,
551
+    effective_defs: &std::collections::HashMap<WorkspaceId, WorkspaceDefinition>,
552
+    managed_specials: &std::collections::HashMap<String, SpecialWorkspaceConfig>,
553
+    effective_specials: &std::collections::HashMap<String, SpecialWorkspaceConfig>,
554
+) -> ConfigSource {
555
+    if managed_defs.contains_key(id)
556
+        || matches!(id, WorkspaceId::Special(name) if managed_specials.contains_key(name))
557
+    {
558
+        return ConfigSource::Managed;
559
+    }
560
+
561
+    match id {
562
+        WorkspaceId::Numbered(1..=10) => {
563
+            let default = WorkspaceDefinition::new(id.clone());
564
+            if effective_defs.get(id).is_some_and(|def| def != &default) {
565
+                ConfigSource::Lua
566
+            } else {
567
+                ConfigSource::Default
568
+            }
569
+        }
570
+        WorkspaceId::Numbered(_) => ConfigSource::Lua,
571
+        WorkspaceId::Special(name) => {
572
+            if effective_defs.contains_key(id) || effective_specials.contains_key(name) {
573
+                ConfigSource::Lua
574
+            } else if resolved == &WorkspaceDefinition::new(id.clone()) {
575
+                ConfigSource::Default
576
+            } else {
577
+                ConfigSource::Lua
578
+            }
579
+        }
580
+        WorkspaceId::Lettered(_) => ConfigSource::Lua,
581
+    }
582
+}
583
+
584
+fn workspace_id_sort_key(id: &WorkspaceId) -> (u8, String) {
585
+    match id {
586
+        WorkspaceId::Numbered(num) => (0, format!("{num:02}")),
587
+        WorkspaceId::Lettered(ch) => (1, ch.to_string()),
588
+        WorkspaceId::Special(name) => (2, name.clone()),
589
+    }
590
+}
591
+
316592
 fn split_managed_block(content: &str) -> (String, Option<String>, String) {
317593
     let Some(begin) = content.find(MANAGED_BEGIN) else {
318594
         return (content.to_string(), None, String::new());
@@ -422,15 +698,6 @@ fn write_workspace_defs(out: &mut String, defs: &[WorkspaceDefinition]) {
422698
 
423699
     let mut wrote_any = false;
424700
     for def in defs {
425
-        let is_default_numbered = matches!(def.id, WorkspaceId::Numbered(1..=10))
426
-            && def.kind == WorkspaceKind::Numbered
427
-            && def.prefs.monitor.is_none()
428
-            && def.prefs.gap_inner.is_none()
429
-            && def.prefs.gap_outer.is_none();
430
-        if is_default_numbered {
431
-            continue;
432
-        }
433
-
434701
         if !wrote_any {
435702
             out.push_str("-- Workspaces\n");
436703
             wrote_any = true;
@@ -579,7 +846,7 @@ mod tests {
579846
     use super::*;
580847
     use crate::config::lua::{RuleMatchMode, RulePattern};
581848
     use crate::core::input::{Action, Key, Modifiers};
582
-    use crate::core::workspace::WorkspaceTarget;
849
+    use crate::core::workspace::{WorkspaceLayout, WorkspaceTarget};
583850
 
584851
     #[test]
585852
     fn splits_and_rewrites_managed_block() {
@@ -769,4 +1036,157 @@ mod tests {
7691036
         let parsed = load_config_from_source(&serialized, "rules-roundtrip").rules;
7701037
         assert_eq!(parsed, rules);
7711038
     }
1039
+
1040
+    #[test]
1041
+    fn workspace_rows_prefer_managed_numbered_override() {
1042
+        let managed_def = WorkspaceDefinition {
1043
+            id: WorkspaceId::Numbered(3),
1044
+            kind: WorkspaceKind::Numbered,
1045
+            prefs: crate::core::workspace::WorkspacePrefs {
1046
+                monitor: None,
1047
+                default_layout: WorkspaceLayout::Bsp,
1048
+                gap_inner: Some(20.0),
1049
+                gap_outer: None,
1050
+            },
1051
+        };
1052
+        let lua_def = WorkspaceDefinition {
1053
+            id: WorkspaceId::Numbered(3),
1054
+            kind: WorkspaceKind::Numbered,
1055
+            prefs: crate::core::workspace::WorkspacePrefs {
1056
+                monitor: None,
1057
+                default_layout: WorkspaceLayout::Bsp,
1058
+                gap_inner: Some(8.0),
1059
+                gap_outer: None,
1060
+            },
1061
+        };
1062
+        let doc = ManagedConfigDocument {
1063
+            path: PathBuf::new(),
1064
+            prefix: String::new(),
1065
+            suffix: String::new(),
1066
+            managed: ManagedConfig {
1067
+                settings: Settings::default(),
1068
+                keybinds: Vec::new(),
1069
+                rules: Vec::new(),
1070
+                special_configs: Vec::new(),
1071
+                workspace_defs: vec![managed_def.clone()],
1072
+            },
1073
+            effective: LuaConfig {
1074
+                settings: Settings::default(),
1075
+                keybinds: Vec::new(),
1076
+                rules: Vec::new(),
1077
+                special_configs: Vec::new(),
1078
+                workspace_defs: {
1079
+                    let mut defs = super::super::lua::default_workspace_definitions();
1080
+                    defs.retain(|def| def.id != WorkspaceId::Numbered(3));
1081
+                    defs.push(lua_def);
1082
+                    defs
1083
+                },
1084
+                lua: None,
1085
+                callbacks: Vec::new(),
1086
+            },
1087
+        };
1088
+
1089
+        let rows = doc.workspace_rows();
1090
+        let row = rows
1091
+            .iter()
1092
+            .find(|row| row.id == "3")
1093
+            .expect("workspace 3 row missing");
1094
+        assert_eq!(row.source, ConfigSource::Managed);
1095
+        assert_eq!(row.definition, managed_def);
1096
+        assert_eq!(rows.iter().filter(|row| row.id == "3").count(), 1);
1097
+    }
1098
+
1099
+    #[test]
1100
+    fn workspace_rows_merge_special_workspace_prefs_and_overlay() {
1101
+        let special_def = WorkspaceDefinition {
1102
+            id: WorkspaceId::Special("term".to_string()),
1103
+            kind: WorkspaceKind::Special,
1104
+            prefs: crate::core::workspace::WorkspacePrefs {
1105
+                monitor: Some(crate::core::workspace::MonitorAssignment { display_id: 42 }),
1106
+                default_layout: WorkspaceLayout::Bsp,
1107
+                gap_inner: Some(12.0),
1108
+                gap_outer: Some(18.0),
1109
+            },
1110
+        };
1111
+        let special_overlay = SpecialWorkspaceConfig {
1112
+            name: "term".to_string(),
1113
+            position: "top".to_string(),
1114
+            width: 0.8,
1115
+            height: 0.5,
1116
+        };
1117
+        let doc = ManagedConfigDocument {
1118
+            path: PathBuf::new(),
1119
+            prefix: String::new(),
1120
+            suffix: String::new(),
1121
+            managed: ManagedConfig {
1122
+                settings: Settings::default(),
1123
+                keybinds: Vec::new(),
1124
+                rules: Vec::new(),
1125
+                special_configs: vec![special_overlay.clone()],
1126
+                workspace_defs: vec![special_def.clone()],
1127
+            },
1128
+            effective: LuaConfig {
1129
+                settings: Settings::default(),
1130
+                keybinds: Vec::new(),
1131
+                rules: Vec::new(),
1132
+                special_configs: vec![special_overlay.clone()],
1133
+                workspace_defs: {
1134
+                    let mut defs = super::super::lua::default_workspace_definitions();
1135
+                    defs.push(special_def.clone());
1136
+                    defs
1137
+                },
1138
+                lua: None,
1139
+                callbacks: Vec::new(),
1140
+            },
1141
+        };
1142
+
1143
+        let rows = doc.workspace_rows();
1144
+        let row = rows
1145
+            .iter()
1146
+            .find(|row| row.id == "special:term")
1147
+            .expect("special workspace row missing");
1148
+        assert_eq!(row.source, ConfigSource::Managed);
1149
+        assert_eq!(row.definition, special_def);
1150
+        assert_eq!(row.special.as_ref(), Some(&special_overlay));
1151
+    }
1152
+
1153
+    #[test]
1154
+    fn resolved_workspace_defs_and_specials_prefer_managed_special_overrides() {
1155
+        let managed_special = SpecialWorkspaceConfig {
1156
+            name: "web".to_string(),
1157
+            position: "bottom".to_string(),
1158
+            width: 0.9,
1159
+            height: 0.4,
1160
+        };
1161
+        let lua_special = SpecialWorkspaceConfig {
1162
+            name: "web".to_string(),
1163
+            position: "center".to_string(),
1164
+            width: 0.7,
1165
+            height: 0.7,
1166
+        };
1167
+        let doc = ManagedConfigDocument {
1168
+            path: PathBuf::new(),
1169
+            prefix: String::new(),
1170
+            suffix: String::new(),
1171
+            managed: ManagedConfig {
1172
+                settings: Settings::default(),
1173
+                keybinds: Vec::new(),
1174
+                rules: Vec::new(),
1175
+                special_configs: vec![managed_special.clone()],
1176
+                workspace_defs: Vec::new(),
1177
+            },
1178
+            effective: LuaConfig {
1179
+                settings: Settings::default(),
1180
+                keybinds: Vec::new(),
1181
+                rules: Vec::new(),
1182
+                special_configs: vec![lua_special],
1183
+                workspace_defs: super::super::lua::default_workspace_definitions(),
1184
+                lua: None,
1185
+                callbacks: Vec::new(),
1186
+            },
1187
+        };
1188
+
1189
+        let (_, specials) = doc.resolved_workspace_defs_and_specials();
1190
+        assert_eq!(specials, vec![managed_special]);
1191
+    }
7721192
 }
tarmac/src/main.rsmodified
@@ -2,11 +2,11 @@ use std::cell::RefCell;
22
 use std::ffi::c_void;
33
 use std::ptr;
44
 
5
-use tarmac::config::document::{KeybindRow, ManagedConfigDocument, RuleRow};
6
-use tarmac::config::lua::{LuaKeybind, WindowRule};
5
+use tarmac::config::document::{KeybindRow, ManagedConfigDocument, RuleRow, WorkspaceRow};
6
+use tarmac::config::lua::{LuaKeybind, SpecialWorkspaceConfig, WindowRule};
77
 use tarmac::core::input::{Action, Key, Modifiers};
88
 use tarmac::core::state::WmState;
9
-use tarmac::core::workspace::WorkspaceTarget;
9
+use tarmac::core::workspace::{WorkspaceDefinition, WorkspaceId, WorkspaceKind, WorkspaceTarget};
1010
 use tarmac::platform::event_tap::EventTap;
1111
 use tarmac::platform::hotkey::HotkeyManager;
1212
 use tarmac::platform::permissions;
@@ -25,6 +25,7 @@ thread_local! {
2525
     static SETTINGS_WIN: RefCell<Option<tarmac::ui::settings::SettingsWindow>> = const { RefCell::new(None) };
2626
     static SETTINGS_SELECTED_KEYBIND_ID: RefCell<Option<String>> = const { RefCell::new(None) };
2727
     static SETTINGS_SELECTED_RULE_ID: RefCell<Option<String>> = const { RefCell::new(None) };
28
+    static SETTINGS_SELECTED_WORKSPACE_ID: RefCell<Option<String>> = const { RefCell::new(None) };
2829
 }
2930
 
3031
 fn main() {
@@ -794,8 +795,12 @@ fn load_runtime_config(path: &std::path::Path) -> tarmac::config::lua::LuaConfig
794795
         Ok(doc) => {
795796
             let mod_key = doc.effective.settings.mod_key;
796797
             let resolved_keybinds = doc.resolved_keybinds(mod_key);
798
+            let (resolved_workspace_defs, resolved_special_configs) =
799
+                doc.resolved_workspace_defs_and_specials();
797800
             let mut config = doc.effective;
798801
             config.keybinds = resolved_keybinds;
802
+            config.workspace_defs = resolved_workspace_defs;
803
+            config.special_configs = resolved_special_configs;
799804
             config
800805
         }
801806
         Err(err) => {
@@ -970,8 +975,30 @@ fn build_settings_snapshot() -> Option<tarmac::ui::settings::SettingsSnapshot> {
970975
     let selected_rule_id = resolve_selected_rule_id(&rules, previous_selected_rule_id.as_deref());
971976
     SETTINGS_SELECTED_RULE_ID.with(|slot| *slot.borrow_mut() = selected_rule_id.clone());
972977
 
973
-    let workspaces_external_text = format_workspace_snapshot(&doc, false);
974
-    let workspaces_managed_text = format_workspace_snapshot(&doc, true);
978
+    let workspaces = doc.workspace_rows();
979
+    let previous_selected_workspace_id =
980
+        SETTINGS_SELECTED_WORKSPACE_ID.with(|slot| slot.borrow().clone());
981
+    let selected_workspace_id =
982
+        resolve_selected_workspace_id(&workspaces, previous_selected_workspace_id.as_deref());
983
+    SETTINGS_SELECTED_WORKSPACE_ID.with(|slot| *slot.borrow_mut() = selected_workspace_id.clone());
984
+    let displays = WM_STATE.with(|slot| {
985
+        slot.borrow()
986
+            .as_ref()
987
+            .map(|state| {
988
+                state
989
+                    .monitors
990
+                    .iter()
991
+                    .enumerate()
992
+                    .map(
993
+                        |(index, monitor)| tarmac::ui::settings::WorkspaceDisplayOption {
994
+                            display_id: monitor.id,
995
+                            label: format!("Display {} ({})", index + 1, monitor.id),
996
+                        },
997
+                    )
998
+                    .collect::<Vec<_>>()
999
+            })
1000
+            .unwrap_or_default()
1001
+    });
9751002
 
9761003
     Some(tarmac::ui::settings::SettingsSnapshot {
9771004
         gap_inner: doc.managed.settings.gap_inner,
@@ -992,8 +1019,9 @@ fn build_settings_snapshot() -> Option<tarmac::ui::settings::SettingsSnapshot> {
9921019
         selected_keybind_id,
9931020
         rules,
9941021
         selected_rule_id,
995
-        workspaces_external_text,
996
-        workspaces_managed_text,
1022
+        workspaces,
1023
+        selected_workspace_id,
1024
+        displays,
9971025
     })
9981026
 }
9991027
 
@@ -1045,72 +1073,39 @@ fn fallback_selected_rule_after_delete(rows: &[RuleRow], deleted_id: &str) -> Op
10451073
         .map(|row| row.id.clone())
10461074
 }
10471075
 
1048
-fn format_workspace_snapshot(doc: &ManagedConfigDocument, managed: bool) -> String {
1049
-    let defs = if managed {
1050
-        &doc.managed.workspace_defs
1051
-    } else {
1052
-        &doc.effective.workspace_defs
1053
-    };
1054
-    let specials = if managed {
1055
-        &doc.managed.special_configs
1056
-    } else {
1057
-        &doc.effective.special_configs
1058
-    };
1076
+fn resolve_selected_workspace_id(
1077
+    rows: &[WorkspaceRow],
1078
+    preferred_id: Option<&str>,
1079
+) -> Option<String> {
1080
+    if let Some(preferred_id) = preferred_id
1081
+        && rows.iter().any(|row| row.id == preferred_id)
1082
+    {
1083
+        return Some(preferred_id.to_string());
1084
+    }
10591085
 
1060
-    let mut lines = defs
1061
-        .iter()
1062
-        .filter(|def| {
1063
-            if managed {
1064
-                true
1065
-            } else {
1066
-                !doc.managed
1067
-                    .workspace_defs
1068
-                    .iter()
1069
-                    .any(|managed_def| managed_def == *def)
1070
-            }
1071
-        })
1072
-        .map(|def| {
1073
-            let mut parts = Vec::new();
1074
-            if let Some(monitor) = &def.prefs.monitor {
1075
-                parts.push(format!("monitor={}", monitor.display_id));
1076
-            }
1077
-            parts.push(format!("layout={}", def.prefs.default_layout.as_str()));
1078
-            if let Some(gap_inner) = def.prefs.gap_inner {
1079
-                parts.push(format!(
1080
-                    "gap_inner={}",
1081
-                    tarmac::config::lua::lua_number(gap_inner)
1082
-                ));
1083
-            }
1084
-            if let Some(gap_outer) = def.prefs.gap_outer {
1085
-                parts.push(format!(
1086
-                    "gap_outer={}",
1087
-                    tarmac::config::lua::lua_number(gap_outer)
1088
-                ));
1089
-            }
1090
-            format!("{} | {}", def.id, parts.join(" "))
1091
-        })
1092
-        .collect::<Vec<_>>();
1086
+    rows.iter()
1087
+        .find(|row| row.editable)
1088
+        .or_else(|| rows.first())
1089
+        .map(|row| row.id.clone())
1090
+}
10931091
 
1094
-    for special in specials {
1095
-        if !managed
1096
-            && doc
1097
-                .managed
1098
-                .special_configs
1099
-                .iter()
1100
-                .any(|managed_cfg| managed_cfg == special)
1101
-        {
1102
-            continue;
1103
-        }
1104
-        lines.push(format!(
1105
-            "special:{} | overlay.position={} overlay.width={} overlay.height={}",
1106
-            special.name,
1107
-            special.position,
1108
-            tarmac::config::lua::lua_number(special.width),
1109
-            tarmac::config::lua::lua_number(special.height)
1110
-        ));
1092
+fn fallback_selected_workspace_after_delete(
1093
+    rows: &[WorkspaceRow],
1094
+    deleted_id: &str,
1095
+) -> Option<String> {
1096
+    let deleted_row = rows.iter().find(|row| row.id == deleted_id)?;
1097
+    if deleted_row.kind == WorkspaceKind::Numbered {
1098
+        return Some(deleted_id.to_string());
11111099
     }
11121100
 
1113
-    lines.join("\n")
1101
+    let deleted_index = rows.iter().position(|row| row.id == deleted_id)?;
1102
+    rows.get(deleted_index + 1)
1103
+        .or_else(|| {
1104
+            deleted_index
1105
+                .checked_sub(1)
1106
+                .and_then(|index| rows.get(index))
1107
+        })
1108
+        .map(|row| row.id.clone())
11141109
 }
11151110
 
11161111
 fn managed_rule_index(rules: &[WindowRule], target_id: &str) -> Option<usize> {
@@ -1326,6 +1321,131 @@ fn toggle_managed_rule_enabled(rules: &mut [WindowRule], target_id: &str, enable
13261321
     true
13271322
 }
13281323
 
1324
+fn managed_workspace_def_index(defs: &[WorkspaceDefinition], target_id: &str) -> Option<usize> {
1325
+    let target = WorkspaceId::parse(target_id)?;
1326
+    defs.iter().position(|def| def.id == target)
1327
+}
1328
+
1329
+fn managed_special_index(specials: &[SpecialWorkspaceConfig], target_id: &str) -> Option<usize> {
1330
+    let name = target_id.strip_prefix("special:")?;
1331
+    specials.iter().position(|special| special.name == name)
1332
+}
1333
+
1334
+fn upsert_managed_workspace_def(
1335
+    defs: &mut Vec<WorkspaceDefinition>,
1336
+    definition: WorkspaceDefinition,
1337
+) {
1338
+    if let Some(index) = defs.iter().position(|def| def.id == definition.id) {
1339
+        defs[index] = definition;
1340
+    } else {
1341
+        defs.push(definition);
1342
+    }
1343
+}
1344
+
1345
+fn upsert_managed_special_config(
1346
+    specials: &mut Vec<SpecialWorkspaceConfig>,
1347
+    special: SpecialWorkspaceConfig,
1348
+) {
1349
+    if let Some(index) = specials
1350
+        .iter()
1351
+        .position(|existing| existing.name == special.name)
1352
+    {
1353
+        specials[index] = special;
1354
+    } else {
1355
+        specials.push(special);
1356
+    }
1357
+}
1358
+
1359
+fn add_managed_lettered_workspace(
1360
+    defs: &mut Vec<WorkspaceDefinition>,
1361
+    letter: &str,
1362
+) -> Option<String> {
1363
+    let id = WorkspaceId::parse(letter)?;
1364
+    if !matches!(id, WorkspaceId::Lettered(_)) {
1365
+        return None;
1366
+    }
1367
+    let definition = WorkspaceDefinition::new(id.clone());
1368
+    upsert_managed_workspace_def(defs, definition);
1369
+    Some(id.to_string())
1370
+}
1371
+
1372
+fn add_managed_special_workspace(
1373
+    defs: &mut Vec<WorkspaceDefinition>,
1374
+    specials: &mut Vec<SpecialWorkspaceConfig>,
1375
+    name: &str,
1376
+) -> Option<String> {
1377
+    let name = name.trim();
1378
+    if name.is_empty() {
1379
+        return None;
1380
+    }
1381
+    let id = WorkspaceId::Special(name.to_string());
1382
+    upsert_managed_workspace_def(defs, WorkspaceDefinition::new(id.clone()));
1383
+    upsert_managed_special_config(specials, SpecialWorkspaceConfig::default_for(name));
1384
+    Some(id.to_string())
1385
+}
1386
+
1387
+fn copy_workspace_to_managed(
1388
+    rows: &[WorkspaceRow],
1389
+    defs: &mut Vec<WorkspaceDefinition>,
1390
+    specials: &mut Vec<SpecialWorkspaceConfig>,
1391
+    target_id: &str,
1392
+) -> Option<String> {
1393
+    let row = rows
1394
+        .iter()
1395
+        .find(|row| row.id == target_id && !row.editable)?;
1396
+    upsert_managed_workspace_def(defs, row.definition.clone());
1397
+    if let Some(special) = row.special.clone() {
1398
+        upsert_managed_special_config(specials, special);
1399
+    }
1400
+    Some(row.id.clone())
1401
+}
1402
+
1403
+fn delete_managed_workspace(
1404
+    rows: &[WorkspaceRow],
1405
+    defs: &mut Vec<WorkspaceDefinition>,
1406
+    specials: &mut Vec<SpecialWorkspaceConfig>,
1407
+    target_id: &str,
1408
+) -> bool {
1409
+    let Some(row) = rows.iter().find(|row| row.id == target_id && row.editable) else {
1410
+        return false;
1411
+    };
1412
+
1413
+    let mut changed = false;
1414
+    if let Some(index) = managed_workspace_def_index(defs, target_id) {
1415
+        defs.remove(index);
1416
+        changed = true;
1417
+    }
1418
+    if row.kind == WorkspaceKind::Special
1419
+        && let Some(index) = managed_special_index(specials, target_id)
1420
+    {
1421
+        specials.remove(index);
1422
+        changed = true;
1423
+    }
1424
+    changed
1425
+}
1426
+
1427
+fn replace_managed_workspace(
1428
+    defs: &mut Vec<WorkspaceDefinition>,
1429
+    specials: &mut Vec<SpecialWorkspaceConfig>,
1430
+    target_id: &str,
1431
+    draft: tarmac::ui::settings::WorkspaceDraft,
1432
+) -> bool {
1433
+    if target_id != draft.id {
1434
+        return false;
1435
+    }
1436
+
1437
+    upsert_managed_workspace_def(defs, draft.to_definition());
1438
+    match draft.special_config() {
1439
+        Some(special) => upsert_managed_special_config(specials, special),
1440
+        None => {
1441
+            if let Some(index) = managed_special_index(specials, target_id) {
1442
+                specials.remove(index);
1443
+            }
1444
+        }
1445
+    }
1446
+    true
1447
+}
1448
+
13291449
 fn poll_settings_actions() {
13301450
     use tarmac::ui::settings::SettingsAction;
13311451
 
@@ -1350,6 +1470,7 @@ fn poll_settings_actions() {
13501470
         let should_refresh = false;
13511471
         let mut pending_keybind_draft = None;
13521472
         let mut pending_rule_draft = None;
1473
+        let mut pending_workspace_draft = None;
13531474
         for action in actions {
13541475
             match action {
13551476
                 SettingsAction::GapInner(value) => {
@@ -1510,10 +1631,67 @@ fn poll_settings_actions() {
15101631
                         should_write = true;
15111632
                     }
15121633
                 }
1513
-                SettingsAction::ApplyManagedWorkspaces(text) => {
1514
-                    if let Ok((defs, specials)) = parse_managed_workspaces(&text) {
1515
-                        doc.managed.workspace_defs = defs;
1516
-                        doc.managed.special_configs = specials;
1634
+                SettingsAction::SelectWorkspace(id) => {
1635
+                    SETTINGS_SELECTED_WORKSPACE_ID.with(|slot| *slot.borrow_mut() = Some(id));
1636
+                }
1637
+                SettingsAction::AddLetteredWorkspace(letter) => {
1638
+                    if let Some(id) =
1639
+                        add_managed_lettered_workspace(&mut doc.managed.workspace_defs, &letter)
1640
+                    {
1641
+                        SETTINGS_SELECTED_WORKSPACE_ID.with(|slot| *slot.borrow_mut() = Some(id));
1642
+                        should_write = true;
1643
+                    }
1644
+                }
1645
+                SettingsAction::AddSpecialWorkspace(name) => {
1646
+                    if let Some(id) = add_managed_special_workspace(
1647
+                        &mut doc.managed.workspace_defs,
1648
+                        &mut doc.managed.special_configs,
1649
+                        &name,
1650
+                    ) {
1651
+                        SETTINGS_SELECTED_WORKSPACE_ID.with(|slot| *slot.borrow_mut() = Some(id));
1652
+                        should_write = true;
1653
+                    }
1654
+                }
1655
+                SettingsAction::CopyWorkspaceToManaged(id) => {
1656
+                    let rows = doc.workspace_rows();
1657
+                    if let Some(new_id) = copy_workspace_to_managed(
1658
+                        &rows,
1659
+                        &mut doc.managed.workspace_defs,
1660
+                        &mut doc.managed.special_configs,
1661
+                        &id,
1662
+                    ) {
1663
+                        SETTINGS_SELECTED_WORKSPACE_ID
1664
+                            .with(|slot| *slot.borrow_mut() = Some(new_id));
1665
+                        should_write = true;
1666
+                    }
1667
+                }
1668
+                SettingsAction::DeleteWorkspace(id) => {
1669
+                    let rows = doc.workspace_rows();
1670
+                    let fallback_id = fallback_selected_workspace_after_delete(&rows, &id);
1671
+                    if delete_managed_workspace(
1672
+                        &rows,
1673
+                        &mut doc.managed.workspace_defs,
1674
+                        &mut doc.managed.special_configs,
1675
+                        &id,
1676
+                    ) {
1677
+                        SETTINGS_SELECTED_WORKSPACE_ID
1678
+                            .with(|slot| *slot.borrow_mut() = fallback_id);
1679
+                        should_write = true;
1680
+                    }
1681
+                }
1682
+                SettingsAction::UpdateWorkspaceDraft(draft) => {
1683
+                    pending_workspace_draft = Some(draft);
1684
+                }
1685
+                SettingsAction::ApplyWorkspace(id) => {
1686
+                    if let Some(draft) = pending_workspace_draft.take()
1687
+                        && replace_managed_workspace(
1688
+                            &mut doc.managed.workspace_defs,
1689
+                            &mut doc.managed.special_configs,
1690
+                            &id,
1691
+                            draft,
1692
+                        )
1693
+                    {
1694
+                        SETTINGS_SELECTED_WORKSPACE_ID.with(|slot| *slot.borrow_mut() = Some(id));
15171695
                         should_write = true;
15181696
                     }
15191697
                 }
@@ -1529,93 +1707,6 @@ fn poll_settings_actions() {
15291707
     });
15301708
 }
15311709
 
1532
-fn parse_managed_workspaces(
1533
-    text: &str,
1534
-) -> Result<
1535
-    (
1536
-        Vec<tarmac::core::workspace::WorkspaceDefinition>,
1537
-        Vec<tarmac::config::lua::SpecialWorkspaceConfig>,
1538
-    ),
1539
-    String,
1540
-> {
1541
-    let mut source = String::new();
1542
-    for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) {
1543
-        let Some((id, rest)) = line.split_once('|') else {
1544
-            return Err(format!("invalid workspace line: {line}"));
1545
-        };
1546
-        let id = id.trim();
1547
-        let entries = rest.trim();
1548
-        let mut workspace_entries = Vec::new();
1549
-        let mut overlay_entries = Vec::new();
1550
-        for token in entries.split_whitespace() {
1551
-            let Some((key, value)) = token.split_once('=') else {
1552
-                continue;
1553
-            };
1554
-            if let Some(overlay_key) = key.strip_prefix("overlay.") {
1555
-                overlay_entries.push((overlay_key.to_string(), value.to_string()));
1556
-            } else {
1557
-                workspace_entries.push((key.to_string(), value.to_string()));
1558
-            }
1559
-        }
1560
-
1561
-        source.push_str(&format!(
1562
-            "gar.workspace({}, {{{}}})\n",
1563
-            workspace_id_lua_literal(id),
1564
-            workspace_tokens_to_lua(&workspace_entries)
1565
-        ));
1566
-        if let Some(name) = id.strip_prefix("special:")
1567
-            && !overlay_entries.is_empty()
1568
-        {
1569
-            source.push_str(&format!(
1570
-                "gar.special_workspace({}, {{{}}})\n",
1571
-                tarmac::config::lua::lua_string(name),
1572
-                workspace_tokens_to_lua(&overlay_entries)
1573
-            ));
1574
-        }
1575
-    }
1576
-
1577
-    let config = tarmac::config::lua::load_config_from_source(&source, "managed-workspaces");
1578
-    let defs = config
1579
-        .workspace_defs
1580
-        .into_iter()
1581
-        .filter(|def| {
1582
-            !matches!(
1583
-                def.id,
1584
-                tarmac::core::workspace::WorkspaceId::Numbered(1..=10)
1585
-            ) || def.prefs.monitor.is_some()
1586
-                || def.prefs.gap_inner.is_some()
1587
-                || def.prefs.gap_outer.is_some()
1588
-        })
1589
-        .collect();
1590
-    Ok((defs, config.special_configs))
1591
-}
1592
-
1593
-fn workspace_id_lua_literal(id: &str) -> String {
1594
-    if id.chars().all(|ch| ch.is_ascii_digit()) {
1595
-        id.to_string()
1596
-    } else {
1597
-        tarmac::config::lua::lua_string(id)
1598
-    }
1599
-}
1600
-
1601
-fn workspace_tokens_to_lua(entries: &[(String, String)]) -> String {
1602
-    entries
1603
-        .iter()
1604
-        .map(|(key, value)| format!("{key} = {}", lua_value(value)))
1605
-        .collect::<Vec<_>>()
1606
-        .join(", ")
1607
-}
1608
-
1609
-fn lua_value(value: &str) -> String {
1610
-    if value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false") {
1611
-        value.to_ascii_lowercase()
1612
-    } else if value.parse::<f64>().is_ok() {
1613
-        value.to_string()
1614
-    } else {
1615
-        tarmac::config::lua::lua_string(value)
1616
-    }
1617
-}
1618
-
16191710
 fn run_app() {
16201711
     use objc2::MainThreadMarker;
16211712
     use objc2_app_kit::NSApplication;
@@ -1690,6 +1781,22 @@ mod tests {
16901781
         }
16911782
     }
16921783
 
1784
+    fn sample_workspace_row(id: &str, source: ConfigSource, kind: WorkspaceKind) -> WorkspaceRow {
1785
+        WorkspaceRow {
1786
+            id: id.to_string(),
1787
+            kind,
1788
+            source,
1789
+            editable: source == ConfigSource::Managed,
1790
+            definition: WorkspaceDefinition::new(WorkspaceId::parse(id).expect("invalid id")),
1791
+            special: (kind == WorkspaceKind::Special).then(|| SpecialWorkspaceConfig {
1792
+                name: id.trim_start_matches("special:").to_string(),
1793
+                position: "center".to_string(),
1794
+                width: 0.7,
1795
+                height: 0.7,
1796
+            }),
1797
+        }
1798
+    }
1799
+
16931800
     #[test]
16941801
     fn add_keybind_uses_unique_shortcut() {
16951802
         let mut keybinds = vec![sample_keybind(Key::Period)];
@@ -1812,4 +1919,54 @@ mod tests {
18121919
             Some("rule_001")
18131920
         );
18141921
     }
1922
+
1923
+    #[test]
1924
+    fn add_lettered_workspace_normalizes_to_uppercase() {
1925
+        let mut defs = Vec::new();
1926
+        let id = add_managed_lettered_workspace(&mut defs, "w").expect("workspace add failed");
1927
+        assert_eq!(id, "W");
1928
+        assert_eq!(defs[0].id, WorkspaceId::Lettered('W'));
1929
+    }
1930
+
1931
+    #[test]
1932
+    fn add_special_workspace_rejects_empty_name() {
1933
+        let mut defs = Vec::new();
1934
+        let mut specials = Vec::new();
1935
+        assert!(add_managed_special_workspace(&mut defs, &mut specials, "   ").is_none());
1936
+        assert!(defs.is_empty());
1937
+        assert!(specials.is_empty());
1938
+    }
1939
+
1940
+    #[test]
1941
+    fn delete_workspace_selection_keeps_numbered_row_selected() {
1942
+        let rows = vec![
1943
+            sample_workspace_row("1", ConfigSource::Managed, WorkspaceKind::Numbered),
1944
+            sample_workspace_row("A", ConfigSource::Managed, WorkspaceKind::Lettered),
1945
+        ];
1946
+        assert_eq!(
1947
+            fallback_selected_workspace_after_delete(&rows, "1").as_deref(),
1948
+            Some("1")
1949
+        );
1950
+        assert_eq!(
1951
+            fallback_selected_workspace_after_delete(&rows, "A").as_deref(),
1952
+            Some("1")
1953
+        );
1954
+    }
1955
+
1956
+    #[test]
1957
+    fn copy_workspace_to_managed_promotes_inherited_row() {
1958
+        let rows = vec![sample_workspace_row(
1959
+            "special:term",
1960
+            ConfigSource::Lua,
1961
+            WorkspaceKind::Special,
1962
+        )];
1963
+        let mut defs = Vec::new();
1964
+        let mut specials = Vec::new();
1965
+        let id = copy_workspace_to_managed(&rows, &mut defs, &mut specials, "special:term")
1966
+            .expect("copy failed");
1967
+        assert_eq!(id, "special:term");
1968
+        assert_eq!(defs[0].id, WorkspaceId::Special("term".to_string()));
1969
+        assert_eq!(specials[0].name, "term");
1970
+    }
1971
+
18151972
 }
tarmac/src/ui/settings.rsmodified
1490 lines changed — click to load
@@ -10,14 +10,16 @@ use objc2_app_kit::{
1010
     NSBackingStoreType, NSButton, NSColorWell, NSImage, NSPopUpButton, NSScrollView, NSSlider,
1111
     NSSplitView, NSSplitViewDividerStyle, NSTabViewController, NSTabViewControllerTabStyle,
1212
     NSTabViewItem, NSTableColumn, NSTableView, NSTableViewRowSizeStyle, NSTableViewStyle,
13
-    NSTextField, NSTextView, NSView, NSViewController, NSWindow, NSWindowStyleMask,
14
-    NSWindowToolbarStyle,
13
+    NSTextField, NSView, NSViewController, NSWindow, NSWindowStyleMask, NSWindowToolbarStyle,
1514
 };
1615
 use objc2_core_foundation::{CGFloat, CGPoint, CGRect, CGSize};
1716
 use objc2_foundation::{NSIndexSet, NSInteger, NSNotification, NSObject, NSString};
1817
 
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
+};
2123
 
2224
 const WIN_W: f64 = 920.0;
2325
 const WIN_H: f64 = 660.0;
@@ -30,6 +32,11 @@ const RULE_COLUMN_SOURCE: &str = "source";
3032
 const KEYBIND_COLUMN_SHORTCUT: &str = "shortcut";
3133
 const KEYBIND_COLUMN_ACTION: &str = "action";
3234
 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";
3340
 
3441
 #[derive(Debug, Clone, PartialEq, Eq)]
3542
 pub struct KeybindDraft {
@@ -46,6 +53,76 @@ impl KeybindDraft {
4653
     }
4754
 }
4855
 
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
+
49126
 #[derive(Debug)]
50127
 pub enum SettingsAction {
51128
     GapInner(f64),
@@ -74,7 +151,13 @@ pub enum SettingsAction {
74151
     UpdateRuleDraft(WindowRule),
75152
     ApplyRule(String),
76153
     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),
78161
 }
79162
 
80163
 pub struct SettingsSnapshot {
@@ -92,8 +175,9 @@ pub struct SettingsSnapshot {
92175
     pub selected_keybind_id: Option<String>,
93176
     pub rules: Vec<RuleRow>,
94177
     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>,
97181
 }
98182
 
99183
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -120,6 +204,19 @@ struct RuleInspectorState {
120204
     rule: Option<WindowRule>,
121205
 }
122206
 
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
+
123220
 struct KeybindingsUiRefs {
124221
     table: Retained<NSTableView>,
125222
     placeholder_label: Retained<NSTextField>,
@@ -157,9 +254,27 @@ struct RulesUiRefs {
157254
     apply_button: Retained<NSButton>,
158255
 }
159256
 
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
+
160276
 struct SettingsHandlerIvars {
161277
     tx: mpsc::Sender<SettingsAction>,
162
-    workspaces_editor: RefCell<Option<Retained<NSTextView>>>,
163278
     keybind_rows: RefCell<Vec<KeybindRow>>,
164279
     selected_keybind_id: RefCell<Option<String>>,
165280
     draft_keybind: RefCell<Option<KeybindDraft>>,
@@ -170,13 +285,18 @@ struct SettingsHandlerIvars {
170285
     draft_rule: RefCell<Option<WindowRule>>,
171286
     suppress_rule_selection_change: RefCell<bool>,
172287
     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>>,
173294
 }
174295
 
175296
 impl SettingsHandler {
176297
     fn new(mtm: MainThreadMarker, tx: mpsc::Sender<SettingsAction>) -> Retained<Self> {
177298
         let this = mtm.alloc().set_ivars(SettingsHandlerIvars {
178299
             tx,
179
-            workspaces_editor: RefCell::new(None),
180300
             keybind_rows: RefCell::new(Vec::new()),
181301
             selected_keybind_id: RefCell::new(None),
182302
             draft_keybind: RefCell::new(None),
@@ -187,6 +307,12 @@ impl SettingsHandler {
187307
             draft_rule: RefCell::new(None),
188308
             suppress_rule_selection_change: RefCell::new(false),
189309
             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),
190316
         });
191317
         unsafe { msg_send![super(this), init] }
192318
     }
@@ -199,14 +325,14 @@ impl SettingsHandler {
199325
         *self.ivars().keybindings_ui.borrow_mut() = Some(ui);
200326
     }
201327
 
202
-    fn set_workspaces_editor(&self, editor: Retained<NSTextView>) {
203
-        *self.ivars().workspaces_editor.borrow_mut() = Some(editor);
204
-    }
205
-
206328
     fn set_rules_ui(&self, ui: RulesUiRefs) {
207329
         *self.ivars().rules_ui.borrow_mut() = Some(ui);
208330
     }
209331
 
332
+    fn set_workspaces_ui(&self, ui: WorkspacesUiRefs) {
333
+        *self.ivars().workspaces_ui.borrow_mut() = Some(ui);
334
+    }
335
+
210336
     fn load_keybinds(&self, rows: Vec<KeybindRow>, selected_keybind_id: Option<String>) {
211337
         let resolved_selection = if let Some(selected) = selected_keybind_id {
212338
             rows.iter().find(|row| row.id == selected).map(|_| selected)
@@ -245,6 +371,31 @@ impl SettingsHandler {
245371
         self.refresh_rule_inspector();
246372
     }
247373
 
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
+
248399
     fn selected_keybind_row(&self) -> Option<KeybindRow> {
249400
         let selected = self.ivars().selected_keybind_id.borrow().clone()?;
250401
         self.ivars()
@@ -283,6 +434,25 @@ impl SettingsHandler {
283434
             .position(|row| row.id == selected)
284435
     }
285436
 
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
+
286456
     fn current_keybind_inspector_state(&self) -> KeybindInspectorState {
287457
         let rows = self.ivars().keybind_rows.borrow().clone();
288458
         let selected_keybind_id = self.ivars().selected_keybind_id.borrow().clone();
@@ -301,6 +471,17 @@ impl SettingsHandler {
301471
         derive_rule_inspector_state(&rows, selected_rule_id.as_deref(), draft_rule.as_ref())
302472
     }
303473
 
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
+
304485
     fn reload_keybind_table(&self) {
305486
         let selected_index = self.selected_keybind_row_index();
306487
         let ui_borrow = self.ivars().keybindings_ui.borrow();
@@ -339,6 +520,31 @@ impl SettingsHandler {
339520
         *self.ivars().suppress_rule_selection_change.borrow_mut() = false;
340521
     }
341522
 
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
+
342548
     fn refresh_keybind_inspector(&self) {
343549
         let state = self.current_keybind_inspector_state();
344550
         let ui_borrow = self.ivars().keybindings_ui.borrow();
@@ -353,6 +559,14 @@ impl SettingsHandler {
353559
         apply_rule_inspector_state(ui, &state);
354560
     }
355561
 
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
+
356570
     fn select_keybind_by_row_index(&self, row_index: usize, emit_action: bool) {
357571
         let Some(row) = self.ivars().keybind_rows.borrow().get(row_index).cloned() else {
358572
             return;
@@ -378,6 +592,19 @@ impl SettingsHandler {
378592
         }
379593
     }
380594
 
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
+
381608
     fn sync_keybind_draft_from_controls(&self) -> Option<KeybindDraft> {
382609
         let row = self.selected_keybind_row()?;
383610
         if !row.editable {
@@ -515,6 +742,39 @@ impl SettingsHandler {
515742
             self.refresh_rule_inspector();
516743
         }
517744
     }
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
+    }
518778
 }
519779
 
520780
 define_class!(
@@ -642,14 +902,76 @@ define_class!(
642902
             self.emit(SettingsAction::ResetManagedKeybinds);
643903
         }
644904
 
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;
651965
             };
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();
653975
         }
654976
 
655977
         #[unsafe(method(onRuleAdd:))]
@@ -729,7 +1051,13 @@ define_class!(
7291051
                 usize::from(std::ptr::eq(_table_view, &*ui.table))
7301052
             });
7311053
             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
7331061
             } else {
7341062
                 0
7351063
             }
@@ -780,6 +1108,33 @@ define_class!(
7801108
                 return Retained::into_raw(container);
7811109
             }
7821110
 
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
+
7831138
             let Some(row_data) = self.ivars().rule_rows.borrow().get(row).cloned() else {
7841139
                 return std::ptr::null_mut();
7851140
             };
@@ -843,6 +1198,34 @@ define_class!(
8431198
                 }
8441199
             }
8451200
 
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
+
8461229
             if *self.ivars().suppress_rule_selection_change.borrow() {
8471230
                 return;
8481231
             }
@@ -879,8 +1262,6 @@ pub struct SettingsWindow {
8791262
     bar_height_label: Retained<NSTextField>,
8801263
     border_width_label: Retained<NSTextField>,
8811264
     border_radius_label: Retained<NSTextField>,
882
-    workspaces_external: Retained<NSTextView>,
883
-    workspaces_editor: Retained<NSTextView>,
8841265
 }
8851266
 
8861267
 impl SettingsWindow {
@@ -920,18 +1301,8 @@ impl SettingsWindow {
9201301
         let rules = build_rules_tab(mtm, &handler);
9211302
         handler.set_rules_ui(rules.ui);
9221303
 
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);
9351306
 
9361307
         let about_view = build_about_view(mtm);
9371308
 
@@ -981,8 +1352,6 @@ impl SettingsWindow {
9811352
             bar_height_label: general.bar_height_label,
9821353
             border_width_label: general.border_width_label,
9831354
             border_radius_label: general.border_radius_label,
984
-            workspaces_external: workspaces.read_only,
985
-            workspaces_editor: workspaces.editor,
9861355
         }
9871356
     }
9881357
 
@@ -1027,11 +1396,11 @@ impl SettingsWindow {
10271396
         );
10281397
         self.handler
10291398
             .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(),
10331403
         );
1034
-        set_text_view(&self.workspaces_editor, &snapshot.workspaces_managed_text);
10351404
     }
10361405
 
10371406
     pub fn poll_actions(&self) -> Vec<SettingsAction> {
@@ -1076,12 +1445,6 @@ struct GeneralTab {
10761445
     border_radius_label: Retained<NSTextField>,
10771446
 }
10781447
 
1079
-struct EditorTab {
1080
-    root: Retained<NSView>,
1081
-    read_only: Retained<NSTextView>,
1082
-    editor: Retained<NSTextView>,
1083
-}
1084
-
10851448
 struct RulesTab {
10861449
     root: Retained<NSView>,
10871450
     ui: RulesUiRefs,
@@ -1092,6 +1455,11 @@ struct KeybindingsTab {
10921455
     ui: KeybindingsUiRefs,
10931456
 }
10941457
 
1458
+struct WorkspacesTab {
1459
+    root: Retained<NSView>,
1460
+    ui: WorkspacesUiRefs,
1461
+}
1462
+
10951463
 fn add_tab(
10961464
     mtm: MainThreadMarker,
10971465
     tab_controller: &NSTabViewController,
@@ -1260,95 +1628,298 @@ fn build_general_view(mtm: MainThreadMarker, handler: &SettingsHandler) -> Gener
12601628
     y -= row;
12611629
     let mod_key_popup = add_popup(
12621630
         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,
12641879
         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:),
12701885
     );
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(
13081887
         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:),
13151895
     );
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);
13201896
 
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);
13471902
 
1348
-    EditorTab {
1903
+    WorkspacesTab {
13491904
         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
+        },
13521923
     }
13531924
 }
13541925
 
@@ -2184,6 +2755,140 @@ fn set_rule_inspector_enabled(ui: &RulesUiRefs, editable: bool, geometry_enabled
21842755
         .setEnabled(editable && geometry_enabled);
21852756
 }
21862757
 
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
+
21872892
 fn add_keybind_table_column(
21882893
     mtm: MainThreadMarker,
21892894
     table: &NSTableView,
@@ -2200,6 +2905,22 @@ fn add_keybind_table_column(
22002905
     table.addTableColumn(&column);
22012906
 }
22022907
 
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
+
22032924
 fn add_rule_table_column(
22042925
     mtm: MainThreadMarker,
22052926
     table: &NSTableView,
@@ -2321,7 +3042,13 @@ fn add_wrapped_label_field(
23213042
     label
23223043
 }
23233044
 
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> {
23253052
     let frame = CGRect::new(CGPoint::new(x, y), CGSize::new(260.0, 24.0));
23263053
     let label: Retained<NSTextField> =
23273054
         unsafe { msg_send![NSTextField::alloc(mtm), initWithFrame: frame] };
@@ -2335,6 +3062,11 @@ fn add_section_label(mtm: MainThreadMarker, parent: &NSView, text: &str, x: f64,
23353062
         label.setFont(Some(&font));
23363063
     }
23373064
     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);
23383070
 }
23393071
 
23403072
 fn add_value_label(
@@ -2474,39 +3206,6 @@ fn add_input_field(
24743206
     field
24753207
 }
24763208
 
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
-
25103209
 #[allow(clippy::too_many_arguments)]
25113210
 fn add_button(
25123211
     mtm: MainThreadMarker,
@@ -2530,10 +3229,6 @@ fn add_button(
25303229
     button
25313230
 }
25323231
 
2533
-fn set_text_view(text_view: &NSTextView, content: &str) {
2534
-    text_view.setString(&NSString::from_str(content));
2535
-}
2536
-
25373232
 fn set_text_field_value(text_field: &NSTextField, content: &str) {
25383233
     text_field.setStringValue(&NSString::from_str(content));
25393234
 }
@@ -2632,6 +3327,16 @@ fn parse_f64_field(field: &NSTextField, fallback: f64) -> f64 {
26323327
         .unwrap_or(fallback)
26333328
 }
26343329
 
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
+
26353340
 fn format_number_field(value: f64) -> String {
26363341
     if (value.fract()).abs() < f64::EPSILON {
26373342
         format!("{value:.0}")
@@ -2640,6 +3345,115 @@ fn format_number_field(value: f64) -> String {
26403345
     }
26413346
 }
26423347
 
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
+
26433457
 #[cfg(test)]
26443458
 mod tests {
26453459
     use super::*;
@@ -2719,6 +3533,31 @@ mod tests {
27193533
         }
27203534
     }
27213535
 
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
+
27223561
     #[test]
27233562
     fn selecting_lua_rule_is_read_only() {
27243563
         let rows = vec![managed_row("rule_001", None), lua_row("lua_rule")];
@@ -2785,4 +3624,70 @@ mod tests {
27853624
         let state = derive_keybind_inspector_state(&rows, Some("managed:0"), Some(&draft));
27863625
         assert_eq!(state.draft, Some(draft));
27873626
     }
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
+    }
27883693
 }