Skip to content

Commit 95c2e40

Browse files
authored
[mac] Add View → Hide Toolbar (⌥⌘T) for focused writing (#292)
Toggles `window.toolbar?.isVisible` from a custom NSMenuItem injected into the View menu. State persists in UserDefaults("toolbarHidden"). When the toolbar is hidden, traffic lights also fade out and reappear on hover near the top of the window. Scrubs the AppKit-auto-injected "Hide Toolbar" / "Customize Toolbar…" items so they don't race with the custom action. Fixes #287
1 parent 87a621d commit 95c2e40

1 file changed

Lines changed: 174 additions & 2 deletions

File tree

Clearly/ClearlyApp.swift

Lines changed: 174 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,29 @@ final class ClearlyAppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid
5656
private weak var trackedSettingsWindow: NSWindow?
5757
private var isOpeningSettingsFromMenuBar = false
5858

59+
private let toolbarMenuItemTag = 7287
60+
61+
private var isToolbarHidden: Bool {
62+
get { UserDefaults.standard.bool(forKey: "toolbarHidden") }
63+
set { UserDefaults.standard.set(newValue, forKey: "toolbarHidden") }
64+
}
65+
66+
private var trafficLightHoverMonitor: Any?
67+
private var trafficLightsAreInHoverZone: Bool = false
68+
private var lastAppliedToolbarHidden: Bool? = nil
69+
private weak var lastAppliedWindow: NSWindow?
70+
71+
/// Like `mainWindow` but explicitly skips the Settings window. Settings
72+
/// matches `isUserFacingDocumentWindow` (regular non-panel window ≥
73+
/// 200×200) and on macOS 26 carries its own NSToolbar, so without this
74+
/// filter ⌥⌘T while Settings is focused could hide the Settings toolbar.
75+
private var documentMainWindow: NSWindow? {
76+
NSApp.windows.first { window in
77+
guard WindowRouter.isUserFacingDocumentWindow(window) else { return false }
78+
return window !== trackedSettingsWindow
79+
}
80+
}
81+
5982
/// Returns the active main document window if SwiftUI has presented one.
6083
var mainWindow: NSWindow? {
6184
NSApp.windows.first { WindowRouter.isUserFacingDocumentWindow($0) }
@@ -332,9 +355,11 @@ final class ClearlyAppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid
332355
func applicationWillUpdate(_ notification: Notification) {
333356
injectSpellingMenuIfNeeded()
334357
injectSidebarToggleIfNeeded()
358+
injectHideToolbarIfNeeded()
335359
injectViewCommandsIfNeeded()
336360
injectGlobalSearchIfNeeded()
337361
injectExportPrintIfNeeded()
362+
applyToolbarVisibility()
338363
}
339364

340365
private func injectGlobalSearchIfNeeded() {
@@ -374,6 +399,153 @@ final class ClearlyAppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid
374399
doToggleSidebar()
375400
}
376401

402+
private func injectHideToolbarIfNeeded() {
403+
guard let viewMenu = NSApp.mainMenu?.item(withTitle: "View")?.submenu else { return }
404+
405+
// AppKit auto-injects "Hide Toolbar" / "Show Toolbar" / "Customize
406+
// Toolbar…" items whenever an NSToolbar is attached. They're wired to
407+
// toggleToolbarShown: / runToolbarCustomizationPalette: and would
408+
// race with our custom action — pressing ⌥⌘T would fire both, with
409+
// the system action reverting our state. Strip them every cycle,
410+
// preserving our tagged item.
411+
viewMenu.items.removeAll { item in
412+
guard item.tag != toolbarMenuItemTag else { return false }
413+
return item.title == "Hide Toolbar" || item.title == "Show Toolbar" || item.title == "Customize Toolbar…"
414+
}
415+
416+
if let existing = viewMenu.items.first(where: { $0.tag == toolbarMenuItemTag }) {
417+
let desiredTitle = isToolbarHidden ? "Show Toolbar" : "Hide Toolbar"
418+
if existing.title != desiredTitle { existing.title = desiredTitle }
419+
return
420+
}
421+
422+
// Sidebar injector must run first so Toggle Sidebar is at index 0.
423+
// Slot Hide Toolbar between Toggle Sidebar and the separator that
424+
// follows it; the existing separator gets pushed to index 2.
425+
guard viewMenu.items.first?.title == "Toggle Sidebar" else { return }
426+
427+
let item = NSMenuItem(
428+
title: isToolbarHidden ? "Show Toolbar" : "Hide Toolbar",
429+
action: #selector(toggleToolbarHiddenAction(_:)),
430+
keyEquivalent: "t"
431+
)
432+
item.keyEquivalentModifierMask = [.command, .option]
433+
item.target = self
434+
item.tag = toolbarMenuItemTag
435+
436+
viewMenu.insertItem(item, at: 1)
437+
}
438+
439+
@objc private func toggleToolbarHiddenAction(_ sender: Any?) {
440+
isToolbarHidden.toggle()
441+
applyToolbarVisibility()
442+
if let item = NSApp.mainMenu?.item(withTitle: "View")?.submenu?
443+
.items.first(where: { $0.tag == toolbarMenuItemTag }) {
444+
item.title = isToolbarHidden ? "Show Toolbar" : "Hide Toolbar"
445+
}
446+
}
447+
448+
/// Applies the persisted `toolbarHidden` flag to the main window's NSToolbar.
449+
/// Same primitive AppKit's stock View → Hide Toolbar uses. Idempotent and cheap,
450+
/// safe to call from `applicationWillUpdate` so SwiftUI scene churn (window
451+
/// rebuild on fullscreen / occlusion) cannot leave the toolbar disagreeing
452+
/// with the persisted state.
453+
private func applyToolbarVisibility() {
454+
guard let window = documentMainWindow, let toolbar = window.toolbar else { return }
455+
let shouldBeVisible = !isToolbarHidden
456+
if toolbar.isVisible != shouldBeVisible {
457+
toolbar.isVisible = shouldBeVisible
458+
}
459+
// Apply traffic-light state on (a) hidden/visible transitions, and
460+
// (b) when the underlying NSWindow itself is replaced (e.g., user
461+
// closed and reopened the document window — SwiftUI hands us a fresh
462+
// instance whose buttons default to alpha=1). Without (b) the new
463+
// window would land in inconsistent state. Without (a) the hover
464+
// monitor's alpha=1 would be stomped back to 0 every cycle.
465+
let windowChanged = lastAppliedWindow !== window
466+
lastAppliedWindow = window
467+
if windowChanged || lastAppliedToolbarHidden != isToolbarHidden {
468+
lastAppliedToolbarHidden = isToolbarHidden
469+
applyTrafficLightAutoHide(window: window, hidden: isToolbarHidden)
470+
}
471+
}
472+
473+
private func trafficLightButtons(for window: NSWindow) -> [NSButton] {
474+
[.closeButton, .miniaturizeButton, .zoomButton]
475+
.compactMap { window.standardWindowButton($0) }
476+
}
477+
478+
/// When the toolbar is hidden, fade out the traffic lights too — they
479+
/// reappear only when the cursor enters the top strip of the window.
480+
/// Goal: a focused writing surface with no chrome at rest.
481+
private func applyTrafficLightAutoHide(window: NSWindow, hidden: Bool) {
482+
let buttons = trafficLightButtons(for: window)
483+
if hidden {
484+
// Reset hover-zone tracking so the next mouseMoved event always
485+
// re-evaluates against the current window's frame. Without this,
486+
// a window change while staying hidden could leave the flag stuck
487+
// at `true` (from the old window's zone) and the new window's
488+
// first valid hover would be skipped.
489+
trafficLightsAreInHoverZone = false
490+
for button in buttons { button.alphaValue = 0 }
491+
installTrafficLightHoverMonitor(window: window)
492+
} else {
493+
for button in buttons { button.alphaValue = 1 }
494+
removeTrafficLightHoverMonitor()
495+
}
496+
}
497+
498+
private func installTrafficLightHoverMonitor(window: NSWindow) {
499+
// Always set the flag, even when the monitor already exists — a
500+
// window-replacement (close + reopen of the document window while
501+
// in hidden mode) hands us a fresh NSWindow whose
502+
// `acceptsMouseMovedEvents` defaults to false, which would silently
503+
// kill hover detection without this.
504+
window.acceptsMouseMovedEvents = true
505+
guard trafficLightHoverMonitor == nil else { return }
506+
trafficLightHoverMonitor = NSEvent.addLocalMonitorForEvents(matching: .mouseMoved) { [weak self] event in
507+
self?.handleTrafficLightHover(event)
508+
return event
509+
}
510+
}
511+
512+
private func removeTrafficLightHoverMonitor() {
513+
if let monitor = trafficLightHoverMonitor {
514+
NSEvent.removeMonitor(monitor)
515+
trafficLightHoverMonitor = nil
516+
}
517+
trafficLightsAreInHoverZone = false
518+
}
519+
520+
private func handleTrafficLightHover(_ event: NSEvent) {
521+
guard isToolbarHidden, let window = documentMainWindow else { return }
522+
523+
// Use screen coordinates rather than event.locationInWindow — local
524+
// event monitors sometimes deliver mouseMoved events with event.window
525+
// unset, and `event.window === mainWindow` then bails incorrectly.
526+
// Top ~50pt of the window covers the titlebar (where the lights sit)
527+
// plus a buffer below — entering the very top of the editor triggers
528+
// the fade-in.
529+
let mouse = NSEvent.mouseLocation
530+
let zone = NSRect(
531+
x: window.frame.minX,
532+
y: window.frame.maxY - 50,
533+
width: window.frame.width,
534+
height: 50
535+
)
536+
let inZone = zone.contains(mouse)
537+
538+
guard inZone != trafficLightsAreInHoverZone else { return }
539+
trafficLightsAreInHoverZone = inZone
540+
541+
let target: CGFloat = inZone ? 1 : 0
542+
let buttons = trafficLightButtons(for: window)
543+
NSAnimationContext.runAnimationGroup { ctx in
544+
ctx.duration = 0.18
545+
for button in buttons { button.animator().alphaValue = target }
546+
}
547+
}
548+
377549
private func injectQuickOpenIfNeeded() {
378550
guard let viewMenu = NSApp.mainMenu?.item(withTitle: "View")?.submenu else { return }
379551
guard !viewMenu.items.contains(where: { $0.title == "Quick Open…" }) else { return }
@@ -418,8 +590,8 @@ final class ClearlyAppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid
418590
previewItem.keyEquivalentModifierMask = [.command]
419591
previewItem.target = self
420592

421-
// Insert right after Toggle Sidebar (index 0)
422-
var insertIndex = 1
593+
// Insert after Toggle Sidebar (0), Hide Toolbar (1), separator (2)
594+
var insertIndex = 3
423595
viewMenu.insertItem(outlineItem, at: insertIndex); insertIndex += 1
424596
viewMenu.insertItem(backlinksItem, at: insertIndex); insertIndex += 1
425597
viewMenu.insertItem(lineNumbersItem, at: insertIndex); insertIndex += 1

0 commit comments

Comments
 (0)