Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .afk/plans/recursive-splits-and-window-chooser.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions app/Sources/Umber/AppDelegate+EditorActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
8 changes: 8 additions & 0 deletions app/Sources/Umber/AppMenu+Navigate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(_:)),
Expand Down
19 changes: 19 additions & 0 deletions app/Sources/Umber/CommandPalette+Commands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
7 changes: 5 additions & 2 deletions app/Sources/Umber/CommandPalette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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 }

Expand Down
181 changes: 181 additions & 0 deletions app/Sources/Umber/Config+Load.swift
Original file line number Diff line number Diff line change
@@ -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<String> = [
"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")
}
}
Loading