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
70 changes: 45 additions & 25 deletions SecVF/TacticalSidebarSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,21 @@ final class TacticalSidebarSection: NSView {

super.init(frame: .zero)

addSubview(titleLabel)
rebuild(rows: rows)
}

required init?(coder: NSCoder) {
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
Expand All @@ -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 }
Expand All @@ -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
Expand Down
30 changes: 26 additions & 4 deletions SecVF/VMCardCellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 31 additions & 3 deletions SecVF/VMLibraryWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading