From 9a2200ec86927e6d72577b9f7a44fe91920ecf69 Mon Sep 17 00:00:00 2001 From: Meir Dick Date: Thu, 20 Aug 2026 13:29:22 -0400 Subject: [PATCH 1/2] Zoom the text with Ctrl and the plus and minus keys Omawrite followed the desktop text size, so changing the writing size meant leaving the app. Ctrl+= and Ctrl+- now step the text along the zoom ladder a browser uses, Ctrl+0 returns to the default, and Ctrl and the wheel does the same as it scrolls. The zoom multiplies the desktop text size rather than replacing it, and is remembered between sessions. --- README.md | 5 ++++ src/Main.qml | 55 +++++++++++++++++++++++++++++++++++++++-- src/backend.cpp | 56 +++++++++++++++++++++++++++++++++++++++--- src/backend.h | 18 +++++++++++--- src/main.cpp | 17 +++++++------ tests/tst_omawrite.cpp | 49 ++++++++++++++++++++++++++++++++++-- 6 files changed, 182 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c44ade3..d240384 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+-` make the text bigger and smaller, and `Ctrl+0` returns + it to the default. `Ctrl` and the mouse wheel does the same. - `Ctrl+?` shows the keyboard shortcut reference. Unsaved drafts are recovered after an abnormal exit. Omawrite also watches open files @@ -31,6 +33,9 @@ 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. +The zoom shortcuts scale on top of that, along the same steps a browser uses, from +half size to triple. Omawrite remembers the zoom between sessions. + ## Requirements - Qt 6: `qt6-base`, `qt6-declarative`, `qt6-quickcontrols2` diff --git a/src/Main.qml b/src/Main.qml index cdcbc3e..434e3b1 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -23,7 +23,7 @@ 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 in-app zoom. readonly property real textScale: backend.textScale readonly property int editorFontPixelSize: scaledSize(20) readonly property int editorWidth: Math.min( @@ -85,6 +85,29 @@ ApplicationWindow { return Math.max(1, Math.round(pixels * win.textScale)); } + // One zoom step per wheel notch. Finger scrolling arrives as a stream of + // small pixel deltas instead, so those add up to a notch before they move + // the zoom, which keeps a pinch from running to the end of the ladder. + property real zoomWheelDelta: 0 + + function zoomByWheel(wheel) { + var delta = wheel.angleDelta.y !== 0 ? wheel.angleDelta.y : wheel.pixelDelta.y * 4; + if (delta === 0) + return; + if (delta > 0 !== zoomWheelDelta > 0) + zoomWheelDelta = 0; + + zoomWheelDelta += delta; + while (zoomWheelDelta >= 120) { + zoomWheelDelta -= 120; + backend.zoomIn(); + } + while (zoomWheelDelta <= -120) { + zoomWheelDelta += 120; + backend.zoomOut(); + } + } + function toggleFullScreen() { win.visibility = win.visibility === Window.FullScreen ? Window.Windowed @@ -196,6 +219,27 @@ ApplicationWindow { onActivated: backend.saveAsDialog() } + // Ctrl and the plus key, which the layout may only reach through Shift, + // and Ctrl and minus. Listing a sequence twice makes Qt call the match + // ambiguous and fire nothing, so each spelling appears once. + Shortcut { + sequences: ["Ctrl+=", "Ctrl++"] + context: Qt.ApplicationShortcut + onActivated: backend.zoomIn() + } + + Shortcut { + sequence: "Ctrl+-" + context: Qt.ApplicationShortcut + onActivated: backend.zoomOut() + } + + Shortcut { + sequence: "Ctrl+0" + context: Qt.ApplicationShortcut + onActivated: backend.resetZoom() + } + Shortcut { sequence: "Ctrl+P" context: Qt.ApplicationShortcut @@ -331,7 +375,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+= Bigger Text\nCtrl+- Smaller Text\nCtrl+0 Reset Text Size\nF11 / Super+F Fullscreen\nCtrl+? Shortcuts" lineHeight: 1.5 } } @@ -463,6 +507,13 @@ ApplicationWindow { // finger scrolling carries pixel-precise pixelDelta. acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad onWheel: function(wheel) { + // Ctrl and the wheel zooms, as it does in a browser. + if (wheel.modifiers & Qt.ControlModifier) { + win.zoomByWheel(wheel); + wheel.accepted = true; + return; + } + scrollLinger.restart(); if (wheel.pixelDelta.y !== 0) editorFlick.scrollTo(editorFlick.clampContentY(editorFlick.contentY - wheel.pixelDelta.y)); diff --git a/src/backend.cpp b/src/backend.cpp index 90e279e..81fef3d 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -35,6 +35,11 @@ constexpr qreal typoraLineHeightPercent = 140; const QString lastSaveDirectorySetting = QStringLiteral("file/lastSaveDirectory"); +const QString zoomSetting = QStringLiteral("view/zoom"); +// The zoom steps a browser uses, so Ctrl+= and Ctrl+- land where the muscle +// memory from Chromium and Firefox expects. +const qreal zoomSteps[] = {0.5, 0.67, 0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0}; +constexpr int zoomStepCount = int(sizeof(zoomSteps) / sizeof(zoomSteps[0])); QString Backend::normalizedLinkUrl(const QString &clipboardText) { QString candidate = clipboardText.trimmed(); @@ -91,6 +96,11 @@ Backend::Backend(QObject *parent) : QObject(parent) { } } } + // A hand-edited settings file could hold anything; keep the restored + // zoom inside the range the shortcuts can reach. + const qreal savedZoom = QSettings().value(zoomSetting, 1.0).toReal(); + m_zoom = qBound(zoomSteps[0], savedZoom, zoomSteps[zoomStepCount - 1]); + m_wordCountTimer.setSingleShot(true); m_wordCountTimer.setInterval(120); connect(&m_wordCountTimer, &QTimer::timeout, this, &Backend::refreshWordCount); @@ -158,11 +168,51 @@ void Backend::setDarkMode(bool darkMode) { emit darkModeChanged(); } -void Backend::setTextScale(qreal textScale) { - if (qFuzzyCompare(m_textScale, textScale)) +void Backend::setSystemTextScale(qreal textScale) { + if (qFuzzyCompare(m_systemTextScale, textScale)) + return; + + m_systemTextScale = textScale; + emit textScaleChanged(); +} + +// The next step up (direction 1) or down (-1) from the given zoom, snapping a +// value that sits between two steps onto the ladder. +qreal Backend::steppedZoom(qreal zoom, int direction) { + if (direction > 0) { + for (int step = 0; step < zoomStepCount; ++step) { + if (zoomSteps[step] > zoom + 0.001) + return zoomSteps[step]; + } + return zoomSteps[zoomStepCount - 1]; + } + + for (int step = zoomStepCount - 1; step >= 0; --step) { + if (zoomSteps[step] < zoom - 0.001) + return zoomSteps[step]; + } + return zoomSteps[0]; +} + +void Backend::zoomIn() { + setZoom(steppedZoom(m_zoom, 1)); +} + +void Backend::zoomOut() { + setZoom(steppedZoom(m_zoom, -1)); +} + +void Backend::resetZoom() { + setZoom(1.0); +} + +void Backend::setZoom(qreal zoom) { + if (qFuzzyCompare(m_zoom, zoom)) return; - m_textScale = textScale; + m_zoom = zoom; + QSettings().setValue(zoomSetting, m_zoom); + setStatus(QStringLiteral("Text size %1%").arg(qRound(m_zoom * 100))); emit textScaleChanged(); } diff --git a/src/backend.h b/src/backend.h index 2429590..c3026ac 100644 --- a/src/backend.h +++ b/src/backend.h @@ -23,7 +23,8 @@ 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(qreal zoom READ zoom 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 +44,12 @@ 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); + // The desktop text size and the in-app zoom, combined into the one + // factor the interface scales by. + qreal textScale() const { return m_systemTextScale * m_zoom; } + qreal zoom() const { return m_zoom; } + void setSystemTextScale(qreal textScale); + static qreal steppedZoom(qreal zoom, int direction); QString themeBackground() const { return m_themeBackground; } QString themeForeground() const { return m_themeForeground; } QString themeAccent() const { return m_themeAccent; } @@ -64,6 +69,9 @@ class Backend : public QObject { Q_INVOKABLE void discardRecovery(); Q_INVOKABLE void reloadFromDisk(); Q_INVOKABLE void keepExternalVersion(); + Q_INVOKABLE void zoomIn(); + Q_INVOKABLE void zoomOut(); + Q_INVOKABLE void resetZoom(); Q_INVOKABLE void printDocument(); Q_INVOKABLE void newWindow(); Q_INVOKABLE QString clipboardUrl() const; @@ -90,6 +98,7 @@ class Backend : public QObject { void externalChangeDetected(bool deleted, bool locallyModified); private: + void setZoom(qreal zoom); void loadDocumentText(const QString &text); void setFileUrl(const QUrl &url); void setModified(bool modified); @@ -116,7 +125,8 @@ class Backend : public QObject { QString m_status; int m_wordCount = 0; bool m_darkMode = true; - qreal m_textScale = 1.0; + qreal m_systemTextScale = 1.0; + qreal m_zoom = 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..150978e 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.setSystemTextScale(systemTheme.textScale()); + applyInterfaceFont(backend.textScale()); - backend.setTextScale(systemTheme.textScale()); QObject::connect(&systemTheme, &SystemTheme::textScaleChanged, &backend, - [&backend, applyInterfaceFont](qreal textScale) { - applyInterfaceFont(textScale); - backend.setTextScale(textScale); + &Backend::setSystemTextScale); + // Both the desktop text size and the in-app zoom arrive through this one + // signal, so the chrome follows either of them. + 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..4bc80aa 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -44,6 +44,45 @@ private slots: QStringLiteral("Already.md")); } + void stepsZoomAlongTheLadder() { + QCOMPARE(Backend::steppedZoom(1.0, 1), 1.1); + QCOMPARE(Backend::steppedZoom(1.0, -1), 0.9); + // A value between two steps snaps onto the ladder. + QCOMPARE(Backend::steppedZoom(1.2, 1), 1.25); + QCOMPARE(Backend::steppedZoom(1.2, -1), 1.1); + // The ends hold rather than run off. + QCOMPARE(Backend::steppedZoom(3.0, 1), 3.0); + QCOMPARE(Backend::steppedZoom(0.5, -1), 0.5); + } + + void remembersZoomBetweenSessions() { + { + Backend backend; + QCOMPARE(backend.zoom(), 1.0); + backend.zoomIn(); + QCOMPARE(backend.zoom(), 1.1); + } + + Backend restarted; + QCOMPARE(restarted.zoom(), 1.1); + restarted.resetZoom(); + QCOMPARE(restarted.zoom(), 1.0); + } + + void scalesTextByBothTheDesktopSizeAndTheZoom() { + Backend backend; + QSignalSpy textScaleSpy(&backend, &Backend::textScaleChanged); + + backend.setSystemTextScale(1.5); + QCOMPARE(backend.textScale(), 1.5); + + backend.zoomIn(); + QCOMPARE(backend.textScale(), 1.5 * 1.1); + QCOMPARE(textScaleSpy.count(), 2); + + backend.resetZoom(); + } + void findsInlineMarkdownRanges() { const auto markup = MarkdownHighlighter::inlineMarkup( QStringLiteral("**bold** and *italic* and [site](https://example.com)")); @@ -209,13 +248,19 @@ 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.setSystemTextScale(16.0 / 12.0); QCOMPARE(window->property("editorFontPixelSize").toInt(), 27); QCOMPARE(editor->property("font").value().pixelSize(), 27); - backend.setTextScale(9.0 / 12.0); + backend.setSystemTextScale(9.0 / 12.0); QCOMPARE(window->property("editorFontPixelSize").toInt(), 15); QCOMPARE(editor->property("font").value().pixelSize(), 15); + + // Zooming scales on top of the desktop text size. + backend.zoomIn(); + QCOMPARE(editor->property("font").value().pixelSize(), 17); + backend.resetZoom(); + QCOMPARE(editor->property("font").value().pixelSize(), 15); } void remembersLastSaveDirectory() { From b9c35a6724760f721381bc2b4336a9739af8ba8a Mon Sep 17 00:00:00 2001 From: Meir Dick Date: Thu, 20 Aug 2026 13:29:40 -0400 Subject: [PATCH 2/2] Scale the footer icons with the text The save and open icons stayed 16px while the status text beside them followed the text size, which the zoom shortcuts make easy to notice. The icons are drawn on a 16px grid, so the canvas now paints at the scaled size and the hit area grows with it. --- src/FooterIconButton.qml | 12 +++++++++--- src/Main.qml | 6 +++++- tests/tst_omawrite.cpp | 7 +++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/FooterIconButton.qml b/src/FooterIconButton.qml index f6b4239..f782895 100644 --- a/src/FooterIconButton.qml +++ b/src/FooterIconButton.qml @@ -14,6 +14,10 @@ Item { width: 16 height: 16 + // The icon paths are drawn on a 16px grid, so a larger button paints the + // same shape at a larger stroke. + readonly property real drawScale: width / 16 + ToolTip.visible: hitArea.containsMouse && tooltip.length > 0 ToolTip.text: tooltip @@ -29,10 +33,12 @@ Item { transformOrigin: Item.TopLeft scale: 1 / dpr onDprChanged: requestPaint() + onWidthChanged: requestPaint() onPaint: { var context = getContext("2d"); - context.setTransform(dpr, 0, 0, dpr, 0, 0); + var unit = dpr * control.drawScale; + context.setTransform(unit, 0, 0, unit, 0, 0); context.clearRect(0, 0, width, height); context.strokeStyle = control.iconColor; context.lineWidth = 1.4; @@ -76,8 +82,8 @@ Item { MouseArea { id: hitArea anchors.centerIn: parent - width: 28 - height: 28 + width: control.width + 12 + height: control.height + 12 hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: control.clicked() diff --git a/src/Main.qml b/src/Main.qml index 434e3b1..ece6683 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -853,11 +853,13 @@ ApplicationWindow { anchors.bottom: parent.bottom anchors.leftMargin: 12 anchors.bottomMargin: 10 - spacing: 12 + spacing: win.scaledSize(12) opacity: 0.55 FooterIconButton { objectName: "saveButton" + width: win.scaledSize(16) + height: win.scaledSize(16) iconName: "save" iconColor: win.mutedColor tooltip: "Save" @@ -866,6 +868,8 @@ ApplicationWindow { FooterIconButton { objectName: "openButton" + width: win.scaledSize(16) + height: win.scaledSize(16) iconName: "open" iconColor: win.mutedColor tooltip: "Open" diff --git a/tests/tst_omawrite.cpp b/tests/tst_omawrite.cpp index 4bc80aa..b317dc8 100644 --- a/tests/tst_omawrite.cpp +++ b/tests/tst_omawrite.cpp @@ -256,11 +256,18 @@ private slots: QCOMPARE(window->property("editorFontPixelSize").toInt(), 15); QCOMPARE(editor->property("font").value().pixelSize(), 15); + // The footer icons grow with the text beside them. + QObject *saveButton = window->findChild(QStringLiteral("saveButton")); + QVERIFY(saveButton); + QCOMPARE(saveButton->property("width").toInt(), 12); + // Zooming scales on top of the desktop text size. backend.zoomIn(); QCOMPARE(editor->property("font").value().pixelSize(), 17); + QCOMPARE(saveButton->property("width").toInt(), 13); backend.resetZoom(); QCOMPARE(editor->property("font").value().pixelSize(), 15); + QCOMPARE(saveButton->property("width").toInt(), 12); } void remembersLastSaveDirectory() {