Improve resizing with draggable and hotkeys
This commit is contained in:
Binary file not shown.
@@ -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.
|
/// NSViewRepresentable that captures key events when focused.
|
||||||
struct HotkeyRecorderField: NSViewRepresentable {
|
struct HotkeyRecorderField: NSViewRepresentable {
|
||||||
@Binding var binding: HotkeyBinding
|
@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.
|
/// The actual NSView that handles key capture.
|
||||||
class HotkeyNSView: NSView {
|
class HotkeyNSView: NSView {
|
||||||
var currentLabel: String = ""
|
var currentLabel: String = ""
|
||||||
@@ -59,21 +112,32 @@ class HotkeyNSView: NSView {
|
|||||||
var onKeyRecorded: ((HotkeyBinding) -> Void)?
|
var onKeyRecorded: ((HotkeyBinding) -> Void)?
|
||||||
var onFocusChanged: ((Bool) -> 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 var acceptsFirstResponder: Bool { true }
|
||||||
|
|
||||||
override func draw(_ dirtyRect: NSRect) {
|
override func layout() {
|
||||||
let text = showRecording ? "Press keys…" : currentLabel
|
super.layout()
|
||||||
let attrs: [NSAttributedString.Key: Any] = [
|
updateLabelAppearance()
|
||||||
.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 mouseDown(with event: NSEvent) {
|
override func mouseDown(with event: NSEvent) {
|
||||||
@@ -108,4 +172,19 @@ class HotkeyNSView: NSView {
|
|||||||
// Resign first responder after recording
|
// Resign first responder after recording
|
||||||
window?.makeFirstResponder(nil)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ struct ContentView: View {
|
|||||||
@AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverSpringDamping = NotchSettings.Defaults.hoverSpringDamping
|
@AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverSpringDamping = NotchSettings.Defaults.hoverSpringDamping
|
||||||
|
|
||||||
@State private var hoverTask: Task<Void, Never>?
|
@State private var hoverTask: Task<Void, Never>?
|
||||||
|
@State private var resizeStartSize: CGSize?
|
||||||
|
@State private var resizeStartMouseLocation: CGPoint?
|
||||||
|
|
||||||
private var hoverAnimation: Animation {
|
private var hoverAnimation: Animation {
|
||||||
.interactiveSpring(response: hoverSpringResponse, dampingFraction: hoverSpringDamping)
|
.interactiveSpring(response: hoverSpringResponse, dampingFraction: hoverSpringDamping)
|
||||||
@@ -53,6 +55,11 @@ struct ContentView: View {
|
|||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
Rectangle().fill(.black).frame(height: 1)
|
Rectangle().fill(.black).frame(height: 1)
|
||||||
}
|
}
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
if vm.notchState == .open {
|
||||||
|
resizeHandle
|
||||||
|
}
|
||||||
|
}
|
||||||
.shadow(
|
.shadow(
|
||||||
color: enableShadow ? Color.black.opacity(shadowOpacity) : .clear,
|
color: enableShadow ? Color.black.opacity(shadowOpacity) : .clear,
|
||||||
radius: enableShadow ? shadowRadius : 0
|
radius: enableShadow ? shadowRadius : 0
|
||||||
@@ -62,8 +69,8 @@ struct ContentView: View {
|
|||||||
.opacity(notchOpacity)
|
.opacity(notchOpacity)
|
||||||
.blur(radius: blurRadius)
|
.blur(radius: blurRadius)
|
||||||
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchState)
|
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchState)
|
||||||
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchSize.width)
|
.animation(sizeAnimation, value: vm.notchSize.width)
|
||||||
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchSize.height)
|
.animation(sizeAnimation, value: vm.notchSize.height)
|
||||||
.onHover { handleHover($0) }
|
.onHover { handleHover($0) }
|
||||||
.onChange(of: vm.isCloseTransitionActive) { _, isClosing in
|
.onChange(of: vm.isCloseTransitionActive) { _, isClosing in
|
||||||
if isClosing {
|
if isClosing {
|
||||||
@@ -74,6 +81,9 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
hoverTask?.cancel()
|
hoverTask?.cancel()
|
||||||
|
resizeStartSize = nil
|
||||||
|
resizeStartMouseLocation = nil
|
||||||
|
vm.endInteractiveResize()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.edgesIgnoringSafeArea(.all)
|
.edgesIgnoringSafeArea(.all)
|
||||||
@@ -104,6 +114,47 @@ struct ContentView: View {
|
|||||||
.background(.black)
|
.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,
|
/// Open layout: VStack with toolbar row on top, terminal in the middle,
|
||||||
/// tab bar at the bottom. Every section has a black background.
|
/// tab bar at the bottom. Every section has a black background.
|
||||||
private var openContent: some View {
|
private var openContent: some View {
|
||||||
@@ -187,3 +238,16 @@ struct ContentView: View {
|
|||||||
title.count <= 30 ? title : String(title.prefix(28)) + "…"
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class HotkeyManager {
|
|||||||
var onNextTab: (() -> Void)?
|
var onNextTab: (() -> Void)?
|
||||||
var onPreviousTab: (() -> Void)?
|
var onPreviousTab: (() -> Void)?
|
||||||
var onDetachTab: (() -> Void)?
|
var onDetachTab: (() -> Void)?
|
||||||
|
var onApplySizePreset: ((TerminalSizePreset) -> Void)?
|
||||||
var onSwitchToTab: ((Int) -> Void)?
|
var onSwitchToTab: ((Int) -> Void)?
|
||||||
|
|
||||||
/// Tab-level hotkeys only fire when the notch is open.
|
/// Tab-level hotkeys only fire when the notch is open.
|
||||||
@@ -50,6 +51,9 @@ class HotkeyManager {
|
|||||||
private var detachBinding: HotkeyBinding {
|
private var detachBinding: HotkeyBinding {
|
||||||
binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD
|
binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD
|
||||||
}
|
}
|
||||||
|
private var sizePresets: [TerminalSizePreset] {
|
||||||
|
TerminalSizePresetStore.load()
|
||||||
|
}
|
||||||
|
|
||||||
private func binding(for key: String) -> HotkeyBinding? {
|
private func binding(for key: String) -> HotkeyBinding? {
|
||||||
guard let json = UserDefaults.standard.string(forKey: key) else { return nil }
|
guard let json = UserDefaults.standard.string(forKey: key) else { return nil }
|
||||||
@@ -211,6 +215,13 @@ class HotkeyManager {
|
|||||||
onDetachTab?()
|
onDetachTab?()
|
||||||
return true
|
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
|
// Cmd+1 through Cmd+9
|
||||||
if event.modifierFlags.contains(.command) {
|
if event.modifierFlags.contains(.command) {
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ class ScreenManager: ObservableObject {
|
|||||||
hk.onDetachTab = { [weak self] in
|
hk.onDetachTab = { [weak self] in
|
||||||
MainActor.assumeIsolated { self?.detachActiveTab() }
|
MainActor.assumeIsolated { self?.detachActiveTab() }
|
||||||
}
|
}
|
||||||
|
hk.onApplySizePreset = { [weak self] preset in
|
||||||
|
MainActor.assumeIsolated { self?.applySizePreset(preset) }
|
||||||
|
}
|
||||||
hk.onSwitchToTab = { index in
|
hk.onSwitchToTab = { index in
|
||||||
MainActor.assumeIsolated { tm.switchToTab(at: index) }
|
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
|
// MARK: - Window creation
|
||||||
|
|
||||||
func rebuildWindows() {
|
func rebuildWindows() {
|
||||||
@@ -149,21 +165,10 @@ class ScreenManager: ObservableObject {
|
|||||||
private func createWindow(for screen: NSScreen) {
|
private func createWindow(for screen: NSScreen) {
|
||||||
let uuid = screen.displayUUID
|
let uuid = screen.displayUUID
|
||||||
let vm = NotchViewModel(screenUUID: uuid)
|
let vm = NotchViewModel(screenUUID: uuid)
|
||||||
|
let initialContentSize = vm.openNotchSize
|
||||||
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 window = NotchWindow(
|
let window = NotchWindow(
|
||||||
contentRect: windowRect,
|
contentRect: NSRect(origin: .zero, size: CGSize(width: initialContentSize.width + 40, height: initialContentSize.height + 20)),
|
||||||
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
|
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false
|
defer: false
|
||||||
@@ -181,19 +186,29 @@ class ScreenManager: ObservableObject {
|
|||||||
vm.requestClose = { [weak self] in
|
vm.requestClose = { [weak self] in
|
||||||
self?.closeNotch(screenUUID: uuid)
|
self?.closeNotch(screenUUID: uuid)
|
||||||
}
|
}
|
||||||
|
vm.requestWindowResize = { [weak self] in
|
||||||
|
self?.updateWindowFrame(for: uuid, centerHorizontally: true)
|
||||||
|
}
|
||||||
|
|
||||||
let hostingView = NSHostingView(
|
let hostingView = NSHostingView(
|
||||||
rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared)
|
rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared)
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
)
|
)
|
||||||
hostingView.frame = NSRect(origin: .zero, size: windowRect.size)
|
let containerView = NSView(frame: NSRect(origin: .zero, size: window.frame.size))
|
||||||
window.contentView = hostingView
|
containerView.autoresizesSubviews = true
|
||||||
|
containerView.wantsLayer = true
|
||||||
|
containerView.layer?.backgroundColor = NSColor.clear.cgColor
|
||||||
|
|
||||||
window.setFrame(windowRect, display: true)
|
hostingView.frame = containerView.bounds
|
||||||
window.orderFrontRegardless()
|
hostingView.autoresizingMask = [.width, .height]
|
||||||
|
containerView.addSubview(hostingView)
|
||||||
|
window.contentView = containerView
|
||||||
|
|
||||||
windows[uuid] = window
|
windows[uuid] = window
|
||||||
viewModels[uuid] = vm
|
viewModels[uuid] = vm
|
||||||
|
|
||||||
|
updateWindowFrame(for: uuid, centerHorizontally: true)
|
||||||
|
window.orderFrontRegardless()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Repositioning
|
// MARK: - Repositioning
|
||||||
@@ -205,19 +220,42 @@ class ScreenManager: ObservableObject {
|
|||||||
|
|
||||||
vm.refreshClosedSize()
|
vm.refreshClosedSize()
|
||||||
|
|
||||||
|
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 shadowPadding: CGFloat = 20
|
||||||
let openSize = vm.openNotchSize
|
let openSize = vm.openNotchSize
|
||||||
let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5)
|
let windowWidth = openSize.width + 40
|
||||||
let windowHeight = openSize.height + shadowPadding
|
let windowHeight = openSize.height + shadowPadding
|
||||||
|
let centeredX = screen.frame.origin.x + (screen.frame.width - windowWidth) / 2
|
||||||
|
|
||||||
let newFrame = NSRect(
|
let x: CGFloat = centerHorizontally || vm.notchState == .closed
|
||||||
x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2,
|
? 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,
|
y: screen.frame.origin.y + screen.frame.height - windowHeight,
|
||||||
width: windowWidth,
|
width: windowWidth,
|
||||||
height: windowHeight
|
height: windowHeight
|
||||||
)
|
)
|
||||||
window.setFrame(newFrame, display: true)
|
guard !window.frame.equalTo(frame) else { return }
|
||||||
}
|
window.setFrame(frame, display: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Carbon.HIToolbox
|
|||||||
|
|
||||||
/// Serializable representation of a keyboard shortcut (modifier flags + key code).
|
/// Serializable representation of a keyboard shortcut (modifier flags + key code).
|
||||||
/// Stored in UserDefaults as a JSON string.
|
/// 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 modifiers: UInt // NSEvent.ModifierFlags.rawValue, masked to cmd/shift/ctrl/opt
|
||||||
var keyCode: UInt16
|
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 cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ]
|
||||||
static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [
|
static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [
|
||||||
static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2)
|
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 Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
/// Central registry of all user-configurable notch settings.
|
/// Central registry of all user-configurable notch settings.
|
||||||
enum NotchSettings {
|
enum NotchSettings {
|
||||||
@@ -45,6 +46,7 @@ enum NotchSettings {
|
|||||||
static let terminalFontSize = "terminalFontSize"
|
static let terminalFontSize = "terminalFontSize"
|
||||||
static let terminalShell = "terminalShell"
|
static let terminalShell = "terminalShell"
|
||||||
static let terminalTheme = "terminalTheme"
|
static let terminalTheme = "terminalTheme"
|
||||||
|
static let terminalSizePresets = "terminalSizePresets"
|
||||||
|
|
||||||
// Hotkeys — each stores a HotkeyBinding JSON string
|
// Hotkeys — each stores a HotkeyBinding JSON string
|
||||||
static let hotkeyToggle = "hotkey_toggle"
|
static let hotkeyToggle = "hotkey_toggle"
|
||||||
@@ -90,6 +92,7 @@ enum NotchSettings {
|
|||||||
static let terminalFontSize: Double = 13
|
static let terminalFontSize: Double = 13
|
||||||
static let terminalShell: String = ""
|
static let terminalShell: String = ""
|
||||||
static let terminalTheme: String = TerminalTheme.terminalApp.rawValue
|
static let terminalTheme: String = TerminalTheme.terminalApp.rawValue
|
||||||
|
static let terminalSizePresets: String = TerminalSizePresetStore.defaultPresetsJSON()
|
||||||
|
|
||||||
// Default hotkey bindings as JSON
|
// Default hotkey bindings as JSON
|
||||||
static let hotkeyToggle: String = HotkeyBinding.cmdReturn.toJSON()
|
static let hotkeyToggle: String = HotkeyBinding.cmdReturn.toJSON()
|
||||||
@@ -136,6 +139,7 @@ enum NotchSettings {
|
|||||||
Keys.terminalFontSize: Defaults.terminalFontSize,
|
Keys.terminalFontSize: Defaults.terminalFontSize,
|
||||||
Keys.terminalShell: Defaults.terminalShell,
|
Keys.terminalShell: Defaults.terminalShell,
|
||||||
Keys.terminalTheme: Defaults.terminalTheme,
|
Keys.terminalTheme: Defaults.terminalTheme,
|
||||||
|
Keys.terminalSizePresets: Defaults.terminalSizePresets,
|
||||||
|
|
||||||
Keys.hotkeyToggle: Defaults.hotkeyToggle,
|
Keys.hotkeyToggle: Defaults.hotkeyToggle,
|
||||||
Keys.hotkeyNewTab: Defaults.hotkeyNewTab,
|
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.
|
/// Per-screen observable state that drives the notch UI.
|
||||||
@MainActor
|
@MainActor
|
||||||
class NotchViewModel: ObservableObject {
|
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
|
let screenUUID: String
|
||||||
|
|
||||||
@@ -13,6 +17,7 @@ class NotchViewModel: ObservableObject {
|
|||||||
@Published var isHovering: Bool = false
|
@Published var isHovering: Bool = false
|
||||||
@Published var isCloseTransitionActive: Bool = false
|
@Published var isCloseTransitionActive: Bool = false
|
||||||
@Published var suppressHoverOpenUntilHoverExit: Bool = false
|
@Published var suppressHoverOpenUntilHoverExit: Bool = false
|
||||||
|
@Published var isUserResizing: Bool = false
|
||||||
|
|
||||||
let terminalManager = TerminalManager.shared
|
let terminalManager = TerminalManager.shared
|
||||||
|
|
||||||
@@ -20,6 +25,7 @@ class NotchViewModel: ObservableObject {
|
|||||||
/// window activation so the terminal receives keyboard input.
|
/// window activation so the terminal receives keyboard input.
|
||||||
var requestOpen: (() -> Void)?
|
var requestOpen: (() -> Void)?
|
||||||
var requestClose: (() -> Void)?
|
var requestClose: (() -> Void)?
|
||||||
|
var requestWindowResize: (() -> Void)?
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
@@ -49,7 +55,10 @@ class NotchViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func open() {
|
func open() {
|
||||||
notchSize = CGSize(width: openWidth, height: openHeight)
|
let size = openNotchSize
|
||||||
|
openWidth = size.width
|
||||||
|
openHeight = size.height
|
||||||
|
notchSize = size
|
||||||
notchState = .open
|
notchState = .open
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +74,58 @@ class NotchViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var openNotchSize: CGSize {
|
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 {
|
var closeInteractionLockDuration: TimeInterval {
|
||||||
@@ -102,3 +162,9 @@ class NotchViewModel: ObservableObject {
|
|||||||
closeTransitionTask?.cancel()
|
closeTransitionTask?.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension CGFloat {
|
||||||
|
func clamped(to range: ClosedRange<CGFloat>) -> CGFloat {
|
||||||
|
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
/// Tabbed settings panel with General, Appearance, Animation, Terminal, Hotkeys, and About.
|
/// Tabbed settings panel with General, Appearance, Animation, Terminal, Hotkeys, and About.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@@ -85,6 +86,14 @@ struct GeneralSettingsView: View {
|
|||||||
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
|
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
|
||||||
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
|
@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 {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section("Display") {
|
Section("Display") {
|
||||||
@@ -146,12 +155,12 @@ struct GeneralSettingsView: View {
|
|||||||
Section("Open Notch Size") {
|
Section("Open Notch Size") {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Width")
|
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)
|
Text("\(Int(openWidth))pt").monospacedDigit().frame(width: 60)
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Text("Height")
|
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)
|
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.terminalFontSize) private var fontSize = NotchSettings.Defaults.terminalFontSize
|
||||||
@AppStorage(NotchSettings.Keys.terminalShell) private var shellPath = NotchSettings.Defaults.terminalShell
|
@AppStorage(NotchSettings.Keys.terminalShell) private var shellPath = NotchSettings.Defaults.terminalShell
|
||||||
@AppStorage(NotchSettings.Keys.terminalTheme) private var theme = NotchSettings.Defaults.terminalTheme
|
@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 {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -298,8 +311,54 @@ struct TerminalSettingsView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
.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 {
|
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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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
|
// MARK: - About
|
||||||
|
|
||||||
struct AboutSettingsView: View {
|
struct AboutSettingsView: View {
|
||||||
|
|||||||
Reference in New Issue
Block a user