diff --git a/Sources/Tic/AppModel.swift b/Sources/Tic/AppModel.swift index cf4bd3d..75a09c1 100644 --- a/Sources/Tic/AppModel.swift +++ b/Sources/Tic/AppModel.swift @@ -122,12 +122,21 @@ final class AppModel { searchWindow = panel } guard let window = searchWindow else { return } - window.center() // centered on screen every time it's called + centerOnActiveDisplay(window) // centered on screen every time it's called window.level = .floating // and always on top when called window.makeKeyAndOrderFront(nil) NSApp.activate() } + /// Dead-centre of the display the pointer is on. `NSWindow.center()` isn't that: it sits above + /// centre, and on whichever display the reused panel was last shown. + private func centerOnActiveDisplay(_ window: NSWindow) { + let pointer = NSEvent.mouseLocation + guard let area = (NSScreen.screens.first { $0.frame.contains(pointer) } ?? NSScreen.main)?.visibleFrame + else { return } + window.setFrameOrigin(NSPoint(x: area.midX - window.frame.width / 2, y: area.midY - window.frame.height / 2)) + } + /// Closes the Lists palette (Escape / pick a list / its close button). func dismissSearch() { searchWindow?.orderOut(nil) @@ -158,7 +167,7 @@ final class AppModel { mcpWindow = panel } guard let window = mcpWindow else { return } - window.center() + centerOnActiveDisplay(window) window.level = .floating window.makeKeyAndOrderFront(nil) NSApp.activate() diff --git a/Sources/Tic/Views/ListsSearchView.swift b/Sources/Tic/Views/ListsSearchView.swift index 166af86..89cf4e0 100644 --- a/Sources/Tic/Views/ListsSearchView.swift +++ b/Sources/Tic/Views/ListsSearchView.swift @@ -8,6 +8,8 @@ struct ListsSearchView: View { @State private var model = AppModel.shared @State private var search = "" @State private var selectedID: Note.ID? + @State private var scrollRequest: Note.ID? + @State private var pointerAtKeyMove: NSPoint? @FocusState private var searchFocused: Bool private var results: [Note] { @@ -27,6 +29,7 @@ struct ListsSearchView: View { } .frame(width: 700, height: 440) .background(.regularMaterial) + .ignoresSafeArea() // else the hidden title bar leaves a blank strip on top .task { searchFocused = true selectedID = results.first?.id @@ -87,7 +90,9 @@ struct ListsSearchView: View { onDelete: { model.delete(note) } ) .id(note.id) - .onHover { if $0 { selectedID = note.id } } + // A row scrolled under a resting pointer isn't a hover: that would + // snatch the selection back from the arrow keys. + .onHover { if $0, NSEvent.mouseLocation != pointerAtKeyMove { selectedID = note.id } } .onTapGesture { selectedID = note.id; openSelected() } } } @@ -95,9 +100,12 @@ struct ListsSearchView: View { .padding(.horizontal, 8) .padding(.vertical, 6) } - .onChange(of: selectedID) { _, id in + // Only the arrow keys scroll. Hover also selects, and scrolling for that moved the + // list under the pointer, which hovered a new row, which scrolled again… + .onChange(of: scrollRequest) { _, id in guard let id else { return } - withAnimation(.easeOut(duration: 0.12)) { proxy.scrollTo(id, anchor: .center) } + withAnimation(.easeOut(duration: 0.12)) { proxy.scrollTo(id) } + scrollRequest = nil } } .frame(maxHeight: .infinity) @@ -138,6 +146,8 @@ struct ListsSearchView: View { let current = selectedID.flatMap { ids.firstIndex(of: $0) } ?? -1 let next = min(max(current + delta, 0), ids.count - 1) selectedID = ids[next] + scrollRequest = ids[next] + pointerAtKeyMove = NSEvent.mouseLocation } private func openSelected() { diff --git a/Sources/Tic/Views/MCPSetupView.swift b/Sources/Tic/Views/MCPSetupView.swift index fbf852f..0014d1d 100644 --- a/Sources/Tic/Views/MCPSetupView.swift +++ b/Sources/Tic/Views/MCPSetupView.swift @@ -38,6 +38,7 @@ struct MCPSetupView: View { } .frame(width: 680, height: 460) .background(.regularMaterial) + .ignoresSafeArea() // else the hidden title bar leaves a blank strip on top .onExitCommand { model.dismissMCPSetup() } }