Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,30 @@ import Foundation
public struct CustomMetricsSnapshot: Codable, Sendable, Equatable {
public var title: String
public var symbol: String?
public var textOverflow: CustomMetricsTextOverflow?
public var metricsBarValue: String?
public var metrics: [CustomMetric]
public var lastUpdatedDate: Date

public init(
title: String,
symbol: String? = nil,
textOverflow: CustomMetricsTextOverflow? = nil,
metricsBarValue: String? = nil,
metrics: [CustomMetric] = [],
lastUpdatedDate: Date
) {
self.title = title
self.symbol = symbol
self.textOverflow = textOverflow
self.metricsBarValue = metricsBarValue
self.metrics = metrics
self.lastUpdatedDate = lastUpdatedDate
}
}

public enum CustomMetricsTextOverflow: String, Codable, Sendable, Equatable {
case truncate
case wrap
case expand
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,36 @@ struct CustomMetricsCardView: View {
}
}

private var textOverflow: CustomMetricsTextOverflow {
snapshot.textOverflow ?? .expand
}

var body: some View {
ViewThatFits(in: .vertical) {
cardContent
.fixedSize(horizontal: false, vertical: true)
ScrollView {
cardContent
.fixedSize(horizontal: false, vertical: true)
}
.scrollIndicators(.visible)
}
}

