Yep. AI rewrote the whole thing.

This commit is contained in:
2026-03-13 03:24:24 +11:00
parent e4719cb9f4
commit fe6c7d8c12
47 changed files with 5348 additions and 1182 deletions

View File

@@ -0,0 +1,29 @@
import SwiftUI
struct AboutSettingsView: View {
private var versionLabel: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
}
var body: some View {
VStack(spacing: 16) {
Image(systemName: "terminal")
.font(.system(size: 64))
.foregroundStyle(.secondary)
Text("CommandNotch")
.font(.largeTitle.bold())
Text("Version \(versionLabel)")
.foregroundStyle(.secondary)
Text("A drop-down terminal that lives in your notch.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Spacer()
}
.frame(maxWidth: .infinity)
.padding(.top, 40)
}
}

View File

@@ -0,0 +1,72 @@
import SwiftUI
struct AnimationSettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
var body: some View {
Form {
Section("Open Animation") {
springControls(
response: settingsController.binding(\.animation.openSpringResponse),
damping: settingsController.binding(\.animation.openSpringDamping)
)
}
Section("Close Animation") {
springControls(
response: settingsController.binding(\.animation.closeSpringResponse),
damping: settingsController.binding(\.animation.closeSpringDamping)
)
}
Section("Hover Animation") {
springControls(
response: settingsController.binding(\.animation.hoverSpringResponse),
damping: settingsController.binding(\.animation.hoverSpringDamping)
)
}
Section("Resize Animation") {
durationControl(duration: settingsController.binding(\.animation.resizeAnimationDuration))
}
Section {
Button("Reset to Defaults") {
settingsController.update {
$0.animation = AppSettings.default.animation
}
}
}
}
.formStyle(.grouped)
}
@ViewBuilder
private func springControls(response: Binding<Double>, damping: Binding<Double>) -> some View {
HStack {
Text("Response")
Slider(value: response, in: 0.1...1.5, step: 0.01)
Text(String(format: "%.2f", response.wrappedValue))
.monospacedDigit()
.frame(width: 50)
}
HStack {
Text("Damping")
Slider(value: damping, in: 0.1...1.5, step: 0.01)
Text(String(format: "%.2f", damping.wrappedValue))
.monospacedDigit()
.frame(width: 50)
}
}
@ViewBuilder
private func durationControl(duration: Binding<Double>) -> some View {
HStack {
Text("Duration")
Slider(value: duration, in: 0.05...1.5, step: 0.01)
Text(String(format: "%.2fs", duration.wrappedValue))
.monospacedDigit()
.frame(width: 56)
}
}
}

View File

@@ -0,0 +1,51 @@
import SwiftUI
struct AppearanceSettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
var body: some View {
Form {
Section("Shadow") {
Toggle("Enable shadow", isOn: settingsController.binding(\.appearance.enableShadow))
if settingsController.settings.appearance.enableShadow {
HStack {
Text("Radius")
Slider(value: settingsController.binding(\.appearance.shadowRadius), in: 0...30, step: 1)
Text(String(format: "%.0f", settingsController.settings.appearance.shadowRadius))
.monospacedDigit()
.frame(width: 40)
}
HStack {
Text("Opacity")
Slider(value: settingsController.binding(\.appearance.shadowOpacity), in: 0...1, step: 0.05)
Text(String(format: "%.2f", settingsController.settings.appearance.shadowOpacity))
.monospacedDigit()
.frame(width: 50)
}
}
}
Section("Shape") {
Toggle("Scale corner radii when open", isOn: settingsController.binding(\.appearance.cornerRadiusScaling))
}
Section("Opacity & Blur") {
HStack {
Text("Notch opacity")
Slider(value: settingsController.binding(\.appearance.notchOpacity), in: 0...1, step: 0.05)
Text(String(format: "%.2f", settingsController.settings.appearance.notchOpacity))
.monospacedDigit()
.frame(width: 50)
}
HStack {
Text("Blur radius")
Slider(value: settingsController.binding(\.appearance.blurRadius), in: 0...20, step: 0.5)
Text(String(format: "%.1f", settingsController.settings.appearance.blurRadius))
.monospacedDigit()
.frame(width: 50)
}
}
}
.formStyle(.grouped)
}
}

View File

