From 3001f115b61a31a27bbc33855f7b473f2ce2358e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Dec 2025 02:47:39 +0000 Subject: [PATCH] fix(mac): keep context row labels together --- .../Sources/Clawdis/ContextUsageRow.swift | 162 ++++++++++++++++++ .../Sources/Clawdis/MenuContentView.swift | 36 +--- 2 files changed, 171 insertions(+), 27 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/ContextUsageRow.swift diff --git a/apps/macos/Sources/Clawdis/ContextUsageRow.swift b/apps/macos/Sources/Clawdis/ContextUsageRow.swift new file mode 100644 index 000000000..a514242b3 --- /dev/null +++ b/apps/macos/Sources/Clawdis/ContextUsageRow.swift @@ -0,0 +1,162 @@ +import AppKit +import SwiftUI + +/// Single-row context usage display that stays intact inside menu rendering. +/// +/// SwiftUI menus tend to decompose view hierarchies into separate menu rows +/// (image row, text row, etc.). We render the combined layout into an image +/// so session name + numbers are guaranteed to appear on the same row. +struct ContextUsageRow: View { + let sessionKey: String + let summary: String + let usedTokens: Int + let contextTokens: Int + let width: CGFloat + var barHeight: CGFloat = 4 + var rowHeight: CGFloat = 18 + var isMain: Bool = false + + var body: some View { + Image(nsImage: Self.renderRow( + width: self.width, + rowHeight: self.rowHeight, + barHeight: self.barHeight, + sessionKey: self.sessionKey, + summary: self.summary, + usedTokens: self.usedTokens, + contextTokens: self.contextTokens, + isMain: self.isMain)) + .resizable() + .interpolation(.none) + .frame(width: self.width, height: self.rowHeight) + .accessibilityLabel("Context usage") + .accessibilityValue("\(self.sessionKey) \(self.summary)") + } + + private static func renderRow( + width: CGFloat, + rowHeight: CGFloat, + barHeight: CGFloat, + sessionKey: String, + summary: String, + usedTokens: Int, + contextTokens: Int, + isMain: Bool + ) -> NSImage { + let safeWidth = max(1, width) + let safeRowHeight = max(1, rowHeight) + let safeBarHeight = min(max(1, barHeight), safeRowHeight) + + let size = NSSize(width: safeWidth, height: safeRowHeight) + let image = NSImage(size: size) + image.isTemplate = false + + image.lockFocus() + defer { image.unlockFocus() } + + let barRect = NSRect(x: 0, y: 0, width: size.width, height: safeBarHeight) + drawBar(in: barRect, usedTokens: usedTokens, contextTokens: contextTokens) + + let textRect = NSRect( + x: 0, + y: safeBarHeight, + width: size.width, + height: size.height - safeBarHeight + ) + drawText(in: textRect, sessionKey: sessionKey, summary: summary, isMain: isMain) + + return image + } + + private static func drawText(in rect: NSRect, sessionKey: String, summary: String, isMain: Bool) { + guard rect.width > 1, rect.height > 1 else { return } + + let keyFont = NSFont.systemFont( + ofSize: NSFont.smallSystemFontSize, + weight: isMain ? .semibold : .regular + ) + let summaryFont = NSFont.monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) + + let keyParagraph = NSMutableParagraphStyle() + keyParagraph.alignment = .left + keyParagraph.lineBreakMode = .byTruncatingMiddle + + let summaryParagraph = NSMutableParagraphStyle() + summaryParagraph.alignment = .right + summaryParagraph.lineBreakMode = .byClipping + + let keyAttr = NSAttributedString( + string: sessionKey, + attributes: [ + .font: keyFont, + .foregroundColor: NSColor.labelColor, + .paragraphStyle: keyParagraph, + ] + ) + let summaryAttr = NSAttributedString( + string: summary, + attributes: [ + .font: summaryFont, + .foregroundColor: NSColor.secondaryLabelColor, + .paragraphStyle: summaryParagraph, + ] + ) + + let summarySize = summaryAttr.size() + let gap: CGFloat = 10 + let rightWidth = min(rect.width, ceil(summarySize.width)) + let leftWidth = max(1, rect.width - rightWidth - gap) + + let textHeight = max(keyAttr.size().height, summarySize.height) + let y = rect.minY + floor((rect.height - textHeight) / 2) + + let leftRect = NSRect(x: rect.minX, y: y, width: leftWidth, height: textHeight) + keyAttr.draw(with: leftRect, options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine]) + + let rightRect = NSRect( + x: rect.maxX - rightWidth, + y: y, + width: rightWidth, + height: textHeight + ) + summaryAttr.draw(with: rightRect, options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine]) + } + + private static func drawBar(in rect: NSRect, usedTokens: Int, contextTokens: Int) { + let radius = rect.height / 2 + let background = NSColor.white.withAlphaComponent(0.12) + let stroke = NSColor.white.withAlphaComponent(0.18) + + let fractionUsed: Double = { + guard contextTokens > 0 else { return 0 } + return min(1, max(0, Double(usedTokens) / Double(contextTokens))) + }() + let percentUsed: Int? = { + guard contextTokens > 0, usedTokens > 0 else { return nil } + return min(100, Int(round(fractionUsed * 100))) + }() + + let fill: NSColor = { + guard let pct = percentUsed else { return NSColor.secondaryLabelColor } + if pct >= 95 { return .systemRed } + if pct >= 80 { return .systemOrange } + if pct >= 60 { return .systemYellow } + return .systemGreen + }() + + let track = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) + background.setFill() + track.fill() + stroke.setStroke() + track.lineWidth = 0.75 + track.stroke() + + let fillWidth = max(1, floor(rect.width * fractionUsed)) + let fillRect = NSRect(x: rect.minX, y: rect.minY, width: fillWidth, height: rect.height) + let clip = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) + clip.addClip() + fill.setFill() + NSBezierPath(rect: fillRect).fill() + } +} + diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index bbbb35b25..02a958c4f 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -24,7 +24,6 @@ struct MenuContent: View { private let contextCardPadding: CGFloat = 10 private let contextBarHeight: CGFloat = 4 private let contextFallbackWidth: CGFloat = 320 - private let contextSessionRowHeight: CGFloat = 18 var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -316,32 +315,15 @@ struct MenuContent: View { @ViewBuilder private func contextSessionRow(_ row: SessionRow) -> some View { let width = self.contextPillWidth - ZStack(alignment: .bottomLeading) { - ContextUsageBar( - usedTokens: row.tokens.total, - contextTokens: row.tokens.contextTokens, - width: width, - height: self.contextBarHeight) - .padding(.bottom, 0) - - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(row.key) - .font(.caption.weight(row.key == "main" ? .semibold : .regular)) - .lineLimit(1) - .truncationMode(.middle) - .layoutPriority(1) - Spacer(minLength: 8) - Text(row.tokens.contextSummaryShort) - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .layoutPriority(2) - } - .padding(.bottom, self.contextBarHeight + 4) - .frame(width: width) - } - .frame(width: width, height: self.contextSessionRowHeight, alignment: .bottomLeading) + ContextUsageRow( + sessionKey: row.key, + summary: row.tokens.contextSummaryShort, + usedTokens: row.tokens.total, + contextTokens: row.tokens.contextTokens, + width: width, + barHeight: self.contextBarHeight, + rowHeight: 18, + isMain: row.key == "main") } private var heartbeatStatusRow: some View {