From 248c0d5904ac978207f89d0269c71742ff5d0ab4 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 17 Oct 2026 09:15:50 -0700 Subject: [PATCH] macos transcript selection --- docs/macos.md | 4 +- mac/Sources/Byre/Fonts.swift | 15 +- mac/Sources/Byre/MarkdownView.swift | 216 +++++++++++++++++++++++++++- 3 files changed, 228 insertions(+), 7 deletions(-) diff --git a/docs/macos.md b/docs/macos.md index 626bb35..997dca2 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -164,7 +164,7 @@ Places where SwiftUI alone may not match the web client, each checked side by si | Enter sends, Shift-Enter newline, nothing during IME composition | `TextEditor` with `onKeyPress` | `NSTextView` in an `NSViewRepresentable`, sending from `doCommand(by: insertNewline:)`, which the input method never reaches while it holds marked text | No fallback: `onKeyPress(.return)` runs before the `TextEditor`'s `NSTextView` and its input context. It sends, and returns `.ignored` for Shift so the text view adds the newline (checked by hand). Because it runs first, it must also return `.ignored` while the focused text view's `hasMarkedText()` is true, or Return sends the draft mid-composition. An in-process test that set marked text with `setMarkedText` and delivered a Return `NSEvent` to the window showed both: an unguarded handler sent the draft with the marked text still pending, and the guarded one sent nothing and passed Return on to the text view. From there the input method handles Return as it does in any `NSTextView`, so the `NSTextView` fallback would behave the same. The guard reads the key window's first responder, which is the composer while the user types | | Image paste and drop into the composer | `onPasteCommand` and `onDrop` | The same `NSTextView`, overriding `paste(_:)` and drag handling | No fallback, but not through these two modifiers: `TextEditor`'s own text view takes ⌘V and drops before `onPasteCommand` and `onDrop` and inserts the file's path as text. Instead the app replaces Edit's pasteboard group (`CommandGroup(replacing: .pasteboard)` with Cut, Copy, Paste and Select All sent to the first responder): Paste attaches the clipboard's image when the composer has focus (`focusedValue`) and otherwise sends the ordinary `paste:`, so text paste works in every field and the terminal. Drops land on a `Color.clear` overlay on the `TextEditor` with `onDrop` and `allowsHitTesting(false)`: it still receives the drag, and clicks and typing pass through to the editor (with hit testing on, the editor can't be clicked). Both attach thumbnails and upload to `POST /api/blobs` | | Transcript of a long task (thousands of events) scrolling smoothly and staying stuck to the bottom | `LazyVStack` in a `ScrollView` | `List` with its own row reuse | No fallback: on a 5258-event task, `LazyVStack` scrolls smoothly, stays at the bottom while rows arrive and a message streams (`ScrollPosition.scrollTo(edge: .bottom)` on each change while within 80 points of the bottom, tracked with `onScrollGeometryChange`), and stays put when scrolled up. `List` passed too | -| Selecting text across several messages | `textSelection(.enabled)` selects within one `Text` only | None found in SwiftUI; listed as a difference below if the spike confirms it | Confirmed: a drag stops at the edge of one message. Listed under "Differences from the web client" | +| Selecting text across blocks and messages | `textSelection(.enabled)` selects within one `Text` only | A selectable `NSTextView` in an `NSViewRepresentable` | Fallback used within a message: `MarkdownView` draws each run of consecutive paragraphs, headings and lists (bullets as text, hanging indents from tab stops and paragraph styles) as one `NSAttributedString` in one non-editable `FlowText` text view, so a drag crosses blocks and bullets. Code blocks, diffs, tables, quotes, diagrams, `
`, paragraphs with images and choice lists stay SwiftUI views and end a run. Messages are still separate views; listed under "Differences from the web client" | | The terminal | No terminal view in SwiftUI | SwiftTerm's AppKit `TerminalView` in an `NSViewRepresentable` | Fallback used, as planned: SwiftTerm over `URLSessionWebSocketTask` gives a working shell; Ctrl-C, Ctrl-L, Ctrl-K and Ctrl-R reach it. A local `keyDown` monitor takes Ctrl-K/P/L/S// as Go shortcuts except while the first responder is the `TerminalView` | | Font from `web/fonts/` | Core Text | Convert the files to TTF in the build script if Core Text refuses them | No fallback: Core Text reads the woff2 files and their `wght` axis (200–800) | | SVG images from the worktree image endpoint | `NSImage(data:)` | Show the "image not available" text | No fallback: `NSImage(data:)` decodes the SVG from the worktree image endpoint (`_NSSVGImageRep`) and draws it sharp at any size | @@ -184,7 +184,7 @@ Platform differences the copy can't avoid, each with what the web client does an | Clicking a transcript image | Opens the image in a new browser tab | Opens it in an in-window overlay | | Preview links (`expose_port`) | Open in a new browser tab | Open in the default browser | | Window frame | Browser chrome and tabs | One macOS window with a standard title bar | -| Selecting transcript text | A drag selects across any number of messages | A drag selects within one message; each message is copied on its own | +| Selecting transcript text | A drag selects across any number of messages | A drag selects within one run of text in one message, ending at a code block, table, quote, diagram or `
`; each message is copied on its own | ## Building and checking diff --git a/mac/Sources/Byre/Fonts.swift b/mac/Sources/Byre/Fonts.swift index a24399c..44be0b2 100644 --- a/mac/Sources/Byre/Fonts.swift +++ b/mac/Sources/Byre/Fonts.swift @@ -24,10 +24,10 @@ enum Fonts { return CTFontCreateWithFontDescriptor(d, size, nil) } - nonisolated(unsafe) private static var fonts: [String: Font] = [:] + nonisolated(unsafe) private static var fonts: [String: (font: Font, ns: NSFont)] = [:] private static let lock = NSLock() - static func font(_ size: CGFloat, weight: CGFloat = 400, italic: Bool = false) -> Font { + private static func cached(_ size: CGFloat, _ weight: CGFloat, _ italic: Bool) -> (font: Font, ns: NSFont) { let key = "\(size) \(weight) \(italic)" lock.lock() defer { @@ -36,8 +36,17 @@ enum Fonts { if let f = fonts[key] { return f } - let f = Font(ct(size, weight: weight, italic: italic)) + let c = ct(size, weight: weight, italic: italic) + let f = (Font(c), c as NSFont) fonts[key] = f return f } + + static func font(_ size: CGFloat, weight: CGFloat = 400, italic: Bool = false) -> Font { + cached(size, weight, italic).font + } + + static func ns(_ size: CGFloat, weight: CGFloat = 400, italic: Bool = false) -> NSFont { + cached(size, weight, italic).ns + } } diff --git a/mac/Sources/Byre/MarkdownView.swift b/mac/Sources/Byre/MarkdownView.swift index 20ebccd..1ec3e49 100644 --- a/mac/Sources/Byre/MarkdownView.swift +++ b/mac/Sources/Byre/MarkdownView.swift @@ -110,11 +110,13 @@ struct InlineRenderer { private func run(_ s: String, _ style: Style) -> AttributedString { var a = AttributedString(s) a.font = Fonts.font(style.small ? 11 : size, weight: style.bold ? 700 : 400, italic: style.italic) + a.appKit.font = Fonts.ns(style.small ? 11 : size, weight: style.bold ? 700 : 400, italic: style.italic) if let link = style.link { a.link = link } if let color = style.color { a.foregroundColor = color + a.appKit.foregroundColor = NSColor(color) } return a } @@ -137,6 +139,7 @@ struct InlineRenderer { case .code(let c): var a = run(c, style) a.backgroundColor = Palette.surface + a.appKit.backgroundColor = NSColor(Palette.surface) append(a, &out) case .strong(let inner): var s = style @@ -221,18 +224,227 @@ struct MDContext { var choice: ((MDItem) -> AnyView)? } +private enum MDRun { + case flow([MDBlock]) + case block(MDBlock) +} + struct MarkdownView: View { let blocks: [MDBlock] var ctx = MDContext() + @Environment(Store.self) private var store var body: some View { VStack(alignment: .leading, spacing: 8) { - ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in - BlockView(block: block, ctx: ctx) + ForEach(Array(runs.enumerated()), id: \.offset) { _, run in + switch run { + case .flow(let blocks): + FlowText(text: flow(blocks)) + case .block(let block): + BlockView(block: block, ctx: ctx) + } } } .frame(maxWidth: .infinity, alignment: .leading) } + + private func flow(_ blocks: [MDBlock]) -> NSAttributedString { + var builder = FlowBuilder(store: store, ctx: ctx) + return builder.build(blocks) + } + + private var runs: [MDRun] { + var out: [MDRun] = [] + for block in blocks { + if !flows(block) { + out.append(.block(block)) + continue + } + if case .flow(let prev) = out.last { + out[out.count - 1] = .flow(prev + [block]) + continue + } + out.append(.flow([block])) + } + return out + } + + private func flows(_ block: MDBlock) -> Bool { + switch block { + case .paragraph(let inlines), .heading(_, let inlines): + return !hasImage(inlines) + case .list(_, _, let items, _): + if ctx.choice != nil && items.contains(where: { $0.choice != nil }) { + return false + } + return items.allSatisfy { !hasImage($0.inline) && $0.blocks.allSatisfy(flows) } + default: + return false + } + } + + private func hasImage(_ inlines: [MDInline]) -> Bool { + inlines.contains { item in + switch item { + case .image: + true + case .strong(let inner), .em(let inner), .link(_, let inner), .mark(_, let inner): + hasImage(inner) + default: + false + } + } + } +} + +@MainActor +private struct FlowBuilder { + let store: Store + let ctx: MDContext + private var out = NSMutableAttributedString() + private var gap: CGFloat = 0 + + init(store: Store, ctx: MDContext) { + self.store = store + self.ctx = ctx + } + + mutating func build(_ blocks: [MDBlock]) -> NSAttributedString { + self.blocks(blocks, 0) + if out.length > 0 { + out.deleteCharacters(in: NSRange(location: out.length - 1, length: 1)) + } + return out + } + + private mutating func blocks(_ blocks: [MDBlock], _ indent: CGFloat) { + for (i, block) in blocks.enumerated() { + if i > 0 { + gap = 8 + } + self.block(block, indent) + } + } + + private mutating func block(_ block: MDBlock, _ indent: CGFloat) { + switch block { + case .paragraph(let inlines): + para(inline(inlines, ctx.size), indent, indent) + case .heading(let level, let inlines): + gap += 4 + para(inline([.strong(inlines)], level == 3 ? 14 : 13), indent, indent) + case .list(let ordered, let start, let items, _): + let font = Fonts.ns(ctx.size) + for (n, item) in items.enumerated() { + if n > 0 { + gap = 2 + } + let marker = ordered ? "\(start + n)." : "•" + let inner = indent + ceil((marker as NSString).size(withAttributes: [.font: font]).width) + 6 + let line = NSMutableAttributedString(string: "\(marker)\t", attributes: [.font: font]) + line.append(inline(item.inline, ctx.size)) + para(line, indent, inner) + gap = 6 + blocks(item.blocks, inner) + } + default: + break + } + } + + private func inline(_ inlines: [MDInline], _ size: CGFloat) -> NSAttributedString { + let a = InlineRenderer(store: store, size: size, taskId: ctx.taskId).text(inlines) + return try! NSAttributedString(a, including: \.appKit) + } + + private mutating func para(_ a: NSAttributedString, _ first: CGFloat, _ head: CGFloat) { + let s = NSMutableAttributedString(attributedString: a) + s.append(NSAttributedString(string: "\n", attributes: [.font: Fonts.ns(ctx.size)])) + let all = NSRange(location: 0, length: s.length) + let color = NSColor(ctx.color) + s.enumerateAttribute(.foregroundColor, in: all) { value, range, _ in + if value == nil { + s.addAttribute(.foregroundColor, value: color, range: range) + } + } + let lead = (s.string as NSString).range(of: "\n").location + 1 + s.addAttribute(.paragraphStyle, value: style(first, head, 0), range: all) + s.addAttribute(.paragraphStyle, value: style(first, head, gap), range: NSRange(location: 0, length: lead)) + out.append(s) + gap = 0 + } + + private func style(_ first: CGFloat, _ head: CGFloat, _ before: CGFloat) -> NSParagraphStyle { + let p = NSMutableParagraphStyle() + p.lineSpacing = 3 + p.paragraphSpacingBefore = before + p.firstLineHeadIndent = first + p.headIndent = head + p.tabStops = [NSTextTab(textAlignment: .left, location: head)] + return p + } +} + +private final class FlowTextView: NSTextView { + override func resignFirstResponder() -> Bool { + setSelectedRange(NSRange(location: 0, length: 0)) + return super.resignFirstResponder() + } +} + +private struct FlowText: NSViewRepresentable { + let text: NSAttributedString + + func makeNSView(context: Context) -> FlowTextView { + let v = FlowTextView(usingTextLayoutManager: false) + v.isEditable = false + v.isSelectable = true + v.drawsBackground = false + v.isVerticallyResizable = false + v.isHorizontallyResizable = false + v.textContainerInset = .zero + v.textContainer!.lineFragmentPadding = 0 + v.linkTextAttributes = [.cursor: NSCursor.pointingHand] + v.delegate = context.coordinator + return v + } + + func updateNSView(_ v: FlowTextView, context: Context) { + context.coordinator.openURL = context.environment.openURL + if v.textStorage!.isEqual(to: text) { + return + } + let selected = v.selectedRanges + v.textStorage!.setAttributedString(text) + if selected.allSatisfy({ NSMaxRange($0.rangeValue) <= text.length }) { + v.selectedRanges = selected + } + } + + func sizeThatFits(_ proposal: ProposedViewSize, nsView v: FlowTextView, context: Context) -> CGSize? { + let width = proposal.width.flatMap { $0.isFinite ? $0 : nil } + let container = v.textContainer! + container.size = CGSize(width: width ?? .greatestFiniteMagnitude, height: .greatestFiniteMagnitude) + v.layoutManager!.ensureLayout(for: container) + let used = v.layoutManager!.usedRect(for: container) + return CGSize(width: width ?? ceil(used.width), height: ceil(used.height)) + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var openURL: OpenURLAction? + + func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { + guard let url = link as? URL, let openURL else { + return false + } + openURL(url) + return true + } + } } struct InlineView: View {