feat(ui): use thin overlay scrollers app-wide#41
Conversation
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 <bh@bugs.cc>
Summary by CodeRabbit
WalkthroughA new 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✨ Finishing Touches✨ Simplify code
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
Sources/LockIME/Support/OverlayScrollers.swift (1)
26-30: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winGate full-window sweeps on
triggerchanges.
updateNSViewrescans the whole window on every SwiftUI invalidation, so views likeAppPickerSheetandUpdateWindowViewwill do two tree walks on each search or progress update. The API already exposestrigger; use it to re-sweep only when the mounted scroll-view set may have changed.Proposed change
private struct OverlayScrollerSweep: NSViewRepresentable { let trigger: AnyHashable func makeNSView(context: Context) -> SweepProbe { SweepProbe(frame: .zero) } - func updateNSView(_ probe: SweepProbe, context: Context) { probe.sweep() } + func updateNSView(_ probe: SweepProbe, context: Context) { + probe.update(trigger: trigger) + } } /// Zero-size, event-transparent probe that re-styles its window's scroll views. private final class SweepProbe: NSView { + private var lastTrigger: AnyHashable? + + func update(trigger: AnyHashable) { + guard lastTrigger != trigger else { return } + lastTrigger = trigger + sweep() + } + // The probe sits behind the content, but never intercept events meant for it. override func hitTest(_ point: NSPoint) -> NSView? { nil }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/LockIME/Support/OverlayScrollers.swift` around lines 26 - 30, The `OverlayScrollerSweep` representable is sweeping the entire window on every SwiftUI update, even when `trigger` has not changed. Update `updateNSView(_:context:)` to cache the last seen `trigger` in the `SweepProbe`/context and call `sweep()` only when that value changes, so `AppPickerSheet` and `UpdateWindowView` don’t perform redundant tree walks on unrelated invalidations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@Sources/LockIME/Support/OverlayScrollers.swift`:
- Around line 26-30: The `OverlayScrollerSweep` representable is sweeping the
entire window on every SwiftUI update, even when `trigger` has not changed.
Update `updateNSView(_:context:)` to cache the last seen `trigger` in the
`SweepProbe`/context and call `sweep()` only when that value changes, so
`AppPickerSheet` and `UpdateWindowView` don’t perform redundant tree walks on
unrelated invalidations.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 2df883e9-3da1-42ba-a9a4-420dbf575940
📒 Files selected for processing (6)
Sources/LockIME/Support/OverlayScrollers.swiftSources/LockIME/UI/AboutView.swiftSources/LockIME/UI/Settings/AppPickerSheet.swiftSources/LockIME/UI/Settings/ImportReviewSheet.swiftSources/LockIME/UI/SettingsRootView.swiftSources/LockIME/UI/UpdateWindowView.swift
With the system Appearance ▸ Show scroll bars preference set to "Always", every scroll surface in the app drew macOS's wide, always-visible legacy scrollers — a thick gutter pinned open even when nothing was scrolling. SwiftUI only lets you control scroll-indicator visibility (
.scrollIndicators), not scroller style (which lives onNSScrollView.scrollerStyle), so the system preference leaks into the app with no SwiftUI escape hatch.This adds an
.overlayScrollers()modifier that drops a zero-size, event-transparent probe into the view's background, finds the hostNSWindow, and sweeps everyNSScrollViewto.overlay— the thin bars that appear on scroll and fade out afterward. Overlay scrollers also float over the content instead of reserving a gutter, so this reclaims the width the legacy bars ate. The sweep re-asserts overlay onpreferredScrollerStyleDidChangeNotification(AppKit resets the style whenever the user flips the system preference) and runs again on the next runloop turn to catch scroll views SwiftUI mounts lazily.It's applied 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.
Verified visually with the system set to "Always": the thick persistent bar is gone and a thin overlay bar appears only while scrolling.