Skip to content

Tab from notes list to preview does not transfer keyboard focus #1915

@gjouret

Description

@gjouret

Bug Description

When a note is in Preview mode and the user presses Tab or Right Arrow from the notes list, keyboard focus is not properly transferred to the preview pane. Arrow keys do not scroll the preview, there is no visual indication of focus, and there is no way to navigate back without using the mouse.

Additionally, when focus moves to the editor pane (non-preview mode) via Tab/Right Arrow, there is no visual indicator that focus has shifted.

Cmd+/ (toggle preview) also does not manage focus correctly: toggling to preview leaves focus on the notes list, and toggling back to edit mode does not place the cursor in the editor.

Steps to Reproduce

  1. Select a note in the notes list
  2. Press Cmd+/ to enable Preview mode
  3. Press Tab or Right Arrow to move focus to the preview pane
  4. Press Up/Down arrow keys to scroll the preview

Expected Behavior

  • After pressing Tab/Right Arrow, the preview pane should scroll with arrow keys
  • A subtle visual indicator (blue border) should show the focused pane — both in preview and edit mode
  • Left Arrow or Shift+Tab should return focus to the notes list
  • Escape should also return focus to the notes list
  • Cmd+/ toggling to preview should focus the preview pane (with blue border)
  • Cmd+/ toggling to edit should focus the editor (with cursor placed in text)

Actual Behavior

  • The notes list selection turns gray (loses focus) but no view receives it
  • Arrow keys do not scroll the preview
  • No visual indication of where focus went
  • No keyboard navigation path back to the notes list
  • Cmd+/ leaves focus on the notes list instead of the preview/editor pane

Root Cause

The Tab handler in NotesTableView.keyUp calls makeFirstResponder(markdownView), but MPreviewContainerView doesn't override acceptsFirstResponder (returns false by default), so the focus transfer silently fails.

Additionally, even with acceptsFirstResponder returning true, WKWebView inside the container does not reliably accept keyboard events for scrolling via the responder chain alone. The ViewController.keyDown global event monitor (via NSEvent.addLocalMonitorForEvents) is the correct place to handle this, as it intercepts all key events before dispatch.

The togglePreview method in EditorViewController captured the previous first responder and restored it after toggling, undoing any focus transfer to the preview or editor pane.

Working Fix

The fix has five parts:

1. MPreviewContainerView.swift — Accept focus and show visual indicator

override var acceptsFirstResponder: Bool { return true }

public func showFocusBorder() {
    wantsLayer = true
    layer?.borderColor = NSColor.controlAccentColor.cgColor
    layer?.borderWidth = 1.0
}

public func hideFocusBorder() {
    layer?.borderWidth = 0
}

2. EditorScrollView.swift — Show visual indicator in edit mode

func showFocusBorder() {
    wantsLayer = true
    layer?.borderColor = NSColor.controlAccentColor.cgColor
    layer?.borderWidth = 1.0
}

func hideFocusBorder() {
    layer?.borderWidth = 0
}

The border is hidden automatically when EditTextView.resignFirstResponder() fires:

override func resignFirstResponder() -> Bool {
    userActivity?.needsSave = true
    if let scrollView = enclosingScrollView as? EditorScrollView {
        scrollView.hideFocusBorder()
    }
    return super.resignFirstResponder()
}

3. ViewController.swift — Handle all preview keyboard navigation in the global event monitor

Add a previewHasFocus flag. In the existing keyDown handler (the NSEvent.addLocalMonitorForEvents block):

Tab/Right Arrow from notes list (preview enabled): set previewHasFocus = true, call showFocusBorder() on MPreviewContainerView, make the container first responder.

Tab/Right Arrow from notes list (edit mode): call showFocusBorder() on EditorScrollView, call focusEditArea().

Shift+Tab when previewHasFocus: set previewHasFocus = false, call hideFocusBorder(), return focus to notes list.

Arrow keys when previewHasFocus: execute JavaScript scroll commands (window.scrollBy) on the WebView directly — do not rely on the responder chain. Also handle Page Up/Down, Home/End, Space.

Left Arrow when previewHasFocus: clear flag, hide border, return focus to notes list.

Escape when previewHasFocus: same as Left Arrow.

4. NotesTableView.swift — Defer to ViewController for preview focus

In the keyUp handler for Tab, when preview is enabled, simply return (the ViewController's keyDown monitor handles it):

if vc.editor?.isPreviewEnabled() == true {
    return
}

5. EditorViewController.swift — Cmd+/ focus management in togglePreview

When toggling preview ON: always focus the preview pane with previewHasFocus = true and showFocusBorder().

When toggling preview OFF: always focus the editor with focusEditArea() and showFocusBorder() on the scroll view.

The search bar and sidebar are excluded — if focus was there before Cmd+/, it stays there.

Why this approach works

The global event monitor in ViewController.keyDown fires before normal AppKit event dispatch. By handling all preview navigation there and scrolling via evaluateJavaScript, we bypass the unreliable WKWebView responder chain entirely. The MPreviewContainerView is made first responder purely for visual feedback (notes list selection turns gray, focus border appears) — it never needs to handle key events itself. The edit mode border uses the same visual style and is automatically removed when EditTextView resigns first responder. The togglePreview method now consistently moves focus to the appropriate pane based on the new mode.

Environment

  • macOS
  • FSNotes latest (main branch)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions