Files
downterm/Downterm/Downterm/Views/SettingsView.swift
2026-02-27 11:57:09 +11:00

381 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
/// Tabbed settings panel with General, Appearance, Animation, Terminal, Hotkeys, and About.
struct SettingsView: View {
@State private var selectedTab: SettingsTab = .general
var body: some View {
NavigationSplitView {
List(SettingsTab.allCases, selection: $selectedTab) { tab in
Label(tab.label, systemImage: tab.icon)
.tag(tab)
}
.listStyle(.sidebar)
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
} detail: {
ScrollView {
detailView.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minWidth: 600, minHeight: 400)
}
@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()
}
}
}
// MARK: - Tabs
enum SettingsTab: String, CaseIterable, Identifiable {
case general, appearance, 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"
}
}
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"
}
}
}
// 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.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
var body: some View {
Form {
Section("Display") {
Toggle("Show on all displays", isOn: $showOnAllDisplays)
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
}
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: 300...1200, step: 10)
Text("\(Int(openWidth))pt").monospacedDigit().frame(width: 60)
}
HStack {
Text("Height")
Slider(value: $openHeight, in: 100...600, 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
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 {
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
}
}
}
.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)
}
}
}
// 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
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("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)
}
}
.formStyle(.grouped)
}
}
// 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.")
.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
}
}
// MARK: - About
struct AboutSettingsView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "terminal")
.font(.system(size: 64))
.foregroundStyle(.secondary)
Text("Downterm")
.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)
}
}