diff --git a/LocalPackage/Sources/DataSource/Entities/CustomMetrics/CustomMetricsSnapshot.swift b/LocalPackage/Sources/DataSource/Entities/CustomMetrics/CustomMetricsSnapshot.swift index c6840b3..d96b7a2 100644 --- a/LocalPackage/Sources/DataSource/Entities/CustomMetrics/CustomMetricsSnapshot.swift +++ b/LocalPackage/Sources/DataSource/Entities/CustomMetrics/CustomMetricsSnapshot.swift @@ -23,6 +23,7 @@ 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 @@ -30,14 +31,22 @@ public struct CustomMetricsSnapshot: Codable, Sendable, Equatable { 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 +} diff --git a/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/CustomMetricsCardView.swift b/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/CustomMetricsCardView.swift index 02ec47d..04788b3 100644 --- a/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/CustomMetricsCardView.swift +++ b/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/CustomMetricsCardView.swift @@ -38,7 +38,23 @@ 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() @@ -46,10 +62,12 @@ struct CustomMetricsCardView: View { .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) } @@ -61,7 +79,6 @@ struct CustomMetricsCardView: View { .padding(.leading, 12) } } - .fixedSize() .padding(.leading, 8) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) diff --git a/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/DashboardView.swift b/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/DashboardView.swift index 6132c62..ca70654 100644 --- a/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/DashboardView.swift +++ b/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/DashboardView.swift @@ -18,6 +18,8 @@ limitations under the License. */ +import AppKit +import DataSource import Model import SwiftUI @@ -25,8 +27,14 @@ 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) @@ -40,6 +48,7 @@ struct DashboardView: View { } ) } + .frame(maxWidth: .infinity) SystemInfoStackView( systemInfoBundle: store.systemInfoBundle, cpuRingBuffer: store.cpuRingBuffer, @@ -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))) @@ -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 {} diff --git a/LocalPackage/Tests/DataSourceTests/EntityTests/CustomMetricsSnapshotTests.swift b/LocalPackage/Tests/DataSourceTests/EntityTests/CustomMetricsSnapshotTests.swift index 673d084..9047226 100644 --- a/LocalPackage/Tests/DataSourceTests/EntityTests/CustomMetricsSnapshotTests.swift +++ b/LocalPackage/Tests/DataSourceTests/EntityTests/CustomMetricsSnapshotTests.swift @@ -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 = """ diff --git a/docs/CustomMetricsSchema.md b/docs/CustomMetricsSchema.md index 666f9f9..d6a0ad1 100644 --- a/docs/CustomMetricsSchema.md +++ b/docs/CustomMetricsSchema.md @@ -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" }, @@ -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 | 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. | @@ -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