@@ -0,0 +1,107 @@
import AppKit
import SwiftUI
struct GeneralSettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
private var maxOpenWidth: Double {
let currentWidth = settingsController.settings.display.openWidth
let screenWidth = NSScreen.screens.map { $0.frame.width - 40 }.max() ?? 1600
return max(currentWidth, Double(screenWidth.rounded()))
}
private var maxOpenHeight: Double {
let currentHeight = settingsController.settings.display.openHeight
let screenHeight = NSScreen.screens.map { $0.frame.height - 20 }.max() ?? 900
return max(currentHeight, Double(screenHeight.rounded()))
}
var body: some View {
Form {
Section("Display") {
Toggle("Show on all displays", isOn: settingsController.binding(\.display.showOnAllDisplays))
Toggle("Show menu bar icon", isOn: settingsController.binding(\.display.showMenuBarIcon))
Toggle("Launch at login", isOn: settingsController.binding(\.display.launchAtLogin))
.onChange(of: settingsController.settings.display.launchAtLogin) { _, newValue in
LaunchAtLoginHelper.setEnabled(newValue)
}
}
Section("Hover Behavior") {
Toggle("Open notch on hover", isOn: settingsController.binding(\.behavior.openNotchOnHover))
if settingsController.settings.behavior.openNotchOnHover {
HStack {
Text("Hover delay")
Slider(value: settingsController.binding(\.behavior.minimumHoverDuration), in: 0.0...2.0, step: 0.05)
Text(String(format: "%.2fs", settingsController.settings.behavior.minimumHoverDuration))
.monospacedDigit()
.frame(width: 50)
}
}
}
Section("Gestures") {
Toggle("Enable gestures", isOn: settingsController.binding(\.behavior.enableGestures))
if settingsController.settings.behavior.enableGestures {
HStack {
Text("Sensitivity")
Slider(value: settingsController.binding(\.behavior.gestureSensitivity), in: 0.1...1.0, step: 0.05)
Text(String(format: "%.2f", settingsController.settings.behavior.gestureSensitivity))
.monospacedDigit()
.frame(width: 50)
}
}
}
Section("Closed Notch Size") {
Picker("Notch screens", selection: settingsController.binding(\.display.notchHeightMode)) {
ForEach(NotchHeightMode.allCases) { mode in
Text(mode.label).tag(mode.rawValue)
}
}
if settingsController.settings.display.notchHeightMode == NotchHeightMode.custom.rawValue {
HStack {
Text("Custom height")
Slider(value: settingsController.binding(\.display.notchHeight), in: 16...64, step: 1)
Text("\(Int(settingsController.settings.display.notchHeight))pt")
.monospacedDigit()
.frame(width: 50)
}
}
Picker("Non-notch screens", selection: settingsController.binding(\.display.nonNotchHeightMode)) {
ForEach(NonNotchHeightMode.allCases) { mode in
Text(mode.label).tag(mode.rawValue)
}
}
if settingsController.settings.display.nonNotchHeightMode == NonNotchHeightMode.custom.rawValue {
HStack {
Text("Custom height")
Slider(value: settingsController.binding(\.display.nonNotchHeight), in: 16...64, step: 1)
Text("\(Int(settingsController.settings.display.nonNotchHeight))pt")
.monospacedDigit()
.frame(width: 50)
}
}
}
Section("Open Notch Size") {
HStack {
Text("Width")
Slider(value: settingsController.binding(\.display.openWidth), in: 320...maxOpenWidth, step: 10)
Text("\(Int(settingsController.settings.display.openWidth))pt")
.monospacedDigit()
.frame(width: 60)
}
HStack {
Text("Height")
Slider(value: settingsController.binding(\.display.openHeight), in: 140...maxOpenHeight, step: 10)
Text("\(Int(settingsController.settings.display.openHeight))pt")
.monospacedDigit()
.frame(width: 60)
}
}
}
.formStyle(.grouped)
}
}

View File