private var cardContent: some View {
HStack(alignment: .center, spacing: 16) {
Image(systemName: snapshot.displaySymbol)
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 2) {
Text(verbatim: snapshot.title)
.lineLimit(textOverflow == .wrap ? nil : 1)
Group {
ForEach(snapshot.metrics.enumerated(), id: \.offset) { _, metric in
Text(verbatim: "\(metric.title): \(metric.formattedValue)")
.font(.caption)
.lineLimit(textOverflow == .wrap ? nil : 1)
if let normalizedValue = metric.normalizedValue {
BarGraphView(value: max(0, min(1, normalizedValue)) * 100)
}
Expand All @@ -61,7 +79,6 @@ struct CustomMetricsCardView: View {
.padding(.leading, 12)
}
}
.fixedSize()
.padding(.leading, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,23 @@
limitations under the License.
*/

import AppKit
import DataSource
import Model
import SwiftUI

struct DashboardView: View {
@Environment(\.appDependencies) private var appDependencies
@StateObject var store: Dashboard

private var screenVisibleFrame: CGRect? {
let mouseLocation = NSEvent.mouseLocation
return NSScreen.screens.first(where: { $0.frame.contains(mouseLocation) })?.visibleFrame
?? NSScreen.main?.visibleFrame
}

var body: some View {
VStack(spacing: 8) {
DashboardColumnsLayout(maximumHeight: screenVisibleFrame?.height) {
HStack {
Text(store.appName)
.foregroundStyle(.secondary)
Expand All @@ -40,6 +48,7 @@ struct DashboardView: View {
}
)
}
.frame(maxWidth: .infinity)
SystemInfoStackView(
systemInfoBundle: store.systemInfoBundle,
cpuRingBuffer: store.cpuRingBuffer,
Expand All @@ -48,9 +57,12 @@ struct DashboardView: View {
)
ForEach(store.customMetricsBundles) { customMetricsBundle in
CustomMetricsCardView(customMetricsBundle: customMetricsBundle)
.layoutValue(
key: TextOverflowLayoutValueKey.self,
value: customMetricsBundle.snapshot.textOverflow ?? .expand
)
}
}
.fixedSize()
.padding(8)
.task {
await store.send(.task(String(describing: Self.self)))
Expand All @@ -63,4 +75,121 @@ struct DashboardView: View {
}
}

private struct DashboardColumnsLayout: Layout {
var maximumHeight: CGFloat?
private let spacing: CGFloat = 8

private struct Column {
var indices: [Int] = []
var width: CGFloat = 0
var height: CGFloat = 0
}

private func columns(for subviews: Subviews, heightLimit: CGFloat?) -> [Column] {
let baseWidth = subviews.count > 1
? subviews[subviews.index(subviews.startIndex, offsetBy: 1)].sizeThatFits(.unspecified).width
: 0
let indices = Array(subviews.indices.dropFirst())
var columns: [Column] = []
var start = indices.startIndex
while start < indices.endIndex {
var selectedColumn = measuredColumn(
indices: [indices[start]],
baseWidth: baseWidth,
subviews: subviews,
heightLimit: heightLimit
)
var selectedEnd = start
for end in indices.indices[start...].dropFirst() {
let column = measuredColumn(
indices: Array(indices[start...end]),
baseWidth: baseWidth,
subviews: subviews,
heightLimit: heightLimit
)
if heightLimit.map({ column.height <= $0 }) ?? true {
selectedColumn = column
selectedEnd = end
}
}
columns.append(selectedColumn)
start = indices.index(after: selectedEnd)
}
return columns
}

private func measuredColumn(
indices: [Int],
baseWidth: CGFloat,
subviews: Subviews,
heightLimit: CGFloat?
) -> Column {
let width = indices.reduce(baseWidth) { width, index in
guard subviews[index][TextOverflowLayoutValueKey.self] == .expand else { return width }
return max(width, subviews[index].sizeThatFits(.unspecified).width)
}
let height = indices.reduce(0) { height, index in
let naturalHeight = subviews[index].sizeThatFits(
ProposedViewSize(width: width, height: nil)
).height
let itemHeight = min(naturalHeight, heightLimit ?? naturalHeight)
return height + (height > 0 ? spacing : 0) + itemHeight
}
return Column(indices: indices, width: width, height: height)
}

private func layout(for subviews: Subviews) -> (headerSize: CGSize, columns: [Column]) {
guard let header = subviews.first else { return (.zero, []) }
let headerSize = header.sizeThatFits(.unspecified)
let heightLimit = maximumHeight.flatMap {
$0.isFinite ? max(1, $0 - 16 - headerSize.height - spacing) : nil
}
return (headerSize, columns(for: subviews, heightLimit: heightLimit))
}

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let layout = layout(for: subviews)
let columns = layout.columns
let columnsWidth = columns.reduce(0) { $0 + $1.width }
+ spacing * CGFloat(max(0, columns.count - 1))
return CGSize(
width: max(layout.headerSize.width, columnsWidth),
height: layout.headerSize.height
+ (columns.isEmpty ? 0 : spacing + (columns.map(\.height).max() ?? 0))
)
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard let header = subviews.first else { return }
let layout = layout(for: subviews)
header.place(
at: bounds.origin,
anchor: .topLeading,
proposal: ProposedViewSize(width: bounds.width, height: layout.headerSize.height)
)
var x = bounds.minX
let heightLimit = maximumHeight.flatMap {
$0.isFinite ? max(1, $0 - 16 - layout.headerSize.height - spacing) : nil
}
for column in layout.columns {
var y = bounds.minY + layout.headerSize.height + spacing
for index in column.indices {
let naturalSize = subviews[index].sizeThatFits(ProposedViewSize(width: column.width, height: nil))
let height = min(naturalSize.height, heightLimit ?? naturalSize.height)
subviews[index].place(
at: CGPoint(x: x, y: y),
anchor: .topLeading,
proposal: ProposedViewSize(width: column.width, height: height)
)
y += height + spacing
}
x += column.width + spacing
}
}
}

private struct TextOverflowLayoutValueKey: LayoutValueKey {
static let defaultValue = CustomMetricsTextOverflow.truncate
}

extension Dashboard: ObservableObject {}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,25 @@ struct CustomMetricsSnapshotTests {
#expect(snapshot == expected)
}

@Test
func decode_snapshot_with_textOverflow() throws {
let json = """
{
"title": "Sessions",
"textOverflow": "wrap",
"metrics": [],
"lastUpdatedDate": "2026-06-05T04:50:40Z"
}
""".data(using: .utf8)!
#expect(
try decoder.decode(CustomMetricsSnapshot.self, from: json) == CustomMetricsSnapshot(
title: "Sessions",
textOverflow: .wrap,
lastUpdatedDate: try #require(ISO8601DateFormatter().date(from: "2026-06-05T04:50:40Z"))
)
)
}

@Test
func decode_throws_when_title_missing() {
let json = """
Expand Down
6 changes: 6 additions & 0 deletions docs/CustomMetricsSchema.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ A valid file might look like this:
{
"title": "Claude Code",
"symbol": "staroflife",
"textOverflow": "truncate",
"metricsBarValue": "5.4%",
"metrics": [
{ "title": "Model", "formattedValue": "Opus 4.7" },
Expand All @@ -37,6 +38,7 @@ The values above are illustrative — `title`, `symbol`, and the metric labels a
|-------------------|----------------|----------|-------------|
| `title` | string | yes | Card header text. |
| `symbol` | string | no | [SF Symbol](https://developer.apple.com/sf-symbols/) identifier shown next to the title. Defaults to `chart.bar.horizontal.page.fill`. |
| `textOverflow` | string | no | Long-line behavior: `truncate`, `wrap`, or `expand`. Defaults to `expand` to preserve the original behavior. |
| `metricsBarValue` | string | no | Short text shown in the Metrics Bar (the dedicated menu-bar item) next to the source's symbol. Displayed verbatim; keep it short — the bar caps the label width and truncates longer strings. Each source is hidden in the bar by default: click the Metrics Bar and flip the source's toggle to show it. When the source is shown but this field is omitted, the bar renders `---`. |
| `metrics` | array<Metric\> | yes | Rows displayed inside the card. Empty array is allowed. |
| `lastUpdatedDate` | string | yes | ISO 8601 timestamp (e.g. `"2026-06-05T04:50:40Z"`) of when the producer wrote this file. Shown as a relative time (`"3 min ago"`) at the bottom of the card; updates automatically. |
Expand All @@ -56,6 +58,10 @@ The values above are illustrative — `title`, `symbol`, and the metric labels a
- The bar is drawn in the accent color regardless of the value — it does not change color as the value grows. Encode any severity you want to convey in `formattedValue` itself.
- If `metrics` is empty, the card shows only its title and the last-updated line.
- `metricsBarValue` is rendered verbatim in the Metrics Bar with a monospaced digit font, prefixed by the source's `symbol`.
- `truncate` and `wrap` do not widen a column. They use the width established by other cards in the same column, or the dashboard's original width when no card widens it. `truncate` keeps text on one line, while `wrap` uses as many lines as needed.
- `expand` preserves the original behavior: a card's content can widen its column.
- Cards are arranged into additional columns only when the dashboard would exceed the screen's visible height.
- A single card taller than the visible height is clipped to that height and becomes internally scrollable.

## Failure behavior

Expand Down