From 936ae05b3bc3a55457e4604377755df62b35cf43 Mon Sep 17 00:00:00 2001 From: Pavel Kadera Date: Sun, 16 Aug 2026 21:31:29 +0200 Subject: [PATCH 1/2] Let the user set their own text size The desktop's text scale was the only knob on typography, so a document that read too small could only be fixed by changing the size of every other application too. Multiply a text size of the user's own onto that scale: the desktop still sets the baseline, and Ctrl+= / Ctrl+- / Ctrl+0 and Ctrl+wheel move from there. Because everything already sizes itself off textScale, the editor font, the 65-character column, the footer, and the wheel physics all follow with no further plumbing. The steps are Chromium's zoom ladder trimmed to 67%-200%, the range the whole window still reads well at, so Ctrl+wheel here lands where the same gesture lands in a browser. The ends of the ladder hold instead of running away, and every change reports the new size in the footer, including a keypress the ladder has no room for. A wheel notch is a whole step, but a touchpad trickles in pixels, so bank the fractions and step once a whole notch has been scrolled. The size persists, so the next window opens where the last one left off. Co-Authored-By: Claude Opus 5 --- README.md | 4 +++ src/Main.qml | 55 ++++++++++++++++++++++++++++++-- src/backend.cpp | 59 +++++++++++++++++++++++++++++++++-- src/backend.h | 15 ++++++--- src/main.cpp | 17 +++++----- tests/tst_omawrite.cpp | 71 ++++++++++++++++++++++++++++++++++++++++-- 6 files changed, 203 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c44ade3..d280057 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Install via the Omarchy Package Repository via the `omawrite` package. It's inst - `Ctrl+F` searches the document. Use `Enter` or `Ctrl+G` for the next match and `Shift+Enter` for the previous match. - `Ctrl+H` opens find and replace. - `Ctrl+B`, `Ctrl+I`, and `Ctrl+K` insert bold, italic, and link Markdown. +- `Ctrl+=` and `Ctrl+-` change the text size, `Ctrl+0` returns to the default. + `Ctrl+scroll` does the same. - `Ctrl+?` shows the keyboard shortcut reference. Unsaved drafts are recovered after an abnormal exit. Omawrite also watches open files @@ -30,6 +32,8 @@ and warns before an external change can replace local work. Text follows the desktop text size — `omarchy display text size`, or GNOME's `text-scaling-factor` — and re-flows without a restart. The default of 12px leaves Omawrite at the size it is designed around; larger and smaller sizes scale from there. +`Ctrl+=`, `Ctrl+-`, and `Ctrl+scroll` set your own size on top of that, from 67% to +200% in browser-sized steps. It is remembered for the next window you open. ## Requirements diff --git a/src/Main.qml b/src/Main.qml index cdcbc3e..54920ca 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -23,7 +23,8 @@ ApplicationWindow { readonly property color selectionFill: backend.themeSelection // The desktop's text size knob (GNOME's text-scaling-factor, which // `omarchy display text size` drives) anchored so its 12px default leaves - // the app at the sizes it was designed around. + // the app at the sizes it was designed around, times the user's own + // text size set with Ctrl+= / Ctrl+- / Ctrl+wheel. readonly property real textScale: backend.textScale readonly property int editorFontPixelSize: scaledSize(20) readonly property int editorWidth: Math.min( @@ -178,6 +179,24 @@ ApplicationWindow { onActivated: shortcutsDialog.open() } + Shortcut { + sequence: "Ctrl+=" + context: Qt.ApplicationShortcut + onActivated: backend.increaseTextSize() + } + + Shortcut { + sequence: "Ctrl+-" + context: Qt.ApplicationShortcut + onActivated: backend.decreaseTextSize() + } + + Shortcut { + sequence: "Ctrl+0" + context: Qt.ApplicationShortcut + onActivated: backend.resetTextSize() + } + Shortcut { sequence: "Ctrl+O" context: Qt.ApplicationShortcut @@ -331,7 +350,7 @@ ApplicationWindow { standardButtons: Dialog.Close anchors.centerIn: parent contentItem: Label { - text: "Ctrl+S Save\nCtrl+Shift+S Save As\nCtrl+O Open\nCtrl+N New Window\nCtrl+F Find\nCtrl+H Find and Replace\nCtrl+B Bold\nCtrl+I Italic\nCtrl+K Link\nCtrl+P Print\nF11 / Super+F Fullscreen\nCtrl+? Shortcuts" + text: "Ctrl+S Save\nCtrl+Shift+S Save As\nCtrl+O Open\nCtrl+N New Window\nCtrl+F Find\nCtrl+H Find and Replace\nCtrl+B Bold\nCtrl+I Italic\nCtrl+K Link\nCtrl+P Print\nCtrl+= / Ctrl+- Text Size\nCtrl+0 Reset Text Size\nF11 / Super+F Fullscreen\nCtrl+? Shortcuts" lineHeight: 1.5 } } @@ -341,6 +360,7 @@ ApplicationWindow { Flickable { id: editorFlick + objectName: "editorScroll" anchors.fill: parent anchors.leftMargin: 24 anchors.rightMargin: 24 @@ -463,6 +483,12 @@ ApplicationWindow { // finger scrolling carries pixel-precise pixelDelta. acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad onWheel: function(wheel) { + if (wheel.modifiers & Qt.ControlModifier) { + editorFlick.zoomByWheel(wheel); + wheel.accepted = true; + return; + } + scrollLinger.restart(); if (wheel.pixelDelta.y !== 0) editorFlick.scrollTo(editorFlick.clampContentY(editorFlick.contentY - wheel.pixelDelta.y)); @@ -474,6 +500,31 @@ ApplicationWindow { onMovementStarted: wheelScroll.stop() + // Ctrl+wheel changes the text size, as it does in a browser. A + // wheel notch is a whole step, but a touchpad trickles in pixels, + // so bank the fractions and step once a whole one is scrolled. + property real zoomNotches: 0 + + function zoomByWheel(wheel) { + // Finger scrolling synthesizes an angleDelta too, so pixelDelta + // is the one to test first, as in onWheel. + var notches = wheel.pixelDelta.y !== 0 + ? wheel.pixelDelta.y / 50 + : wheel.angleDelta.y / 120; + if (zoomNotches * notches < 0) + zoomNotches = 0; + + zoomNotches += notches; + while (zoomNotches >= 1) { + backend.increaseTextSize(); + zoomNotches -= 1; + } + while (zoomNotches <= -1) { + backend.decreaseTextSize(); + zoomNotches += 1; + } + } + function scrollByWheel(wheel) { // High-resolution wheels report fractional notches; feed // those through the same animated path, like Chromium does diff --git a/src/backend.cpp b/src/backend.cpp index 90e279e..c1dd4b5 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -30,11 +30,19 @@ #include #include +#include #include "markdownhighlighter.h" constexpr qreal typoraLineHeightPercent = 140; const QString lastSaveDirectorySetting = QStringLiteral("file/lastSaveDirectory"); +const QString textZoomSetting = QStringLiteral("text/zoom"); + +// The user's own text size, multiplied onto the desktop's text scale. +// Chromium's zoom ladder, trimmed to the range the whole window still reads +// well at, so Ctrl+wheel here steps like Ctrl+wheel in a browser. +constexpr qreal textZoomSteps[] = {0.67, 0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0}; +constexpr int textZoomStepCount = int(std::size(textZoomSteps)); QString Backend::normalizedLinkUrl(const QString &clipboardText) { QString candidate = clipboardText.trimmed(); @@ -72,6 +80,15 @@ QString Backend::normalizedLinkUrl(const QString &clipboardText) { } Backend::Backend(QObject *parent) : QObject(parent) { + // A settings file truncated by a crash reads back as an empty value, and + // both that and a NaN would clamp to the smallest size rather than fall + // back to the default. + bool zoomOk = false; + const qreal storedZoom = QSettings().value(textZoomSetting, 1.0).toDouble(&zoomOk); + m_textZoom = zoomOk && storedZoom > 0 ? qBound(textZoomSteps[0], storedZoom, + textZoomSteps[textZoomStepCount - 1]) + : 1.0; + const QString stateDirectory = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QDir().mkpath(stateDirectory); // Claim an orphaned snapshot before taking an empty slot. This ensures a @@ -158,11 +175,47 @@ void Backend::setDarkMode(bool darkMode) { emit darkModeChanged(); } -void Backend::setTextScale(qreal textScale) { - if (qFuzzyCompare(m_textScale, textScale)) +void Backend::setDesktopTextScale(qreal textScale) { + if (qFuzzyCompare(m_desktopTextScale, textScale)) + return; + + m_desktopTextScale = textScale; + emit textScaleChanged(); +} + +void Backend::increaseTextSize() { + setTextZoom(steppedTextZoom(1)); +} + +void Backend::decreaseTextSize() { + setTextZoom(steppedTextZoom(-1)); +} + +void Backend::resetTextSize() { + setTextZoom(1.0); +} + +// The neighbouring rung, starting from whichever rung the current zoom sits +// closest to; the ends of the ladder hold. +qreal Backend::steppedTextZoom(int direction) const { + int closest = 0; + for (int step = 1; step < textZoomStepCount; ++step) { + if (qAbs(textZoomSteps[step] - m_textZoom) < qAbs(textZoomSteps[closest] - m_textZoom)) + closest = step; + } + + return textZoomSteps[qBound(0, closest + direction, textZoomStepCount - 1)]; +} + +void Backend::setTextZoom(qreal zoom) { + // Report the size even when the ladder has run out, so a keypress that + // changes nothing still says why. + setStatus(QStringLiteral("Text size %1%").arg(qRound(zoom * 100))); + if (qFuzzyCompare(m_textZoom, zoom)) return; - m_textScale = textScale; + m_textZoom = zoom; + QSettings().setValue(textZoomSetting, zoom); emit textScaleChanged(); } diff --git a/src/backend.h b/src/backend.h index 2429590..971191e 100644 --- a/src/backend.h +++ b/src/backend.h @@ -23,7 +23,7 @@ class Backend : public QObject { Q_PROPERTY(QString status READ status NOTIFY statusChanged) Q_PROPERTY(int wordCount READ wordCount NOTIFY wordCountChanged) Q_PROPERTY(bool darkMode READ darkMode WRITE setDarkMode NOTIFY darkModeChanged) - Q_PROPERTY(qreal textScale READ textScale WRITE setTextScale NOTIFY textScaleChanged) + Q_PROPERTY(qreal textScale READ textScale NOTIFY textScaleChanged) Q_PROPERTY(QString themeBackground READ themeBackground NOTIFY themeColorsChanged) Q_PROPERTY(QString themeForeground READ themeForeground NOTIFY themeColorsChanged) Q_PROPERTY(QString themeAccent READ themeAccent NOTIFY themeColorsChanged) @@ -43,8 +43,9 @@ class Backend : public QObject { int wordCount() const { return m_wordCount; } bool darkMode() const { return m_darkMode; } void setDarkMode(bool darkMode); - qreal textScale() const { return m_textScale; } - void setTextScale(qreal textScale); + // What the desktop asks for, times what the user asked for on top of it. + qreal textScale() const { return m_desktopTextScale * m_textZoom; } + void setDesktopTextScale(qreal textScale); QString themeBackground() const { return m_themeBackground; } QString themeForeground() const { return m_themeForeground; } QString themeAccent() const { return m_themeAccent; } @@ -64,6 +65,9 @@ class Backend : public QObject { Q_INVOKABLE void discardRecovery(); Q_INVOKABLE void reloadFromDisk(); Q_INVOKABLE void keepExternalVersion(); + Q_INVOKABLE void increaseTextSize(); + Q_INVOKABLE void decreaseTextSize(); + Q_INVOKABLE void resetTextSize(); Q_INVOKABLE void printDocument(); Q_INVOKABLE void newWindow(); Q_INVOKABLE QString clipboardUrl() const; @@ -91,6 +95,8 @@ class Backend : public QObject { private: void loadDocumentText(const QString &text); + qreal steppedTextZoom(int direction) const; + void setTextZoom(qreal zoom); void setFileUrl(const QUrl &url); void setModified(bool modified); void setStatus(const QString &status); @@ -116,7 +122,8 @@ class Backend : public QObject { QString m_status; int m_wordCount = 0; bool m_darkMode = true; - qreal m_textScale = 1.0; + qreal m_desktopTextScale = 1.0; + qreal m_textZoom = 1.0; bool m_loading = false; bool m_closeAfterSave = false; bool m_formattingTypography = false; diff --git a/src/main.cpp b/src/main.cpp index 8b22213..4e6fb97 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -34,8 +34,8 @@ int main(int argc, char *argv[]) { QObject::connect(&systemTheme, &SystemTheme::darkModeChanged, &backend, &Backend::setDarkMode); - // Carry the desktop's text scale into the default font, so the chrome that - // inherits it (dialog titles, buttons) grows along with the writing area. + // Carry the text scale into the default font, so the chrome that inherits + // it (dialog titles, buttons) grows along with the writing area. const QFont interfaceFont(QStringLiteral("iA Writer Mono S")); const qreal basePointSize = interfaceFont.pointSizeF() > 0 ? interfaceFont.pointSizeF() @@ -45,13 +45,16 @@ int main(int argc, char *argv[]) { scaled.setPointSizeF(basePointSize * textScale); app.setFont(scaled); }; - applyInterfaceFont(systemTheme.textScale()); - backend.setTextScale(systemTheme.textScale()); + // The desktop sets the baseline; the backend folds the user's own text + // size into it and reports the total. + backend.setDesktopTextScale(systemTheme.textScale()); + applyInterfaceFont(backend.textScale()); QObject::connect(&systemTheme, &SystemTheme::textScaleChanged, &backend, - [&backend, applyInterfaceFont](qreal textScale) { - applyInterfaceFont(textScale); - backend.setTextScale(textScale); + &Backend::setDesktopTextScale); + QObject::connect(&backend, &Backend::textScaleChanged, &app, + [&backend, applyInterfaceFont]() { + applyInterfaceFont(backend.textScale()); }); QQmlApplicationEngine engine; diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index 5c3306a..9a3ca32 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -209,15 +209,82 @@ private slots: QCOMPARE(editor->property("font").value().pixelSize(), 20); // `omarchy display text size 16` sets the GNOME factor to 16/12. - backend.setTextScale(16.0 / 12.0); + backend.setDesktopTextScale(16.0 / 12.0); QCOMPARE(window->property("editorFontPixelSize").toInt(), 27); QCOMPARE(editor->property("font").value().pixelSize(), 27); - backend.setTextScale(9.0 / 12.0); + backend.setDesktopTextScale(9.0 / 12.0); QCOMPARE(window->property("editorFontPixelSize").toInt(), 15); QCOMPARE(editor->property("font").value().pixelSize(), 15); } + void stepsTextSizeOnTopOfTheDesktopScale() { + Backend backend; + QSignalSpy textScaleSpy(&backend, &Backend::textScaleChanged); + backend.setDesktopTextScale(1.5); + QCOMPARE(backend.textScale(), 1.5); + + backend.increaseTextSize(); + QCOMPARE(backend.textScale(), 1.5 * 1.1); + backend.decreaseTextSize(); + backend.decreaseTextSize(); + QCOMPARE(backend.textScale(), 1.5 * 0.9); + QCOMPARE(backend.status(), QStringLiteral("Text size 90%")); + + // The ends of the ladder hold instead of running away. + for (int step = 0; step < 8; ++step) + backend.decreaseTextSize(); + QCOMPARE(backend.textScale(), 1.5 * 0.67); + const int settledCount = textScaleSpy.count(); + backend.decreaseTextSize(); + QCOMPARE(textScaleSpy.count(), settledCount); + + // A new window picks the size back up. + Backend reopened; + QCOMPARE(reopened.textScale(), 0.67); + + backend.resetTextSize(); + QCOMPARE(backend.textScale(), 1.5); + QCOMPARE(Backend().textScale(), 1.0); + } + + void banksTouchpadScrollBeforeChangingTextSize() { + const QString mainQmlPath = QFINDTESTDATA("../src/Main.qml"); + QVERIFY(!mainQmlPath.isEmpty()); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(mainQmlPath)); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + + QObject *scroll = window->findChild(QStringLiteral("editorScroll")); + QVERIFY(scroll); + + const auto scrollBy = [scroll](int angle, int pixels) { + const QVariant wheel = QVariantMap{ + {QStringLiteral("angleDelta"), QVariantMap{{QStringLiteral("y"), angle}}}, + {QStringLiteral("pixelDelta"), QVariantMap{{QStringLiteral("y"), pixels}}}}; + QVERIFY(QMetaObject::invokeMethod(scroll, "zoomByWheel", Q_ARG(QVariant, wheel))); + }; + + // A touchpad trickles in pixels: a nudge is not a whole step yet. Its + // events carry a synthesized angleDelta as well, as Wayland sends them. + scrollBy(240, 20); + scrollBy(240, 20); + QCOMPARE(backend.textScale(), 1.0); + scrollBy(240, 20); + QCOMPARE(backend.textScale(), 1.1); + + // A wheel notch is one step, and reversing drops the banked remainder. + scrollBy(-120, 0); + QCOMPARE(backend.textScale(), 1.0); + + backend.resetTextSize(); + } + void remembersLastSaveDirectory() { QTemporaryDir saveDirectory; QVERIFY(saveDirectory.isValid()); From ee6e551523dbfc4ad1130cb501bbb5b35f57088c Mon Sep 17 00:00:00 2001 From: Omabot Date: Thu, 20 Aug 2026 05:30:35 -0700 Subject: [PATCH 2/2] Grow the text from either plus key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl++ is a distinct shortcut to Qt rather than a shifted Ctrl+=: pressing Shift and = resolves through QXkbCommon::possibleKeys to Ctrl+Plus, which Ctrl+= does not match, and a layout that puts + on its own key — German, Czech, the Nordic ones — never produces Ctrl+= at all, so its users had no way to make the text bigger. Ctrl+- needs nothing: minus is unshifted on those layouts, and the keypad modifier is stripped before shortcuts are matched, so the numpad keys fall out of this too. Co-Authored-By: Claude Opus 5 (1M context) --- src/Main.qml | 4 +++- tests/tst_omawrite.cpp | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/Main.qml b/src/Main.qml index 54920ca..704c029 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -180,7 +180,9 @@ ApplicationWindow { } Shortcut { - sequence: "Ctrl+=" + // Ctrl++ is a different shortcut to Qt, not a shifted Ctrl+=, and it is + // the only one a layout that puts + on its own key can reach. + sequences: ["Ctrl+=", "Ctrl++"] context: Qt.ApplicationShortcut onActivated: backend.increaseTextSize() } diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index 9a3ca32..9392ed3 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include "backend.h" #include "markdownhighlighter.h" @@ -285,6 +286,33 @@ private slots: backend.resetTextSize(); } + void growsTextFromEitherPlusKey() { + const QString mainQmlPath = QFINDTESTDATA("../src/Main.qml"); + QVERIFY(!mainQmlPath.isEmpty()); + + Backend backend; + QQmlEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("backend"), &backend); + QQmlComponent component(&engine, QUrl::fromLocalFile(mainQmlPath)); + QVERIFY2(component.isReady(), qPrintable(component.errorString())); + QScopedPointer object(component.create()); + QQuickWindow *window = qobject_cast(object.data()); + QVERIFY(window); + window->show(); + QVERIFY(QTest::qWaitForWindowExposed(window)); + + // Qt resolves a shifted = to Ctrl++ rather than to Ctrl+=, and a layout + // that puts + on its own key never produces Ctrl+= at all. + QTest::keyClick(window, Qt::Key_Plus, Qt::ControlModifier); + QCOMPARE(backend.textScale(), 1.1); + QTest::keyClick(window, Qt::Key_Equal, Qt::ControlModifier); + QCOMPARE(backend.textScale(), 1.25); + QTest::keyClick(window, Qt::Key_Minus, Qt::ControlModifier); + QCOMPARE(backend.textScale(), 1.1); + + backend.resetTextSize(); + } + void remembersLastSaveDirectory() { QTemporaryDir saveDirectory; QVERIFY(saveDirectory.isValid());