From df5f9cdd2e4dead92e7a9e059d0435de1b6d6ffe Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Fri, 18 Sep 2026 16:26:46 +0530 Subject: [PATCH] fix(palette): stop hover auto-scroll, open dead-centre, drop the top strip Hovering a row selected it, and every selection change scrolled that row to the centre, so the list moved under the pointer, hovered a new row, and scrolled again. Only the arrow keys scroll now (minimally, to keep the row visible), and a row that scrolls under a resting pointer no longer takes the selection back from the keys. NSWindow.center() sits above centre, on whichever display the reused panel was last shown. Both palettes (Lists, AI Agents) now open in the exact middle of the display the pointer is on. Both also ignore the hidden title bar's safe area, which left a blank strip above their content. --- Sources/Tic/AppModel.swift | 13 +++++++++++-- Sources/Tic/Views/ListsSearchView.swift | 16 +++++++++++++--- Sources/Tic/Views/MCPSetupView.swift | 1 + 3 files changed, 25 insertions(+), 5 deletions(-) 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() } }