Skip to content

Add focus mode (Ctrl+Shift+T) - #17

Open
jvlianodorneles wants to merge 9 commits into
omacom-io:masterfrom
jvlianodorneles:focus-mode
Open

Add focus mode (Ctrl+Shift+T)#17
jvlianodorneles wants to merge 9 commits into
omacom-io:masterfrom
jvlianodorneles:focus-mode

Conversation

@jvlianodorneles

Copy link
Copy Markdown

Focus Mode (Ctrl+Shift+T)

This PR introduces a distraction-free Focus Mode toggled via Ctrl+Shift+T (and documented in Ctrl+? and the README), combining three complementary features:

1. Typewriter Scrolling

  • Automatically centers the active cursor line vertically in the viewport as you type or navigate.
  • Accommodates top and bottom scroll padding so that the first and last lines of a document can reach the vertical center naturally.

2. Paragraph Dimming

  • Dims inactive text blocks/paragraphs using a dynamic 30/70 foreground-background blend that adapts to light, dark, and custom Omarchy themes.
  • Efficient block-level updates: only the previous and current cursor blocks are rehighlighted when moving the cursor.
  • Applied before search highlighting so that active search matches (Ctrl+F) remain prominently visible across dimmed blocks.

3. Auto-hiding Footer

  • In focus mode, the footer buttons and word count smoothly fade out to 0 opacity.
  • Hovering near the bottom edge reveals the footer with a smooth 250ms fade animation.

Verification & Tests

  • Added unit tests in tests/tst_omawrite.cpp:
    • togglesFocusMode(): Tests property toggling and signal notifications.
    • focusModeDimsInactiveBlocks(): Tests that active block retains normal formatting while inactive blocks are dimmed, and verifies updates when cursor position changes.
  • All 14 tests pass (./bin/test).
  • Built cleanly with ./bin/build.

jvlianodorneles and others added 5 commits August 19, 2026 11:50
Introduce focusMode property to Backend and paragraph dimming to
MarkdownHighlighter. When enabled, non-active text blocks are dimmed
using a 30/70 blend of foreground and background that adapts to any
theme. Only the old and new cursor blocks are rehighlighted on cursor
move for efficiency.
Ctrl+Shift+T toggles focus mode. When active:
- ensureCursorVisible() centers the cursor line vertically
- Extra top/bottom padding lets the first and last lines reach center
- Footer fades out and reappears on hover (250ms animation)
- Shortcuts dialog updated with the new keybinding
Add Ctrl+Shift+T to README shortcuts section. Add togglesFocusMode()
and focusModeDimsInactiveBlocks() unit tests verifying property
toggling, signal emission, and block-level dimming behavior.
Remove anchors.centerIn on the Popup Dialog and provide explicit width and x/y centering to eliminate circular dependency calculations in Qt Quick Controls Material style.
Focus dimming rewrote the foreground of every format run in an inactive block, including the marker runs that `**`, `[` and `](url)` are painted with. Those are made invisible by drawing them in the background colour, so overwriting that with the dimmed colour brought every marker in the document back as a smudge — visual noise in the one mode meant to remove it. Runs already painted in the background colour are now left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@omarchybot

Copy link
Copy Markdown
Collaborator

Reviewed this and pushed one fix to the branch (f1f63f4). Everything below was reproduced by driving the real Main.qml and highlighter rather than read off the diff.

Fixed: applyFocusDimming rewrote the foreground of every format run in an inactive block, including the marker runs. Those markers are invisible because they are painted in the background colour, so overwriting that brought them back — in a dimmed block containing some **bold** here, the ** runs came back as #535353, the same dimmed colour as the real text, instead of the #101010 background. Every **, [ and ](url) in the document turns into a visible smudge, which is noise in the one mode meant to remove it. Runs already painted in the background colour are now left alone, with a test covering it.

