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
73 changes: 73 additions & 0 deletions Sources/LockIME/Support/OverlayScrollers.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
1 change: 1 addition & 0 deletions Sources/LockIME/UI/AboutView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,5 +127,6 @@ struct AcknowledgementsView: View {
}
}
.frame(width: DS.Window.acknowledgementsWidth, height: DS.Window.acknowledgementsHeight)
.overlayScrollers()
}
}
1 change: 1 addition & 0 deletions Sources/LockIME/UI/Settings/AppPickerSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Sources/LockIME/UI/Settings/ImportReviewSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ struct ImportReviewSheet: View {
footer
}
.frame(width: 600, height: 620)
.overlayScrollers()
}

// MARK: Header
Expand Down
4 changes: 4 additions & 0 deletions Sources/LockIME/UI/SettingsRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
1 change: 1 addition & 0 deletions Sources/LockIME/UI/UpdateWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ struct UpdateWindowView: View {
footer
}
.frame(width: DS.Window.updateWidth, height: DS.Window.updateHeight)
.overlayScrollers()
}

// MARK: Header
Expand Down
Loading