From 5cad587df63628278d172ca3f102556f55c60d46 Mon Sep 17 00:00:00 2001 From: Arach Tchoupani Date: Tue, 2 Jun 2026 23:22:58 -0400 Subject: [PATCH] feat(scout): interactive scrollbar polish + markdown preview toggle - HUD scroller knob brightens and fattens on hover (via tracking area) with a slightly wider hit lane, so it reads as a grabbable control rather than decoration - file viewer: markdown opens as rendered Preview with a Preview <-> Source segmented toggle; relative links resolve to the file's own folder - expose ScoutMarkdownView so the viewer can reuse the app's renderer --- .../Sources/Scout/ScoutFileViewerPanel.swift | 71 ++++++++++++++++++- apps/macos/Sources/Scout/ScoutRootView.swift | 2 +- .../Sources/Scout/ScoutScrollStyle.swift | 64 ++++++++++++----- 3 files changed, 117 insertions(+), 20 deletions(-) diff --git a/apps/macos/Sources/Scout/ScoutFileViewerPanel.swift b/apps/macos/Sources/Scout/ScoutFileViewerPanel.swift index ed9bb8a6..f3aeffff 100644 --- a/apps/macos/Sources/Scout/ScoutFileViewerPanel.swift +++ b/apps/macos/Sources/Scout/ScoutFileViewerPanel.swift @@ -48,6 +48,8 @@ struct ScoutFileDocument: Sendable { let url: URL let lineCount: Int let highlighted: [AttributedString] + let rawText: String + let isMarkdown: Bool let truncated: Bool let error: String? @@ -56,7 +58,7 @@ struct ScoutFileDocument: Sendable { static let maxLineLength = 2000 private static func failure(_ url: URL, _ message: String) -> ScoutFileDocument { - ScoutFileDocument(url: url, lineCount: 0, highlighted: [], truncated: false, error: message) + ScoutFileDocument(url: url, lineCount: 0, highlighted: [], rawText: "", isMarkdown: false, truncated: false, error: message) } static func load(path: String) -> ScoutFileDocument { @@ -88,7 +90,16 @@ struct ScoutFileDocument: Sendable { let lines = limited.map { $0.count > maxLineLength ? String($0.prefix(maxLineLength)) + " …" : $0 } let language = ScoutCodeLanguage.from(ext: url.pathExtension) let highlighted = ScoutSyntaxHighlighter.highlight(lines: lines, language: language) - return ScoutFileDocument(url: url, lineCount: lines.count, highlighted: highlighted, truncated: truncated, error: nil) + let isMarkdown = ["md", "markdown", "mdown", "mkd", "mdx"].contains(url.pathExtension.lowercased()) + return ScoutFileDocument( + url: url, + lineCount: lines.count, + highlighted: highlighted, + rawText: text, + isMarkdown: isMarkdown, + truncated: truncated, + error: nil + ) } } @@ -104,6 +115,8 @@ struct ScoutFileViewerPanel: View { let onOpenInEditor: () -> Void @State private var document: ScoutFileDocument? + /// For markdown: rendered preview vs. raw source. Defaults to preview. + @State private var showPreview = true private var fileName: String { (target.path as NSString).lastPathComponent } private var dirPath: String { @@ -134,6 +147,7 @@ struct ScoutFileViewerPanel: View { }.value guard !Task.isCancelled else { return } document = loaded + showPreview = loaded.isMarkdown } } @@ -160,7 +174,11 @@ struct ScoutFileViewerPanel: View { Spacer(minLength: HudSpacing.sm) - if let line = target.line { + if document?.isMarkdown == true { + ScoutMarkdownModeToggle(showPreview: $showPreview) + } + + if let line = target.line, !(document?.isMarkdown == true && showPreview) { Text("L\(line)") .font(HudFont.mono(9, weight: .semibold)) .monospacedDigit() @@ -200,6 +218,8 @@ struct ScoutFileViewerPanel: View { if let document { if let error = document.error { errorState(error) + } else if document.isMarkdown, showPreview { + markdownPreview(document) } else { code(document) } @@ -210,6 +230,20 @@ struct ScoutFileViewerPanel: View { } } + private func markdownPreview(_ document: ScoutFileDocument) -> some View { + ScrollView(.vertical) { + ScoutMarkdownView( + text: document.rawText, + baseDirectory: (target.path as NSString).deletingLastPathComponent + ) + .padding(.horizontal, HudSpacing.xxl) + .padding(.vertical, HudSpacing.lg) + .frame(maxWidth: .infinity, alignment: .leading) + .scoutOverlayScrollers() + } + .scrollIndicators(.visible) + } + private func code(_ document: ScoutFileDocument) -> some View { let gutter = gutterWidth(for: document.lineCount) return ScrollViewReader { proxy in @@ -305,6 +339,37 @@ struct ScoutFileViewerPanel: View { } } +/// Compact Preview ⇄ Source segmented toggle for markdown files. +private struct ScoutMarkdownModeToggle: View { + @Binding var showPreview: Bool + + var body: some View { + HStack(spacing: 2) { + segment("Preview", active: showPreview) { showPreview = true } + segment("Source", active: !showPreview) { showPreview = false } + } + .padding(2) + .background(RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous).fill(HudSurface.inset)) + .overlay(RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous).stroke(ScoutDesign.hairline, lineWidth: HudStrokeWidth.thin)) + } + + private func segment(_ title: String, active: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(HudFont.mono(9, weight: .semibold)) + .foregroundStyle(active ? HudPalette.ink : HudPalette.muted) + .padding(.horizontal, HudSpacing.sm) + .frame(height: 20) + .background( + RoundedRectangle(cornerRadius: HudRadius.standard - 2, style: .continuous) + .fill(active ? HudSurface.selected(HudPalette.accent) : Color.clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain).scoutPointerCursor() + } +} + // MARK: - Syntax highlighting /// Coarse language buckets — enough to drive comment/keyword rules for a diff --git a/apps/macos/Sources/Scout/ScoutRootView.swift b/apps/macos/Sources/Scout/ScoutRootView.swift index 22b86958..47a03d79 100644 --- a/apps/macos/Sources/Scout/ScoutRootView.swift +++ b/apps/macos/Sources/Scout/ScoutRootView.swift @@ -2253,7 +2253,7 @@ private struct ScoutAgentHoverCard: View { } } -private struct ScoutMarkdownView: View { +struct ScoutMarkdownView: View { let text: String /// Workspace root of the agent that wrote this message — used to resolve /// relative file paths the agent quoted from its own context. diff --git a/apps/macos/Sources/Scout/ScoutScrollStyle.swift b/apps/macos/Sources/Scout/ScoutScrollStyle.swift index 9289c721..9be490ae 100644 --- a/apps/macos/Sources/Scout/ScoutScrollStyle.swift +++ b/apps/macos/Sources/Scout/ScoutScrollStyle.swift @@ -6,20 +6,28 @@ import AppKit #if os(macOS) enum ScoutScrollbarMetrics { - /// Width of the reserved scroller lane (content is inset by this). - static let laneWidth: CGFloat = 12 - /// Thickness of the knob/track pill within the lane. - static let pillThickness: CGFloat = 6 + /// Width of the reserved scroller lane (this is the clickable hit area — + /// kept comfortably wider than the visible knob so it's easy to grab). + static let laneWidth: CGFloat = 13 + /// Thickness of the faint always-present track pill. + static let trackThickness: CGFloat = 6 + /// Knob thickness at rest and while hovered (it fattens to invite a drag). + static let knobThickness: CGFloat = 7 + static let knobThicknessHover: CGFloat = 10 /// Inset of the pill from the ends of the track. static let pillInset: CGFloat = 2 - static let knobAlpha: CGFloat = 0.34 - static let trackAlpha: CGFloat = 0.07 + static let knobAlpha: CGFloat = 0.40 + static let knobAlphaHover: CGFloat = 0.66 + static let trackAlpha: CGFloat = 0.06 + static let trackAlphaHover: CGFloat = 0.12 } -/// A slim, HUD-coherent scroller. Draws a persistent faint track plus a brighter -/// rounded knob so it's always clear a scroll area exists, while staying tight to -/// the panel edge via a narrow reserved lane. +/// A slim, HUD-coherent scroller. Draws a persistent faint track plus a rounded +/// knob that brightens and fattens on hover, so it reads as a grabbable control +/// rather than decoration — while staying tight to the edge via a narrow lane. final class ScoutHudScroller: NSScroller { + private var hovering = false + override class var isCompatibleWithOverlayScrollers: Bool { true } /// Keep the reserved lane narrow so content sits tight to the divider/border. @@ -30,25 +38,49 @@ final class ScoutHudScroller: NSScroller { ScoutScrollbarMetrics.laneWidth } + // A tracking area gives the knob hover feedback (and confirms the scroller is + // receiving mouse events — i.e. that it is in fact clickable). + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], + owner: self + )) + } + + override func mouseEntered(with event: NSEvent) { + hovering = true + needsDisplay = true + } + + override func mouseExited(with event: NSEvent) { + hovering = false + needsDisplay = true + } + override func drawKnobSlot(in slotRect: NSRect, highlight flag: Bool) { - let pill = pillRect(in: slotRect) + let pill = pillRect(in: slotRect, thickness: ScoutScrollbarMetrics.trackThickness) let radius = min(pill.width, pill.height) / 2 - NSColor.white.withAlphaComponent(ScoutScrollbarMetrics.trackAlpha).setFill() + let alpha = hovering ? ScoutScrollbarMetrics.trackAlphaHover : ScoutScrollbarMetrics.trackAlpha + NSColor.white.withAlphaComponent(alpha).setFill() NSBezierPath(roundedRect: pill, xRadius: radius, yRadius: radius).fill() } override func drawKnob() { let knobRect = rect(for: .knob) guard knobRect.width > 0, knobRect.height > 0 else { return } - let pill = pillRect(in: knobRect) + let thickness = hovering ? ScoutScrollbarMetrics.knobThicknessHover : ScoutScrollbarMetrics.knobThickness + let pill = pillRect(in: knobRect, thickness: thickness) let radius = min(pill.width, pill.height) / 2 - NSColor.white.withAlphaComponent(ScoutScrollbarMetrics.knobAlpha).setFill() + let alpha = hovering ? ScoutScrollbarMetrics.knobAlphaHover : ScoutScrollbarMetrics.knobAlpha + NSColor.white.withAlphaComponent(alpha).setFill() NSBezierPath(roundedRect: pill, xRadius: radius, yRadius: radius).fill() } - /// Slim pill centered within the lane, inset from the track ends. - private func pillRect(in rect: NSRect) -> NSRect { - let thickness = ScoutScrollbarMetrics.pillThickness + /// Pill of the given thickness centered within the lane, inset from the ends. + private func pillRect(in rect: NSRect, thickness: CGFloat) -> NSRect { let inset = ScoutScrollbarMetrics.pillInset let vertical = bounds.height >= bounds.width if vertical {