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
8 changes: 8 additions & 0 deletions SecVF.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
CC1111CC11111111CC111CC1 /* VMConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1111DD11111111DD111DD1 /* VMConfigurationTests.swift */; };
SPKT002SPKT002SPKT002002 /* SparklineViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SPKT001SPKT001SPKT001001 /* SparklineViewTests.swift */; };
TUIT002TUIT002TUIT002002 /* TacticalUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */; };
SBFT002SBFT002SBFT002002 /* SidebarFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SBFT001SBFT001SBFT001001 /* SidebarFilterTests.swift */; };
NPT002NPT002NPT002002 /* NetworkPeersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NPT001NPT001NPT001001 /* NetworkPeersTests.swift */; };
PFPT002PFPT002PFPT002002 /* PacketFilterPresetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */; };
OVLT002OVLT002OVLT002002 /* OverlayViewsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = OVLT001OVLT001OVLT001001 /* OverlayViewsTests.swift */; };
Expand Down Expand Up @@ -70,6 +71,7 @@
THBT002THBT002THBT002002 /* TacticalHoverButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = THBT001THBT001THBT001001 /* TacticalHoverButton.swift */; };
TTRV002TTRV002TTRV002002 /* TacticalTableRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = TTRV001TTRV001TTRV001001 /* TacticalTableRowView.swift */; };
TESV002TESV002TESV002002 /* TacticalEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = TESV001TESV001TESV001001 /* TacticalEmptyStateView.swift */; };
TSSC002TSSC002TSSC002002 /* TacticalSidebarSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = TSSC001TSSC001TSSC001001 /* TacticalSidebarSection.swift */; };
TTHC002TTHC002TTHC002002 /* TacticalTableHeaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = TTHC001TTHC001TTHC001001 /* TacticalTableHeaderCell.swift */; };
PCPR002PCPR002PCPR002002 /* PacketCaptureProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = PCPR001PCPR001PCPR001001 /* PacketCaptureProtocol.swift */; };
PROCX002PROCX002PROCX002 /* ProcessExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = PROCX001PROCX001PROCX001 /* ProcessExecutor.swift */; };
Expand Down Expand Up @@ -123,6 +125,7 @@
DD1111DD11111111DD111DD1 /* VMConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigurationTests.swift; sourceTree = "<group>"; };
SPKT001SPKT001SPKT001001 /* SparklineViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparklineViewTests.swift; sourceTree = "<group>"; };
TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalUITests.swift; sourceTree = "<group>"; };
SBFT001SBFT001SBFT001001 /* SidebarFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFilterTests.swift; sourceTree = "<group>"; };
NPT001NPT001NPT001001 /* NetworkPeersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkPeersTests.swift; sourceTree = "<group>"; };
PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketFilterPresetsTests.swift; sourceTree = "<group>"; };
OVLT001OVLT001OVLT001001 /* OverlayViewsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayViewsTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -160,6 +163,7 @@
THBT001THBT001THBT001001 /* TacticalHoverButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalHoverButton.swift; sourceTree = "<group>"; };
TTRV001TTRV001TTRV001001 /* TacticalTableRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalTableRowView.swift; sourceTree = "<group>"; };
TESV001TESV001TESV001001 /* TacticalEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalEmptyStateView.swift; sourceTree = "<group>"; };
TSSC001TSSC001TSSC001001 /* TacticalSidebarSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalSidebarSection.swift; sourceTree = "<group>"; };
TTHC001TTHC001TTHC001001 /* TacticalTableHeaderCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalTableHeaderCell.swift; sourceTree = "<group>"; };
PCPR001PCPR001PCPR001001 /* PacketCaptureProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketCaptureProtocol.swift; sourceTree = "<group>"; };
PROCX001PROCX001PROCX001 /* ProcessExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessExecutor.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -237,6 +241,7 @@
THBT001THBT001THBT001001 /* TacticalHoverButton.swift */,
TTRV001TTRV001TTRV001001 /* TacticalTableRowView.swift */,
TESV001TESV001TESV001001 /* TacticalEmptyStateView.swift */,
TSSC001TSSC001TSSC001001 /* TacticalSidebarSection.swift */,
TTHC001TTHC001TTHC001001 /* TacticalTableHeaderCell.swift */,
NTNM001NTNM001NTNM001001 /* NotificationNames.swift */,
PROCX001PROCX001PROCX001 /* ProcessExecutor.swift */,
Expand Down Expand Up @@ -291,6 +296,7 @@
DD1111DD11111111DD111DD1 /* VMConfigurationTests.swift */,
SPKT001SPKT001SPKT001001 /* SparklineViewTests.swift */,
TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */,
SBFT001SBFT001SBFT001001 /* SidebarFilterTests.swift */,
NPT001NPT001NPT001001 /* NetworkPeersTests.swift */,
PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */,
OVLT001OVLT001OVLT001001 /* OverlayViewsTests.swift */,
Expand Down Expand Up @@ -456,6 +462,7 @@
THBT002THBT002THBT002002 /* TacticalHoverButton.swift in Sources */,
TTRV002TTRV002TTRV002002 /* TacticalTableRowView.swift in Sources */,
TESV002TESV002TESV002002 /* TacticalEmptyStateView.swift in Sources */,
TSSC002TSSC002TSSC002002 /* TacticalSidebarSection.swift in Sources */,
TTHC002TTHC002TTHC002002 /* TacticalTableHeaderCell.swift in Sources */,
NTNM002NTNM002NTNM002002 /* NotificationNames.swift in Sources */,
UTEX002UTEX002UTEX002002 /* UTTypeExtensions.swift in Sources */,
Expand Down Expand Up @@ -489,6 +496,7 @@
CC1111CC11111111CC111CC1 /* VMConfigurationTests.swift in Sources */,
SPKT002SPKT002SPKT002002 /* SparklineViewTests.swift in Sources */,
TUIT002TUIT002TUIT002002 /* TacticalUITests.swift in Sources */,
SBFT002SBFT002SBFT002002 /* SidebarFilterTests.swift in Sources */,
NPT002NPT002NPT002002 /* NetworkPeersTests.swift in Sources */,
PFPT002PFPT002PFPT002002 /* PacketFilterPresetsTests.swift in Sources */,
OVLT002OVLT002OVLT002002 /* OverlayViewsTests.swift in Sources */,
Expand Down
266 changes: 266 additions & 0 deletions SecVF/TacticalSidebarSection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
//
// TacticalSidebarSection.swift
// SecVF
//
// Sidebar section composed of a label header and a list of selectable
// rows, each showing a leading glyph + title + trailing count. Used
// for the mockup-spec navigation panes (Filters / Operating System /
// Network) and for the Logs shortcut group, so the four blocks share
// the same tactical look + interaction surface.
//
// Each row is a single-selection radio within its section: clicking a
// row sets the section's `selectedID` and invokes the supplied
// `onSelect` closure with the row's identifier. Sections are
// independent — the controller composes their selections into a
// combined filter over the VM list.
//

