fix(mac): keep context row labels together
This commit is contained in:
162
apps/macos/Sources/Clawdis/ContextUsageRow.swift
Normal file
162
apps/macos/Sources/Clawdis/ContextUsageRow.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -24,7 +24,6 @@ struct MenuContent: View {
|
|||||||
private let contextCardPadding: CGFloat = 10
|
private let contextCardPadding: CGFloat = 10
|
||||||
private let contextBarHeight: CGFloat = 4
|
private let contextBarHeight: CGFloat = 4
|
||||||
private let contextFallbackWidth: CGFloat = 320
|
private let contextFallbackWidth: CGFloat = 320
|
||||||
private let contextSessionRowHeight: CGFloat = 18
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -316,32 +315,15 @@ struct MenuContent: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func contextSessionRow(_ row: SessionRow) -> some View {
|
private func contextSessionRow(_ row: SessionRow) -> some View {
|
||||||
let width = self.contextPillWidth
|
let width = self.contextPillWidth
|
||||||
ZStack(alignment: .bottomLeading) {
|
ContextUsageRow(
|
||||||
ContextUsageBar(
|
sessionKey: row.key,
|
||||||
usedTokens: row.tokens.total,
|
summary: row.tokens.contextSummaryShort,
|
||||||
contextTokens: row.tokens.contextTokens,
|
usedTokens: row.tokens.total,
|
||||||
width: width,
|
contextTokens: row.tokens.contextTokens,
|
||||||
height: self.contextBarHeight)
|
width: width,
|
||||||
.padding(.bottom, 0)
|
barHeight: self.contextBarHeight,
|
||||||
|
rowHeight: 18,
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
isMain: row.key == "main")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var heartbeatStatusRow: some View {
|
private var heartbeatStatusRow: some View {
|
||||||
|
|||||||
Reference in New Issue
Block a user