From 256998eb9fb281c9477fcce63f0e58db4c5c9636 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Thu, 12 Mar 2026 23:57:31 +1100 Subject: [PATCH] Improve resizing with draggable and hotkeys --- .../UserInterfaceState.xcuserstate | Bin 34290 -> 40682 bytes .../Components/HotkeyRecorderView.swift | 105 +++++++++++++++-- Downterm/CommandNotch/ContentView.swift | 68 ++++++++++- .../CommandNotch/Managers/HotkeyManager.swift | 11 ++ .../CommandNotch/Managers/ScreenManager.swift | 96 ++++++++++----- .../CommandNotch/Models/HotkeyBinding.swift | 23 +++- .../CommandNotch/Models/NotchSettings.swift | 83 +++++++++++++ .../CommandNotch/Models/NotchViewModel.swift | 70 ++++++++++- .../CommandNotch/Views/SettingsView.swift | 111 +++++++++++++++++- 9 files changed, 517 insertions(+), 50 deletions(-) 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 17f51ba7bfec0e042d5971cd2e0ff9c78d070c5c..6c75193a6eef659879dbe46d92db9db7395384ce 100644 GIT binary patch delta 22234 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 delta 17354 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 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 {