Improve resizing with draggable and hotkeys

This commit is contained in:
2026-03-12 23:57:31 +11:00
parent 9d05bc586a
commit 256998eb9f
9 changed files with 517 additions and 50 deletions

View File

@@ -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
}
}
}

View File

@@ -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)),
]
}
}

View File

@@ -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<AnyCancellable>()
@@ -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>) -> CGFloat {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}