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/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 cdcbc3e..ece6683 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)); @@ -802,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" @@ -815,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/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..b317dc8 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,26 @@ 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); + + // 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() {