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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pane>` 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<Pane>` 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`.
Expand Down
65 changes: 62 additions & 3 deletions Sources/CodecCore/CommandBlocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>
/// 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<Int>, 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
Expand All @@ -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`
Expand Down Expand Up @@ -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<Int>, end: Int, waitingPrompt: Range<Int>? = 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..<upper, isAlternate: (firstNumber + index) % 2 == 1, block: index))
}
if let waitingPrompt, !waitingPrompt.isEmpty {
spans.append(Span(rows: waitingPrompt, isAlternate: (firstNumber + live.count) % 2 == 1))
}
if !spans.isEmpty { spans[spans.count - 1].isNewest = true }
return spans.filter { $0.rows.overlaps(shown) }
}

/// Drops the blocks whose command line isn't where it was found anymore: the screen was cleared under
/// them, by `clear` or ⌃L. `rows` is the screen, and only says so about the command lines on it or
/// below it; the ones above are in the scrollback, which stays as it was.
public mutating func forgetBlocksGone(from rows: [ScreenRow]) {
guard let top = rows.first?.row else { return }
blocks.removeAll { block in
guard let line = block.commandLine, block.output - 1 >= 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
Expand Down
11 changes: 10 additions & 1 deletion Sources/CodecCore/Preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) } }
Expand Down Expand Up @@ -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] = [
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading