Fix add button. Fix hotkeys in settings. Add Workspace hotkeys

This commit is contained in:
2026-03-13 17:25:00 +11:00
parent fe6c7d8c12
commit 1e30e9bf9e
17 changed files with 232 additions and 50 deletions

View File

@@ -76,9 +76,7 @@ struct HotkeyRecorderField: NSViewRepresentable {
}
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
nsView.currentLabel = binding.displayString
nsView.showRecording = isRecording
nsView.needsDisplay = true
nsView.update(currentLabel: binding.displayString, isRecording: isRecording)
}
}
@@ -99,9 +97,7 @@ struct OptionalHotkeyRecorderField: NSViewRepresentable {
}
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
nsView.currentLabel = binding?.displayString ?? "Not set"
nsView.showRecording = isRecording
nsView.needsDisplay = true
nsView.update(currentLabel: binding?.displayString ?? "Not set", isRecording: isRecording)
}
}
@@ -183,6 +179,12 @@ class HotkeyNSView: NSView {
updateLabelAppearance()
}
func update(currentLabel: String, isRecording: Bool) {
self.currentLabel = currentLabel
showRecording = isRecording
updateLabelAppearance()
}
private func updateLabelAppearance() {
label.stringValue = showRecording ? "Press keys..." : currentLabel
label.textColor = showRecording ? .controlAccentColor : .labelColor

View File

@@ -9,17 +9,6 @@ struct TabBar: View {
var body: some View {
HStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 2) {
ForEach(Array(workspace.tabs.enumerated()), id: \.element.id) { index, tab in
tabButton(for: tab, at: index)
}
}
.padding(.horizontal, 4)
}
Spacer()
Button {
workspace.newTab()
} label: {
@@ -31,6 +20,15 @@ struct TabBar: View {
.accessibilityIdentifier("notch.new-tab")
.buttonStyle(.plain)
.padding(.horizontal, 8)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 2) {
ForEach(Array(workspace.tabs.enumerated()), id: \.element.id) { index, tab in
tabButton(for: tab, at: index)
}
}
.padding(.horizontal, 4)
}
}
.frame(height: 28)
.background(.black)

View File

