diff --git a/.afk/plans/recursive-splits-and-window-chooser.md b/.afk/plans/recursive-splits-and-window-chooser.md new file mode 100644 index 0000000..bb0ac6e --- /dev/null +++ b/.afk/plans/recursive-splits-and-window-chooser.md @@ -0,0 +1,88 @@ +# Recursive Splits (Quarters) + Window Chooser + +## Status: In Progress + +Two features for Umber: +1. **Recursive splits** -- split an already-split pane to get 4 panes (quarters) +2. **Window chooser** -- tmux ctrl-b w equivalent for navigating Spaces and documents + +## Approach + +### Feature 1: Quarters via Nested SplitContainerView + +**Key insight:** `SplitContainerView` already accepts any `NSView` as its child -- +including another `SplitContainerView`. Nesting is compositionally free. Each container +stays at exactly 2 children with its own divider. No new tree data structure needed. + +**How it works:** +- When the user splits a pane that's already inside a split, the focused pane's slot + in its parent `SplitContainerView` gets replaced by a NEW `SplitContainerView` that + holds the original pane + the new peer. +- The outer container doesn't know or care that its child is itself a split container. +- Divider drag, layout, and cursor rects all compose naturally because each level is + independent. +- Depth limit: 2 levels (max 4 panes per tab). Enforced by a depth check, not a guard. + +**Model change:** +- `splitPeers` dict value type changes from `(document, direction)` to include an + optional sub-split: `(document, direction, subSplit: (document, container, direction)?)` +- Still keyed by primary document identity. Still flat -- no recursion in the data model. +- Or: a small `SplitEntry` struct that tracks the peer document, direction, the nested + container (if any), and any sub-peer document. + +**Teardown:** Three sequential loops (sub-peers, then peers, then primaries). Same +pattern as today's two-phase teardown. No recursion. + +**Focus movement:** Extended from 2-case binary toggle to 4-case explicit mapping. +Each direction (H/J/K/L) resolves which of the 4 panes to focus by checking which +split level the current focus is in and which direction was requested. + +### Feature 2: Window Chooser via CommandPalette + +All three adversarial critics converged: reuse the existing CommandPalette infrastructure. + +- Add Space-switching commands dynamically from `SpaceWindowController.open` registry +- Fuzzy search comes free from the palette's existing filtering +- Wire ⌘⇧A to open the palette pre-filtered to space/tab navigation +- tmux ctrl-b w is itself a plain text list -- the palette matches that UX + +### Files Changed + +| File | Change | +|------|--------| +| `SpaceViewController+Splits.swift` (272) | Major: drop guard !hasSplit, add sub-split creation, extend focus to 4 panes, nested container swap | +| `SpaceViewController+Closing.swift` (71) | Update: closeSplitPane handles sub-splits, three-phase teardown | +| `SpaceViewController.swift` (354) | Minimal: splitPeers value type widened, selectDocument handles nested splits | +| `SplitContainerView.swift` (323) | Add replaceChild method for swapping a leaf with a nested container | +| `ShellHosting.swift` (239) | Update focusedShellHost for 4-pane first-responder walk | +| `DocumentAreaViewController.swift` (168) | Handle nested container presentation | +| `AppMenu.swift` (326) | Split items: enable when depth < 2. Add ⌘⇧A for window chooser | +| `CommandPalette+Commands.swift` (65) | Add dynamic Space/tab switching commands | +| `AppDelegate.swift` or `AppDelegate+EditorActions.swift` | Add selectSpace/selectTab actions | + +### Risks + +1. **SpaceViewController.swift at 354 LOC** -- no new stored properties, just tuple + type widening. Must extract something to get back under ceiling. +2. **Nested divider drag** -- each SplitContainerView handles its own divider + independently. Manual testing needed to confirm outer divider doesn't capture + inner drag events (hit-test should resolve this since inner container is deeper). +3. **Dimming with 4 panes** -- must dim 3 unfocused panes. The nested container's + `setFocusedChild` only knows about its 2 children; the outer container's dimming + applies to the whole nested container (correct: dimming the outer slot dims all + inner panes via alphaValue propagation through the layer tree). +4. **Click callback lifetime** -- `didReceiveClickInChild` must be set on both the + outer and inner containers, and cleared correctly during teardown. + +### Alternatives Considered (Devils Advocate) + +1. **PaneNode indirect enum tree** (original proposal) -- rejected due to impedance + mismatch with SplitContainerView's 2-slot API, SpaceViewController.swift ceiling + violation, and unspecified spatial tree walk. +2. **Flat TilingLayout enum** (architect) -- rejected because SplitContainerView would + need 4-child layout, multiplying divider/drag/cursor logic. Nesting existing + 2-child containers is simpler. +3. **Second splitPeers dict** (paranoid) -- rejected because it only covers 1+1+2 + layout, not true 2x2 quarters. +4. **Dedicated WindowChooserPanel** (original F2) -- rejected unanimously by all + critics. CommandPalette reuse is cheaper, proven, and matches tmux's actual UX. diff --git a/app/Sources/Umber/AppDelegate+EditorActions.swift b/app/Sources/Umber/AppDelegate+EditorActions.swift index 486fbca..e250263 100644 --- a/app/Sources/Umber/AppDelegate+EditorActions.swift +++ b/app/Sources/Umber/AppDelegate+EditorActions.swift @@ -125,4 +125,24 @@ extension AppDelegate { dir = parent } } + + // MARK: - Space switching (window chooser via command palette) + + /// Switch to a Space by index. Invoked from the command palette's dynamic + /// Space entries, where the `tag` is the index into `SpaceWindowController.open`. + @objc func selectSpace(_ sender: Any?) { + guard let item = sender as? NSMenuItem else { return } + let index = item.tag + let spaces = SpaceWindowController.open + guard spaces.indices.contains(index) else { return } + spaces[index].window?.makeKeyAndOrderFront(nil) + } + + /// ⌘⇧A: open the command palette as a window chooser. Equivalent to typing + /// "space" in the palette, but the shortcut is discoverable from the menu. + @objc func showWindowChooser(_ sender: Any?) { + guard let window = NSApp.keyWindow ?? SpaceWindowController.open.first?.window + else { return } + CommandPalette.shared.toggle(in: window) + } } diff --git a/app/Sources/Umber/AppMenu+Navigate.swift b/app/Sources/Umber/AppMenu+Navigate.swift index 80c1266..ab20665 100644 --- a/app/Sources/Umber/AppMenu+Navigate.swift +++ b/app/Sources/Umber/AppMenu+Navigate.swift @@ -38,6 +38,14 @@ extension AppDelegate { action: Selector(("showSymbolOutline:")), keyEquivalent: "o") symbolItem.keyEquivalentModifierMask = [.command, .shift] navigateMenu.addItem(symbolItem) + // ⌘⇧A — Window Chooser. Opens the command palette with Space entries so + // you can fuzzy-search and switch between open Spaces and their tabs — + // the Umber equivalent of tmux's ctrl-b w. + let chooserItem = NSMenuItem( + title: "Choose Window…", + action: #selector(showWindowChooser(_:)), keyEquivalent: "a") + chooserItem.keyEquivalentModifierMask = [.command, .shift] + navigateMenu.addItem(chooserItem) navigateMenu.addItem(.separator()) let nextTabItem = NSMenuItem( title: "Next Tab", action: #selector(nextDocument(_:)), diff --git a/app/Sources/Umber/CommandPalette+Commands.swift b/app/Sources/Umber/CommandPalette+Commands.swift index 994da78..dbb6fe6 100644 --- a/app/Sources/Umber/CommandPalette+Commands.swift +++ b/app/Sources/Umber/CommandPalette+Commands.swift @@ -61,5 +61,24 @@ extension CommandPalette { PaletteCommand("Settings…", key: "⌘,", action: #selector(AppDelegate.openConfigFile(_:))), PaletteCommand("Reload Config", key: "⌘R", action: #selector(AppDelegate.reloadConfig(_:))), PaletteCommand("New Space", key: "⌘N", action: #selector(AppDelegate.newSpace(_:))), + // Splits + PaletteCommand("Split Right", key: "⌘⇧\\", action: #selector(SpaceViewController.splitHorizontal(_:))), + PaletteCommand("Split Down", key: "⌘⇧-", action: #selector(SpaceViewController.splitVertical(_:))), ] + + /// Dynamic commands generated at show-time: one entry per open Space, so the + /// palette doubles as a tmux ctrl-b w style window chooser when you type "space" + /// or "switch". Rebuilt on each `toggle(in:)` call -- cheap, always fresh. + static func spaceCommands() -> [PaletteCommand] { + SpaceWindowController.open.enumerated().map { idx, wc in + let name = wc.root.lastPathComponent + let isCurrent = wc.window?.isKeyWindow == true + let prefix = isCurrent ? "● " : "" + return PaletteCommand( + "\(prefix)Space: \(name)", + key: idx < 9 ? "⌘\(idx + 1)" : "", + action: #selector(AppDelegate.selectSpace(_:)), + tag: idx) + } + } } diff --git a/app/Sources/Umber/CommandPalette.swift b/app/Sources/Umber/CommandPalette.swift index e894a30..4ca9d1d 100644 --- a/app/Sources/Umber/CommandPalette.swift +++ b/app/Sources/Umber/CommandPalette.swift @@ -70,8 +70,9 @@ final class CommandPalette: NSObject { // MARK: State - /// The full command list. Filtered into `filtered` on every keystroke. - private let all: [PaletteCommand] = CommandPalette.allCommands + /// The full command list (static + dynamic). Rebuilt on each `toggle` so + /// dynamic entries (open Spaces) are always fresh. + private var all: [PaletteCommand] = CommandPalette.allCommands // Internal so the delegate methods in CommandPalette+UI.swift can read it. var filtered: [PaletteCommand] = [] @@ -107,6 +108,8 @@ final class CommandPalette: NSObject { // Build the panel once; subsequent shows just reset query + reposition. if panel == nil { buildPanel() } + // Rebuild command list so dynamic entries (open Spaces) are fresh. + all = Self.allCommands + Self.spaceCommands() guard let p = panel, let sf = searchField, let tv = tableView else { return } diff --git a/app/Sources/Umber/Config+Load.swift b/app/Sources/Umber/Config+Load.swift new file mode 100644 index 0000000..9afd1b7 --- /dev/null +++ b/app/Sources/Umber/Config+Load.swift @@ -0,0 +1,181 @@ +// +// Config+Load.swift +// The loader concern for `AppConfig`: `load()`, font resolution, and +// the system-mono alias table. +// +// Extracted from `Config.swift` at the 350-LOC ceiling. The seam is +// "everything that reads the wire format (`ConfigFile`) and produces a +// resolved `AppConfig`". `Config.swift` owns the types; this file owns +// the decoding. `ConfigFile` is `internal` (not `private`) specifically +// so this extension can decode it — nothing else in the module should +// touch `ConfigFile` directly. +// + +import AppKit + +extension AppConfig { + + /// Resolve the user's config, falling back field-by-field. + /// + /// `@MainActor` because when auto mode is active (`"preset": "auto"`) this reads + /// `NSApp.effectiveAppearance` via `AppearanceObserver.currentIsDark` to choose + /// the initial palette. All callers are already `@MainActor` (`AppDelegate`). + @MainActor static func load() -> AppConfig { + var config = defaults() + let url = configURL + guard let data = try? Data(contentsOf: url) else { + return config // No config file is the normal case, not an error. + } + guard let file = try? JSONDecoder().decode(ConfigFile.self, from: data) else { + config.warnings.append("\(url.path): not valid JSON — using defaults") + return config + } + + // Font. An out-of-range size is reported rather than silently clamped: + // every other field in this file explains itself when it is ignored, and + // a size that quietly did nothing is precisely the failure that makes a + // configurable setting feel unconfigurable. + var size = defaultFontSize + if let requested = file.font?.size { + if requested >= minFontSize && requested <= maxFontSize { + size = requested + } else { + config.warnings.append( + "font.size \(requested) is outside \(Int(minFontSize))–\(Int(maxFontSize)) — using \(Int(size))") + } + } + let resolvedFont = preferredMonoFont(family: file.font?.family, size: size) + config.font = resolvedFont.font + if let warning = resolvedFont.warning { config.warnings.append(warning) } + + // Theme. The whole per-field, fail-soft assembly lives in `Config+Theme.swift` — + // pulled out when Config.swift hit the 350-line ceiling, because "which palette do + // we install given some optional strings a user typed" is one whole concern and + // needs nothing else in `AppConfig`. Primitives rather than the spec itself, so + // `ConfigFile` stays internal to the loader rather than leaking into the rest of + // the module. + if let t = file.theme { + if t.preset?.lowercased() == "auto" { + // Auto mode: resolve both palettes now, store them for the observer, and + // pick the initial theme from the current system appearance. The observer + // in `AppearanceObserver` handles every subsequent switch. + let auto = AppConfig.resolveAutoTheme(dark: t.dark, + light: t.light, + warnings: &config.warnings) + config.autoTheme = auto + config.theme = AppearanceObserver.currentIsDark ? auto.dark : auto.light + } else { + config.theme = AppConfig.resolveTheme(preset: t.preset, + background: t.background, + foreground: t.foreground, + cursor: t.cursor, + ansi: t.ansi, + warnings: &config.warnings) + } + } + + if let raw = file.cursor { + // Same fail-soft shape as `renderer` below: an unrecognised value degrades to + // the default and says so, rather than throwing. The spellings themselves moved + // to `CursorStyle.named(_:)` so the mapping is compilable without AppKit. + if let style = CursorStyle.named(raw) { config.cursorStyle = style } + else { config.warnings.append("cursor '\(raw)' unrecognised — using block") } + } + + if let sb = file.scrollback { + if sb >= 0 { config.scrollback = sb } + else { config.warnings.append("scrollback must be >= 0 — using \(config.scrollback)") } + } + if let sh = file.shell { + // A newline in a shell path is rejected alongside non-executable: a legal macOS + // filename containing one could cause subtle breakage if any downstream consumer + // joins paths with newlines (config renderers, diagnostic output). Caught here so + // the result is one fail-soft warning rather than silent corruption. + if sh.contains(where: \.isNewline) { + config.warnings.append("shell path contains a newline — using \(config.shell)") + } else if FileManager.default.isExecutableFile(atPath: sh) { config.shell = sh } + else { config.warnings.append("shell '\(sh)' is not executable — using \(config.shell)") } + } + if let meta = file.optionAsMeta { config.optionAsMeta = meta } + if let mr = file.mouseReporting { config.mouseReporting = mr } + if let raw = file.renderer { + if let r = Renderer.named(raw) { config.renderer = r } + else { + config.warnings.append( + "renderer '\(raw)' unrecognised (expected one of: \(Renderer.configNames))" + + " — using \(config.renderer.configName)") + } + } + if let v = file.fontThicken { config.fontThicken = v } + if let v = file.lineHeight { + let lo = 0.8, hi = 2.0 + if v >= lo && v <= hi { config.lineHeight = CGFloat(v) } + else { config.warnings.append("lineHeight \(v) is outside \(lo)–\(hi) — using \(config.lineHeight)") } + } + // `ligatures` is accepted in the config file but not yet acted on — the field + // is parsed here so the file stays valid when the feature ships. See the header + // of TerminalPane+Typography.swift for why it is deferred. + if file.ligatures != nil { + config.warnings.append("ligatures: not yet implemented — ignored") + } + if let e = file.editor { + config.applyEditor(tabWidth: e.tabWidth, softTabs: e.softTabs, + wordWrap: e.wordWrap, + indentRainbow: e.indentRainbow, + columnGuide: e.columnGuide, + showTrailingWhitespace: e.showTrailingWhitespace, + stickyScroll: e.stickyScroll) + } + if let p = file.padding { + config.applyPadding(x: p.x, y: p.y) + } + if let op = file.unfocusedPaneOpacity { + // Fail-soft: a value outside [0, 1] is nonsensical (a transparency cannot + // be negative or exceed fully-opaque). Report it and keep the default (1.0) + // rather than clamping silently — clamping would make a typo ("10" instead + // of "1.0") look like success. + if op >= 0.0 && op <= 1.0 { + config.unfocusedPaneOpacity = op + } else { + config.warnings.append( + "unfocusedPaneOpacity \(op) is outside 0.0–1.0 — using 1.0 (no dimming)") + } + } + return config + } + + // MARK: - Font resolution + + /// Names people write in a config when they mean the system monospaced face. + /// None of these resolve through `NSFont(name:)`, so they must be mapped. + /// Internal (not `private`) so `defaults()` in `Config.swift` can call + /// `preferredMonoFont` — both live in the same module but different files. + static let systemMonoAliases: Set = [ + "sf mono", "sfmono", "sfmono-regular", "sf mono regular", "system", "system mono", + ] + + /// Resolve a monospaced font, reporting a human-readable problem when a + /// requested family cannot be honoured. Never returns nil — a terminal with + /// no font is not a useful failure mode. + /// + /// With nothing requested this returns the **system monospaced face**, which + /// is SF Mono and is also exactly what SwiftTerm's own `FontSet.defaultFont` + /// uses. It is deliberately NOT reached via `NSFont(name: "SF Mono")`: that + /// call returns nil, because SF Mono is not registered under that name (nor + /// under "SFMono-Regular"). v0.1 asked for it by name, silently got Menlo as + /// the next candidate, and therefore rendered visibly worse than the Step 0 + /// spike — which set no font at all and so kept SwiftTerm's default. The + /// system-mono call used to sit at the BOTTOM of this function, unreachable, + /// because Menlo always resolves first. + /// + /// Internal (not `private`) so `defaults()` in `Config.swift` can call it — + /// both live in the same module but different files. + static func preferredMonoFont(family: String?, size: Double) -> (font: NSFont, warning: String?) { + let systemMono = NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + guard let family, !systemMonoAliases.contains(family.lowercased()) else { + return (systemMono, nil) + } + if let f = NSFont(name: family, size: size) { return (f, nil) } + return (systemMono, "font.family '\(family)' unavailable — using the system monospaced face") + } +} diff --git a/app/Sources/Umber/Config.swift b/app/Sources/Umber/Config.swift index 7ffb48f..f464c28 100644 --- a/app/Sources/Umber/Config.swift +++ b/app/Sources/Umber/Config.swift @@ -1,6 +1,6 @@ // // Config.swift -// The on-disk config shape and the resolved `AppConfig` it loads into. +// The on-disk config shape (`ConfigFile`) and the resolved `AppConfig` type. // // Config lives at ~/.config/umber/config.json. Every field is optional; // anything absent falls back to a default that is meant to already look good, @@ -9,12 +9,13 @@ // Deliberately plain JSON rather than a bespoke format: it is diffable, // machine-editable, and needs no parser of its own. // -// This file is the *loader* and the single source of truth for defaults. The -// values it resolves into live next door: colour parsing and the presets are in -// `Theme.swift`, and the UserDefaults stores (zoom, remembered Space roots) are -// in `Defaults.swift` — none of which this file's fail-soft contract depends on. -// `ConfigFile` stays `private` here because nothing outside the loader may see -// the wire format; every other file reads the resolved `AppConfig` instead. +// This file owns: `ConfigFile` (the wire-format struct), `AppConfig` (the resolved +// type with its stored properties, statics, `defaults()`, `resized(_:to:)`, and +// `configURL`). +// +// `load()`, `preferredMonoFont`, and `systemMonoAliases` live in `Config+Load.swift` +// — extracted at the 350-LOC ceiling. `ConfigFile` is `internal` (not `private`) so +// that extension file can decode the wire format; nothing else in the module reads it. // import AppKit @@ -25,7 +26,9 @@ import AppKit // MARK: - Config file shape /// On-disk config. All fields optional — see `AppConfig.resolved`. -private struct ConfigFile: Decodable { +/// Internal (not `private`) so `Config+Load.swift` can decode it; nothing else in +/// the module should read `ConfigFile` directly — use the resolved `AppConfig`. +struct ConfigFile: Decodable { struct FontSpec: Decodable { var family: String? var size: Double? @@ -269,159 +272,4 @@ struct AppConfig { ) } - /// Resolve the user's config, falling back field-by-field. - /// - /// `@MainActor` because when auto mode is active (`"preset": "auto"`) this reads - /// `NSApp.effectiveAppearance` via `AppearanceObserver.currentIsDark` to choose - /// the initial palette. All callers are already `@MainActor` (`AppDelegate`). - @MainActor static func load() -> AppConfig { - var config = defaults() - let url = configURL - guard let data = try? Data(contentsOf: url) else { - return config // No config file is the normal case, not an error. - } - guard let file = try? JSONDecoder().decode(ConfigFile.self, from: data) else { - config.warnings.append("\(url.path): not valid JSON — using defaults") - return config - } - - // Font. An out-of-range size is reported rather than silently clamped: - // every other field in this file explains itself when it is ignored, and - // a size that quietly did nothing is precisely the failure that makes a - // configurable setting feel unconfigurable. - var size = defaultFontSize - if let requested = file.font?.size { - if requested >= minFontSize && requested <= maxFontSize { - size = requested - } else { - config.warnings.append( - "font.size \(requested) is outside \(Int(minFontSize))–\(Int(maxFontSize)) — using \(Int(size))") - } - } - let resolvedFont = preferredMonoFont(family: file.font?.family, size: size) - config.font = resolvedFont.font - if let warning = resolvedFont.warning { config.warnings.append(warning) } - - // Theme. The whole per-field, fail-soft assembly lives in `Config+Theme.swift` — - // pulled out when this file hit the 350-line ceiling, because "which palette do we - // install given some optional strings a user typed" is one whole concern and needs - // nothing else in `AppConfig`. Primitives rather than the spec itself, so - // `ConfigFile` can stay `private` to this file. - if let t = file.theme { - if t.preset?.lowercased() == "auto" { - // Auto mode: resolve both palettes now, store them for the observer, and - // pick the initial theme from the current system appearance. The observer - // in `AppearanceObserver` handles every subsequent switch. - let auto = AppConfig.resolveAutoTheme(dark: t.dark, - light: t.light, - warnings: &config.warnings) - config.autoTheme = auto - config.theme = AppearanceObserver.currentIsDark ? auto.dark : auto.light - } else { - config.theme = AppConfig.resolveTheme(preset: t.preset, - background: t.background, - foreground: t.foreground, - cursor: t.cursor, - ansi: t.ansi, - warnings: &config.warnings) - } - } - - if let raw = file.cursor { - // Same fail-soft shape as `renderer` below: an unrecognised value degrades to - // the default and says so, rather than throwing. The spellings themselves moved - // to `CursorStyle.named(_:)` so the mapping is compilable without AppKit. - if let style = CursorStyle.named(raw) { config.cursorStyle = style } - else { config.warnings.append("cursor '\(raw)' unrecognised — using block") } - } - - if let sb = file.scrollback { - if sb >= 0 { config.scrollback = sb } - else { config.warnings.append("scrollback must be >= 0 — using \(config.scrollback)") } - } - if let sh = file.shell { - // A newline in a shell path is rejected alongside non-executable: a legal macOS - // filename containing one could cause subtle breakage if any downstream consumer - // joins paths with newlines (config renderers, diagnostic output). Caught here so - // the result is one fail-soft warning rather than silent corruption. - if sh.contains(where: \.isNewline) { - config.warnings.append("shell path contains a newline — using \(config.shell)") - } else if FileManager.default.isExecutableFile(atPath: sh) { config.shell = sh } - else { config.warnings.append("shell '\(sh)' is not executable — using \(config.shell)") } - } - if let meta = file.optionAsMeta { config.optionAsMeta = meta } - if let mr = file.mouseReporting { config.mouseReporting = mr } - if let raw = file.renderer { - if let r = Renderer.named(raw) { config.renderer = r } - else { - config.warnings.append( - "renderer '\(raw)' unrecognised (expected one of: \(Renderer.configNames))" - + " — using \(config.renderer.configName)") - } - } - if let v = file.fontThicken { config.fontThicken = v } - if let v = file.lineHeight { - let lo = 0.8, hi = 2.0 - if v >= lo && v <= hi { config.lineHeight = CGFloat(v) } - else { config.warnings.append("lineHeight \(v) is outside \(lo)–\(hi) — using \(config.lineHeight)") } - } - // `ligatures` is accepted in the config file but not yet acted on — the field - // is parsed here so the file stays valid when the feature ships. See the header - // of TerminalPane+Typography.swift for why it is deferred. - if file.ligatures != nil { - config.warnings.append("ligatures: not yet implemented — ignored") - } - if let e = file.editor { - config.applyEditor(tabWidth: e.tabWidth, softTabs: e.softTabs, - wordWrap: e.wordWrap, - indentRainbow: e.indentRainbow, - columnGuide: e.columnGuide, - showTrailingWhitespace: e.showTrailingWhitespace, - stickyScroll: e.stickyScroll) - } - if let p = file.padding { - config.applyPadding(x: p.x, y: p.y) - } - if let op = file.unfocusedPaneOpacity { - // Fail-soft: a value outside [0, 1] is nonsensical (a transparency cannot - // be negative or exceed fully-opaque). Report it and keep the default (1.0) - // rather than clamping silently — clamping would make a typo ("10" instead - // of "1.0") look like success. - if op >= 0.0 && op <= 1.0 { - config.unfocusedPaneOpacity = op - } else { - config.warnings.append( - "unfocusedPaneOpacity \(op) is outside 0.0–1.0 — using 1.0 (no dimming)") - } - } - return config - } - - /// Names people write in a config when they mean the system monospaced face. - /// None of these resolve through `NSFont(name:)`, so they must be mapped. - private static let systemMonoAliases: Set = [ - "sf mono", "sfmono", "sfmono-regular", "sf mono regular", "system", "system mono", - ] - - /// Resolve a monospaced font, reporting a human-readable problem when a - /// requested family cannot be honoured. Never returns nil — a terminal with - /// no font is not a useful failure mode. - /// - /// With nothing requested this returns the **system monospaced face**, which - /// is SF Mono and is also exactly what SwiftTerm's own `FontSet.defaultFont` - /// uses. It is deliberately NOT reached via `NSFont(name: "SF Mono")`: that - /// call returns nil, because SF Mono is not registered under that name (nor - /// under "SFMono-Regular"). v0.1 asked for it by name, silently got Menlo as - /// the next candidate, and therefore rendered visibly worse than the Step 0 - /// spike — which set no font at all and so kept SwiftTerm's default. The - /// system-mono call used to sit at the BOTTOM of this function, unreachable, - /// because Menlo always resolves first. - private static func preferredMonoFont(family: String?, size: Double) -> (font: NSFont, warning: String?) { - let systemMono = NSFont.monospacedSystemFont(ofSize: size, weight: .regular) - guard let family, !systemMonoAliases.contains(family.lowercased()) else { - return (systemMono, nil) - } - if let f = NSFont(name: family, size: size) { return (f, nil) } - return (systemMono, "font.family '\(family)' unavailable — using the system monospaced face") - } } diff --git a/app/Sources/Umber/ShellHosting.swift b/app/Sources/Umber/ShellHosting.swift index eb0c32b..de64a9f 100644 --- a/app/Sources/Umber/ShellHosting.swift +++ b/app/Sources/Umber/ShellHosting.swift @@ -209,12 +209,16 @@ extension SpaceViewController { /// fallback total over shells only, which is exactly what the feature means /// (`libghostty-swap-sequencing-2026-07-28.md` §2; `next-sequencing-2026-07-28.md` §1). var focusedShellHost: ShellHosting? { - // In a split, check whether the first responder lives in the peer's view. - // If so, the peer is the intended target even though the primary is "active". - if let active = activeDocument, - let peer = splitPeer(for: active) as? ShellHosting { + // In a split, check whether the first responder lives in any peer's view. + // Walk all split documents (main peer + any sub-split peers) so that focus + // inside a nested quarter pane resolves to the right shell. + if let active = activeDocument { let fr = view.window?.firstResponder - if isDescendant(fr, of: peer.documentView) { return peer } + for peer in allSplitDocuments(for: active) { + if let host = peer as? ShellHosting, isDescendant(fr, of: peer.documentView) { + return host + } + } } return (activeDocument as? ShellHosting) ?? shellHosts.last } diff --git a/app/Sources/Umber/SpaceViewController+Closing.swift b/app/Sources/Umber/SpaceViewController+Closing.swift index 7fc6842..8b0acd1 100644 --- a/app/Sources/Umber/SpaceViewController+Closing.swift +++ b/app/Sources/Umber/SpaceViewController+Closing.swift @@ -58,10 +58,11 @@ extension SpaceViewController { /// would fire `spaceViewControllerDidCloseLastDocument` mid-teardown, asking the delegate /// to close a window that is already closing. func tearDownAllDocuments() { - // Close split peers first, before their primary documents, so the - // splitPeers dictionary is cleared while the primary identity is still - // valid as a key. teardownSplit(for:) is idempotent — tabs without a - // peer produce the guard-nil early return in +Splits.swift. + // Close split peers first (including any sub-split peers), before their + // primary documents, so the splitPeers dictionary is cleared while the + // primary identity is still valid as a key. teardownSplit(for:) is + // idempotent — tabs without a peer produce the guard-nil early return + // in +Splits.swift. Sub-split peers are closed inside teardownSplit. for document in documents { teardownSplit(for: document) } for document in documents { document.documentWillClose() } } diff --git a/app/Sources/Umber/SpaceViewController+DirectoryFollow.swift b/app/Sources/Umber/SpaceViewController+DirectoryFollow.swift index 4548ea5..582c118 100644 --- a/app/Sources/Umber/SpaceViewController+DirectoryFollow.swift +++ b/app/Sources/Umber/SpaceViewController+DirectoryFollow.swift @@ -169,7 +169,7 @@ extension SpaceViewController { // resign-key notifies all — every terminal loses focus when the window leaves, // regardless of which tab is showing. for document in documents { document.notifyWindowFocus(false) } - for (_, entry) in splitPeers { entry.document.notifyWindowFocus(false) } + for (_, entry) in splitPeers { for peer in entry.allPeerDocuments { peer.notifyWindowFocus(false) } } } /// Re-poll immediately — see `DirectoryFollow.pollNow()` for why the timer's rhythm diff --git a/app/Sources/Umber/SpaceViewController+SplitFocus.swift b/app/Sources/Umber/SpaceViewController+SplitFocus.swift new file mode 100644 index 0000000..7efbdd8 --- /dev/null +++ b/app/Sources/Umber/SpaceViewController+SplitFocus.swift @@ -0,0 +1,120 @@ +// +// SpaceViewController+SplitFocus.swift +// Focus movement between split panes (⌘⇧H/J/K/L), the spatial leaf-gathering +// that drives it, and menu validation for split/focus items. +// +// Extracted from SpaceViewController+Splits.swift at the 350-LOC ceiling. +// `validateUserInterfaceItem` was moved here from `SpaceViewController.swift` +// when that file hit the ceiling: it only validates split and focus selectors, +// so it belongs with the split-focus concern rather than in the container. +// This is the focus concern; the model (create/close) stays in +Splits.swift +// and the display (dimming/presentation) stays in +SplitPresentation.swift. +// + +import AppKit + +extension SpaceViewController { + + // MARK: - Focus movement (tmux-inspired ⌘⇧H/J/K/L) + + @objc func moveFocusLeft(_ sender: Any?) { moveFocus(toward: .left) } + @objc func moveFocusRight(_ sender: Any?) { moveFocus(toward: .right) } + @objc func moveFocusUp(_ sender: Any?) { moveFocus(toward: .up) } + @objc func moveFocusDown(_ sender: Any?) { moveFocus(toward: .down) } + + private enum FocusDirection { case left, right, up, down } + + /// Move keyboard focus between panes. Works for 2, 3, or 4 panes. + /// + /// Strategy: collect all leaf panes with their bounding rects in the outer + /// container's coordinate space, find the focused one, then pick the nearest + /// neighbour in the requested direction. + private func moveFocus(toward direction: FocusDirection) { + guard let primary = activeDocument, + let entry = splitPeers[ObjectIdentifier(primary)] else { return } + + let leaves = gatherLeaves(primary: primary, entry: entry) + guard leaves.count > 1 else { return } + + let fr = view.window?.firstResponder + guard let focusedIdx = leaves.firstIndex(where: { isDescendant(fr, of: $0.view) }) + else { return } + + let focused = leaves[focusedIdx] + if let target = bestNeighbour(of: focused, in: leaves, toward: direction) { + target.document.documentDidBecomeActive() + updateSplitDimming(for: primary) + } + } + + /// Pick the nearest leaf in the given direction based on bounding-rect geometry. + /// Falls back to any other pane when no pane exists in that exact direction (wrap). + private func bestNeighbour( + of focused: LeafPane, in leaves: [LeafPane], toward dir: FocusDirection + ) -> LeafPane? { + let fc = NSPoint(x: focused.rect.midX, y: focused.rect.midY) + var best: LeafPane? + var bestDist = CGFloat.greatestFiniteMagnitude + + for leaf in leaves where leaf.view !== focused.view { + let lc = NSPoint(x: leaf.rect.midX, y: leaf.rect.midY) + let valid: Bool + switch dir { + case .left: valid = lc.x < fc.x + case .right: valid = lc.x > fc.x + case .down: valid = lc.y < fc.y // non-flipped: lower y = below + case .up: valid = lc.y > fc.y + } + guard valid else { continue } + let dist = hypot(lc.x - fc.x, lc.y - fc.y) + if dist < bestDist { bestDist = dist; best = leaf } + } + return best + } + + // MARK: - Leaf gathering + + struct LeafPane { + let document: SpaceDocument + let view: NSView + let rect: NSRect // in the outer container's coordinate space + } + + /// Collect all leaf panes with their rects in the outer container's coordinate space. + /// Used by focus movement and dimming to enumerate the visible panes. + func gatherLeaves(primary: SpaceDocument, entry: SplitEntry) -> [LeafPane] { + let coord = documentArea.container + var leaves: [LeafPane] = [] + + func addLeaf(_ doc: SpaceDocument) { + let v = doc.documentView + let r = v.convert(v.bounds, to: coord) + leaves.append(LeafPane(document: doc, view: v, rect: r)) + } + + // Primary side + addLeaf(primary) + if let sub = entry.primarySubSplit { addLeaf(sub.document) } + // Peer side + addLeaf(entry.document) + if let sub = entry.peerSubSplit { addLeaf(sub.document) } + return leaves + } + + // MARK: - Menu validation + + /// Gate split/focus menu items — without this, AppKit auto-enables every item whose + /// @objc selector is answered, even when the action would silently no-op. + override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { + switch item.action { + case #selector(splitHorizontal(_:)), #selector(splitVertical(_:)): + guard let doc = activeDocument, doc is ShellHosting else { return false } + return canSplitFocusedPane(for: doc) + case #selector(moveFocusLeft(_:)), #selector(moveFocusRight(_:)), + #selector(moveFocusUp(_:)), #selector(moveFocusDown(_:)): + return activeDocument.map { hasSplit(for: $0) } ?? false + default: + return super.validateUserInterfaceItem(item) + } + } +} diff --git a/app/Sources/Umber/SpaceViewController+SplitPresentation.swift b/app/Sources/Umber/SpaceViewController+SplitPresentation.swift new file mode 100644 index 0000000..b6823e6 --- /dev/null +++ b/app/Sources/Umber/SpaceViewController+SplitPresentation.swift @@ -0,0 +1,99 @@ +// +// SpaceViewController+SplitPresentation.swift +// The display half of split panes: dimming, click callbacks, and restoring +// the nested view hierarchy when switching tabs. +// +// Extracted from SpaceViewController+Splits.swift at the 350-LOC ceiling. +// The model half (creating, closing, focus movement) stays there; this file +// owns the visual concern of how a split entry maps to the view hierarchy. +// + +import AppKit + +extension SpaceViewController { + + // MARK: - Presentation (restoring nested splits on tab switch) + + /// Present a SplitEntry's full view hierarchy (outer + any nested containers). + func presentSplitEntry(_ entry: SplitEntry, primary: SpaceDocument) { + // Build the primary side: either the doc's own view or a nested container. + // Clear any existing split first so re-presenting on tab switch works -- + // addSplit guards `splitView == nil` and silently no-ops otherwise. + let primaryView: NSView + if let sub = entry.primarySubSplit { + if sub.container.isSplit { sub.container.removeSplit() } + sub.container.setPrimary(primary.documentView) + sub.container.addSplit(sub.document.documentView, direction: sub.direction) + primaryView = sub.container + } else { + primaryView = primary.documentView + } + + // Build the peer side. + let peerView: NSView + if let sub = entry.peerSubSplit { + if sub.container.isSplit { sub.container.removeSplit() } + sub.container.setPrimary(entry.document.documentView) + sub.container.addSplit(sub.document.documentView, direction: sub.direction) + peerView = sub.container + } else { + peerView = entry.document.documentView + } + + documentArea.presentSplit( + primaryView: primaryView, splitView: peerView, direction: entry.direction) + installClickCallback(primary: primary) + } + + // MARK: - Dimming + + /// Resolve which leaf pane currently holds focus and update opacity accordingly. + /// + /// Called after any event that might change focus: split creation, keyboard + /// navigation (⌘⇧H/J/K/L), config reload (⌘R), tab switches, and clicks. + func updateSplitDimming(for primary: SpaceDocument) { + guard let entry = splitPeers[ObjectIdentifier(primary)] else { return } + let opacity = CGFloat(config.unfocusedPaneOpacity) + let fr = view.window?.firstResponder + let leaves = gatherLeaves(primary: primary, entry: entry) + let focusedView = leaves.first(where: { isDescendant(fr, of: $0.view) })?.view + + if leaves.count <= 2 { + // Simple 2-pane: use the container's built-in setFocusedChild dimming. + documentArea.container.setFocusedChild(focusedView, opacity: opacity) + } else { + // 3-4 panes: dim each leaf individually. Clear the container-level + // dimming first so it does not stack with per-leaf alphas. + documentArea.container.setFocusedChild(nil, opacity: 1.0) + for leaf in leaves { + let alpha: CGFloat = (leaf.view === focusedView) ? 1.0 : opacity + if opacity < 1.0 { + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.15 + ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + leaf.view.animator().alphaValue = alpha + } + } else { + leaf.view.alphaValue = 1.0 + } + } + } + } + + /// Install the click callback on the outer container and any nested containers. + /// + /// Each callback re-runs `updateSplitDimming` so a mouse click on an unfocused + /// pane immediately dims the other panes -- before firstResponder changes. + func installClickCallback(primary: SpaceDocument) { + let capturedPrimary = primary + let handler: (NSView) -> Void = { [weak self] _ in + guard let self else { return } + self.updateSplitDimming(for: capturedPrimary) + } + documentArea.container.didReceiveClickInChild = handler + if let entry = splitPeers[ObjectIdentifier(primary)] { + entry.primarySubSplit?.container.didReceiveClickInChild = handler + entry.peerSubSplit?.container.didReceiveClickInChild = handler + } + } +} diff --git a/app/Sources/Umber/SpaceViewController+Splits.swift b/app/Sources/Umber/SpaceViewController+Splits.swift index 7770aa3..cf561d1 100644 --- a/app/Sources/Umber/SpaceViewController+Splits.swift +++ b/app/Sources/Umber/SpaceViewController+Splits.swift @@ -3,17 +3,15 @@ // The model half of split panes: creating and closing a split, tracking per-tab // split peer documents, and wiring the container to show/hide the split view. // -// Its own file because this is one whole concern ("a tab can have a split peer") -// separable from the document-list operations in SpaceViewController.swift and from -// the display plumbing in DocumentAreaViewController.swift. The stored property -// (splitPeers) lives in SpaceViewController.swift — Swift extensions cannot add -// stored properties, and the setter on `documents` is private to that file — while -// all the methods that read and manipulate it live here. +// v2 scope: up to TWO levels of split per tab (4 panes / quarters). The outer +// split divides the tab into two halves; either half may itself be split into two +// sub-panes inside a nested SplitContainerView. The nesting is compositional -- +// each SplitContainerView still holds exactly 2 children and handles its own +// divider drag independently. Depth is capped at 2 (max 4 panes per tab). // -// v1 scope: exactly ONE split per tab (2 panes). ⌘⇧\ splits right (horizontal); -// ⌘⇧- splits down (vertical). The split peer is NOT in documents[] — it does not -// appear as a tab. The tab strip stays 1:1 with documents[]. ⌘W when split collapses -// the split (closes the peer), keeping the primary tab; ⌘W when unsplit closes the tab. +// ⌘⇧\ splits the focused pane horizontally; ⌘⇧- splits it vertically. ⌘W when +// split collapses the focused sub-pane first, then the outer split. The tab strip +// stays 1:1 with documents[] -- split peers never appear as tabs. // import AppKit @@ -22,109 +20,176 @@ extension SpaceViewController { // MARK: - Split creation - /// ⌘⇧\ — split the active terminal horizontally, opening a new pane to the right. - /// - /// Wired from AppMenu.swift. Uses the same construction path as addTerminalDocument - /// (SpaceViewController+DocumentConstruction.swift) so both engines are handled, - /// but deliberately does NOT call add(document:) — the peer must not appear as a tab. - @objc func splitHorizontal(_ sender: Any?) { - makeSplit(direction: .horizontal) - } + @objc func splitHorizontal(_ sender: Any?) { makeSplit(direction: .horizontal) } + @objc func splitVertical(_ sender: Any?) { makeSplit(direction: .vertical) } - /// ⌘⇧- — split the active terminal vertically, opening a new pane below. + /// Split the focused pane in the given direction. /// - /// In a vertical split the primary pane is at the bottom and the peer is at the top. - /// Wired from AppMenu.swift (⌘⇧-). Focus movement ⌘⇧K (up) reaches the peer, - /// ⌘⇧J (down) reaches the primary. - @objc func splitVertical(_ sender: Any?) { - makeSplit(direction: .vertical) + /// Three cases: + /// 1. No split yet: create the outer split (primary + peer). + /// 2. Outer split exists, focused pane has no sub-split: create a sub-split + /// inside the focused half, nesting a new SplitContainerView. + /// 3. Depth 2 already reached on the focused pane: no-op. + private func makeSplit(direction: SplitContainerView.Direction) { + guard let primary = activeDocument, primary is ShellHosting else { return } + + if !hasSplit(for: primary) { + // Case 1: no split yet -- create the outer split. + makeOuterSplit(primary: primary, direction: direction) + } else if let entry = splitPeers[ObjectIdentifier(primary)] { + // Case 2/3: outer split exists -- try to sub-split the focused pane. + makeSubSplit(primary: primary, entry: entry, direction: direction) + } } - /// Create a split peer for the active document in the given direction. - /// - /// Does nothing when: no active document, the active document is not a terminal - /// (FileViewerPane has no shell whose cwd to inherit), or this tab already has a - /// split peer (v1 allows exactly one split per tab). - private func makeSplit(direction: SplitContainerView.Direction) { - guard let primary = activeDocument else { return } - // Only allow splitting terminal documents. A file viewer has no shell. - guard primary is ShellHosting else { return } - // v1: one split per tab maximum. - guard !hasSplit(for: primary) else { return } - - // Inherit the focused pane's working directory so the new pane opens in the - // same location the user is already in. Falls back to the Space root before - // the first OSC 7 fires (new tab, pre-first-prompt). + /// Case 1: Create the initial outer split for a tab. + private func makeOuterSplit(primary: SpaceDocument, direction: SplitContainerView.Direction) { let peerDirectory = (primary as? ShellHosting)?.currentDirectory ?? root - - // Build the peer the same way addTerminalDocument does. The frame uses - // the current container bounds so the initial size is sane before the split - // layout runs for the first time. let frame = documentArea.container.bounds let peer: SpaceDocument = TerminalPane( config: config, frame: frame, workingDirectory: peerDirectory) - - // Wire the delegate so the peer can report title changes and exit. (peer as? any SpaceDocumentReporting)?.documentDelegate = self - // Register in splitPeers BEFORE starting the shell, so if the shell exits - // immediately the teardown path (documentDidTerminate via the delegate above) - // can find it and clean up correctly. - splitPeers[ObjectIdentifier(primary)] = (document: peer, direction: direction) - - // Start the shell. The beforeActivating ordering from addTerminalDocument is - // not needed here — the peer lives beside the primary, not replacing it, so - // the tab strip geometry is already settled. + splitPeers[ObjectIdentifier(primary)] = SplitEntry( + document: peer, direction: direction) (peer as? TerminalPane)?.start() - // Hand the peer's view to the container so it appears in the split layout. documentArea.presentSplit( primaryView: primary.documentView, splitView: peer.documentView, direction: direction) - // Register the click callback so that a mouse click on the unfocused pane - // updates dimming immediately — before firstResponder has changed — giving - // a snappy response rather than waiting for the next key event or poll. - // The callback is cleared in closeSplitPane/teardownSplit/terminateSplitPeer - // when the split collapses, so it does not linger across split lifetimes. - // `[weak self]` prevents a retain cycle: SplitContainerView → closure → self. + installClickCallback(primary: primary) + updateSplitDimming(for: primary) + } + + /// Case 2: Sub-split the focused pane inside an existing outer split. + private func makeSubSplit( + primary: SpaceDocument, entry: SplitEntry, + direction: SplitContainerView.Direction + ) { + let fr = view.window?.firstResponder + let peerHasFocus = isDescendant(fr, of: entry.document.documentView) + + // Check depth: is the focused half already sub-split? + if peerHasFocus && entry.peerSubSplit != nil { return } + if !peerHasFocus && entry.primarySubSplit != nil { return } + + // The focused document is the one whose pane will be split. + let focusedDoc: SpaceDocument = peerHasFocus ? entry.document : primary + let focusedView = focusedDoc.documentView + let peerDirectory = (focusedDoc as? ShellHosting)?.currentDirectory ?? root + let newPeer = TerminalPane( + config: config, frame: focusedView.bounds, workingDirectory: peerDirectory) + (newPeer as? any SpaceDocumentReporting)?.documentDelegate = self + + // Create a nested SplitContainerView to hold the original pane + new peer. + let nested = SplitContainerView(frame: focusedView.frame) + nested.autoresizingMask = [] + + // Find the parent container that holds focusedView and swap it. + let parentContainer = focusedView.superview as? SplitContainerView ?? documentArea.container + parentContainer.replaceChild(focusedView, with: nested) + nested.setPrimary(focusedView) + nested.addSplit(newPeer.documentView, direction: direction) + + // Register in splitPeers before starting the shell. + let subSplit = SplitEntry.SubSplit( + document: newPeer, container: nested, direction: direction) + var updated = entry + if peerHasFocus { updated.peerSubSplit = subSplit } + else { updated.primarySubSplit = subSplit } + splitPeers[ObjectIdentifier(primary)] = updated + + newPeer.start() + + // Install click callbacks on the nested container too. let capturedPrimary = primary - documentArea.container.didReceiveClickInChild = { [weak self] clickedView in + nested.didReceiveClickInChild = { [weak self] _ in guard let self else { return } - self.updateSplitDimmingForClickedView(clickedView, primary: capturedPrimary) + self.updateSplitDimming(for: capturedPrimary) } - // Dim the peer immediately on creation — the primary already has focus, so - // the peer starts unfocused. `view.window?.firstResponder` is the focused - // view, which lives inside the primary's subtree at this point. updateSplitDimming(for: primary) } // MARK: - Split closing - /// Collapse the split for the active document, tearing down the peer. + /// Collapse the focused sub-pane, or the outer split if no sub-split is focused. /// - /// v1 behavior: always closes the PEER (the right-hand pane), keeping the primary - /// tab. This is intentionally simpler than a focus-sensitive swap — the split peer - /// is an auxiliary view, and "close the split" means "remove the auxiliary". Called - /// from AppDelegate.closeDocument(_:) when the active tab has a split; the ⌘W - /// routing lives in AppDelegate+EditorActions.swift. + /// ⌘W routing: closeActiveDocument() calls this when the active tab has a split. + /// In v2, ⌘W removes the focused leaf one level at a time -- sub-pane first, + /// then the outer split on the next ⌘W. func closeSplitPane() { guard let primary = activeDocument, - let entry = splitPeers.removeValue(forKey: ObjectIdentifier(primary)) else { return } - let peer = entry.document - peer.documentWillClose() - // dismissSplit() → container.removeSplit() owns removeFromSuperview on the peer — - // one owner for the hierarchy change, not two (PR #48 review M2). + var entry = splitPeers[ObjectIdentifier(primary)] else { return } + + let fr = view.window?.firstResponder + + // Check if the focused responder is inside a sub-split. If so, collapse + // that sub-split rather than the whole outer split. + if let sub = entry.peerSubSplit, + isDescendant(fr, of: sub.container) { + collapseSubSplit(primary: primary, entry: &entry, side: .peer) + splitPeers[ObjectIdentifier(primary)] = entry + updateSplitDimming(for: primary) + return + } + if let sub = entry.primarySubSplit, + isDescendant(fr, of: sub.container) { + collapseSubSplit(primary: primary, entry: &entry, side: .primary) + splitPeers[ObjectIdentifier(primary)] = entry + updateSplitDimming(for: primary) + return + } + + // No sub-split focused -- collapse the outer split entirely. + // First tear down any sub-splits. + teardownSubSplits(entry: &entry) + splitPeers.removeValue(forKey: ObjectIdentifier(primary)) + entry.document.documentWillClose() documentArea.dismissSplit() - // Clear the click callback and reset alpha now that there is no split. documentArea.container.didReceiveClickInChild = nil documentArea.container.setFocusedChild(nil, opacity: 1.0) - // Restore focus to the primary pane. primary.documentDidBecomeActive() } + private enum SubSplitSide { case primary, peer } + + /// Collapse one sub-split, promoting the surviving pane back to its parent slot. + private func collapseSubSplit( + primary: SpaceDocument, entry: inout SplitEntry, side: SubSplitSide + ) { + let sub: SplitEntry.SubSplit + let survivingDoc: SpaceDocument + + switch side { + case .peer: + guard let s = entry.peerSubSplit else { return } + sub = s + survivingDoc = entry.document + entry.peerSubSplit = nil + case .primary: + guard let s = entry.primarySubSplit else { return } + sub = s + survivingDoc = primary + entry.primarySubSplit = nil + } + + sub.document.documentWillClose() + sub.container.removeSplit() + sub.container.didReceiveClickInChild = nil + + // Replace the nested container with the surviving pane's view. + // replaceChild removes the old view from the hierarchy, so no separate + // removeFromSuperview is needed. + let parentContainer = sub.container.superview as? SplitContainerView + ?? documentArea.container + parentContainer.replaceChild(sub.container, with: survivingDoc.documentView) + + survivingDoc.documentDidBecomeActive() + } + // MARK: - Queries func splitPeer(for document: SpaceDocument) -> SpaceDocument? { @@ -135,80 +200,47 @@ extension SpaceViewController { splitPeers[ObjectIdentifier(document)] != nil } - // MARK: - Focus movement (tmux-inspired ⌘⇧H/J/K/L) - - /// ⌘⇧H — move focus to the left pane. - /// - /// In v1 (one horizontal split per tab), this moves focus from the peer (right) - /// to the primary (left). No-op when unsplit or when the primary already has focus. - @objc func moveFocusLeft(_ sender: Any?) { moveFocus(toward: .left) } - - /// ⌘⇧L — move focus to the right pane. - @objc func moveFocusRight(_ sender: Any?) { moveFocus(toward: .right) } - - /// ⌘⇧K — move focus to the top pane (peer in a vertical split). - @objc func moveFocusUp(_ sender: Any?) { moveFocus(toward: .up) } - - /// ⌘⇧J — move focus to the bottom pane (primary in a vertical split). - @objc func moveFocusDown(_ sender: Any?) { moveFocus(toward: .down) } - - private enum FocusDirection { case left, right, up, down } - - /// Move keyboard focus between the primary pane and its split peer. - /// - /// Horizontal split: primary=left, peer=right. - /// .left → primary .right → peer .up/.down → toggle - /// Vertical split: primary=bottom, peer=top. - /// .up → peer .down → primary .left/.right → toggle - private func moveFocus(toward direction: FocusDirection) { - guard let primary = activeDocument, - let peer = splitPeer(for: primary) else { return } - - let splitDirection = splitPeers[ObjectIdentifier(primary)]?.direction + /// Whether the focused pane can accept another split (depth < 2). + func canSplitFocusedPane(for primary: SpaceDocument) -> Bool { + guard let entry = splitPeers[ObjectIdentifier(primary)] else { return true } let fr = view.window?.firstResponder - let peerHasFocus = isDescendant(fr, of: peer.documentView) - - switch (splitDirection, direction) { - // Horizontal: primary is left, peer is right. - case (.horizontal, .left): - if peerHasFocus { primary.documentDidBecomeActive() } - case (.horizontal, .right): - if !peerHasFocus { peer.documentDidBecomeActive() } - case (.horizontal, .up), (.horizontal, .down): - if peerHasFocus { primary.documentDidBecomeActive() } - else { peer.documentDidBecomeActive() } - // Vertical: primary is bottom, peer is top. - case (.vertical, .up): - if !peerHasFocus { peer.documentDidBecomeActive() } - case (.vertical, .down): - if peerHasFocus { primary.documentDidBecomeActive() } - case (.vertical, .left), (.vertical, .right): - if peerHasFocus { primary.documentDidBecomeActive() } - else { peer.documentDidBecomeActive() } - // No split or unknown — no-op. - case (nil, _): - break - } + let peerHasFocus = isDescendant(fr, of: entry.document.documentView) + if peerHasFocus { return entry.peerSubSplit == nil } + return entry.primarySubSplit == nil + } - // After every focus movement, re-resolve which view has focus and update - // dimming. `documentDidBecomeActive()` moves firstResponder, so the query - // below sees the post-move state correctly. - updateSplitDimming(for: primary) + /// All peer documents for a primary (main peer + any sub-split peers). + func allSplitDocuments(for primary: SpaceDocument) -> [SpaceDocument] { + splitPeers[ObjectIdentifier(primary)]?.allPeerDocuments ?? [] } + // Focus movement (⌘⇧H/J/K/L) and leaf gathering live in + // SpaceViewController+SplitFocus.swift — extracted at the 350-LOC ceiling. + // MARK: - Peer-initiated termination - /// Called from SpaceViewController+Delegates when a split peer's shell exits. - /// - /// A peer document is NOT in documents[], so documentDidTerminate's normal path - /// (firstIndex lookup → closeDocument) finds nothing. This handles the peer case: - /// find the primary, remove the entry from splitPeers, and collapse the split. func terminateSplitPeer(_ document: SpaceDocument) { - for primary in documents where splitPeers[ObjectIdentifier(primary)]?.document === document { + for primary in documents { + guard var entry = splitPeers[ObjectIdentifier(primary)] else { continue } + + // Check sub-splits first. + if entry.primarySubSplit?.document === document { + collapseSubSplit(primary: primary, entry: &entry, side: .primary) + splitPeers[ObjectIdentifier(primary)] = entry + return + } + if entry.peerSubSplit?.document === document { + collapseSubSplit(primary: primary, entry: &entry, side: .peer) + splitPeers[ObjectIdentifier(primary)] = entry + return + } + + // Main peer exiting: tear down the whole split. + guard entry.document === document else { continue } + teardownSubSplits(entry: &entry) splitPeers.removeValue(forKey: ObjectIdentifier(primary)) document.documentWillClose() - documentArea.dismissSplit() // owns removeFromSuperview on the peer - // Clear the click callback and dimming — primary is now unsplit. + documentArea.dismissSplit() documentArea.container.didReceiveClickInChild = nil documentArea.container.setFocusedChild(nil, opacity: 1.0) primary.documentDidBecomeActive() @@ -216,57 +248,40 @@ extension SpaceViewController { } } - // MARK: - Teardown (called when a tab closes, so its peer closes too) + // MARK: - Teardown - /// Tear down the split peer for a document that is itself about to close. - /// - /// Called from SpaceViewController.closeDocument(at:) BEFORE the primary is - /// removed from the document list, so the peer can be cleaned up while the - /// primary's identity is still available as the dictionary key. Also called from - /// tearDownAllDocuments() in SpaceViewController+Closing.swift. func teardownSplit(for document: SpaceDocument) { - guard let entry = splitPeers.removeValue(forKey: ObjectIdentifier(document)) else { return } + guard var entry = splitPeers.removeValue(forKey: ObjectIdentifier(document)) else { return } + teardownSubSplits(entry: &entry) entry.document.documentWillClose() - documentArea.dismissSplit() // owns removeFromSuperview on the peer - // Clear the click callback and reset alpha — tab is closing, split is gone. + documentArea.dismissSplit() documentArea.container.didReceiveClickInChild = nil documentArea.container.setFocusedChild(nil, opacity: 1.0) } - // MARK: - Dimming - - /// Resolve which child view currently holds focus and update pane opacity accordingly. + /// Close all sub-split peers in an entry. Called before tearing down the outer split. /// - /// Called after any event that might change focus: split creation (primary wins), - /// keyboard navigation (⌘⇧H/J/K/L), config reload (⌘R), and tab switches. - /// The container's `setFocusedChild(_:opacity:)` owns the actual alphaValue writes - /// and the 150ms animation; this method only resolves *which* view is focused. - /// - /// Uses `view.window?.firstResponder` rather than `activeDocument` because in a split - /// BOTH panes are simultaneously active — `activeDocument` always points to the primary - /// tab, not to whichever pane the user clicked most recently. The first-responder walk - /// (`isDescendant`) is the canonical way to answer "which terminal is the user in?" - /// (same approach as `focusedShellHost` in `ShellHosting.swift`). - func updateSplitDimming(for primary: SpaceDocument) { - guard let peer = splitPeer(for: primary) else { return } - let opacity = CGFloat(config.unfocusedPaneOpacity) - let fr = view.window?.firstResponder - // Determine focused child: if firstResponder is in the peer's subtree, the peer - // wins; otherwise the primary wins (covers the no-responder case too, where - // dimming the primary would be more surprising than dimming the peer). - let focusedView: NSView = isDescendant(fr, of: peer.documentView) - ? peer.documentView : primary.documentView - documentArea.container.setFocusedChild(focusedView, opacity: opacity) + /// Does NOT replaceChild -- the outer split is dismissed right after, so the + /// nested container just needs to release its documents and be removed. Putting + /// a zombie view into the outer container (then immediately dismissing it) was + /// the cause of Bug #1 in the audit. + private func teardownSubSplits(entry: inout SplitEntry) { + if let sub = entry.primarySubSplit { + sub.container.removeSplit() + sub.document.documentWillClose() + sub.container.didReceiveClickInChild = nil + sub.container.removeFromSuperview() + entry.primarySubSplit = nil + } + if let sub = entry.peerSubSplit { + sub.container.removeSplit() + sub.document.documentWillClose() + sub.container.didReceiveClickInChild = nil + sub.container.removeFromSuperview() + entry.peerSubSplit = nil + } } - /// Update dimming from a `didReceiveClickInChild` callback. - /// - /// The clicked view IS the focused one (the click is about to make it first - /// responder), so we pass it directly to the container instead of re-querying - /// `firstResponder` (which has not changed yet at the point this fires). - private func updateSplitDimmingForClickedView(_ clicked: NSView, primary: SpaceDocument) { - guard splitPeer(for: primary) != nil else { return } - let opacity = CGFloat(config.unfocusedPaneOpacity) - documentArea.container.setFocusedChild(clicked, opacity: opacity) - } + // Presentation, dimming, and click callbacks live in + // SpaceViewController+SplitPresentation.swift — extracted at the 350-LOC ceiling. } diff --git a/app/Sources/Umber/SpaceViewController.swift b/app/Sources/Umber/SpaceViewController.swift index d906667..1eecf41 100644 --- a/app/Sources/Umber/SpaceViewController.swift +++ b/app/Sources/Umber/SpaceViewController.swift @@ -71,7 +71,7 @@ final class SpaceViewController: NSSplitViewController { /// Split peers keyed by primary document identity. NOT in `documents[]` — peers /// don't appear as tabs. Internal (not `private(set)`) because +Splits.swift needs /// subscript-set and removeValue. Writes go through that extension — convention-enforced. - var splitPeers: [ObjectIdentifier: (document: SpaceDocument, direction: SplitContainerView.Direction)] = [:] + var splitPeers: [ObjectIdentifier: SplitEntry] = [:] /// Internal for the same reason `config` above is: document construction lives in /// `+DocumentConstruction` and needs `documentArea.container.bounds` as the frame for a @@ -186,7 +186,7 @@ final class SpaceViewController: NSSplitViewController { let document = documents[index] if let entry = splitPeers[ObjectIdentifier(document)] { - documentArea.presentSplit(primaryView: document.documentView, splitView: entry.document.documentView, direction: entry.direction) + presentSplitEntry(entry, primary: document) } else { documentArea.present(documentView: document.documentView) } @@ -280,32 +280,14 @@ final class SpaceViewController: NSSplitViewController { view.window?.isDocumentEdited = hasEditedDocuments } - // MARK: - Menu validation - - /// Gate split/focus menu items — without this, AppKit auto-enables every item whose - /// @objc selector is answered, even when the action would silently no-op. - override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { - switch item.action { - case #selector(splitHorizontal(_:)): - guard let doc = activeDocument else { return false } - return doc is ShellHosting && !hasSplit(for: doc) - case #selector(splitVertical(_:)): - guard let doc = activeDocument else { return false } - return doc is ShellHosting && !hasSplit(for: doc) - case #selector(moveFocusLeft(_:)), #selector(moveFocusRight(_:)), - #selector(moveFocusUp(_:)), #selector(moveFocusDown(_:)): - return activeDocument.map { hasSplit(for: $0) } ?? false - default: - return super.validateUserInterfaceItem(item) - } - } - // MARK: - Config / lifecycle func apply(config: AppConfig) { self.config = config for document in documents { document.apply(config: config) } - for (_, entry) in splitPeers { entry.document.apply(config: config) } // peers not in documents[] + for (_, entry) in splitPeers { // peers not in documents[] + for peer in entry.allPeerDocuments { peer.apply(config: config) } + } documentArea.strip.apply( background: config.effectiveBackground, foreground: config.effectiveForeground) if let doc = activeDocument { // re-apply padding after ⌘R @@ -347,8 +329,8 @@ final class SpaceViewController: NSSplitViewController { // gates the send on `sendFocus` (DECSET 1004), so a pane without focus events is a // no-op. Resign-key counterpart lives in `windowDidResignKey()` in +DirectoryFollow. activeDocument?.notifyWindowFocus(true) - if let active = activeDocument, let peer = splitPeer(for: active) { - peer.notifyWindowFocus(true) + if let active = activeDocument { + for peer in allSplitDocuments(for: active) { peer.notifyWindowFocus(true) } } } } diff --git a/app/Sources/Umber/SplitContainerView.swift b/app/Sources/Umber/SplitContainerView.swift index b2cd493..49c4fc5 100644 --- a/app/Sources/Umber/SplitContainerView.swift +++ b/app/Sources/Umber/SplitContainerView.swift @@ -132,6 +132,31 @@ final class SplitContainerView: NSView { var isSplit: Bool { splitView != nil } + /// Replace a child (primary or split) with a different view. Used when a leaf + /// is itself being split: its slot becomes a nested SplitContainerView holding + /// the original leaf plus a new peer. Returns false if `old` is not a child. + @discardableResult + func replaceChild(_ old: NSView, with replacement: NSView) -> Bool { + replacement.autoresizingMask = [] + if primaryView === old { + old.removeFromSuperview() + primaryView = replacement + addSubview(replacement) + needsLayout = true + layout() + return true + } + if splitView === old { + old.removeFromSuperview() + splitView = replacement + addSubview(replacement) + needsLayout = true + layout() + return true + } + return false + } + /// Update pane dimming so the focused child is fully opaque and the other is /// drawn at `opacity`. /// diff --git a/app/Sources/Umber/SplitEntry.swift b/app/Sources/Umber/SplitEntry.swift new file mode 100644 index 0000000..2ce7dc7 --- /dev/null +++ b/app/Sources/Umber/SplitEntry.swift @@ -0,0 +1,57 @@ +// +// SplitEntry.swift +// The data model for a split-peer relationship within one tab. +// +// Its own file for two reasons. First, SpaceViewController.swift is at the 350-LOC +// ceiling and cannot absorb a new type. Second, the struct is the seam between +// +Splits.swift (which writes it), +Closing.swift (which tears it down), and +// ShellHosting.swift (which queries it for focused-shell resolution) -- a shared +// type in its own file is readable from all three without coupling them. +// +// v2 model: a primary document may have a split peer, AND that peer may itself be +// split into two sub-peers inside a nested SplitContainerView. This gives up to +// 4 panes per tab (quarters) with depth capped at 2. The nested container holds +// two sub-peer documents and its own direction, independent of the outer split. +// + +import AppKit + +/// One split-peer entry, keyed by its primary document's `ObjectIdentifier` in +/// `SpaceViewController.splitPeers`. +/// +/// v1 was a plain tuple `(document, direction)`. v2 adds an optional `subSplit` +/// that tracks a second level of nesting: the primary itself may be split (via +/// `primarySubSplit`), and the peer may be split (via `peerSubSplit`). Each sub-split +/// creates a nested `SplitContainerView` that replaces the leaf pane's slot in the +/// outer container. +@MainActor +struct SplitEntry { + let document: SpaceDocument + let direction: SplitContainerView.Direction + + /// Sub-split of the PRIMARY pane (left/top in the outer split). + var primarySubSplit: SubSplit? + /// Sub-split of the PEER pane (right/bottom in the outer split). + var peerSubSplit: SubSplit? + + /// A second-level split inside one half of the outer split. + struct SubSplit { + let document: SpaceDocument + let container: SplitContainerView + let direction: SplitContainerView.Direction + } + + /// Every peer document in this entry (the main peer + any sub-split documents). + var allPeerDocuments: [SpaceDocument] { + var result = [document] + if let sub = primarySubSplit { result.append(sub.document) } + if let sub = peerSubSplit { result.append(sub.document) } + return result + } + + /// How many panes this tab currently has (including the primary). + /// 2 = simple split, 3 = one sub-split, 4 = both sub-splits (quarters). + var paneCount: Int { + 2 + (primarySubSplit != nil ? 1 : 0) + (peerSubSplit != nil ? 1 : 0) + } +}