diff --git a/CLAUDE.md b/CLAUDE.md index 445ad7f..84fa420 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ make screenshots # retakes docs/images; opens windows for ~1 min, Three targets (details in `docs/development.md`): - **`CodecCore`**: all logic with no UI, fully unit-tested: the split layout tree (`SplitLayout`), shell integration, command blocks and timelines, history, Intel, preferences, git, syntax highlighting, Markdown, updates. It doesn't depend on SwiftTerm. Put anything testable without AppKit here. -- **`CodecUI`**: AppKit and SwiftUI. `TerminalWindowController` owns tabs (`TerminalTab`, a `SplitLayout` plus split sizes) and handles most menu actions. `Pane` owns one shell: the process, its `PaneView` (the `CodecTerminalView` plus overlays: find bar and highlights, command marks, dimmer, exit banner) and its observable `Session`. +- **`CodecUI`**: AppKit and SwiftUI. `TerminalWindowController` owns tabs (`TerminalTab`, a `SplitLayout` plus split sizes) and handles most menu actions. `Pane` owns one shell: the process, its `PaneView` (the `CodecTerminalView` plus overlays: find bar and highlights, command marks, dimmer, exit banner; and under it, the lines and shades between commands) and its observable `Session`. - **`Codec`**: `main.swift`, which calls `CodecApp.run()`. Services come in through `AppEnvironment` (preferences, sounds, notifier, system monitor, shell integration, history, coordinator), and a pane reaches its window only through the `PaneHost` protocol. Tests build both with fakes: `TestEnvironment` and `FakeHost` in `Tests/CodecUITests/TestSupport.swift`. diff --git a/Sources/CodecCore/CommandBlocks.swift b/Sources/CodecCore/CommandBlocks.swift index fb9c470..ec2826c 100644 --- a/Sources/CodecCore/CommandBlocks.swift +++ b/Sources/CodecCore/CommandBlocks.swift @@ -36,9 +36,30 @@ public struct CommandBlocks: Equatable, Sendable { } } + /// A command's rows, from its prompt to the end of its output, to set it apart on screen. + public struct Span: Equatable, Sendable { + public var rows: Range + /// Every other command, counted from the first one run: the ones that get a shade. + public var isAlternate: Bool + /// Where its block is in `blocks`. Nil for the prompt waiting for a command. + public var block: Int? + /// The last one: it ends only where it has got to so far. + public var isNewest: Bool + + public init(rows: Range, isAlternate: Bool, block: Int? = nil, isNewest: Bool = false) { + self.rows = rows + self.isAlternate = isAlternate + self.block = block + self.isNewest = isNewest + } + } + public private(set) var blocks: [Block] = [] /// Older blocks are dropped; they've long scrolled out of the scrollback anyway. public let limit: Int + /// How many commands came before the first block, so that dropping the oldest ones doesn't turn + /// every other command into the one that isn't. + private var firstNumber = 0 public init(limit: Int = 2_000) { self.limit = limit @@ -49,7 +70,10 @@ public struct CommandBlocks: Equatable, Sendable { // Rows only grow; going back means the history was cleared. if let last = blocks.last, block.prompt <= last.prompt { blocks.removeAll() } blocks.append(block) - if blocks.count > limit { blocks.removeFirst(blocks.count - limit) } + if blocks.count > limit { + firstNumber += blocks.count - limit + blocks.removeFirst(blocks.count - limit) + } } /// The output of block `index`, up to the next block's prompt or, for the newest, to `end` @@ -94,11 +118,46 @@ public struct CommandBlocks: Equatable, Sendable { return nil } - /// Replaces every block, after they were found again with `relocate`. - public mutating func replace(with blocks: [Block]) { + /// Replaces every block, after they were found again with `relocate`. `dropped` is how many of the + /// oldest weren't found. + public mutating func replace(with blocks: [Block], dropped: Int = 0) { + firstNumber += dropped + max(blocks.count - limit, 0) self.blocks = Array(blocks.suffix(limit)) } + /// The commands with rows among `shown`, oldest first. Each runs to the next one's prompt, and the + /// newest to `end`: the first row of the prompt the shell is waiting at, or past the last line while + /// a command runs. `waitingPrompt`, that prompt's rows, comes last, as the command it's about to be. + /// + /// Nothing is printed past `end`, so a block that reaches further isn't there anymore: the screen was + /// cleared under it. It's left out, with the ones after it. + public func spans(overlapping shown: Range, end: Int, waitingPrompt: Range? = nil) -> [Span] { + let live = blocks.prefix { $0.output <= end } + var spans: [Span] = [] + for (index, block) in live.enumerated() { + let upper = index + 1 < live.count ? live[index + 1].prompt : end + guard block.prompt < upper else { continue } + spans.append(Span(rows: block.prompt..= top else { return false } + return Self.locate(command: line, line: line, in: rows, cursorRow: block.output - 1, promptLines: 1)?.output + != block.output + } + } + /// Finds commands' blocks again after their rows moved, as they do when the terminal rewraps its lines /// on a resize. `commands` are oldest first, each with its command line as first found (see `locate`); /// each is looked for above the one after it, starting from `lastRow`. Nil for a command that isn't on diff --git a/Sources/CodecCore/Preferences.swift b/Sources/CodecCore/Preferences.swift index 381326d..112f82d 100644 --- a/Sources/CodecCore/Preferences.swift +++ b/Sources/CodecCore/Preferences.swift @@ -21,6 +21,11 @@ public final class Preferences: ObservableObject { @Published public var blur: Bool { didSet { store(blur, .blur) } } @Published public var scanlines: Bool { didSet { store(scanlines, .scanlines) } } @Published public var dimInactivePanes: Bool { didSet { store(dimInactivePanes, .dimInactivePanes) } } + /// Draws a line above each command's prompt, between it and what the command before printed, and + /// a bar in the margin beside a command that failed or is running (zsh). + @Published public var commandSeparators: Bool { didSet { store(commandSeparators, .commandSeparators) } } + /// Shades every other command, prompt and output, like the rows of a table (zsh). + @Published public var shadeAlternateCommands: Bool { didSet { store(shadeAlternateCommands, .shadeAlternateCommands) } } @Published public var bell: BellBehavior { didSet { store(bell.rawValue, .bell) } } @Published public var notifyInBackground: Bool { didSet { store(notifyInBackground, .notifyInBackground) } } @Published public var codecSounds: Bool { didSet { store(codecSounds, .codecSounds) } } @@ -69,7 +74,7 @@ public final class Preferences: ObservableObject { codecSounds, paneSounds, soundVolume, optionAsMeta, exitBehavior, shellIntegration, shellPath, showSystemStats, prompt, copyOnSelect, suggestCommands, historyKeys, friendlyCompletion, colorFileListings, ezaForLs, recordHistory, restoreWindows, restorePaneContents, showUsualDuration, activateVenvs, explainFailures, - checkForUpdates + checkForUpdates, commandSeparators, shadeAlternateCommands } private static let factoryDefaults: [Key: Any] = [ @@ -80,6 +85,8 @@ public final class Preferences: ObservableObject { .blur: true, .scanlines: false, .dimInactivePanes: true, + .commandSeparators: true, + .shadeAlternateCommands: true, .bell: BellBehavior.visual.rawValue, .notifyInBackground: true, .codecSounds: true, @@ -115,6 +122,8 @@ public final class Preferences: ObservableObject { blur = defaults.bool(forKey: Key.blur.rawValue) scanlines = defaults.bool(forKey: Key.scanlines.rawValue) dimInactivePanes = defaults.bool(forKey: Key.dimInactivePanes.rawValue) + commandSeparators = defaults.bool(forKey: Key.commandSeparators.rawValue) + shadeAlternateCommands = defaults.bool(forKey: Key.shadeAlternateCommands.rawValue) bell = defaults.string(forKey: Key.bell.rawValue).flatMap(BellBehavior.init(rawValue:)) ?? .visual notifyInBackground = defaults.bool(forKey: Key.notifyInBackground.rawValue) codecSounds = defaults.bool(forKey: Key.codecSounds.rawValue) diff --git a/Sources/CodecUI/Pane.swift b/Sources/CodecUI/Pane.swift index a1acfcf..16d73ba 100644 --- a/Sources/CodecUI/Pane.swift +++ b/Sources/CodecUI/Pane.swift @@ -131,9 +131,11 @@ final class Pane: LocalProcessTerminalViewDelegate { } } view.onScroll = { [weak self] in - guard let self, self.openableNames != nil else { return } - self.refreshFileLink(modifiers: self.fileLinkModifiers, rescan: true) + guard let self else { return } + self.refreshCommandSeparators() + if self.openableNames != nil { self.refreshFileLink(modifiers: self.fileLinkModifiers, rescan: true) } } + view.onLayout = { [weak self] in self?.refreshCommandSeparators() } terminal.onRelease = { [weak self] clicks, dragged, point in self?.terminalGestureEnded(clicks: clicks, dragged: dragged, at: point) } @@ -338,6 +340,7 @@ final class Pane: LocalProcessTerminalViewDelegate { session.unseenOutput = true } if printed { view.finder.contentChanged() } + if printed { refreshCommandSeparators() } if printed, openableNames != nil { refreshFileLink(modifiers: fileLinkModifiers, rescan: true) } ticks += 1 if ticks % 4 == 0 { pollProcess() } @@ -397,6 +400,8 @@ final class Pane: LocalProcessTerminalViewDelegate { case .commandStarted: runningCommandText = nil runningDirectory = session.directory + promptIsDrawn = false + waitingPromptRow = nil default: break } @@ -424,6 +429,7 @@ final class Pane: LocalProcessTerminalViewDelegate { session.running?.usual = usual timeline.begin(command: started, directory: session.directory, started: session.running?.started ?? Date(), block: block, usualDuration: usual, toolchain: session.toolchain) + refreshCommandSeparators() } guard case .commandFinished(let exitCode, let duration) = event else { return } let finished = timeline.running @@ -890,10 +896,17 @@ final class Pane: LocalProcessTerminalViewDelegate { /// Without one (something was typed ahead), it's kept once the next command starts, from where /// that command's prompt is found. private func promptWasDrawn(on screen: (rows: [ScreenRow], cursorRow: Int)?) { - guard outputToCapture != nil, let screen else { return } - relocateBlocksIfNeeded() - captureFinishedOutput(end: CommandBlocks.promptStart(cursorRow: screen.cursorRow, in: screen.rows, - promptLines: session.promptLines)) + promptIsDrawn = true + defer { refreshCommandSeparators() } + guard let screen else { return } + let start = CommandBlocks.promptStart(cursorRow: screen.cursorRow, in: screen.rows, promptLines: session.promptLines) + waitingPromptRow = start + if outputToCapture != nil { + relocateBlocksIfNeeded() + captureFinishedOutput(end: start) + } + // `clear` and ⌃L leave the commands that were on screen with nothing to show for themselves. + if !rowsMayHaveMoved { blocks.forgetBlocksGone(from: screen.rows) } } /// Keeps what the command that just finished printed, up to `end`: the first row of the prompt after it. @@ -966,7 +979,126 @@ final class Pane: LocalProcessTerminalViewDelegate { in: Pane.screenRows(snapshot).rows, lastRow: end - 1, promptLines: session.promptLines) timeline.setBlocks(Dictionary(uniqueKeysWithValues: zip(placed.map(\.id), found))) - blocks.replace(with: found.compactMap { $0 }) + blocks.replace(with: found.compactMap { $0 }, dropped: found.prefix { $0 == nil }.count) + } + + // MARK: - Telling commands apart + + /// The shell has drawn the prompt it's waiting at. Not so between a command finishing and its + /// prompt, when what's on screen still ends with the command's output. + private var promptIsDrawn = false + /// That prompt's first row, from where zsh marked its end. Nil when the mark came too late to say + /// (something was typed ahead) or the terminal was resized since; the row is worked out from the + /// cursor then. + private var waitingPromptRow: Int? + private var relocationToken = 0 + + /// Draws what sets the commands on screen apart, as the settings say: a line above each one's + /// prompt, with a bar in the margin beside one that failed or is running, and a shade behind every + /// other one, from its line to the next. The prompt waiting for a command counts as one. Only with + /// shell integration, which is what tells commands apart. + func refreshCommandSeparators() { + let separators = view.commandSeparators + let lines = preferences.commandSeparators, shading = preferences.shadeAlternateCommands + guard lines || shading, session.integrated, !isShutDown, promptIsDrawn || !blocks.blocks.isEmpty, + terminal.terminalInputStateSnapshot()?.isAlternateBuffer == false else { + return separators.update() + } + // Finding the commands again takes the whole scrollback: once a resize is over, not at each step of it. + if rowsMayHaveMoved, !blocks.blocks.isEmpty { + separators.update() + return relocateBlocksSoon() + } + // One copy of the screen where `TerminalGrid` takes two: this runs at every frame of a scroll. + let caret = terminal.firstRect(forCharacterRange: NSRange(location: 0, length: 0), actualRange: nil) + guard let window = terminal.window, caret.height > 0, + let snapshot = terminal.terminalContentSnapshot(region: .viewport), + let top = snapshot.rows.first?.absoluteRow else { return separators.update() } + let shown = top..<(top + snapshot.rows.count) + let cursorRow = snapshot.cursorRow + func isBlank(_ row: Int) -> Bool { snapshot.rows[row - top].text.allSatisfy(\.isWhitespace) } + var waiting: Range? + if promptIsDrawn { + let start: Int + if let row = waitingPromptRow, row <= cursorRow { + start = row + } else { + start = CommandBlocks.promptStart(cursorRow: cursorRow, in: Pane.screenRows(snapshot).rows, + promptLines: session.promptLines) + } + // And what the line editor shows under the cursor: the rest of a long command, a list of completions. + var last = max(cursorRow, start) + while shown.contains(last + 1), !isBlank(last + 1) { last += 1 } + waiting = start..<(last + 1) + } + // While a command runs, or before its prompt is drawn, the newest one's output ends at the cursor. + let spans = blocks.spans(overlapping: shown, end: waiting?.lowerBound ?? cursorRow + 1, waitingPrompt: waiting) + + let bounds = separators.bounds + let text = separators.convert(terminal.bounds, from: terminal) + // Rows are laid out from the terminal's top edge, each as tall as the caret: see `TerminalGrid`. + let terminalFrame = terminal.convert(terminal.bounds, to: nil) + let rowHeight = window.convertFromScreen(caret).height + func frame(ofRow row: Int) -> NSRect { + separators.convert(NSRect(x: terminalFrame.minX, y: terminalFrame.maxY - CGFloat(row - top + 1) * rowHeight, + width: terminalFrame.width, height: rowHeight), from: nil) + } + // Where each command starts: the top of its prompt, or the middle of the blank line a prompt + // begins with, as Codec's does, so the line has as much room above it as below. + let tops = spans.map { span -> CGFloat in + guard shown.contains(span.rows.lowerBound) else { return bounds.maxY } + let first = frame(ofRow: span.rows.lowerBound) + return isBlank(span.rows.lowerBound) && span.rows.count > 1 ? first.midY : first.maxY + } + var shades: [CommandSeparatorView.Shade] = [], rules: [NSRect] = [], marks: [CommandSeparatorView.Mark] = [] + for (index, span) in spans.enumerated() { + let top = tops[index] + // Where it ends: at the next one's line, past the screen's edge (so into the pane's padding), + // or, for the newest, under its last row. + let endsOnScreen = index + 1 == spans.count && span.rows.upperBound < shown.upperBound + let bottom = index + 1 < spans.count ? tops[index + 1] + : endsOnScreen ? frame(ofRow: span.rows.upperBound - 1).minY : bounds.minY + guard top > bottom else { continue } + // Nothing is above the very first row to set a command apart from. + if lines, shown.contains(span.rows.lowerBound), span.rows.lowerBound > 0 { + rules.append(NSRect(x: text.minX, y: top - 0.5, width: text.width, height: 1)) + } + if lines, let kind = markKind(of: span) { + // In the margin, where the mark over a command gone back to is, and short of the lines + // at either end unless the command goes on past the screen's edge. + let gap: CGFloat = top - bottom > 12 ? 3 : 0 + let upper = top < bounds.maxY ? top - gap : top, lower = bottom > bounds.minY ? bottom + gap : bottom + marks.append(.init(frame: NSRect(x: max(text.minX - 9, 2), y: lower, width: 3, height: upper - lower), kind: kind)) + } + guard shading, span.isAlternate else { continue } + // The newest one isn't over: instead of an edge, its shade fades out over a row below its last. + let fade = endsOnScreen && span.isNewest ? rowHeight : 0 + shades.append(.init(frame: NSRect(x: bounds.minX, y: bottom - fade, width: bounds.width, height: top - bottom + fade), + fade: fade)) + } + separators.update(shades: shades, rules: rules, marks: marks) + } + + /// What the bar beside a command says, when there's something to say: it's running, or it failed. + /// Nothing for one that worked, nor for the prompt waiting for one. + private func markKind(of span: CommandBlocks.Span) -> CommandSeparatorView.Mark.Kind? { + guard let index = span.block, blocks.blocks.indices.contains(index) else { return nil } + let block = blocks.blocks[index] + guard let entry = timeline.entries.last(where: { $0.block == block }) else { return nil } + return entry.isRunning ? .running : entry.failed ? .failed : nil + } + + /// Finds the commands again a moment after the last resize, then draws what sets them apart. + private func relocateBlocksSoon() { + relocationToken += 1 + let token = relocationToken + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + MainActor.assumeIsolated { + guard let self, self.relocationToken == token, !self.isShutDown else { return } + self.relocateBlocksIfNeeded() + if !self.rowsMayHaveMoved { self.refreshCommandSeparators() } + } + } } /// The output part of `rows(of:)`. @@ -1308,6 +1440,7 @@ final class Pane: LocalProcessTerminalViewDelegate { terminal.installColors(theme.terminalPalette) terminal.optionAsMetaKey = preferences.optionAsMeta view.apply(theme: theme, scanlines: preferences.scanlines) + refreshCommandSeparators() } func changeFontSize(by delta: CGFloat) { @@ -1331,7 +1464,9 @@ final class Pane: LocalProcessTerminalViewDelegate { func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) { rowsMayHaveMoved = true + waitingPromptRow = nil view.finder.contentChanged() + refreshCommandSeparators() } func setTerminalTitle(source: LocalProcessTerminalView, title: String) {} diff --git a/Sources/CodecUI/PaneView.swift b/Sources/CodecUI/PaneView.swift index e3fe8fe..2355db4 100644 --- a/Sources/CodecUI/PaneView.swift +++ b/Sources/CodecUI/PaneView.swift @@ -4,9 +4,11 @@ import CodecCore /// The terminal plus the pane's overlays: find bar and highlights, the file ⌘-click would open, bell /// indicator, exit banner, the question whether to activate a Python environment and the -/// inactive-pane dimmer. +/// inactive-pane dimmer. And under it, what sets one command apart from the next. final class PaneView: NSView { let terminal = CodecTerminalView(frame: .zero) + /// The line above each command and the shade behind every other one. + let commandSeparators = CommandSeparatorView() let scanlines = ScanlineView() /// Marks a command the user went back to. let commandMark = CommandMarkView() @@ -27,6 +29,8 @@ final class PaneView: NSView { } /// The terminal scrolled, by the user or as output came in. var onScroll: (() -> Void)? + /// The pane was laid out again: what's drawn over the terminal's rows may be elsewhere now. + var onLayout: (() -> Void)? var onDismiss: (() -> Void)? { didSet { keyCatcher.onEscape = onDismiss } } @@ -57,7 +61,7 @@ final class PaneView: NSView { self?.onScroll?() } - for view in [terminal, finder.highlights, fileLink, scanlines, commandMark, finder.bar, dimmer, venvBanner, overlay, + for view in [commandSeparators, terminal, finder.highlights, fileLink, scanlines, commandMark, finder.bar, dimmer, venvBanner, overlay, keyCatcher] as [NSView] { view.translatesAutoresizingMaskIntoConstraints = false addSubview(view) @@ -77,12 +81,19 @@ final class PaneView: NSView { finder.bar.topAnchor.constraint(equalTo: topAnchor, constant: 8), finder.bar.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14), barRoom, - ] + [finder.highlights, fileLink, scanlines, commandMark, dimmer, overlay].flatMap { $0.constraintsFilling(self) }) + ] + [commandSeparators, finder.highlights, fileLink, scanlines, commandMark, dimmer, overlay].flatMap { $0.constraintsFilling(self) }) } override func layout() { super.layout() if finder.isOpen { finder.refreshHighlights() } + onLayout?() + } + + /// Back in a window, as when its tab is selected again: what's drawn over the rows is due again. + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil { needsLayout = true } } @available(*, unavailable) @@ -102,6 +113,7 @@ final class PaneView: NSView { func apply(theme: CodecTheme, scanlines enabled: Bool) { finder.apply(theme: theme) fileLink.theme = theme + commandSeparators.theme = theme scanlines.isHidden = !enabled scanlines.lineColor = theme.isDark ? .black : NSColor(white: 0, alpha: 0.5) scanlines.needsDisplay = true @@ -307,6 +319,76 @@ final class FileLinkView: NSView { } } +/// Sets commands apart: a line above each one's prompt, a shade behind every other one, prompt and +/// output, and a bar in the margin beside one that failed or is still running. It's under the +/// terminal, whose own background is clear, so text over a shade keeps its contrast and anything the +/// terminal fills in (a selection, a prompt chip) covers the line. +final class CommandSeparatorView: NSView { + /// The bar beside a command there's something to say about. One that worked has none. + struct Mark: Equatable { + enum Kind: Equatable { + case running + case failed + } + var frame: NSRect + var kind: Kind + } + + /// A shaded command's rows. The newest command isn't over, so its shade has no bottom edge: it + /// fades out over its last `fade` points. + struct Shade: Equatable { + var frame: NSRect + var fade: CGFloat = 0 + } + + var theme: CodecTheme = .codec { + didSet { needsDisplay = true } + } + /// All in this view's coordinates. + private(set) var shades: [Shade] = [] + /// The lines, a point thick. + private(set) var rules: [NSRect] = [] + private(set) var marks: [Mark] = [] + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + func update(shades: [Shade] = [], rules: [NSRect] = [], marks: [Mark] = []) { + guard shades != self.shades || rules != self.rules || marks != self.marks else { return } + let wasEmpty = self.shades.isEmpty && self.rules.isEmpty && self.marks.isEmpty + self.shades = shades + self.rules = rules + self.marks = marks + needsDisplay = true + // Back after a resize hid them, they come in rather than pop. + guard wasEmpty, window != nil, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { return } + alphaValue = 0 + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.14 + animator().alphaValue = 1 + } + } + + override func draw(_ dirtyRect: NSRect) { + let shade = theme.commandShade + for band in shades { + let frame = backingAlignedRect(band.frame, options: .alignAllEdgesNearest) + let fade = min(band.fade, frame.height) + shade.setFill() + NSRect(x: frame.minX, y: frame.minY + fade, width: frame.width, height: frame.height - fade).fill(using: .sourceOver) + guard fade > 0 else { continue } + NSGradient(starting: shade, ending: shade.withAlphaComponent(0))? + .draw(in: NSRect(x: frame.minX, y: frame.minY, width: frame.width, height: fade), angle: -90) + } + theme.commandRule.setFill() + for rule in rules { backingAlignedRect(rule, options: .alignAllEdgesNearest).fill(using: .sourceOver) } + for mark in marks { + // Quieter than the mark over a command gone back to, which is drawn in the same place. + (mark.kind == .failed ? theme.alert : theme.accent).withAlphaComponent(0.8).setFill() + NSBezierPath(roundedRect: mark.frame, xRadius: 1.5, yRadius: 1.5).fill() + } + } +} + /// The mark over a command the user went back to; it fades after a moment. final class CommandMarkView: NSView { private let bar = PassthroughView() diff --git a/Sources/CodecUI/SettingsView.swift b/Sources/CodecUI/SettingsView.swift index b4ecd13..99217d3 100644 --- a/Sources/CodecUI/SettingsView.swift +++ b/Sources/CodecUI/SettingsView.swift @@ -113,6 +113,13 @@ private struct AppearanceSettings: View { .help("Keeps each pane's scrollback and the Commands sidebar when you quit, in ~/Library/Application Support/Codec, readable only by you.") } + Section("Commands") { + Toggle("Line between commands", isOn: $prefs.commandSeparators) + .help("Draws a line above each command's prompt in the terminal, and a bar in the margin beside a command that failed or is running. Needs shell integration (zsh).") + Toggle("Shade every other command", isOn: $prefs.shadeAlternateCommands) + .help("Gives every other command, with its output, a slightly different background. Needs shell integration (zsh).") + } + Section("Status bar") { Toggle("Show CPU, memory and network meters", isOn: $prefs.showSystemStats) } diff --git a/Sources/CodecUI/Theme.swift b/Sources/CodecUI/Theme.swift index 545da9e..f2824dc 100644 --- a/Sources/CodecUI/Theme.swift +++ b/Sources/CodecUI/Theme.swift @@ -21,6 +21,10 @@ struct CodecTheme: Identifiable, Equatable { var appearance: NSAppearance? { NSAppearance(named: isDark ? .darkAqua : .aqua) } /// Hairlines between panes and around the bars. var divider: NSColor { foreground.withAlphaComponent(isDark ? 0.1 : 0.14) } + /// The line above each command's prompt in the terminal. + var commandRule: NSColor { foreground.withAlphaComponent(isDark ? 0.14 : 0.18) } + /// What every other command in the terminal is shaded with. + var commandShade: NSColor { foreground.withAlphaComponent(isDark ? 0.05 : 0.055) } var terminalPalette: [SwiftTerm.Color] { ansi.map(\.terminalColor) } /// The pill behind a prompt chip of `color`: that color washed over the background, as `#rrggbb`. diff --git a/Tests/CodecCoreTests/CommandBlocksTests.swift b/Tests/CodecCoreTests/CommandBlocksTests.swift index 35ce08b..b9c8cc7 100644 --- a/Tests/CodecCoreTests/CommandBlocksTests.swift +++ b/Tests/CodecCoreTests/CommandBlocksTests.swift @@ -135,4 +135,60 @@ struct CommandBlocksTests { blocks.record(.init(prompt: 2, output: 3)) #expect(blocks.blocks == [.init(prompt: 2, output: 3)]) } + + /// What sets commands apart on screen: each one's rows up to the next one's prompt, every other one + /// told from the rest. + @Test func spansRunFromEachPromptToTheNext() { + var blocks = CommandBlocks() + for prompt in [0, 4, 9] { blocks.record(.init(prompt: prompt, output: prompt + 1)) } + // At a prompt of two rows. + #expect(blocks.spans(overlapping: 0..<20, end: 12, waitingPrompt: 12..<14) == [ + .init(rows: 0..<4, isAlternate: false, block: 0), .init(rows: 4..<9, isAlternate: true, block: 1), + .init(rows: 9..<12, isAlternate: false, block: 2), .init(rows: 12..<14, isAlternate: true, isNewest: true), + ]) + // While the newest one runs, scrolled so that the first is out of view. + #expect(blocks.spans(overlapping: 5..<25, end: 30) == [ + .init(rows: 4..<9, isAlternate: true, block: 1), .init(rows: 9..<30, isAlternate: false, block: 2, isNewest: true), + ]) + #expect(blocks.spans(overlapping: 40..<60, end: 30).isEmpty) + } + + /// Nothing is printed below the cursor: a command recorded there was cleared off the screen. + @Test func spansLeaveOutCommandsPastTheEnd() { + var blocks = CommandBlocks() + for prompt in [0, 4, 9] { blocks.record(.init(prompt: prompt, output: prompt + 1)) } + #expect(blocks.spans(overlapping: 0..<20, end: 0, waitingPrompt: 0..<1) + == [.init(rows: 0..<1, isAlternate: false, isNewest: true)]) + #expect(blocks.spans(overlapping: 0..<20, end: 6) == [ + .init(rows: 0..<4, isAlternate: false, block: 0), .init(rows: 4..<6, isAlternate: true, block: 1, isNewest: true), + ]) + } + + @Test func everyOtherCommandStaysTheSameOneAsOldOnesAreDropped() { + var blocks = CommandBlocks(limit: 2) + for prompt in [0, 5, 10] { blocks.record(.init(prompt: prompt, output: prompt + 1)) } + #expect(blocks.spans(overlapping: 0..<20, end: 15) == [ + .init(rows: 5..<10, isAlternate: true, block: 0), .init(rows: 10..<15, isAlternate: false, block: 1, isNewest: true), + ]) + // Found again after a rewrap, the older of the two gone from the scrollback. + blocks.replace(with: [.init(prompt: 12, output: 13)], dropped: 1) + #expect(blocks.spans(overlapping: 0..<20, end: 15) == [.init(rows: 12..<15, isAlternate: false, block: 0, isNewest: true)]) + } + + @Test func forgetsCommandsTheScreenWasClearedUnder() { + var blocks = CommandBlocks() + blocks.record(.init(prompt: 3, output: 4, commandLine: "% make")) + blocks.record(.init(prompt: 20, output: 21, commandLine: "% ls")) + blocks.record(.init(prompt: 22, output: 23, commandLine: "% ls")) + var cleared = blocks + // ⌃L: the screen, from row 20, holds only the prompt. The first command is in the scrollback. + cleared.forgetBlocksGone(from: rows(["% ", "", ""], from: 20)) + #expect(cleared.blocks.map(\.prompt) == [3]) + // Run again after a clear, the same command is on another row. + cleared = blocks + cleared.forgetBlocksGone(from: rows(["% ls", "a b", "% "], from: 20)) + #expect(cleared.blocks.map(\.prompt) == [3, 20]) + blocks.forgetBlocksGone(from: rows(["% ls", "a b", "% ls ", "a b", "% "], from: 20)) + #expect(blocks.blocks.map(\.prompt) == [3, 20, 22]) + } } diff --git a/Tests/CodecCoreTests/PoliciesTests.swift b/Tests/CodecCoreTests/PoliciesTests.swift index 55e2f99..ca38252 100644 --- a/Tests/CodecCoreTests/PoliciesTests.swift +++ b/Tests/CodecCoreTests/PoliciesTests.swift @@ -75,6 +75,8 @@ struct PreferencesTests { #expect(preferences.shellIntegration) #expect(preferences.showSystemStats) #expect(!preferences.scanlines) + #expect(preferences.commandSeparators) + #expect(preferences.shadeAlternateCommands) } @Test func persistsChangesAcrossInstances() { diff --git a/Tests/CodecUITests/CommandSeparatorTests.swift b/Tests/CodecUITests/CommandSeparatorTests.swift new file mode 100644 index 0000000..e12e5cf --- /dev/null +++ b/Tests/CodecUITests/CommandSeparatorTests.swift @@ -0,0 +1,207 @@ +import Testing +import Foundation +import AppKit +import CodecCore +@testable import CodecUI + +/// The line above each command and the shade behind every other one, in a real pane running zsh. +@Suite("Command separators", .serialized, .timeLimit(.minutes(1)), + .enabled(if: FileManager.default.isExecutableFile(atPath: "/bin/zsh"))) +@MainActor +struct CommandSeparatorTests { + /// A pane in a window, its zsh kept from the user's own configuration. + private func makeZshPane(_ env: TestEnvironment, promptedBy zshrc: String = "PS1='%# '\n") throws -> (Pane, NSWindow) { + let config = env.directory.appendingPathComponent("zdotdir") + try FileManager.default.createDirectory(at: config, withIntermediateDirectories: true) + try zshrc.write(to: config.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) + setenv("ZDOTDIR", config.path, 1) + defer { unsetenv("ZDOTDIR") } + let pane = env.makePane(host: FakeHost()) + let window = makeTestWindow(size: NSSize(width: 800, height: 300)) + window.contentView = pane.view + pane.view.layoutSubtreeIfNeeded() + return (pane, window) + } + + private func run(_ command: String, in pane: Pane) async -> Bool { + let count = pane.timeline.entries.count + try? await Task.sleep(for: .milliseconds(200)) + pane.terminal.send(txt: command + "\r") + return await waitUntil { pane.timeline.entries.count == count + 1 && pane.timeline.running == nil && pane.isAtCommandLine } + } + + /// A visible row's frame in the separators' coordinates. + private func frame(ofRow row: Int, in pane: Pane) throws -> NSRect { + let grid = try #require(TerminalGrid(terminal: pane.terminal)) + return pane.view.commandSeparators.convert(grid.frame(row: row, columns: 0..<1), from: nil) + } + + @Test func drawsALineAboveEachCommandAndShadesEveryOtherOne() async throws { + let env = try TestEnvironment(shell: "/bin/zsh", integration: true) + defer { env.tearDown() } + let (pane, window) = try makeZshPane(env) + defer { + pane.shutDown() + window.close() + } + let separators = pane.view.commandSeparators + #expect(await waitUntil { pane.isAtCommandLine }) + // The first prompt has nothing above it to be set apart from. + #expect(separators.rules.isEmpty && separators.shades.isEmpty) + + #expect(await run("echo one", in: pane)) + #expect(await run("echo two", in: pane)) + // Rows: "% echo one", "one", "% echo two", "two", "% ". + #expect(await waitUntil { separators.rules.count == 2 && separators.shades.count == 1 }) + let second = try frame(ofRow: 2, in: pane), waiting = try frame(ofRow: 4, in: pane) + #expect(separators.rules.map(\.midY) == [second.maxY, waiting.maxY]) + #expect(separators.rules.allSatisfy { $0.height == 1 && $0.width == pane.terminal.frame.width }) + // The second command, from its line to the next, as wide as the pane. + let shade = try #require(separators.shades.first) + #expect(shade.frame.maxY == second.maxY && shade.frame.minY == waiting.maxY && shade.fade == 0) + #expect(shade.frame.width == pane.view.bounds.width) + // Both worked: nothing in the margin. + #expect(separators.marks.isEmpty) + + // Each has its setting. + env.preferences.commandSeparators = false + pane.applyPreferences() + #expect(separators.rules.isEmpty && separators.marks.isEmpty && separators.shades.count == 1) + env.preferences.shadeAlternateCommands = false + pane.applyPreferences() + #expect(separators.rules.isEmpty && separators.shades.isEmpty) + env.preferences.commandSeparators = true + pane.applyPreferences() + #expect(separators.rules.count == 2 && separators.shades.isEmpty) + + // Out of its window, as while another tab is selected, and back. + window.contentView = NSView() + pane.refreshCommandSeparators() + #expect(separators.rules.isEmpty) + window.contentView = pane.view + window.layoutIfNeeded() + #expect(await waitUntil { separators.rules.count == 2 }) + } + + /// Codec's prompt starts with a blank line: the line goes through the middle of it. + @Test func drawsTheLineThroughAPromptsBlankFirstLine() async throws { + let env = try TestEnvironment(shell: "/bin/zsh", integration: true) + defer { env.tearDown() } + let (pane, window) = try makeZshPane(env, promptedBy: "PS1=$'\\n%# '\n") + defer { + pane.shutDown() + window.close() + } + let separators = pane.view.commandSeparators + #expect(await waitUntil { pane.isAtCommandLine }) + #expect(await run("echo one", in: pane)) + // Rows: "", "% echo one", "one", "", "% ". + #expect(await waitUntil { separators.rules.count == 1 }) + let blank = try frame(ofRow: 3, in: pane) + #expect(separators.rules.first?.midY == blank.midY) + // The waiting prompt is the second command: shaded from its line, and fading out under its + // last row, since it's not over. + let last = try frame(ofRow: 4, in: pane) + #expect(separators.shades == [.init(frame: NSRect(x: 0, y: last.minY - last.height, width: pane.view.bounds.width, + height: blank.midY - last.minY + last.height), fade: last.height)]) + } + + /// A bar in the margin beside a command that failed, and beside the one running; nothing beside one + /// that worked. + @Test func marksCommandsThatFailedOrAreRunning() async throws { + let env = try TestEnvironment(shell: "/bin/zsh", integration: true) + defer { env.tearDown() } + let (pane, window) = try makeZshPane(env) + defer { + pane.shutDown() + window.close() + } + let separators = pane.view.commandSeparators + #expect(await waitUntil { pane.isAtCommandLine }) + #expect(await run("false", in: pane)) + #expect(await waitUntil { separators.marks.map(\.kind) == [.failed] }) + // Beside its one row, between the pane's edge and the text. + let failed = try #require(separators.marks.first?.frame), row = try frame(ofRow: 0, in: pane) + #expect(failed.minX > 0 && failed.maxX < pane.terminal.frame.minX) + #expect(failed.minY >= row.minY && failed.maxY <= row.maxY && failed.height == row.height - 6) + pane.terminal.send(txt: "sleep 1\r") + #expect(await waitUntil { separators.marks.map(\.kind) == [.failed, .running] }) + // It's the second command, and the newest: shaded, with no bottom edge yet. + #expect(separators.shades.count == 1 && (separators.shades.first?.fade ?? 0) > 0) + #expect(await waitUntil { separators.marks.map(\.kind) == [.failed] && separators.shades.map(\.fade) == [0] }) + } + + /// `clear` leaves the commands that were on screen nowhere: nothing is drawn where they were. + @Test func forgetsCommandsWhenTheScreenIsCleared() async throws { + let env = try TestEnvironment(shell: "/bin/zsh", integration: true) + defer { env.tearDown() } + let (pane, window) = try makeZshPane(env) + defer { + pane.shutDown() + window.close() + } + let separators = pane.view.commandSeparators + #expect(await waitUntil { pane.isAtCommandLine }) + #expect(await run("echo one", in: pane)) + #expect(await run("echo two", in: pane)) + #expect(await waitUntil { separators.rules.count == 2 }) + #expect(await run("clear", in: pane)) + #expect(await waitUntil { separators.rules.isEmpty && separators.shades.isEmpty }) + #expect(pane.blocks.blocks.isEmpty) + // Empty prompts take up the rows the commands were on: the only line is above the one waiting. + pane.terminal.send(txt: "\r\r\r\r\r") + #expect(await waitUntil { pane.terminal.visibleRowsText(0..<8).filter { $0.hasPrefix("%") }.count == 6 }) + let waiting = try frame(ofRow: 5, in: pane) + #expect(await waitUntil { separators.rules.map(\.midY) == [waiting.maxY] }) + #expect(separators.shades.isEmpty) + // Counting starts over: the first command after the clear isn't shaded, the prompt after it is. + #expect(await run("echo three", in: pane)) + #expect(await waitUntil { separators.rules.count == 2 && separators.shades.count == 1 }) + #expect(separators.shades.first?.frame.maxY == (try frame(ofRow: 7, in: pane)).maxY) + } + + /// A full-screen program's screen isn't the commands': nothing is drawn over it, and all of it is + /// back when the program leaves. + @Test func hidesWhileAFullScreenProgramHasTheScreen() async throws { + let env = try TestEnvironment(shell: "/bin/zsh", integration: true) + defer { env.tearDown() } + let (pane, window) = try makeZshPane(env) + defer { + pane.shutDown() + window.close() + } + let separators = pane.view.commandSeparators + #expect(await waitUntil { pane.isAtCommandLine }) + #expect(await run("echo one", in: pane)) + #expect(await waitUntil { separators.rules.count == 1 }) + pane.terminal.send(txt: "printf '\\e[?1049h'; sleep 1; printf '\\e[?1049l'\r") + #expect(await waitUntil { pane.terminal.terminalInputStateSnapshot()?.isAlternateBuffer == true }) + #expect(await waitUntil { separators.rules.isEmpty && separators.shades.isEmpty }) + #expect(await waitUntil { pane.isAtCommandLine && separators.rules.count == 2 && separators.shades.count == 1 }) + } + + /// A narrower terminal rewraps its lines, and the commands are found again where they went. + @Test func followsCommandsWhenTheTerminalIsResized() async throws { + let env = try TestEnvironment(shell: "/bin/zsh", integration: true) + defer { env.tearDown() } + let (pane, window) = try makeZshPane(env) + defer { + pane.shutDown() + window.close() + } + let separators = pane.view.commandSeparators + #expect(await waitUntil { pane.isAtCommandLine }) + #expect(await run("echo \(String(repeating: "wide ", count: 20))", in: pane)) + #expect(await run("echo two", in: pane)) + #expect(await waitUntil { separators.rules.count == 2 }) + + window.setContentSize(NSSize(width: 420, height: 300)) + pane.view.layoutSubtreeIfNeeded() + func row(startingWith text: String) -> Int? { pane.terminal.visibleRowsText(0..<12).firstIndex { $0.hasPrefix(text) } } + #expect(await waitUntil { (row(startingWith: "% echo two") ?? 0) > 2 }) + let second = try #require(row(startingWith: "% echo two")) + #expect(await waitUntil { + separators.rules.count == 2 && separators.rules.first?.midY == (try? frame(ofRow: second, in: pane))?.maxY + }) + } +} diff --git a/docs/README.md b/docs/README.md index 689952f..f7c5605 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,7 @@ How to use Codec, a feature at a time. New here? Start with [Getting started](ge |---|---| | [Getting started](getting-started.md) | Requirements, installing, updates, building from source, shell integration, where Codec keeps things | | [Tabs and panes](tabs-and-panes.md) | Tabs, splits, focus, closing safely, attention and exits | -| [The command line](command-line.md) | Editing the line like a text field, selecting text, history as you type, the history menu and search, Tab completion, command output | +| [The command line](command-line.md) | Editing the line like a text field, selecting text, history as you type, the history menu and search, Tab completion, the lines between commands, command output | | [Commands sidebar](commands-sidebar.md) | Every command the pane ran, with its output, to go back to | | [Intel](intel.md) | Saved scripts with variables and notes, personal or shared with the project, in folders; the file format | | [Files and editor](files-and-editor.md) | The file tree, the editor, Markdown preview | diff --git a/docs/command-line.md b/docs/command-line.md index f132efb..cc9e770 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -31,6 +31,16 @@ Copy on select is a setting (Settings › Shell › Selection). The scrollback k ⌘F searches the scrollback. Every match on screen is tinted and the current one is outlined; the bar counts them ("3 of 14"), and marks down the pane's right edge show where the rest are. Return or ⌘G goes to the next match, ⇧Return or ⇧⌘G to the previous one (↓ and ↑ do too), wrapping around; Esc closes the bar. **Aa** matches case, **ab** whole words, **.\*** a regular expression. With text selected, ⌘F (or ⌘E) searches for it. Matches keep up as the pane prints more. +## Telling commands apart (zsh) + +A thin line runs above each command's prompt, and every other command sits on a slightly different shade, its prompt and its output together, so a long scrollback reads as one command after another. The prompt you're at counts as the next one; the newest command isn't over, so its shade fades out below it rather than ending in an edge. + +A command that failed has a bar beside it in the margin, in the theme's alert color, from its prompt to the end of its output: scrolling back, the failures are the ones that stand out. The command that's running has one in the accent color. Commands that worked have nothing. + +All of it follows the theme and stays behind the text. The lines, with the bars, and the shades each have their switch in Settings › Appearance › Commands. + +They're hidden while a full-screen program has the screen, and come back a moment after a resize, once Codec has found where the lines went. + ## A command's output (zsh) Copy or select what a command printed without dragging over it: right-click its output › Copy Output or Select Output, or press ⇧⌘C / ⇧⌘A for the last command. Codec finds a command's output from the command line itself, so it stays right however fast the output scrolled by, and after a resize rewraps the lines. diff --git a/docs/getting-started.md b/docs/getting-started.md index 9b34f40..46c497f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -71,7 +71,7 @@ Codec loads a small hook script into zsh through `ZDOTDIR`, then your own startu - the working directory (OSC 7), - each command's text, exit status and duration. -That's what the Commands sidebar, the status bar, ⌘↑ / ⌘↓ between commands, Copy Output, history with folders and durations, notifications for long commands, the editable command line and Codec's prompt are built on. +That's what the Commands sidebar, the status bar, ⌘↑ / ⌘↓ between commands, the lines and shades that set them apart, Copy Output, history with folders and durations, notifications for long commands, the editable command line and Codec's prompt are built on. Turn it off in Settings › Shell › Shell integration; it applies to new panes. Other shells (bash, fish, nushell…) run as plain terminals: set one in Settings › Shell. diff --git a/docs/images/branches.png b/docs/images/branches.png index e687431..1ede23c 100644 Binary files a/docs/images/branches.png and b/docs/images/branches.png differ diff --git a/docs/images/commands.png b/docs/images/commands.png index d61aa03..6dce0e2 100644 Binary files a/docs/images/commands.png and b/docs/images/commands.png differ diff --git a/docs/images/editor.png b/docs/images/editor.png index 50a5fe9..25ea27a 100644 Binary files a/docs/images/editor.png and b/docs/images/editor.png differ diff --git a/docs/images/environment-list.png b/docs/images/environment-list.png index a897d45..1df758d 100644 Binary files a/docs/images/environment-list.png and b/docs/images/environment-list.png differ diff --git a/docs/images/environments.png b/docs/images/environments.png index c1fa2eb..5ab417c 100644 Binary files a/docs/images/environments.png and b/docs/images/environments.png differ diff --git a/docs/images/hero.png b/docs/images/hero.png index 2ce67c0..651389b 100644 Binary files a/docs/images/hero.png and b/docs/images/hero.png differ diff --git a/docs/images/history.png b/docs/images/history.png index 4b745af..f2d9d40 100644 Binary files a/docs/images/history.png and b/docs/images/history.png differ diff --git a/docs/images/intel-document.png b/docs/images/intel-document.png index dccaf9b..06c0aaf 100644 Binary files a/docs/images/intel-document.png and b/docs/images/intel-document.png differ diff --git a/docs/images/intel.png b/docs/images/intel.png index 97fe617..64a6237 100644 Binary files a/docs/images/intel.png and b/docs/images/intel.png differ diff --git a/docs/images/listing.png b/docs/images/listing.png index 070b255..536231b 100644 Binary files a/docs/images/listing.png and b/docs/images/listing.png differ diff --git a/docs/images/open-file.png b/docs/images/open-file.png index 2e4292b..cde7f69 100644 Binary files a/docs/images/open-file.png and b/docs/images/open-file.png differ diff --git a/docs/images/themes.png b/docs/images/themes.png index df50d2a..712b8f4 100644 Binary files a/docs/images/themes.png and b/docs/images/themes.png differ diff --git a/docs/images/usual-duration.png b/docs/images/usual-duration.png index 119c630..7e0451b 100644 Binary files a/docs/images/usual-duration.png and b/docs/images/usual-duration.png differ diff --git a/docs/settings.md b/docs/settings.md index a1cddc3..67addcf 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -9,6 +9,7 @@ Open Settings with ⌘,. Changes apply as you make them, unless a setting says i - **Theme**: Codec (green on black, after the codec screen), Arctic, Amber and Daylight. Also in the command palette. - **Text**: the font (SF Mono by default, or any monospaced font you have) and its size, 9 to 24 points. ⌘+ / ⌘− / ⌘0 change the size from anywhere. - **Window**: blur behind the window, its opacity, dimming inactive split panes, CRT scanlines, and whether Codec [reopens your windows and tabs](tabs-and-panes.md#picking-up-where-you-left-off) at launch, with what each pane showed and its commands. +- **Commands**: a line between commands in the terminal, with a bar beside the ones that failed, and a shade behind every other one, each on by default. See [The command line › Telling commands apart](command-line.md#telling-commands-apart-zsh). - **Status bar**: show or hide the CPU, memory and network meters. ## Shell