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
71 changes: 68 additions & 3 deletions apps/macos/Sources/Scout/ScoutFileViewerPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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 {
Expand Down Expand Up @@ -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
)
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -134,6 +147,7 @@ struct ScoutFileViewerPanel: View {
}.value
guard !Task.isCancelled else { return }
document = loaded
showPreview = loaded.isMarkdown
}
}

Expand All @@ -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()
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/macos/Sources/Scout/ScoutRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 48 additions & 16 deletions apps/macos/Sources/Scout/ScoutScrollStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down