macos transcript selection

This commit is contained in:
Ian Gulliver
2026-10-17 09:15:50 -07:00
parent c42a513e2c
commit 248c0d5904
3 changed files with 228 additions and 7 deletions
+214 -2
View File
@@ -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 {