Yep. AI rewrote the whole thing.
This commit is contained in:
29
Downterm/CommandNotch/Views/AboutSettingsView.swift
Normal file
29
Downterm/CommandNotch/Views/AboutSettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
72
Downterm/CommandNotch/Views/AnimationSettingsView.swift
Normal file
72
Downterm/CommandNotch/Views/AnimationSettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
51
Downterm/CommandNotch/Views/AppearanceSettingsView.swift
Normal file
51
Downterm/CommandNotch/Views/AppearanceSettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
107
Downterm/CommandNotch/Views/GeneralSettingsView.swift
Normal file
107
Downterm/CommandNotch/Views/GeneralSettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
36
Downterm/CommandNotch/Views/HotkeySettingsView.swift
Normal file
36
Downterm/CommandNotch/Views/HotkeySettingsView.swift
Normal 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("⌘1–9 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)
|
||||
}
|
||||
}
|
||||
13
Downterm/CommandNotch/Views/SettingsBindings.swift
Normal file
13
Downterm/CommandNotch/Views/SettingsBindings.swift
Normal 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 }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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("⌘1–9 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)
|
||||
}
|
||||
}
|
||||
|
||||
152
Downterm/CommandNotch/Views/TerminalSettingsView.swift
Normal file
152
Downterm/CommandNotch/Views/TerminalSettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
161
Downterm/CommandNotch/Views/WorkspaceSwitcherView.swift
Normal file
161
Downterm/CommandNotch/Views/WorkspaceSwitcherView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
271
Downterm/CommandNotch/Views/WorkspacesSettingsView.swift
Normal file
271
Downterm/CommandNotch/Views/WorkspacesSettingsView.swift
Normal 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 ?? ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user