Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions Sources/ReaderMd/Models/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,10 @@ enum Settings {
defaults.set(value, forKey: sidebarWidthKey)
}

// Focus mode. All four default on: the mode's advertised behaviour is the
// full takeover, and each switch only subtracts from it.
// Focus mode. Fullscreen, the narrow canvas and the hidden toolbar default
// on: that takeover is the mode's advertised behaviour, and each switch only
// subtracts from it. Dimming is the exception — it rewrites the page itself
// rather than the chrome around it, so it is opt-in.
static func loadFocusFullscreen() -> Bool {
defaults.object(forKey: focusFullscreenKey) as? Bool ?? true
}
Expand All @@ -128,7 +130,7 @@ enum Settings {
}

static func loadFocusDimSections() -> Bool {
defaults.object(forKey: focusDimSectionsKey) as? Bool ?? true
defaults.object(forKey: focusDimSectionsKey) as? Bool ?? false
}
static func saveFocusDimSections(_ value: Bool) {
defaults.set(value, forKey: focusDimSectionsKey)
Expand All @@ -148,13 +150,17 @@ enum Settings {
defaults.set(value, forKey: focusHideToolbarKey)
}

// Both default to what focus mode shipped with, so an existing install sees
// no change until it touches a setting. `object(forKey:) as?` rather than
// `integer`/`double`: those return 0 for an absent key, and 0 is neither a
// valid heading level nor a legible opacity.
// The region defaults to `.h3`: every heading is too fine a grain for most
// documents, where an `h4` is a step inside the section being read rather
// than a section of its own. Only an ABSENT key takes that default, and the
// focus keys move independently — an install that switched dimming on but
// never opened the picker keeps its toggle and does widen to `.h3`.
// `object(forKey:) as?` rather than `integer`/`double`: those return 0 for
// an absent key, and 0 is neither a valid heading level nor a legible
// opacity.
static func loadFocusRegionDepth() -> FocusRegionDepth {
guard let raw = defaults.object(forKey: focusRegionDepthKey) as? Int,
let depth = FocusRegionDepth(rawValue: raw) else { return .any }
let depth = FocusRegionDepth(rawValue: raw) else { return .h3 }
return depth
}
static func saveFocusRegionDepth(_ value: FocusRegionDepth) {
Expand Down
12 changes: 12 additions & 0 deletions Sources/ReaderMd/Resources/docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- **Focus mode no longer dims other sections out of the box.** ⌥⌘F still hides
the chrome, goes fullscreen and narrows the canvas; dimming — the one piece
that changes the page itself rather than the chrome around it — is now
opt-in, under **Dim other sections** in Settings → Focus Mode.
- **A dim region now ends at H3 rather than at every heading.** With dimming
on, an `h4` inside the section you're reading no longer splits it. *Any
heading* is still there in **Region ends at**, alongside H2 and H1 — and the
setting is now a floor rather than a hard rule, so a document with no headings
that coarse falls back to the next level down instead of dimming nothing.

## [1.20.1] - 2026-09-07

### Changed
Expand Down
2 changes: 1 addition & 1 deletion Sources/ReaderMd/Resources/docs/SHORTCUTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ document you're reading.
| ⌘0 | Actual Size |
| ⇧⌘\\ | Cycle Canvas Width (Narrow / Wide / Full) |
| ⇧⌘D | Toggle Diff (in a git repository) |
| ⌥⌘F | Focus Mode (hides the chrome, dims other sections) |
| ⌥⌘F | Focus Mode (hides the chrome, goes fullscreen, narrows the canvas) |

## Settings

Expand Down
38 changes: 22 additions & 16 deletions Sources/ReaderMd/Resources/web/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -649,20 +649,23 @@ function postTOC() {
}

let focusDim = false;
// The deepest heading level that ends a region; 4 = every heading, the default.
let focusDepth = 4;
// The deepest heading level that ends a region; 3 = H3 or above. Mirrors
// `Settings.loadFocusRegionDepth`'s default — Swift pushes the real value on
// `ready`, so this only has to agree with it.
let focusDepth = 3;

// Classes the top-level blocks OUTSIDE the active heading's region. Deliberately
// no <section> wrappers: marked emits a flat h2/p/p/h2 sibling list, and mark
// anchoring, find, footnotes and diff hunks all read that flat structure.
//
// The region ends at the next heading at or above `focusDepth` — every heading
// by default. Depth is an ABSOLUTE level, fixed by the setting, never one
// relative to the active heading. The relative rule ("the next heading of the
// same or higher level") would make a 20px scroll across a nested heading swing
// the lit region between a paragraph and its whole parent section; an absolute
// level changes the region only when a boundary heading is crossed, and a
// heading deeper than the setting is not a boundary at all.
// The region ends at the next heading at or above `focusDepth` — H3 or above by
// default, and a floor rather than a fixed rule (see below). Depth is an
// ABSOLUTE level, fixed by the setting, never one relative to the active
// heading. The relative rule ("the next heading of the same or higher level")
// would make a 20px scroll across a nested heading swing the lit region between
// a paragraph and its whole parent section; an absolute level changes the region
// only when a boundary heading is crossed, and a heading deeper than the depth
// in force is not a boundary at all.
function applyFocusDim() {
const blocks = [...contentEl.children];
for (const b of blocks) b.classList.remove('focus-dim');
Expand All @@ -672,13 +675,16 @@ function applyFocusDim() {
// side-by-side).
if (!focusDim || diffMode || findQuery) return;

const headings = [];
blocks.forEach((b, i) => {
if (/^H[1-4]$/.test(b.tagName) && +b.tagName[1] <= focusDepth) headings.push(i);
});
// One region means dimming has nothing to say. Also the answer when every
// heading in the document is deeper than the chosen depth: no boundaries, so
// no regions to tell apart.
// `focusDepth` is a floor, not a fixed rule: a document with fewer than two
// headings that coarse (an h1 title over h4 steps, say) would have no
// boundaries at all, so widen a level at a time until it has two.
let headings = [];
for (let depth = focusDepth; depth <= 4 && headings.length < 2; depth++) {
headings = blocks.flatMap((b, i) =>
/^H[1-4]$/.test(b.tagName) && +b.tagName[1] <= depth ? [i] : []);
}
// Still one region at every depth: a document with fewer than two headings,
// where dimming has nothing to say.
if (headings.length < 2) return;

let active = headings[0];
Expand Down
39 changes: 27 additions & 12 deletions Tests/ReaderMdTests/FocusModeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ final class FocusModeTests: XCTestCase {
private var savedTOC = false
private var savedWidth = ContentWidth.wide
private var savedFocusFullscreen = true
private var savedFocusDimSections = true
private var savedFocusDimSections = false
private var savedFocusNarrowCanvas = true
private var savedFocusHideToolbar = true
private var savedFocusRegionDepth = FocusRegionDepth.any
private var savedFocusRegionDepth = FocusRegionDepth.h3
private var savedFocusDimOpacity = 0.38

override func setUp() async throws {
Expand All @@ -29,14 +29,15 @@ final class FocusModeTests: XCTestCase {
savedFocusDimOpacity = Settings.loadFocusDimOpacity()

// Tests below flip individual focus switches, and those persist to
// UserDefaults like any other setter. Reset to the shipped defaults
// (all four on) so each test starts from a known baseline instead of
// whatever an earlier test in this suite left behind.
// UserDefaults like any other setter. Pin the shipped defaults so each
// test starts from a known baseline instead of whatever an earlier test
// left behind — except dimming, which ships off and is pinned on here so
// the dimming tests have something to assert.
Settings.saveFocusFullscreen(true)
Settings.saveFocusDimSections(true)
Settings.saveFocusNarrowCanvas(true)
Settings.saveFocusHideToolbar(true)
Settings.saveFocusRegionDepth(.any)
Settings.saveFocusRegionDepth(.h3)
Settings.saveFocusDimOpacity(0.38)
}

Expand Down Expand Up @@ -133,16 +134,30 @@ final class FocusModeTests: XCTestCase {

// MARK: - Configurable dimming

/// Both new preferences default to today's behaviour, so an existing install
/// sees no change until it touches a setting.
func testDimmingPreferencesDefaultToTodaysBehaviour() {
/// Dimming is opt-in, and when it is switched on the region is a section
/// down to `h3` rather than every heading.
func testDimmingDefaultsToOffAtH3() {
Settings.defaults.removeObject(forKey: "reader.md.focus.dimSections")
Settings.defaults.removeObject(forKey: "reader.md.focus.regionDepth")
Settings.defaults.removeObject(forKey: "reader.md.focus.dimOpacity")

XCTAssertEqual(Settings.loadFocusRegionDepth(), .any)
XCTAssertFalse(Settings.loadFocusDimSections())
XCTAssertEqual(Settings.loadFocusRegionDepth(), .h3)
XCTAssertEqual(Settings.loadFocusDimOpacity(), 0.38, accuracy: 0.0001)
}

/// The other three switches are the mode's advertised takeover, so they stay
/// on out of the box.
func testTheOtherThreeSwitchesDefaultOn() {
Settings.defaults.removeObject(forKey: "reader.md.focus.fullscreen")
Settings.defaults.removeObject(forKey: "reader.md.focus.narrowCanvas")
Settings.defaults.removeObject(forKey: "reader.md.focus.hideToolbar")

XCTAssertTrue(Settings.loadFocusFullscreen())
XCTAssertTrue(Settings.loadFocusNarrowCanvas())
XCTAssertTrue(Settings.loadFocusHideToolbar())
}

/// An absent key must not read as 0: `defaults.integer` would make depth 0
/// (no valid heading level) and `defaults.double` would make the document
/// invisible.
Expand Down Expand Up @@ -180,10 +195,10 @@ final class FocusModeTests: XCTestCase {

/// A depth stored by a future build (or corrupted) must not become a depth of
/// 0, which would produce an empty boundary list and silently kill dimming.
func testUnknownStoredDepthFallsBackToAny() {
func testUnknownStoredDepthFallsBackToTheDefault() {
Settings.defaults.set(99, forKey: "reader.md.focus.regionDepth")

XCTAssertEqual(Settings.loadFocusRegionDepth(), .any)
XCTAssertEqual(Settings.loadFocusRegionDepth(), .h3)
}

/// The preview widens what counts as "dimming is showing" — never what counts
Expand Down
Binary file modified docs/assets/screenshots/settings/01-window.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ See [Reading a document](features/reading.md).
- **Text size** (⌘+ / ⌘− / ⌘0) and **canvas width** (⇧⌘\\, narrow / wide / full) — both persisted
- **Reading feedback** — a progress bar under the toolbar, with the word count and reading time under the file name
- **Resume where you stopped** — a long document reopens at the place you left it
- **Focus mode** (⌥⌘F) — one toggle hides the chrome, goes fullscreen, and dims everything outside the section you're reading
- **Focus mode** (⌥⌘F) — one toggle hides the chrome, goes fullscreen, and narrows the canvas; an optional setting also dims everything outside the section you're reading

## How a document is rendered

Expand Down Expand Up @@ -186,7 +186,7 @@ reaches only text fields.
| ⌘0 | Actual Size |
| ⇧⌘\\ | Cycle Canvas Width (Narrow / Wide / Full) |
| ⇧⌘D | Toggle Diff (in a git repository) |
| ⌥⌘F | Focus Mode (hides the chrome, dims other sections) |
| ⌥⌘F | Focus Mode (hides the chrome, goes fullscreen, narrows the canvas) |

### Settings and help

Expand Down
41 changes: 22 additions & 19 deletions docs/features/reading.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,12 @@ tables and long code blocks.
## Focus mode

**⌥⌘F** takes everything away except the page. The sidebar and outline
collapse, the toolbar goes, the window moves to fullscreen, the canvas
narrows, and every section but the one you're reading dims. The toolbar's
focus button and `>Focus Mode` in ⌘P do the same thing.
collapse, the toolbar goes, the window moves to fullscreen, and the canvas
narrows. Switch on **Dim other sections** in Settings and every section but
the one you're reading dims too. The toolbar's focus button and `>Focus Mode`
in ⌘P do the same thing.

![Focus mode — chrome gone, everything but the current section dimmed](../assets/screenshots/reading/06-focus-mode.png)
![Focus mode with Dim other sections switched on — chrome gone, everything but the current section dimmed](../assets/screenshots/reading/06-focus-mode.png)

**⌥⌘F again, or ⎋, brings it all back** — and back means back: the sidebar,
outline, and column width return to what they were, not to a default. ⎋
Expand All @@ -90,27 +91,29 @@ doesn't outlive the fullscreen it's running in. (If the window was
already in fullscreen before ⌥⌘F, exiting focus mode leaves it there rather
than dropping out.)

The dimming follows the outline rather than the scroll position, so it holds
still while you read a section and fades across when you reach the next
heading. **Region ends at** in Settings decides how wide "a section" is: every
heading by default, or only headings down to H3, H2 or H1 — at *H2 or above* an
`h2` stays lit across all of its subheadings, and crossing one of them changes
nothing. A document whose headings are all deeper than that setting has no
regions to tell apart, so nothing dims. **Dimming** sets how far the rest fades,
from 40% to 88%. Dimming steps aside entirely while you're searching, in diff
mode, and in a document with fewer than two headings.
Dimming is off until you turn it on, and once on it follows the outline rather
than the scroll position, so it holds still while you read a section and fades
across when you reach the next heading. **Region ends at** in Settings decides
how wide "a section" is: headings down to H3 by default, or H2, H1, or every
heading — at *H2 or above* an `h2` stays lit across all of its subheadings, and
crossing one of them changes nothing. The setting is a floor rather than a hard
rule: a document with no headings that coarse — an `h1` title over `h4` steps,
say — falls back to the next level down rather than dimming nothing. **Dimming**
sets how far the rest fades, from 40% to 88%. Dimming steps aside entirely
while you're searching, in diff mode, and in a document with fewer than two
headings.

⌘F still works: rather than dropping you out of focus mode, it slides the
toolbar back down with the find field ready, and the toolbar stays down until
you leave focus mode.

Settings (⌘,) has a switch for each of the four pieces — fullscreen, dimming,
narrow canvas, hidden toolbar — all on by default, so you can keep only the
parts you want. Hiding the toolbar behaves the same whether or not fullscreen
is on, and the toolbar is never far away: nudge the pointer to the top edge of
the screen and it slides back down for as long as you're up there — long enough
to reach a control and click it. ⌘F brings it down too, and keeps it down while
you search. Neither costs you focus mode.
narrow canvas, hidden toolbar — so you can keep only the parts you want; all
are on out of the box except dimming. Hiding the toolbar behaves the same
whether or not fullscreen is on, and the toolbar is never far away: nudge the
pointer to the top edge of the screen and it slides back down for as long as
you're up there — long enough to reach a control and click it. ⌘F brings it
down too, and keeps it down while you search. Neither costs you focus mode.

Focus mode never persists. However you leave the app, it starts up outside it.

Expand Down
4 changes: 2 additions & 2 deletions docs/features/reading.shots.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@
{
"id": "06-focus-mode",
"open": "field-notes/guides/setup.md",
"prefs": { "reader.md.focus.fullscreen": false },
"prefs": { "reader.md.focus.fullscreen": false, "reader.md.focus.dimSections": true },
"actions": [
{ "key": "f", "mods": ["command", "option"] },
{ "waitMs": 900 }
],
"caption": "Focus mode — chrome gone, everything but the current section dimmed"
"caption": "Focus mode with Dim other sections switched on — chrome gone, everything but the current section dimmed"
}
]
}
15 changes: 9 additions & 6 deletions docs/features/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,17 @@ same three ⇧⌘\\ cycles through. Both are described in more detail on

Focus mode (⌥⌘F) is four things at once, and each is a switch here: **Enter
fullscreen**, **Dim other sections**, **Narrow the canvas**, and **Hide the
toolbar**. All four are on by default. Turning all four off leaves ⌥⌘F with
nothing to do, and the window says so.
toolbar**. Three of them are on by default; **Dim other sections** is off, since
it changes the page itself rather than the chrome around it — switch it on if
you want it. Turning all four off leaves ⌥⌘F with nothing to do, and the window
says so.

Two settings shape the dimming itself. **Region ends at** decides how much of
the document counts as the section you're reading: *Any heading* is the default
and lights one heading's worth at a time, while *H2 or above* keeps a whole `h2`
section lit including its subheadings. **Dimming** sets how far everything else
fades, from 40% to 88%.
the document counts as the section you're reading: *H3 or above* is the default,
so an `h4` step inside a section doesn't split it, while *H2 or above* keeps a
whole `h2` section lit including its subheadings and *Any heading* lights one
heading's worth at a time. **Dimming** sets how far everything else fades, from
40% to 88%.

Both only matter with **Dim other sections** on, and grey out without it. While
this window is open the document behind it previews them, so dragging the slider
Expand Down
Loading