From 43817da89d29ba1159427bfa8ed5d8110db3c3e2 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Mon, 29 Jun 2026 00:40:34 -0400 Subject: [PATCH] feat(ui): use thin overlay scrollers app-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the system *Appearance ▸ Show scroll bars* setting is "Always", every scroll surface drew the wide, always-visible legacy scrollers — a thick gutter pinned open even when nothing was scrolling. SwiftUI only exposes scroll-indicator *visibility* (`.scrollIndicators`), not scroller *style*, which lives on `NSScrollView.scrollerStyle`, so the preference leaks into the app with no SwiftUI escape hatch. Add an `.overlayScrollers()` modifier that drops a zero-size, event-transparent probe into the background, finds the host `NSWindow`, and sweeps every `NSScrollView` to `.overlay` — the thin bars that appear on scroll and fade out afterward, and float over the content instead of reserving a gutter, reclaiming the width the legacy bars ate. The sweep re-asserts overlay on `preferredScrollerStyleDidChangeNotification` (AppKit resets the style when the user flips the system preference) and runs again on the next runloop turn to catch scroll views SwiftUI mounts lazily. Apply it to every scrolling surface: the Settings window (keyed on the selected tab so each lazily-mounted pane and the Log table is caught), the update window, the app picker, the acknowledgements list, and the import review sheet. Signed-off-by: Kevin Cui --- .../LockIME/Support/OverlayScrollers.swift | 73 +++++++++++++++++++ Sources/LockIME/UI/AboutView.swift | 1 + .../LockIME/UI/Settings/AppPickerSheet.swift | 1 + .../UI/Settings/ImportReviewSheet.swift | 1 + Sources/LockIME/UI/SettingsRootView.swift | 4 + Sources/LockIME/UI/UpdateWindowView.swift | 1 + 6 files changed, 81 insertions(+) create mode 100644 Sources/LockIME/Support/OverlayScrollers.swift diff --git a/Sources/LockIME/Support/OverlayScrollers.swift b/Sources/LockIME/Support/OverlayScrollers.swift new file mode 100644 index 0000000..0214d0a --- /dev/null +++ b/Sources/LockIME/Support/OverlayScrollers.swift @@ -0,0 +1,73 @@ +import AppKit +import SwiftUI + +extension View { + /// Forces native macOS **overlay** scrollers — the thin bars that appear only + /// while scrolling and fade away afterward — on every scroll view in this + /// view's window, regardless of the system *Appearance ▸ Show scroll bars* + /// setting (which can pin every app to the wide, always-visible *legacy* + /// scrollers the user otherwise can't escape). + /// + /// SwiftUI exposes scroll-indicator *visibility* (`.scrollIndicators`) but not + /// scroller *style* — that lives on `NSScrollView.scrollerStyle`. So we drop a + /// zero-size probe into the background, find its host `NSWindow`, and sweep + /// every `NSScrollView` in the window to `.overlay`. Overlay scrollers also + /// float over the content instead of reserving a gutter, so this reclaims the + /// width the legacy bars ate. + /// + /// - Parameter trigger: a value that changes whenever the set of mounted + /// scroll views might change (e.g. the selected settings tab), so the sweep + /// re-runs and catches scroll views that SwiftUI mounts lazily. + func overlayScrollers(trigger: AnyHashable = 0) -> some View { + background(OverlayScrollerSweep(trigger: trigger)) + } +} + +private struct OverlayScrollerSweep: NSViewRepresentable { + let trigger: AnyHashable + + func makeNSView(context: Context) -> SweepProbe { SweepProbe(frame: .zero) } + func updateNSView(_ probe: SweepProbe, context: Context) { probe.sweep() } +} + +/// Zero-size, event-transparent probe that re-styles its window's scroll views. +private final class SweepProbe: NSView { + // The probe sits behind the content, but never intercept events meant for it. + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window != nil else { return } + // AppKit resets every scroller to the system style when the user flips + // the "Show scroll bars" preference, so re-assert overlay when it does. + NotificationCenter.default.removeObserver( + self, name: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(sweep), + name: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil) + sweep() + } + + /// Sweep now, then once more on the next runloop turn — a scroll view for a + /// tab or sheet mounted in this same update pass may not be in the tree yet. + @objc func sweep() { + applyOverlayStyle() + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { self?.applyOverlayStyle() } + } + } + + private func applyOverlayStyle() { + guard let root = window?.contentView else { return } + Self.forEachScrollView(in: root) { scrollView in + if scrollView.scrollerStyle != .overlay { scrollView.scrollerStyle = .overlay } + } + } + + private static func forEachScrollView(in view: NSView, _ body: (NSScrollView) -> Void) { + if let scrollView = view as? NSScrollView { body(scrollView) } + for subview in view.subviews { forEachScrollView(in: subview, body) } + } + + deinit { NotificationCenter.default.removeObserver(self) } +} diff --git a/Sources/LockIME/UI/AboutView.swift b/Sources/LockIME/UI/AboutView.swift index bb0723f..736fb79 100644 --- a/Sources/LockIME/UI/AboutView.swift +++ b/Sources/LockIME/UI/AboutView.swift @@ -127,5 +127,6 @@ struct AcknowledgementsView: View { } } .frame(width: DS.Window.acknowledgementsWidth, height: DS.Window.acknowledgementsHeight) + .overlayScrollers() } } diff --git a/Sources/LockIME/UI/Settings/AppPickerSheet.swift b/Sources/LockIME/UI/Settings/AppPickerSheet.swift index b277356..87fe090 100644 --- a/Sources/LockIME/UI/Settings/AppPickerSheet.swift +++ b/Sources/LockIME/UI/Settings/AppPickerSheet.swift @@ -55,6 +55,7 @@ struct AppPickerSheet: View { .searchable(text: $query, placement: .toolbar) } .frame(width: DS.Window.pickerWidth, height: DS.Window.pickerHeight) + .overlayScrollers() .task { let scanned = InstalledAppsScanner.scan() apps = scanned diff --git a/Sources/LockIME/UI/Settings/ImportReviewSheet.swift b/Sources/LockIME/UI/Settings/ImportReviewSheet.swift index 620a8d2..98beb38 100644 --- a/Sources/LockIME/UI/Settings/ImportReviewSheet.swift +++ b/Sources/LockIME/UI/Settings/ImportReviewSheet.swift @@ -141,6 +141,7 @@ struct ImportReviewSheet: View { footer } .frame(width: 600, height: 620) + .overlayScrollers() } // MARK: Header diff --git a/Sources/LockIME/UI/SettingsRootView.swift b/Sources/LockIME/UI/SettingsRootView.swift index 265850f..5e20475 100644 --- a/Sources/LockIME/UI/SettingsRootView.swift +++ b/Sources/LockIME/UI/SettingsRootView.swift @@ -32,6 +32,10 @@ struct SettingsRootView: View { } .scenePadding() .frame(minWidth: 680, idealWidth: 700, minHeight: 600) + // Thin, auto-hiding overlay scrollers for every pane (incl. the Log + // table), even when the system is pinned to wide legacy bars. Keyed on + // the selected tab so the sweep re-runs for each lazily-mounted pane. + .overlayScrollers(trigger: state.settingsTab) // The Settings *window* closing (not a tab switch — this root outlives // those) is the "abandon" signal for an in-flight Accessibility grant. .onDisappear { state.stopAccessibilityWatch() } diff --git a/Sources/LockIME/UI/UpdateWindowView.swift b/Sources/LockIME/UI/UpdateWindowView.swift index c119ce0..2775678 100644 --- a/Sources/LockIME/UI/UpdateWindowView.swift +++ b/Sources/LockIME/UI/UpdateWindowView.swift @@ -20,6 +20,7 @@ struct UpdateWindowView: View { footer } .frame(width: DS.Window.updateWidth, height: DS.Window.updateHeight) + .overlayScrollers() } // MARK: Header