import Cocoa

final class TacticalSidebarSection: NSView {

/// Row definition. `id` is the value handed back via `onSelect`;
/// `title` is the visible label; `glyph` is the leading icon
/// character; `count` is the trailing count badge (nil = no badge).
struct Row {
let id: String
let glyph: String
let title: String
var count: Int?
}

/// Externally-mutated selection. Setting this updates the visual
/// state and does NOT fire `onSelect` (so callers can drive the
/// selection programmatically without recursion).
var selectedID: String? {
didSet { refreshSelectionStyling() }
}

/// Called when the user clicks a row. The row's `id` is supplied.
/// Empty `onSelect` (the default) means the section is display-only.
var onSelect: (String) -> Void = { _ in }

private let titleLabel: NSTextField
private var rowViews: [String: SidebarRowView] = [:]

/// Convenience: total height the section currently lays out to. Use
/// when stacking sections vertically — caller positions origin.y
/// and reads `intrinsicHeight` to advance.
var intrinsicHeight: CGFloat { _intrinsicHeight }
private var _intrinsicHeight: CGFloat = 0

init(title: String, rows: [Row]) {
titleLabel = NSTextField(labelWithString: title.uppercased())
titleLabel.font = NSFont.monospacedSystemFont(ofSize: 9, weight: .bold)
titleLabel.textColor = AppColors.textSubtle

super.init(frame: .zero)

addSubview(titleLabel)
rebuild(rows: rows)
}

required init?(coder: NSCoder) {
fatalError("Use init(title:rows:)")
}

/// 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
/// any sections stacked below this one if `intrinsicHeight` changes.
func rebuild(rows: [Row]) {
// Tear down existing rows
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
_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)
titleLabel.autoresizingMask = [.width, .minYMargin]

// 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)
view.autoresizingMask = [.width, .minYMargin]
view.onTap = { [weak self] id in
guard let self = self else { return }
if self.selectedID != id {
self.selectedID = id
}
self.onSelect(id)
}
addSubview(view)
rowViews[row.id] = view
y -= (rowH + rowGap)
}

