diff --git a/Downterm/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate b/Downterm/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate index 17f51ba..6c75193 100644 Binary files a/Downterm/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate and b/Downterm/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Downterm/CommandNotch/Components/HotkeyRecorderView.swift b/Downterm/CommandNotch/Components/HotkeyRecorderView.swift index a28ace4..50bdcc6 100644 --- a/Downterm/CommandNotch/Components/HotkeyRecorderView.swift +++ b/Downterm/CommandNotch/Components/HotkeyRecorderView.swift @@ -28,6 +28,36 @@ struct HotkeyRecorderView: View { } } +struct OptionalHotkeyRecorderView: View { + let label: String + @Binding var binding: HotkeyBinding? + + @State private var isRecording = false + + var body: some View { + HStack { + Text(label) + .frame(width: 140, alignment: .leading) + + OptionalHotkeyRecorderField(binding: $binding, isRecording: $isRecording) + .frame(width: 120, height: 24) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isRecording ? Color.accentColor.opacity(0.15) : Color.secondary.opacity(0.1)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(isRecording ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) + ) + + Button("Clear") { + binding = nil + } + .disabled(binding == nil) + } + } +} + /// NSViewRepresentable that captures key events when focused. struct HotkeyRecorderField: NSViewRepresentable { @Binding var binding: HotkeyBinding @@ -52,6 +82,29 @@ struct HotkeyRecorderField: NSViewRepresentable { } } +struct OptionalHotkeyRecorderField: NSViewRepresentable { + @Binding var binding: HotkeyBinding? + @Binding var isRecording: Bool + + func makeNSView(context: Context) -> HotkeyNSView { + let view = HotkeyNSView() + view.onKeyRecorded = { newBinding in + binding = newBinding + isRecording = false + } + view.onFocusChanged = { focused in + isRecording = focused + } + return view + } + + func updateNSView(_ nsView: HotkeyNSView, context: Context) { + nsView.currentLabel = binding?.displayString ?? "Not set" + nsView.showRecording = isRecording + nsView.needsDisplay = true + } +} + /// The actual NSView that handles key capture. class HotkeyNSView: NSView { var currentLabel: String = "" @@ -59,21 +112,32 @@ class HotkeyNSView: NSView { var onKeyRecorded: ((HotkeyBinding) -> Void)? var onFocusChanged: ((Bool) -> Void)? + private let label: NSTextField = { + let field = NSTextField(labelWithString: "") + field.alignment = .center + field.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) + field.translatesAutoresizingMaskIntoConstraints = false + field.backgroundColor = .clear + field.isBezeled = false + field.lineBreakMode = .byTruncatingTail + return field + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupLabel() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupLabel() + } + override var acceptsFirstResponder: Bool { true } - override func draw(_ dirtyRect: NSRect) { - let text = showRecording ? "Press keys…" : currentLabel - let attrs: [NSAttributedString.Key: Any] = [ - .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium), - .foregroundColor: showRecording ? NSColor.controlAccentColor : NSColor.labelColor - ] - let str = NSAttributedString(string: text, attributes: attrs) - let size = str.size() - let point = NSPoint( - x: (bounds.width - size.width) / 2, - y: (bounds.height - size.height) / 2 - ) - str.draw(at: point) + override func layout() { + super.layout() + updateLabelAppearance() } override func mouseDown(with event: NSEvent) { @@ -108,4 +172,19 @@ class HotkeyNSView: NSView { // Resign first responder after recording window?.makeFirstResponder(nil) } + + private func setupLabel() { + addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6), + label.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + updateLabelAppearance() + } + + private func updateLabelAppearance() { + label.stringValue = showRecording ? "Press keys..." : currentLabel + label.textColor = showRecording ? .controlAccentColor : .labelColor + } } diff --git a/Downterm/CommandNotch/ContentView.swift b/Downterm/CommandNotch/ContentView.swift index f96a446..528c7f5 100644 --- a/Downterm/CommandNotch/ContentView.swift +++ b/Downterm/CommandNotch/ContentView.swift @@ -28,6 +28,8 @@ struct ContentView: View { @AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverSpringDamping = NotchSettings.Defaults.hoverSpringDamping @State private var hoverTask: Task? + @State private var resizeStartSize: CGSize? + @State private var resizeStartMouseLocation: CGPoint? private var hoverAnimation: Animation { .interactiveSpring(response: hoverSpringResponse, dampingFraction: hoverSpringDamping) @@ -53,6 +55,11 @@ struct ContentView: View { .overlay(alignment: .top) { Rectangle().fill(.black).frame(height: 1) } + .overlay(alignment: .bottomTrailing) { + if vm.notchState == .open { + resizeHandle + } + } .shadow( color: enableShadow ? Color.black.opacity(shadowOpacity) : .clear, radius: enableShadow ? shadowRadius : 0 @@ -62,8 +69,8 @@ struct ContentView: View { .opacity(notchOpacity) .blur(radius: blurRadius) .animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchState) - .animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchSize.width) - .animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchSize.height) + .animation(sizeAnimation, value: vm.notchSize.width) + .animation(sizeAnimation, value: vm.notchSize.height) .onHover { handleHover($0) } .onChange(of: vm.isCloseTransitionActive) { _, isClosing in if isClosing { @@ -74,6 +81,9 @@ struct ContentView: View { } .onDisappear { hoverTask?.cancel() + resizeStartSize = nil + resizeStartMouseLocation = nil + vm.endInteractiveResize() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .edgesIgnoringSafeArea(.all) @@ -104,6 +114,47 @@ struct ContentView: View { .background(.black) } + private var resizeHandle: some View { + ResizeHandleShape() + .stroke(.white.opacity(0.32), style: StrokeStyle(lineWidth: 1.2, lineCap: .round)) + .frame(width: 16, height: 16) + .padding(.trailing, 8) + .padding(.bottom, 8) + .contentShape(Rectangle().inset(by: -8)) + .gesture(resizeGesture) + } + + private var resizeGesture: some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { value in + if resizeStartSize == nil { + resizeStartSize = vm.notchSize + resizeStartMouseLocation = NSEvent.mouseLocation + vm.beginInteractiveResize() + } + + guard let startSize = resizeStartSize, + let startMouseLocation = resizeStartMouseLocation else { return } + let currentMouseLocation = NSEvent.mouseLocation + vm.resizeOpenNotch( + to: CGSize( + width: startSize.width + ((currentMouseLocation.x - startMouseLocation.x) * 2), + height: startSize.height + (startMouseLocation.y - currentMouseLocation.y) + ) + ) + } + .onEnded { _ in + resizeStartSize = nil + resizeStartMouseLocation = nil + vm.endInteractiveResize() + } + } + + private var sizeAnimation: Animation? { + guard !vm.isUserResizing else { return nil } + return vm.notchState == .open ? vm.openAnimation : vm.closeAnimation + } + /// Open layout: VStack with toolbar row on top, terminal in the middle, /// tab bar at the bottom. Every section has a black background. private var openContent: some View { @@ -187,3 +238,16 @@ struct ContentView: View { title.count <= 30 ? title : String(title.prefix(28)) + "…" } } + +private struct ResizeHandleShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.maxX - 10, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 10)) + path.move(to: CGPoint(x: rect.maxX - 6, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 6)) + path.move(to: CGPoint(x: rect.maxX - 2, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 2)) + return path + } +} diff --git a/Downterm/CommandNotch/Managers/HotkeyManager.swift b/Downterm/CommandNotch/Managers/HotkeyManager.swift index cee9ecc..2c9075d 100644 --- a/Downterm/CommandNotch/Managers/HotkeyManager.swift +++ b/Downterm/CommandNotch/Managers/HotkeyManager.swift @@ -18,6 +18,7 @@ class HotkeyManager { var onNextTab: (() -> Void)? var onPreviousTab: (() -> Void)? var onDetachTab: (() -> Void)? + var onApplySizePreset: ((TerminalSizePreset) -> Void)? var onSwitchToTab: ((Int) -> Void)? /// Tab-level hotkeys only fire when the notch is open. @@ -50,6 +51,9 @@ class HotkeyManager { private var detachBinding: HotkeyBinding { binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD } + private var sizePresets: [TerminalSizePreset] { + TerminalSizePresetStore.load() + } private func binding(for key: String) -> HotkeyBinding? { guard let json = UserDefaults.standard.string(forKey: key) else { return nil } @@ -211,6 +215,13 @@ class HotkeyManager { onDetachTab?() return true } + for preset in sizePresets { + guard let binding = preset.hotkey else { continue } + if binding.matches(event) { + onApplySizePreset?(preset) + return true + } + } // Cmd+1 through Cmd+9 if event.modifierFlags.contains(.command) { diff --git a/Downterm/CommandNotch/Managers/ScreenManager.swift b/Downterm/CommandNotch/Managers/ScreenManager.swift index b40a477..79c2297 100644 --- a/Downterm/CommandNotch/Managers/ScreenManager.swift +++ b/Downterm/CommandNotch/Managers/ScreenManager.swift @@ -54,6 +54,9 @@ class ScreenManager: ObservableObject { hk.onDetachTab = { [weak self] in MainActor.assumeIsolated { self?.detachActiveTab() } } + hk.onApplySizePreset = { [weak self] preset in + MainActor.assumeIsolated { self?.applySizePreset(preset) } + } hk.onSwitchToTab = { index in MainActor.assumeIsolated { tm.switchToTab(at: index) } } @@ -130,6 +133,19 @@ class ScreenManager: ObservableObject { } } + func applySizePreset(_ preset: TerminalSizePreset) { + guard let (screenUUID, vm) = viewModels.first(where: { $0.value.notchState == .open }) else { + UserDefaults.standard.set(preset.width, forKey: NotchSettings.Keys.openWidth) + UserDefaults.standard.set(preset.height, forKey: NotchSettings.Keys.openHeight) + return + } + + withAnimation(vm.openAnimation) { + vm.applySizePreset(preset, notifyWindowResize: false) + } + updateWindowFrame(for: screenUUID, centerHorizontally: true) + } + // MARK: - Window creation func rebuildWindows() { @@ -149,21 +165,10 @@ class ScreenManager: ObservableObject { private func createWindow(for screen: NSScreen) { let uuid = screen.displayUUID let vm = NotchViewModel(screenUUID: uuid) - - let shadowPadding: CGFloat = 20 - let openSize = vm.openNotchSize - let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5) - let windowHeight = openSize.height + shadowPadding - - let windowRect = NSRect( - x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2, - y: screen.frame.origin.y + screen.frame.height - windowHeight, - width: windowWidth, - height: windowHeight - ) + let initialContentSize = vm.openNotchSize let window = NotchWindow( - contentRect: windowRect, + contentRect: NSRect(origin: .zero, size: CGSize(width: initialContentSize.width + 40, height: initialContentSize.height + 20)), styleMask: [.borderless, .nonactivatingPanel, .utilityWindow], backing: .buffered, defer: false @@ -181,19 +186,29 @@ class ScreenManager: ObservableObject { vm.requestClose = { [weak self] in self?.closeNotch(screenUUID: uuid) } + vm.requestWindowResize = { [weak self] in + self?.updateWindowFrame(for: uuid, centerHorizontally: true) + } let hostingView = NSHostingView( rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared) .preferredColorScheme(.dark) ) - hostingView.frame = NSRect(origin: .zero, size: windowRect.size) - window.contentView = hostingView + let containerView = NSView(frame: NSRect(origin: .zero, size: window.frame.size)) + containerView.autoresizesSubviews = true + containerView.wantsLayer = true + containerView.layer?.backgroundColor = NSColor.clear.cgColor - window.setFrame(windowRect, display: true) - window.orderFrontRegardless() + hostingView.frame = containerView.bounds + hostingView.autoresizingMask = [.width, .height] + containerView.addSubview(hostingView) + window.contentView = containerView windows[uuid] = window viewModels[uuid] = vm + + updateWindowFrame(for: uuid, centerHorizontally: true) + window.orderFrontRegardless() } // MARK: - Repositioning @@ -205,21 +220,44 @@ class ScreenManager: ObservableObject { vm.refreshClosedSize() - let shadowPadding: CGFloat = 20 - let openSize = vm.openNotchSize - let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5) - let windowHeight = openSize.height + shadowPadding - - let newFrame = NSRect( - x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2, - y: screen.frame.origin.y + screen.frame.height - windowHeight, - width: windowWidth, - height: windowHeight - ) - window.setFrame(newFrame, display: true) + updateWindowFrame(for: uuid, on: screen, window: window, centerHorizontally: true) } } + private func updateWindowFrame(for screenUUID: String, centerHorizontally: Bool = false) { + guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }), + let window = windows[screenUUID] else { return } + updateWindowFrame(for: screenUUID, on: screen, window: window, centerHorizontally: centerHorizontally) + } + + private func updateWindowFrame( + for screenUUID: String, + on screen: NSScreen, + window: NotchWindow, + centerHorizontally: Bool = false + ) { + guard let vm = viewModels[screenUUID] else { return } + + let shadowPadding: CGFloat = 20 + let openSize = vm.openNotchSize + let windowWidth = openSize.width + 40 + let windowHeight = openSize.height + shadowPadding + let centeredX = screen.frame.origin.x + (screen.frame.width - windowWidth) / 2 + + let x: CGFloat = centerHorizontally || vm.notchState == .closed + ? centeredX + : min(max(window.frame.minX, screen.frame.minX), screen.frame.maxX - windowWidth) + + let frame = NSRect( + x: x, + y: screen.frame.origin.y + screen.frame.height - windowHeight, + width: windowWidth, + height: windowHeight + ) + guard !window.frame.equalTo(frame) else { return } + window.setFrame(frame, display: false) + } + // MARK: - Cleanup private func cleanupAllWindows() { diff --git a/Downterm/CommandNotch/Models/HotkeyBinding.swift b/Downterm/CommandNotch/Models/HotkeyBinding.swift index f484d7f..0e767fd 100644 --- a/Downterm/CommandNotch/Models/HotkeyBinding.swift +++ b/Downterm/CommandNotch/Models/HotkeyBinding.swift @@ -3,7 +3,7 @@ import Carbon.HIToolbox /// Serializable representation of a keyboard shortcut (modifier flags + key code). /// Stored in UserDefaults as a JSON string. -struct HotkeyBinding: Codable, Equatable { +struct HotkeyBinding: Codable, Equatable, Hashable { var modifiers: UInt // NSEvent.ModifierFlags.rawValue, masked to cmd/shift/ctrl/opt var keyCode: UInt16 @@ -89,4 +89,25 @@ struct HotkeyBinding: Codable, Equatable { static let cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ] static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [ static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2) + + static func cmdShiftDigit(_ digit: Int) -> HotkeyBinding? { + guard let keyCode = keyCode(forDigit: digit) else { return nil } + return HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: keyCode) + } + + static func keyCode(forDigit digit: Int) -> UInt16? { + switch digit { + case 0: return 29 + case 1: return 18 + case 2: return 19 + case 3: return 20 + case 4: return 21 + case 5: return 23 + case 6: return 22 + case 7: return 26 + case 8: return 28 + case 9: return 25 + default: return nil + } + } } diff --git a/Downterm/CommandNotch/Models/NotchSettings.swift b/Downterm/CommandNotch/Models/NotchSettings.swift index 892c258..0c2d4f8 100644 --- a/Downterm/CommandNotch/Models/NotchSettings.swift +++ b/Downterm/CommandNotch/Models/NotchSettings.swift @@ -1,4 +1,5 @@ import Foundation +import AppKit /// Central registry of all user-configurable notch settings. enum NotchSettings { @@ -45,6 +46,7 @@ enum NotchSettings { static let terminalFontSize = "terminalFontSize" static let terminalShell = "terminalShell" static let terminalTheme = "terminalTheme" + static let terminalSizePresets = "terminalSizePresets" // Hotkeys — each stores a HotkeyBinding JSON string static let hotkeyToggle = "hotkey_toggle" @@ -90,6 +92,7 @@ enum NotchSettings { static let terminalFontSize: Double = 13 static let terminalShell: String = "" static let terminalTheme: String = TerminalTheme.terminalApp.rawValue + static let terminalSizePresets: String = TerminalSizePresetStore.defaultPresetsJSON() // Default hotkey bindings as JSON static let hotkeyToggle: String = HotkeyBinding.cmdReturn.toJSON() @@ -136,6 +139,7 @@ enum NotchSettings { Keys.terminalFontSize: Defaults.terminalFontSize, Keys.terminalShell: Defaults.terminalShell, Keys.terminalTheme: Defaults.terminalTheme, + Keys.terminalSizePresets: Defaults.terminalSizePresets, Keys.hotkeyToggle: Defaults.hotkeyToggle, Keys.hotkeyNewTab: Defaults.hotkeyNewTab, @@ -174,3 +178,82 @@ enum NonNotchHeightMode: Int, CaseIterable, Identifiable { } } } + +struct TerminalSizePreset: Codable, Equatable, Identifiable { + var id: UUID + var name: String + var width: Double + var height: Double + var hotkey: HotkeyBinding? + + init( + id: UUID = UUID(), + name: String, + width: Double, + height: Double, + hotkey: HotkeyBinding? = nil + ) { + self.id = id + self.name = name + self.width = width + self.height = height + self.hotkey = hotkey + } + + var size: CGSize { + CGSize(width: width, height: height) + } +} + +enum TerminalSizePresetStore { + static func load() -> [TerminalSizePreset] { + let defaults = UserDefaults.standard + guard let json = defaults.string(forKey: NotchSettings.Keys.terminalSizePresets), + let data = json.data(using: .utf8), + let presets = try? JSONDecoder().decode([TerminalSizePreset].self, from: data) else { + return defaultPresets() + } + return presets + } + + static func save(_ presets: [TerminalSizePreset]) { + guard let data = try? JSONEncoder().encode(presets), + let json = String(data: data, encoding: .utf8) else { return } + UserDefaults.standard.set(json, forKey: NotchSettings.Keys.terminalSizePresets) + } + + static func reset() { + save(defaultPresets()) + } + + static func loadDefaults() -> [TerminalSizePreset] { + defaultPresets() + } + + static func defaultPresetsJSON() -> String { + guard let data = try? JSONEncoder().encode(defaultPresets()), + let json = String(data: data, encoding: .utf8) else { + return "[]" + } + return json + } + + static func suggestedHotkey(for presets: [TerminalSizePreset]) -> HotkeyBinding? { + let used = Set(presets.compactMap(\.hotkey)) + for digit in 1...9 { + guard let candidate = HotkeyBinding.cmdShiftDigit(digit) else { continue } + if !used.contains(candidate) { + return candidate + } + } + return nil + } + + private static func defaultPresets() -> [TerminalSizePreset] { + [ + TerminalSizePreset(name: "Compact", width: 480, height: 220, hotkey: HotkeyBinding.cmdShiftDigit(1)), + TerminalSizePreset(name: "Default", width: 640, height: 350, hotkey: HotkeyBinding.cmdShiftDigit(2)), + TerminalSizePreset(name: "Large", width: 900, height: 500, hotkey: HotkeyBinding.cmdShiftDigit(3)), + ] + } +} diff --git a/Downterm/CommandNotch/Models/NotchViewModel.swift b/Downterm/CommandNotch/Models/NotchViewModel.swift index 7e90f1c..667d6ef 100644 --- a/Downterm/CommandNotch/Models/NotchViewModel.swift +++ b/Downterm/CommandNotch/Models/NotchViewModel.swift @@ -4,6 +4,10 @@ import Combine /// Per-screen observable state that drives the notch UI. @MainActor class NotchViewModel: ObservableObject { + private static let minimumOpenWidth: CGFloat = 320 + private static let minimumOpenHeight: CGFloat = 140 + private static let windowHorizontalPadding: CGFloat = 40 + private static let windowVerticalPadding: CGFloat = 20 let screenUUID: String @@ -13,6 +17,7 @@ class NotchViewModel: ObservableObject { @Published var isHovering: Bool = false @Published var isCloseTransitionActive: Bool = false @Published var suppressHoverOpenUntilHoverExit: Bool = false + @Published var isUserResizing: Bool = false let terminalManager = TerminalManager.shared @@ -20,6 +25,7 @@ class NotchViewModel: ObservableObject { /// window activation so the terminal receives keyboard input. var requestOpen: (() -> Void)? var requestClose: (() -> Void)? + var requestWindowResize: (() -> Void)? private var cancellables = Set() @@ -49,7 +55,10 @@ class NotchViewModel: ObservableObject { } func open() { - notchSize = CGSize(width: openWidth, height: openHeight) + let size = openNotchSize + openWidth = size.width + openHeight = size.height + notchSize = size notchState = .open } @@ -65,7 +74,58 @@ class NotchViewModel: ObservableObject { } var openNotchSize: CGSize { - CGSize(width: openWidth, height: openHeight) + clampedOpenSize(CGSize(width: openWidth, height: openHeight)) + } + + func beginInteractiveResize() { + isUserResizing = true + } + + func resizeOpenNotch(to proposedSize: CGSize) { + setOpenSize(proposedSize, notifyWindowResize: true) + } + + func endInteractiveResize() { + isUserResizing = false + } + + func applySizePreset(_ preset: TerminalSizePreset, notifyWindowResize: Bool = true) { + setOpenSize(preset.size, notifyWindowResize: notifyWindowResize) + } + + @discardableResult + func setOpenSize(_ proposedSize: CGSize, notifyWindowResize: Bool) -> CGSize { + let clampedSize = clampedOpenSize(proposedSize) + openWidth = clampedSize.width + openHeight = clampedSize.height + if notchState == .open { + notchSize = clampedSize + } + if notifyWindowResize { + requestWindowResize?() + } + return clampedSize + } + + private func clampedOpenSize(_ size: CGSize) -> CGSize { + CGSize( + width: size.width.clamped(to: Self.minimumOpenWidth...maximumAllowedWidth), + height: size.height.clamped(to: Self.minimumOpenHeight...maximumAllowedHeight) + ) + } + + private var maximumAllowedWidth: CGFloat { + guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }) ?? NSScreen.main else { + return Self.minimumOpenWidth + } + return max(Self.minimumOpenWidth, screen.frame.width - Self.windowHorizontalPadding) + } + + private var maximumAllowedHeight: CGFloat { + guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }) ?? NSScreen.main else { + return Self.minimumOpenHeight + } + return max(Self.minimumOpenHeight, screen.frame.height - Self.windowVerticalPadding) } var closeInteractionLockDuration: TimeInterval { @@ -102,3 +162,9 @@ class NotchViewModel: ObservableObject { closeTransitionTask?.cancel() } } + +private extension CGFloat { + func clamped(to range: ClosedRange) -> CGFloat { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/Downterm/CommandNotch/Views/SettingsView.swift b/Downterm/CommandNotch/Views/SettingsView.swift index fa33e86..3fc55b1 100644 --- a/Downterm/CommandNotch/Views/SettingsView.swift +++ b/Downterm/CommandNotch/Views/SettingsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit /// Tabbed settings panel with General, Appearance, Animation, Terminal, Hotkeys, and About. struct SettingsView: View { @@ -85,6 +86,14 @@ struct GeneralSettingsView: View { @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") { @@ -146,12 +155,12 @@ struct GeneralSettingsView: View { Section("Open Notch Size") { HStack { Text("Width") - Slider(value: $openWidth, in: 300...1200, step: 10) + Slider(value: $openWidth, in: 320...maxOpenWidth, step: 10) Text("\(Int(openWidth))pt").monospacedDigit().frame(width: 60) } HStack { Text("Height") - Slider(value: $openHeight, in: 100...600, step: 10) + Slider(value: $openHeight, in: 140...maxOpenHeight, step: 10) Text("\(Int(openHeight))pt").monospacedDigit().frame(width: 60) } } @@ -266,6 +275,10 @@ 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 { @@ -298,8 +311,54 @@ struct TerminalSettingsView: View { .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) } } @@ -329,7 +388,7 @@ struct HotkeySettingsView: View { } Section { - Text("⌘1–9 always switch to tab by number.") + Text("⌘1–9 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets.") .font(.caption) .foregroundStyle(.secondary) } @@ -377,6 +436,52 @@ struct HotkeySettingsView: View { } } +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 {