@@ -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