@@ -5,7 +5,7 @@ import Combine
/// Manages global and local hotkeys.
///
/// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works
/// system-wide without Accessibility permission. Tab-level hotkeys
/// system-wide without Accessibility permission. Notch-scoped hotkeys
/// use a local `NSEvent` monitor (only fires when our app is active).
@MainActor
class HotkeyManager {
@@ -19,21 +19,29 @@ class HotkeyManager {
var onCloseTab: (() -> Void)?
var onNextTab: (() -> Void)?
var onPreviousTab: (() -> Void)?
var onNextWorkspace: (() -> Void)?
var onPreviousWorkspace: (() -> Void)?
var onDetachTab: (() -> Void)?
var onApplySizePreset: ((TerminalSizePreset) -> Void)?
var onSwitchToTab: ((Int) -> Void)?
var onSwitchToWorkspace: ((WorkspaceID) -> Void)?
/// Tab-level hotkeys only fire when the notch is open.
/// Notch-scoped hotkeys only fire when the notch is open.
var isNotchOpen: Bool = false
private var hotKeyRef: EventHotKeyRef?
private var eventHandlerRef: EventHandlerRef?
private var localMonitor: Any?
private let settingsProvider: TerminalSessionConfigurationProviding
private let workspaceRegistry: WorkspaceRegistry
private var settingsCancellable: AnyCancellable?
init(settingsProvider: TerminalSessionConfigurationProviding? = nil) {
init(
settingsProvider: TerminalSessionConfigurationProviding? = nil,
workspaceRegistry: WorkspaceRegistry? = nil
) {
self.settingsProvider = settingsProvider ?? AppSettingsController.shared
self.workspaceRegistry = workspaceRegistry ?? WorkspaceRegistry.shared
}
// MARK: - Resolved bindings from typed runtime settings
@@ -53,6 +61,12 @@ class HotkeyManager {
private var prevTabBinding: HotkeyBinding {
settingsProvider.hotkeySettings.previousTab
}
private var nextWorkspaceBinding: HotkeyBinding {
settingsProvider.hotkeySettings.nextWorkspace
}
private var previousWorkspaceBinding: HotkeyBinding {
settingsProvider.hotkeySettings.previousWorkspace
}
private var detachBinding: HotkeyBinding {
settingsProvider.hotkeySettings.detachTab
}
@@ -173,7 +187,7 @@ class HotkeyManager {
}
}
// MARK: - Local monitor (tab-level hotkeys, only when our app is active)
// MARK: - Local monitor (notch-level hotkeys, only when our app is active)
private func installLocalMonitor() {
localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
@@ -189,9 +203,9 @@ class HotkeyManager {
}
}
/// Handles tab-level hotkeys. Returns true if the event was consumed.
/// Handles notch-scoped hotkeys. Returns true if the event was consumed.
private func handleLocalKeyEvent(_ event: NSEvent) -> Bool {
// Tab hotkeys only when the notch is open and focused
// Local shortcuts only fire when the notch is open and focused.
guard isNotchOpen else { return false }
if newTabBinding.matches(event) {
@@ -210,10 +224,25 @@ class HotkeyManager {
onPreviousTab?()
return true
}
if nextWorkspaceBinding.matches(event) {
onNextWorkspace?()
return true
}
if previousWorkspaceBinding.matches(event) {
onPreviousWorkspace?()
return true
}
if detachBinding.matches(event) {
onDetachTab?()
return true
}
for summary in workspaceRegistry.workspaceSummaries {
guard let binding = summary.hotkey else { continue }
if binding.matches(event) {
onSwitchToWorkspace?(summary.id)
return true
}
}
for preset in sizePresets {
guard let binding = preset.hotkey else { continue }
if binding.matches(event) {

View File

@@ -9,6 +9,7 @@ final class ScreenManager: ObservableObject {
static let shared = ScreenManager()
private let screenRegistry = ScreenRegistry.shared
private let workspaceRegistry = WorkspaceRegistry.shared
private let windowCoordinator = WindowCoordinator()
private lazy var orchestrator = NotchOrchestrator(screenRegistry: screenRegistry, host: self)
@@ -55,6 +56,12 @@ final class ScreenManager: ObservableObject {
hotkeyManager.onPreviousTab = { [weak self] in
MainActor.assumeIsolated { self?.activeWorkspace().previousTab() }
}
hotkeyManager.onNextWorkspace = { [weak self] in
MainActor.assumeIsolated { self?.switchWorkspace(offset: 1) }
}
hotkeyManager.onPreviousWorkspace = { [weak self] in
MainActor.assumeIsolated { self?.switchWorkspace(offset: -1) }
}
hotkeyManager.onDetachTab = { [weak self] in
MainActor.assumeIsolated { self?.detachActiveTab() }
}
@@ -64,6 +71,9 @@ final class ScreenManager: ObservableObject {
hotkeyManager.onSwitchToTab = { [weak self] index in
MainActor.assumeIsolated { self?.activeWorkspace().switchToTab(at: index) }
}
hotkeyManager.onSwitchToWorkspace = { [weak self] workspaceID in
MainActor.assumeIsolated { self?.switchActiveScreen(to: workspaceID) }
}
hotkeyManager.start()
}
@@ -92,6 +102,33 @@ final class ScreenManager: ObservableObject {
}
}
private func switchWorkspace(offset: Int) {
guard let screenID = screenRegistry.activeScreenID() else { return }
let currentWorkspaceID = screenRegistry.screenContext(for: screenID)?.workspaceID ?? workspaceRegistry.defaultWorkspaceID
let nextWorkspaceID = offset >= 0
? workspaceRegistry.nextWorkspaceID(after: currentWorkspaceID)
: workspaceRegistry.previousWorkspaceID(before: currentWorkspaceID)
guard let nextWorkspaceID else { return }
switchScreen(screenID, to: nextWorkspaceID)
}
private func switchActiveScreen(to workspaceID: WorkspaceID) {
guard let screenID = screenRegistry.activeScreenID() else { return }
switchScreen(screenID, to: workspaceID)
}
private func switchScreen(_ screenID: ScreenID, to workspaceID: WorkspaceID) {
screenRegistry.assignWorkspace(workspaceID, to: screenID)
guard let context = screenRegistry.screenContext(for: screenID),
context.notchState == .open else {
return
}
orchestrator.open(screenID: screenID)
}
func applySizePreset(_ preset: TerminalSizePreset) {
guard let context = screenRegistry.allScreens().first(where: { $0.notchState == .open }) else {
AppSettingsController.shared.update {

View File

@@ -56,6 +56,8 @@ struct AppSettings: Equatable, Codable {
closeTab: .cmdW,
nextTab: .cmdShiftRB,
previousTab: .cmdShiftLB,
nextWorkspace: .cmdShiftDown,
previousWorkspace: .cmdShiftUp,
detachTab: .cmdD
)
)
@@ -121,6 +123,8 @@ extension AppSettings {
var closeTab: HotkeyBinding
var nextTab: HotkeyBinding
var previousTab: HotkeyBinding
var nextWorkspace: HotkeyBinding
var previousWorkspace: HotkeyBinding
var detachTab: HotkeyBinding
}
}

View File

@@ -60,6 +60,8 @@ struct UserDefaultsAppSettingsStore: AppSettingsStoreType {
closeTab: hotkey(NotchSettings.Keys.hotkeyCloseTab, default: .cmdW),
nextTab: hotkey(NotchSettings.Keys.hotkeyNextTab, default: .cmdShiftRB),
previousTab: hotkey(NotchSettings.Keys.hotkeyPreviousTab, default: .cmdShiftLB),
nextWorkspace: hotkey(NotchSettings.Keys.hotkeyNextWorkspace, default: .cmdShiftDown),
previousWorkspace: hotkey(NotchSettings.Keys.hotkeyPreviousWorkspace, default: .cmdShiftUp),
detachTab: hotkey(NotchSettings.Keys.hotkeyDetachTab, default: .cmdD)
)
)
@@ -106,6 +108,8 @@ struct UserDefaultsAppSettingsStore: AppSettingsStoreType {
defaults.set(settings.hotkeys.closeTab.toJSON(), forKey: NotchSettings.Keys.hotkeyCloseTab)
defaults.set(settings.hotkeys.nextTab.toJSON(), forKey: NotchSettings.Keys.hotkeyNextTab)
defaults.set(settings.hotkeys.previousTab.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousTab)
defaults.set(settings.hotkeys.nextWorkspace.toJSON(), forKey: NotchSettings.Keys.hotkeyNextWorkspace)
defaults.set(settings.hotkeys.previousWorkspace.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousWorkspace)
defaults.set(settings.hotkeys.detachTab.toJSON(), forKey: NotchSettings.Keys.hotkeyDetachTab)
}

View File

@@ -88,6 +88,8 @@ struct HotkeyBinding: Codable, Equatable, Hashable {
static let cmdW = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 13)
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 cmdShiftDown = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 125)
static let cmdShiftUp = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 126)
static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2)
static func cmdShiftDigit(_ digit: Int) -> HotkeyBinding? {

View File

@@ -57,6 +57,8 @@ enum NotchSettings {
static let hotkeyCloseTab = "hotkey_closeTab"
static let hotkeyNextTab = "hotkey_nextTab"
static let hotkeyPreviousTab = "hotkey_previousTab"
static let hotkeyNextWorkspace = "hotkey_nextWorkspace"
static let hotkeyPreviousWorkspace = "hotkey_previousWorkspace"
static let hotkeyDetachTab = "hotkey_detachTab"
}
@@ -104,6 +106,8 @@ enum NotchSettings {
static let hotkeyCloseTab: String = HotkeyBinding.cmdW.toJSON()
static let hotkeyNextTab: String = HotkeyBinding.cmdShiftRB.toJSON()
static let hotkeyPreviousTab: String = HotkeyBinding.cmdShiftLB.toJSON()
static let hotkeyNextWorkspace: String = HotkeyBinding.cmdShiftDown.toJSON()
static let hotkeyPreviousWorkspace: String = HotkeyBinding.cmdShiftUp.toJSON()
static let hotkeyDetachTab: String = HotkeyBinding.cmdD.toJSON()
}
@@ -151,6 +155,8 @@ enum NotchSettings {
Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab,
Keys.hotkeyNextTab: Defaults.hotkeyNextTab,
Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab,
Keys.hotkeyNextWorkspace: Defaults.hotkeyNextWorkspace,
Keys.hotkeyPreviousWorkspace: Defaults.hotkeyPreviousWorkspace,
Keys.hotkeyDetachTab: Defaults.hotkeyDetachTab,
])
}

View File

@@ -18,6 +18,7 @@ final class WorkspaceController: ObservableObject {
let createdAt: Date
@Published private(set) var name: String
@Published private(set) var hotkey: HotkeyBinding?
@Published private(set) var tabs: [TerminalSession] = []
@Published private(set) var activeTabIndex: Int = 0
@@ -34,6 +35,7 @@ final class WorkspaceController: ObservableObject {
self.id = summary.id
self.name = summary.name
self.createdAt = summary.createdAt
self.hotkey = summary.hotkey
self.sessionFactory = sessionFactory
self.settingsProvider = settingsProvider
@@ -51,7 +53,7 @@ final class WorkspaceController: ObservableObject {
}
var summary: WorkspaceSummary {
WorkspaceSummary(id: id, name: name, createdAt: createdAt)
WorkspaceSummary(id: id, name: name, createdAt: createdAt, hotkey: hotkey)
}
var state: WorkspaceState {
@@ -78,6 +80,11 @@ final class WorkspaceController: ObservableObject {
name = trimmed
}
func updateHotkey(_ updatedHotkey: HotkeyBinding?) {
guard hotkey != updatedHotkey else { return }
hotkey = updatedHotkey
}
func newTab() {
let config = settingsProvider.terminalSessionConfiguration
let session = sessionFactory.makeSession(

View File

@@ -104,6 +104,37 @@ final class WorkspaceRegistry: ObservableObject {
persistWorkspaceSummaries()
}
func updateWorkspaceHotkey(id: WorkspaceID, to hotkey: HotkeyBinding?) {
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else { return }
guard workspaceSummaries[index].hotkey != hotkey else { return }
workspaceSummaries[index].hotkey = hotkey
controllers[id]?.updateHotkey(hotkey)
persistWorkspaceSummaries()
}
func nextWorkspaceID(after id: WorkspaceID) -> WorkspaceID? {
guard !workspaceSummaries.isEmpty else { return nil }
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else {
return workspaceSummaries.first?.id
}
let nextIndex = workspaceSummaries.index(after: index)
return workspaceSummaries[nextIndex == workspaceSummaries.endIndex ? workspaceSummaries.startIndex : nextIndex].id
}
func previousWorkspaceID(before id: WorkspaceID) -> WorkspaceID? {
guard !workspaceSummaries.isEmpty else { return nil }
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else {
return workspaceSummaries.last?.id
}
let previousIndex = index == workspaceSummaries.startIndex
? workspaceSummaries.index(before: workspaceSummaries.endIndex)
: workspaceSummaries.index(before: index)
return workspaceSummaries[previousIndex].id
}
@discardableResult
func deleteWorkspace(id: WorkspaceID) -> Bool {
guard canDeleteWorkspace(id: id) else { return false }

View File

@@ -6,11 +6,13 @@ struct WorkspaceSummary: Identifiable, Equatable, Codable {
var id: WorkspaceID
var name: String
var createdAt: Date
var hotkey: HotkeyBinding?
init(id: WorkspaceID = UUID(), name: String, createdAt: Date = Date()) {
init(id: WorkspaceID = UUID(), name: String, createdAt: Date = Date(), hotkey: HotkeyBinding? = nil) {
self.id = id
self.name = name
self.createdAt = createdAt
self.hotkey = hotkey
}
}

View File

@@ -17,8 +17,13 @@ struct HotkeySettingsView: View {
HotkeyRecorderView(label: "Detach tab", binding: settingsController.binding(\.hotkeys.detachTab))
}
Section("Workspaces (active when notch is open)") {
HotkeyRecorderView(label: "Next workspace", binding: settingsController.binding(\.hotkeys.nextWorkspace))
HotkeyRecorderView(label: "Previous workspace", binding: settingsController.binding(\.hotkeys.previousWorkspace))
}
Section {
Text("⌘19 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets.")
Text("⌘19 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets. Per-workspace jump hotkeys are configured in Workspaces.")
.font(.caption)
.foregroundStyle(.secondary)
}

View File

@@ -2,21 +2,7 @@ import SwiftUI
struct TerminalSettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
private var sizePresetsBinding: Binding<[TerminalSizePreset]> {
Binding(
get: {
TerminalSizePresetStore.decodePresets(
from: settingsController.settings.terminal.sizePresetsJSON
) ?? TerminalSizePresetStore.loadDefaults()
},
set: { newValue in
settingsController.update {
$0.terminal.sizePresetsJSON = TerminalSizePresetStore.encodePresets(newValue)
}
}
)
}
@State private var sizePresets: [TerminalSizePreset] = []
var body: some View {
Form {
@@ -55,7 +41,7 @@ struct TerminalSettingsView: View {
}
Section("Size Presets") {
ForEach(sizePresetsBinding) { $preset in
ForEach($sizePresets) { $preset in
TerminalSizePresetEditor(
preset: $preset,
currentOpenWidth: settingsController.settings.display.openWidth,
@@ -67,20 +53,18 @@ struct TerminalSettingsView: View {
HStack {
Button("Add Preset") {
var presets = sizePresetsBinding.wrappedValue
presets.append(
sizePresets.append(
TerminalSizePreset(
name: "Preset \(presets.count + 1)",
name: "Preset \(sizePresets.count + 1)",
width: settingsController.settings.display.openWidth,
height: settingsController.settings.display.openHeight,
hotkey: TerminalSizePresetStore.suggestedHotkey(for: presets)
hotkey: TerminalSizePresetStore.suggestedHotkey(for: sizePresets)
)
)
sizePresetsBinding.wrappedValue = presets
}
Button("Reset Presets") {
sizePresetsBinding.wrappedValue = TerminalSizePresetStore.loadDefaults()
sizePresets = TerminalSizePresetStore.loadDefaults()
}
}
@@ -90,10 +74,24 @@ struct TerminalSettingsView: View {
}
}
.formStyle(.grouped)
.onAppear {
synchronizePresetsFromSettings()
}
.onChange(of: settingsController.settings.terminal.sizePresetsJSON) { _, _ in
synchronizePresetsFromSettings()
}
.onChange(of: sizePresets) { _, newValue in
let encoded = TerminalSizePresetStore.encodePresets(newValue)
guard encoded != settingsController.settings.terminal.sizePresetsJSON else { return }
settingsController.update {
$0.terminal.sizePresetsJSON = encoded
}
}
}
private func deletePreset(id: UUID) {
sizePresetsBinding.wrappedValue.removeAll { $0.id == id }
sizePresets.removeAll { $0.id == id }
}
private func applyPreset(_ preset: TerminalSizePreset) {
@@ -103,6 +101,15 @@ struct TerminalSettingsView: View {
}
ScreenManager.shared.applySizePreset(preset)
}
private func synchronizePresetsFromSettings() {
let decoded = TerminalSizePresetStore.decodePresets(
from: settingsController.settings.terminal.sizePresetsJSON
) ?? TerminalSizePresetStore.loadDefaults()
guard decoded != sizePresets else { return }
sizePresets = decoded
}
}
private struct TerminalSizePresetEditor: View {

View File

@@ -75,6 +75,11 @@ struct WorkspacesSettingsView: View {
renameSelectedWorkspace()
}
OptionalHotkeyRecorderView(
label: "Jump Hotkey",
binding: workspaceHotkeyBinding(for: summary.id)
)
HStack {
Button("Save Name") {
renameSelectedWorkspace()
@@ -86,6 +91,10 @@ struct WorkspacesSettingsView: View {
}
.accessibilityIdentifier("settings.workspaces.new")
}
Text("Workspace jump hotkeys are active when the notch is open and switch the current screen to this workspace.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Usage") {
@@ -256,6 +265,17 @@ struct WorkspacesSettingsView: View {
renameDraft = workspaceRegistry.summary(for: workspaceID)?.name ?? ""
}
private func workspaceHotkeyBinding(for workspaceID: WorkspaceID) -> Binding<HotkeyBinding?> {
Binding(
get: {
workspaceRegistry.summary(for: workspaceID)?.hotkey
},
set: { newValue in
workspaceRegistry.updateWorkspaceHotkey(id: workspaceID, to: newValue)
}
)
}
private func deleteSelectedWorkspace() {
guard let effectiveSelectedWorkspaceID,
let fallbackWorkspaceID = screenRegistry.deleteWorkspace(