2026-10-16 23:30:57 -07:00
|
|
|
import AppKit
|
2026-10-17 09:33:27 -07:00
|
|
|
import ByreAPI
|
2026-10-16 23:30:57 -07:00
|
|
|
import OpenAPIRuntime
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
typealias JSONDict = [String: (any Sendable)?]
|
|
|
|
|
typealias Review = Components.Schemas.Review
|
|
|
|
|
|
|
|
|
|
let taskPalette = [
|
|
|
|
|
Palette.task0, Palette.task1, Palette.task2, Palette.task3, Palette.task4, Palette.task5,
|
|
|
|
|
Palette.task6, Palette.task7, Palette.task8, Palette.task9, Palette.task10,
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
func str(_ d: JSONDict, _ key: String) -> String? {
|
|
|
|
|
d[key] as? String
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func int(_ d: JSONDict, _ key: String) -> Int? {
|
|
|
|
|
if let i = d[key] as? Int {
|
|
|
|
|
return i
|
|
|
|
|
}
|
|
|
|
|
return (d[key] as? Double).map { Int($0) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func bool(_ d: JSONDict, _ key: String) -> Bool {
|
|
|
|
|
d[key] as? Bool == true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func dict(_ d: JSONDict, _ key: String) -> JSONDict? {
|
|
|
|
|
d[key] as? JSONDict
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func jsString(_ v: (any Sendable)??) -> String {
|
|
|
|
|
switch v {
|
|
|
|
|
case .none, .some(.none):
|
|
|
|
|
return "undefined"
|
|
|
|
|
case .some(.some(let x)):
|
|
|
|
|
if let s = x as? String {
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
if let b = x as? Bool {
|
|
|
|
|
return b ? "true" : "false"
|
|
|
|
|
}
|
|
|
|
|
if let i = x as? Int {
|
|
|
|
|
return String(i)
|
|
|
|
|
}
|
|
|
|
|
if let d = x as? Double {
|
|
|
|
|
return d == d.rounded() && abs(d) < 1e15 ? String(Int(d)) : String(d)
|
|
|
|
|
}
|
|
|
|
|
return prettyJSON(x)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func prettyJSON(_ v: (any Sendable)?) -> String {
|
2026-10-17 10:54:06 -07:00
|
|
|
jsonText(v, indent: " ", depth: 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func compactJSON(_ v: (any Sendable)?) -> String {
|
|
|
|
|
jsonText(v, indent: nil, depth: 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func jsonText(_ v: (any Sendable)?, indent: String?, depth: Int) -> String {
|
|
|
|
|
guard let v else {
|
|
|
|
|
return "null"
|
|
|
|
|
}
|
|
|
|
|
if let s = v as? String {
|
|
|
|
|
return jsonQuote(s)
|
|
|
|
|
}
|
|
|
|
|
if let b = v as? Bool {
|
|
|
|
|
return b ? "true" : "false"
|
|
|
|
|
}
|
|
|
|
|
if let i = v as? Int {
|
|
|
|
|
return String(i)
|
|
|
|
|
}
|
|
|
|
|
if let d = v as? Double {
|
|
|
|
|
return d.isFinite ? jsString(d) : "null"
|
|
|
|
|
}
|
|
|
|
|
let items: [String]
|
|
|
|
|
let open: String
|
|
|
|
|
let close: String
|
|
|
|
|
if let list = v as? [(any Sendable)?] {
|
|
|
|
|
items = list.map { jsonText($0, indent: indent, depth: depth + 1) }
|
|
|
|
|
open = "["
|
|
|
|
|
close = "]"
|
|
|
|
|
} else if let d = v as? JSONDict {
|
|
|
|
|
let keys = d.keys.sorted { $0.utf8.lexicographicallyPrecedes($1.utf8) }
|
|
|
|
|
items = keys.map { "\(jsonQuote($0)):\(indent == nil ? "" : " ")\(jsonText(d[$0] ?? nil, indent: indent, depth: depth + 1))" }
|
|
|
|
|
open = "{"
|
|
|
|
|
close = "}"
|
|
|
|
|
} else {
|
|
|
|
|
return jsonQuote(String(describing: v))
|
|
|
|
|
}
|
|
|
|
|
if items.isEmpty {
|
|
|
|
|
return open + close
|
|
|
|
|
}
|
|
|
|
|
guard let indent else {
|
|
|
|
|
return open + items.joined(separator: ",") + close
|
|
|
|
|
}
|
|
|
|
|
let inner = "\n" + String(repeating: indent, count: depth + 1)
|
|
|
|
|
return open + inner + items.joined(separator: "," + inner) + "\n" + String(repeating: indent, count: depth) + close
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func jsonQuote(_ s: String) -> String {
|
|
|
|
|
var out = "\""
|
|
|
|
|
for u in s.unicodeScalars {
|
|
|
|
|
switch u {
|
|
|
|
|
case "\"":
|
|
|
|
|
out += "\\\""
|
|
|
|
|
case "\\":
|
|
|
|
|
out += "\\\\"
|
|
|
|
|
case "\u{08}":
|
|
|
|
|
out += "\\b"
|
|
|
|
|
case "\u{0C}":
|
|
|
|
|
out += "\\f"
|
|
|
|
|
case "\n":
|
|
|
|
|
out += "\\n"
|
|
|
|
|
case "\r":
|
|
|
|
|
out += "\\r"
|
|
|
|
|
case "\t":
|
|
|
|
|
out += "\\t"
|
|
|
|
|
default:
|
|
|
|
|
if u.value < 0x20 {
|
|
|
|
|
let hex = String(u.value, radix: 16)
|
|
|
|
|
out += "\\u" + String(repeating: "0", count: 4 - hex.count) + hex
|
|
|
|
|
} else {
|
|
|
|
|
out.unicodeScalars.append(u)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-16 23:30:57 -07:00
|
|
|
}
|
2026-10-17 10:54:06 -07:00
|
|
|
return out + "\""
|
2026-10-16 23:30:57 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ago(_ date: Date) -> String {
|
|
|
|
|
let seconds = Int((Date().timeIntervalSince(date)).rounded())
|
|
|
|
|
if seconds < 60 {
|
|
|
|
|
return "just now"
|
|
|
|
|
}
|
|
|
|
|
if seconds < 3600 {
|
|
|
|
|
return "\(seconds / 60)m ago"
|
|
|
|
|
}
|
|
|
|
|
if seconds < 86400 {
|
|
|
|
|
return "\(seconds / 3600)h ago"
|
|
|
|
|
}
|
|
|
|
|
return "\(seconds / 86400)d ago"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isoText(_ date: Date) -> String {
|
|
|
|
|
date.formatted(Date.ISO8601FormatStyle(includingFractionalSeconds: true))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 10:54:06 -07:00
|
|
|
func clockText(_ d: Date) -> String {
|
|
|
|
|
d.formatted(Date.VerbatimFormatStyle(format: "\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased)):\(minute: .twoDigits):\(second: .twoDigits)", timeZone: .current, calendar: .current))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 23:30:57 -07:00
|
|
|
func trimmed(_ s: String) -> String {
|
|
|
|
|
s.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension Store {
|
|
|
|
|
func taskColor(_ taskId: String) -> Color? {
|
|
|
|
|
findTask(taskId).map { taskPalette[$0.color] }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct PaneAccentKey: EnvironmentKey {
|
|
|
|
|
static let defaultValue = Palette.accent
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct InRightPaneKey: EnvironmentKey {
|
|
|
|
|
static let defaultValue = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension EnvironmentValues {
|
|
|
|
|
var paneAccent: Color {
|
|
|
|
|
get {
|
|
|
|
|
self[PaneAccentKey.self]
|
|
|
|
|
}
|
|
|
|
|
set {
|
|
|
|
|
self[PaneAccentKey.self] = newValue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var inRightPane: Bool {
|
|
|
|
|
get {
|
|
|
|
|
self[InRightPaneKey.self]
|
|
|
|
|
}
|
|
|
|
|
set {
|
|
|
|
|
self[InRightPaneKey.self] = newValue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Copyable<Content: View>: View {
|
|
|
|
|
let text: String
|
|
|
|
|
@ViewBuilder let content: Content
|
|
|
|
|
@ViewState private var hover = false
|
|
|
|
|
@ViewState private var copied = false
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
content
|
|
|
|
|
.overlay(alignment: .topTrailing) {
|
|
|
|
|
if hover {
|
|
|
|
|
Button {
|
|
|
|
|
NSPasteboard.general.clearContents()
|
|
|
|
|
NSPasteboard.general.setString(text, forType: .string)
|
|
|
|
|
copied = true
|
|
|
|
|
Task {
|
|
|
|
|
try? await Task.sleep(for: .milliseconds(1500))
|
|
|
|
|
copied = false
|
|
|
|
|
}
|
|
|
|
|
} label: {
|
|
|
|
|
Image(systemName: copied ? "checkmark" : "doc.on.doc")
|
|
|
|
|
.font(.system(size: 11))
|
|
|
|
|
.foregroundStyle(Palette.muted)
|
|
|
|
|
.padding(4)
|
|
|
|
|
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 4))
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
.help("Copy to the clipboard")
|
|
|
|
|
.padding(4)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.onHover { hover = $0 }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct PreBox: View {
|
|
|
|
|
let text: String
|
|
|
|
|
var maxHeight: CGFloat? = 320
|
|
|
|
|
var size: CGFloat = 12
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
Copyable(text: text) {
|
|
|
|
|
ScrollView {
|
|
|
|
|
Text(verbatim: text)
|
|
|
|
|
.font(Fonts.font(size))
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.padding(.horizontal, 8)
|
|
|
|
|
.padding(.vertical, 6)
|
|
|
|
|
}
|
|
|
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
|
|
|
.frame(maxHeight: maxHeight)
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
|
|
|
|
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Spinner: View {
|
|
|
|
|
var color: Color
|
|
|
|
|
private static let frames = Array("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
TimelineView(.periodic(from: .now, by: 0.08)) { context in
|
|
|
|
|
let i = Int(context.date.timeIntervalSinceReferenceDate / 0.08) % Self.frames.count
|
|
|
|
|
Text(String(Self.frames[i]))
|
|
|
|
|
.foregroundStyle(color)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Badge: View {
|
|
|
|
|
let count: Int
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
Text("\(count)")
|
|
|
|
|
.font(Fonts.font(11, weight: 700))
|
|
|
|
|
.foregroundStyle(Palette.danger)
|
|
|
|
|
.padding(.horizontal, 7)
|
|
|
|
|
.background(Palette.dangerTint, in: Capsule())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Muted: View {
|
|
|
|
|
let text: String
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
Text(text)
|
|
|
|
|
.foregroundStyle(Palette.muted)
|
|
|
|
|
.padding(32)
|
|
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
|
.multilineTextAlignment(.center)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct WebButtonStyle: ButtonStyle {
|
|
|
|
|
var primary = false
|
|
|
|
|
var danger = false
|
|
|
|
|
|
|
|
|
|
func makeBody(configuration: ButtonStyleConfiguration) -> some View {
|
|
|
|
|
WebButton(configuration: configuration, primary: primary, danger: danger)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct WebButton: View {
|
|
|
|
|
let configuration: ButtonStyleConfiguration
|
|
|
|
|
let primary: Bool
|
|
|
|
|
let danger: Bool
|
|
|
|
|
@Environment(\.isEnabled) private var enabled
|
|
|
|
|
@ViewState private var hover = false
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
2026-10-17 07:26:18 -07:00
|
|
|
let fill = danger ? Palette.danger : Palette.accent
|
2026-10-16 23:30:57 -07:00
|
|
|
configuration.label
|
|
|
|
|
.font(Fonts.font(13, weight: primary ? 700 : 400))
|
|
|
|
|
.foregroundStyle(primary ? Palette.bg : danger ? Palette.danger : Palette.text)
|
|
|
|
|
.padding(.horizontal, 12)
|
|
|
|
|
.padding(.vertical, 4)
|
2026-10-17 07:26:18 -07:00
|
|
|
.background(primary ? fill : Palette.raised, in: RoundedRectangle(cornerRadius: 6))
|
|
|
|
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(primary ? fill : hover && enabled ? Palette.muted : Palette.border))
|
2026-10-16 23:30:57 -07:00
|
|
|
.opacity(enabled ? (configuration.isPressed ? 0.8 : 1) : 0.45)
|
|
|
|
|
.contentShape(Rectangle())
|
|
|
|
|
.onHover { hover = $0 }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct SmallLinkButtonStyle: ButtonStyle {
|
|
|
|
|
var color = Palette.accent
|
|
|
|
|
@Environment(\.isEnabled) private var enabled
|
|
|
|
|
|
|
|
|
|
func makeBody(configuration: ButtonStyleConfiguration) -> some View {
|
|
|
|
|
configuration.label
|
|
|
|
|
.font(Fonts.font(11))
|
|
|
|
|
.foregroundStyle(color)
|
|
|
|
|
.padding(.horizontal, 6)
|
|
|
|
|
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Palette.border))
|
|
|
|
|
.opacity(enabled ? 1 : 0.45)
|
|
|
|
|
.contentShape(Rectangle())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct FieldStyle: ViewModifier {
|
|
|
|
|
var accent: Color? = nil
|
|
|
|
|
var focused = false
|
|
|
|
|
|
|
|
|
|
func body(content: Content) -> some View {
|
|
|
|
|
content
|
|
|
|
|
.textFieldStyle(.plain)
|
|
|
|
|
.padding(.horizontal, 10)
|
|
|
|
|
.padding(.vertical, 6)
|
|
|
|
|
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
|
|
|
|
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(focused ? (accent ?? Palette.accent) : (accent ?? Palette.border)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 07:26:18 -07:00
|
|
|
struct HeaderBar: ViewModifier {
|
|
|
|
|
func body(content: Content) -> some View {
|
|
|
|
|
content
|
|
|
|
|
.padding(.horizontal, 16)
|
|
|
|
|
.padding(.vertical, 8)
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.overlay(alignment: .bottom) {
|
|
|
|
|
Rectangle().fill(Palette.border).frame(height: 1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct RangeTabButton: View {
|
|
|
|
|
let label: String
|
|
|
|
|
let active: Bool
|
|
|
|
|
var help = ""
|
|
|
|
|
let action: () -> Void
|
|
|
|
|
@ViewState private var hover = false
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
Button(action: action) {
|
|
|
|
|
Text(label)
|
|
|
|
|
.font(Fonts.font(12))
|
|
|
|
|
.foregroundStyle(active || hover ? Palette.text : Palette.muted)
|
|
|
|
|
.padding(.horizontal, 6)
|
|
|
|
|
.padding(.top, 2)
|
|
|
|
|
.padding(.bottom, 3)
|
|
|
|
|
.overlay(alignment: .bottom) {
|
|
|
|
|
Rectangle().fill(active ? Palette.accent : .clear).frame(height: 2)
|
|
|
|
|
}
|
|
|
|
|
.contentShape(Rectangle())
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
.help(help)
|
|
|
|
|
.onHover { hover = $0 }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct EmptyText: View {
|
|
|
|
|
let text: String
|
|
|
|
|
var error = false
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack {
|
|
|
|
|
Text(text)
|
|
|
|
|
.foregroundStyle(error ? Palette.danger : Palette.muted)
|
|
|
|
|
.multilineTextAlignment(.center)
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
.padding(32)
|
|
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct StateDot: View {
|
|
|
|
|
let filled: Color?
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
Group {
|
|
|
|
|
if let filled {
|
|
|
|
|
Circle().fill(filled)
|
|
|
|
|
} else {
|
|
|
|
|
Circle().strokeBorder(Palette.muted, lineWidth: 1.5)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.frame(width: 8, height: 8)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct StateMark: View {
|
|
|
|
|
let state: TaskState
|
|
|
|
|
let color: Color
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
Group {
|
|
|
|
|
switch state.kind {
|
|
|
|
|
case .working, .merging:
|
|
|
|
|
Spinner(color: color)
|
|
|
|
|
case .answer:
|
|
|
|
|
StateDot(filled: Palette.warn)
|
|
|
|
|
case .error:
|
|
|
|
|
StateDot(filled: Palette.danger)
|
|
|
|
|
default:
|
|
|
|
|
StateDot(filled: nil)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.help(state.title)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum PatchLine {
|
|
|
|
|
case hunk, add, del, meta, ctx
|
|
|
|
|
|
|
|
|
|
init(_ l: String) {
|
|
|
|
|
self = l.hasPrefix("@@") ? .hunk : l.hasPrefix("+") ? .add : l.hasPrefix("-") ? .del : l.hasPrefix("\\") ? .meta : .ctx
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var background: Color {
|
|
|
|
|
switch self {
|
|
|
|
|
case .hunk: Palette.surface
|
|
|
|
|
case .add: Palette.ok.opacity(0.14)
|
|
|
|
|
case .del: Palette.danger.opacity(0.14)
|
|
|
|
|
default: .clear
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var color: Color {
|
|
|
|
|
self == .hunk || self == .meta ? Palette.muted : Palette.text
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct StretchColumn: Layout {
|
|
|
|
|
var minWidth: CGFloat = 0
|
|
|
|
|
|
|
|
|
|
private func width(_ proposal: ProposedViewSize, _ subviews: Subviews) -> CGFloat {
|
|
|
|
|
max(minWidth, proposal.width ?? 0, subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
|
|
|
let w = width(ProposedViewSize(width: nil, height: nil), subviews)
|
|
|
|
|
let h = subviews.reduce(0) { $0 + $1.sizeThatFits(ProposedViewSize(width: w, height: nil)).height }
|
|
|
|
|
return CGSize(width: w, height: h)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
|
|
|
var y = bounds.minY
|
|
|
|
|
for sub in subviews {
|
|
|
|
|
let h = sub.sizeThatFits(ProposedViewSize(width: bounds.width, height: nil)).height
|
|
|
|
|
sub.place(at: CGPoint(x: bounds.minX, y: y), proposal: ProposedViewSize(width: bounds.width, height: h))
|
|
|
|
|
y += h
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 23:30:57 -07:00
|
|
|
struct TrailingBubble: Layout {
|
|
|
|
|
var fraction: CGFloat = 0.85
|
|
|
|
|
|
|
|
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
|
|
|
let width = proposal.width ?? 600
|
|
|
|
|
let size = subviews[0].sizeThatFits(ProposedViewSize(width: width * fraction, height: nil))
|
|
|
|
|
return CGSize(width: width, height: size.height)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
|
|
|
let size = subviews[0].sizeThatFits(ProposedViewSize(width: bounds.width * fraction, height: nil))
|
|
|
|
|
subviews[0].place(at: CGPoint(x: bounds.maxX - size.width, y: bounds.minY), proposal: ProposedViewSize(size))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct FitWidth: Layout {
|
|
|
|
|
let natural: CGSize
|
|
|
|
|
|
|
|
|
|
private func scaled(_ width: CGFloat?) -> CGSize {
|
|
|
|
|
let w = width ?? natural.width
|
|
|
|
|
let scale = natural.width > 0 ? min(1, w / natural.width) : 1
|
|
|
|
|
return CGSize(width: natural.width * scale, height: natural.height * scale)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
|
|
|
scaled(proposal.width)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
|
|
|
let size = scaled(bounds.width)
|
|
|
|
|
subviews[0].place(at: bounds.origin, proposal: ProposedViewSize(size))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct FlowLayout: Layout {
|
|
|
|
|
var spacing: CGFloat = 6
|
|
|
|
|
|
|
|
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
|
|
|
let rows = arrange(proposal.width ?? .infinity, subviews)
|
|
|
|
|
let height = rows.last.map { $0.y + $0.height } ?? 0
|
|
|
|
|
let width = rows.map(\.width).max() ?? 0
|
|
|
|
|
return CGSize(width: proposal.width ?? width, height: height)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
|
|
|
for row in arrange(bounds.width, subviews) {
|
|
|
|
|
for (i, x) in zip(row.items, row.xs) {
|
|
|
|
|
subviews[i].place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + row.y), proposal: .unspecified)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct Row {
|
|
|
|
|
var items: [Int] = []
|
|
|
|
|
var xs: [CGFloat] = []
|
|
|
|
|
var y: CGFloat = 0
|
|
|
|
|
var width: CGFloat = 0
|
|
|
|
|
var height: CGFloat = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func arrange(_ maxWidth: CGFloat, _ subviews: Subviews) -> [Row] {
|
|
|
|
|
var rows: [Row] = []
|
|
|
|
|
var row = Row()
|
|
|
|
|
var y: CGFloat = 0
|
|
|
|
|
for (i, sub) in subviews.enumerated() {
|
|
|
|
|
let size = sub.sizeThatFits(.unspecified)
|
|
|
|
|
if !row.items.isEmpty && row.width + spacing + size.width > maxWidth {
|
|
|
|
|
rows.append(row)
|
|
|
|
|
y += row.height + spacing
|
|
|
|
|
row = Row(y: y)
|
|
|
|
|
}
|
|
|
|
|
let x = row.items.isEmpty ? 0 : row.width + spacing
|
|
|
|
|
row.items.append(i)
|
|
|
|
|
row.xs.append(x)
|
|
|
|
|
row.width = x + size.width
|
|
|
|
|
row.height = max(row.height, size.height)
|
|
|
|
|
}
|
|
|
|
|
if !row.items.isEmpty {
|
|
|
|
|
rows.append(row)
|
|
|
|
|
}
|
|
|
|
|
return rows
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-17 10:54:06 -07:00
|
|
|
|
|
|
|
|
let overlayShadow = Color(nsColor: NSColor(name: nil) { appearance in
|
|
|
|
|
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.5) : NSColor(srgbRed: 16 / 255, green: 24 / 255, blue: 40 / 255, alpha: 0.18)
|
|
|
|
|
})
|