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
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ public enum HoverCardPlacement: Sendable {
case above
/// Right of the target, top-aligned (the update card).
case beside
/// Beside a sidebar row, one device pixel off the sidebar's edge (the anchor is the row's band
/// across the sidebar, `HoverCardEscapeGeometry.rowBand`), its top on the row's top as Chrome's
/// Beside a sidebar row, touching its anchor (a workspace's drawn fill, or a group/rail band),
/// its top on the row's top as Chrome's
/// and Arc's tab cards (or on the row's middle, below an escape slot, under a Debug switch:
/// `HoverCardEscapeGeometry`).
case besideRow
Expand Down Expand Up @@ -446,8 +446,7 @@ extension HoverCardPanel {

/// The room between a card and its tab: none. The card touches its tab, so the pointer never
/// crosses empty space between them (Lawrence 2026-10-19: "there is a gap in between ... such
/// that there is no gap in between"). A card beside a sidebar row stands
/// `HoverCardEscapeGeometry.edgeGap(scale:)` off the sidebar's edge instead.
/// that there is no gap in between"). Sidebar previews also touch their row anchor.
static let targetGap: CGFloat = 0

/// The card's origin (screen coordinates, y up) for `placement` next to `anchor`, before
Expand All @@ -464,9 +463,8 @@ extension HoverCardPanel {
case .below: CGPoint(x: anchor.minX, y: up(anchor.minY - targetGap) - size.height)
// The card's bottom edge on the tab's top edge (rounded down: into the tab).
case .above: CGPoint(x: anchor.minX, y: down(anchor.maxY + targetGap))
// The card's leading edge one device pixel past the target's trailing edge, top-aligned: the
// sidebar row cards' gap (`HoverCardEscapeGeometry.edgeGap(scale:)`, Lawrence 2026-10-21 "horizontally
// only 1 pixel gap"), so the update card sits at the hover cards' gap (Lawrence 2026-10-19).
// The non-sidebar `.beside` card keeps its one-device-pixel breathing room. Sidebar row
// cards use `.besideRow` and touch the row's anchor instead.
case .beside: CGPoint(x: down(anchor.maxX + HoverCardEscapeGeometry.edgeGap(scale: scale)), y: anchor.maxY - size.height)
// Right of the row, its top on the row's top (before any clamp or flip the screen needs).
case .besideRow: HoverCardEscapeGeometry(anchor: anchor, cardSize: size, scale: scale, alignment: rowAlignment).card.origin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,16 @@ public import CoreGraphics
/// from row to row. Near the screen's bottom edge the visible frame clamps it up (it still covers
/// the row's side); near its right edge it goes left of the row.
///
/// Lawrence 2026-10-21: "horizontally only 1 pixel gap". The anchor is the row's band across the
/// sidebar column (`rowBand(row:column:)`), not the row's pill: the card's near edge is one device
/// pixel past the column's visible edge (`edgeGap(scale:)`), whatever the row's inset, trim or
/// nesting depth. Every sidebar card (a workspace row, a group header, a thin rail's mark) builds
/// its anchor with `rowBand` and places it here, so there is one rule for where the card goes.
/// Leo 2026-10-28: "the gap between the hover preview and ... the workspace sidebar element".
/// Workspace cards anchor on the drawn row's fill, before the resize/drag thumb. Group headers
/// and rail marks retain their column band (`rowBand(row:column:)`). The card touches its anchor.
///
/// ```
/// +---- sidebar column ----+ +--------------------+
/// | +-----------+ |1| | (the card's top on the row's top,
/// | | row | |px card | 1 device pixel off the column)
/// | +-----------+ | +--------------------+
/// +---- sidebar column ---------------------------+
/// | +----------------+--------------------+ |
/// | | row | card | | (the card's top on the row's top,
/// | +----------------+ | | touching the row's fill)
/// | +--------------------+ |
/// ```
///
/// The gap keeps the card only for a pointer heading into it (`HoverCardCoordinator`); one
Expand Down Expand Up @@ -63,11 +62,15 @@ public nonisolated struct HoverCardEscapeGeometry: Hashable, Sendable {
case middle
}

/// The room between the sidebar column's visible edge and a row's card, and between a target
/// and a `.beside` card (the update card, a pull request badge's card): one device pixel of
/// `scale` (Lawrence 2026-10-21: "horizontally only 1 pixel gap").
/// The room between a target and a `.beside` card (the update card or a pull request badge's
/// card): one device pixel of `scale`. Sidebar row cards use ``rowGap`` so they sit
/// directly against the row and its resize/drag edge.
public static func edgeGap(scale: CGFloat) -> CGFloat { 1 / max(scale, 1) }

/// Sidebar row cards sit directly against their anchor. Keeping this separate from
/// ``edgeGap(scale:)`` preserves the small breathing room used by non-sidebar `.beside` cards.
public static let rowGap: CGFloat = 0

/// A sidebar row's anchor for its card: the row's height across the whole sidebar column.
///
/// The card's top lines up with `row`'s top and its near edge with the column's visible edge,
Expand Down Expand Up @@ -103,23 +106,23 @@ public nonisolated struct HoverCardEscapeGeometry: Hashable, Sendable {
/// The alignment the sidebar uses now: top, or middle while the Debug Settings switch is on.
public static var currentAlignment: RowAlignment { escapeSlotTunable.value ? .middle : .top }

/// The card for `anchor` (a row's band across the sidebar, `rowBand(row:column:)`) at `size`,
/// The card for `anchor` (a workspace's fill, or a group/rail band) at `size`,
/// kept inside `visible` (the screen's visible frame, already inset by any margin) when given,
/// its near edge `gap` (one device pixel when nil, `edgeGap(scale:)`) off the anchor's edge on
/// its near edge `gap` (zero when nil, ``rowGap``) off the anchor's edge on
/// device pixels of `scale`, lined up with the row by `alignment`.
public init(anchor: CGRect, cardSize size: CGSize, visible: CGRect? = nil, scale: CGFloat = 2, gap: CGFloat? = nil,
alignment: RowAlignment = .top) {
let scale = max(scale, 1)
let gap = gap ?? Self.edgeGap(scale: scale)
func pixel(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale }
let gap = gap ?? Self.rowGap
func up(_ value: CGFloat) -> CGFloat { (value * scale).rounded(.up) / scale }
func down(_ value: CGFloat) -> CGFloat { (value * scale).rounded(.down) / scale }
self.anchor = anchor
let middle = down(anchor.midY)
// Right of the sidebar: its left edge `gap` past the edge's device pixel.
var x = pixel(anchor.maxX) + gap
// Snap toward the row so a fractional edge never leaves a visible seam.
var x = down(anchor.maxX) + gap
var leading = false
if let visible, x + size.width > visible.maxX, pixel(anchor.minX) - gap - size.width >= visible.minX {
x = pixel(anchor.minX) - gap - size.width
if let visible, x + size.width > visible.maxX, up(anchor.minX) - gap - size.width >= visible.minX {
x = up(anchor.minX) - gap - size.width
leading = true
}
var y: CGFloat
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ extension NSView {
/// `SidebarContainerView.visibleEdgeOutset`). A view in no sidebar container answers its own
/// frame. Nil out of a window.
///
/// A sidebar row's hover card stands one device pixel off this edge
/// (`HoverCardEscapeGeometry.rowBand(row:column:)`, Lawrence 2026-10-21: "horizontally only
/// 1 pixel gap"). Every sidebar surface with row cards (the list, a thin rail) reads it here,
/// so the cards all line up on one edge.
/// Group and rail hover cards use this column band (`HoverCardEscapeGeometry.rowBand(row:column:)`).
/// Workspace previews use their drawn row's fill instead, before the resize/drag edge.
public var sidebarColumnOnScreen: CGRect? {
guard let window else { return nil }
var container: SidebarContainerView?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ extension SidebarContainerView {

/// How far past the container's content-side edge the sidebar shows: the outer half of the
/// border line when it shows at rest (the resize handle centers it on the edge), else 0.
/// Hover cards beside a row stand one device pixel past this (`sidebarColumnOnScreen`).
/// Hover cards beside a row touch this edge (`sidebarColumnOnScreen`).
var visibleEdgeOutset: CGFloat {
guard !handle.isHiddenOrHasHiddenAncestor, handle.restingLineWidth > 0 else { return 0 }
return handle.lineWidth / 2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,12 @@ extension SidebarListViewComponents {
guard let pinned = blockItemView(for: id) else { return nil }
return window.convertToScreen(convert(convert(pinned.bounds, from: pinned), to: nil))
}
/// Where a workspace row's hover card goes: the row's band across the sidebar column
/// (`HoverCardEscapeGeometry.rowBand`), so the card's top is the row's top and its near edge
/// one device pixel off the sidebar's visible edge. Nil when the row is not laid out.
/// A workspace preview touches the row's fill, before the sidebar's resize/drag thumb.
/// Pinned items use their drawn pill rather than their wider layout cell.
func hoverCardBand(for id: WorkspaceID) -> CGRect? {
hoverCardAnchor(for: id).flatMap(hoverCardBand(row:))
if displayed.row(for: .workspace(id)) != nil { return hoverCardAnchor(for: id) }
guard let window, let pinned = blockItemView(for: id) else { return nil }
return window.convertToScreen(pinned.convert(pinned.pill.frame, to: nil))
}
/// A group header's band across the sidebar column, as `hoverCardBand(for:)`.
func hoverCardBand(forGroup id: GroupID) -> CGRect? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public import CmuxNextDesign
/// Cmd-digit), anchored to the mark's row. It is a source of the app's one `HoverCardCoordinator`,
/// as the sidebar list's rows are, with the list's one anchor rule: the mark's row as a band
/// across the sidebar column (`HoverCardEscapeGeometry.rowBand`, `sidebarColumnOnScreen`), placement
/// `.besideRow`, so the card's top is the row's top and it stands one device pixel past the rail. Rows
/// `.besideRow`, so the card's top is the row's top and it touches the rail. Rows
/// of a group touch, and the gaps between groups bridge, so moving from mark to mark retargets
/// the one card instead of hiding and showing it.
@MainActor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ final class WorkspaceHoverCardController: HoverCardSource {
/// leaving the row and the card fades it out at once, as a tab card does. A gap or header
/// between rows (`hoverCardBridges`) holds it for the reshow window, so the next row slides
/// it over instead of hiding and reshowing it. Leo 2026-10-21: the card's top lines up with
/// its row's top, one device pixel off the sidebar's edge (`HoverCardPlacement.besideRow`).
/// its row's top, touching the row's fill (`HoverCardPlacement.besideRow`).
var leave: HoverCardLeave { .hides(reshow: HoverCardTiming.reshowWindow) }
weak var list: SidebarListView?
/// The app's one coordinator; the App injects it.
Expand Down Expand Up @@ -115,14 +115,14 @@ final class WorkspaceHoverCardController: HoverCardSource {
}

func hoverCardAnchor(for id: HoverTargetID) -> CGRect? {
// The row's band across the sidebar: the card's top on the row's, one pixel off the edge.
// Workspace fill or group band: the card touches its anchor, its top on the row's top.
if let group = groupID(id) { return list?.hoverCardBand(forGroup: group) }
return workspaceID(id).flatMap { list?.hoverCardBand(for: $0) }
}

func hoverCardTargetFrame(for id: HoverTargetID) -> CGRect? {
if let group = groupID(id) { return list?.hoverCardAnchor(forGroup: group) }
return workspaceID(id).flatMap { list?.hoverCardAnchor(for: $0) }
return workspaceID(id).flatMap { list?.hoverCardBand(for: $0) }
}

/// The pointer is on a row's pull request badge, whose own card (`SidebarPullRequestCard`)
Expand Down Expand Up @@ -162,7 +162,7 @@ final class WorkspaceHoverCardController: HoverCardSource {
body.configure(Self.groupSummary(item, workspaces: list.subtreeWorkspaces(of: group),
options: DesignSettings.shared.groupHoverCard, now: Date()))
bodyID = id
// One rule for sidebar cards: beside the sidebar, its top on the header's, one pixel off the edge.
// One rule for sidebar cards: beside the sidebar, its top on the header's, touching the edge.
return HoverCardBody(view: body, placement: .besideRow, themeAnchor: list) { [weak body] in body?.applyColors() }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ import Testing
#expect(abs(anchor.minY - onScreen.minY) < 0.5 && abs(anchor.maxY - onScreen.maxY) < 0.5, "\(anchor) vs \(onScreen)")
let band = try #require(harness.list.hoverCardBand(for: Self.pinnedID))
#expect(abs(band.maxY - onScreen.maxY) < 0.5)
let pill = harness.window.convertToScreen(row.convert(row.pill.frame, to: nil))
#expect(band == pill, "a pinned preview must touch its fill rather than its layout cell")
let target = WorkspaceHoverCardController.targetID(Self.pinnedID)
#expect(harness.list.hoverCard.hoverCardTargetFrame(for: target) == band,
"placement and pointer escape must use the same pinned fill")
}

/// The list row of the other workspace still opens its own card (the pinned lookup takes nothing from it).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import CmuxNextWakeupsTestSupport

/// A sidebar workspace's hover card in the real list. Leo 2026-10-21 ("WHY HAS IT NOT BEEN
/// ALIGNED"): the card's top lines up with its row's top, as Chrome's and Arc's tab cards, and
/// follows the row from row to row; it stands one device pixel off the sidebar's edge. The escape slot beside the
/// follows the row from row to row; it touches the sidebar's edge. The escape slot beside the
/// row's upper half (#22473, Leo 2026-10-20 "trapped by the preview") is a Debug Settings layout
/// now, covered by `HoverCardEscapeGeometryTests`. A pointer heading into the card keeps it, one
/// going down past the row lets it go, and leaving both the row and the card dismisses it.
Expand Down Expand Up @@ -84,18 +84,17 @@ import CmuxNextWakeupsTestSupport
try await WorkspaceHoverCardScrollTests.tearDown(window, coordinator)
}

/// Lawrence 2026-10-21: "horizontally only 1 pixel gap". The card's near edge is one device
/// pixel past the sidebar's edge (here the list fills its window), not a gap off the row's
/// pill, which the row's inset and trim moved.
@Test func theCardIsOnePixelOffTheSidebarsEdge() async throws {
/// The row ends before the sidebar's drag thumb. Its preview touches the row, not the thumb.
@Test func theCardTouchesItsWorkspaceRowBeforeTheDragThumb() async throws {
let clock = ManualClock()
let (list, window, coordinator, rows) = Self.sidebar(clock: clock)
let (row, card) = try await Self.showSecondRowCard(list, coordinator, clock, rows)
let edge = window.convertToScreen(list.convert(list.bounds, to: nil)).maxX
let pixel = 1 / max(window.backingScaleFactor, 1)
#expect(abs(card.minX - (edge + pixel)) < 0.01,
"the card's left edge is \(card.minX - edge) pt from the sidebar's edge, not one pixel (\(pixel) pt); row ends at \(row.maxX)")
#expect(card.maxX > edge)
#expect(row.maxX < edge, "the fixture needs a row-to-thumb gutter")
#expect(card.minX <= row.maxX + 0.001, "a \(card.minX - row.maxX) pt gap from row to preview")
#expect(row.maxX - card.minX < pixel + 0.001, "overlap must stay below one device pixel")
#expect(card.minX < edge, "the preview was pushed past the drag thumb")
try await WorkspaceHoverCardScrollTests.tearDown(window, coordinator)
}

Expand All @@ -104,10 +103,14 @@ import CmuxNextWakeupsTestSupport
@Test func movingDownPastTheRowsRightEndDismissesTheCard() async throws {
let clock = ManualClock()
let (list, window, coordinator, rows) = Self.sidebar(clock: clock)
let (row, _) = try await Self.showSecondRowCard(list, coordinator, clock, rows)
let (row, card) = try await Self.showSecondRowCard(list, coordinator, clock, rows)
coordinator.pointerMoved(to: CGPoint(x: row.maxX - 2, y: row.minY + 3))
let shown = try #require(coordinator.machine.shownTarget, "the card went on the row itself")
coordinator.pointerMoved(to: CGPoint(x: row.maxX + 3, y: row.minY - 14))
// Stay on the list side of the row's edge: with a flush preview, a point to its right
// is on the preview itself and correctly keeps it open.
let below = CGPoint(x: row.maxX - 2, y: row.minY - 14)
try #require(!card.contains(below), "the dismissal path must not enter the preview")
coordinator.pointerMoved(to: below)
// Gone, or handed to the row below (the sidebar's margin can still hit-test as that row).
#expect(coordinator.machine.shownTarget?.id != shown.id,
"the card stayed (\(coordinator.machine.phase)) with the pointer below its row")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import Testing

/// The rail's hover card (`sidebar.style` = rail). Lawrence 2026-10-21: "im talking about the
/// floating card... floating card relative to the workspace row". Pointing at a mark shows one
/// small card for that workspace, its top on the mark's row's top, exactly one device pixel right
/// of the rail, following the pointer row to row, inside the window. Here: the rows the card is
/// small card for that workspace, its top on the mark's row's top, flush with the rail,
/// following the pointer row to row, inside the window. Here: the rows the card is
/// anchored to, where the list rows' one anchor rule (`HoverCardEscapeGeometry.rowBand`, placement
/// `.besideRow`) puts the card for them, and the toggle.
@MainActor
Expand Down Expand Up @@ -44,14 +44,14 @@ struct SidebarRailHoverCardTests {
return (row, geometry.card)
}

@Test func aMarksCardTopIsItsRowsTopAndOnePixelPastTheRailAtTopMiddleAndBottom() {
@Test func aMarksCardTopIsItsRowsTopAndFlushWithTheRailAtTopMiddleAndBottom() {
let layout = Self.layout()
let rail = SidebarRailLayout.Metrics().railWidth
for index in [0, 20, 39] {
let (_, card) = Self.card(layout, index)
// The row's top is where the mark's row starts (`rowY`), not the 2 pt tick's top.
#expect(card.maxY == 700 - layout.ticks[index].rowY, "row \(index): the card's top is the mark's row's top")
#expect(card.minX == rail + 0.5, "row \(index): one device pixel past the rail")
#expect(card.minX == rail, "row \(index): card is not flush with the rail")
}
}

Expand Down
Loading
Loading