macOS client parity review
This commit is contained in:
@@ -7,11 +7,15 @@ enum MDSegment {
|
||||
}
|
||||
|
||||
enum AppLink {
|
||||
static func task(_ id: String) -> URL {
|
||||
static func task(_ id: String, tab: String? = nil) -> URL {
|
||||
route("#/task/\(id)\(tab.map { "/\($0)" } ?? "")")
|
||||
}
|
||||
|
||||
static func route(_ hash: String) -> URL {
|
||||
var c = URLComponents()
|
||||
c.scheme = "byre-app"
|
||||
c.host = "task"
|
||||
c.queryItems = [URLQueryItem(name: "id", value: id)]
|
||||
c.host = "route"
|
||||
c.queryItems = [URLQueryItem(name: "hash", value: hash)]
|
||||
return c.url!
|
||||
}
|
||||
|
||||
@@ -39,13 +43,13 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct
|
||||
}
|
||||
let q = Dictionary((c.queryItems ?? []).map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { a, _ in a })
|
||||
switch c.host {
|
||||
case "task":
|
||||
store.focusTask(q["id"] ?? "")
|
||||
case "route":
|
||||
store.route(q["hash"] ?? "")
|
||||
case "todo":
|
||||
store.startTodo(q["project"] ?? "", q["title"] ?? "", discuss: q["discuss"] == "1", overlays)
|
||||
case "suggest":
|
||||
if q["discuss"] == "1" {
|
||||
store.discussSuggestion(q["title"] ?? "", brief: q["brief"] ?? "", overlays)
|
||||
store.discussSuggestion(q["project"] ?? "", q["title"] ?? "", brief: q["brief"] ?? "", overlays)
|
||||
} else {
|
||||
store.startSuggestion(q["project"] ?? "", q["title"] ?? "", brief: q["brief"] ?? "", overlays)
|
||||
}
|
||||
@@ -55,6 +59,34 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct
|
||||
return .handled
|
||||
}
|
||||
|
||||
private struct ChoiceTapKey: EnvironmentKey {
|
||||
static let defaultValue: (@MainActor @Sendable () -> Void)? = nil
|
||||
}
|
||||
|
||||
private struct CenterImagesKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var choiceTap: (@MainActor @Sendable () -> Void)? {
|
||||
get {
|
||||
self[ChoiceTapKey.self]
|
||||
}
|
||||
set {
|
||||
self[ChoiceTapKey.self] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var centerImages: Bool {
|
||||
get {
|
||||
self[CenterImagesKey.self]
|
||||
}
|
||||
set {
|
||||
self[CenterImagesKey.self] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MDMark {
|
||||
var color: Color {
|
||||
switch self {
|
||||
@@ -85,6 +117,7 @@ struct InlineRenderer {
|
||||
let store: Store
|
||||
let size: CGFloat
|
||||
let taskId: String?
|
||||
var weight: CGFloat = 400
|
||||
|
||||
private struct Style {
|
||||
var bold = false
|
||||
@@ -92,6 +125,7 @@ struct InlineRenderer {
|
||||
var link: URL?
|
||||
var color: Color?
|
||||
var small = false
|
||||
var tip: String?
|
||||
}
|
||||
|
||||
func segments(_ items: [MDInline]) -> [MDSegment] {
|
||||
@@ -112,8 +146,8 @@ 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.mergeAttributes(try! AttributeContainer([.font: Fonts.ns(style.small ? 11 : size, weight: style.bold ? 700 : 400, italic: style.italic)], including: \.appKit))
|
||||
a.font = Fonts.font(style.small ? 11 : size, weight: style.bold ? 700 : weight, italic: style.italic)
|
||||
a.mergeAttributes(try! AttributeContainer([.font: Fonts.ns(style.small ? 11 : size, weight: style.bold ? 700 : weight, italic: style.italic)], including: \.appKit))
|
||||
if let link = style.link {
|
||||
a.link = link
|
||||
}
|
||||
@@ -121,6 +155,9 @@ struct InlineRenderer {
|
||||
a.foregroundColor = color
|
||||
a.appKit.foregroundColor = NSColor(color)
|
||||
}
|
||||
if let tip = style.tip {
|
||||
a.appKit.toolTip = tip
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
@@ -177,7 +214,7 @@ struct InlineRenderer {
|
||||
return
|
||||
}
|
||||
if let m = taskHref.exec(href) {
|
||||
s.link = AppLink.task(m[1]!)
|
||||
s.link = AppLink.route(href)
|
||||
s.color = store.taskColor(m[1]!) ?? Palette.accent
|
||||
render(inner, s, &out)
|
||||
return
|
||||
@@ -190,6 +227,12 @@ struct InlineRenderer {
|
||||
todo(m[1]!, title, brief, inner, style, &out)
|
||||
return
|
||||
}
|
||||
if href.hasPrefix("#/") {
|
||||
s.link = AppLink.route(href)
|
||||
s.color = Palette.accent
|
||||
render(inner, s, &out)
|
||||
return
|
||||
}
|
||||
render(inner, style, &out)
|
||||
}
|
||||
|
||||
@@ -201,22 +244,40 @@ struct InlineRenderer {
|
||||
render(inner, s, &out)
|
||||
append(run(" ", style), &out)
|
||||
let state = store.currentSession(task.id)?.state
|
||||
var dot = Style(link: AppLink.task(task.id), color: state == .running ? Palette.ok : Palette.muted, small: true)
|
||||
var dot = Style(link: AppLink.task(task.id), color: state == .running ? Palette.ok : Palette.muted, small: true, tip: state?.rawValue ?? "no session")
|
||||
append(run(state == .running ? "●" : "○", dot), &out)
|
||||
dot.color = Palette.muted
|
||||
dot.tip = nil
|
||||
append(run(" \(state?.rawValue ?? "no agent")", dot), &out)
|
||||
return
|
||||
}
|
||||
render(inner, style, &out)
|
||||
append(run(" ", style), &out)
|
||||
if store.todoStarting.contains("\(projectId)\n\(title)") {
|
||||
append(run("starting…", Style(color: Palette.muted, small: true)), &out)
|
||||
let busy = store.todoBusy["\(projectId)\n\(title)"]
|
||||
func action(_ discuss: Bool, _ label: String, _ busyLabel: String, _ tip: String) {
|
||||
let url = brief.map { AppLink.suggest(projectId, title, $0, discuss: discuss) } ?? AppLink.todo(projectId, title, discuss: discuss)
|
||||
let mine = busy == (discuss ? "discuss" : "start")
|
||||
let a = Style(link: busy == nil ? url : nil, color: busy == nil ? Palette.accent : Palette.muted, small: true, tip: tip)
|
||||
append(run(mine ? busyLabel : label, a), &out)
|
||||
}
|
||||
if brief != nil {
|
||||
action(false, "start", "starting…", "Start a task with the lead's brief")
|
||||
append(run(" ", style), &out)
|
||||
action(true, "discuss", "sending…", "Ask your lead to talk it over in chat")
|
||||
return
|
||||
}
|
||||
let link = { (discuss: Bool) in brief.map { AppLink.suggest(projectId, title, $0, discuss: discuss) } ?? AppLink.todo(projectId, title, discuss: discuss) }
|
||||
append(run("start", Style(link: link(false), color: Palette.accent, small: true)), &out)
|
||||
action(false, "start", "starting…", "Start a task that gets straight to work")
|
||||
append(run(" ", style), &out)
|
||||
append(run("discuss", Style(link: link(true), color: Palette.accent, small: true)), &out)
|
||||
action(true, "discuss", "starting…", "Start a task that discusses it with you in chat before building anything")
|
||||
}
|
||||
}
|
||||
|
||||
private func flowColor(_ s: NSMutableAttributedString, _ color: NSColor) {
|
||||
let all = NSRange(location: 0, length: s.length)
|
||||
unsafe s.enumerateAttribute(.foregroundColor, in: all) { value, range, _ in
|
||||
if value == nil {
|
||||
s.addAttribute(.foregroundColor, value: color, range: range)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +285,9 @@ struct MDContext {
|
||||
var size: CGFloat = 13
|
||||
var color: Color = Palette.text
|
||||
var taskId: String?
|
||||
var choice: ((MDItem) -> AnyView)?
|
||||
var choice: ((MDItem, Bool) -> AnyView)?
|
||||
var gridColumns = 4
|
||||
var weight: CGFloat = 400
|
||||
}
|
||||
|
||||
private enum MDRun {
|
||||
@@ -356,7 +419,7 @@ private struct FlowBuilder {
|
||||
}
|
||||
|
||||
private func inline(_ inlines: [MDInline], _ size: CGFloat) -> NSAttributedString {
|
||||
let a = InlineRenderer(store: store, size: size, taskId: ctx.taskId).text(inlines)
|
||||
let a = InlineRenderer(store: store, size: size, taskId: ctx.taskId, weight: ctx.weight).text(inlines)
|
||||
return try! NSAttributedString(a, including: \.appKit)
|
||||
}
|
||||
|
||||
@@ -364,12 +427,7 @@ private struct FlowBuilder {
|
||||
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)
|
||||
unsafe s.enumerateAttribute(.foregroundColor, in: all) { value, range, _ in
|
||||
if value == nil {
|
||||
s.addAttribute(.foregroundColor, value: color, range: range)
|
||||
}
|
||||
}
|
||||
flowColor(s, NSColor(ctx.color))
|
||||
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))
|
||||
@@ -389,10 +447,37 @@ private struct FlowBuilder {
|
||||
}
|
||||
|
||||
private final class FlowTextView: NSTextView {
|
||||
var onTap: (@MainActor @Sendable () -> Void)?
|
||||
|
||||
override func resignFirstResponder() -> Bool {
|
||||
setSelectedRange(NSRange(location: 0, length: 0))
|
||||
return super.resignFirstResponder()
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
let onLink = link(at: convert(event.locationInWindow, from: nil))
|
||||
super.mouseDown(with: event)
|
||||
if let onTap, !onLink, selectedRange().length == 0 {
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
|
||||
private func link(at point: NSPoint) -> Bool {
|
||||
guard let lm = unsafe layoutManager, let tc = unsafe textContainer, let ts = unsafe textStorage, ts.length > 0 else {
|
||||
return false
|
||||
}
|
||||
let p = NSPoint(x: point.x - textContainerOrigin.x, y: point.y - textContainerOrigin.y)
|
||||
let g = lm.glyphIndex(for: p, in: tc)
|
||||
if !lm.boundingRect(forGlyphRange: NSRange(location: g, length: 1), in: tc).contains(p) {
|
||||
return false
|
||||
}
|
||||
let i = lm.characterIndexForGlyph(at: g)
|
||||
if i >= ts.length {
|
||||
return false
|
||||
}
|
||||
let value = unsafe ts.attribute(.link, at: i, effectiveRange: nil)
|
||||
return value != nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct FlowText: NSViewRepresentable {
|
||||
@@ -415,6 +500,7 @@ private struct FlowText: NSViewRepresentable {
|
||||
|
||||
func updateNSView(_ v: FlowTextView, context: Context) {
|
||||
context.coordinator.openURL = context.environment.openURL
|
||||
v.onTap = context.environment.choiceTap
|
||||
if unsafe v.textStorage!.isEqual(to: text) {
|
||||
return
|
||||
}
|
||||
@@ -480,25 +566,45 @@ struct InlineView: View {
|
||||
let inlines: [MDInline]
|
||||
let ctx: MDContext
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(\.choiceTap) private var choiceTap
|
||||
@Environment(\.centerImages) private var centerImages
|
||||
|
||||
var body: some View {
|
||||
let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId).segments(inlines)
|
||||
let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId, weight: ctx.weight).segments(inlines)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(Array(segs.enumerated()), id: \.offset) { _, seg in
|
||||
switch seg {
|
||||
case .text(let a):
|
||||
Text(a)
|
||||
.font(Fonts.font(ctx.size))
|
||||
.foregroundStyle(ctx.color)
|
||||
.lineSpacing(3)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if choiceTap != nil {
|
||||
FlowText(text: flowString(a))
|
||||
} else {
|
||||
Text(a)
|
||||
.font(Fonts.font(ctx.size))
|
||||
.foregroundStyle(ctx.color)
|
||||
.lineSpacing(3)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
case .image(let source, let src):
|
||||
ByreImage(source: source, src: src)
|
||||
if centerImages {
|
||||
ByreImage(source: source, src: src)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
ByreImage(source: source, src: src)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flowString(_ a: AttributedString) -> NSAttributedString {
|
||||
let s = NSMutableAttributedString(attributedString: try! NSAttributedString(a, including: \.appKit))
|
||||
flowColor(s, NSColor(ctx.color))
|
||||
let p = NSMutableParagraphStyle()
|
||||
p.lineSpacing = 3
|
||||
s.addAttribute(.paragraphStyle, value: p, range: NSRange(location: 0, length: s.length))
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
private struct BlockView: View {
|
||||
@@ -562,16 +668,17 @@ private struct ListBlock: View {
|
||||
|
||||
var body: some View {
|
||||
if grid, let choice = ctx.choice {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8, alignment: .top), count: 4), spacing: 8) {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8, alignment: .top), count: ctx.gridColumns), spacing: 8) {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
|
||||
choice(item)
|
||||
choice(item, true)
|
||||
}
|
||||
}
|
||||
.environment(\.centerImages, true)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { n, item in
|
||||
if item.choice != nil, let choice = ctx.choice {
|
||||
choice(item)
|
||||
choice(item, n == 0)
|
||||
} else {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text(verbatim: ordered ? "\(start + n)." : "•")
|
||||
@@ -606,21 +713,28 @@ struct ItemBody: View {
|
||||
private struct DiffBlock: View {
|
||||
let lines: [String]
|
||||
let size: CGFloat
|
||||
@ViewState private var width: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
Copyable(text: lines.map { $0 + "\n" }.joined()) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||||
Text(verbatim: line.isEmpty ? " " : line)
|
||||
.font(Fonts.font(size))
|
||||
.foregroundStyle(line.hasPrefix("@@") ? Palette.muted : Palette.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8)
|
||||
.background(background(line))
|
||||
ScrollView(.horizontal) {
|
||||
StretchColumn(minWidth: width) {
|
||||
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||||
Text(verbatim: line.isEmpty ? " " : line)
|
||||
.font(Fonts.font(size))
|
||||
.foregroundStyle(line.hasPrefix("@@") || line.hasPrefix("+++") || line.hasPrefix("---") ? Palette.muted : Palette.text)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize()
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(background(line))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 }
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
|
||||
}
|
||||
@@ -680,6 +794,14 @@ private struct TableBlock: View {
|
||||
let ctx: MDContext
|
||||
|
||||
var body: some View {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
grid.fixedSize(horizontal: true, vertical: false)
|
||||
grid
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var grid: some View {
|
||||
Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) {
|
||||
GridRow {
|
||||
ForEach(Array(head.enumerated()), id: \.offset) { _, cell in
|
||||
@@ -696,7 +818,7 @@ private struct TableBlock: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(Rectangle().stroke(Palette.border))
|
||||
.overlay(Rectangle().strokeBorder(Palette.border))
|
||||
}
|
||||
|
||||
private func tint(_ cell: [MDInline]) -> Color? {
|
||||
|
||||
Reference in New Issue
Block a user