diff --git a/app/Sources/Umber/Config+Padding.swift b/app/Sources/Umber/Config+Padding.swift new file mode 100644 index 0000000..405f14f --- /dev/null +++ b/app/Sources/Umber/Config+Padding.swift @@ -0,0 +1,63 @@ +// +// Config+Padding.swift +// Turning a config file's `padding` value into resolved terminal insets, fail-soft. +// +// Its own file for the same reason as `Config+Theme.swift` and `Config+Editor.swift`: +// `Config.swift` is near the 350-line ceiling and this is a clean seam. Everything +// here is one question — "given an optional padding value a user typed, what insets +// do we apply?" — and answering it needs none of the font/theme/engine machinery. +// +// Two accepted config forms: +// "padding": 4 → uniform 4px on all sides +// "padding": { "x": 8, "y": 4 } → separate horizontal / vertical +// +// Why x/y and not top/bottom/left/right: +// Terminal layouts are symmetric. A terminal with asymmetric left/right padding +// would be visually off-balance, and the cost in columns is the same either side. +// x/y is the minimal orthogonal representation for symmetric padding — two numbers +// rather than four, and each maps cleanly to the axis it describes. +// +// Why terminals only, not the editor: +// `FileViewerPane` uses `NSTextView.textContainerInset`, which is the AppKit-native +// way to pad a text view and respects the glyph layout. Applying a frame inset on +// the editor's container view would add padding *outside* the text container's own +// margins, double-dipping. The editor's own defaults are already tuned separately. +// +// Why 4px default: +// 4px is barely perceptible on a standard display but lifts text off the window +// edge, which reduces the cognitive friction of a full-screen terminal. At common +// terminal widths (80–132 columns at 14pt SF Mono), 4px horizontal padding drops +// roughly 0.2 columns — well within one character's width and therefore invisible +// to most column-alignment-sensitive TUIs. Raising it to 8px or more starts trading +// visible columns; the config is the right place to make that trade explicitly. +// +// THE INVARIANT THIS FILE OWNS: `applyPadding` never throws. A bad value costs that +// field and appends one warning. Preserve that — `AppConfig.load()` has no error path. +// + +import CoreGraphics + +extension AppConfig { + /// Resolve the `padding` value from config primitives, fail-soft. + /// + /// Called from `AppConfig.load()` when a `padding` key is present. Mutates `self` + /// directly and appends to `warnings` for any degradations — same contract as + /// `applyEditor` and the theme resolver. + /// + /// Validation rules: + /// - Negative values are floored to 0: a negative inset would expand the terminal + /// view *outside* its container frame, clipping against the window edge. + /// - Values > 100 are rejected with a warning and default to 4. 100px padding is + /// already extravagant — a 13" MacBook at 14pt would lose ~6 terminal columns — + /// and a value above it almost certainly reflects a unit confusion (e.g. "em" + /// intended, px applied) rather than intentional configuration. + mutating func applyPadding(x: CGFloat, y: CGFloat) { + let px = max(0, x), py = max(0, y) + if px > 100 || py > 100 { + warnings.append( + "padding values (\(x), \(y)) exceed the 100px cap — using default 4") + } else { + terminalPadding = (x: px, y: py) + } + } +} diff --git a/app/Sources/Umber/Config.swift b/app/Sources/Umber/Config.swift index e65274b..03ac35d 100644 --- a/app/Sources/Umber/Config.swift +++ b/app/Sources/Umber/Config.swift @@ -60,6 +60,31 @@ private struct ConfigFile: Decodable { var stickyScroll: Bool? } var editor: EditorSpec? + + // `padding` accepts two forms: + // "padding": 4 → uniform 4px on all sides + // "padding": { "x": 8, "y": 4 } → separate horizontal / vertical + // Both decode via a custom Decodable that tries a plain Double first. + struct PaddingSpec: Decodable { + let x: CGFloat + let y: CGFloat + + private struct XY: Decodable { + let x: CGFloat + let y: CGFloat + } + + init(from decoder: Decoder) throws { + let single = try? decoder.singleValueContainer().decode(CGFloat.self) + if let v = single { + x = v; y = v + } else { + let xy = try XY(from: decoder) + x = xy.x; y = xy.y + } + } + } + var padding: PaddingSpec? } /// Resolved, ready-to-use configuration. Never fails: a missing, malformed, or @@ -130,6 +155,10 @@ struct AppConfig { /// Pin the enclosing scope header at the top of the editor while scrolling. /// Default: true. Configured via `editor.stickyScroll` in config.json. var stickyScroll: Bool + /// Inner margin around terminal content. See `Config+Padding.swift` for the full + /// rationale and the parsing details (accepted forms, validation, fail-soft contract). + /// Default (4, 4) — see `defaults()`. + var terminalPadding: (x: CGFloat, y: CGFloat) /// Human-readable notes about anything in the config that was ignored. var warnings: [String] = [] @@ -194,7 +223,8 @@ struct AppConfig { indentRainbow: false, columnGuideColumn: 80, showTrailingWhitespace: true, - stickyScroll: true + stickyScroll: true, + terminalPadding: (x: 4, y: 4) ) } @@ -281,6 +311,9 @@ struct AppConfig { showTrailingWhitespace: e.showTrailingWhitespace, stickyScroll: e.stickyScroll) } + if let p = file.padding { + config.applyPadding(x: p.x, y: p.y) + } return config } diff --git a/app/Sources/Umber/DocumentAreaViewController.swift b/app/Sources/Umber/DocumentAreaViewController.swift index 90c5f31..796e3bb 100644 --- a/app/Sources/Umber/DocumentAreaViewController.swift +++ b/app/Sources/Umber/DocumentAreaViewController.swift @@ -38,8 +38,55 @@ final class DocumentAreaViewController: NSViewController { private var isStripVisible = false + // MARK: - Terminal padding + + /// Inset applied to the terminal container so content has breathing room from the + /// window edges. The gap is filled by `paddingBackdrop`, coloured to match the + /// terminal background for a seamless appearance (no visible border or letterbox). + /// + /// Zero by default and set to zero again when a non-terminal document is presented + /// (`setTerminalPadding(_:backgroundColor:)` called with (0,0) from + /// `SpaceViewController.selectDocument`), so `FileViewerPane` is never affected. + /// + /// Stored as (x:y:) rather than NSEdgeInsets because terminal padding is always + /// symmetric — applying different insets to the leading vs trailing side would + /// shift the terminal off-centre with no user-visible benefit. + private var terminalPadding: (x: CGFloat, y: CGFloat) = (0, 0) + + /// A view behind the container that fills the full document area. When padding is + /// active, the portion not covered by the container shows this view's background, + /// which is set to the terminal's own background colour — making the inset + /// invisible to the eye (the terminal "floats" in a same-colour void). + /// + /// Using a separate backdrop rather than colouring the root view directly keeps + /// the backdrop out of the responder chain and avoids fighting the sidebar + /// material's bleed-through, which sits behind this file's root NSView. + private let paddingBackdrop: NSView = { + let v = NSView() + v.wantsLayer = true + return v + }() + + /// Update the terminal padding and backdrop colour for the newly-active document. + /// + /// Call this from `SpaceViewController.selectDocument(at:)` whenever the active + /// document changes. For non-terminal documents pass `(0, 0)` so no inset is + /// applied. The backdrop colour should match the terminal's resolved background + /// (`config.effectiveBackground`) so the inset area is seamless. + func setTerminalPadding(_ padding: (x: CGFloat, y: CGFloat), backgroundColor: NSColor) { + terminalPadding = padding + paddingBackdrop.layer?.backgroundColor = backgroundColor.cgColor + // Re-run layout immediately so the new inset takes effect on this frame + // rather than waiting for the next resize — the same reasoning as the + // `container.layout()` call at the end of `viewDidLayout`. + view.needsLayout = true + viewDidLayout() + } + override func loadView() { let root = NSView() + // Backdrop first so it sits below the container in Z-order. + root.addSubview(paddingBackdrop) root.addSubview(container) view = root } @@ -97,8 +144,20 @@ final class DocumentAreaViewController: NSViewController { // Non-flipped coordinates: the strip is at the TOP, so it takes the high y. strip.frame = NSRect( x: 0, y: bounds.height - stripHeight, width: bounds.width, height: stripHeight) - container.frame = NSRect( + + // The backdrop always fills the full area below the strip — it is the + // base layer that shows the terminal background colour through the inset. + let contentArea = NSRect( x: 0, y: 0, width: bounds.width, height: bounds.height - stripHeight) + paddingBackdrop.frame = contentArea + + // Apply terminal padding as frame insets on the container. When padding is + // zero (non-terminal documents, or padding disabled) the container fills the + // full content area, exactly as before. The backdrop behind it is invisible + // because the document view covers it entirely. + let px = terminalPadding.x, py = terminalPadding.y + container.frame = contentArea.insetBy(dx: px, dy: py) + // Children are distributed by SplitContainerView.layout(). Call it // immediately rather than deferring via needsLayout so terminal rows/cols // are correct on the same pass that resized the container — deferred layout diff --git a/app/Sources/Umber/SpaceViewController.swift b/app/Sources/Umber/SpaceViewController.swift index 0854a12..56c3d26 100644 --- a/app/Sources/Umber/SpaceViewController.swift +++ b/app/Sources/Umber/SpaceViewController.swift @@ -190,6 +190,8 @@ final class SpaceViewController: NSSplitViewController { } else { documentArea.present(documentView: document.documentView) } + let pad = document is TerminalPane ? config.terminalPadding : (x: CGFloat(0), y: CGFloat(0)) + documentArea.setTerminalPadding(pad, backgroundColor: config.effectiveBackground) syncDocumentChrome() spaceDelegate?.spaceViewController(self, didChangeDocumentTitle: document.documentTitle) // Re-check staleness on activation, not just on window-level focus @@ -306,6 +308,10 @@ final class SpaceViewController: NSSplitViewController { for (_, entry) in splitPeers { entry.document.apply(config: config) } // peers not in documents[] documentArea.strip.apply( background: config.effectiveBackground, foreground: config.effectiveForeground) + if let doc = activeDocument { // re-apply padding after ⌘R + let pad = doc is TerminalPane ? config.terminalPadding : (x: CGFloat(0), y: CGFloat(0)) + documentArea.setTerminalPadding(pad, backgroundColor: config.effectiveBackground) + } } /// Called when the Space's window becomes key — see `FileTreeViewController.refresh()` diff --git a/app/Sources/Umber/StarterConfig.swift b/app/Sources/Umber/StarterConfig.swift index fe47f7e..8922ba1 100644 --- a/app/Sources/Umber/StarterConfig.swift +++ b/app/Sources/Umber/StarterConfig.swift @@ -41,6 +41,12 @@ enum StarterConfig { "// renderer-note": "OPT-IN because upstream calls the GPU path experimental and its speedup here has not been measured. Falls back to coretext by itself if it cannot start, and says so on stderr. Change it and hit Cmd-R; run app/Scripts/check-metal-renderer.sh if you suspect it silently fell back.", "renderer": "coretext", + "// padding": "inner margin around terminal content in points. Default 4. The gap fills with the terminal background colour (seamless, not a border). Two forms accepted:", + "// padding-uniform": " \"padding\": 4 → same on all sides", + "// padding-xy": " \"padding\": { \"x\": 8, \"y\": 4 } → separate horizontal / vertical", + "// padding-note": "Does NOT apply to the file editor (that uses textContainerInset). Values above 100 are rejected and fall back to the default.", + "padding": 4, + "// editor": "editing behaviour for the file viewer (the tab you get when you double-click a file in the sidebar)", "// editor.tabWidth": "spaces per indent level, 1-16. Default 4.", "// editor.softTabs": "true inserts spaces when you press Tab; false inserts a literal tab character",