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