Needs your attention before this lands:

  • Toggling the mode destroys the unsaved-changes indicator. toggleFocusMode writes to the same m_status that carries "Unsaved", and nothing restores it. After an edit, status is "Unsaved" and modified is 1; entering the mode makes it "Focus mode"; leaving makes it "" while modified is still 1. The footer Label is visible: text !== "", so the indicator disappears, and setStatus early-returns on an equal value, so it does not come back until the next keystroke. Nothing is actually lost — the close guard reads modified, not the status string, so the unsaved-changes dialog still fires — but a user who toggles the mode and glances at the footer is told a modified document is clean. I did not push a repair because each option loses something different: not touching the status at all removes the announcement you deliberately added, remembering and restoring the previous string goes stale if the document is saved while in the mode, and re-deriving from modified loses "Saved foo.md". Worth a maintainer opinion.
  • The typewriter scrolling does not engage when you turn the mode on. Toggling changes editor.y and contentHeight, but ensureCursorVisible() is only reached from onCursorRectangleChanged, and cursorRectangle is in the editor's own coordinates — so moving editor.y does not change it. Press Ctrl+Shift+T and the current line is not centred; the behaviour only starts at the next cursor movement. In a long document with the caret near the bottom of the viewport, entering the mode can push it off-screen until something moves it. The onActivated handler is the obvious place to nudge it, but getting that right depends on editor.y and contentHeight having settled and on wheelScroll not being mid-animation, which I could not prove in an offscreen test, so I left it to you.

Smaller:

  • The shortcuts dialog is pinned to Math.min(380, win.width - 48) while its Label does not wrap and its font scales with win.scaledSize, so the list clips at large desktop text sizes. Add a Ctrl+Shift+X strikethrough shortcut #15 adds Ctrl+Shift+X Strikethrough, the longest line the dialog will hold, and Let the user set their own text size #14 makes text size user-configurable — both make this worse.
  • focusModeDimsInactiveBlocks computes firstFormat and never asserts on it, and after moving the caret it never checks that the third block un-dimmed. An implementation that dimmed every block unconditionally, active one included, would still pass every assertion in it.

Things I checked that are fine: the search bar is unaffected and still works inside the mode; the footer and word count fade and return on hover as described; the mode can always be exited, including on a fast double press or a window focus change; and it does not persist across launches, since m_focusMode is never written to QSettings.

Also worth saying: the implicitWidth binding loop that 4479b4e fixes is pre-existing on master, not something this PR introduced — the warning shows up in the test output of #8 and #15, which do not touch the dialog, and is absent here. That fix is worth having on its own merits.

Heads up that this overlaps #8 (link URL hover) and #15 (strikethrough) on the same files. #15 is the one to watch: you both rewrite the Ctrl+? dialog string and your README bullets are adjacent, so that pair needs a real resolution rather than a keep-both.

Tests pass (15 passed, 0 failed).

@jvlianodorneles

Copy link
Copy Markdown
Author

Updated with the requested fixes:

  • Status indicator preserved: Removed setStatus from toggleFocusMode() in src/backend.cpp, keeping the document state string (e.g. "Unsaved", "Saved foo.md") intact across toggling.
  • Immediate typewriter scrolling: Added onFocusModeChanged handler (Qt.callLater(editorFlick.ensureCursorVisible)) and onYChanged on the editor in src/Main.qml so entering focus mode centers the active line immediately and aborts any active wheel animation.
  • Shortcuts dialog scaling: Updated shortcutsDialog width in src/Main.qml to Math.min(win.scaledSize(380), win.width - 48) to scale proportionally with desktop text size and prevent clipping.
  • Test assertions: Updated focusModeDimsInactiveBlocks in tests/tst_omawrite.cpp to assert that the active block remains undimmed and that the third block un-dims upon cursor movement. Extended togglesFocusMode to assert status preservation.

Recentring on `editor.y` fires outside focus mode too, where y just tracks the window height, so any resize dragged a reader who had scrolled away from the caret back to it — including F11, which changes the same height. Driving the real window, a page parked at contentY 6520 with the caret out of sight jumped to 5464 on a resize from 820 to 900.

Guarded on `backend.focusMode`, which is the case the handler was added for. The test covers both halves: a resize outside the mode leaves the page where the reader put it, and entering the mode still centres the caret line straight away — with neither handler present that left the caret 599px above the top of the viewport.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@omarchybot

Copy link
Copy Markdown
Collaborator

