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 @@ -134,7 +134,7 @@ public nonisolated struct SidebarRegionLayout: Hashable, Sendable {
// A tiles section sits on its own card in every look: the tonal
// step, plus a section gap below it, sets it apart from the list.
let tiled = if case .tiles = SectionFlow.mode(section, look: look) { true } else { false }
let carded = look == .card || tiled
let carded = isCarded(section, look: look)
let x = carded ? m.inset : 0
let innerWidth = max(0, width - x * 2)
let top = y
Expand Down Expand Up @@ -162,7 +162,7 @@ public nonisolated struct SidebarRegionLayout: Hashable, Sendable {
} else {
for item in section.items {
result.rows.append(SidebarRegionRow(kind: .item(item.id, section: section.id),
frame: CGRect(x: x, y: y, width: max(0, innerWidth - (carded ? 0 : m.trailingTrim)),
frame: CGRect(x: x, y: y, width: max(0, innerWidth - itemTrailingTrim(section, look: look, metrics: m)),
height: m.rowHeight)))
y += m.rowHeight
}
Expand Down Expand Up @@ -192,6 +192,19 @@ public nonisolated struct SidebarRegionLayout: Hashable, Sendable {

/// An empty titled section's title: it draws it in every look, so the section stays in sight and takes a drop
/// there (an empty section with no title takes no space).
/// A tiles section sits on its own card in every look, and every section does in the card look.
static func isCarded(_ section: LayoutSection, look: SectionsLookVariant) -> Bool {
if look == .card { return true }
if case .tiles = SectionFlow.mode(section, look: look) { return true }
return false
}

/// How far `section`'s list rows end short of its header's right edge: `metrics.trailingTrim`, none on a card (its
/// rows keep the card's width). The header centers its chevron on its rows' trailing column with it.
static func itemTrailingTrim(_ section: LayoutSection, look: SectionsLookVariant, metrics: SidebarRegionMetrics) -> CGFloat {
isCarded(section, look: look) ? 0 : metrics.trailingTrim
}

static func emptyTitle(_ section: LayoutSection) -> String? {
section.content == .items && section.items.isEmpty ? section.headerTitle : nil
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -404,13 +404,15 @@ final class GroupHeaderRowView: SidebarRowView {
}
let glyphSide = min(SidebarStyle.iconBox, barHeight)
glyph.isHidden = !hasIcon
// A pinned group's pin leads the bar, its ink at the rows' leading inset, and its icon and name take the one
// text column after it, as a pinned workspace row's do (Lawrence 2026-10-28, "home and pinned are not aligned").
// A pinned group's pin leads the bar, its ink centered on the rows' icon column, and its icon and name take the
// one text column after it, as a pinned workspace row's do (Lawrence 2026-10-28, "home and pinned are not
// aligned"; Leo 2026-10-28, "the alignment").
let pinSide = Metrics.smallIconSize
pin.isHidden = !pinned
let pinInk = pin.image.flatMap { SidebarGlyphInk.shared.box(of: $0, glyph: SidebarItemRowView.pinGlyph) }
let scale = window?.backingScaleFactor ?? 2
pin.frame = NSRect(x: ((lead - (pinInk?.minX ?? 0)) * scale).rounded() / scale, y: (b.height - pinSide) / 2, width: pinSide, height: pinSide)
let pinX = SidebarStyle.iconColumnCenterX(leading: lead) - (pinInk?.midX ?? pinSide / 2)
pin.frame = NSRect(x: (pinX * scale).rounded() / scale, y: (b.height - pinSide) / 2, width: pinSide, height: pinSide)
let iconLead = pinned ? SidebarStyle.leadingTextX(leading: lead) : lead
glyph.frame = NSRect(x: iconLead, y: (b.height - glyphSide) / 2, width: glyphSide, height: glyphSide)
// The label's frame sits its text inset left of the column, as a workspace title's does.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,10 @@ final class SectionHeaderRowView: SidebarRowView {
// making the header reflow when the pointer arrives.
chevron.isHidden = isEmpty
let chevronSide = Metrics.smallIconSize
chevron.frame = NSRect(x: trailing - chevronSide, y: (b.height - chevronSide) / 2, width: chevronSide, height: chevronSide)
// Without the add button the chevron takes the trailing column, as a group header's does.
chevron.frame = allowsAdd ? NSRect(x: trailing - chevronSide, y: (b.height - chevronSide) / 2, width: chevronSide, height: chevronSide)
: SidebarStyle.trailingColumnFrame(rowWidth: b.width, centerY: b.midY, side: chevronSide)
if !allowsAdd { trailing = SidebarStyle.trailingColumnStart(rowWidth: b.width) }
trailing -= chevronSide + Metrics.space2
let nw = min(ceil(name.attributedStringValue.size().width) + Metrics.space2, max(0, trailing - nameX - Metrics.space5))
let nh = ceil(name.intrinsicContentSize.height)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,9 +273,9 @@ class SidebarItemRowView: NSView {
icon.frame = frame
} else {
icon.image = glyphImage(side: glyphSide)
// A bare glyph beside a title (Home, the band's rows) starts its ink at the row's leading inset, as the pin
// does; a glyph in a well or a tile centers in it.
icon.frame = style == .builtIn && !isRailButton ? inkLeadingGlyphFrame(side: glyphSide, leading: iconFrame.minX, centeredIn: iconFrame)
// A bare glyph beside a title (Home, the band's rows) centers its ink on the icon column, as the pin and a
// workspace row's icon do (Leo 2026-10-28: "the alignment"); a glyph in a well or a tile centers its square.
icon.frame = style == .builtIn && !isRailButton ? inkCenteredGlyphFrame(side: glyphSide, centeredIn: iconFrame)
: alignedGlyphFrame(side: glyphSide, centeredIn: iconFrame)
}
title.font = SidebarStyle.titleFont
Expand Down Expand Up @@ -358,16 +358,18 @@ class SidebarItemRowView: NSView {
/// The cache key of the pin's ink (`SidebarGlyphInk`).
static let pinGlyph = "pin|\(IconName.statePinned.rawValue)"

/// The leading pin's frame: its drawn ink, not its square, starts at `leading` (where an unpinned workspace
/// row's icon or title starts) and centers on the row, on the device pixel grid. The pin's square leaves a
/// margin on each side of its ink, so the square at the rows' inset drew the pin in from them (Leo 2026-10-21).
/// The leading pin's frame: its drawn ink, not its square, centers on the icon column of a slot starting at
/// `leading` (where an unpinned workspace row's icon starts, `SidebarStyle.iconColumnCenterX`) and on the row,
/// on the device pixel grid. The pin's square leaves a margin around its ink (Leo 2026-10-21), and its ink is
/// narrower than a row icon, so ink-leading drew it left of the icons' center (Leo 2026-10-28: "the alignment").
private func pinGlyphFrame(side: CGFloat, leading: CGFloat, height: CGFloat) -> NSRect {
let scale = window?.backingScaleFactor ?? 2
let snap = { (value: CGFloat) in (value * scale).rounded() / scale }
let column = SidebarStyle.iconColumnCenterX(leading: leading)
guard let image = pin.image, let ink = SidebarGlyphInk.shared.box(of: image, glyph: Self.pinGlyph) else {
return NSRect(x: leading, y: snap((height - side) / 2), width: side, height: side)
return NSRect(x: snap(column - side / 2), y: snap((height - side) / 2), width: side, height: side)
}
return NSRect(x: snap(leading - ink.minX), y: snap(height / 2 - ink.midY), width: side, height: side)
return NSRect(x: snap(column - ink.midX), y: snap(height / 2 - ink.midY), width: side, height: side)
}

/// The pin's drawn ink in the row's coordinates; nil without a pin (tests).
Expand Down Expand Up @@ -451,13 +453,15 @@ class SidebarItemRowView: NSView {
return NSRect(x: snap(box.midX - side / 2), y: snap(box.midY - side / 2), width: side, height: side)
}

/// A `side` square whose drawn ink, not its square, starts at `leading`, centered on `box` vertically, on the device
/// pixel grid. Falls back to centering in `box` when the glyph draws nothing measurable.
private func inkLeadingGlyphFrame(side: CGFloat, leading: CGFloat, centeredIn box: NSRect) -> NSRect {
/// A `side` square whose drawn ink, not its square, centers in `box` on both axes, on the device pixel grid: a
/// glyph with uneven margins still lands on the icon column and its title's center line. Falls back to centering
/// the square when the glyph draws nothing measurable.
private func inkCenteredGlyphFrame(side: CGFloat, centeredIn box: NSRect) -> NSRect {
let centered = alignedGlyphFrame(side: side, centeredIn: box)
guard let image = icon.image, let ink = SidebarGlyphInk.shared.box(of: image, glyph: glyphKey) else { return centered }
let scale = window?.backingScaleFactor ?? 2
return NSRect(x: ((leading - ink.minX) * scale).rounded() / scale, y: centered.minY, width: side, height: side)
let snap = { (value: CGFloat) in (value * scale).rounded() / scale }
return NSRect(x: snap(box.midX - ink.midX), y: snap(box.midY - ink.midY), width: side, height: side)
}

/// The glyph drawn now (tests).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ final class SidebarRegionView: NSView {
let new = headerViews[id] == nil
let view = headerViews[id] ?? makeHeader(id)
view.configure(title: section.title ?? "", collapsed: content.collapsed.contains(id))
view.trailingTrim = SidebarRegionLayout.itemTrailingTrim(section, look: content.look, metrics: content.metrics)
view.setAccessibilityIdentifier("cmux.sidebar.region.\(id.rawValue)")
place(view, row.frame, new: new)
case let .app(id):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ final class SidebarSectionHeaderView: NSView {
private let name = ChromeLabel(labelWithString: "")
let chevron = DecorativeImageView() // AXExpanded says what it draws
private var collapsed = false
/// How far the section's item rows end past this header (`SidebarRegionMetrics.trailingTrim`): the chevron
/// centers on the items' trailing column, not on its own frame's.
var trailingTrim: CGFloat = 0 { didSet { if trailingTrim != oldValue { needsLayout = true } } }
/// Set by the hover owner (`PointerHover`, cx-3wu5).
var isHovered = false { didSet { if isHovered != oldValue { updateChevron(); needsDisplay = true } } }
private var pointerHover: PointerHover?
Expand Down Expand Up @@ -78,7 +81,10 @@ final class SidebarSectionHeaderView: NSView {
name.font = SidebarStyle.headerFont
let th = ceil(name.intrinsicContentSize.height)
let chevronSide = Metrics.smallIconSize
chevron.frame = NSRect(x: b.width - inset - chevronSide, y: (b.height - chevronSide) / 2, width: chevronSide, height: chevronSide)
// Its chevron centers on the trailing column its item rows' marks share (Leo 2026-10-28: "the alignment"): an
// item's fill is inset by `horizontalInset` in a frame `trailingTrim` wider than this header's.
let rowWidth = b.width - trailingTrim - SidebarStyle.horizontalInset
chevron.frame = SidebarStyle.trailingColumnFrame(rowWidth: rowWidth, centerY: b.midY, side: chevronSide)
// The label cell draws inside its frame: the text starts at the inset, with workspace titles.
let nameX = inset - SidebarStyle.labelCellPadding
name.frame = NSRect(x: nameX, y: (b.height - th) / 2, width: max(0, chevron.frame.minX - Metrics.space2 - nameX), height: th)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,11 @@ final class SidebarChatHistoryHeader: NSView {
override func layout() {
super.layout()
let side = Metrics.smallIconSize
chevron.frame = NSRect(x: bounds.width - Metrics.space2 - side, y: (bounds.height - side) / 2, width: side, height: side)
// On the sidebar's trailing column, as every row's mark and header chevron (Leo 2026-10-28: "the alignment").
chevron.frame = SidebarStyle.trailingColumnFrame(rowWidth: bounds.width, centerY: bounds.midY, side: side)
let h = ceil(name.intrinsicContentSize.height)
let x = SidebarStyle.titleLeading - SidebarStyle.labelCellPadding
let trailing = foldable ? chevron.frame.minX - Metrics.space2 : bounds.width - Metrics.space2
let trailing = foldable ? SidebarStyle.trailingColumnStart(rowWidth: bounds.width) - Metrics.space2 : bounds.width - Metrics.space2
name.frame = NSRect(x: x, y: (bounds.height - h) / 2, width: max(0, trailing - x), height: h)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ enum SidebarStyle {
static let placeholderFractions: [CGFloat] = [0.72, 0.5, 0.62]
/// Icon frame; the glyph inside is `kindGlyphSize`.
static var iconBox: CGFloat { Metrics.smallIconSize + Metrics.space2 }
/// The one leading icon column (Leo 2026-10-28: "the alignment"): in a row whose leading slot starts at `leading`,
/// every leading glyph (a band row's, a pinned row's pin, a workspace row's icon) centers its ink here, one
/// `iconBox` wide, so glyphs of different shapes and margins share one center x at every density.
static func iconColumnCenterX(leading: CGFloat) -> CGFloat { leading + iconBox / 2 }
/// A workspace row's leading glyph: the size an icon takes beside the
/// row title (cap height and stroke matched to the text).
static var kindGlyphSize: CGFloat { .iconRowSize(forLabelPointSize: titleFont.pointSize) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ final class StatusHeaderRowView: SidebarRowView {
let namePadding = name.naturalSize.width - name.intrinsicContentSize.width
count.frame = NSRect(x: name.frame.maxX - namePadding + Metrics.space1, y: name.frame.minY,
width: ceil(count.naturalSize.width), height: h)
chevron.frame = NSRect(x: b.maxX - Metrics.space2 - side, y: (b.height - side) / 2, width: side, height: side)
// On the trailing column every row's mark and the group header's chevron share (Leo 2026-10-28: "the alignment").
chevron.frame = SidebarStyle.trailingColumnFrame(rowWidth: b.width, centerY: b.midY, side: side).offsetBy(dx: b.minX, dy: 0)
needsDisplay = true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import Testing

/// Lawrence 2026-10-28 ("home and pinned are not aligned", screenshot): Home's house glyph and label
/// started at other x positions than the pinned rows' pins and titles, and neither lined up with the
/// workspace rows below. Every row's first ink (Home's glyph, a pin, a text-only row's title) starts at
/// one leading inset, and a title after a leading glyph (Home's, a pinned row's, a workspace row with
/// workspace rows below. Every row's leading glyph (Home's, a pin) sits in the one icon slot that starts
/// where a text-only row's title does, and a title after a leading glyph (Home's, a pinned row's, a workspace row with
/// an icon) starts at one text column: `SidebarStyle.leadingTextX`, the rule workspace rows already used.
@MainActor @Suite(.serialized) struct HomePinnedRowAlignmentTests {
nonisolated struct Case: CustomTestStringConvertible, Sendable {
Expand Down Expand Up @@ -77,20 +77,23 @@ import Testing
list.convert(NSPoint(x: value, y: 0), from: view).x
}

/// Home's glyph, the pin and a text-only row's title start at one leading inset.
/// Home's glyph and the pin sit in the one icon slot that starts where a text-only row's title starts, centered
/// on its column (`SidebarIconColumnTests` checks the centers; Leo 2026-10-28: "the alignment").
@Test(arguments: cases)
func everyRowsFirstInkStartsAtOneInset(_ c: Case) throws {
func everyRowsLeadingGlyphSitsInTheIconSlot(_ c: Case) throws {
try SidebarGroupRailAlignmentTests.with(c.density, c.minimal) {
let (list, window) = Self.list(width: c.width)
defer { window.close() }
let home = try Self.item(list, Self.home.id), pin = try Self.item(list, Self.pinned.id)
let loose = try Self.row(list, Self.loose)
let textX = Self.x(list, loose.title.frame.minX, in: loose)
let homeInk = Self.x(list, try #require(home.glyphInkFrame, "Home drew no glyph").minX, in: home)
let pinInk = Self.x(list, try #require(pin.pinInkFrame, "the pinned row drew no pin").minX, in: pin)
#expect(abs(homeInk - textX) <= Self.tolerance, "\(c.testDescription): Home's glyph at \(homeInk), a row's text at \(textX)")
#expect(abs(pinInk - textX) <= Self.tolerance, "\(c.testDescription): the pin at \(pinInk), a row's text at \(textX)")
#expect(abs(homeInk - pinInk) <= Self.tolerance, "\(c.testDescription): Home's glyph at \(homeInk), the pin at \(pinInk)")
let slot = textX...(textX + SidebarStyle.iconBox)
let homeInk = try #require(home.glyphInkFrame, "Home drew no glyph"), pinInk = try #require(pin.pinInkFrame, "the pinned row drew no pin")
for (name, ink, row) in [("Home's glyph", homeInk, home), ("the pin", pinInk, pin)] {
let minX = Self.x(list, ink.minX, in: row), maxX = Self.x(list, ink.maxX, in: row)
#expect(minX >= slot.lowerBound - Self.tolerance && maxX <= slot.upperBound + Self.tolerance,
"\(c.testDescription): \(name) spans \(minX)...\(maxX), the icon slot \(slot)")
}
}
}

Expand Down
Loading
Loading