Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Sources/KeyPathAppKit/UI/Experimental/MapperViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,39 @@ class MapperViewModel {
var selectedFolder: (path: String, name: String?)?
/// Selected script path for script-run mapping (nil = normal key output)
var selectedScript: (path: String, name: String?)?

// MARK: - Output-Type Picker (Overlay) Navigation State

/// Which page the overlay output-type picker is showing.
///
/// The picker is an iPhone-style drill-down — the root list and each
/// sub-list (System Action / Launch App / Go to Layer) are separate pages
/// that slide over one another inside a *stable* popover frame. This
/// deliberately replaced inline expansion: growing the popover's height
/// forced the hoisted window-anchored layer to re-measure and reposition on
/// every toggle, which fought SwiftUI's preference/position machinery and
/// left the expandable rows unresponsive. Swapping pages in a fixed frame
/// avoids all of that.
///
/// It lives on the view model (not `@State` on the view) because the picker
/// is rendered in that detached hoisted layer; `@State` mutated from there
/// does not propagate back, whereas this shared `@Observable` reference does.
enum OutputPickerPage: Equatable {
case root
case systemActions
case launchApps
case layers
}

var outputPickerPage: OutputPickerPage = .root
/// Selected layer name for "Go to Layer" output (nil = not a layer output).
var selectedLayerOutput: String?
/// Filter text for the Launch App sub-page's known-apps list. Lives here
/// (not `@State` on the view) because the picker popover is hoisted; a
/// `TextField` bound to view `@State` from that detached layer wouldn't
/// propagate. Cleared each time the picker opens.
var launchAppSearchText: String = ""

/// Key code of the captured input (for overlay-style rendering)
/// Default to 0 (A key) so the default state shows the A key selected
var inputKeyCode: UInt16? = 0
Expand Down
65 changes: 65 additions & 0 deletions Sources/KeyPathAppKit/UI/Overlay/AutoFocusTextField.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import AppKit
import SwiftUI

/// A borderless text field that grabs first-responder focus as soon as it is
/// placed in a window. Used inside the hoisted output-type picker popover where
/// SwiftUI's `@FocusState` does not bridge across the window-anchored host, so
/// programmatic focus there is unreliable. This drops to AppKit and calls
/// `makeFirstResponder` directly, which works regardless of view-tree hoisting.
///
/// Focus is grabbed once (guarded by the coordinator) so a re-render does not
/// keep stealing focus back from the user.
struct AutoFocusTextField: NSViewRepresentable {
@Binding var text: String
var autoFocus: Bool = true

func makeNSView(context: Context) -> NSTextField {
let field = NSTextField()
field.delegate = context.coordinator
field.isBordered = false
field.isBezeled = false
field.drawsBackground = false
field.focusRingType = .none
field.font = .systemFont(ofSize: NSFont.systemFontSize)
field.lineBreakMode = .byTruncatingTail
field.usesSingleLineMode = true
field.cell?.isScrollable = true
field.cell?.wraps = false
field.stringValue = text
field.setContentHuggingPriority(.defaultLow, for: .horizontal)
field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return field
}

func updateNSView(_ nsView: NSTextField, context: Context) {
if nsView.stringValue != text {
nsView.stringValue = text
}
guard autoFocus, !context.coordinator.didFocus else { return }
// Defer until the field is actually in a window and the responder chain
// has settled (the popover slides in), then make it first responder.
DispatchQueue.main.async {
guard let window = nsView.window, !context.coordinator.didFocus else { return }
context.coordinator.didFocus = true
window.makeFirstResponder(nsView)
}
}

func makeCoordinator() -> Coordinator {
Coordinator(text: $text)
}

final class Coordinator: NSObject, NSTextFieldDelegate {
private let text: Binding<String>
var didFocus = false

init(text: Binding<String>) {
self.text = text
}

func controlTextDidChange(_ notification: Notification) {
guard let field = notification.object as? NSTextField else { return }
text.wrappedValue = field.stringValue
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,11 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate {

let wrappedContent = buildRootView()

let hostingView = NSHostingView(rootView: wrappedContent)
// FirstMouseHostingView (not plain NSHostingView) so clicks register on
// the very first mouse-down even while the non-focus-stealing overlay is
// not key — otherwise hoisted controls (mapper output-type picker rows)
// swallow the activation click and appear unclickable.
let hostingView = FirstMouseHostingView(rootView: wrappedContent)
hostingView.setFrameSize(initialSize)
self.hostingView = hostingView

Expand Down
19 changes: 19 additions & 0 deletions Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,25 @@ final class OverlayWindow: NSWindow {
}
}

// MARK: - First-Mouse Hosting View

/// Hosting view that processes the *first* click even when the overlay window is
/// not the key window.
///
/// The overlay is intentionally non-focus-stealing (`OverlayWindow` only becomes
/// key when the user explicitly clicks into it). Without accepting first mouse,
/// a click on an interactive control rendered in a hoisted layer — notably the
/// mapper output-type picker rows in the window-anchored popover — is swallowed
/// as a window-activation event instead of hitting the control, so the rows
/// appear unclickable. The drag-header already overrides this for window
/// dragging (`NativeDragNSView`); this extends the same behavior to all overlay
/// content so the very first click lands on the control under the cursor.
final class FirstMouseHostingView: NSHostingView<LiveKeyboardOverlayView> {
override func acceptsFirstMouse(for _: NSEvent?) -> Bool {
true
}
}

@MainActor
final class OneShotLayerOverrideState {
private(set) var currentLayer: String?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,16 @@ extension OverlayMapperSection {

/// Content shown when Launch Apps section is expanded
var launchAppsExpandedContent: some View {
VStack(spacing: 0) {
// Section header
HStack {
Text("Known Apps")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 4)
let query = viewModel.launchAppSearchText.trimmingCharacters(in: .whitespaces)
let filteredApps = query.isEmpty
? knownApps
: knownApps.filter { $0.name.localizedCaseInsensitiveContains(query) }

// List of known apps
return VStack(spacing: 0) {
// Search / filter field
launchAppSearchField

// List of known apps (filtered)
if knownApps.isEmpty {
HStack {
Text("No apps configured yet")
Expand All @@ -28,8 +25,17 @@ extension OverlayMapperSection {
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
} else if filteredApps.isEmpty {
HStack {
Text("No apps match “\(query)”")
.font(.caption)
.foregroundStyle(.tertiary)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
} else {
ForEach(knownApps, id: \.name) { app in
ForEach(filteredApps, id: \.name) { app in
knownAppRow(app)
}
}
Expand Down Expand Up @@ -57,6 +63,40 @@ extension OverlayMapperSection {
}
}

/// Search box that filters the known-apps list by name.
private var launchAppSearchField: some View {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.font(.caption)
.foregroundStyle(.secondary)
AutoFocusTextField(text: $viewModel.launchAppSearchText)
.frame(height: 18)
.accessibilityLabel("Search apps")
.accessibilityIdentifier("overlay-launch-app-search")
if !viewModel.launchAppSearchText.isEmpty {
Button {
viewModel.launchAppSearchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
.focusable(false)
.accessibilityIdentifier("overlay-launch-app-search-clear")
}
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.primary.opacity(0.06))
)
.padding(.horizontal, 10)
.padding(.top, 8)
.padding(.bottom, 4)
}

/// Button for a known app in the list
private func knownAppRow(_ app: AppLaunchInfo) -> some View {
let isSelected = viewModel.selectedApp?.bundleIdentifier == app.bundleIdentifier
Expand All @@ -70,7 +110,7 @@ extension OverlayMapperSection {
viewModel.selectedFolder = nil
viewModel.selectedScript = nil
viewModel.clearShiftedOutput()
selectedLayerOutput = nil
viewModel.selectedLayerOutput = nil
viewModel.outputLabel = app.name
isSystemActionPickerOpen = false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ extension OverlayMapperSection {

/// Button for a layer in the list
private func layerRow(_ layer: String) -> some View {
let isSelected = selectedLayerOutput == layer
let isSelected = viewModel.selectedLayerOutput == layer
let isSystemLayer = viewModel.isSystemLayer(layer)
let layerIdentifier = layer
.lowercased()
Expand Down Expand Up @@ -84,7 +84,7 @@ extension OverlayMapperSection {

/// Select a layer as the output action
func selectLayerOutput(_ layer: String) {
selectedLayerOutput = layer
viewModel.selectedLayerOutput = layer
viewModel.selectedApp = nil
viewModel.selectedSystemAction = nil
viewModel.selectedURL = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ extension OverlayMapperSection {
("Open Folder", "folder.fill", false)
} else if viewModel.selectedScript != nil {
("Run Script", "terminal.fill", false)
} else if selectedLayerOutput != nil {
} else if viewModel.selectedLayerOutput != nil {
("Go to Layer", "square.stack.3d.up", false)
} else if viewModel.hasShiftedOutputConfigured {
("Keystroke + ⇧", "keyboard", false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
accessibilityIdentifier: "overlay-mapper-shift-mode-shifted",
trailingAction: {
if viewModel.hasShiftedOutputConfigured {
Button("Remove") {

Check warning on line 47 in Sources/KeyPathAppKit/UI/Overlay/OverlayMapperSection+ShiftOutput.swift

View workflow job for this annotation

GitHub Actions / code-quality

Interactive UI element (Button/Toggle/Picker) should have .accessibilityIdentifier() modifier. See ACCESSIBILITY_COVERAGE.md (require_accessibility_identifier)
removeShiftVariant()
}
.buttonStyle(.plain)
Expand Down Expand Up @@ -95,7 +95,7 @@
if viewModel.selectedURL != nil {
return "Opens URL"
}
if selectedLayerOutput != nil {
if viewModel.selectedLayerOutput != nil {
return "Goes to a layer"
}
return "Types \(viewModel.outputLabel)"
Expand Down
Loading
Loading