Re-reviewed the two new commits by driving the real window rather than reading the diff. Both things I blocked on are genuinely fixed, and I pushed one fix for a regression the scroll change brought with it (27b3bda).

Verified fixed

  • Status. With an edited document the footer now reads Unsaved before, during and after a toggle, where it used to read Focus mode and then go blank. toggleFocusMode was the only thing writing the status on that transition, so nothing else depended on it and the string cannot go stale — a save taken while in the mode still writes Saved foo.md through the same setter.
  • Immediate centring. Caret on line 150 of a 300-line document in a 900px viewport: entering the mode now puts the caret centre at 450px, dead centre. The same sequence without either handler leaves it 599px above the top of the window, invisible until the next keystroke. With the caret on the last line it centres at 437px instead of 1239px below the bottom edge. onYChanged is what does the work; the Qt.callLater pass is a harmless second settle.
  • Test assertions. togglesFocusMode now fails against the old toggleFocusMode, on the Focus mode string, and focusModeDimsInactiveBlocks catches an implementation that dims everything.

Pushed — 27b3bda

onYChanged fires outside focus mode too, because editor.y also tracks the window height there. So any resize dragged a reader who had scrolled away back to the caret, F11 included, since fullscreen changes the same height. Measured: a page parked at contentY 6520 with the caret out of sight jumped to 5464 on a resize from 820 to 900. Guarding the handler on backend.focusMode keeps the case you added it for and drops the rest; the test covers both halves, and fails on the old handler in one direction and on no handler in the other.

Worth knowing where that fix stops: changing the window width still pulls you back to the caret, through onCursorRectangleChanged. I checked that against master and it does the same there — pre-existing, not yours.

Still open, small

The shortcuts dialog. Scaling the 380 fixes it at ordinary window sizes — at 1280px wide it now fits at every text scale. It still clips at the top of the range, because win.width - 48 takes over in a narrow window: at text scale 3 the longest line, F11 / Super+F Fullscreen, measures 720px in iA Writer Mono S against 624px of available width, so roughly 96px is cut off in any window under about 816px, and the minimum width is 720px. #15's Ctrl+Shift+X Strikethrough is longer still. Wrapping the Label or sizing the dialog from its content would fix it, but that is a design call on a dialog #15 also rewrites, so I left it to you and the maintainer.

Tests: 16 passed, 0 failed.

jvlianodorneles and others added 2 commits August 20, 2026 10:18
Hiding the window stopped the resize from reaching the layout: after setting the window to 900 its own height property read 900 while the flickable stayed at 820 and editor.y stayed at 42, so onYChanged never fired and the assertion that the page had not moved passed whatever the handler did. With the focusMode guard removed from onYChanged the suite still went green, which is the regression the test exists to catch. bin/test runs offscreen, so no window manager sees this window; a resize that does not land now fails the test instead of quietly emptying it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@omarchybot

Copy link
Copy Markdown
Collaborator

Re-reviewed 6376af8, the only commit since the last pass. Both earlier fixes are still on the branch and the code is untouched — that commit only changes tests/tst_omawrite.cpp — but one of its two edits made the regression test unable to fail. Pushed a repair as 7c4d55b.

window->setProperty("visible", false) empties the first half of recentresOnlyWhenFocusModeMovesTheEditor. Hiding the window stops a resize from reaching the layout. Measured inside the test on a worker: after window->setProperty("height", 900) the window's own height reads 900, while the flickable stays at 820 and editor.y stays at 42. So onYChanged never fires, nothing can move the page, and the assertion that the page stayed put passes no matter what the handler does. Proof by mutation: delete the if (backend.focusMode) guard from onYChanged — the exact regression this test was written to catch — and with your version the whole suite is green, 16 passed. With the test as it stood at 27b3bda the same mutation fails it, the page moved from 6520.59 to 5464. The 600-line change is fine on its own and I kept it; with the visible line removed and 600 lines the mutation fails again with the same numbers.

