diff --git a/Sources/ReaderMd/Models/Settings.swift b/Sources/ReaderMd/Models/Settings.swift index d312dfe..f3fc44f 100644 --- a/Sources/ReaderMd/Models/Settings.swift +++ b/Sources/ReaderMd/Models/Settings.swift @@ -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 } @@ -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) @@ -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) { diff --git a/Sources/ReaderMd/Resources/docs/CHANGELOG.md b/Sources/ReaderMd/Resources/docs/CHANGELOG.md index f5e33fe..52410af 100644 --- a/Sources/ReaderMd/Resources/docs/CHANGELOG.md +++ b/Sources/ReaderMd/Resources/docs/CHANGELOG.md @@ -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 diff --git a/Sources/ReaderMd/Resources/docs/SHORTCUTS.md b/Sources/ReaderMd/Resources/docs/SHORTCUTS.md index cf94de6..bb95ecb 100644 --- a/Sources/ReaderMd/Resources/docs/SHORTCUTS.md +++ b/Sources/ReaderMd/Resources/docs/SHORTCUTS.md @@ -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 diff --git a/Sources/ReaderMd/Resources/web/bridge.js b/Sources/ReaderMd/Resources/web/bridge.js index f7ac83d..61ef178 100644 --- a/Sources/ReaderMd/Resources/web/bridge.js +++ b/Sources/ReaderMd/Resources/web/bridge.js @@ -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
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'); @@ -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]; diff --git a/Tests/ReaderMdTests/FocusModeTests.swift b/Tests/ReaderMdTests/FocusModeTests.swift index 7b89a83..4a90f8e 100644 --- a/Tests/ReaderMdTests/FocusModeTests.swift +++ b/Tests/ReaderMdTests/FocusModeTests.swift @@ -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 { @@ -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) } @@ -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. @@ -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 diff --git a/docs/assets/screenshots/settings/01-window.png b/docs/assets/screenshots/settings/01-window.png index 820adee..9ea36d0 100644 Binary files a/docs/assets/screenshots/settings/01-window.png and b/docs/assets/screenshots/settings/01-window.png differ diff --git a/docs/features.md b/docs/features.md index fcf9456..0f3ec11 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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 @@ -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 diff --git a/docs/features/reading.md b/docs/features/reading.md index fd753ab..781aca0 100644 --- a/docs/features/reading.md +++ b/docs/features/reading.md @@ -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. ⎋ @@ -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. diff --git a/docs/features/reading.shots.json b/docs/features/reading.shots.json index b7a9150..9e8835a 100644 --- a/docs/features/reading.shots.json +++ b/docs/features/reading.shots.json @@ -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" } ] } diff --git a/docs/features/settings.md b/docs/features/settings.md index 9a26b46..ea0be34 100644 --- a/docs/features/settings.md +++ b/docs/features/settings.md @@ -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