diff --git a/.ai/memory.md b/.ai/memory.md index f1e2362d..e5bafcd1 100644 --- a/.ai/memory.md +++ b/.ai/memory.md @@ -561,3 +561,12 @@ - **Over-scroll degrades to appending rather than clamping.** A move above the window is ignored — the pre-window behaviour, which looks wrong but never writes to the *wrong* row. Deliberate escape hatch for sequences this model doesn't cover (`ESC[H`, `ESC[J`, scroll regions, alt screen — brew's download queue uses none of them). - **Trailing-newline-ness is a property of how a row ended, not of `isComplete`.** Conflating them regressed `BrewCommandServicePseudoTerminalTests`: a child that ends with `printf 'tty'` (no newline) must not gain one in `standardOutput`. `TerminalTranscript` tracks `endsWithNewline` separately so the returned transcript holds settled rows without inventing terminators. - **Rejected: adopting SwiftTerm.** Its `Terminal` models a fixed grid + scrollback; the console models a variable-length transcript with stable row identities for SwiftUI diffing. Using the engine headless means diffing a grid to infer which rows changed — plausibly more code than this, and it fights `CONVENTIONS.md`'s "add packages sparingly". Revisit only if the app ever needs to run arbitrary interactive commands (a shell, a pager, anything using the alt screen), where hand-rolling stops being viable. + +## 2026-08-31 — The console body is a text view, not a list of rows + +- **What was wrong.** A `List` with one `Text` per line gives every line its own selection scope. Dragging across lines selected nothing, ⌘A had no document to select, the gaps between rows (row insets, and the empty area below the last line) weren't text at all so clicks there did nothing, and the pointer alternated between an arrow and an I-beam depending on which of those it was over. All four are the same defect: there was no document. +- **`NSTextView` is the fix, and it stays inside View + ViewModel.** `ConsoleTextView` (an `NSViewRepresentable` in `BrewFeatureConsole/Views/`) hosts a TextKit 1 text view — selectable, not editable, `isRichText = false` so ⌘C yields plain text. `ANSIConsoleText` now renders `NSAttributedString`; colours are still named on the SwiftUI token palette and bridged with `NSColor(_:)`, so the design system remains the source and light/dark still resolve. A colour-scheme flip re-renders the whole document (tracked with `@Environment(\.colorScheme)` as a *stored* dependency — reading `context.environment` inside `updateNSView` isn't a reliable invalidation trigger). +- **Streaming is a diff, not a rebuild.** `ConsoleTranscript` (ViewModels) holds the rendered lines plus their cached UTF-16 lengths and returns the minimal edit — every change `brew` makes lands in a suffix, so it is "replace from the first line that differs to the end". Rebuilding per line would be quadratic over a run **and** would drop the user's selection each time. Lines compare on stream + text only; identity is deliberately ignored because a redrawn progress row keeps its id (see 2026-08-27). +- **The renderer and the transcript must agree character-for-character.** Offsets address the rendered text, so `ConsoleTranscript.text(of:)` and `ANSIConsoleText.attributed(for:)` both emit spans-joined + `"\n"`. A unit test pins the invariant (`rendered length == transcript.length`) because nothing else would catch drift. +- **Follow-the-tail is owned by a scroll observer, not by the update.** `ConsoleOutputTextView` gives up following when the user scrolls away and takes it back when they return to the end — growing the document doesn't move the clip view, so the bounds-change notification only ever fires for a real scroll. The pin also runs from `layout()`: SwiftUI hands over the first batch of output *before* the scroll view has any size, and a scroll issued then goes nowhere (symptom: opening the console on a finished job showed the top of the log). +- **Verified by driving the real app** (DYLD-injected driver, see the 2026-08-25 note): drag across lines selects four lines; `selectAll:` validates on the first responder and selects the whole document; hit-testing the bottom edge of the output area lands on the text view (no arrow-cursor dead zone); a 200-character selection survives ~1100 characters of streamed output; scrolling away leaves the reader in place. Menu-routed ⌘A could **not** be exercised — a bare exec never becomes the key app — so that path rests on the responder validating `selectAll:`. diff --git a/BrewUITests/Screens/ConsoleScreen.swift b/BrewUITests/Screens/ConsoleScreen.swift index 4a0b10ee..92a91689 100644 --- a/BrewUITests/Screens/ConsoleScreen.swift +++ b/BrewUITests/Screens/ConsoleScreen.swift @@ -78,8 +78,8 @@ struct ConsoleScreen: Screen { line: UInt = #line, ) -> Self { expand(file: file, line: line) - // macOS exposes a `List` row's `Text` through the accessibility *value*, with no label at all, - // so matching on label alone silently never matches. + // macOS exposes text through the accessibility *value*, with no label at all, so matching on + // label alone silently never matches. let predicate = NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", substring, substring) let match = output.element.descendants(matching: .any).matching(predicate).firstMatch XCTAssertTrue( diff --git a/Sources/BrewFeatureConsole/ViewModels/ConsoleBodyContent.swift b/Sources/BrewFeatureConsole/ViewModels/ConsoleBodyContent.swift new file mode 100644 index 00000000..7a675c8c --- /dev/null +++ b/Sources/BrewFeatureConsole/ViewModels/ConsoleBodyContent.swift @@ -0,0 +1,14 @@ +// +// ConsoleBodyContent.swift +// BrewFeatureConsole +// + +import BrewCore +import BrewRepositoryInterfaces + +/// What the expanded console body shows. The job's identity travels with its lines because the text +/// view treats a different job as a different document. +enum ConsoleBodyContent: Equatable { + case noActivity + case output(jobID: CommandJobID, lines: [BrewCommandOutputLine]) +} diff --git a/Sources/BrewFeatureConsole/ViewModels/ConsoleTranscript.swift b/Sources/BrewFeatureConsole/ViewModels/ConsoleTranscript.swift new file mode 100644 index 00000000..b16783a5 --- /dev/null +++ b/Sources/BrewFeatureConsole/ViewModels/ConsoleTranscript.swift @@ -0,0 +1,65 @@ +// +// ConsoleTranscript.swift +// BrewFeatureConsole +// + +import BrewCore +import Foundation + +/// The console body's output as one text document, plus the smallest edit that brings an already +/// rendered copy of it up to date. +/// +/// Every change `brew` makes to the buffer lands in a suffix of it, so an update is "replace from the +/// first line that differs to the end" — re-rendering the whole thing per line would be quadratic over +/// a run and would drop the user's selection. Offsets are UTF-16, as `NSTextStorage` indexes. +struct ConsoleTranscript: Equatable { + private(set) var lines: [BrewCommandOutputLine] = [] + + /// Cached so an update doesn't re-measure the buffer. + private var lengths: [Int] = [] + + struct Edit: Equatable { + let location: Int + let length: Int + let lines: [BrewCommandOutputLine] + } + + /// The renderer must agree with this character-for-character, or the offsets address the wrong text. + static func text(of line: BrewCommandOutputLine) -> String { + line.spans.map(\.text).joined() + "\n" + } + + var text: String { + lines.map(Self.text(of:)).joined() + } + + var length: Int { + lengths.reduce(0, +) + } + + /// The edit a rendered document needs to catch up, or `nil` when nothing changed. + mutating func update(to newLines: [BrewCommandOutputLine]) -> Edit? { + let common = commonPrefixCount(with: newLines) + guard common < lines.count || common < newLines.count else { + return nil + } + let location = lengths.prefix(common).reduce(0, +) + let replacedLength = lengths.dropFirst(common).reduce(0, +) + let replacement = Array(newLines[common...]) + lines = newLines + lengths = Array(lengths.prefix(common)) + replacement.map { Self.text(of: $0).utf16.count } + return Edit(location: location, length: replacedLength, lines: replacement) + } + + /// Identity is deliberately not compared: a redrawn progress row keeps its id and changes its text. + private func commonPrefixCount(with newLines: [BrewCommandOutputLine]) -> Int { + var index = 0 + while index < lines.count, index < newLines.count, + lines[index].stream == newLines[index].stream, + lines[index].text == newLines[index].text + { + index += 1 + } + return index + } +} diff --git a/Sources/BrewFeatureConsole/ViewModels/ConsoleViewModel.swift b/Sources/BrewFeatureConsole/ViewModels/ConsoleViewModel.swift index bd9b0edb..449526b1 100644 --- a/Sources/BrewFeatureConsole/ViewModels/ConsoleViewModel.swift +++ b/Sources/BrewFeatureConsole/ViewModels/ConsoleViewModel.swift @@ -63,6 +63,13 @@ final class ConsoleViewModel { return repository.orderedIDs.last.flatMap { repository.jobs[$0] } } + var bodyContent: ConsoleBodyContent { + guard let job = selectedJob else { + return .noActivity + } + return .output(jobID: job.id, lines: job.output) + } + var statusPresentation: ConsoleStatusPresentation { if let active = activeJob { return ConsoleStatusPresentation( diff --git a/Sources/BrewFeatureConsole/Views/ANSIConsoleText.swift b/Sources/BrewFeatureConsole/Views/ANSIConsoleText.swift index 0468a478..7a3767ad 100644 --- a/Sources/BrewFeatureConsole/Views/ANSIConsoleText.swift +++ b/Sources/BrewFeatureConsole/Views/ANSIConsoleText.swift @@ -3,32 +3,57 @@ // BrewFeatureConsole // +import AppKit import BrewCore import BrewUIComponents import SwiftUI -/// Spans without an explicit foreground fall back to `defaultColor` so an uncoloured line looks exactly -/// as it did before ANSI support. Bold spans get a bold monospaced font; every other run is left -/// fontless so it inherits the `Text`'s base font — setting a font on all runs would defeat that. +/// Renders output lines into the attributed text the console's text view displays. +/// +/// Colours are named on the SwiftUI token palette and bridged with `NSColor(_:)`, which keeps the +/// asset's light/dark variants — the design system stays the source even though the drawing is AppKit's. enum ANSIConsoleText { - /// Spans are resolved once where the line is built, off the main actor. - static func attributed(for line: BrewCommandOutputLine, defaultColor: Color) -> AttributedString { - var result = AttributedString() + static let font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + static let boldFont = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .bold) + + private static let lineSpacing = BrewSpacing.xxs + + /// Newline-terminated to match ``ConsoleTranscript/text(of:)``, whose offsets address this text. + static func attributed(for line: BrewCommandOutputLine) -> NSAttributedString { + let result = NSMutableAttributedString() + let fallback = defaultColor(for: line.stream) for span in line.spans { - var piece = AttributedString(span.text) - piece.foregroundColor = span.style.foreground.map(color(for:)) ?? defaultColor - if span.style.bold { - piece.font = .system(.body, design: .monospaced).weight(.bold) - } - result.append(piece) + result.append(NSAttributedString( + string: span.text, + attributes: attributes( + color: span.style.foreground.map(color(for:)) ?? fallback, + bold: span.style.bold, + ), + )) } + result.append(NSAttributedString(string: "\n", attributes: attributes(color: fallback, bold: false))) return result } - /// Maps a terminal-palette colour onto a SwiftUI colour. Semantic system colours are used so the - /// output adapts to light/dark and stays legible on the console surface; bright variants reuse the - /// same hue since the display palette doesn't distinguish them. - private static func color(for ansiColor: ANSIColor) -> Color { + static func attributed(for lines: [BrewCommandOutputLine]) -> NSAttributedString { + let result = NSMutableAttributedString() + for line in lines { + result.append(attributed(for: line)) + } + return result + } + + static func defaultColor(for stream: BrewCommandOutputLine.Stream) -> Color { + switch stream { + case .stdout: + .brewTextPrimary + case .stderr: + .brewStatusError + } + } + + /// Bright variants reuse their base hue, since the display palette doesn't distinguish them. + static func color(for ansiColor: ANSIColor) -> Color { switch ansiColor { case .black, .brightBlack: .brewTextSecondary @@ -48,4 +73,18 @@ enum ANSIConsoleText { .brewTextPrimary } } + + private static func attributes(color: Color, bold: Bool) -> [NSAttributedString.Key: Any] { + [ + .font: bold ? boldFont : font, + .foregroundColor: NSColor(color), + .paragraphStyle: paragraphStyle, + ] + } + + private static let paragraphStyle: NSParagraphStyle = { + let style = NSMutableParagraphStyle() + style.lineSpacing = lineSpacing + return style + }() } diff --git a/Sources/BrewFeatureConsole/Views/ConsoleBody.swift b/Sources/BrewFeatureConsole/Views/ConsoleBody.swift index 30907976..22dffcae 100644 --- a/Sources/BrewFeatureConsole/Views/ConsoleBody.swift +++ b/Sources/BrewFeatureConsole/Views/ConsoleBody.swift @@ -3,21 +3,16 @@ // Brew // -import BrewAccessibilityID -import BrewCore -import BrewRepositoryInterfaces import BrewUIComponents import SwiftUI -/// Output area of the expanded console — virtualizing `List` over the selected job's output buffer -/// with auto-pin-to-bottom when new lines arrive. User scroll-lock-on-scroll-up is deferred to polish. +/// Output area of the expanded console. struct ConsoleBody: View { let viewModel: ConsoleViewModel var body: some View { - if let job = viewModel.selectedJob { - outputList(for: job) - } else { + switch viewModel.bodyContent { + case .noActivity: ContentUnavailableView( "No activity", systemImage: "terminal", @@ -25,40 +20,9 @@ struct ConsoleBody: View { ) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.brewSurfaceElevated) - } - } - - private func outputList(for job: CommandJob) -> some View { - ScrollViewReader { proxy in - List(job.output) { line in - Text(ANSIConsoleText.attributed( - for: line, - defaultColor: line.stream == .stderr ? Color.brewStatusError : Color.brewTextPrimary, - )) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 1, leading: BrewSpacing.lg, bottom: 1, trailing: BrewSpacing.lg)) - .id(line.id) - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(Color.brewSurfaceElevated) - .axid(.consoleOutput) - .onAppear { - if let last = job.output.last { - proxy.scrollTo(last.id, anchor: .bottom) - } - } - .onChange(of: job.output.count) { - guard let last = job.output.last else { - return - } - withAnimation(.linear(duration: 0.1)) { - proxy.scrollTo(last.id, anchor: .bottom) - } - } + case let .output(jobID, lines): + ConsoleTextView(lines: lines, jobID: jobID) + .background(Color.brewSurfaceElevated) } } } diff --git a/Sources/BrewFeatureConsole/Views/ConsoleTextView.swift b/Sources/BrewFeatureConsole/Views/ConsoleTextView.swift new file mode 100644 index 00000000..9efdb205 --- /dev/null +++ b/Sources/BrewFeatureConsole/Views/ConsoleTextView.swift @@ -0,0 +1,169 @@ +// +// ConsoleTextView.swift +// BrewFeatureConsole +// + +import AppKit +import BrewAccessibilityID +import BrewCore +import BrewRepositoryInterfaces +import BrewUIComponents +import SwiftUI + +/// The console output as one selectable text document, so selection, ⌘A and ⌘C behave the way they do +/// in Terminal. New output is applied as ``ConsoleTranscript``'s minimal edit, which is what lets a +/// selection made mid-run survive it. +struct ConsoleTextView: NSViewRepresentable { + let lines: [BrewCommandOutputLine] + + /// Switching tabs is a new document rather than an edit of this one. + let jobID: CommandJobID + + /// Stored rather than read off `context.environment`, so a flip is guaranteed to re-invoke the update. + @Environment(\.colorScheme) private var colorScheme + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context _: Context) -> NSScrollView { + // TextKit 1 explicitly: non-contiguous layout keeps a long transcript cheap. + let storage = NSTextStorage() + let layoutManager = NSLayoutManager() + layoutManager.allowsNonContiguousLayout = true + storage.addLayoutManager(layoutManager) + let container = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) + container.widthTracksTextView = true + layoutManager.addTextContainer(container) + + let textView = ConsoleOutputTextView(frame: .zero, textContainer: container) + textView.isEditable = false + textView.isSelectable = true + // Plain text on the pasteboard, not RTF. + textView.isRichText = false + textView.drawsBackground = false + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [NSView.AutoresizingMask.width] + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude, + ) + textView.textContainerInset = NSSize(width: BrewSpacing.lg, height: BrewSpacing.sm) + + let scrollView = NSScrollView() + scrollView.documentView = textView + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + scrollView.setAccessibilityIdentifier(AXID.consoleOutput.rawValue) + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? ConsoleOutputTextView, + let storage = textView.textStorage + else { + return + } + let coordinator = context.coordinator + // A different job, or a restyle of every line, is a re-render rather than an edit. + if coordinator.jobID != jobID || coordinator.colorScheme != colorScheme { + coordinator.jobID = jobID + coordinator.colorScheme = colorScheme + coordinator.transcript = ConsoleTranscript() + storage.setAttributedString(NSAttributedString()) + } + + guard let edit = coordinator.transcript.update(to: lines) else { + return + } + let selection = textView.selectedRanges.map(\.rangeValue) + storage.replaceCharacters( + in: NSRange(location: edit.location, length: edit.length), + with: ANSIConsoleText.attributed(for: edit.lines), + ) + textView.selectedRanges = ConsoleTextSelection + .clamped(selection, toLength: storage.length) + .map { NSValue(range: $0) } + + textView.pinToBottomIfFollowing() + } + + final class Coordinator { + var transcript = ConsoleTranscript() + var jobID: CommandJobID? + var colorScheme: ColorScheme? + } +} + +/// Follows the end of the document while the user is parked at the bottom of it, giving that up when +/// they scroll away so reading back through a long install isn't interrupted. +final class ConsoleOutputTextView: NSTextView { + /// How close to the end counts as "at the bottom". + private static let bottomSlack: CGFloat = 4 + + private var followsOutput = true + + private var isScrolledToBottom: Bool { + guard let clipView = enclosingScrollView?.contentView else { + return true + } + return clipView.documentVisibleRect.maxY >= frame.height - Self.bottomSlack + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let clipView = enclosingScrollView?.contentView else { + return + } + clipView.postsBoundsChangedNotifications = true + NotificationCenter.default.addObserver( + self, + selector: #selector(clipViewDidScroll), + name: NSView.boundsDidChangeNotification, + object: clipView, + ) + } + + /// Pinning on layout too: SwiftUI hands over the first batch of output before the scroll view has + /// any size, and a scroll issued then goes nowhere. + override func layout() { + super.layout() + pinToBottomIfFollowing() + } + + /// Growing the document doesn't move the clip view, so this only ever fires for a real scroll. + @objc private func clipViewDidScroll() { + followsOutput = isScrolledToBottom + } + + func pinToBottomIfFollowing() { + guard followsOutput, let scrollView = enclosingScrollView else { + return + } + let clipView = scrollView.contentView + let target = max(0, frame.height - clipView.bounds.height) + // Guarded so a pin that changes nothing doesn't churn the scroll view on every layout pass. + guard abs(clipView.bounds.origin.y - target) > 0.5 else { + return + } + clipView.scroll(to: NSPoint(x: clipView.bounds.origin.x, y: target)) + scrollView.reflectScrolledClipView(clipView) + } +} + +/// Re-applying the selection after a streaming edit, which can leave ranges past the end of the document. +enum ConsoleTextSelection { + /// Clamped rather than dropped, so a selection above the edit survives. `NSTextView` rejects an empty + /// set of ranges, so the result always holds at least one. + static func clamped(_ ranges: [NSRange], toLength length: Int) -> [NSRange] { + let clamped = ranges.map { range -> NSRange in + let location = min(range.location, length) + return NSRange(location: location, length: min(range.length, length - location)) + } + let selected = clamped.filter { $0.length > 0 } + return selected.isEmpty ? [clamped.first ?? NSRange(location: 0, length: 0)] : selected + } +} diff --git a/Sources/BrewFeatureConsole/Views/ConsoleToolbar.swift b/Sources/BrewFeatureConsole/Views/ConsoleToolbar.swift index 020ac9dc..9ce84783 100644 --- a/Sources/BrewFeatureConsole/Views/ConsoleToolbar.swift +++ b/Sources/BrewFeatureConsole/Views/ConsoleToolbar.swift @@ -66,50 +66,28 @@ struct ConsoleToolbar: View { @ViewBuilder private var actionButtons: some View { if let job = viewModel.selectedJob { - toolbarAction( - systemImage: "square.and.arrow.down", - label: "Save", - help: "Save output to file", - ) { + // Saving ends in a save panel, which is its own confirmation. + BrewActionButton("Save", systemImage: "square.and.arrow.down", help: "Save output to file") { ConsoleOutputExport.save(job) } - toolbarAction( + BrewActionButton( + "Copy", systemImage: "doc.on.doc", - label: "Copy", + confirmationTitle: "Copied", help: "Copy output to clipboard", ) { ConsoleOutputExport.copy(job) } } - toolbarAction( + BrewActionButton( + "Clear", systemImage: "trash", - label: "Clear", + confirmationTitle: "Cleared", help: "Clear completed jobs", ) { viewModel.clearCompleted() } } - - private func toolbarAction( - systemImage: String, - label: String, - help: String, - action: @escaping () -> Void, - ) -> some View { - Button(action: action) { - HStack(spacing: 3) { - Image(systemName: systemImage) - .font(.system(size: 11, weight: .medium)) - Text(label) - .font(.caption) - } - .foregroundStyle(Color.brewTextSecondary) - .padding(.horizontal, BrewSpacing.sm) - .padding(.vertical, BrewSpacing.xxs) - } - .buttonStyle(.borderless) - .help(help) - } } private struct JobPill: View { diff --git a/Sources/BrewUIComponents/Views/BrewActionButton.swift b/Sources/BrewUIComponents/Views/BrewActionButton.swift new file mode 100644 index 00000000..069bd741 --- /dev/null +++ b/Sources/BrewUIComponents/Views/BrewActionButton.swift @@ -0,0 +1,138 @@ +// +// BrewActionButton.swift +// BrewUIComponents +// + +import SwiftUI + +/// Compact icon + title action button for chrome — the console toolbar, the command block header. +/// +/// An action that leaves no visible trace (copying to the pasteboard, clearing a list) passes a +/// `confirmationTitle`: the button swaps to a tick and that title for a few seconds. +public struct BrewActionButton: View { + private let title: String + private let systemImage: String + private let confirmationTitle: String? + private let help: String? + private let action: () -> Void + + @State private var isHovered = false + @State private var isConfirming = false + @State private var confirmationTask: Task? + + private static let confirmationDuration: Duration = .seconds(5) + + public init( + _ title: String, + systemImage: String, + confirmationTitle: String? = nil, + help: String? = nil, + action: @escaping () -> Void, + ) { + self.title = title + self.systemImage = systemImage + self.confirmationTitle = confirmationTitle + self.help = help + self.action = action + } + + public var body: some View { + let appearance = BrewActionButtonAppearance( + title: title, + systemImage: systemImage, + confirmationTitle: confirmationTitle, + isConfirming: isConfirming, + ) + Button { + action() + confirm() + } label: { + Label(appearance.title, systemImage: appearance.systemImage) + .font(.brewCaption) + } + .buttonStyle(BrewActionButtonStyle(isHovered: isHovered)) + .onHover { isHovered = $0 } + .help(help ?? title) + // The label changes while confirming; what a screen reader or a UI test matches on must not. + .accessibilityLabel(title) + } + + private func confirm() { + guard confirmationTitle != nil else { + return + } + isConfirming = true + confirmationTask?.cancel() + confirmationTask = Task { @MainActor in + try? await Task.sleep(for: Self.confirmationDuration) + if !Task.isCancelled { + isConfirming = false + } + } + } +} + +/// What a ``BrewActionButton`` shows right now. +struct BrewActionButtonAppearance: Equatable { + let title: String + let systemImage: String + + init(title: String, systemImage: String, confirmationTitle: String?, isConfirming: Bool) { + if isConfirming, let confirmationTitle { + self.title = confirmationTitle + self.systemImage = "checkmark" + } else { + self.title = title + self.systemImage = systemImage + } + } +} + +/// Borderless until pointed at: hover fills the button, and a press takes the app's selection tint. +private struct BrewActionButtonStyle: ButtonStyle { + let isHovered: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle( + isHovered || configuration.isPressed ? Color.brewTextPrimary : Color.brewTextSecondary, + ) + .padding(.horizontal, BrewSpacing.sm) + .padding(.vertical, BrewSpacing.xxs) + .background( + RoundedRectangle(cornerRadius: BrewRadius.sm) + .fill(background(isPressed: configuration.isPressed)), + ) + .contentShape(RoundedRectangle(cornerRadius: BrewRadius.sm)) + .animation(.brewFast, value: isHovered) + } + + private func background(isPressed: Bool) -> Color { + if isPressed { + return .brewBrandTint + } + return isHovered ? .brewSurfaceElevated : .clear + } +} + +#if DEBUG + #Preview { + HStack(spacing: BrewSpacing.xs) { + BrewActionButton("Save", systemImage: "square.and.arrow.down", help: "Save output to file") {} + BrewActionButton( + "Copy", + systemImage: "doc.on.doc", + confirmationTitle: "Copied", + help: "Copy output to clipboard", + ) {} + BrewActionButton( + "Clear", + systemImage: "trash", + confirmationTitle: "Cleared", + help: "Clear completed jobs", + ) {} + } + .padding() + .background(Color.brewSurface) + } +#endif diff --git a/Sources/BrewUIComponents/Views/CommandBlockView.swift b/Sources/BrewUIComponents/Views/CommandBlockView.swift index 9f9e35af..b9301ee2 100644 --- a/Sources/BrewUIComponents/Views/CommandBlockView.swift +++ b/Sources/BrewUIComponents/Views/CommandBlockView.swift @@ -31,8 +31,6 @@ public struct CommandBlockView: View { _isExpanded = State(initialValue: !collapsible) } - @State private var copied = false - @State private var copyTask: Task? @State private var isExpanded: Bool public var body: some View { @@ -71,21 +69,10 @@ public struct CommandBlockView: View { .foregroundStyle(Color.brewTextSecondary) } Spacer() - Button(copied ? "Copied" : copyTitle, systemImage: copied ? "checkmark" : "doc.on.doc") { + BrewActionButton(copyTitle, systemImage: "doc.on.doc", confirmationTitle: "Copied") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(commands.joined(separator: "\n"), forType: .string) - copied = true - copyTask?.cancel() - copyTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(5)) - if !Task.isCancelled { - copied = false - } - } } - .font(.brewCaption) - .foregroundStyle(Color.brewTextSecondary) - .buttonStyle(.plain) } .padding(.horizontal, BrewSpacing.md) .padding(.vertical, BrewSpacing.sm) diff --git a/Tests/BrewFeatureConsoleTests/ANSIConsoleTextTests.swift b/Tests/BrewFeatureConsoleTests/ANSIConsoleTextTests.swift index e8d2f148..0961d038 100644 --- a/Tests/BrewFeatureConsoleTests/ANSIConsoleTextTests.swift +++ b/Tests/BrewFeatureConsoleTests/ANSIConsoleTextTests.swift @@ -3,6 +3,7 @@ // BrewFeatureConsoleTests // +import AppKit import BrewCore @testable import BrewFeatureConsole import BrewUIComponents @@ -12,57 +13,104 @@ import Testing @MainActor struct ANSIConsoleTextTests { - @Test func `uncoloured line renders as a single run in the default colour`() { + @Test func `uncoloured line renders as a single run in its stream's colour`() { let line = BrewCommandOutputLine(stream: .stdout, text: "Pouring gh") - let attributed = ANSIConsoleText.attributed(for: line, defaultColor: .brewTextPrimary) + let runs = runs(of: ANSIConsoleText.attributed(for: line)) - #expect(String(attributed.characters) == "Pouring gh") - #expect(Array(attributed.runs).count == 1) - #expect(attributed.runs.first?.foregroundColor == .brewTextPrimary) - } - - @Test func `visible text preserves ANSI content without the escape codes`() { - let line = BrewCommandOutputLine(stream: .stdout, text: "\u{1B}[34m==>\u{1B}[0m Downloading") - - let attributed = ANSIConsoleText.attributed(for: line, defaultColor: .brewTextPrimary) - - #expect(String(attributed.characters) == "==> Downloading") + #expect(runs == [Run(text: "Pouring gh\n", color: NSColor(.brewTextPrimary), bold: false)]) } @Test func `coloured and default spans produce distinct runs`() { let line = BrewCommandOutputLine(stream: .stdout, text: "\u{1B}[34m==>\u{1B}[0m Downloading") - let attributed = ANSIConsoleText.attributed(for: line, defaultColor: .brewTextPrimary) - let runs = Array(attributed.runs) + let runs = runs(of: ANSIConsoleText.attributed(for: line)) - #expect(runs.count == 2) - #expect(runs.first?.foregroundColor == .brewStatusInfo) - #expect(runs.last?.foregroundColor == .brewTextPrimary) + #expect(runs == [ + Run(text: "==>", color: NSColor(.brewStatusInfo), bold: false), + Run(text: " Downloading\n", color: NSColor(.brewTextPrimary), bold: false), + ]) } @Test func `stderr default colour is applied to uncoloured spans`() { let line = BrewCommandOutputLine(stream: .stderr, text: "Warning: something") - let attributed = ANSIConsoleText.attributed(for: line, defaultColor: .brewStatusError) + let runs = runs(of: ANSIConsoleText.attributed(for: line)) - #expect(attributed.runs.first?.foregroundColor == .brewStatusError) + #expect(runs == [Run(text: "Warning: something\n", color: NSColor(.brewStatusError), bold: false)]) } @Test func `bold span carries a bold monospaced font`() { let line = BrewCommandOutputLine(stream: .stdout, text: "\u{1B}[1;32mSUCCESS") - let attributed = ANSIConsoleText.attributed(for: line, defaultColor: .brewTextPrimary) + // The terminator is its own run: it carries the line's default style, not the span's. + let runs = runs(of: ANSIConsoleText.attributed(for: line)) - #expect(attributed.runs.first?.foregroundColor == .brewStatusSuccess) - #expect(attributed.runs.first?.font == .system(.body, design: .monospaced).weight(.bold)) + #expect(runs.first == Run(text: "SUCCESS", color: NSColor(.brewStatusSuccess), bold: true)) } - @Test func `empty line yields empty attributed string`() { + @Test func `empty line renders as its terminator alone`() { let line = BrewCommandOutputLine(stream: .stdout, text: "") - let attributed = ANSIConsoleText.attributed(for: line, defaultColor: .brewTextPrimary) + let attributed = ANSIConsoleText.attributed(for: line) + + #expect(attributed.string == "\n") + } + + @Test func `a buffer renders as its lines in order`() { + let lines = [ + BrewCommandOutputLine(stream: .stdout, text: "\u{1B}[34m==>\u{1B}[0m Fetching"), + BrewCommandOutputLine(stream: .stderr, text: "Warning: something"), + ] + + let attributed = ANSIConsoleText.attributed(for: lines) + + #expect(attributed.string == "==> Fetching\nWarning: something\n") + } + + /// The transcript's offsets address this text, and nothing checks that agreement at compile time. + @Test func `rendered length matches what the transcript measures`() { + let lines = [ + BrewCommandOutputLine(stream: .stdout, text: "\u{1B}[34m==>\u{1B}[0m Fetching gh"), + BrewCommandOutputLine(stream: .stdout, text: ""), + BrewCommandOutputLine(stream: .stderr, text: "Warning: brew is out of date"), + ] + var transcript = ConsoleTranscript() + _ = transcript.update(to: lines) + + #expect(ANSIConsoleText.attributed(for: lines).length == transcript.length) + } + + @Test func `ANSI colours map onto the console palette`() { + let mapped = [ANSIColor.blue, .brightBlue, .red, .green, .yellow, .black, .white] + .map(ANSIConsoleText.color(for:)) + + #expect(mapped == [ + .brewStatusInfo, + .brewStatusInfo, + .brewStatusError, + .brewStatusSuccess, + .brewStatusWarning, + .brewTextSecondary, + .brewTextPrimary, + ]) + } + + private struct Run: Equatable { + let text: String + let color: NSColor + let bold: Bool + } - #expect(String(attributed.characters).isEmpty) + private func runs(of attributed: NSAttributedString) -> [Run] { + var runs: [Run] = [] + attributed.enumerateAttributes(in: NSRange(location: 0, length: attributed.length)) { attributes, range, _ in + runs.append(Run( + text: attributed.attributedSubstring(from: range).string, + color: attributes[.foregroundColor] as? NSColor ?? .clear, + bold: attributes[.font] as? NSFont == ANSIConsoleText.boldFont, + )) + } + return runs } } diff --git a/Tests/BrewFeatureConsoleTests/ConsoleTextSelectionTests.swift b/Tests/BrewFeatureConsoleTests/ConsoleTextSelectionTests.swift new file mode 100644 index 00000000..6130777c --- /dev/null +++ b/Tests/BrewFeatureConsoleTests/ConsoleTextSelectionTests.swift @@ -0,0 +1,47 @@ +// +// ConsoleTextSelectionTests.swift +// BrewFeatureConsoleTests +// + +@testable import BrewFeatureConsole +import Foundation +import Testing + +@MainActor +struct ConsoleTextSelectionTests { + @Test func `a selection inside the document is left alone`() { + let selection = [NSRange(location: 4, length: 10)] + + #expect(ConsoleTextSelection.clamped(selection, toLength: 40) == selection) + } + + @Test func `a selection running past the end is clamped to it`() { + let selection = [NSRange(location: 4, length: 100)] + + #expect( + ConsoleTextSelection.clamped(selection, toLength: 40) == [NSRange(location: 4, length: 36)], + ) + } + + @Test func `discontiguous selections are each clamped`() { + let selection = [NSRange(location: 0, length: 4), NSRange(location: 30, length: 20)] + + #expect(ConsoleTextSelection.clamped(selection, toLength: 40) == [ + NSRange(location: 0, length: 4), + NSRange(location: 30, length: 10), + ]) + } + + /// `NSTextView` rejects an empty set of ranges. + @Test func `a selection entirely past the end collapses to a caret`() { + let selection = [NSRange(location: 80, length: 20)] + + #expect( + ConsoleTextSelection.clamped(selection, toLength: 40) == [NSRange(location: 40, length: 0)], + ) + } + + @Test func `no selection yields a caret at the start`() { + #expect(ConsoleTextSelection.clamped([], toLength: 40) == [NSRange(location: 0, length: 0)]) + } +} diff --git a/Tests/BrewFeatureConsoleTests/ConsoleTranscriptTests.swift b/Tests/BrewFeatureConsoleTests/ConsoleTranscriptTests.swift new file mode 100644 index 00000000..295528cb --- /dev/null +++ b/Tests/BrewFeatureConsoleTests/ConsoleTranscriptTests.swift @@ -0,0 +1,161 @@ +// +// ConsoleTranscriptTests.swift +// BrewFeatureConsoleTests +// + +import BrewCore +@testable import BrewFeatureConsole +import Foundation +import Testing + +@MainActor +struct ConsoleTranscriptTests { + @Test func `document is the visible text of every line, newline terminated`() { + var transcript = ConsoleTranscript() + + _ = transcript.update(to: [line("==> Fetching"), line("\u{1B}[32mPoured\u{1B}[0m gh")]) + + #expect(transcript.text == "==> Fetching\nPoured gh\n") + } + + @Test func `an unchanged buffer produces no edit`() { + var transcript = ConsoleTranscript() + let lines = [line("==> Fetching")] + _ = transcript.update(to: lines) + + #expect(transcript.update(to: lines) == nil) + } + + @Test func `an appended line is inserted after the lines already rendered`() { + var transcript = ConsoleTranscript() + let first = line("==> Fetching") + _ = transcript.update(to: [first]) + + let edit = transcript.update(to: [first, line("==> Pouring")]) + + #expect(EditSummary(edit) == EditSummary( + location: "==> Fetching\n".utf16.count, + length: 0, + texts: ["==> Pouring"], + )) + } + + /// A progress row is rewritten many times a second; replacing more would throw away the selection. + @Test func `a revised last line replaces only itself`() { + var transcript = ConsoleTranscript() + let settled = line("==> Fetching") + let progress = line("#### 40%", isComplete: false) + _ = transcript.update(to: [settled, progress]) + + let edit = transcript.update(to: [ + settled, + BrewCommandOutputLine(stream: .stdout, text: "######## 80%", id: progress.id, isComplete: false), + ]) + + #expect(EditSummary(edit) == EditSummary( + location: "==> Fetching\n".utf16.count, + length: "#### 40%\n".utf16.count, + texts: ["######## 80%"], + )) + } + + /// `CommandJob` trims the front past `maxOutputLines`, which leaves no shared prefix. + @Test func `trimming the front of the buffer replaces the whole document`() { + var transcript = ConsoleTranscript() + _ = transcript.update(to: [line("first"), line("second")]) + + let edit = transcript.update(to: [line("second"), line("third")]) + + #expect(EditSummary(edit) == EditSummary( + location: 0, + length: "first\nsecond\n".utf16.count, + texts: ["second", "third"], + )) + } + + @Test func `dropping trailing lines deletes them without rendering anything`() { + var transcript = ConsoleTranscript() + let kept = line("first") + _ = transcript.update(to: [kept, line("second")]) + + let edit = transcript.update(to: [kept]) + + #expect(EditSummary(edit) == EditSummary( + location: "first\n".utf16.count, + length: "second\n".utf16.count, + texts: [], + )) + } + + /// Stream decides the line's colour, so it matters even when the text is unchanged. + @Test func `lines differing only in stream are re-rendered`() { + var transcript = ConsoleTranscript() + _ = transcript.update(to: [BrewCommandOutputLine(stream: .stdout, text: "Warning")]) + + let edit = transcript.update(to: [BrewCommandOutputLine(stream: .stderr, text: "Warning")]) + + #expect(edit?.lines.first?.stream == .stderr) + } + + /// Replays a streaming run against a string that indexes the way `NSTextStorage` does. + @Test func `applying each edit in turn reproduces the document`() { + var transcript = ConsoleTranscript() + let rendered = NSMutableString() + let fetching = line("==> Fetching gh") + let progress = line("#### 40%", isComplete: false) + let done = BrewCommandOutputLine(stream: .stdout, text: "######## 100%", id: progress.id) + let warning = BrewCommandOutputLine(stream: .stderr, text: "Warning: brew is out of date") + + for buffer in [ + [fetching], + [fetching, progress], + [fetching, done], + [fetching, done, warning], + [done, warning], + ] { + guard let edit = transcript.update(to: buffer) else { + continue + } + rendered.replaceCharacters( + in: NSRange(location: edit.location, length: edit.length), + with: edit.lines.map(ConsoleTranscript.text(of:)).joined(), + ) + } + + #expect(rendered as String == transcript.text) + } + + @Test func `length matches the rendered document`() { + var transcript = ConsoleTranscript() + + _ = transcript.update(to: [line("==> Fetching"), line("\u{1B}[32mPoured\u{1B}[0m gh")]) + + #expect(transcript.length == transcript.text.utf16.count) + } + + private func line(_ text: String, isComplete: Bool = true) -> BrewCommandOutputLine { + BrewCommandOutputLine(stream: .stdout, text: text, isComplete: isComplete) + } + + /// Asserts on position and visible content; identity and timestamps aren't the document's business. + private struct EditSummary: Equatable { + let location: Int + let length: Int + let texts: [String] + + init(location: Int, length: Int, texts: [String]) { + self.location = location + self.length = length + self.texts = texts + } + + init?(_ edit: ConsoleTranscript.Edit?) { + guard let edit else { + return nil + } + location = edit.location + length = edit.length + texts = edit.lines.map(\.text) + } + } +} diff --git a/Tests/BrewFeatureConsoleTests/ConsoleViewModelTests.swift b/Tests/BrewFeatureConsoleTests/ConsoleViewModelTests.swift index e919db1e..599ae655 100644 --- a/Tests/BrewFeatureConsoleTests/ConsoleViewModelTests.swift +++ b/Tests/BrewFeatureConsoleTests/ConsoleViewModelTests.swift @@ -110,6 +110,43 @@ struct ConsoleViewModelTests { #expect(harness.viewModel.selectedID == runningJobID) } + // MARK: - bodyContent + + @Test func `bodyContent is the empty state until something has run`() async { + let harness = ConsoleJobsHarness() + await harness.awaitReady() + + #expect(harness.viewModel.bodyContent == .noActivity) + } + + @Test func `bodyContent carries the selected job's identity and output`() async throws { + let harness = ConsoleJobsHarness() + await harness.awaitReady() + let id = BrewOperationID(kind: .formula, name: "gh") + await harness.emit(id: id, phase: .running(.installFormula)) + let line = BrewCommandOutputLine(stream: .stdout, text: "==> Fetching gh") + await harness.emit(id: id, output: line) + let job = try #require(harness.job(for: id)) + + #expect(harness.viewModel.bodyContent == .output(jobID: job.id, lines: [line])) + } + + @Test func `bodyContent follows the selected job`() async throws { + let harness = ConsoleJobsHarness() + await harness.awaitReady() + let first = BrewOperationID(kind: .formula, name: "gh") + let second = BrewOperationID(kind: .formula, name: "ripgrep") + await harness.emit(id: first, phase: .running(.installFormula)) + let line = BrewCommandOutputLine(stream: .stdout, text: "==> Fetching gh") + await harness.emit(id: first, output: line) + await harness.emit(id: second, phase: .running(.installFormula)) + let firstJob = try #require(harness.job(for: first)) + + harness.viewModel.select(id: firstJob.id) + + #expect(harness.viewModel.bodyContent == .output(jobID: firstJob.id, lines: [line])) + } + // MARK: - shouldAutoExpandConsole @Test func `shouldAutoExpandConsole is false with no jobs`() async { diff --git a/Tests/BrewUIComponentsTests/BrewActionButtonAppearanceTests.swift b/Tests/BrewUIComponentsTests/BrewActionButtonAppearanceTests.swift new file mode 100644 index 00000000..2283ca6e --- /dev/null +++ b/Tests/BrewUIComponentsTests/BrewActionButtonAppearanceTests.swift @@ -0,0 +1,48 @@ +// +// BrewActionButtonAppearanceTests.swift +// BrewUIComponentsTests +// + +@testable import BrewUIComponents +import Foundation +import Testing + +struct BrewActionButtonAppearanceTests { + @Test func `a button at rest shows its own title and icon`() { + let appearance = BrewActionButtonAppearance( + title: "Copy", + systemImage: "doc.on.doc", + confirmationTitle: "Copied", + isConfirming: false, + ) + + #expect(appearance == BrewActionButtonAppearance( + title: "Copy", + systemImage: "doc.on.doc", + confirmationTitle: nil, + isConfirming: false, + )) + } + + @Test func `a confirming button shows a tick and its confirmation title`() { + let appearance = BrewActionButtonAppearance( + title: "Clear", + systemImage: "trash", + confirmationTitle: "Cleared", + isConfirming: true, + ) + + #expect((appearance.title, appearance.systemImage) == ("Cleared", "checkmark")) + } + + @Test func `a button without a confirmation title never changes`() { + let appearance = BrewActionButtonAppearance( + title: "Save", + systemImage: "square.and.arrow.down", + confirmationTitle: nil, + isConfirming: true, + ) + + #expect((appearance.title, appearance.systemImage) == ("Save", "square.and.arrow.down")) + } +}