Frontend Components: Unit 3 — Focus & Immersion

ShoechooTextView Extensions for Focus Mode

Focus Dimming Implementation

extension ShoechooTextView {

    /// Apply focus mode dimming: active block at full opacity, all others dimmed.
    /// Called when focus mode is enabled and active block changes.
    func applyFocusModeDimming(activeBlockRange: ActiveBlockRange) {
        guard let textLayoutManager = self.textLayoutManager else { return }

        let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
        let dimmingConfig = focusModeState.dimmingConfiguration

        let applyAlpha: (NSTextLayoutFragment, CGFloat) -> Void = { fragment, alpha in
            // Set alpha on the fragment's content layer
            fragment.textLineFragments.forEach { lineFragment in
                // Apply alpha via custom drawing or attributed string manipulation
            }
        }

        if reduceMotion {
            // Immediate application — no animation
            enumerateFragments(textLayoutManager: textLayoutManager) { fragment, range in
                let alpha = range.overlaps(activeBlockRange.textRange)
                    ? dimmingConfig.activeAlpha
                    : dimmingConfig.inactiveAlpha
                applyAlpha(fragment, alpha)
            }
            self.needsDisplay = true
        } else {
            // Animated transition
            NSAnimationContext.runAnimationGroup { context in
                context.duration = 0.2
                context.allowsImplicitAnimation = true
                enumerateFragments(textLayoutManager: textLayoutManager) { fragment, range in
                    let alpha = range.overlaps(activeBlockRange.textRange)
                        ? dimmingConfig.activeAlpha
                        : dimmingConfig.inactiveAlpha
                    applyAlpha(fragment, alpha)
                }
                self.needsDisplay = true
            }
        }
    }

    /// Remove all focus dimming — restore full opacity to all blocks.
    func removeFocusModeDimming() {
        guard let textLayoutManager = self.textLayoutManager else { return }

        let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion

        if reduceMotion {
            enumerateFragments(textLayoutManager: textLayoutManager) { fragment, _ in
                // Restore full alpha
            }
            self.needsDisplay = true
        } else {
            NSAnimationContext.runAnimationGroup { context in
                context.duration = 0.2
                context.allowsImplicitAnimation = true
                enumerateFragments(textLayoutManager: textLayoutManager) { fragment, _ in
                    // Restore full alpha
                }
                self.needsDisplay = true
            }
        }
    }

    /// Helper: enumerate all text layout fragments with their NSRange.
    private func enumerateFragments(
        textLayoutManager: NSTextLayoutManager,
        using block: (NSTextLayoutFragment, NSRange) -> Void
    ) {
        textLayoutManager.enumerateTextLayoutFragments(
            from: textLayoutManager.documentRange.location,
            options: [.ensuresLayout]
        ) { fragment in
            let range = NSRange(fragment.rangeInElement, in: textLayoutManager)
            block(fragment, range)
            return true
        }
    }
}

ShoechooTextView Extensions for Typewriter Scroll

Typewriter Scroll Implementation

extension ShoechooTextView {

    /// Scroll so that the given line rect is vertically centered in the visible area.
    /// No-op if the document is shorter than the visible area.
    func scrollToCenterLine(_ lineRect: CGRect) {
        guard let scrollView = self.enclosingScrollView else { return }

        let visibleHeight = scrollView.contentView.bounds.height
        let documentHeight = self.textLayoutManager?
            .usageBoundsForTextContainer.height ?? self.bounds.height

        // Short document guard: suppress scroll
        if documentHeight <= visibleHeight { return }

        let visibleCenter = visibleHeight / 2.0
        let activeLineCenter = lineRect.midY
        let targetScrollY = activeLineCenter - visibleCenter

        // Clamp to valid bounds
        let maxY = documentHeight - visibleHeight
        let clampedY = max(0, min(maxY, targetScrollY))
        let targetPoint = NSPoint(x: 0, y: clampedY)

        let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion

        if reduceMotion {
            scrollView.contentView.scroll(to: targetPoint)
            scrollView.reflectScrolledClipView(scrollView.contentView)
        } else {
            NSAnimationContext.runAnimationGroup { context in
                context.duration = 0.15
                context.allowsImplicitAnimation = true
                scrollView.contentView.animator().setBoundsOrigin(targetPoint)
            }
            scrollView.reflectScrolledClipView(scrollView.contentView)
        }
    }
}