7c4d55b drops the visible line and asserts the resize actually landed, so a platform that refuses it fails the test loudly instead of hollowing it out. Three mutations now bite: guard removed → the page moved from 6520.59 to 5464; onYChanged removed entirely → caret centre -599.406 in a viewport of 900; resize suppressed → the resize never reached the editor: viewport 820, expected 900.

On the window-manager problem you were solving: bin/test sets QT_QPA_PLATFORM=offscreen, so no window manager ever sees this window. If it was flaky, it was the test binary being run directly rather than through bin/test — worth knowing that the other two tests that build the same Main.qml window (savesAndOpensFromFooterButtons, scalesTextWithDesktopTextSize) never hid it and were not flaky either.

Two things asked about that came back clean, so you do not need to chase them.

The keybinding: Ctrl+Shift+T collides with nothing. It is unique among the sixteen Shortcut blocks in Main.qml, Qt binds no standard editing action to it, the TextEdit's own Keys.onPressed claims only paste and unmodified navigation keys, and Omarchy's Hyprland bindings are all SUPER-based, so the compositor never intercepts it. Qt.ApplicationShortcut is safe here because Ctrl+N starts a separate process rather than a second window in this one. The only cost is habit: Ctrl+Shift+T is "reopen closed tab" almost everywhere else, and that is a product call rather than a defect.

Focus-mode restoration: leaving the mode puts the editor back through the y binding and returns the footer and word count to their normal opacity; the mode is absent from QSettings and from the recovery JSON, which carries only fileUrl and text, so it survives neither a restart nor crash recovery; saving is unrestricted while it is active, through Ctrl+S or the footer button the hover area reveals; and toggling reads the cursor position without ever assigning one, so the caret and selection survive in both directions.

Still open, unchanged: the shortcuts dialog clips at the top of the text-scale range in a narrow window. Since the last pass #21 has appeared and also adds shortcut lines, so that dialog now has three PRs queued against it — a maintainer call, not yours.

Tests: ./bin/test on a disposable worker, 16 passed 0 failed on 7c4d55b. Every number above is measured, not read off the diff.

Second opinion from codex at xhigh: it ran and concluded the opposite — that hiding the window "does not [...] prevent the later height assignment from propagating" and that the test still kills both mutations. That is wrong, and the measurement above is what settles it; its remaining sections, on the keybinding and on restoration, agree with what I found, though its independence is not currently guaranteed.

@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a keyboard-toggleable focus mode that centers the caret, dims inactive text blocks, and auto-hides the footer.

  • Adds focus-mode state and cursor synchronization between QML and the backend.
  • Extends Markdown highlighting with theme-aware inactive-block dimming while preserving hidden Markdown markers.
  • Updates shortcut documentation and adds focus-mode highlighting and viewport tests.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking issues identified.

The focus-mode state, highlighting updates, scrolling geometry, footer behavior, and documented shortcut are internally consistent, and the investigated edge cases did not establish an observable changed-code failure.

Important Files Changed

Filename Overview
src/Main.qml Adds focus-mode shortcut handling, caret-centering geometry, cursor synchronization, shortcut documentation, and hover-revealed footer behavior without an accepted defect.
src/backend.cpp Adds focus-mode state toggling and forwards cursor positions to the Markdown highlighter.
src/backend.h Exposes focus mode as a QML property and declares the new state, signal, and invokable methods.
src/markdownhighlighter.cpp Implements incremental active-block tracking and theme-aware dimming before search highlighting, while retaining hidden marker colors.
src/markdownhighlighter.h Declares focus-mode highlighting APIs and state.
tests/tst_omawrite.cpp Adds coverage for focus toggling, inactive-block dimming, hidden inline markers, and immediate caret recentering.
README.md Documents the new Ctrl+Shift+T focus-mode shortcut.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Ctrl+Shift+T] --> B[Sync current cursor position]
    B --> C[Toggle Backend focusMode]
    C --> D[Rehighlight document]
    C --> E[Reposition editor and center caret]
    C --> F[Hide footer until bottom-edge hover]
    G[Cursor moves] --> H[Update focus cursor block]
    H --> I[Rehighlight previous and current blocks]
Loading

Reviews (1): Last reviewed commit: "Keep the resize half of the focus-mode t..." | Re-trigger Greptile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants