Business Logic Model: Unit 3 — Focus & Immersion
Pipeline Overview
User toggles focus mode / typewriter scroll / full-screen
|
v
[1] State Toggle (EditorViewModel)
| Update isFocusModeEnabled / isTypewriterScrollEnabled
| Persist to EditorSettings
v
[2] Active Block Tracking (from Unit 1 pipeline)
| Cursor move → resolve active block
| Emit ActiveBlockRange
v
[3] Focus Mode Dimming (if enabled)
| Compute dimming for all NSTextLayoutFragments
| Apply alpha to inactive blocks
| Full alpha on active block
v
[4] Typewriter Scroll (if enabled)
| Compute center offset of visible rect
| Scroll active line to center
| Guard: suppress for short documents
v
[5] Full-Screen Integration
| Enter/exit native full-screen
| Auto-hide toolbar and sidebar
| Restore on exit
[1] State Toggle
Trigger: User keyboard shortcut or menu item
Focus Mode Toggle
Input: Current isFocusModeEnabled state
Logic:
- Flip
isFocusModeEnabledboolean - If enabling:
- Resolve current
ActiveBlockRangefromEditorNodeModel - Call
applyFocusModeDimming(activeBlockRange:)onShoechooTextView
- Resolve current
- If disabling:
- Call
removeFocusModeDimming()onShoechooTextView - Restore all blocks to full alpha
- Call
- Persist new value to
EditorSettings.defaultFocusMode
Typewriter Scroll Toggle
Input: Current isTypewriterScrollEnabled state
Logic:
- Flip
isTypewriterScrollEnabledboolean - If enabling:
- Compute active line position
- Call
scrollToCenterLine(_:)onShoechooTextView
- If disabling:
- No immediate scroll action (scroll position stays where it is)
- Persist new value to
EditorSettings.defaultTypewriterScroll
[2] Active Block Tracking
This step reuses the Active Block Resolution logic from Unit 1 (business-logic-model.md, step [4]). The output is extended to include layout geometry for dimming and scrolling.
Input: cursorPosition: Int, EditorNodeModel, NSTextLayoutManager
Logic:
- Resolve active block via Unit 1 logic (cursor → innermost block → ActivationScope)
- Query
NSTextLayoutManagerfor theNSTextLayoutFragmentof the active block:textLayoutManager.textLayoutFragment(for: textPosition)
- Compute
ActiveBlockRange:blockID: from the resolvedEditorNodetextRange:NSRangeof the block inNSTextStoragelayoutRect:layoutFragmentFramefrom the layout fragment
- If
activeBlockIDchanged from previous:- Emit the new
ActiveBlockRangeto both dimming and scroll pipelines - Re-run dimming pass (step [3])
- Re-run typewriter scroll (step [4])
- Emit the new
[3] Focus Mode Dimming
Input: ActiveBlockRange, NSTextLayoutManager, DimmingConfiguration
Precondition: isFocusModeEnabled == true
Logic:
- Enumerate all
NSTextLayoutFragmentinstances viaNSTextLayoutManager.enumerateTextLayoutFragments(from:options:using:) - For each fragment:
- If fragment’s range overlaps
ActiveBlockRange.textRange:- Set
fragment.alphaValue = dimmingConfiguration.activeAlpha(1.0)
- Set
- Else:
- Set
fragment.alphaValue = dimmingConfiguration.inactiveAlpha(0.3)
- Set
- If fragment’s range overlaps
- Determine transition style:
- Query
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion - If reduce motion: apply alpha changes immediately (no animation)
- Else: animate alpha changes with
NSAnimationContext(duration: 0.2s)
- Query
- Request display update on the text view
Dimming Removal
Trigger: Focus mode disabled
Logic:
- Enumerate all
NSTextLayoutFragmentinstances - Set
fragment.alphaValue = 1.0for all fragments - Apply transition (animated or immediate per accessibility setting)
Implementation Note
NSTextLayoutFragment does not natively expose an alphaValue property. The actual implementation uses one of these approaches:
- Approach A (preferred): Override
NSTextLayoutFragment.draw(at:in:)to applyCGContext.setAlpha()before drawing - Approach B: Use a custom
NSTextLayoutFragmentProviderlayer with alpha manipulation - Approach C: Apply alpha via
NSAttributedStringforeground color with reduced alpha on inactive block ranges
The chosen approach must not interfere with Unit 1 rendering (active block raw syntax vs. styled output).
[4] Typewriter Scroll
Input: ActiveBlockRange, NSScrollView (enclosing ShoechooTextView), TypewriterScrollState
Precondition: isTypewriterScrollEnabled == true
Logic:
- Short document guard:
- Get
documentHeightfromNSTextLayoutManager.usageBoundsForTextContainer.height - Get
visibleHeightfromNSScrollView.contentView.bounds.height - If
documentHeight <= visibleHeight: setscrollBehavior = .suppressed, return (no scroll)
- Get
- Compute center target:
visibleCenter = visibleHeight / 2.0activeLineCenter = ActiveBlockRange.layoutRect.midYtargetScrollY = activeLineCenter - visibleCenter
- Clamp scroll position:
minY = 0maxY = documentHeight - visibleHeightclampedY = max(minY, min(maxY, targetScrollY))
- Apply scroll:
- Query
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion - If reduce motion:
scrollView.contentView.scroll(to: NSPoint(x: 0, y: clampedY))immediately - Else:
NSAnimationContext.runAnimationGroupwith duration 0.15s,scrollView.contentView.animator().setBoundsOrigin(NSPoint(x: 0, y: clampedY))
- Query
- Call
scrollView.reflectScrolledClipView(scrollView.contentView)
Typewriter Scroll Triggering
| Event | Action |
|---|---|
| Cursor move (arrow keys, click) | Scroll to center active line |
| Text insertion (typing) | Scroll to center active line |
| Window resize | Recalculate center offset, scroll if needed |
| Active block change | Scroll to center new active line |
| Document load | Scroll to center initial cursor position |
[5] Full-Screen Integration
Input: User action (Ctrl+Cmd+F or green title bar button)
Logic:
Entering Full-Screen
- Call
NSWindow.toggleFullScreen(_:)(standard macOS API) - In
windowDidEnterFullScreen(_:)delegate callback:- Set
fullScreenState.isFullScreen = true - Set
toolbarPolicy = .autoHide - Set
sidebarPolicy = .hidden - Configure
NSWindow.toolbar?.isVisiblewith auto-hide behavior - Collapse sidebar via
NSSplitViewControlleror SwiftUINavigationSplitViewvisibility
- Set
Exiting Full-Screen
- Call
NSWindow.toggleFullScreen(_:)or user presses Esc / Ctrl+Cmd+F - In
windowDidExitFullScreen(_:)delegate callback:- Set
fullScreenState.isFullScreen = false - Set
toolbarPolicy = .alwaysVisible - Set
sidebarPolicy = .visible - Restore toolbar visibility
- Restore sidebar state to pre-full-screen value
- Set
Auto-Hide Behavior (Full-Screen)
| Element | Trigger to Show | Trigger to Hide |
|---|---|---|
| Toolbar | Move mouse to top edge of screen | Mouse moves away from top edge (after 1.5s delay) |
| Sidebar | Move mouse to left edge of screen | Mouse moves away from sidebar area (after 1.5s delay) |
Initialization & Restore on App Launch
Trigger: Document opens, EditorViewModel.init()
Logic:
- Read
EditorSettings.defaultFocusMode→ setisFocusModeEnabled - Read
EditorSettings.defaultTypewriterScroll→ setisTypewriterScrollEnabled - After first layout pass completes:
- If focus mode enabled: apply dimming on initial active block
- If typewriter scroll enabled: scroll to center initial cursor position