WYSIWYGTextView (NSViewRepresentable) Updates

updateNSView Extensions

The existing updateNSView from Unit 1 is extended with focus and typewriter logic.

func updateNSView(_ nsView: ShoechooTextView, context: Context) {
    // ... existing Unit 1 logic (re-render changed blocks) ...

    // Unit 3: Focus Mode Dimming
    if viewModel.isFocusModeEnabled {
        if let activeRange = viewModel.currentActiveBlockRange {
            nsView.applyFocusModeDimming(activeBlockRange: activeRange)
        }
    } else {
        nsView.removeFocusModeDimming()
    }

    // Unit 3: Typewriter Scroll
    if viewModel.isTypewriterScrollEnabled {
        if let activeRange = viewModel.currentActiveBlockRange {
            nsView.scrollToCenterLine(activeRange.layoutRect)
        }
    }
}

Coordinator Extensions

class Coordinator: NSObject, NSTextViewDelegate, NSTextStorageDelegate {

    // ... existing Unit 1 delegate methods ...

    // Extended cursor move handler
    func textViewDidChangeSelection(_ notification: Notification) {
        let pos = textView.selectedRange().location
        parent.viewModel.cursorDidMove(to: pos)

        // Focus mode and typewriter scroll react via ViewModel state change
        // which triggers updateNSView on the next SwiftUI render cycle
    }
}

EditorViewModel Extensions

Focus & Typewriter State

extension EditorViewModel {

    // MARK: - Focus Mode

    var isFocusModeEnabled: Bool  // Published property, drives UI updates

    func toggleFocusMode() {
        isFocusModeEnabled.toggle()
        settings.defaultFocusMode = isFocusModeEnabled
        // Active block range is already tracked — dimming applied in updateNSView
    }

    // MARK: - Typewriter Scroll

    var isTypewriterScrollEnabled: Bool  // Published property, drives UI updates

    func toggleTypewriterScroll() {
        isTypewriterScrollEnabled.toggle()
        settings.defaultTypewriterScroll = isTypewriterScrollEnabled
        // Scroll applied in updateNSView
    }

    // MARK: - Active Block Range (computed from EditorNodeModel)

    var currentActiveBlockRange: ActiveBlockRange? {
        guard let activeBlock = nodeModel.blocks.first(where: { $0.isActive }) else {
            return nil
        }
        // textRange and layoutRect are computed during the render pipeline
        return ActiveBlockRange(
            blockID: activeBlock.id,
            textRange: computeNSRange(for: activeBlock),
            layoutRect: computeLayoutRect(for: activeBlock)
        )
    }
}

Full-Screen Integration

EditorView Commands

struct EditorView: View {
    @State var viewModel: EditorViewModel
    @State var settings: EditorSettings

    var body: some View {
        WYSIWYGTextView(viewModel: viewModel, settings: settings)
    }
}

Keyboard Shortcut Registration

// In ShoechooApp or EditorView .commands modifier
.commands {
    // Unit 3: Focus & Immersion
    CommandGroup(after: .textFormatting) {
        Divider()
        Button(viewModel.isFocusModeEnabled ? "Disable Focus Mode" : "Enable Focus Mode") {
            viewModel.toggleFocusMode()
        }
        .keyboardShortcut("f", modifiers: [.command, .shift])

        Button(viewModel.isTypewriterScrollEnabled ? "Disable Typewriter Scroll" : "Enable Typewriter Scroll") {
            viewModel.toggleTypewriterScroll()
        }
        .keyboardShortcut("t", modifiers: [.command, .control])
    }
}

Note: Full-screen toggle (Ctrl+Cmd+F) is handled automatically by macOS via NSWindow.toggleFullScreen(_:) and the green title bar button. No custom command registration is needed unless the app overrides the default behavior.

Full-Screen Window Delegate

class WindowDelegate: NSObject, NSWindowDelegate {

    var fullScreenState: FullScreenState

