Files
downterm/Downterm/CommandNotch/Views/SettingsView.swift

507 lines
21 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
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 {
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.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
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
@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)
}
}