Improve resizing with draggable and hotkeys
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user