Skip to content
Merged
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
74 changes: 71 additions & 3 deletions Sources/CodeCaps/PipWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,41 @@ enum PipMetrics {
}
}

// MARK: Horizontal rows

/// How much of the NEXT row must be visible for a horizontal strip to be
/// worth switching to. Less than this and the owner sees a full row and a
/// sliver, which reads as a clipped row rather than as "there is more".
static let horizontalPeek: CGFloat = 64

/// The width one row needs at a given detail level.
static func rowWidth(for detail: Detail) -> CGFloat {
switch detail {
case .full: return fullMinWidth
case .noCountdown: return noCountdownMinWidth
case .singleMeter: return singleMeterMinWidth
case .logoOnly: return logoOnlyMinWidth
case .minimal: return minimalMinWidth
case .barsOnly: return barsOnlyMinWidth
case .singleBar: return singleMeterMinWidth
}
}

/// Whether the panel is wide enough to scroll its rows sideways.
///
/// Owner 2026-10-10: "once it gets to where it is in the screenshot or
/// actually 80-100 pixels wider than that so that it could still have
/// platform logos at the left … we could make it scroll left to right like
/// it now does up and down."
///
/// Derived rather than hardcoded to his 80-100 figure, because the number
/// that matters is the row's own width at the detail level in force, which
/// moves when any bar metric changes. The result lands in his range: the
/// squeeze point is around 222pt, and this is roughly 90pt past it.
static func rowsScrollHorizontally(forWidth width: CGFloat, detail: Detail) -> Bool {
width >= padding * 2 + rowWidth(for: detail) + horizontalPeek
}

/// How many rows fit in `height` without clipping, never fewer than one.
static func visibleRowCount(total: Int, height: CGFloat) -> Int {
guard total > 0 else { return 0 }
Expand Down Expand Up @@ -424,6 +459,25 @@ struct WindowDragView: NSViewRepresentable {
/// The SwiftUI view for the floating PiP HUD widget.
struct PipWidgetView: View {
@ObservedObject var model: MonitorModel
/// The same rows laid end to end. Every row keeps its logo at its own left
/// edge, so scrolling never hides which platform you are looking at —
/// which was the whole point of the owner's request.
private func horizontalRows(detail: PipMetrics.Detail) -> some View {
let rowWidth = PipMetrics.rowWidth(for: detail)
return ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: PipMetrics.rowSpacing * 2) {
ForEach(targetRows) { row in
pipRow(row, detail: detail)
.frame(width: rowWidth, alignment: .leading)
}
}
.padding(.horizontal, PipMetrics.padding)
.padding(.vertical, PipMetrics.bodyVerticalPadding)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Theme.groupBand)
}

/// The row whose platform detail is open, if the owner clicked one. `nil`
/// is the normal all-rows HUD.
@State private var detailRowId: String?
Expand Down Expand Up @@ -466,7 +520,7 @@ struct PipWidgetView: View {
if let detailRow {
detailPanel(detailRow, detail: detail)
} else {
rowsPanel(detail: detail, height: geo.size.height)
rowsPanel(detail: detail, height: geo.size.height, width: geo.size.width)
}
}
floatingControl
Expand Down Expand Up @@ -588,11 +642,25 @@ struct PipWidgetView: View {

/// The rows panel: every pinned row, or the ones that fit, or the lot
/// scrolled if the owner has made the window shorter than its content.
private func rowsPanel(detail: PipMetrics.Detail, height: CGFloat) -> some View {
@ViewBuilder
private func rowsPanel(detail: PipMetrics.Detail, height: CGFloat, width: CGFloat) -> some View {
let visible = PipMetrics.visibleRowCount(total: targetRows.count, height: height)
let overflows = visible < targetRows.count

return ScrollView(.vertical, showsIndicators: false) {
// Wide enough for one full row and a peek of the next: the rows become
// a horizontal strip, each keeping its own logo at its left edge, which
// is what the owner asked for. The detail level is computed from the
// panel width first, so the row width the strip needs is the width the
// row actually wants — the two cannot disagree.
if PipMetrics.rowsScrollHorizontally(forWidth: width, detail: detail) {
horizontalRows(detail: detail)
} else {
verticalRows(detail: detail, visible: visible, overflows: overflows)
}
}

private func verticalRows(detail: PipMetrics.Detail, visible: Int, overflows: Bool) -> some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
ForEach(Array(targetRows.prefix(max(1, visible)))) { row in
pipRow(row, detail: detail)
Expand Down
36 changes: 36 additions & 0 deletions Tests/CodeCapsTests/PipMetricsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,42 @@ final class PipMetricsTests: XCTestCase {
}
}

/// Owner 2026-10-10: "once it gets to where it is in the screenshot or
/// actually 80-100 pixels wider than that so that it could still have
/// platform logos at the left … we could make it scroll left to right like
/// it now does up and down."
///
/// The threshold is derived from the row's own width at the detail level in
/// force rather than hardcoded to his 80-100 figure, because that figure is
/// only meaningful while the bar metrics behind it hold. The derived value
/// lands in his range: the squeeze point is about 222pt, and this is
/// roughly 90pt past it.
func testRowsScrollSidewaysOnlyWhenOneFullRowAndAPeekFit() {
let detail = PipMetrics.Detail.noCountdown
let rowWidth = PipMetrics.rowWidth(for: detail)
let threshold = PipMetrics.padding * 2 + rowWidth + PipMetrics.horizontalPeek

XCTAssertFalse(PipMetrics.rowsScrollHorizontally(forWidth: threshold - 1, detail: detail),
"one point short of a full row plus a peek stays vertical")
XCTAssertTrue(PipMetrics.rowsScrollHorizontally(forWidth: threshold, detail: detail))

// The switch point must sit in the owner's stated band, or the feature
// is not the thing he asked for.
let squeezePoint = PipMetrics.padding * 2 + PipMetrics.logoOnlyMinWidth
let past = threshold - squeezePoint
XCTAssertGreaterThanOrEqual(past, 60, "switching at \(past)pt past the squeeze point is too eager")
XCTAssertLessThanOrEqual(past, 140, "…and \(past)pt is too late to feel responsive")

// Every detail level agrees with itself: the row always fits.
for level in PipMetrics.Detail.allCases {
let needed = PipMetrics.padding * 2 + PipMetrics.rowWidth(for: level) + PipMetrics.horizontalPeek
XCTAssertTrue(PipMetrics.rowsScrollHorizontally(forWidth: needed, detail: level),
"\(level) must be able to reach its own horizontal layout")
XCTAssertFalse(PipMetrics.rowsScrollHorizontally(forWidth: needed - 1, detail: level),
"\(level) must not switch before its own row fits")
}
}

func testNarrowWindowStillShowsBarAndPercent() {
// The owner's floor: only the bars and the percentages remain.
let detail = PipMetrics.detail(forWidth: PipMetrics.minWidth)
Expand Down
Loading