@@ -0,0 +1,36 @@
import SwiftUI
struct HotkeySettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
var body: some View {
Form {
Section("Global") {
HotkeyRecorderView(label: "Toggle notch", binding: settingsController.binding(\.hotkeys.toggle))
}
Section("Terminal Tabs (active when notch is open)") {
HotkeyRecorderView(label: "New tab", binding: settingsController.binding(\.hotkeys.newTab))
HotkeyRecorderView(label: "Close tab", binding: settingsController.binding(\.hotkeys.closeTab))
HotkeyRecorderView(label: "Next tab", binding: settingsController.binding(\.hotkeys.nextTab))
HotkeyRecorderView(label: "Previous tab", binding: settingsController.binding(\.hotkeys.previousTab))
HotkeyRecorderView(label: "Detach tab", binding: settingsController.binding(\.hotkeys.detachTab))
}
Section {
Text("⌘19 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
Button("Reset to Defaults") {
settingsController.update {
$0.hotkeys = AppSettings.default.hotkeys
}
}
}
}
.formStyle(.grouped)
}
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
@MainActor
extension AppSettingsController {
func binding<Value>(_ keyPath: WritableKeyPath<AppSettings, Value>) -> Binding<Value> {
Binding(
get: { self.settings[keyPath: keyPath] },
set: { newValue in
self.update { $0[keyPath: keyPath] = newValue }
}
)
}
}

View File

@@ -1,9 +1,6 @@
import SwiftUI
import AppKit
/// Tabbed settings panel with General, Appearance, Animation, Terminal, Hotkeys, and About.
struct SettingsView: View {
@State private var selectedTab: SettingsTab = .general
var body: some View {
@@ -11,6 +8,7 @@ struct SettingsView: View {
List(SettingsTab.allCases, selection: $selectedTab) { tab in
Label(tab.label, systemImage: tab.icon)
.tag(tab)
.accessibilityIdentifier("settings.tab.\(tab.rawValue)")
}
.listStyle(.sidebar)
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
@@ -26,495 +24,64 @@ struct SettingsView: View {
@ViewBuilder
private var detailView: some View {
switch selectedTab {
case .general: GeneralSettingsView()
case .appearance: AppearanceSettingsView()
case .animation: AnimationSettingsView()
case .terminal: TerminalSettingsView()
case .hotkeys: HotkeySettingsView()
case .about: AboutSettingsView()
case .general:
GeneralSettingsView()
case .appearance:
AppearanceSettingsView()
case .workspaces:
WorkspacesSettingsView()
case .animation:
AnimationSettingsView()
case .terminal:
TerminalSettingsView()
case .hotkeys:
HotkeySettingsView()
case .about:
AboutSettingsView()
}
}
}
// MARK: - Tabs
enum SettingsTab: String, CaseIterable, Identifiable {
case general, appearance, animation, terminal, hotkeys, about
case general, appearance, workspaces, animation, terminal, hotkeys, about
var id: String { rawValue }
var label: String {
switch self {
case .general: return "General"
case .appearance: return "Appearance"
case .animation: return "Animation"
case .terminal: return "Terminal"
case .hotkeys: return "Hotkeys"
case .about: return "About"
case .general:
"General"
case .appearance:
"Appearance"
case .workspaces:
"Workspaces"
case .animation:
"Animation"
case .terminal:
"Terminal"
case .hotkeys:
"Hotkeys"
case .about:
"About"
}
}
var icon: String {
switch self {
case .general: return "gearshape"
case .appearance: return "paintbrush"
case .animation: return "bolt.fill"
case .terminal: return "terminal"
case .hotkeys: return "keyboard"
case .about: return "info.circle"
case .general:
"gearshape"
case .appearance:
"paintbrush"
case .workspaces:
"rectangle.3.group"
case .animation:
"bolt.fill"
case .terminal:
"terminal"
case .hotkeys:
"keyboard"
case .about:
"info.circle"
}
}
}
// MARK: - General
struct GeneralSettingsView: View {
@AppStorage(NotchSettings.Keys.showOnAllDisplays) private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays
@AppStorage(NotchSettings.Keys.openNotchOnHover) private var openNotchOnHover = NotchSettings.Defaults.openNotchOnHover
@AppStorage(NotchSettings.Keys.minimumHoverDuration) private var minimumHoverDuration = NotchSettings.Defaults.minimumHoverDuration
@AppStorage(NotchSettings.Keys.showMenuBarIcon) private var showMenuBarIcon = NotchSettings.Defaults.showMenuBarIcon
@AppStorage(NotchSettings.Keys.launchAtLogin) private var launchAtLogin = NotchSettings.Defaults.launchAtLogin
@AppStorage(NotchSettings.Keys.enableGestures) private var enableGestures = NotchSettings.Defaults.enableGestures
@AppStorage(NotchSettings.Keys.gestureSensitivity) private var gestureSensitivity = NotchSettings.Defaults.gestureSensitivity
@AppStorage(NotchSettings.Keys.notchHeightMode) private var notchHeightMode = NotchSettings.Defaults.notchHeightMode
@AppStorage(NotchSettings.Keys.notchHeight) private var notchHeight = NotchSettings.Defaults.notchHeight
@AppStorage(NotchSettings.Keys.nonNotchHeightMode) private var nonNotchHeightMode = NotchSettings.Defaults.nonNotchHeightMode
@AppStorage(NotchSettings.Keys.nonNotchHeight) private var nonNotchHeight = NotchSettings.Defaults.nonNotchHeight
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
private var maxOpenWidth: Double {
max(openWidth, Double((NSScreen.screens.map { $0.frame.width - 40 }.max() ?? 1600).rounded()))
}
private var maxOpenHeight: Double {
max(openHeight, Double((NSScreen.screens.map { $0.frame.height - 20 }.max() ?? 900).rounded()))
}
var body: some View {
Form {
Section("Display") {
Toggle("Show on all displays", isOn: $showOnAllDisplays)
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
Toggle("Launch at login", isOn: $launchAtLogin)
.onChange(of: launchAtLogin) { _, newValue in
LaunchAtLoginHelper.setEnabled(newValue)
}
}
Section("Hover Behavior") {
Toggle("Open notch on hover", isOn: $openNotchOnHover)
if openNotchOnHover {
HStack {
Text("Hover delay")
Slider(value: $minimumHoverDuration, in: 0.0...2.0, step: 0.05)
Text(String(format: "%.2fs", minimumHoverDuration))
.monospacedDigit().frame(width: 50)
}
}
}
Section("Gestures") {
Toggle("Enable gestures", isOn: $enableGestures)
if enableGestures {
HStack {
Text("Sensitivity")
Slider(value: $gestureSensitivity, in: 0.1...1.0, step: 0.05)
Text(String(format: "%.2f", gestureSensitivity))
.monospacedDigit().frame(width: 50)
}
}
}
Section("Closed Notch Size") {
Picker("Notch screens", selection: $notchHeightMode) {
ForEach(NotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) }
}
if notchHeightMode == NotchHeightMode.custom.rawValue {
HStack {
Text("Custom height")
Slider(value: $notchHeight, in: 16...64, step: 1)
Text("\(Int(notchHeight))pt").monospacedDigit().frame(width: 50)
}
}
Picker("Non-notch screens", selection: $nonNotchHeightMode) {
ForEach(NonNotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) }
}
if nonNotchHeightMode == NonNotchHeightMode.custom.rawValue {
HStack {
Text("Custom height")
Slider(value: $nonNotchHeight, in: 16...64, step: 1)
Text("\(Int(nonNotchHeight))pt").monospacedDigit().frame(width: 50)
}
}
}
Section("Open Notch Size") {
HStack {
Text("Width")
Slider(value: $openWidth, in: 320...maxOpenWidth, step: 10)
Text("\(Int(openWidth))pt").monospacedDigit().frame(width: 60)
}
HStack {
Text("Height")
Slider(value: $openHeight, in: 140...maxOpenHeight, step: 10)
Text("\(Int(openHeight))pt").monospacedDigit().frame(width: 60)
}
}
}
.formStyle(.grouped)
}
}
// MARK: - Appearance
struct AppearanceSettingsView: View {
@AppStorage(NotchSettings.Keys.enableShadow) private var enableShadow = NotchSettings.Defaults.enableShadow
@AppStorage(NotchSettings.Keys.shadowRadius) private var shadowRadius = NotchSettings.Defaults.shadowRadius
@AppStorage(NotchSettings.Keys.shadowOpacity) private var shadowOpacity = NotchSettings.Defaults.shadowOpacity
@AppStorage(NotchSettings.Keys.cornerRadiusScaling) private var cornerRadiusScaling = NotchSettings.Defaults.cornerRadiusScaling
@AppStorage(NotchSettings.Keys.notchOpacity) private var notchOpacity = NotchSettings.Defaults.notchOpacity
@AppStorage(NotchSettings.Keys.blurRadius) private var blurRadius = NotchSettings.Defaults.blurRadius
var body: some View {
Form {
Section("Shadow") {
Toggle("Enable shadow", isOn: $enableShadow)
if enableShadow {
HStack {
Text("Radius")
Slider(value: $shadowRadius, in: 0...30, step: 1)
Text(String(format: "%.0f", shadowRadius)).monospacedDigit().frame(width: 40)
}
HStack {
Text("Opacity")
Slider(value: $shadowOpacity, in: 0...1, step: 0.05)
Text(String(format: "%.2f", shadowOpacity)).monospacedDigit().frame(width: 50)
}
}
}
Section("Shape") {
Toggle("Scale corner radii when open", isOn: $cornerRadiusScaling)
}
Section("Opacity & Blur") {
HStack {
Text("Notch opacity")
Slider(value: $notchOpacity, in: 0...1, step: 0.05)
Text(String(format: "%.2f", notchOpacity)).monospacedDigit().frame(width: 50)
}
HStack {
Text("Blur radius")
Slider(value: $blurRadius, in: 0...20, step: 0.5)
Text(String(format: "%.1f", blurRadius)).monospacedDigit().frame(width: 50)
}
}
}
.formStyle(.grouped)
}
}
// MARK: - Animation
struct AnimationSettingsView: View {
@AppStorage(NotchSettings.Keys.openSpringResponse) private var openResponse = NotchSettings.Defaults.openSpringResponse
@AppStorage(NotchSettings.Keys.openSpringDamping) private var openDamping = NotchSettings.Defaults.openSpringDamping
@AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeResponse = NotchSettings.Defaults.closeSpringResponse
@AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeDamping = NotchSettings.Defaults.closeSpringDamping
@AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverResponse = NotchSettings.Defaults.hoverSpringResponse
@AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverDamping = NotchSettings.Defaults.hoverSpringDamping
@AppStorage(NotchSettings.Keys.resizeAnimationDuration) private var resizeDuration = NotchSettings.Defaults.resizeAnimationDuration
var body: some View {
Form {
Section("Open Animation") {
springControls(response: $openResponse, damping: $openDamping)
}
Section("Close Animation") {
springControls(response: $closeResponse, damping: $closeDamping)
}
Section("Hover Animation") {
springControls(response: $hoverResponse, damping: $hoverDamping)
}
Section("Resize Animation") {
durationControl(duration: $resizeDuration)
}
Section {
Button("Reset to Defaults") {
openResponse = NotchSettings.Defaults.openSpringResponse
openDamping = NotchSettings.Defaults.openSpringDamping
closeResponse = NotchSettings.Defaults.closeSpringResponse
closeDamping = NotchSettings.Defaults.closeSpringDamping
hoverResponse = NotchSettings.Defaults.hoverSpringResponse
hoverDamping = NotchSettings.Defaults.hoverSpringDamping
resizeDuration = NotchSettings.Defaults.resizeAnimationDuration
}
}
}
.formStyle(.grouped)
}
@ViewBuilder
private func springControls(response: Binding<Double>, damping: Binding<Double>) -> some View {
HStack {
Text("Response")
Slider(value: response, in: 0.1...1.5, step: 0.01)
Text(String(format: "%.2f", response.wrappedValue)).monospacedDigit().frame(width: 50)
}
HStack {
Text("Damping")
Slider(value: damping, in: 0.1...1.5, step: 0.01)
Text(String(format: "%.2f", damping.wrappedValue)).monospacedDigit().frame(width: 50)
}
}
@ViewBuilder
private func durationControl(duration: Binding<Double>) -> some View {
HStack {
Text("Duration")
Slider(value: duration, in: 0.05...1.5, step: 0.01)
Text(String(format: "%.2fs", duration.wrappedValue)).monospacedDigit().frame(width: 56)
}
}
}
// MARK: - Terminal
struct TerminalSettingsView: View {
@AppStorage(NotchSettings.Keys.terminalFontSize) private var fontSize = NotchSettings.Defaults.terminalFontSize
@AppStorage(NotchSettings.Keys.terminalShell) private var shellPath = NotchSettings.Defaults.terminalShell
@AppStorage(NotchSettings.Keys.terminalTheme) private var theme = NotchSettings.Defaults.terminalTheme
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
@State private var sizePresets = TerminalSizePresetStore.load()
var body: some View {
Form {
Section("Font") {
HStack {
Text("Font size")
Slider(value: $fontSize, in: 8...28, step: 1)
Text("\(Int(fontSize))pt").monospacedDigit().frame(width: 50)
}
}
Section("Colors") {
Picker("Theme", selection: $theme) {
ForEach(TerminalTheme.allCases) { terminalTheme in
Text(terminalTheme.label).tag(terminalTheme.rawValue)
}
}
Text(TerminalTheme.resolve(theme).detail)
.font(.caption)
.foregroundStyle(.secondary)
Text("Applies to normal terminal text and the ANSI palette used by tools like `ls`.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Shell") {
TextField("Shell path (empty = $SHELL)", text: $shellPath)
.textFieldStyle(.roundedBorder)
Text("Leave empty to use your default shell ($SHELL or /bin/zsh).")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Size Presets") {
ForEach($sizePresets) { $preset in
TerminalSizePresetEditor(
preset: $preset,
currentOpenWidth: openWidth,
currentOpenHeight: openHeight,
onDelete: { deletePreset(id: preset.id) },
onApply: { applyPreset(preset) }
)
}
HStack {
Button("Add Preset") {
sizePresets.append(
TerminalSizePreset(
name: "Preset \(sizePresets.count + 1)",
width: openWidth,
height: openHeight,
hotkey: TerminalSizePresetStore.suggestedHotkey(for: sizePresets)
)
)
}
Button("Reset Presets") {
sizePresets = TerminalSizePresetStore.loadDefaults()
}
}
Text("Size preset hotkeys are active when the notch is open. Default presets use ⌘⇧1, ⌘⇧2, and ⌘⇧3.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onChange(of: sizePresets) { _, newValue in
TerminalSizePresetStore.save(newValue)
}
}
private func deletePreset(id: UUID) {
sizePresets.removeAll { $0.id == id }
}
private func applyPreset(_ preset: TerminalSizePreset) {
openWidth = preset.width
openHeight = preset.height
ScreenManager.shared.applySizePreset(preset)
}
}
// MARK: - Hotkeys
struct HotkeySettingsView: View {
@State private var toggleBinding = loadBinding(NotchSettings.Keys.hotkeyToggle, fallback: .cmdReturn)
@State private var newTabBinding = loadBinding(NotchSettings.Keys.hotkeyNewTab, fallback: .cmdT)
@State private var closeTabBinding = loadBinding(NotchSettings.Keys.hotkeyCloseTab, fallback: .cmdW)
@State private var nextTabBinding = loadBinding(NotchSettings.Keys.hotkeyNextTab, fallback: .cmdShiftRB)
@State private var prevTabBinding = loadBinding(NotchSettings.Keys.hotkeyPreviousTab, fallback: .cmdShiftLB)
@State private var detachBinding = loadBinding(NotchSettings.Keys.hotkeyDetachTab, fallback: .cmdD)
var body: some View {
Form {
Section("Global") {
HotkeyRecorderView(label: "Toggle notch", binding: bindAndSave($toggleBinding, key: NotchSettings.Keys.hotkeyToggle))
}
Section("Terminal Tabs (active when notch is open)") {
HotkeyRecorderView(label: "New tab", binding: bindAndSave($newTabBinding, key: NotchSettings.Keys.hotkeyNewTab))
HotkeyRecorderView(label: "Close tab", binding: bindAndSave($closeTabBinding, key: NotchSettings.Keys.hotkeyCloseTab))
HotkeyRecorderView(label: "Next tab", binding: bindAndSave($nextTabBinding, key: NotchSettings.Keys.hotkeyNextTab))
HotkeyRecorderView(label: "Previous tab", binding: bindAndSave($prevTabBinding, key: NotchSettings.Keys.hotkeyPreviousTab))
HotkeyRecorderView(label: "Detach tab", binding: bindAndSave($detachBinding, key: NotchSettings.Keys.hotkeyDetachTab))
}
Section {
Text("⌘19 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
Button("Reset to Defaults") {
toggleBinding = .cmdReturn
newTabBinding = .cmdT
closeTabBinding = .cmdW
nextTabBinding = .cmdShiftRB
prevTabBinding = .cmdShiftLB
detachBinding = .cmdD
save(.cmdReturn, key: NotchSettings.Keys.hotkeyToggle)
save(.cmdT, key: NotchSettings.Keys.hotkeyNewTab)
save(.cmdW, key: NotchSettings.Keys.hotkeyCloseTab)
save(.cmdShiftRB, key: NotchSettings.Keys.hotkeyNextTab)
save(.cmdShiftLB, key: NotchSettings.Keys.hotkeyPreviousTab)
save(.cmdD, key: NotchSettings.Keys.hotkeyDetachTab)
}
}
}
.formStyle(.grouped)
}
/// Creates a binding that saves to UserDefaults on every change.
private func bindAndSave(_ state: Binding<HotkeyBinding>, key: String) -> Binding<HotkeyBinding> {
Binding(
get: { state.wrappedValue },
set: { newValue in
state.wrappedValue = newValue
save(newValue, key: key)
}
)
}
private func save(_ binding: HotkeyBinding, key: String) {
UserDefaults.standard.set(binding.toJSON(), forKey: key)
}
private static func loadBinding(_ key: String, fallback: HotkeyBinding) -> HotkeyBinding {
guard let json = UserDefaults.standard.string(forKey: key),
let b = HotkeyBinding.fromJSON(json) else { return fallback }
return b
}
}
private struct TerminalSizePresetEditor: View {
@Binding var preset: TerminalSizePreset
let currentOpenWidth: Double
let currentOpenHeight: Double
let onDelete: () -> Void
let onApply: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
TextField("Preset name", text: $preset.name)
.textFieldStyle(.roundedBorder)
Button(role: .destructive, action: onDelete) {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
}
HStack {
Text("Width")
TextField("Width", value: $preset.width, format: .number.precision(.fractionLength(0)))
.textFieldStyle(.roundedBorder)
.frame(width: 90)
Text("Height")
TextField("Height", value: $preset.height, format: .number.precision(.fractionLength(0)))
.textFieldStyle(.roundedBorder)
.frame(width: 90)
Spacer()
Button("Use Current Size") {
preset.width = currentOpenWidth
preset.height = currentOpenHeight
}
Button("Apply", action: onApply)
}
OptionalHotkeyRecorderView(label: "Hotkey", binding: $preset.hotkey)
}
.padding(.vertical, 4)
}
}
// MARK: - About
struct AboutSettingsView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "terminal")
.font(.system(size: 64))
.foregroundStyle(.secondary)
Text("CommandNotch")
.font(.largeTitle.bold())
Text("Version 0.3.0")
.foregroundStyle(.secondary)
Text("A drop-down terminal that lives in your notch.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Spacer()
}
.frame(maxWidth: .infinity)
.padding(.top, 40)
}
}

View File

@@ -0,0 +1,152 @@
import SwiftUI
struct TerminalSettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
private var sizePresetsBinding: Binding<[TerminalSizePreset]> {
Binding(
get: {
TerminalSizePresetStore.decodePresets(
from: settingsController.settings.terminal.sizePresetsJSON
) ?? TerminalSizePresetStore.loadDefaults()
},
set: { newValue in
settingsController.update {
$0.terminal.sizePresetsJSON = TerminalSizePresetStore.encodePresets(newValue)
}
}
)
}
var body: some View {
Form {
Section("Font") {
HStack {
Text("Font size")
Slider(value: settingsController.binding(\.terminal.fontSize), in: 8...28, step: 1)
Text("\(Int(settingsController.settings.terminal.fontSize))pt")
.monospacedDigit()
.frame(width: 50)
}
}
Section("Colors") {
Picker("Theme", selection: settingsController.binding(\.terminal.themeRawValue)) {
ForEach(TerminalTheme.allCases) { terminalTheme in
Text(terminalTheme.label).tag(terminalTheme.rawValue)
}
}
Text(settingsController.settings.terminal.theme.detail)
.font(.caption)
.foregroundStyle(.secondary)
Text("Applies to normal terminal text and the ANSI palette used by tools like `ls`.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Shell") {
TextField("Shell path (empty = $SHELL)", text: settingsController.binding(\.terminal.shellPath))
.textFieldStyle(.roundedBorder)
Text("Leave empty to use your default shell ($SHELL or /bin/zsh).")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Size Presets") {
ForEach(sizePresetsBinding) { $preset in
TerminalSizePresetEditor(
preset: $preset,
currentOpenWidth: settingsController.settings.display.openWidth,
currentOpenHeight: settingsController.settings.display.openHeight,
onDelete: { deletePreset(id: preset.id) },
onApply: { applyPreset(preset) }
)
}
HStack {
Button("Add Preset") {
var presets = sizePresetsBinding.wrappedValue
presets.append(
TerminalSizePreset(
name: "Preset \(presets.count + 1)",
width: settingsController.settings.display.openWidth,
height: settingsController.settings.display.openHeight,
hotkey: TerminalSizePresetStore.suggestedHotkey(for: presets)
)
)
sizePresetsBinding.wrappedValue = presets
}
Button("Reset Presets") {
sizePresetsBinding.wrappedValue = TerminalSizePresetStore.loadDefaults()
}
}
Text("Size preset hotkeys are active when the notch is open. Default presets use ⌘⇧1, ⌘⇧2, and ⌘⇧3.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
private func deletePreset(id: UUID) {
sizePresetsBinding.wrappedValue.removeAll { $0.id == id }
}
private func applyPreset(_ preset: TerminalSizePreset) {
settingsController.update {
$0.display.openWidth = preset.width
$0.display.openHeight = preset.height
}
ScreenManager.shared.applySizePreset(preset)
}
}
private struct TerminalSizePresetEditor: View {
@Binding var preset: TerminalSizePreset
let currentOpenWidth: Double
let currentOpenHeight: Double
let onDelete: () -> Void
let onApply: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
TextField("Preset name", text: $preset.name)
.textFieldStyle(.roundedBorder)
Button(role: .destructive, action: onDelete) {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
}
HStack {
Text("Width")
TextField("Width", value: $preset.width, format: .number.precision(.fractionLength(0)))
.textFieldStyle(.roundedBorder)
.frame(width: 90)
Text("Height")
TextField("Height", value: $preset.height, format: .number.precision(.fractionLength(0)))
.textFieldStyle(.roundedBorder)
.frame(width: 90)
Spacer()
Button("Use Current Size") {
preset.width = currentOpenWidth
preset.height = currentOpenHeight
}
Button("Apply", action: onApply)
}
OptionalHotkeyRecorderView(label: "Hotkey", binding: $preset.hotkey)
}
.padding(.vertical, 4)
}
}

View File

@@ -0,0 +1,161 @@
import SwiftUI
struct WorkspaceSwitcherView: View {
@ObservedObject var screen: ScreenContext
let orchestrator: NotchOrchestrator
@ObservedObject private var screenRegistry = ScreenRegistry.shared
@ObservedObject private var workspaceRegistry = WorkspaceRegistry.shared
@State private var isRenameAlertPresented = false
@State private var isDeleteConfirmationPresented = false
@State private var renameDraft = ""
private var currentWorkspaceSummary: WorkspaceSummary {
workspaceRegistry.summary(for: screen.workspaceID)
?? workspaceRegistry.allWorkspaceSummaries().first
?? WorkspaceSummary(id: screen.workspaceID, name: "Workspace")
}
private var deletionFallbackSummary: WorkspaceSummary? {
guard let fallbackWorkspaceID = workspaceRegistry.deletionFallbackWorkspaceID(
forDeleting: screen.workspaceID,
preferredFallback: workspaceRegistry.defaultWorkspaceID
) else {
return nil
}
return workspaceRegistry.summary(for: fallbackWorkspaceID)
}
private var assignedScreenCount: Int {
screenRegistry.assignedScreenCount(to: screen.workspaceID)
}
var body: some View {
Menu {
ForEach(workspaceRegistry.workspaceSummaries) { summary in
Button {
selectWorkspace(summary.id)
} label: {
if summary.id == screen.workspaceID {
Label(summary.name, systemImage: "checkmark")
} else {
Text(summary.name)
}
}
}
Divider()
Button("New Workspace") {
let workspaceID = workspaceRegistry.createWorkspace()
selectWorkspace(workspaceID)
}
Button("Rename Current Workspace") {
renameDraft = currentWorkspaceSummary.name
syncFocusLossSuppression(renamePresented: true, deletePresented: isDeleteConfirmationPresented)
isRenameAlertPresented = true
}
Button("Delete Current Workspace", role: .destructive) {
syncFocusLossSuppression(renamePresented: isRenameAlertPresented, deletePresented: true)
isDeleteConfirmationPresented = true
}
.disabled(!workspaceRegistry.canDeleteWorkspace(id: screen.workspaceID))
} label: {
switcherLabel
}
.menuStyle(.borderlessButton)
.accessibilityIdentifier("notch.workspace-switcher")
.accessibilityLabel("Workspace Switcher")
.accessibilityValue(currentWorkspaceSummary.name)
.fixedSize(horizontal: false, vertical: true)
.help("Switch workspace for this screen")
.alert("Rename Workspace", isPresented: $isRenameAlertPresented) {
TextField("Workspace name", text: $renameDraft)
Button("Cancel", role: .cancel) {}
Button("Save") {
workspaceRegistry.renameWorkspace(id: screen.workspaceID, to: renameDraft)
}
} message: {
Text("This only renames the shared workspace. Screens assigned to it keep following the new name.")
}
.confirmationDialog("Delete Workspace", isPresented: $isDeleteConfirmationPresented, titleVisibility: .visible) {
Button("Delete Workspace", role: .destructive) {
deleteCurrentWorkspace()
}
} message: {
Text(deleteMessage)
}
.onChange(of: isRenameAlertPresented) { _, isPresented in
syncFocusLossSuppression(renamePresented: isPresented, deletePresented: isDeleteConfirmationPresented)
}
.onChange(of: isDeleteConfirmationPresented) { _, isPresented in
syncFocusLossSuppression(renamePresented: isRenameAlertPresented, deletePresented: isPresented)
}
.onDisappear {
screen.setCloseOnFocusLossSuppressed(false)
}
}
private var deleteMessage: String {
if let fallback = deletionFallbackSummary {
return "This reassigns \(assignedScreenCount) screen\(assignedScreenCount == 1 ? "" : "s") to \(fallback.name) and closes this workspace."
}
return "At least one workspace must remain."
}
private var switcherLabel: some View {
HStack(spacing: 6) {
Image(systemName: "rectangle.3.group")
.font(.system(size: 11, weight: .medium))
Text(currentWorkspaceSummary.name)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(.white.opacity(0.7))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.white.opacity(0.08))
)
.contentShape(Rectangle())
.accessibilityElement(children: .ignore)
.accessibilityLabel("Workspace Switcher")
.accessibilityValue(currentWorkspaceSummary.name)
.accessibilityIdentifier("notch.workspace-switcher")
}
private func selectWorkspace(_ workspaceID: WorkspaceID) {
screenRegistry.assignWorkspace(workspaceID, to: screen.id)
if screen.notchState == .open {
orchestrator.open(screenID: screen.id)
}
}
private func deleteCurrentWorkspace() {
guard let fallback = screenRegistry.deleteWorkspace(
screen.workspaceID,
preferredFallback: workspaceRegistry.defaultWorkspaceID
) else {
return
}
screenRegistry.assignWorkspace(fallback, to: screen.id)
if screen.notchState == .open {
orchestrator.open(screenID: screen.id)
} else {
screen.requestTerminalFocus?()
}
}
private func syncFocusLossSuppression(renamePresented: Bool, deletePresented: Bool) {
screen.setCloseOnFocusLossSuppressed(renamePresented || deletePresented)
}
}

View File

@@ -0,0 +1,271 @@
import SwiftUI
struct WorkspacesSettingsView: View {
@ObservedObject private var workspaceRegistry = WorkspaceRegistry.shared
@ObservedObject private var screenRegistry = ScreenRegistry.shared
@State private var selectedWorkspaceID: WorkspaceID?
@State private var renameDraft = ""
@State private var isDeleteAlertPresented = false
private var effectiveSelectedWorkspaceID: WorkspaceID? {
selectedWorkspaceID ?? workspaceRegistry.workspaceSummaries.first?.id
}
private var selectedSummary: WorkspaceSummary? {
guard let effectiveSelectedWorkspaceID else { return nil }
return workspaceRegistry.summary(for: effectiveSelectedWorkspaceID)
}
private var selectedController: WorkspaceController? {
guard let effectiveSelectedWorkspaceID else { return nil }
return workspaceRegistry.controller(for: effectiveSelectedWorkspaceID)
}
private var selectedAssignedScreenIDs: [ScreenID] {
guard let effectiveSelectedWorkspaceID else { return [] }
return screenRegistry.assignedScreenIDs(to: effectiveSelectedWorkspaceID)
}
private var connectedScreenSummaries: [ConnectedScreenSummary] {
screenRegistry.connectedScreenSummaries()
}
private var activeConnectedScreenSummary: ConnectedScreenSummary? {
connectedScreenSummaries.first(where: \.isActive)
}
private var deletionFallbackSummary: WorkspaceSummary? {
guard let effectiveSelectedWorkspaceID,
let fallbackID = workspaceRegistry.deletionFallbackWorkspaceID(
forDeleting: effectiveSelectedWorkspaceID,
preferredFallback: workspaceRegistry.defaultWorkspaceID
) else {
return nil
}
return workspaceRegistry.summary(for: fallbackID)
}
var body: some View {
HStack(spacing: 20) {
List(selection: $selectedWorkspaceID) {
ForEach(workspaceRegistry.workspaceSummaries) { summary in
VStack(alignment: .leading, spacing: 4) {
Text(summary.name)
.font(.headline)
Text(usageDescription(for: summary))
.font(.caption)
.foregroundStyle(.secondary)
}
.tag(summary.id)
.accessibilityIdentifier("settings.workspace.row.\(summary.id.uuidString)")
}
}
.accessibilityIdentifier("settings.workspaces.list")
.frame(minWidth: 220, idealWidth: 240, maxWidth: 260, maxHeight: .infinity)
if let summary = selectedSummary {
Form {
Section("Identity") {
TextField("Workspace name", text: $renameDraft)
.accessibilityIdentifier("settings.workspaces.name-field")
.onSubmit {
renameSelectedWorkspace()
}
HStack {
Button("Save Name") {
renameSelectedWorkspace()
}
.accessibilityIdentifier("settings.workspaces.save-name")
Button("New Workspace") {
createWorkspace()
}
.accessibilityIdentifier("settings.workspaces.new")
}
}
Section("Usage") {
LabeledContent("Assigned screens") {
Text("\(selectedAssignedScreenIDs.count)")
.accessibilityIdentifier("settings.workspaces.assigned-count")
}
LabeledContent("Open tabs") {
Text("\(selectedController?.tabs.count ?? 0)")
}
if selectedAssignedScreenIDs.isEmpty {
Text("No screens are currently assigned to this workspace.")
.foregroundStyle(.secondary)
} else {
ForEach(selectedAssignedScreenIDs, id: \.self) { screenID in
LabeledContent("Screen") {
Text(screenID)
.font(.caption.monospaced())
}
}
}
}
Section("Shared Workspace Rules") {
Text(sharedWorkspaceDescription(for: selectedAssignedScreenIDs.count))
.foregroundStyle(.secondary)
}
Section("Connected Screens") {
if let activeScreen = activeConnectedScreenSummary {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(activeScreen.displayName)
Text(activeScreen.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
Button(activeScreen.assignedWorkspaceID == summary.id ? "Assigned Here" : "Assign Current Screen") {
screenRegistry.assignWorkspace(summary.id, to: activeScreen.id)
}
.accessibilityIdentifier("settings.workspaces.assign-current")
.disabled(activeScreen.assignedWorkspaceID == summary.id)
}
} else {
Text("No connected screens are currently available.")
.foregroundStyle(.secondary)
}
ForEach(connectedScreenSummaries.filter { !$0.isActive }) { screen in
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(screen.displayName)
Text(screen.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
Button(screen.assignedWorkspaceID == summary.id ? "Assigned Here" : "Assign Here") {
screenRegistry.assignWorkspace(summary.id, to: screen.id)
}
.accessibilityIdentifier("settings.workspaces.assign.\(screen.id)")
.disabled(screen.assignedWorkspaceID == summary.id)
}
}
}
Section("Danger Zone") {
Button("Delete Workspace", role: .destructive) {
isDeleteAlertPresented = true
}
.accessibilityIdentifier("settings.workspaces.delete")
.disabled(!workspaceRegistry.canDeleteWorkspace(id: summary.id))
if !workspaceRegistry.canDeleteWorkspace(id: summary.id) {
Text("At least one workspace must remain.")
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
} else {
ContentUnavailableView(
"No Workspaces",
systemImage: "rectangle.3.group",
description: Text("Create a workspace to start grouping tabs across screens.")
)
}
}
.onAppear {
selectInitialWorkspaceIfNeeded()
}
.onChange(of: workspaceRegistry.workspaceSummaries) { _, _ in
synchronizeSelectionWithRegistry()
}
.onChange(of: selectedWorkspaceID) { _, _ in
renameDraft = selectedSummary?.name ?? ""
}
.alert("Delete Workspace", isPresented: $isDeleteAlertPresented) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
deleteSelectedWorkspace()
}
} message: {
if let summary = selectedSummary, let fallback = deletionFallbackSummary {
Text(
"Deleting \(summary.name) reassigns its screens to \(fallback.name) and closes the workspace."
)
} else {
Text("At least one workspace must remain.")
}
}
}
private func usageDescription(for summary: WorkspaceSummary) -> String {
let screenCount = screenRegistry.assignedScreenCount(to: summary.id)
let tabCount = workspaceRegistry.controller(for: summary.id)?.tabs.count ?? 0
return "\(screenCount) screen\(screenCount == 1 ? "" : "s") · \(tabCount) tab\(tabCount == 1 ? "" : "s")"
}
private func sharedWorkspaceDescription(for screenCount: Int) -> String {
if screenCount > 1 {
return "This workspace is shared across \(screenCount) screens. Tab changes stay in sync across each assigned screen."
}
if screenCount == 1 {
return "This workspace is assigned to one screen. You can assign additional screens to share the same tabs."
}
return "Unassigned workspaces keep their tabs and can be attached to any screen later."
}
private func selectInitialWorkspaceIfNeeded() {
if selectedWorkspaceID == nil {
selectedWorkspaceID = workspaceRegistry.workspaceSummaries.first?.id
}
renameDraft = selectedSummary?.name ?? ""
}
private func synchronizeSelectionWithRegistry() {
guard let selectedWorkspaceID else {
selectInitialWorkspaceIfNeeded()
return
}
if workspaceRegistry.summary(for: selectedWorkspaceID) == nil {
self.selectedWorkspaceID = workspaceRegistry.workspaceSummaries.first?.id
}
renameDraft = selectedSummary?.name ?? ""
}
private func renameSelectedWorkspace() {
guard let effectiveSelectedWorkspaceID else { return }
workspaceRegistry.renameWorkspace(id: effectiveSelectedWorkspaceID, to: renameDraft)
renameDraft = selectedSummary?.name ?? renameDraft
}
private func createWorkspace() {
let workspaceID = workspaceRegistry.createWorkspace()
selectedWorkspaceID = workspaceID
renameDraft = workspaceRegistry.summary(for: workspaceID)?.name ?? ""
}
private func deleteSelectedWorkspace() {
guard let effectiveSelectedWorkspaceID,
let fallbackWorkspaceID = screenRegistry.deleteWorkspace(
effectiveSelectedWorkspaceID,
preferredFallback: workspaceRegistry.defaultWorkspaceID
) else {
return
}
self.selectedWorkspaceID = fallbackWorkspaceID
renameDraft = workspaceRegistry.summary(for: fallbackWorkspaceID)?.name ?? ""
}
}