macos transcript selection
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user