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
137 changes: 53 additions & 84 deletions Sources/CodeCaps/PipWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ enum PipMetrics {

static let padding: CGFloat = 10
static let cornerRadius: CGFloat = 12
static let headerHeight: CGFloat = 42
static let rowHeight: CGFloat = 20
static let bodyVerticalPadding: CGFloat = 6

Expand All @@ -33,23 +32,21 @@ enum PipMetrics {
static let barMinWidth: CGFloat = 28
static let barHeight: CGFloat = 4

// MARK: Header
// MARK: Floating controls

/// Big enough to read as the app's mark rather than as an icon in a list.
/// It replaces a 12pt fallback that read as a smudge at HUD scale.
static let markSize: CGFloat = 22
static let markTextSize: CGFloat = 12
/// Below this the word "CodeCaps" is what collides with the close button,
/// so the mark travels alone.
static let headerTextMinWidth: CGFloat = 130
/// The close/back control floats over the rows rather than living in a band
/// of its own (owner, 2026-10-08), so it is inset from the corner rather
/// than laid out beside a title.
static let controlInset: CGFloat = 4
static let controlHitSize: CGFloat = 18

// MARK: Panel bounds

static let minWidth: CGFloat = 110
static let maxWidth: CGFloat = 520
/// One header, one row, and the body's own vertical padding. The two
/// zones run edge to edge, so this is the whole panel height.
static let minHeight: CGFloat = headerHeight + rowHeight + bodyVerticalPadding * 2
/// One row plus the body's own vertical padding, now that the header band
/// is gone and the panel is rows only.
static let minHeight: CGFloat = rowHeight + bodyVerticalPadding * 2
static let maxHeight: CGFloat = 460

/// A window with no dual-meter row opens comfortably wide without being
Comment thread
kody-ai[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -84,6 +81,10 @@ enum PipMetrics {
static let singleMeterMinWidth: CGFloat =
providerLogoSize + rowSpacing + titleWidth + rowSpacing + meterMinWidthWithCountdown

/// Provider logo and two meters, no countdowns and no title.
static let logoOnlyMinWidth: CGFloat =
providerLogoSize + rowSpacing + meterMinWidth + rowSpacing + meterMinWidth

/// Two labelled meters, with no provider identity at all.
static let minimalMinWidth: CGFloat = meterMinWidth + rowSpacing + meterMinWidth

Expand All @@ -109,7 +110,15 @@ enum PipMetrics {
case noCountdown
/// One meter, with its countdown, still named.
case singleMeter
/// Two labelled meters, provider identity dropped.
/// Two meters and the provider's logo, but its NAME dropped.
///
/// Owner, 2026-10-08: "when the window is getting narrower, the first
/// thing to go (after things have compressed as much as allowable) is
/// the platform name ASSUMING WE FINALLY CAN MAKE THE LOGOS ACTUALLY
/// SHOW UP". The logo and the name used to disappear together, so
/// there was nothing to fall back to; this is that level.
case logoOnly
/// Two labelled meters, provider identity dropped entirely.
case minimal
/// Two bars and two percentages, captions dropped.
case barsOnly
Expand All @@ -124,7 +133,7 @@ enum PipMetrics {

var maxMeters: Int {
switch self {
case .full, .noCountdown, .minimal, .barsOnly: return 2
case .full, .noCountdown, .logoOnly, .minimal, .barsOnly: return 2
case .singleMeter, .singleBar: return 1
}
}
Expand All @@ -138,10 +147,14 @@ enum PipMetrics {
}

var showsProviderLogo: Bool {
self == .full || self == .noCountdown || self == .singleMeter
self == .full || self == .noCountdown || self == .singleMeter || self == .logoOnly
}

var showsTitle: Bool { showsProviderLogo }
/// The name outlives the logo nowhere: the logo is what identifies a row
/// once the name is gone, so the name is the first of the two to drop.
var showsTitle: Bool {
self == .full || self == .noCountdown || self == .singleMeter
}
}

/// The richest level whose content fits `width`, content width being what
Expand All @@ -152,26 +165,22 @@ enum PipMetrics {
case fullMinWidth...: return .full
case noCountdownMinWidth...: return .noCountdown
case singleMeterMinWidth...: return .singleMeter
case logoOnlyMinWidth...: return .logoOnly
case minimalMinWidth...: return .minimal
case barsOnlyMinWidth...: return .barsOnly
default: return .singleBar
}
}

/// Whether the header can carry the word as well as the mark.
static func showsHeaderText(forWidth width: CGFloat) -> Bool {
width >= headerTextMinWidth
}

/// How many rows fit in `height` without clipping, never fewer than one:
/// a PiP HUD that has been shrunk is still a PiP HUD, and one visible meter
/// is the honest answer to "no room".
///
/// The two zones run edge to edge, so `height` is the header, the body's
/// own vertical padding and the rows — there is no outer padding to remove.
/// `height` is the body's own vertical padding plus the rows — the header
/// band is gone, so there is nothing else to take out.
static func visibleRowCount(total: Int, height: CGFloat) -> Int {
guard total > 0 else { return 0 }
let available = height - headerHeight - bodyVerticalPadding * 2
let available = height - bodyVerticalPadding * 2
guard available >= rowHeight else { return 1 }
return min(total, max(1, Int(available / rowHeight)))
}
Expand All @@ -182,7 +191,7 @@ enum PipMetrics {
/// failure the owner reported.
static func fitSize(rowCount: Int, hasDualMeterRow: Bool) -> NSSize {
let rows = CGFloat(max(1, rowCount))
let height = headerHeight + rows * rowHeight + bodyVerticalPadding * 2
let height = rows * rowHeight + bodyVerticalPadding * 2
let width = hasDualMeterRow
? padding * 2 + fullMinWidth
: max(singleMeterDefaultWidth, padding * 2 + singleMeterMinWidth)
Expand Down Expand Up @@ -430,13 +439,21 @@ struct PipWidgetView: View {
// their own contents, so the ladder below reads real panel width
// rather than width that has already had the padding taken out of it.
let detail = PipMetrics.detail(forWidth: geo.size.width)
VStack(spacing: 0) {
header(width: geo.size.width)
// No header band. The owner read the raised strip as a stray silver
// tab (2026-10-08) and, with it gone, asked that the app's own mark
// and name not appear on the HUD at all — the provider logos are what
// identify it now. The close/back control floats over the rows
// instead, still only while the pointer is over the window.
ZStack(alignment: .topTrailing) {
if let detailRow {
detailPanel(detailRow, detail: detail)
} else {
rowsPanel(detail: detail, height: geo.size.height)
}
floatingControl
// Without this the 18x18 control sat flush in the corner and
// overlapped the first row's right-edge percentage.
.padding(PipMetrics.controlInset)
}
}
.background {
Expand All @@ -455,66 +472,18 @@ struct PipWidgetView: View {

// MARK: Header

/// The header band, edge to edge with the rows panel below it. The
/// material strip that used to sit above it (the window's own background
/// showing through) is gone: the owner read it as a stray silver tab and
/// nothing looked right on it.
///
/// The close button appears only while the mouse is over the window. The
/// owner saw it permanently after it was made always-on and asked for the
/// hover behaviour back: a control that never moves is furniture, and this
/// one sits on top of the quota numbers.
private func header(width: CGFloat) -> some View {
ZStack {
// Centred as its own layer, so the close button appearing and
// disappearing cannot shift the mark and title sideways.
HStack(spacing: 6) {
mark
if PipMetrics.showsHeaderText(forWidth: width) {
Text("CodeCaps")
.font(.system(size: PipMetrics.markTextSize, weight: .bold))
.foregroundStyle(Theme.solidMark)
.fixedSize()
}
}

HStack(spacing: 0) {
Spacer(minLength: 0)
if isHovering {
if detailRow == nil {
closeButton
} else {
backButton
}
}
}
}
.frame(height: PipMetrics.headerHeight)
.frame(maxWidth: .infinity)
.padding(.horizontal, PipMetrics.padding)
.background(Theme.raisedBand)
}

/// CodeCaps' own mark, from the owner's black-on-transparent artwork, drawn
/// as a template so the same asset is black-on-transparent on the light
/// band and white-on-transparent on the dark one. It is the app's mark
/// reused as-is — never re-cut, re-exported or substituted.
private var mark: some View {
Group {
if let image = PipWidgetController.brandMark {
Image(nsImage: image)
.resizable()
.renderingMode(.template)
.aspectRatio(contentMode: .fit)
.foregroundStyle(Theme.solidMark)
/// Close, or Back when a platform detail is open. It floats over the
/// rows' top-right corner and appears only on hover, so it never occupies
/// permanent space above the numbers.
@ViewBuilder
private var floatingControl: some View {
if isHovering {
if detailRow == nil {
closeButton
} else {
Image(systemName: "gauge.with.needle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(Theme.solidMark)
backButton
}
}
.frame(width: PipMetrics.markSize, height: PipMetrics.markSize)
}

private var closeButton: some View {
Expand Down
49 changes: 37 additions & 12 deletions Tests/CodeCapsTests/PipMetricsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,37 @@ final class PipMetricsTests: XCTestCase {
}
}

/// Owner, 2026-10-08: "when the window is getting narrower, the first
/// thing to go (after things have compressed as much as allowable) is the
/// platform name ASSUMING WE FINALLY CAN MAKE THE LOGOS ACTUALLY SHOW UP".
///
/// The logo and the name used to drop together at `minimal`, so there was
/// nothing left to identify the row. The name now goes first, at its own
/// `logoOnly` level, and the logo survives until the identity ladder runs
/// out entirely.
func testThePlatformNameDropsBeforeTheLogo() {
XCTAssertTrue(PipMetrics.Detail.logoOnly.showsProviderLogo,
"the logo must still identify the row at this level")
XCTAssertFalse(PipMetrics.Detail.logoOnly.showsTitle,
"the name is the first thing to go")

// And there is a real width band that selects it, between the named
// single-meter row and the identity-free minimal one.
XCTAssertEqual(PipMetrics.detail(forWidth: PipMetrics.padding * 2 + PipMetrics.logoOnlyMinWidth),
.logoOnly)
XCTAssertEqual(PipMetrics.detail(forWidth: PipMetrics.padding * 2 + PipMetrics.minimalMinWidth),
.minimal)
XCTAssertFalse(PipMetrics.Detail.minimal.showsProviderLogo,
"the logo goes only after the name, at the identity-free end")

// Nothing above logoOnly may lose the name while keeping two meters.
for level in [PipMetrics.Detail.full, .noCountdown, .singleMeter, .logoOnly] {
if level.maxMeters > 1 || level == .logoOnly {
XCTAssertTrue(level.showsProviderLogo, "\(level) must keep the logo")
}
}
}

func testNarrowWindowStillShowsBarAndPercent() {
// The owner's floor: only the bars and the percentages remain.
let detail = PipMetrics.detail(forWidth: PipMetrics.minWidth)
Expand Down Expand Up @@ -79,13 +110,12 @@ final class PipMetricsTests: XCTestCase {
}

func testFitHeightHasNoOuterPaddingTerm() {
// The two zones run edge to edge, so the panel height is exactly
// header + rows + the body's own vertical padding. If a future change
// puts padding back on the outside, `height` starts double-counting it:
// the ladder then drops a level early and the panel asks for room it is
// no longer using.
let expected = PipMetrics.headerHeight
+ CGFloat(3) * PipMetrics.rowHeight
// The rows run edge to edge, so the panel height is exactly the rows
// plus the body's own vertical padding — there is no header band (owner,
// 2026-10-08). If a future change puts padding back on the outside,
// `height` starts double-counting it: the ladder then drops a level
// early and the panel asks for room it is no longer using.
let expected = CGFloat(3) * PipMetrics.rowHeight
+ PipMetrics.bodyVerticalPadding * 2
XCTAssertEqual(PipMetrics.fitSize(rowCount: 3, hasDualMeterRow: false).height, expected)
}
Expand Down Expand Up @@ -129,11 +159,6 @@ final class PipMetricsTests: XCTestCase {
}
}

func testHeaderTextIsDroppedOnlyWhenItWouldCollideWithTheCloseButton() {
XCTAssertTrue(PipMetrics.showsHeaderText(forWidth: PipMetrics.minWidth * 2))
XCTAssertFalse(PipMetrics.showsHeaderText(forWidth: PipMetrics.minWidth))
}

// MARK: Window behaviour

func testPanelIsResizableAndBounded() {
Expand Down
Loading