refreshSelectionStyling()
}

/// Update just the count badges + (optionally) the selection state
/// without rebuilding the row layout. Cheaper than `rebuild` for
/// the common "VM list changed, counts shifted" path.
func refreshCounts(_ counts: [String: Int]) {
for (id, view) in rowViews {
view.setCount(counts[id])
}
}

private func refreshSelectionStyling() {
for (id, view) in rowViews {
view.isSelected = (id == selectedID)
}
}
}

// MARK: - SidebarRowView

/// Visual row inside a TacticalSidebarSection. Custom NSView (rather
/// than NSButton) because the layered selection band + leading glyph
/// + trailing count layout doesn't fit NSButton's title-only API.
private final class SidebarRowView: NSView {

private let glyphLabel: NSTextField
private let titleLabel: NSTextField
private let countLabel: NSTextField
private let rowID: String

var onTap: (String) -> Void = { _ in }

var isSelected: Bool = false {
didSet { refreshStyling() }
}

private var isHovered: Bool = false {
didSet { refreshStyling() }
}

init(row: TacticalSidebarSection.Row, height: CGFloat) {
self.rowID = row.id

glyphLabel = NSTextField(labelWithString: row.glyph)
glyphLabel.font = NSFont.systemFont(ofSize: 11, weight: .medium)
glyphLabel.alignment = .center

titleLabel = NSTextField(labelWithString: row.title)
titleLabel.font = NSFont.systemFont(ofSize: 11, weight: .medium)

countLabel = NSTextField(labelWithString: row.count.map { String($0) } ?? "")
countLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular)
countLabel.alignment = .right
countLabel.textColor = AppColors.textSubtle

super.init(frame: NSRect(x: 0, y: 0, width: 100, height: height))

wantsLayer = true
layer?.cornerRadius = LayoutConstants.cornerRadiusSM

let glyphW: CGFloat = 18
let countW: CGFloat = 32
let textPadL: CGFloat = 6
let textPadR: CGFloat = 4

glyphLabel.frame = NSRect(x: 6, y: 0, width: glyphW, height: height)
glyphLabel.autoresizingMask = [.maxXMargin]
addSubview(glyphLabel)

countLabel.frame = NSRect(x: bounds.width - countW - 8, y: 0,
width: countW, height: height)
countLabel.autoresizingMask = [.minXMargin]
addSubview(countLabel)

let titleX = 6 + glyphW + textPadL
titleLabel.frame = NSRect(x: titleX, y: 0,
width: bounds.width - titleX - countW - textPadR - 8,
height: height)
titleLabel.autoresizingMask = [.width]
titleLabel.lineBreakMode = .byTruncatingTail
addSubview(titleLabel)

refreshStyling()

// Click handling — install a tracking area for hover; mouseDown
// fires the tap callback.
let area = NSTrackingArea(rect: bounds,
options: [.mouseEnteredAndExited,
.activeInActiveApp,
.inVisibleRect],
owner: self,
userInfo: nil)
addTrackingArea(area)

setAccessibilityRole(.button)
setAccessibilityLabel("\(row.title)\(row.count.map { " (\($0))" } ?? "")")
}

required init?(coder: NSCoder) {
fatalError("Use init(row:height:)")
}

func setCount(_ count: Int?) {
countLabel.stringValue = count.map { String($0) } ?? ""
}

private func refreshStyling() {
let bgColor: NSColor
let glyphColor: NSColor
let titleColor: NSColor
let countColor: NSColor

if isSelected {
bgColor = AppColors.backgroundRowSelected.withAlphaComponent(0.55)
glyphColor = AppColors.accentODGlow
titleColor = AppColors.textPrimary
countColor = AppColors.textPrimary
} else if isHovered {
bgColor = AppColors.backgroundRowHover.withAlphaComponent(0.4)
glyphColor = AppColors.textPrimary
titleColor = AppColors.textPrimary
countColor = AppColors.textSubtle
} else {
bgColor = .clear
glyphColor = AppColors.textMuted
titleColor = AppColors.textMuted
countColor = AppColors.textSubtle
}

layer?.backgroundColor = bgColor.cgColor
glyphLabel.textColor = glyphColor
titleLabel.textColor = titleColor
countLabel.textColor = countColor
}

override func mouseEntered(with event: NSEvent) {
isHovered = true
}

override func mouseExited(with event: NSEvent) {
isHovered = false
}

override func mouseDown(with event: NSEvent) {
onTap(rowID)
}
}
Loading
Loading