Skip to content
Open
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
28 changes: 28 additions & 0 deletions Sources/localvoxtral/Settings/SettingsPaneHeader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import SwiftUI

/// Title + one-line purpose for the selected pane, above its scrolling content.
///
/// The subtitle exists because the sidebar row labels are short nouns: "Where
/// speech recognition and polishing run" is the sentence that used to be missing
/// entirely from the tabbed layout.
struct SettingsPaneHeader: View {
let tab: SettingsTab

var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(tab.title)
.font(.system(size: 20, weight: .semibold))

Text(tab.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 18)
// Matches the sidebar's inset: the window draws its content full-size
// under a transparent titlebar, so the header needs the same clearance.
.padding(.top, SettingsSidebarMetrics.topInset)
.padding(.bottom, 12)
}
}
231 changes: 231 additions & 0 deletions Sources/localvoxtral/Settings/SettingsSidebarView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import AppKit
import SwiftUI

/// Chrome (display copy, symbol, tint, AX identity) for each Settings tab.
///
/// The raw values are a contract with the AX drills — `scripts/ui-smoke.sh` and
/// `scripts/capture-readme-assets.sh` press `settings.tab.<rawValue>` and scope
/// their content asserts to `settings.pane.<rawValue>` — so the display names
/// here may change freely, the raw values may not.
extension SettingsTab {
/// Sidebar order, top group. Deliberately NOT the declaration order of the
/// enum: raw values are frozen for the scripts, presentation order is not.
static let primarySidebarItems: [SettingsTab] = [
.general, .dictation, .endpoints, .textProcessing,
]

/// Pinned to the bottom of the sidebar, under the spacer.
static let metaSidebarItems: [SettingsTab] = [.about]

var title: String {
switch self {
case .general: return "General"
case .dictation: return "Dictation"
case .endpoints: return "Endpoints"
case .textProcessing: return "Text Processing"
case .about: return "About"
}
}

var subtitle: String {
switch self {
case .general: return "Permissions and app-level behavior."
case .dictation: return "How you start, stop, and see dictation."
case .endpoints: return "Where speech recognition and polishing run."
case .textProcessing: return "Replacements and LLM polishing of your transcript."
case .about: return "Version, project, and diagnostics."
}
}

var systemImage: String {
switch self {
case .general: return "gearshape.fill"
case .dictation: return "mic.fill"
case .endpoints: return "cpu"
case .textProcessing: return "text.badge.checkmark"
case .about: return "info.circle.fill"
}
}

var tint: Color {
switch self {
case .general: return Color(nsColor: .systemGray)
case .dictation: return Color(nsColor: .systemRed)
case .endpoints: return Color(nsColor: .systemBlue)
case .textProcessing: return Color(nsColor: .systemPurple)
case .about: return Color(nsColor: .systemGray)
}
}

/// AX identity of the sidebar row that selects this tab.
var accessibilityIdentifier: String { "settings.tab.\(rawValue)" }

/// AX identity of the scrolling pane content for this tab. The drills scope
/// their content asserts to this subtree, so a sidebar row's label can never
/// vacuously satisfy a pane assert.
var paneAccessibilityIdentifier: String { "settings.pane.\(rawValue)" }
}

enum SettingsSidebarMetrics {
static let width: CGFloat = 208
/// Clears the traffic lights: the window uses a transparent, full-size
/// content view, so the first row would otherwise sit under them.
static let topInset: CGFloat = 28
static let rowHeight: CGFloat = 34
static let rowCornerRadius: CGFloat = 8
static let horizontalInset: CGFloat = 10
}

/// Hand-rolled sidebar: plain `Button` rows rather than `List`/`NavigationSplitView`.
///
/// Deliberate (design decision, 2026-07-27): the split-view containers bring a
/// sidebar-collapse toolbar button that can only be removed with private-API
/// hacks, and their rows surface to accessibility as table cells. Plain buttons
/// keep the window chrome under our control and give the AX drills a stable
/// `AXButton` + identifier to press.
struct SettingsSidebarView: View {
@Binding var selection: SettingsTab

var body: some View {
VStack(alignment: .leading, spacing: 2) {
ForEach(SettingsTab.primarySidebarItems, id: \.self) { tab in
SettingsSidebarRow(tab: tab, isSelected: selection == tab) {
selection = tab
}
}

Spacer(minLength: 12)

ForEach(SettingsTab.metaSidebarItems, id: \.self) { tab in
SettingsSidebarRow(tab: tab, isSelected: selection == tab) {
selection = tab
}
}

SettingsSidebarVersionFooter()
}
.padding(.horizontal, SettingsSidebarMetrics.horizontalInset)
.padding(.top, SettingsSidebarMetrics.topInset)
.padding(.bottom, 12)
.frame(width: SettingsSidebarMetrics.width, alignment: .leading)
.frame(maxHeight: .infinity, alignment: .top)
.background(SettingsSidebarBackground())
}
}

