From bbace2d334af5414db4ec4947977779bb392372f Mon Sep 17 00:00:00 2001 From: DaxxSec Date: Wed, 13 May 2026 11:36:29 -0600 Subject: [PATCH 1/2] fix(ui): three layout regressions from the typography pass merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three visible regressions that the user reported after #15+#18 landed: 1. Sidebar section titles truncating ("ERATING SYSTEM", "TWORK", "GS") ================================================================== `TacticalSidebarSection.rebuild()` runs inside `init` before the caller has set the section's frame. It read `bounds.width` (=0 at that moment) and gave the title label / row views `width: bounds.width - inset * 2` = **-24pt**. The autoresizing mask (`.width`) compensates when the parent's bounds change, but it adds delta only — a section that grows from `bounds.width = 0` to `220` produces a title width of `-24 + 220 = 196`. That should be visible end-to-end, but NSTextField that's first sized with a negative-width frame caches its rendering metrics and the title gets visually left-truncated by ~inset pixels on every paint after. Fix: defer layout. `rebuild` now adds the subviews without sizing them; a new `relayoutContents()` runs from both `rebuild` (so the initial paint has a valid frame once the caller assigns one) AND from `layout()` (Cocoa hook fired whenever the section's bounds resolve). Layout constants moved to type-level statics so the two call sites share them. 2. VM table rows squished to ~20pt — multi-line card not showing ================================================================ The XIB hardcodes `tableView rowHeight="20"`. `switchTableToCardMode` sets `tableView.rowHeight = 62`, which SHOULD be authoritative — but when the XIB latches the lower value before the awakeFromNib call that runs the swap, NSTableView's first layout pass uses the latched 20pt and the card cell renders as a single squished line with row-3 chips spilling into row-1's name area. Fix: implement `tableView(_:heightOfRow:)` returning `VMCardCellView.rowHeight`. The delegate is queried on every row draw, so the value can't be stale or pre-empted by the XIB. 3. Detail-card status pill drifts off-center inside its 80pt bounds ==================================================================== The pill is an NSTextField with `alignment = .center`, `isBordered = false`, layer-backed background. On some macOS revisions the plain `alignment` property doesn't fully center text inside a borderless layer-backed label — the text drifts leftward, producing the dead-space halo on the right that appears as "the pill is spaced wrong". Fix: use `attributedStringValue` with an explicit centered NSMutableParagraphStyle. The paragraph-style centering bypasses NSTextField's plain alignment quirk and works deterministically. No behavior changes — these are all visual/positioning fixes. Full test suite still 292/292 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- SecVF/TacticalSidebarSection.swift | 70 +++++++++++++++++---------- SecVF/VMLibraryWindowController.swift | 34 +++++++++++-- 2 files changed, 76 insertions(+), 28 deletions(-) diff --git a/SecVF/TacticalSidebarSection.swift b/SecVF/TacticalSidebarSection.swift index 3c594a1..5a0edfd 100644 --- a/SecVF/TacticalSidebarSection.swift +++ b/SecVF/TacticalSidebarSection.swift @@ -56,7 +56,6 @@ final class TacticalSidebarSection: NSView { super.init(frame: .zero) - addSubview(titleLabel) rebuild(rows: rows) } @@ -64,6 +63,14 @@ final class TacticalSidebarSection: NSView { fatalError("Use init(title:rows:)") } + // Layout constants captured so `layout()` can re-flow without a + // second `rebuild` call when the frame finally resolves. + private static let labelH: CGFloat = 14 + private static let rowH: CGFloat = 26 + private static let rowGap: CGFloat = 2 + private static let belowLabelGap: CGFloat = 6 + private static let inset: CGFloat = 12 + /// Replace the row set in place. Called when counts update (which /// happens on every VM list reload — see refreshCounts(rows:)). /// Frame is recomputed; caller is responsible for re-laying out @@ -73,36 +80,24 @@ final class TacticalSidebarSection: NSView { rowViews.values.forEach { $0.removeFromSuperview() } rowViews.removeAll() - let labelH: CGFloat = 14 - let rowH: CGFloat = 26 - let rowGap: CGFloat = 2 - let belowLabelGap: CGFloat = 6 - let inset: CGFloat = 12 - - // Lay out from top (y=0 is bottom in standard AppKit coords; - // but this view's frame's origin will be positioned by the - // caller, so we build assuming top-down placement and then - // size ourselves accordingly). - let totalRowsHeight = CGFloat(rows.count) * rowH + CGFloat(max(0, rows.count - 1)) * rowGap - let total = labelH + belowLabelGap + totalRowsHeight + let totalRowsHeight = CGFloat(rows.count) * Self.rowH + + CGFloat(max(0, rows.count - 1)) * Self.rowGap + let total = Self.labelH + Self.belowLabelGap + totalRowsHeight _intrinsicHeight = total - // Width follows the parent — we'll resize via autoresizing. autoresizingMask = [.width] - // Title sits at the top of our bounds - titleLabel.frame = NSRect(x: inset, y: total - labelH, - width: bounds.width - inset * 2, - height: labelH) + // Build views without trying to size them yet — `layoutSubviews` + // (called once the section's frame is set by the controller) + // gives them their final widths based on the section's real + // bounds. This avoids the "bounds.width is 0 during init → + // sub-views get width = -24" trap that previously truncated + // section titles to "ERATING SYSTEM" / "TWORK" / "GS". titleLabel.autoresizingMask = [.width, .minYMargin] + addSubview(titleLabel) - // Rows below, top-down - var y = total - labelH - belowLabelGap - rowH for row in rows { - let view = SidebarRowView(row: row, height: rowH) - view.frame = NSRect(x: inset, y: y, - width: bounds.width - inset * 2, - height: rowH) + let view = SidebarRowView(row: row, height: Self.rowH) view.autoresizingMask = [.width, .minYMargin] view.onTap = { [weak self] id in guard let self = self else { return } @@ -113,10 +108,35 @@ final class TacticalSidebarSection: NSView { } addSubview(view) rowViews[row.id] = view - y -= (rowH + rowGap) } refreshSelectionStyling() + relayoutContents() + } + + /// Position the title + rows based on the section's CURRENT bounds. + /// Called from `layout()` (Cocoa hook fired after frame resolves + /// from the parent) and from `rebuild` so initial paint is right. + private func relayoutContents() { + let usableW = max(0, bounds.width - Self.inset * 2) + let total = _intrinsicHeight + + titleLabel.frame = NSRect(x: Self.inset, y: total - Self.labelH, + width: usableW, height: Self.labelH) + + // Rows below, top-down. Iterate in the same order rebuild built + // them so the visual order matches the data order. + var y = total - Self.labelH - Self.belowLabelGap - Self.rowH + for subview in subviews { + guard let row = subview as? SidebarRowView else { continue } + row.frame = NSRect(x: Self.inset, y: y, width: usableW, height: Self.rowH) + y -= (Self.rowH + Self.rowGap) + } + } + + override func layout() { + super.layout() + relayoutContents() } /// Update just the count badges + (optionally) the selection state diff --git a/SecVF/VMLibraryWindowController.swift b/SecVF/VMLibraryWindowController.swift index 0157375..9f95faf 100644 --- a/SecVF/VMLibraryWindowController.swift +++ b/SecVF/VMLibraryWindowController.swift @@ -1829,7 +1829,13 @@ class VMLibraryWindowController: NSWindowController, detailNameLabel?.stringValue = vm.name detailNameLabel?.textColor = AppColors.textPrimary - // Status pill (uses VMStatus from VMConfiguration) + // Status pill (uses VMStatus from VMConfiguration). Setting the + // text via attributedStringValue with an explicit centered + // paragraph style — NSTextField's plain `alignment = .center` + // can drift inside a layer-backed, border-less label depending + // on the macOS revision, so the explicit paragraph-style + // centering guarantees the dot + text sit center-aligned + // inside the 80pt pill regardless. let (pillText, pillColor): (String, NSColor) = { switch vm.status { case .running: return ("● RUNNING", AppColors.statusRunning) @@ -1838,8 +1844,16 @@ class VMLibraryWindowController: NSWindowController, case .stopped: return ("○ STOPPED", AppColors.statusStopped) } }() - detailStatusPill?.stringValue = pillText - detailStatusPill?.textColor = pillColor + let pillPara = NSMutableParagraphStyle() + pillPara.alignment = .center + detailStatusPill?.attributedStringValue = NSAttributedString( + string: pillText, + attributes: [ + .foregroundColor: pillColor, + .font: NSFont.monospacedSystemFont( + ofSize: LayoutConstants.fontSizeCaption, weight: .semibold), + .paragraphStyle: pillPara, + ]) detailStatusPill?.layer?.backgroundColor = pillColor.withAlphaComponent(0.12).cgColor detailStatusPill?.layer?.borderColor = pillColor.withAlphaComponent(0.45).cgColor @@ -4154,6 +4168,20 @@ class VMLibraryWindowController: NSWindowController, return row } + /// Explicit row height delegate. `tableView.rowHeight = 62` set + /// inside `switchTableToCardMode` should be authoritative, but + /// returning the value through the delegate too defends against + /// "the XIB's rowHeight=20 latched before our card-mode swap" — a + /// regression that surfaced visually as cards rendering as a + /// single squished 20pt line with the row-3 chips spilling into + /// the row-1 name area. heightOfRow:_ is queried on every reload + /// so the value can't be stale. + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + // AI Sandbox outline view uses its own delegate-less row sizing; + // this method only fires for the main VM table. + return VMCardCellView.rowHeight + } + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { // Card column path: switchTableToCardMode collapsed the per-field // columns down to a single full-width "VMCardColumn" hosting a From 703d6f32fd899795a837cba47ea8530071ceef6a Mon Sep 17 00:00:00 2001 From: DaxxSec Date: Wed, 13 May 2026 11:38:40 -0600 Subject: [PATCH 2/2] fix(ui): the VM-card row pill (the one user actually meant) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User clarified the "pill is spaced wrong" report — they meant the status pill inside the VM-card row (e.g. "● RUNNING" / "○ STOPPED" on the right edge of row 1), not the detail card pill at the bottom of the window. My previous commit on this PR fixed the wrong pill. Same root cause + fix: NSTextField's plain `alignment = .center` drifts leftward on layer-backed borderless labels. Apply explicit centered NSMutableParagraphStyle via attributedStringValue on the VMCardCellView statusPill (running-state path) and the AI Sandbox TEMPLATE/SESSION pill (sandbox-bundle path). The previous fix to the detail card's makeStatusPill stays — same class of bug, two separate widgets, both needed the treatment. Co-Authored-By: Claude Opus 4.7 (1M context) --- SecVF/VMCardCellView.swift | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/SecVF/VMCardCellView.swift b/SecVF/VMCardCellView.swift index 3283395..e619bdc 100644 --- a/SecVF/VMCardCellView.swift +++ b/SecVF/VMCardCellView.swift @@ -183,12 +183,24 @@ final class VMCardCellView: NSTableCellView { // Row 1 nameLabel.stringValue = vm.name + (vm.networkConfig.isRouter ? " ⬡ ROUTER" : "") + // VM-card row 1 status pill. Use attributedStringValue with an + // explicit centered NSMutableParagraphStyle — NSTextField's + // plain `alignment = .center` drifts leftward on layer-backed + // borderless labels on some macOS revs, producing the dead- + // space halo the user reported as "pill is still spaced wrong". let (pillGlyph, pillText, pillColor) = Self.statusPillSpec(for: vm.status) + let pillPara = NSMutableParagraphStyle() + pillPara.alignment = .center + let pillFont = NSFont.monospacedSystemFont(ofSize: 9, weight: .semibold) let pillAttr = NSMutableAttributedString(string: pillGlyph + " ", attributes: [ - .foregroundColor: pillColor + .foregroundColor: pillColor, + .font: pillFont, + .paragraphStyle: pillPara, ]) pillAttr.append(NSAttributedString(string: pillText, attributes: [ - .foregroundColor: pillColor.withAlphaComponent(0.95) + .foregroundColor: pillColor.withAlphaComponent(0.95), + .font: pillFont, + .paragraphStyle: pillPara, ])) statusPill.attributedStringValue = pillAttr statusPill.layer?.backgroundColor = pillColor.withAlphaComponent(0.12).cgColor @@ -254,14 +266,24 @@ final class VMCardCellView: NSTableCellView { let suffix = bundle.isBase ? " (base)" : "" nameLabel.stringValue = bundle.displayName + suffix + // AI Sandbox tab uses the same widget; apply the same + // paragraph-style centering as the running-state path above so + // the TEMPLATE / SESSION pill doesn't drift inside its bounds. let (pillGlyph, pillText, pillColor): (String, String, NSColor) = bundle.isBase ? ("◆", "TEMPLATE", AppColors.accentOrange) : ("●", "SESSION", AppColors.statusRunning) + let pillPara = NSMutableParagraphStyle() + pillPara.alignment = .center + let pillFont = NSFont.monospacedSystemFont(ofSize: 9, weight: .semibold) let pillAttr = NSMutableAttributedString(string: pillGlyph + " ", attributes: [ - .foregroundColor: pillColor + .foregroundColor: pillColor, + .font: pillFont, + .paragraphStyle: pillPara, ]) pillAttr.append(NSAttributedString(string: pillText, attributes: [ - .foregroundColor: pillColor.withAlphaComponent(0.95) + .foregroundColor: pillColor.withAlphaComponent(0.95), + .font: pillFont, + .paragraphStyle: pillPara, ])) statusPill.attributedStringValue = pillAttr statusPill.layer?.backgroundColor = pillColor.withAlphaComponent(0.12).cgColor