    func windowDidEnterFullScreen(_ notification: Notification) {
        fullScreenState.isFullScreen = true
        fullScreenState.toolbarPolicy = .autoHide
        fullScreenState.sidebarPolicy = .hidden

        // Configure toolbar auto-hide
        if let window = notification.object as? NSWindow {
            window.toolbar?.isVisible = false
            // Toolbar will show when mouse moves to top of screen (macOS native behavior)
        }
    }

    func windowDidExitFullScreen(_ notification: Notification) {
        fullScreenState.isFullScreen = false
        fullScreenState.toolbarPolicy = .alwaysVisible
        fullScreenState.sidebarPolicy = .visible

        // Restore toolbar
        if let window = notification.object as? NSWindow {
            window.toolbar?.isVisible = true
        }
    }
}

Accessibility Observer

// Set up in ShoechooTextView.init or viewDidMoveToWindow
NotificationCenter.default.addObserver(
    self,
    selector: #selector(accessibilityDisplayOptionsDidChange),
    name: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,
    object: nil
)

@objc func accessibilityDisplayOptionsDidChange(_ notification: Notification) {
    // Re-evaluate transition style
    // If focus mode active: re-apply dimming with new transition style
    // If typewriter scroll active: next scroll will use new behavior automatically
}

User Interaction Flows

Flow 1: Enable Focus Mode (Cmd+Shift+F)

1. User presses Cmd+Shift+F
2. SwiftUI command handler calls viewModel.toggleFocusMode()
3. isFocusModeEnabled flips to true
4. EditorSettings.defaultFocusMode persisted
5. SwiftUI triggers updateNSView()
6. updateNSView() calls nsView.applyFocusModeDimming(activeBlockRange:)
7. Inactive blocks fade to 0.3 alpha (animated or immediate per accessibility)
8. Active block remains at 1.0 alpha

Flow 2: Cursor Move with Focus Mode Active

1. User clicks or arrow-keys to new position
2. Coordinator calls viewModel.cursorDidMove(to:)
3. ViewModel resolves new active block (Unit 1 logic)
4. If active block changed:
   a. Unit 1: re-render old block (styled) + new block (raw syntax)
   b. Unit 3: updateNSView() re-applies dimming with new ActiveBlockRange
   c. Old block fades to 0.3, new block brightens to 1.0
5. If typewriter scroll enabled: scrollToCenterLine() centers new active line

Flow 3: Enable Typewriter Scroll (Cmd+Ctrl+T)

1. User presses Cmd+Ctrl+T
2. SwiftUI command handler calls viewModel.toggleTypewriterScroll()
3. isTypewriterScrollEnabled flips to true
4. EditorSettings.defaultTypewriterScroll persisted
5. SwiftUI triggers updateNSView()
6. updateNSView() calls nsView.scrollToCenterLine(activeRange.layoutRect)
7. View scrolls so active line is at vertical center (animated or immediate)

Flow 4: Enter Full-Screen (Ctrl+Cmd+F or Green Button)

1. User presses Ctrl+Cmd+F or clicks green title bar button
2. macOS calls NSWindow.toggleFullScreen(_:)
3. Window animates to full-screen (macOS native animation)
4. windowDidEnterFullScreen delegate fires
5. Toolbar set to auto-hide, sidebar collapsed
6. Focus mode and typewriter scroll continue operating as before

Flow 5: Type in Typewriter Mode with Focus Mode Active

1. User types a character in the active block
2. NSTextStorageDelegate fires → viewModel.textDidChange()
3. Parse pipeline runs (Unit 1 steps 1-6)
4. Active block re-rendered with new content
5. Focus dimming re-applied (active block stays at 1.0)
6. Typewriter scroll re-centers on the active line
   (line position may have shifted due to new text)

Flow 6: App Launch with Persisted Settings

1. EditorViewModel.init() reads EditorSettings
2. isFocusModeEnabled = settings.defaultFocusMode (e.g., true)
3. isTypewriterScrollEnabled = settings.defaultTypewriterScroll (e.g., true)
4. First layout pass completes
5. updateNSView() applies focus dimming and typewriter scroll
6. User sees the editor in the same focus/typewriter state as last session