private struct SettingsSidebarRow: View {
let tab: SettingsTab
let isSelected: Bool
let action: () -> Void

@State private var isHovering = false

private var fillStyle: Color {
if isSelected {
return Color(nsColor: .selectedContentBackgroundColor)
}
if isHovering {
return Color.primary.opacity(0.06)
}
return Color.clear
}

private var labelStyle: Color {
isSelected ? Color(nsColor: .alternateSelectedControlTextColor) : Color.primary
}

var body: some View {
Button(action: action) {
HStack(spacing: 9) {
SettingsSidebarIconTile(systemImage: tab.systemImage, tint: tab.tint)

Text(tab.title)
.font(.system(size: 13, weight: isSelected ? .semibold : .medium))
.foregroundStyle(labelStyle)
.lineLimit(1)

Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(height: SettingsSidebarMetrics.rowHeight)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
RoundedRectangle(
cornerRadius: SettingsSidebarMetrics.rowCornerRadius,
style: .continuous
)
.fill(fillStyle)
}
.contentShape(
RoundedRectangle(
cornerRadius: SettingsSidebarMetrics.rowCornerRadius,
style: .continuous
)
)
}
.buttonStyle(.plain)
.onHover { isHovering = $0 }
.animation(.easeInOut(duration: 0.12), value: isSelected)
.help(tab.subtitle)
.accessibilityLabel(tab.title)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
.accessibilityIdentifier(tab.accessibilityIdentifier)
}
}

private struct SettingsSidebarIconTile: View {
let systemImage: String
let tint: Color

var body: some View {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(tint)
.frame(width: 22, height: 22)
.overlay {
Image(systemName: systemImage)
.font(.system(size: 12.5, weight: .semibold))
.foregroundStyle(.white)
}
.overlay {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5)
}
.shadow(color: Color.black.opacity(0.15), radius: 1, y: 0.5)
.accessibilityHidden(true)
}
}

private struct SettingsSidebarVersionFooter: View {
private var version: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
?? "dev"
}

private var build: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "dev"
}

var body: some View {
Text("v\(version) (\(build))")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.top, 6)
}
}

/// `.sidebar` material, blended behind the window (the standard macOS sidebar
/// look). If it ever renders flat on a host that cannot vibrate behind this
/// window, the documented fallback is `.withinWindow` — a one-line change here,
/// not a redesign.
private struct SettingsSidebarBackground: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.material = .sidebar
view.state = .followsWindowActiveState
view.blendingMode = .behindWindow
return view
}

func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
}
64 changes: 64 additions & 0 deletions Sources/localvoxtral/Settings/SettingsWindowChrome.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import AppKit
import SwiftUI

/// Makes the Settings window's titlebar transparent and full-size, so the
/// sidebar material runs to the top edge.
///
/// Applied twice on purpose: once when the view is placed in a window, and again
/// on every `didBecomeKey`. SwiftUI re-asserts its own titlebar configuration
/// when it rebuilds the scene's window (observed with the `Settings` scene:
/// close, reopen, and the title reappears), and there is no notification for
/// "SwiftUI just reconfigured you" — becoming key is the reliable moment we get.
struct SettingsWindowChrome: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
SettingsWindowChromeView()
}

func updateNSView(_ nsView: NSView, context: Context) {}
}

private final class SettingsWindowChromeView: NSView {
private var observedWindow: NSWindow?

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()

if observedWindow !== window {
if let observedWindow {
NotificationCenter.default.removeObserver(
self,
name: NSWindow.didBecomeKeyNotification,
object: observedWindow
)
}
observedWindow = window
if let window {
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidBecomeKey(_:)),
name: NSWindow.didBecomeKeyNotification,
object: window
)
}
}

guard let window else { return }
Self.applyChrome(to: window)
}

deinit {
NotificationCenter.default.removeObserver(self)
}

@objc
private func windowDidBecomeKey(_ notification: Notification) {
guard let window else { return }
Self.applyChrome(to: window)
}

private static func applyChrome(to window: NSWindow) {
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.styleMask.insert(.fullSizeContentView)
}
}
Loading
Loading