From c257436356792e94a0e3ece99df99b4c299b8cd3 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:44:02 -0500 Subject: [PATCH] feat(pip): scroll the rows sideways once there is room for one row and a peek MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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." Each row in the strip keeps its own logo at its left edge, so scrolling never hides which platform you are looking at — which was the condition he attached to the request in the first place ("assuming we finally can make the logos actually show up"), and which #190 finally satisfied. The threshold is DERIVED from the row's own width at the detail level in force plus a 64pt peek, not hardcoded to his 80-100 figure: that figure is only meaningful while the bar metrics behind it hold, and a hardcoded number would silently rot the next time one moves. The detail level is computed from the panel width first, so the width the strip demands is the width the row actually wants — the two cannot disagree. It lands in his band: the squeeze point is around 222pt and the switch is roughly 90pt past it, which the test asserts so a future metric change cannot quietly move the feature away from what was asked for. --- Sources/CodeCaps/PipWidget.swift | 74 ++++++++++++++++++++++- Tests/CodeCapsTests/PipMetricsTests.swift | 36 +++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/Sources/CodeCaps/PipWidget.swift b/Sources/CodeCaps/PipWidget.swift index acec592c..daf56add 100644 --- a/Sources/CodeCaps/PipWidget.swift +++ b/Sources/CodeCaps/PipWidget.swift @@ -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 } @@ -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? @@ -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 @@ -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) diff --git a/Tests/CodeCapsTests/PipMetricsTests.swift b/Tests/CodeCapsTests/PipMetricsTests.swift index 25f62bfb..6889733f 100644 --- a/Tests/CodeCapsTests/PipMetricsTests.swift +++ b/Tests/CodeCapsTests/PipMetricsTests.swift @@ -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)