From a5384889da83eea9393fbe22f7bbb9c836dfd584 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 16:29:12 +0200 Subject: [PATCH 1/2] feat(GenesisKit): Claude, Codex and Grok show their marks instead of letters everywhere, drawn by one AIProviderMark behind AIProviderGlyph, ProviderBadge and the transcript avatar, with SVG arcs in the path parser --- .../GenesisKit/Controls/ForgeBadge.swift | 93 +++++++++++++++++- .../GenesisKit/Controls/ProviderBadge.swift | 9 +- .../GenesisKit/Providers/AIProviderMark.swift | 97 +++++++++++++++++++ .../GenesisKit/Providers/AIProviderMeta.swift | 15 ++- .../Transcript/SessionTranscriptList.swift | 22 +++-- .../GenesisKitTests/ForgeBadgeTests.swift | 49 +++++++++- 6 files changed, 259 insertions(+), 26 deletions(-) create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMark.swift diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/ForgeBadge.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/ForgeBadge.swift index 5cd3544c3b..7f183692f7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Controls/ForgeBadge.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/ForgeBadge.swift @@ -96,8 +96,9 @@ public struct ForgeBadge: View { } } -/// The subset of SVG path data the forge marks use: M, L, H, V, C, S and Z, absolute and relative, with -/// implicit repeats and the compact number forms (`.4.07`, `-.17`). Anything else ends the path there. +/// The subset of SVG path data the forge and provider marks use: M, L, H, V, C, S, A and Z, absolute and relative, +/// with implicit repeats and the compact number forms (`.4.07`, `-.17`, arc flags packed as `01-.104`). Anything else +/// ends the path there. enum SVGPath { static func parse(_ data: String) -> Path { var path = Path() @@ -159,6 +160,13 @@ enum SVGPath { path.addCurve(to: target, control1: first, control2: second) current = target lastControl = second + case "A": + guard let rx = tokens.number(), let ry = tokens.number(), let rotation = tokens.number(), + let large = tokens.flag(), let sweep = tokens.flag(), let target = point() else { return path } + addArc(to: &path, from: current, radii: CGSize(width: rx, height: ry), rotation: rotation, + large: large, sweep: sweep, end: target) + current = target + lastControl = nil default: return path } @@ -166,9 +174,68 @@ enum SVGPath { return path } + /// An elliptical arc as cubic curves, at most a quarter turn each (SVG 1.1 implementation notes F.6.5 and F.6.6). + static func addArc( + to path: inout Path, from start: CGPoint, radii: CGSize, rotation: CGFloat, large: Bool, sweep: Bool, + end: CGPoint + ) { + var rx = abs(radii.width) + var ry = abs(radii.height) + guard rx > 0, ry > 0, start != end else { + path.addLine(to: end) + return + } + let phi = rotation * .pi / 180 + let cosPhi = cos(phi), sinPhi = sin(phi) + let dx = (start.x - end.x) / 2, dy = (start.y - end.y) / 2 + let x1 = cosPhi * dx + sinPhi * dy + let y1 = -sinPhi * dx + cosPhi * dy + // Radii too small to reach the end point grow until they just do. + let lambda = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry) + if lambda > 1 { + rx *= sqrt(lambda) + ry *= sqrt(lambda) + } + let numerator = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1 + let denominator = rx * rx * y1 * y1 + ry * ry * x1 * x1 + var factor = sqrt(max(0, numerator / denominator)) + if large == sweep { factor = -factor } + let cx1 = factor * rx * y1 / ry + let cy1 = -factor * ry * x1 / rx + let center = CGPoint(x: cosPhi * cx1 - sinPhi * cy1 + (start.x + end.x) / 2, + y: sinPhi * cx1 + cosPhi * cy1 + (start.y + end.y) / 2) + func angle(_ ux: CGFloat, _ uy: CGFloat, _ vx: CGFloat, _ vy: CGFloat) -> CGFloat { + let value = atan2(ux * vy - uy * vx, ux * vx + uy * vy) + return value + } + let theta = angle(1, 0, (x1 - cx1) / rx, (y1 - cy1) / ry) + var delta = angle((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry) + if !sweep, delta > 0 { delta -= 2 * .pi } + if sweep, delta < 0 { delta += 2 * .pi } + let segments = max(1, Int(ceil(abs(delta) / (.pi / 2)))) + let step = delta / CGFloat(segments) + let k = 4 / 3 * tan(step / 4) + func onEllipse(_ t: CGFloat) -> CGPoint { + CGPoint(x: center.x + rx * cos(t) * cosPhi - ry * sin(t) * sinPhi, + y: center.y + rx * cos(t) * sinPhi + ry * sin(t) * cosPhi) + } + func tangent(_ t: CGFloat) -> CGPoint { + CGPoint(x: -rx * sin(t) * cosPhi - ry * cos(t) * sinPhi, y: -rx * sin(t) * sinPhi + ry * cos(t) * cosPhi) + } + var t = theta + for index in 0 ..< segments { + let next = t + step + let from = onEllipse(t), to = index == segments - 1 ? end : onEllipse(next) + let d1 = tangent(t), d2 = tangent(next) + path.addCurve(to: to, control1: CGPoint(x: from.x + k * d1.x, y: from.y + k * d1.y), + control2: CGPoint(x: to.x - k * d2.x, y: to.y - k * d2.y)) + t = next + } + } + private enum Token: Equatable { case command(Character) - case number(CGFloat) + case number(CGFloat, raw: String) } private struct Tokens { @@ -178,7 +245,7 @@ enum SVGPath { init(_ text: String) { var number = "" func flush() { - if let value = Double(number) { items.append(.number(CGFloat(value))) } + if let value = Double(number) { items.append(.number(CGFloat(value), raw: number)) } number = "" } for char in text { @@ -206,9 +273,25 @@ enum SVGPath { mutating func advance() { index += 1 } mutating func number() -> CGFloat? { - guard case .number(let value) = peek() else { return nil } + guard case .number(let value, _) = peek() else { return nil } index += 1 return value } + + /// An arc flag: one digit, which may share its token with what follows (`01-.104` is flags 0 and 1). + mutating func flag() -> Bool? { + guard case .number(_, let raw) = peek(), let first = raw.first, first == "0" || first == "1" else { + return nil + } + let rest = String(raw.dropFirst()) + if rest.isEmpty { + index += 1 + } else if let value = Double(rest) { + items[index] = .number(CGFloat(value), raw: rest) + } else { + return nil + } + return first == "1" + } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/ProviderBadge.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/ProviderBadge.swift index 9e4c3cf14e..28c7b06f8b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Controls/ProviderBadge.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/ProviderBadge.swift @@ -1,6 +1,7 @@ import SwiftUI -/// An agent's initial in its colour: C (Claude), X (Codex), G (Grok), else the first letter. +/// An agent's mark on a square in its colour (`AIProviderMark`): Claude, Codex, Grok; any other agent keeps its first +/// letter. public struct ProviderBadge: View { let provider: String var size: CGFloat @@ -23,11 +24,7 @@ public struct ProviderBadge: View { public var body: some View { let style = Self.style(for: provider) - Text(verbatim: style.letter) - .font(.system(size: (size * 0.56).rounded(), weight: .bold, design: .rounded)) - .foregroundColor(.black.opacity(0.8)) - .frame(width: size, height: size) - .background(RoundedRectangle(cornerRadius: size * 0.28).fill(style.color)) + AIProviderGlyphBody(provider: provider, letter: style.letter, color: style.color, size: size, style: .filled) .instantTooltip(tooltip ?? provider) .accessibilityLabel(Text(tooltip ?? provider)) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMark.swift b/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMark.swift new file mode 100644 index 0000000000..ce6054df4d --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMark.swift @@ -0,0 +1,97 @@ +import SwiftUI + +/// A coding agent's logo as a shape: Claude's starburst, Codex's terminal cloud, Grok's slashed ring. Every surface +/// that names an agent by its mark draws it through this (`AIProviderGlyph`, `ProviderBadge`), so a change lands +/// everywhere at once. Paths from LobeHub's icon set (MIT, `@lobehub/icons-static-svg`), 24 × 24, even-odd fill. +public struct AIProviderMark: Shape { + public let kind: Kind + + public enum Kind: String, CaseIterable, Sendable { + case claude, codex, grok + + /// The mark for a provider id or alias (`claude`, `anthropic-sub`, `codex`, `openai-sub`, `grok`, `grok-sub`); + /// nil for one with no mark, which keeps its letter. + public init?(provider: String) { + switch provider.lowercased() { + case "claude", "anthropic", "anthropic-sub": self = .claude + case "codex", "openai", "openai-sub": self = .codex + case "grok", "xai", "grok-sub": self = .grok + default: return nil + } + } + + /// The brand colour of the mark itself (Claude's terracotta; Codex and Grok are drawn in white). + public var brandColor: Color { + switch self { + case .claude: return Color(red: 0.851, green: 0.467, blue: 0.341) + case .codex, .grok: return Color.white.opacity(0.92) + } + } + } + + public init(_ kind: Kind) { + self.kind = kind + } + + public func path(in rect: CGRect) -> Path { + let unit = Self.paths[kind] ?? Path() + let scale = min(rect.width, rect.height) / 24 + let dx = rect.minX + (rect.width - 24 * scale) / 2 + let dy = rect.minY + (rect.height - 24 * scale) / 2 + return unit.applying(CGAffineTransform(a: scale, b: 0, c: 0, d: scale, tx: dx, ty: dy)) + } + + /// Parsed once: these paths are drawn in every session row. + static let paths: [Kind: Path] = [ + .claude: SVGPath.parse( + "M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"), + .codex: SVGPath.parse( + "M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"), + .grok: SVGPath.parse( + "M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"), + ] +} + +/// How a provider glyph is drawn. +public enum AIProviderGlyphStyle: Sendable { + /// The mark in its provider colour on a tinted rounded square: row leaders, rails. + case tinted + /// The mark alone, in its provider colour: inline in text and tight spaces. + case plain + /// The mark in dark on a solid provider-coloured square (`ProviderBadge`). + case filled +} + +/// One provider glyph in any style; a provider with no mark keeps its letter. +struct AIProviderGlyphBody: View { + let provider: String + let letter: String + let color: Color + let size: CGFloat + let style: AIProviderGlyphStyle + + var body: some View { + let kind = AIProviderMark.Kind(provider: provider) + let inset = style == .plain ? 0 : size * 0.18 + ZStack { + switch style { + case .tinted: + RoundedRectangle(cornerRadius: size / 4).fill(color.opacity(0.18)) + case .filled: + RoundedRectangle(cornerRadius: size * 0.28).fill(color) + case .plain: + EmptyView() + } + if let kind { + AIProviderMark(kind) + .fill(style == .filled ? Color.black.opacity(0.8) : color, style: FillStyle(eoFill: true)) + .padding(inset) + } else { + Text(verbatim: letter) + .font(.system(size: size * 0.6, weight: .bold, design: .rounded)) + .foregroundStyle(style == .filled ? Color.black.opacity(0.8) : color) + } + } + .frame(width: size, height: size) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMeta.swift b/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMeta.swift index f76b8b309b..641e8ea70b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMeta.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMeta.swift @@ -51,23 +51,22 @@ public enum AIProviders { } } -/// The provider's one-letter glyph in its tinted box: the leader of an account row, and -/// the marker on a session row that is not Claude's. +/// The provider's mark (`AIProviderMark`) in its tinted box: the leader of an account row, and the marker on a session +/// row. A provider with no mark keeps its letter. `style` picks the tinted box, the bare mark or a solid badge. public struct AIProviderGlyph: View { public var meta: AIProviderMeta public var size: CGFloat + public var style: AIProviderGlyphStyle - public init(meta: AIProviderMeta, size: CGFloat = 16) { + public init(meta: AIProviderMeta, size: CGFloat = 16, style: AIProviderGlyphStyle = .tinted) { self.meta = meta self.size = size + self.style = style } public var body: some View { - Text(meta.glyph) - .font(.system(size: size * 0.625, weight: .bold, design: .monospaced)) - .frame(width: size, height: size) - .background(meta.color.opacity(0.18), in: RoundedRectangle(cornerRadius: size / 4)) - .foregroundStyle(meta.color) + AIProviderGlyphBody(provider: meta.alias, letter: meta.glyph, color: meta.color, size: size, style: style) + .accessibilityElement() .accessibilityLabel(meta.displayName) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Sessions/Transcript/SessionTranscriptList.swift b/src/macos/GenesisKit/Sources/GenesisKit/Sessions/Transcript/SessionTranscriptList.swift index 06b2b9ce0d..7be437f335 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Sessions/Transcript/SessionTranscriptList.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Sessions/Transcript/SessionTranscriptList.swift @@ -1241,13 +1241,23 @@ private struct Avatar: View { let glyph: String let color: Color var solid = false + /// An agent's id or alias: its mark (`AIProviderMark`) replaces the letter. + var provider: String? var body: some View { - Text(verbatim: glyph) - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(solid ? Color.black.opacity(0.85) : color) - .frame(width: 22, height: 22) - .background(Circle().fill(solid ? color : color.opacity(0.18))) + Group { + if let kind = provider.flatMap(AIProviderMark.Kind.init(provider:)) { + AIProviderMark(kind) + .fill(solid ? Color.black.opacity(0.85) : color, style: FillStyle(eoFill: true)) + .padding(5) + } else { + Text(verbatim: glyph) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(solid ? Color.black.opacity(0.85) : color) + } + } + .frame(width: 22, height: 22) + .background(Circle().fill(solid ? color : color.opacity(0.18))) } } @@ -1467,7 +1477,7 @@ private struct ReplyBlock: View { private var authorRow: some View { HStack(spacing: 8) { - Avatar(glyph: provider.glyph, color: provider.color) + Avatar(glyph: provider.glyph, color: provider.color, provider: provider.alias) Text(verbatim: provider.displayName) .font(.system(size: 12.5, weight: .semibold)) .foregroundStyle(SessionPalette.text) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ForgeBadgeTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ForgeBadgeTests.swift index e54ea1ecae..8f346c22d6 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/ForgeBadgeTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ForgeBadgeTests.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import XCTest @testable import GenesisKitBase @@ -20,7 +21,7 @@ final class ForgeBadgeTests: XCTestCase { } func testAnUnknownCommandEndsThePathInsteadOfMisdrawing() { - let path = SVGPath.parse("M0 0L4 4A1 1 0 0 0 9 9") + let path = SVGPath.parse("M0 0L4 4Q1 1 9 9") XCTAssertEqual(path.boundingRect, CGRect(x: 0, y: 0, width: 4, height: 4)) } @@ -31,4 +32,50 @@ final class ForgeBadgeTests: XCTestCase { XCTAssertNil(Forge(kind: "bitbucket")) XCTAssertNil(Forge(kind: nil)) } + + func testProviderMarksParseToTheEndAndFillTheirBox() { + for kind in AIProviderMark.Kind.allCases { + let box = (AIProviderMark.paths[kind] ?? Path()).boundingRect + XCTAssertGreaterThan(box.width, 20, "\(kind) spans its 24-unit box (a path cut short by an arc stays small)") + XCTAssertGreaterThan(box.height, 20, "\(kind)") + XCTAssertLessThanOrEqual(box.maxX, 24.5, "\(kind)") + XCTAssertGreaterThanOrEqual(box.minY, -0.5, "\(kind)") + } + XCTAssertEqual(AIProviderMark.Kind(provider: "anthropic-sub"), .claude) + XCTAssertEqual(AIProviderMark.Kind(provider: "openai-sub"), .codex) + XCTAssertEqual(AIProviderMark.Kind(provider: "Grok"), .grok) + XCTAssertNil(AIProviderMark.Kind(provider: "gemini"), "an agent with no mark keeps its letter") + } + + func testAnArcWithPackedFlagsReachesItsEndPoint() { + // `a1 1 0 012 0` is flags 0 and 1, then the point (2, 0): a half circle from (0, 0) to (2, 0). + let path = SVGPath.parse("M0 0a1 1 0 012 0") + XCTAssertEqual(path.currentPoint?.x ?? 0, 2, accuracy: 0.001) + XCTAssertEqual(path.boundingRect.height, 1, accuracy: 0.05, "the half circle bulges by its radius") + } + + /// Renders every provider glyph style to a PNG, for the eye: PROVIDER_MARKS_RENDER=. + @MainActor + func testRenderProviderMarks() throws { + guard let out = ProcessInfo.processInfo.environment["PROVIDER_MARKS_RENDER"] else { + throw XCTSkip("set PROVIDER_MARKS_RENDER") + } + let view = VStack(alignment: .leading, spacing: 10) { + ForEach(["claude", "codex", "grok", "gemini"], id: \.self) { provider in + HStack(spacing: 12) { + AIProviderGlyph(meta: AIProviders.meta(for: provider), size: 32) + AIProviderGlyph(meta: AIProviders.meta(for: provider), size: 18) + AIProviderGlyph(meta: AIProviders.meta(for: provider), size: 11) + AIProviderGlyph(meta: AIProviders.meta(for: provider), size: 24, style: .plain) + ProviderBadge(provider: provider, size: 24) + Text(provider).foregroundStyle(.white) + } + } + }.padding(16).background(Color(white: 0.1)) + let renderer = ImageRenderer(content: view) + renderer.scale = 2 + let image = try XCTUnwrap(renderer.nsImage) + let rep = try XCTUnwrap(NSBitmapImageRep(data: try XCTUnwrap(image.tiffRepresentation))) + try XCTUnwrap(rep.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: out)) + } } From 5487de789b99896cad85d61f653d20bd3d6ff28c Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 26 Oct 2026 00:33:00 +0100 Subject: [PATCH 2/2] test(GenesisKit, domains): one accented sample folder name across the tree, search and git tests, and a plain-word IDN sample --- src/domains/domains.test.ts | 6 +++--- .../Sources/GenesisKit/Files/FileTree.swift | 2 +- .../Sources/GenesisKit/Files/NativeString.swift | 2 +- .../Tests/GenesisKitTests/Diff/GitReposTests.swift | 14 +++++++------- .../Markdown/FileTreeSearchTests.swift | 8 ++++---- .../Markdown/MarkdownTreeLagBenchTests.swift | 2 +- .../Markdown/MarkdownTreeTableTests.swift | 6 +++--- .../Markdown/MarkdownViewerSpeedTests.swift | 2 +- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/domains/domains.test.ts b/src/domains/domains.test.ts index c7a462ecda..3d6f4810ff 100644 --- a/src/domains/domains.test.ts +++ b/src/domains/domains.test.ts @@ -35,9 +35,9 @@ describe("parseDomain", () => { }); it("converts an IDN to punycode and keeps the unicode form", () => { - const parsed = parseDomain("čez.cz"); - expect(parsed?.domain).toBe("xn--ez-dma.cz"); - expect(parsed?.unicode).toBe("čez.cz"); + const parsed = parseDomain("příklad.cz"); + expect(parsed?.domain).toBe("xn--pklad-zsa96e.cz"); + expect(parsed?.unicode).toBe("příklad.cz"); }); it("rejects bare words and invalid labels", () => { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Files/FileTree.swift b/src/macos/GenesisKit/Sources/GenesisKit/Files/FileTree.swift index 5542d3d54f..11f2c24d20 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Files/FileTree.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Files/FileTree.swift @@ -51,7 +51,7 @@ public struct FileTreeRow: Identifiable, Equatable, Sendable { } /// A path as a list identity, hashed once. SwiftUI hashes and compares a lazy list's ids on every scroll frame, and a -/// non-ASCII `String` ("ČEZ") is normalized to NFC on each hash and each comparison: the profile of a tree scroll in +/// non-ASCII `String` ("Účty") is normalized to NFC on each hash and each comparison: the profile of a tree scroll in /// the installed viewer had that normalization as its largest piece of app-side work (2026-10-15). Equal keys have /// equal bytes. A listing path is already one form (`standardizedFileURL.path`, then `nativeUTF8`). A selected /// document path can be the other Unicode form of that file, so `init(selection:)` applies the listing's form first. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Files/NativeString.swift b/src/macos/GenesisKit/Sources/GenesisKit/Files/NativeString.swift index f051a6d280..4a91a14756 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Files/NativeString.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Files/NativeString.swift @@ -10,7 +10,7 @@ import Foundation /// Native string storage for paths shared between filesystem listings and the viewer. extension String { /// The same text in Swift's own contiguous UTF-8 storage. A path from `URL.path` or a Foundation listing is a - /// bridged `NSString` when it holds a non-ASCII letter (a vault folder named "ČEZ"); comparing, hashing or + /// bridged `NSString` when it holds a non-ASCII letter (a vault folder named "Účty"); comparing, hashing or /// searching one walks it through CoreFoundation a character at a time. Measured 2026-10-15 on 636 vault paths: /// finding one 0.42 ms bridged, 0.02 ms native; building a `Set` 1.42 ms against 0.36 ms. var nativeUTF8: String { diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitReposTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitReposTests.swift index d7f429adf0..2240411106 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitReposTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitReposTests.swift @@ -176,17 +176,17 @@ final class GitReposTests: XCTestCase { func testTheTreeMarksWalkFoldersWithAccentedNames() { let root = URL(fileURLWithPath: "/tmp/gk-accents-repo") let status = GitRepoStatus(root: root, branch: "main", upstream: nil, ahead: 0, behind: 0, entries: [ - GitStatusEntry(path: "ČEZ/úkoly/žádost/a.md", unstaged: .modified), - GitStatusEntry(path: "ČEZ/úkoly/b.md", unstaged: .modified), - GitStatusEntry(path: "ČEZ/c.md", unstaged: .untracked), + GitStatusEntry(path: "Účty/úkoly/žádost/a.md", unstaged: .modified), + GitStatusEntry(path: "Účty/úkoly/b.md", unstaged: .modified), + GitStatusEntry(path: "Účty/c.md", unstaged: .untracked), GitStatusEntry(path: "jinde/d.md", unstaged: .modified), ]) let numstat = [ - "ČEZ/úkoly/žádost/a.md": GitLineCount(additions: 2, deletions: 1), - "ČEZ/úkoly/b.md": GitLineCount(additions: 1, deletions: 3), + "Účty/úkoly/žádost/a.md": GitLineCount(additions: 2, deletions: 1), + "Účty/úkoly/b.md": GitLineCount(additions: 1, deletions: 3), ] - let marks = status.decorations(under: root.appendingPathComponent("ČEZ"), numstat: numstat) - let tree = "/tmp/gk-accents-repo/ČEZ" + let marks = status.decorations(under: root.appendingPathComponent("Účty"), numstat: numstat) + let tree = "/tmp/gk-accents-repo/Účty" XCTAssertEqual(Set(marks.files.keys), ["\(tree)/úkoly/žádost/a.md", "\(tree)/úkoly/b.md", "\(tree)/c.md"]) XCTAssertEqual(marks.folders, ["\(tree)/úkoly/žádost", "\(tree)/úkoly"], "the tree's root is not marked") XCTAssertEqual(marks.count(for: "\(tree)/úkoly/žádost"), GitLineCount(additions: 2, deletions: 1)) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/FileTreeSearchTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/FileTreeSearchTests.swift index 5a54888e29..6cb5a16a7c 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/FileTreeSearchTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/FileTreeSearchTests.swift @@ -68,9 +68,9 @@ final class FileTreeSearchTests: XCTestCase { func testRipgrepLinesParseEvenWhenThePathHasAColon() { let root = "/Users/alice/vault" - let hit = FileTreeSearch.parseRipgrepLine("/Users/alice/vault/ČEZ/Bugs/12:30 meeting.md:17: See [[#Not the auth cluster]].", root: root) - XCTAssertEqual(hit?.relative, "ČEZ/Bugs/12:30 meeting.md") - XCTAssertEqual(hit?.path, "/Users/alice/vault/ČEZ/Bugs/12:30 meeting.md") + let hit = FileTreeSearch.parseRipgrepLine("/Users/alice/vault/Účty/Bugs/12:30 meeting.md:17: See [[#Not the auth cluster]].", root: root) + XCTAssertEqual(hit?.relative, "Účty/Bugs/12:30 meeting.md") + XCTAssertEqual(hit?.path, "/Users/alice/vault/Účty/Bugs/12:30 meeting.md") XCTAssertEqual(hit?.line, 17) XCTAssertEqual(hit?.snippet, "See [[#Not the auth cluster]].") XCTAssertEqual(hit?.name, "12:30 meeting.md") @@ -178,7 +178,7 @@ final class FileTreeSearchRipgrepTests: XCTestCase { } /// Names mode used to answer "No match" for a phrase that sits in a note but in no file name -/// ("Followupy" in a ČEZ report, 2026-10-06). It now falls back to a text search. +/// ("Followupy" in a work report, 2026-10-06). It now falls back to a text search. @MainActor final class FileTreeSearchFallbackTests: XCTestCase { private func waitUntilIdle(_ model: FileTreeSearchModel) async { diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTreeLagBenchTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTreeLagBenchTests.swift index c1182f46ec..758c654bbc 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTreeLagBenchTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTreeLagBenchTests.swift @@ -17,7 +17,7 @@ final class MarkdownTreeLagBenchTests: XCTestCase { func testTreeCostForUnrelatedChangesAndScrolling() throws { let env = ProcessInfo.processInfo.environment guard let rootPath = env["MARKDOWN_TREE_BENCH"] else { - throw XCTSkip("set MARKDOWN_TREE_BENCH to a folder (the vault's ČEZ folder reproduces the report)") + throw XCTSkip("set MARKDOWN_TREE_BENCH to a folder (a large vault folder reproduces the report)") } let root = URL(fileURLWithPath: rootPath) let tabs = Int(env["MARKDOWN_TREE_BENCH_TABS"] ?? "") ?? 24 diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTreeTableTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTreeTableTests.swift index 32aaf665b5..d3ea752bee 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTreeTableTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTreeTableTests.swift @@ -126,9 +126,9 @@ final class MarkdownTreeTableTests: XCTestCase { /// Cached path keys agree for identical UTF-8 paths and distinguish neighboring names. func testAnEqualPathIsAnEqualKeyAndANearOneIsNot() { - XCTAssertEqual(FileTreeRowKey("/v/ČEZ/a.md"), FileTreeRowKey(String(decoding: Array("/v/ČEZ/a.md".utf8), as: UTF8.self))) - XCTAssertNotEqual(FileTreeRowKey("/v/ČEZ/a.md"), FileTreeRowKey("/v/ČEZ/b.md")) - XCTAssertEqual(Set([FileTreeRowKey("/v/ČEZ"), FileTreeRowKey("/v/ČEZ")]).count, 1) + XCTAssertEqual(FileTreeRowKey("/v/Účty/a.md"), FileTreeRowKey(String(decoding: Array("/v/Účty/a.md".utf8), as: UTF8.self))) + XCTAssertNotEqual(FileTreeRowKey("/v/Účty/a.md"), FileTreeRowKey("/v/Účty/b.md")) + XCTAssertEqual(Set([FileTreeRowKey("/v/Účty"), FileTreeRowKey("/v/Účty")]).count, 1) } /// A note opened as a precomposed name ("Čtení.md", NFC) against a listing that stored the decomposed name (NFD). diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownViewerSpeedTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownViewerSpeedTests.swift index 764802bea6..afd907ccc8 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownViewerSpeedTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownViewerSpeedTests.swift @@ -13,7 +13,7 @@ final class MarkdownViewerSpeedTests: XCTestCase { /// A non-ASCII path from `URL.path` is a bridged NSString; the document keeps a native copy. func testADocumentKeepsItsPathAsANativeString() throws { - let dir = FileManager.default.temporaryDirectory.appendingPathComponent("ČEZ-\(UUID().uuidString)") + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("Účty-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) addTeardownBlock { try? FileManager.default.removeItem(at: dir) } let url = dir.appendingPathComponent("Návrh.md")