From c3c0ff92bcbde40473022adcb1b6ea3171635092 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 11 Nov 2026 23:00:28 +0100 Subject: [PATCH 01/19] Build with CMake Omarchy's apps now build the same way: one CMakeLists.txt with the sources as a static library shared by the app and its tests, and identical bin/build, bin/test, bin/install and CI across every app. Everything the two .pro files set carries over: the modules, the resources, -fno-exceptions with link-time optimization, -Wall -Wextra, zlib and libwebp (now found through pkg-config), and the Qt 6.9 floor, with a toolchain under build/qt/ still preferred. bin/test now runs ctest, which takes the C++ suite and the Python command tests, which nothing ran before. With the environment no longer set by the script, the window-level tests run unless HYPE_GUI_TESTS=0, rather than only when it is set. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 42 ++++++++++++++++++++++++ CMakeLists.txt | 67 ++++++++++++++++++++++++++++++++++++++ README.md | 11 +++---- bin/build | 46 +++----------------------- bin/install | 9 ++--- bin/test | 18 ++++------ hype.pro | 28 ---------------- pkgbuild/PKGBUILD | 3 +- tests/tests.cpp | 46 +++++++++++++------------- tests/tests.pro | 25 -------------- 10 files changed, 156 insertions(+), 139 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 CMakeLists.txt delete mode 100644 hype.pro delete mode 100644 tests/tests.pro diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e56f4a0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,42 @@ +name: Test + +on: + pull_request: + push: + branches: [main, master] + +jobs: + test: + name: Build and test + runs-on: ubuntu-latest + # The app ships as an Arch package built against Arch's Qt, so it's tested there. + container: archlinux:latest + defaults: + run: + shell: bash + steps: + - name: Install git for the checkout + run: pacman -Syu --noconfirm --needed git + + - uses: actions/checkout@v4 + + - name: Install the package's dependencies + # The PKGBUILD is the one list of what the app needs, so CI reads it rather than keeping a copy. + run: | + source pkgbuild/PKGBUILD + deps=("${depends[@]}" "${makedepends[@]}" "${checkdepends[@]}") + pacman -S --noconfirm --needed base-devel cmake dbus "${deps[@]%%[<>=]*}" + + - name: Set up a user and a locale like a desktop's + # Root can read any file, so tests about unreadable ones need an ordinary user, and the container generates no locale. + run: | + useradd -m tester + chown -R tester . + sed -i 's/^#en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen + locale-gen + + - name: Build + run: su tester -c bin/build + + - name: Test + run: su tester -c 'LANG=en_US.UTF-8 dbus-run-session -- bin/test' diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..bb30cec --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,67 @@ +cmake_minimum_required(VERSION 3.21) +project(hype LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +# Like Qt's own modules, Hype never throws or catches. Without unwinding tables +# and with link-time optimization, the installed binary is about a quarter smaller. +add_compile_options(-fno-exceptions) +add_compile_definitions(QT_NO_EXCEPTIONS) + +# A Qt from Qt's installer unpacked under build/qt/ wins over an older system +# Qt, newest first; CMAKE_PREFIX_PATH picks any other. +file(GLOB bundled_qt LIST_DIRECTORIES true ${CMAKE_SOURCE_DIR}/build/qt/*/gcc_64) +list(SORT bundled_qt COMPARE NATURAL ORDER DESCENDING) +list(PREPEND CMAKE_PREFIX_PATH ${bundled_qt}) + +find_package(Qt6 6.9 REQUIRED COMPONENTS + Core Gui Qml Quick QuickControls2 Multimedia Concurrent DBus Svg Widgets Test) +find_package(PkgConfig REQUIRED) +pkg_check_modules(WEBP REQUIRED IMPORTED_TARGET libwebp libwebpdemux) +find_package(ZLIB REQUIRED) + +# Everything but main.cpp, shared by the app and the tests, so each source +# compiles once and a new file in src/ needs no line here. +file(GLOB hype_sources CONFIGURE_DEPENDS src/*.cpp src/*.h) +list(REMOVE_ITEM hype_sources ${CMAKE_SOURCE_DIR}/src/main.cpp) +add_library(hype_core STATIC ${hype_sources}) +target_include_directories(hype_core PUBLIC src) +target_link_libraries(hype_core PUBLIC + Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 Qt6::Multimedia Qt6::Concurrent + Qt6::DBus Qt6::Svg PkgConfig::WEBP ZLIB::ZLIB) +target_compile_options(hype_core PUBLIC -Wall -Wextra) +set_target_properties(hype_core PROPERTIES INTERPROCEDURAL_OPTIMIZATION ON) + +add_executable(hype src/main.cpp src/resources.qrc) +target_link_libraries(hype PRIVATE hype_core) +set_target_properties(hype PROPERTIES INTERPROCEDURAL_OPTIMIZATION ON) + +enable_testing() + +# The tests read exports back with Qt Pdf, which ships in qt6-webengine, so +# building the app alone doesn't need it. +find_package(Qt6 6.9 COMPONENTS Pdf) +if(TARGET Qt6::Pdf) + # The window-level tests load the same QML as the app, so they embed it too. + add_executable(hype_tests tests/tests.cpp src/resources.qrc) + target_link_libraries(hype_tests PRIVATE hype_core Qt6::Widgets Qt6::Pdf Qt6::Test) + add_test(NAME hype_tests COMMAND hype_tests) + # Hype draws its own controls, so the tests keep Qt's platform theme and style out of it. + set_tests_properties(hype_tests PROPERTIES ENVIRONMENT "QT_QPA_PLATFORMTHEME=generic;QT_STYLE_OVERRIDE=Fusion") +else() + message(WARNING "Qt Pdf was not found, so the tests can't be built; install qt6-webengine.") +endif() + +# The commands, exports and shutdown, driven through build/hype as an agent would. +find_package(Python3 COMPONENTS Interpreter) +if(Python3_FOUND) + add_test(NAME hype_commands + COMMAND ${Python3_EXECUTABLE} -m unittest discover -s tests -p "test_*.py" + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) +endif() diff --git a/README.md b/README.md index 555ecec..5ba95a7 100644 --- a/README.md +++ b/README.md @@ -244,22 +244,21 @@ The mouse wheel over the sidebar selects the previous or next slide. Home/End ju ## Run from source -To build Hype yourself, install a C++17 compiler, make, Qt 6.9 or newer, FFmpeg, and GNU source-highlight; see [the package definition](pkgbuild/PKGBUILD) for dependencies. Then: +To build Hype yourself, install a C++17 compiler, CMake, Qt 6.9 or newer, FFmpeg, and GNU source-highlight; see [the package definition](pkgbuild/PKGBUILD) for dependencies. Then: ```sh ./bin/build ./build/hype open examples/welcome.md ``` -`bin/build` uses a Qt toolchain under `build/qt/` when present, then falls back to `qmake6` on your PATH. It checks for Qt 6.9 or newer before compiling. Set `HYPE_QMAKE=/path/to/qmake6` to select a different toolchain. +The build uses a Qt toolchain from Qt's installer under `build/qt/` when present, newest first, then the system Qt, and checks for Qt 6.9 or newer before compiling. Set `CMAKE_PREFIX_PATH=/path/to/Qt/6.9.0/gcc_64` to select a different toolchain, and delete `build/CMakeCache.txt` when switching, since CMake remembers the Qt it found. For a launcher entry that rebuilds this checkout when opened, run `./bin/install-dev` and choose **Hype (Development)**. -Run `./bin/test` for the regression suite. After building the tests, the presenter -check can also run with two virtual displays: +Run `./bin/test` for the regression suite: the C++ tests (`hype_tests`, which read exported PDFs back with Qt Pdf from `qt6-webengine`) and the command-line tests (`hype_commands`, in Python). Arguments go to `ctest`, so `./bin/test -R hype_commands` runs only those. Set `HYPE_GUI_TESTS=0` to skip the window-level tests on a machine without multimedia. After building the tests, the presenter check can also run with two virtual displays: ```sh -HYPE_GUI_TESTS=1 QT_QPA_PLATFORMTHEME=generic QT_STYLE_OVERRIDE=Fusion \ +QT_QPA_PLATFORMTHEME=generic QT_STYLE_OVERRIDE=Fusion \ QT_QPA_PLATFORM="offscreen:configfile=$PWD/tests/fixtures/presenter-screens.json" \ - ./build/tests/hype-tests presenterViewUsesSeparateScreens + ./build/hype_tests presenterViewUsesSeparateScreens ``` diff --git a/bin/build b/bin/build index 11f6655..c806951 100755 --- a/bin/build +++ b/bin/build @@ -1,42 +1,6 @@ -#!/bin/sh -set -eu -ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" -mkdir -p "$ROOT/build" -cd "$ROOT/build" +#!/bin/bash +set -euo pipefail -if [ -n "${HYPE_QMAKE:-}" ]; then - build_qmake=$HYPE_QMAKE -else - # Qt's installer names this executable qmake; distro packages also use qmake6. - # Version sorting keeps 6.10 ahead of 6.9 when both toolchains are installed. - build_qmake=$( - for candidate in "$ROOT"/build/qt/*/gcc_64/bin/qmake "$ROOT"/build/qt/*/gcc_64/bin/qmake6; do - if [ -x "$candidate" ]; then - printf '%s\n' "$candidate" - fi - done | sort -V | tail -n 1 - ) - if [ -z "$build_qmake" ]; then - build_qmake=$(command -v qmake6 || true) - fi -fi -if [ -z "$build_qmake" ]; then - printf 'Qt 6.9 or newer is required; qmake6 was not found.\n' >&2 - exit 1 -fi -qt_version=$("$build_qmake" -query QT_VERSION) -qt_major=${qt_version%%.*} -qt_minor=${qt_version#*.} -qt_minor=${qt_minor%%.*} -if [ "$qt_major" -lt 6 ] || { [ "$qt_major" -eq 6 ] && [ "$qt_minor" -lt 9 ]; }; then - printf 'Qt 6.9 or newer is required; %s reports Qt %s.\n' "$build_qmake" "$qt_version" >&2 - exit 1 -fi - -"$build_qmake" "$ROOT/hype.pro" -# Objects built with another Qt version cannot be safely reused. -if [ -f Makefile ] && [ "$(cat .qmake-used 2>/dev/null || true)" != "$build_qmake" ]; then - make clean -fi -make -j"$(nproc)" -printf '%s\n' "$build_qmake" > .qmake-used +root="$(cd "$(dirname "$0")/.." && pwd)" +cmake -S "$root" -B "$root/build" -DCMAKE_BUILD_TYPE=Release >/dev/null +cmake --build "$root/build" -j"$(nproc)" diff --git a/bin/install b/bin/install index f805111..b982bd9 100755 --- a/bin/install +++ b/bin/install @@ -1,5 +1,6 @@ -#!/bin/sh -set -eu -ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" -cd "$ROOT/pkgbuild" +#!/bin/bash +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root/pkgbuild" makepkg -fsi "$@" diff --git a/bin/test b/bin/test index d6be55e..0fc642c 100755 --- a/bin/test +++ b/bin/test @@ -1,11 +1,7 @@ -#!/bin/sh -set -eu -ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" -mkdir -p "$ROOT/build/tests" -cd "$ROOT/build/tests" -qmake6 "$ROOT/tests/tests.pro" -make -j"$(nproc)" -# The window-level tests catch the regressions unit tests miss, so they run by default. -# Set HYPE_GUI_TESTS=0 to skip them, e.g. on a machine without multimedia. -if [ "${HYPE_GUI_TESTS-1}" = 0 ]; then unset HYPE_GUI_TESTS; else export HYPE_GUI_TESTS=1; fi -QT_QPA_PLATFORM=offscreen QT_QPA_PLATFORMTHEME=generic QT_STYLE_OVERRIDE=Fusion ./hype-tests "$@" +#!/bin/bash +set -euo pipefail + +# Arguments go to ctest, so `bin/test -R theme` runs only the matching tests. +root="$(cd "$(dirname "$0")/.." && pwd)" +"$root/bin/build" +QT_QPA_PLATFORM=offscreen ctest --test-dir "$root/build" --output-on-failure "$@" diff --git a/hype.pro b/hype.pro deleted file mode 100644 index 3b9918c..0000000 --- a/hype.pro +++ /dev/null @@ -1,28 +0,0 @@ -QT += core gui qml quick quickcontrols2 multimedia concurrent dbus svg -# Like Qt's own modules, Hype never throws or catches. Without unwinding tables and with -# link-time optimization, the installed binary is about a quarter smaller. -CONFIG += c++17 release ltcg exceptions_off -TARGET = hype -TEMPLATE = app -HEADERS += src/deck.h src/renderer.h src/budget.h -SOURCES += src/main.cpp src/deck.cpp src/renderer.cpp -RESOURCES += src/resources.qrc - -SOURCES += src/syntax.cpp -HEADERS += src/syntax.h -SOURCES += src/pptx.cpp -HEADERS += src/pptx.h -LIBS += -lz -lwebpdemux -lwebp - -SOURCES += src/animationexport.cpp -HEADERS += src/animationexport.h - -SOURCES += src/apptheme.cpp -HEADERS += src/apptheme.h -SOURCES += src/images.cpp -HEADERS += src/images.h -SOURCES += src/filedialog.cpp -HEADERS += src/filedialog.h -SOURCES += src/recovery.cpp -SOURCES += src/cli.cpp -HEADERS += src/cli.h diff --git a/pkgbuild/PKGBUILD b/pkgbuild/PKGBUILD index 58f6058..7f04568 100644 --- a/pkgbuild/PKGBUILD +++ b/pkgbuild/PKGBUILD @@ -5,7 +5,8 @@ pkgdesc='Simple Markdown presentations with a visual slide editor' arch=('x86_64' 'aarch64') license=('MIT') depends=('qt6-base>=6.9' 'qt6-declarative>=6.9' 'qt6-multimedia>=6.9' 'qt6-imageformats' 'qt6-svg' 'ffmpeg' 'source-highlight' 'zlib' 'libwebp' 'xdg-desktop-portal') -makedepends=('gcc' 'make') +makedepends=('cmake' 'gcc' 'make' 'pkgconf') +checkdepends=('qt6-webengine' 'python') source=() sha256sums=() build() { diff --git a/tests/tests.cpp b/tests/tests.cpp index 15ece15..320e822 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -646,7 +646,7 @@ static void write(const QString &path, const QString &content) { QVERIFY(latest.recoveryVersions().size() >= 3); // Previous untitled work is still recoverable. } void historyPopupProtectsSlides() { - if (!qEnvironmentVariableIsSet("HYPE_GUI_TESTS")) QSKIP("Set HYPE_GUI_TESTS=1"); + if (qEnvironmentVariable("HYPE_GUI_TESTS") == "0") QSKIP("HYPE_GUI_TESTS=0 skips the window-level tests"); QTemporaryDir files; Deck deck; deck.editSource("# First\n---\n# Last\n"); deck.enableAutosave(files.filePath("recovery")); @@ -677,8 +677,8 @@ static void write(const QString &path, const QString &content) { window->close(); } void presenterScreensAndCloseWhilePresenting() { - if (!qEnvironmentVariableIsSet("HYPE_GUI_TESTS")) - QSKIP("Set HYPE_GUI_TESTS=1"); + if (qEnvironmentVariable("HYPE_GUI_TESTS") == "0") + QSKIP("HYPE_GUI_TESTS=0 skips the window-level tests"); Deck deck; deck.editSource("# First\n---\n# Last\n"); QQuickStyle::setStyle("Basic"); @@ -738,8 +738,8 @@ static void write(const QString &path, const QString &content) { window->close(); } void presenterViewUsesSeparateScreens() { - if (!qEnvironmentVariableIsSet("HYPE_GUI_TESTS")) - QSKIP("Set HYPE_GUI_TESTS=1"); + if (qEnvironmentVariable("HYPE_GUI_TESTS") == "0") + QSKIP("HYPE_GUI_TESTS=0 skips the window-level tests"); const auto screens = QGuiApplication::screens(); if (screens.size() != 2) QSKIP("Run with offscreen:configfile=tests/fixtures/presenter-screens.json"); @@ -827,8 +827,8 @@ static void write(const QString &path, const QString &content) { window->close(); } void placeMenuPlacesImage() { - if (!qEnvironmentVariableIsSet("HYPE_GUI_TESTS")) - QSKIP("Set HYPE_GUI_TESTS=1"); + if (qEnvironmentVariable("HYPE_GUI_TESTS") == "0") + QSKIP("HYPE_GUI_TESTS=0 skips the window-level tests"); Deck deck; deck.editSource("# Headline\n\n![](logo.png)\n"); QQuickStyle::setStyle("Basic"); @@ -1618,8 +1618,8 @@ static void write(const QString &path, const QString &content) { QVERIFY(!unavailable.exporting() && unavailable.exportFailed()); } void pasteCompressionProgress() { - if (!qEnvironmentVariableIsSet("HYPE_GUI_TESTS")) - QSKIP("Set HYPE_GUI_TESTS=1 with local multimedia access"); + if (qEnvironmentVariable("HYPE_GUI_TESTS") == "0") + QSKIP("HYPE_GUI_TESTS=0 skips the window-level tests"); QTemporaryDir files; Deck deck; QVERIFY(deck.savePath(files.path() + "/talk.md")); @@ -1698,8 +1698,8 @@ static void write(const QString &path, const QString &content) { QVERIFY(deck.status().contains("slide changed")); } void videoHoldsLastFrameAndReplays() { - if (!qEnvironmentVariableIsSet("HYPE_GUI_TESTS")) - QSKIP("Set HYPE_GUI_TESTS=1 with local multimedia access"); + if (qEnvironmentVariable("HYPE_GUI_TESTS") == "0") + QSKIP("HYPE_GUI_TESTS=0 skips the window-level tests"); if (qEnvironmentVariable("QT_QUICK_BACKEND") == "software") QSKIP("Video pixel capture requires QT_QUICK_BACKEND=rhi"); QTemporaryDir files; @@ -1769,8 +1769,8 @@ static void write(const QString &path, const QString &content) { QVERIFY(!player->videoSink()->videoFrame().isValid()); } void animatedImages() { - if (!qEnvironmentVariableIsSet("HYPE_GUI_TESTS")) - QSKIP("Set HYPE_GUI_TESTS=1 with local multimedia access"); + if (qEnvironmentVariable("HYPE_GUI_TESTS") == "0") + QSKIP("HYPE_GUI_TESTS=0 skips the window-level tests"); QTemporaryDir files; QVERIFY(QDir().mkpath(files.path() + "/images")); QVERIFY(QFile::copy(QFINDTESTDATA("fixtures/animated.webp"), @@ -1889,8 +1889,8 @@ static void write(const QString &path, const QString &content) { QCOMPARE(empty.property("end").toInt(), 11); } void typingCodeInVisualEditorKeepsTheDeck() { - if (!qEnvironmentVariableIsSet("HYPE_GUI_TESTS")) - QSKIP("Set HYPE_GUI_TESTS=1 with local graphics access"); + if (qEnvironmentVariable("HYPE_GUI_TESTS") == "0") + QSKIP("HYPE_GUI_TESTS=0 skips the window-level tests"); QTemporaryDir files; const QString path = files.path() + "/presentation.md"; const QString tail = "---\n# Following\n---\n# Last\n"; @@ -1985,8 +1985,8 @@ static void write(const QString &path, const QString &content) { QCOMPARE(deck.count(), 5); } void overviewMode() { - if (!qEnvironmentVariableIsSet("HYPE_GUI_TESTS")) - QSKIP("Set HYPE_GUI_TESTS=1 with local graphics access"); + if (qEnvironmentVariable("HYPE_GUI_TESTS") == "0") + QSKIP("HYPE_GUI_TESTS=0 skips the window-level tests"); QQuickStyle::setStyle("Basic"); qmlRegisterType("Hype", 1, 0, "SlideCanvas"); qmlRegisterType("Hype", 1, 0, "AppTheme"); @@ -2180,8 +2180,8 @@ static void write(const QString &path, const QString &content) { QTRY_VERIFY(slideEditor->hasActiveFocus()); } void visualOperations() { - if (!qEnvironmentVariableIsSet("HYPE_GUI_TESTS")) - QSKIP("Set HYPE_GUI_TESTS=1 with local multimedia access"); + if (qEnvironmentVariable("HYPE_GUI_TESTS") == "0") + QSKIP("HYPE_GUI_TESTS=0 skips the window-level tests"); QQuickStyle::setStyle("Basic"); qmlRegisterType("Hype", 1, 0, "SlideCanvas"); qmlRegisterType("Hype", 1, 0, "AppTheme"); @@ -3338,8 +3338,8 @@ static void write(const QString &path, const QString &content) { QCOMPARE(d.count(), 2); } void navigationKeepsDocumentControlsStable() { - if (!qEnvironmentVariableIsSet("HYPE_GUI_TESTS")) - QSKIP("Set HYPE_GUI_TESTS=1 with local multimedia access"); + if (qEnvironmentVariable("HYPE_GUI_TESTS") == "0") + QSKIP("HYPE_GUI_TESTS=0 skips the window-level tests"); QQuickStyle::setStyle("Basic"); qmlRegisterType("Hype", 1, 0, "SlideCanvas"); qmlRegisterType("Hype", 1, 0, "AppTheme"); @@ -3741,8 +3741,8 @@ static void write(const QString &path, const QString &content) { QCOMPARE(provider.requestImage(id, nullptr, QSize(340, 192)).cacheKey(), first.cacheKey()); } void rapidImageNavigation() { - if (!qEnvironmentVariableIsSet("HYPE_GUI_TESTS")) - QSKIP("Set HYPE_GUI_TESTS=1 with local multimedia access"); + if (qEnvironmentVariable("HYPE_GUI_TESTS") == "0") + QSKIP("HYPE_GUI_TESTS=0 skips the window-level tests"); QTemporaryDir tmp; QVERIFY(QDir().mkpath(tmp.path() + "/images")); QString source; diff --git a/tests/tests.pro b/tests/tests.pro deleted file mode 100644 index d8029c8..0000000 --- a/tests/tests.pro +++ /dev/null @@ -1,25 +0,0 @@ -QT += core gui qml quick quickcontrols2 multimedia widgets testlib pdf concurrent dbus svg -CONFIG += c++17 testcase -TEMPLATE = app -TARGET = hype-tests -INCLUDEPATH += ../src -SOURCES += tests.cpp ../src/deck.cpp ../src/renderer.cpp -HEADERS += ../src/deck.h ../src/renderer.h ../src/budget.h -RESOURCES += ../src/resources.qrc - -SOURCES += ../src/syntax.cpp -HEADERS += ../src/syntax.h -SOURCES += ../src/pptx.cpp -HEADERS += ../src/pptx.h -LIBS += -lz -lwebpdemux -lwebp - -SOURCES += ../src/animationexport.cpp -HEADERS += ../src/animationexport.h - -SOURCES += ../src/apptheme.cpp -HEADERS += ../src/apptheme.h -SOURCES += ../src/images.cpp -HEADERS += ../src/images.h -SOURCES += ../src/filedialog.cpp -SOURCES += ../src/recovery.cpp -HEADERS += ../src/filedialog.h From 9b50e8c0f77ddb61e1a8c96b3c492e3b7ae0ad56 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 11 Nov 2026 23:04:26 +0100 Subject: [PATCH 02/19] Read slide theme colors the way the window does The window accepted bare, single-quoted and annotated colors in a theme's colors.toml, but the slides and the colors recorded by choosing a theme only took double-quoted ones, so such a theme styled Hype's window and left the slides on the defaults. Both now read through one function. Co-Authored-By: Claude Opus 5.5 --- src/apptheme.cpp | 29 ++++++++++++++++++----------- src/apptheme.h | 5 +++++ src/deck.cpp | 27 +++++++-------------------- tests/tests.cpp | 18 ++++++++++++++++++ 4 files changed, 48 insertions(+), 31 deletions(-) diff --git a/src/apptheme.cpp b/src/apptheme.cpp index 8d61bc6..33b8c10 100644 --- a/src/apptheme.cpp +++ b/src/apptheme.cpp @@ -19,6 +19,21 @@ static QColor contrastInk(const QColor &color) { .0722 * linear(color.blueF()); return luminance > .179 ? QColor("#111111") : QColor("#ffffff"); } +QMap readThemeColors(const QString &path) { + QMap colors; + QFile file(path); + if (path.isEmpty() || !file.open(QIODevice::ReadOnly)) + return colors; + // Quoted or bare hex with an optional note. Quotes must pair; comments need no gap. + static const QRegularExpression entry( + R"re(^\s*([a-z_]+)\s*=\s*(["']?)(#[0-9a-fA-F]{6})\2\s*(?:#.*)?\s*$)re"); + while (!file.atEnd()) { + const auto match = entry.match(QString::fromUtf8(file.readLine())); + if (match.hasMatch()) + colors[match.captured(1)] = match.captured(3); + } + return colors; +} AppTheme::AppTheme(QObject *parent) : AppTheme(qEnvironmentVariable("XDG_STATE_HOME", QDir::homePath() + "/.local/state") + "/omarchy/current", @@ -35,17 +50,9 @@ AppTheme::AppTheme(const QString ¤tDirectory, QObject *parent) void AppTheme::reload() { const QString theme = m_currentDirectory + "/theme", path = theme + "/colors.toml"; QMap values; - QFile file(path); - if (file.open(QIODevice::ReadOnly)) { - // Quoted or bare hex with an optional note. Quotes must pair; comments need no gap. - const QRegularExpression entry( - R"re(^\s*([a-z_]+)\s*=\s*(["']?)(#[0-9a-fA-F]{6})\2\s*(?:#.*)?\s*$)re"); - while (!file.atEnd()) { - const auto match = entry.match(QString::fromUtf8(file.readLine())); - if (match.hasMatch()) - values[match.captured(1)] = QColor(match.captured(3)); - } - } + const auto written = readThemeColors(path); + for (auto it = written.begin(); it != written.end(); ++it) + values[it.key()] = QColor(it.value()); const QColor bg = values.value("background", QColor("#1a1b26")); const QColor fg = values.value("foreground", QColor("#c0caf5")); const QColor accent = values.value("accent", QColor("#7aa2f7")); diff --git a/src/apptheme.h b/src/apptheme.h index c5080eb..be78aff 100644 --- a/src/apptheme.h +++ b/src/apptheme.h @@ -1,9 +1,14 @@ #pragma once #include +#include #include #include #include +// The hex colors in an Omarchy colors.toml by key, as written, quoted or bare, +// with or without a trailing note. +QMap readThemeColors(const QString &path); + class AppTheme : public QObject { Q_OBJECT Q_PROPERTY(QVariantMap colors READ colors NOTIFY changed) diff --git a/src/deck.cpp b/src/deck.cpp index ce55a64..ff8f29e 100644 --- a/src/deck.cpp +++ b/src/deck.cpp @@ -1,4 +1,5 @@ #include "deck.h" +#include "apptheme.h" #include "budget.h" #include "animationexport.h" #include "filedialog.h" @@ -553,16 +554,9 @@ QVariantMap Deck::palette() const { {"background", "#1a1b26"}, {"foreground", "#c0caf5"}, {"accent", "#7aa2f7"}, {"green", "#9ece6a"}, {"red", "#f7768e"}, {"yellow", "#e0af68"}, {"magenta", "#bb9af7"}, {"cyan", "#7dcfff"}, {"dark_foreground", "#787c99"}}; - QFile f(m_themes.value(themeName())); - if (f.open(QIODevice::ReadOnly)) { - QRegularExpression re("^([a-z_]+)\\s*=\\s*\"(#[0-9a-fA-F]{6})\"", - QRegularExpression::MultilineOption); - auto matches = re.globalMatch(QString::fromUtf8(f.readAll())); - while (matches.hasNext()) { - auto m = matches.next(); - colors[m.captured(1)] = m.captured(2); - } - } + const auto installed = readThemeColors(m_themes.value(themeName())); + for (auto it = installed.begin(); it != installed.end(); ++it) + colors[it.key()] = it.value(); for (const auto &key : colors.keys()) { QString v = scalar(m_parsed.header, "color_" + key); if (QColor(v).isValid()) @@ -581,16 +575,9 @@ void Deck::chooseTheme(const QString &name) { // Remove old palette before reading the newly selected installed theme. header.remove( QRegularExpression("^color_[a-z_]+:[^\\n]*\\n", QRegularExpression::MultilineOption)); - QFile file(m_themes.value(name)); - if (file.open(QIODevice::ReadOnly)) { - QRegularExpression re("^([a-z_]+)\\s*=\\s*\"(#[0-9a-fA-F]{6})\"", - QRegularExpression::MultilineOption); - auto matches = re.globalMatch(QString::fromUtf8(file.readAll())); - while (matches.hasNext()) { - auto m = matches.next(); - header = setScalar(header, "color_" + m.captured(1), m.captured(2)); - } - } + const auto installed = readThemeColors(m_themes.value(name)); + for (auto it = installed.begin(); it != installed.end(); ++it) + header = setScalar(header, "color_" + it.key(), it.value()); replaceHeader(header); } QStringList Deck::mediaPositions() const { return ::mediaPositions(); } diff --git a/tests/tests.cpp b/tests/tests.cpp index 15ece15..894f542 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -204,6 +204,24 @@ static void write(const QString &path, const QString &content) { qPrintable(value)); } } + void slidesReadThemeColorFormats() { + QTemporaryDir omarchy; + const QByteArray previous = qgetenv("OMARCHY_PATH"); + const auto restore = qScopeGuard([&] { qputenv("OMARCHY_PATH", previous); }); + qputenv("OMARCHY_PATH", omarchy.path().toUtf8()); + QVERIFY(QDir().mkpath(omarchy.filePath("themes/hype-formats"))); + write(omarchy.filePath("themes/hype-formats/colors.toml"), + "background = #101020\nforeground = '#eeeeee' # note\naccent = \"#7788ff\"\n"); + Deck named; + named.editSource("---\ntheme: hype-formats\n---\n\n# One\n"); + QCOMPARE(named.background(), QColor("#101020")); + QCOMPARE(named.foreground(), QColor("#eeeeee")); + Deck chosen; + chosen.chooseTheme("hype-formats"); + QVERIFY(chosen.source().contains("color_background: \"#101020\"")); + QVERIFY(chosen.source().contains("color_foreground: \"#eeeeee\"")); + QCOMPARE(chosen.accent(), QColor("#7788ff")); + } void followsDesktopTheme() { QTemporaryDir files; const QString current = files.path() + "/current"; From 3c03f9c44d3bb1c424b87a721f6c7dd447c670c7 Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:05:13 +0100 Subject: [PATCH 03/19] Unset OMARCHY_PATH after the theme test when it was unset before Restoring it as an empty string would point later tests at /themes. Co-Authored-By: Claude Opus 5.5 --- tests/tests.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/tests.cpp b/tests/tests.cpp index 894f542..13c7bb5 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -206,8 +206,12 @@ static void write(const QString &path, const QString &content) { } void slidesReadThemeColorFormats() { QTemporaryDir omarchy; + const bool wasSet = qEnvironmentVariableIsSet("OMARCHY_PATH"); const QByteArray previous = qgetenv("OMARCHY_PATH"); - const auto restore = qScopeGuard([&] { qputenv("OMARCHY_PATH", previous); }); + const auto restore = qScopeGuard([&] { + if (wasSet) qputenv("OMARCHY_PATH", previous); + else qunsetenv("OMARCHY_PATH"); + }); qputenv("OMARCHY_PATH", omarchy.path().toUtf8()); QVERIFY(QDir().mkpath(omarchy.filePath("themes/hype-formats"))); write(omarchy.filePath("themes/hype-formats/colors.toml"), From 85499b7f291b9db27b7e12e1cfa1d748b3bf2d49 Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:06:48 +0100 Subject: [PATCH 04/19] Give the tests fonts, and leave the trial tools out of ctest A bare Arch container has no fonts, so slides painted nothing and the font picker had one family to choose from. test_trials needs the trial importer's own Python packages, which only that tooling uses. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 5 +++-- pkgbuild/PKGBUILD | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index bb30cec..c382b9e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -59,9 +59,10 @@ else() endif() # The commands, exports and shutdown, driven through build/hype as an agent would. +# test_trials covers the trial import tools, which need their own Python packages. find_package(Python3 COMPONENTS Interpreter) if(Python3_FOUND) add_test(NAME hype_commands - COMMAND ${Python3_EXECUTABLE} -m unittest discover -s tests -p "test_*.py" - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + COMMAND ${Python3_EXECUTABLE} -m unittest test_cli test_export test_shutdown + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/tests) endif() diff --git a/pkgbuild/PKGBUILD b/pkgbuild/PKGBUILD index 7f04568..1cefdf2 100644 --- a/pkgbuild/PKGBUILD +++ b/pkgbuild/PKGBUILD @@ -6,7 +6,7 @@ arch=('x86_64' 'aarch64') license=('MIT') depends=('qt6-base>=6.9' 'qt6-declarative>=6.9' 'qt6-multimedia>=6.9' 'qt6-imageformats' 'qt6-svg' 'ffmpeg' 'source-highlight' 'zlib' 'libwebp' 'xdg-desktop-portal') makedepends=('cmake' 'gcc' 'make' 'pkgconf') -checkdepends=('qt6-webengine' 'python') +checkdepends=('qt6-webengine' 'python' 'ttf-jetbrains-mono' 'ttf-dejavu') source=() sha256sums=() build() { From 063d2c618c94a0fa9dfb30298dfa9d8642ba583b Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:07:27 +0100 Subject: [PATCH 05/19] Let CMAKE_PREFIX_PATH choose Qt over the one under build/qt The bundled toolchain was prepended to the search path, which CMake searches before the environment variable, so the documented override couldn't select another Qt. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c382b9e..1a8c692 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,10 +15,12 @@ add_compile_options(-fno-exceptions) add_compile_definitions(QT_NO_EXCEPTIONS) # A Qt from Qt's installer unpacked under build/qt/ wins over an older system -# Qt, newest first; CMAKE_PREFIX_PATH picks any other. -file(GLOB bundled_qt LIST_DIRECTORIES true ${CMAKE_SOURCE_DIR}/build/qt/*/gcc_64) -list(SORT bundled_qt COMPARE NATURAL ORDER DESCENDING) -list(PREPEND CMAKE_PREFIX_PATH ${bundled_qt}) +# Qt, newest first, unless CMAKE_PREFIX_PATH picks another. +if(NOT CMAKE_PREFIX_PATH AND NOT DEFINED ENV{CMAKE_PREFIX_PATH}) + file(GLOB bundled_qt LIST_DIRECTORIES true ${CMAKE_SOURCE_DIR}/build/qt/*/gcc_64) + list(SORT bundled_qt COMPARE NATURAL ORDER DESCENDING) + set(CMAKE_PREFIX_PATH ${bundled_qt}) +endif() find_package(Qt6 6.9 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 Multimedia Concurrent DBus Svg Widgets Test) From 2b409a53be36f46edb8b08978693c6a546d13b91 Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:11:08 +0100 Subject: [PATCH 06/19] Test with Noto fonts, as Omarchy installs them The font picker folds Noto's many families into a few, and the test that it rebuilds on a font change relies on that fold being there. Co-Authored-By: Claude Opus 5.5 --- pkgbuild/PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgbuild/PKGBUILD b/pkgbuild/PKGBUILD index 1cefdf2..a5c4c64 100644 --- a/pkgbuild/PKGBUILD +++ b/pkgbuild/PKGBUILD @@ -6,7 +6,7 @@ arch=('x86_64' 'aarch64') license=('MIT') depends=('qt6-base>=6.9' 'qt6-declarative>=6.9' 'qt6-multimedia>=6.9' 'qt6-imageformats' 'qt6-svg' 'ffmpeg' 'source-highlight' 'zlib' 'libwebp' 'xdg-desktop-portal') makedepends=('cmake' 'gcc' 'make' 'pkgconf') -checkdepends=('qt6-webengine' 'python' 'ttf-jetbrains-mono' 'ttf-dejavu') +checkdepends=('qt6-webengine' 'python' 'ttf-jetbrains-mono' 'noto-fonts') source=() sha256sums=() build() { From 56e20238f1635f149b0d44f804986364c8dc4d72 Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:18:31 +0100 Subject: [PATCH 07/19] Link the tests with LTO too, and depend on qt6-wayland The core library's objects are LTO-only, so the test link says so rather than relying on the linker plugin. qt6-wayland supplies Qt's native Wayland platform plugin; without it Hype runs under XWayland. The HYPE_GUI_TESTS note now says it covers the C++ window-level tests, since test_shutdown always opens the editor. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex --- CMakeLists.txt | 1 + README.md | 2 +- pkgbuild/PKGBUILD | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1a8c692..c4c1c5d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,7 @@ if(TARGET Qt6::Pdf) # The window-level tests load the same QML as the app, so they embed it too. add_executable(hype_tests tests/tests.cpp src/resources.qrc) target_link_libraries(hype_tests PRIVATE hype_core Qt6::Widgets Qt6::Pdf Qt6::Test) + set_target_properties(hype_tests PROPERTIES INTERPROCEDURAL_OPTIMIZATION ON) add_test(NAME hype_tests COMMAND hype_tests) # Hype draws its own controls, so the tests keep Qt's platform theme and style out of it. set_tests_properties(hype_tests PROPERTIES ENVIRONMENT "QT_QPA_PLATFORMTHEME=generic;QT_STYLE_OVERRIDE=Fusion") diff --git a/README.md b/README.md index 5ba95a7..cb5fb77 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ The build uses a Qt toolchain from Qt's installer under `build/qt/` when present For a launcher entry that rebuilds this checkout when opened, run `./bin/install-dev` and choose **Hype (Development)**. -Run `./bin/test` for the regression suite: the C++ tests (`hype_tests`, which read exported PDFs back with Qt Pdf from `qt6-webengine`) and the command-line tests (`hype_commands`, in Python). Arguments go to `ctest`, so `./bin/test -R hype_commands` runs only those. Set `HYPE_GUI_TESTS=0` to skip the window-level tests on a machine without multimedia. After building the tests, the presenter check can also run with two virtual displays: +Run `./bin/test` for the regression suite: the C++ tests (`hype_tests`, which read exported PDFs back with Qt Pdf from `qt6-webengine`) and the command-line tests (`hype_commands`, in Python). Arguments go to `ctest`, so `./bin/test -R hype_commands` runs only those. Set `HYPE_GUI_TESTS=0` to skip the C++ window-level tests on a machine without multimedia. After building the tests, the presenter check can also run with two virtual displays: ```sh QT_QPA_PLATFORMTHEME=generic QT_STYLE_OVERRIDE=Fusion \ diff --git a/pkgbuild/PKGBUILD b/pkgbuild/PKGBUILD index a5c4c64..da100a5 100644 --- a/pkgbuild/PKGBUILD +++ b/pkgbuild/PKGBUILD @@ -4,7 +4,7 @@ pkgrel=1 pkgdesc='Simple Markdown presentations with a visual slide editor' arch=('x86_64' 'aarch64') license=('MIT') -depends=('qt6-base>=6.9' 'qt6-declarative>=6.9' 'qt6-multimedia>=6.9' 'qt6-imageformats' 'qt6-svg' 'ffmpeg' 'source-highlight' 'zlib' 'libwebp' 'xdg-desktop-portal') +depends=('qt6-base>=6.9' 'qt6-declarative>=6.9' 'qt6-multimedia>=6.9' 'qt6-imageformats' 'qt6-svg' 'qt6-wayland' 'ffmpeg' 'source-highlight' 'zlib' 'libwebp' 'xdg-desktop-portal') makedepends=('cmake' 'gcc' 'make' 'pkgconf') checkdepends=('qt6-webengine' 'python' 'ttf-jetbrains-mono' 'noto-fonts') source=() From 058588504ae1b28697de2be8b36c51edeb5dc7c6 Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:20:04 +0100 Subject: [PATCH 08/19] Record a chosen theme's colors in the order the theme lists them A map sorted them by name, which reordered every color line in the front matter on a theme change. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex --- src/apptheme.cpp | 11 +++++------ src/apptheme.h | 9 +++++---- src/deck.cpp | 10 ++++------ 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/apptheme.cpp b/src/apptheme.cpp index 33b8c10..4222510 100644 --- a/src/apptheme.cpp +++ b/src/apptheme.cpp @@ -19,8 +19,8 @@ static QColor contrastInk(const QColor &color) { .0722 * linear(color.blueF()); return luminance > .179 ? QColor("#111111") : QColor("#ffffff"); } -QMap readThemeColors(const QString &path) { - QMap colors; +QList> readThemeColors(const QString &path) { + QList> colors; QFile file(path); if (path.isEmpty() || !file.open(QIODevice::ReadOnly)) return colors; @@ -30,7 +30,7 @@ QMap readThemeColors(const QString &path) { while (!file.atEnd()) { const auto match = entry.match(QString::fromUtf8(file.readLine())); if (match.hasMatch()) - colors[match.captured(1)] = match.captured(3); + colors.append({match.captured(1), match.captured(3)}); } return colors; } @@ -50,9 +50,8 @@ AppTheme::AppTheme(const QString ¤tDirectory, QObject *parent) void AppTheme::reload() { const QString theme = m_currentDirectory + "/theme", path = theme + "/colors.toml"; QMap values; - const auto written = readThemeColors(path); - for (auto it = written.begin(); it != written.end(); ++it) - values[it.key()] = QColor(it.value()); + for (const auto &[key, value] : readThemeColors(path)) + values[key] = QColor(value); const QColor bg = values.value("background", QColor("#1a1b26")); const QColor fg = values.value("foreground", QColor("#c0caf5")); const QColor accent = values.value("accent", QColor("#7aa2f7")); diff --git a/src/apptheme.h b/src/apptheme.h index be78aff..5b4e517 100644 --- a/src/apptheme.h +++ b/src/apptheme.h @@ -1,13 +1,14 @@ #pragma once #include -#include +#include #include +#include #include #include -// The hex colors in an Omarchy colors.toml by key, as written, quoted or bare, -// with or without a trailing note. -QMap readThemeColors(const QString &path); +// The hex colors in an Omarchy colors.toml in file order, as written, quoted +// or bare, with or without a trailing note. +QList> readThemeColors(const QString &path); class AppTheme : public QObject { Q_OBJECT diff --git a/src/deck.cpp b/src/deck.cpp index ff8f29e..79daf5c 100644 --- a/src/deck.cpp +++ b/src/deck.cpp @@ -554,9 +554,8 @@ QVariantMap Deck::palette() const { {"background", "#1a1b26"}, {"foreground", "#c0caf5"}, {"accent", "#7aa2f7"}, {"green", "#9ece6a"}, {"red", "#f7768e"}, {"yellow", "#e0af68"}, {"magenta", "#bb9af7"}, {"cyan", "#7dcfff"}, {"dark_foreground", "#787c99"}}; - const auto installed = readThemeColors(m_themes.value(themeName())); - for (auto it = installed.begin(); it != installed.end(); ++it) - colors[it.key()] = it.value(); + for (const auto &[key, value] : readThemeColors(m_themes.value(themeName()))) + colors[key] = value; for (const auto &key : colors.keys()) { QString v = scalar(m_parsed.header, "color_" + key); if (QColor(v).isValid()) @@ -575,9 +574,8 @@ void Deck::chooseTheme(const QString &name) { // Remove old palette before reading the newly selected installed theme. header.remove( QRegularExpression("^color_[a-z_]+:[^\\n]*\\n", QRegularExpression::MultilineOption)); - const auto installed = readThemeColors(m_themes.value(name)); - for (auto it = installed.begin(); it != installed.end(); ++it) - header = setScalar(header, "color_" + it.key(), it.value()); + for (const auto &[key, value] : readThemeColors(m_themes.value(name))) + header = setScalar(header, "color_" + key, value); replaceHeader(header); } QStringList Deck::mediaPositions() const { return ::mediaPositions(); } From aefe90bd67a0d8d258a7b704f06f7ab1ae274d9c Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:21:40 +0100 Subject: [PATCH 09/19] Exit after saving from the command line hype deck.md --theme tokyo-night --save saved and then opened the editor, which offscreen meant waiting forever, so bin/prepare-trials never got past its first deck. --save now writes, reports and exits like an export, without needing a display. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex --- src/main.cpp | 17 ++++++++++------- tests/test_cli.py | 6 ++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 5d2690e..bdaf262 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -45,7 +45,7 @@ int main(int argc, char **argv) { bool windowless = command; for (int i = 1; i < argc; ++i) { const QByteArray argument(argv[i]); - for (const char *option : {"--pdf", "--pptx", "--render", "--help", "--version"}) + for (const char *option : {"--pdf", "--pptx", "--render", "--save", "--help", "--version"}) windowless = windowless || argument.startsWith(option); windowless = windowless || argument == "-h" || argument == "-v"; } @@ -66,7 +66,7 @@ int main(int argc, char **argv) { args.addOption({"pptx", "Export rendered PowerPoint and exit", "file"}); args.addOption({"render", "Render slide PNGs and manifest and exit", "directory"}); args.addOption({"theme", "Apply installed theme", "name"}); - args.addOption({"save", "Save changes (for theme snapshots)"}); + args.addOption({"save", "Save changes, such as --theme, and exit"}); args.addOption({"slide", "Select a slide (1-based)", "number"}); args.addOption({"markdown", "Start in full-document Markdown mode"}); args.addOption({"overview", "Start in slide overview mode"}); @@ -96,9 +96,9 @@ int main(int argc, char **argv) { }); } auto positional = args.positionalArguments(); - const bool exporting = args.isSet("pdf") || args.isSet("pptx") || args.isSet("render"); + const bool exporting = args.isSet("pdf") || args.isSet("pptx") || args.isSet("render") || args.isSet("save"); if (exporting && positional.isEmpty() && !exportWorker) { - fprintf(stderr, "Name a Markdown presentation to export.\n"); + fprintf(stderr, "Name a Markdown presentation to %s.\n", args.isSet("save") ? "save" : "export"); return 1; } if (!positional.isEmpty() && !deck.loadPath(positional[0], !exporting)) { @@ -109,11 +109,14 @@ int main(int argc, char **argv) { deck.reopenLastPresentation(); if (args.isSet("theme")) deck.chooseTheme(args.value("theme")); - if (args.isSet("save")) - deck.save(); + bool success = true, headless = false; + // Like an export, a save from the command line writes the file and exits. + if (args.isSet("save")) { + headless = true; + success = deck.savePath(deck.path()); + } if (args.isSet("slide")) deck.select(args.value("slide").toInt() - 1); - bool success = true, headless = false; for (const QString &option : {QString("pdf"), QString("pptx"), QString("render")}) if (args.isSet(option)) { headless = true; diff --git a/tests/test_cli.py b/tests/test_cli.py index 95dc815..437c4c8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -53,6 +53,12 @@ def test_new_starts_a_themed_presentation_without_overwriting(self): self.assertIn('already exists', self.hype('new', self.deck, code=1).stderr) self.assertIn('paper', self.hype('new', self.root / 'other.md', '--theme', 'nope', code=1).stderr) + def test_save_applies_a_theme_and_exits(self): + self.write('# One\n') + self.hype(self.deck, '--theme', 'paper', '--save') + self.assertIn('color_background: "#ffffff"', self.deck.read_text()) + self.assertIn('to save', self.hype('--theme', 'paper', '--save', code=1).stderr) + def test_check_reports_every_problem_with_slide_and_line(self): self.write(DECK + '\n---\n\n![](missing.png)\n\n---\n\n![bogus=1](gone.png)\n\n---\n') report = json.loads(self.hype('check', self.deck, '--json', code=1).stdout) From 53f0542816b73fadfccc246bdb45df5a2f0bc1df Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:22:57 +0100 Subject: [PATCH 10/19] Release the window when the file chooser's portal goes away While the chooser is open, Hype swallows input to its own windows and waits for the portal's response. A portal that exited after accepting the request never sends one, which left the editor locked with no way to close it. Watching the portal's name ends the wait with an error. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex --- src/filedialog.cpp | 10 ++++++++++ tests/tests.cpp | 11 ++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/filedialog.cpp b/src/filedialog.cpp index fbc5fc7..7e38e7d 100644 --- a/src/filedialog.cpp +++ b/src/filedialog.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -65,6 +66,15 @@ QString FileDialog::choose(bool save, const QString &location, const QString &la bus.asyncCall(QDBusMessage::createMethodCall(service, dialog.m_request, requestInterface, "Close")); dialog.disconnectRequest(); }); + // A portal that exits before answering never sends its response, and the + // event filter below would otherwise leave the windows locked for good. + QDBusServiceWatcher portal(service, bus, QDBusServiceWatcher::WatchForUnregistration); + connect(&portal, &QDBusServiceWatcher::serviceUnregistered, &dialog, [&] { + if (dialog.m_done) return; + dialog.m_error = "The desktop file chooser closed without answering."; + dialog.m_done = true; + dialog.m_loop.quit(); + }); auto call = QDBusMessage::createMethodCall(service, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser", save ? "SaveFile" : "OpenFile"); call.setArguments({QString(), save ? QString("Save File") : QString("Open File"), options}); diff --git a/tests/tests.cpp b/tests/tests.cpp index 15ece15..b1b9aaf 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -53,6 +53,7 @@ class TestFilePortal : public QDBusVirtualObject { QVariantMap options; uint result = 0; bool respondBeforeReply = false; + bool silent = false; QString introspect(const QString &) const override { return {}; } bool handleMessage(const QDBusMessage &message, const QDBusConnection &bus) override { if (message.interface() != "org.freedesktop.portal.FileChooser") return false; @@ -70,7 +71,9 @@ class TestFilePortal : public QDBusVirtualObject { signal.setArguments({result, QVariantMap{{"uris", QStringList{selected}}}}); bus.send(signal); }; - if (respondBeforeReply) { + if (silent) { + reply(); + } else if (respondBeforeReply) { response(); QTimer::singleShot(10, this, reply); } else { @@ -169,6 +172,12 @@ static void write(const QString &path, const QString &content) { portal.result = 2; QVERIFY(FileDialog::choose(false, "/tmp", "Media", {"*.png"}, &error).isEmpty()); QVERIFY(!error.isEmpty()); + + // A portal that exits without answering releases the app with an error. + portal.silent = true; + QTimer::singleShot(100, [&] { bus.unregisterService("org.freedesktop.portal.Desktop"); }); + QVERIFY(FileDialog::choose(false, "/tmp", "Media", {"*.png"}, &error).isEmpty()); + QCOMPARE(error, "The desktop file chooser closed without answering."); } void speakerNotesIgnoreDirectivesAndCode() { Deck deck; From d888f2e8ad5015055b421f48a0dcbf8fd70deca3 Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:23:06 +0100 Subject: [PATCH 11/19] Take the corrected comment in the shared bin/test ctest -R matches test names, not Qt test functions; -V is what shows those. Co-Authored-By: Claude Opus 5.5 --- bin/test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/test b/bin/test index 0fc642c..080fc52 100755 --- a/bin/test +++ b/bin/test @@ -1,7 +1,7 @@ #!/bin/bash set -euo pipefail -# Arguments go to ctest, so `bin/test -R theme` runs only the matching tests. +# Arguments go to ctest, so `bin/test -V` shows every Qt test function as it runs. root="$(cd "$(dirname "$0")/.." && pwd)" "$root/bin/build" QT_QPA_PLATFORM=offscreen ctest --test-dir "$root/build" --output-on-failure "$@" From c2a0726fd41983c3d924eec9c126a6c79fc2a8dd Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:24:50 +0100 Subject: [PATCH 12/19] Keep hype render from overwriting its presentation or a half-written manifest render --slide wrote its PNG to any -o, the presentation included, and rendering a whole deck wrote slides.json in place without checking the write, so a full disk left a truncated manifest behind a successful exit. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex --- src/cli.cpp | 3 +++ src/deck.cpp | 8 +++++--- tests/test_cli.py | 10 ++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/cli.cpp b/src/cli.cpp index 0c1588a..6648063 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -249,6 +249,9 @@ int render(const QStringList &arguments) { output = name; else if (QFileInfo(output).isDir()) output = QDir(output).filePath(name); + const QString target = QFileInfo(output).canonicalFilePath(); + if (!target.isEmpty() && target == QFileInfo(deck.path()).canonicalFilePath()) + return fail("Choose an output other than the presentation itself."); QDir().mkpath(QFileInfo(output).absolutePath()); // A slide with problems still renders, with the problems on a banner, so it can be looked at. const auto problems = slideProblemsAt(deck, parsed, number - 1); diff --git a/src/deck.cpp b/src/deck.cpp index ce55a64..d6bb773 100644 --- a/src/deck.cpp +++ b/src/deck.cpp @@ -1622,10 +1622,12 @@ bool Deck::renderImages(const QString &directory, int width, bool powerPoint) { } slides.append(entry); } - QFile manifest(directory + "/slides.json"); - if (!manifest.open(QIODevice::WriteOnly)) + QSaveFile manifest(directory + "/slides.json"); + const QByteArray json = QJsonDocument(QJsonObject{{"title", title()}, {"slides", slides}}).toJson(); + if (!manifest.open(QIODevice::WriteOnly) || manifest.write(json) != json.size() || !manifest.commit()) { + setStatus("Could not save slides.json: " + manifest.errorString()); return false; - manifest.write(QJsonDocument(QJsonObject{{"title", title()}, {"slides", slides}}).toJson()); + } return true; } bool Deck::exportPptx(const QString &path) { diff --git a/tests/test_cli.py b/tests/test_cli.py index 95dc815..0f9da19 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -53,6 +53,16 @@ def test_new_starts_a_themed_presentation_without_overwriting(self): self.assertIn('already exists', self.hype('new', self.deck, code=1).stderr) self.assertIn('paper', self.hype('new', self.root / 'other.md', '--theme', 'nope', code=1).stderr) + def test_render_never_overwrites_the_presentation_and_reports_its_manifest(self): + self.write(DECK) + link = self.root / 'link.md' + link.symlink_to(self.deck) + for output in (self.deck, link): + self.assertIn('presentation itself', self.hype('render', self.deck, '--slide', 1, '-o', output, code=1).stderr) + self.assertEqual(self.deck.read_text(), DECK) + (self.root / 'slides/slides.json').mkdir(parents=True) + self.assertIn('slides.json', self.hype('render', self.deck, '-o', self.root / 'slides', code=1).stderr) + def test_check_reports_every_problem_with_slide_and_line(self): self.write(DECK + '\n---\n\n![](missing.png)\n\n---\n\n![bogus=1](gone.png)\n\n---\n') report = json.loads(self.hype('check', self.deck, '--json', code=1).stdout) From 88a939e49e5363d2c41c68aaf68193c09ea41b51 Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:25:52 +0100 Subject: [PATCH 13/19] Quit with Ctrl+Q Hype had no quit key. Ctrl+Q closes the window the way its close button does, so unsaved work still asks first; a bare Q would type a letter in the editors. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex --- README.md | 1 + src/Main.qml | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 555ecec..3930df8 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ Slide navigation and selection shortcuts apply when the sidebar or preview has f | --- | --- | | Ctrl+N / Ctrl+O | New presentation / open file | | Ctrl+S / Ctrl+Shift+S | Save / save as | +| Ctrl+Q | Quit | | Ctrl+E / Ctrl+Shift+E | Export as PDF / PowerPoint | | Ctrl+M | Overview on / off | | Ctrl+. | Markdown source on / off | diff --git a/src/Main.qml b/src/Main.qml index 2356749..dc8d316 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -679,6 +679,7 @@ ApplicationWindow { Shortcut { enabled: !win.popupOpen && !deck.compressingImage; sequences: [StandardKey.Open]; onActivated: deck.openDialog() } Shortcut { enabled: !win.popupOpen && !deck.compressingImage; sequences: [StandardKey.Save]; onActivated: deck.save() } Shortcut { enabled: !win.popupOpen && !deck.compressingImage; sequences: [StandardKey.SaveAs]; onActivated: deck.saveAs() } + Shortcut { enabled: !win.popupOpen && !deck.compressingImage; sequences: [StandardKey.Quit]; context: Qt.ApplicationShortcut; onActivated: win.close() } Shortcut { sequence: "Ctrl+E"; enabled: !win.popupOpen && !deck.compressingImage && !win.presenting && !deck.exporting; onActivated: deck.exportDialog("pdf") } Shortcut { sequence: "Ctrl+Shift+E"; enabled: !win.popupOpen && !deck.compressingImage && !win.presenting && !deck.exporting; onActivated: deck.exportDialog("pptx") } Shortcut { sequences: ["?", "Shift+?"]; enabled: !win.popupOpen && !deck.compressingImage && !win.presenting && !slideEditor.activeFocus && !sourceEditor.activeFocus; onActivated: shortcutsOverlay.open() } @@ -1120,7 +1121,7 @@ ApplicationWindow { background: Rectangle { color: Qt.alpha(win.ui.panel, 0.92); border.color: win.ui.windowBorder; border.width: 2; radius: win.rounding } readonly property var groups: [ { title: "Presentation", keys: [ - ["Ctrl+N", "New presentation"], ["Ctrl+O", "Open"], ["Ctrl+S", "Save"], ["Ctrl+Shift+S", "Save as"], + ["Ctrl+N", "New presentation"], ["Ctrl+O", "Open"], ["Ctrl+S", "Save"], ["Ctrl+Shift+S", "Save as"], ["Ctrl+Q", "Quit"], ["Ctrl+E", "Export as PDF"], ["Ctrl+Shift+E", "Export as PowerPoint"], ["Ctrl+Space / F5", "Present"], ["Shift+F5", "Present from first slide"], ["Esc", "Stop presenting"], ["Space", "Play or pause video while presenting"] ] }, { title: "View", keys: [ From fb2ad2affe1d70c5ca473f15cfce59c388aefd58 Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:29:18 +0100 Subject: [PATCH 14/19] Write a rendered slide beside its target and rename it over A hard link to the presentation passed the path check and was overwritten in place. Renaming replaces the link instead, and a failed render no longer leaves a half-written PNG. Co-Authored-By: Claude Opus 5.5 --- src/cli.cpp | 5 ++++- tests/test_cli.py | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/cli.cpp b/src/cli.cpp index 6648063..e9f3a46 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -257,7 +257,10 @@ int render(const QStringList &arguments) { const auto problems = slideProblemsAt(deck, parsed, number - 1); QString warning; const QImage image = paint(deck, number - 1, width, &warning); - if (!image.save(output, "PNG")) + // Written beside and renamed over, so an existing file, or a hard link to the + // presentation, is replaced rather than overwritten in place. + QSaveFile file(output); + if (!file.open(QIODevice::WriteOnly) || !image.save(&file, "PNG") || !file.commit()) return fail("Could not save " + output); int errors = 0; QJsonArray list; diff --git a/tests/test_cli.py b/tests/test_cli.py index 0f9da19..4c43ac9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -60,6 +60,11 @@ def test_render_never_overwrites_the_presentation_and_reports_its_manifest(self) for output in (self.deck, link): self.assertIn('presentation itself', self.hype('render', self.deck, '--slide', 1, '-o', output, code=1).stderr) self.assertEqual(self.deck.read_text(), DECK) + alias = self.root / 'alias.md' + os.link(self.deck, alias) + self.hype('render', self.deck, '--slide', 1, '-o', alias) + self.assertEqual(self.deck.read_text(), DECK) + self.png_size(alias) (self.root / 'slides/slides.json').mkdir(parents=True) self.assertIn('slides.json', self.hype('render', self.deck, '-o', self.root / 'slides', code=1).stderr) From 772af76486241eea72c275147f13cd84dcde1c09 Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:31:19 +0100 Subject: [PATCH 15/19] Leave packaging to omarchy-pkgs and take its dependencies in CI The package lives in omarchy-pkgs, so CI now reads the dependency list from there, and this change keeps the in-repo PKGBUILD and bin/install as they were apart from what building with CMake needs. Each test sets its own offscreen platform so a bare ctest works too, and a test whose dependency is missing fails with what to install rather than vanishing from the run. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 9 ++++++--- CMakeLists.txt | 11 ++++++++--- bin/install | 9 ++++----- pkgbuild/PKGBUILD | 3 +-- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e56f4a0..d09dcca 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,16 +21,19 @@ jobs: - uses: actions/checkout@v4 - name: Install the package's dependencies - # The PKGBUILD is the one list of what the app needs, so CI reads it rather than keeping a copy. + # The package lives in omarchy-pkgs, so its PKGBUILD is the one list of what the app needs and CI reads it rather than keeping a copy. run: | - source pkgbuild/PKGBUILD + curl -fsSL "https://raw.githubusercontent.com/omacom/omarchy-pkgs/master/pkgbuilds/${{ github.event.repository.name }}/PKGBUILD" -o "$RUNNER_TEMP/PKGBUILD" + source "$RUNNER_TEMP/PKGBUILD" deps=("${depends[@]}" "${makedepends[@]}" "${checkdepends[@]}") pacman -S --noconfirm --needed base-devel cmake dbus "${deps[@]%%[<>=]*}" - name: Set up a user and a locale like a desktop's - # Root can read any file, so tests about unreadable ones need an ordinary user, and the container generates no locale. + # Root can read any file, so tests about unreadable ones need an ordinary user, with a second group for tests that change a file's group; and the container generates no locale. run: | useradd -m tester + groupadd other + usermod -aG other tester chown -R tester . sed -i 's/^#en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen locale-gen diff --git a/CMakeLists.txt b/CMakeLists.txt index c4c1c5d..e33601f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,7 +47,7 @@ set_target_properties(hype PROPERTIES INTERPROCEDURAL_OPTIMIZATION ON) enable_testing() # The tests read exports back with Qt Pdf, which ships in qt6-webengine, so -# building the app alone doesn't need it. +# building the app alone doesn't need it; testing without it fails, not skips. find_package(Qt6 6.9 COMPONENTS Pdf) if(TARGET Qt6::Pdf) # The window-level tests load the same QML as the app, so they embed it too. @@ -55,11 +55,13 @@ if(TARGET Qt6::Pdf) target_link_libraries(hype_tests PRIVATE hype_core Qt6::Widgets Qt6::Pdf Qt6::Test) set_target_properties(hype_tests PROPERTIES INTERPROCEDURAL_OPTIMIZATION ON) add_test(NAME hype_tests COMMAND hype_tests) - # Hype draws its own controls, so the tests keep Qt's platform theme and style out of it. - set_tests_properties(hype_tests PROPERTIES ENVIRONMENT "QT_QPA_PLATFORMTHEME=generic;QT_STYLE_OVERRIDE=Fusion") else() message(WARNING "Qt Pdf was not found, so the tests can't be built; install qt6-webengine.") + add_test(NAME hype_tests COMMAND sh -c "echo 'hype_tests needs Qt Pdf, from qt6-webengine' >&2; exit 1") endif() +# Hype draws its own controls, so the tests keep Qt's platform theme and style out of it. +set_tests_properties(hype_tests PROPERTIES ENVIRONMENT + "QT_QPA_PLATFORM=offscreen;QT_QPA_PLATFORMTHEME=generic;QT_STYLE_OVERRIDE=Fusion") # The commands, exports and shutdown, driven through build/hype as an agent would. # test_trials covers the trial import tools, which need their own Python packages. @@ -68,4 +70,7 @@ if(Python3_FOUND) add_test(NAME hype_commands COMMAND ${Python3_EXECUTABLE} -m unittest test_cli test_export test_shutdown WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/tests) +else() + add_test(NAME hype_commands COMMAND sh -c "echo 'hype_commands needs Python 3' >&2; exit 1") endif() +set_tests_properties(hype_commands PROPERTIES ENVIRONMENT QT_QPA_PLATFORM=offscreen) diff --git a/bin/install b/bin/install index b982bd9..f805111 100755 --- a/bin/install +++ b/bin/install @@ -1,6 +1,5 @@ -#!/bin/bash -set -euo pipefail - -root="$(cd "$(dirname "$0")/.." && pwd)" -cd "$root/pkgbuild" +#!/bin/sh +set -eu +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$ROOT/pkgbuild" makepkg -fsi "$@" diff --git a/pkgbuild/PKGBUILD b/pkgbuild/PKGBUILD index da100a5..6635a60 100644 --- a/pkgbuild/PKGBUILD +++ b/pkgbuild/PKGBUILD @@ -4,9 +4,8 @@ pkgrel=1 pkgdesc='Simple Markdown presentations with a visual slide editor' arch=('x86_64' 'aarch64') license=('MIT') -depends=('qt6-base>=6.9' 'qt6-declarative>=6.9' 'qt6-multimedia>=6.9' 'qt6-imageformats' 'qt6-svg' 'qt6-wayland' 'ffmpeg' 'source-highlight' 'zlib' 'libwebp' 'xdg-desktop-portal') +depends=('qt6-base>=6.9' 'qt6-declarative>=6.9' 'qt6-multimedia>=6.9' 'qt6-imageformats' 'qt6-svg' 'ffmpeg' 'source-highlight' 'zlib' 'libwebp' 'xdg-desktop-portal') makedepends=('cmake' 'gcc' 'make' 'pkgconf') -checkdepends=('qt6-webengine' 'python' 'ttf-jetbrains-mono' 'noto-fonts') source=() sha256sums=() build() { From 91b81bc310ecf5cbf203a1d6413e1ea273b5ee6d Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:31:35 +0100 Subject: [PATCH 16/19] Test that a save from the command line that can't be made fails Co-Authored-By: Claude Opus 5.5 --- tests/test_cli.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 437c4c8..927c207 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -58,6 +58,10 @@ def test_save_applies_a_theme_and_exits(self): self.hype(self.deck, '--theme', 'paper', '--save') self.assertIn('color_background: "#ffffff"', self.deck.read_text()) self.assertIn('to save', self.hype('--theme', 'paper', '--save', code=1).stderr) + unfinished = '# One\n\n```ruby\nputs 1\n' + self.write(unfinished) + self.assertTrue(self.hype(self.deck, '--theme', 'paper', '--save', code=1).stderr.strip()) + self.assertEqual(self.deck.read_text(), unfinished) def test_check_reports_every_problem_with_slide_and_line(self): self.write(DECK + '\n---\n\n![](missing.png)\n\n---\n\n![bogus=1](gone.png)\n\n---\n') From 9b61314458413d41ddf97af5c7902fc00f9cccc0 Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:36:32 +0100 Subject: [PATCH 17/19] Also release the window when the portal is replaced xdg-desktop-portal --replace hands the name straight to the new process, which never unregisters it, and the new process knows nothing of the old request. Any change of owner after the first one now ends the wait. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex --- src/filedialog.cpp | 12 +++++++----- tests/tests.cpp | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/filedialog.cpp b/src/filedialog.cpp index 7e38e7d..eaa6f8d 100644 --- a/src/filedialog.cpp +++ b/src/filedialog.cpp @@ -66,11 +66,13 @@ QString FileDialog::choose(bool save, const QString &location, const QString &la bus.asyncCall(QDBusMessage::createMethodCall(service, dialog.m_request, requestInterface, "Close")); dialog.disconnectRequest(); }); - // A portal that exits before answering never sends its response, and the - // event filter below would otherwise leave the windows locked for good. - QDBusServiceWatcher portal(service, bus, QDBusServiceWatcher::WatchForUnregistration); - connect(&portal, &QDBusServiceWatcher::serviceUnregistered, &dialog, [&] { - if (dialog.m_done) return; + // A portal that exits or is replaced before answering never sends its response, + // and the event filter below would otherwise leave the windows locked for good. + // Only a first owner appearing, as when the call activates the portal, is harmless. + QDBusServiceWatcher portal(service, bus, QDBusServiceWatcher::WatchForOwnerChange); + connect(&portal, &QDBusServiceWatcher::serviceOwnerChanged, &dialog, + [&](const QString &, const QString &oldOwner, const QString &) { + if (dialog.m_done || oldOwner.isEmpty()) return; dialog.m_error = "The desktop file chooser closed without answering."; dialog.m_done = true; dialog.m_loop.quit(); diff --git a/tests/tests.cpp b/tests/tests.cpp index b1b9aaf..0769de9 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -178,6 +179,19 @@ static void write(const QString &path, const QString &content) { QTimer::singleShot(100, [&] { bus.unregisterService("org.freedesktop.portal.Desktop"); }); QVERIFY(FileDialog::choose(false, "/tmp", "Media", {"*.png"}, &error).isEmpty()); QCOMPARE(error, "The desktop file chooser closed without answering."); + + // So does one replaced by another process, which takes the name over directly. + QVERIFY(bus.interface()->registerService("org.freedesktop.portal.Desktop", + QDBusConnectionInterface::DontQueueService, QDBusConnectionInterface::AllowReplacement).value() + == QDBusConnectionInterface::ServiceRegistered); + auto replacement = QDBusConnection::connectToBus(QDBusConnection::SessionBus, "test-file-portal-replacement"); + const auto disconnect = qScopeGuard([] { QDBusConnection::disconnectFromBus("test-file-portal-replacement"); }); + QTimer::singleShot(100, [&] { + replacement.interface()->registerService("org.freedesktop.portal.Desktop", + QDBusConnectionInterface::ReplaceExistingService, QDBusConnectionInterface::AllowReplacement); + }); + QVERIFY(FileDialog::choose(false, "/tmp", "Media", {"*.png"}, &error).isEmpty()); + QCOMPARE(error, "The desktop file chooser closed without answering."); } void speakerNotesIgnoreDirectivesAndCode() { Deck deck; From bdc417e3db24c4834457c314046c73c497e3c10d Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:52:43 +0100 Subject: [PATCH 18/19] Leave packaging to omarchy-pkgs The Arch package is built in omarchy-pkgs from the release tarball, with bin/build and the desktop entry and icon in pkgbuild/. The local PKGBUILD and the bin/install that ran makepkg on it went unused, so they go, with the ignores for their output, and the README points at omarchy-pkgs for dependencies. bin/install-dev stays: it registers a launcher for this checkout and needs only the icon. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 --- README.md | 2 +- bin/install | 5 ----- pkgbuild/PKGBUILD | 21 --------------------- 4 files changed, 1 insertion(+), 30 deletions(-) delete mode 100755 bin/install delete mode 100644 pkgbuild/PKGBUILD diff --git a/.gitignore b/.gitignore index 193d5b5..8379206 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,4 @@ /build/ /trials/ *.user -/pkgbuild/pkg/ -/pkgbuild/src/ -/pkgbuild/*.pkg.tar.* __pycache__/ diff --git a/README.md b/README.md index cb5fb77..8cc1a93 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ The mouse wheel over the sidebar selects the previous or next slide. Home/End ju ## Run from source -To build Hype yourself, install a C++17 compiler, CMake, Qt 6.9 or newer, FFmpeg, and GNU source-highlight; see [the package definition](pkgbuild/PKGBUILD) for dependencies. Then: +To build Hype yourself, install a C++17 compiler, CMake, Qt 6.9 or newer, FFmpeg, and GNU source-highlight; see [the package definition in omarchy-pkgs](https://github.com/omacom/omarchy-pkgs/blob/master/pkgbuilds/hype/PKGBUILD) for dependencies. Then: ```sh ./bin/build diff --git a/bin/install b/bin/install deleted file mode 100755 index f805111..0000000 --- a/bin/install +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -set -eu -ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" -cd "$ROOT/pkgbuild" -makepkg -fsi "$@" diff --git a/pkgbuild/PKGBUILD b/pkgbuild/PKGBUILD deleted file mode 100644 index 6635a60..0000000 --- a/pkgbuild/PKGBUILD +++ /dev/null @@ -1,21 +0,0 @@ -pkgname=hype -pkgver=0.6.0 -pkgrel=1 -pkgdesc='Simple Markdown presentations with a visual slide editor' -arch=('x86_64' 'aarch64') -license=('MIT') -depends=('qt6-base>=6.9' 'qt6-declarative>=6.9' 'qt6-multimedia>=6.9' 'qt6-imageformats' 'qt6-svg' 'ffmpeg' 'source-highlight' 'zlib' 'libwebp' 'xdg-desktop-portal') -makedepends=('cmake' 'gcc' 'make' 'pkgconf') -source=() -sha256sums=() -build() { - cd "$startdir/.." - ./bin/build -} -package() { - cd "$startdir/.." - install -Dm755 build/hype "$pkgdir/usr/bin/hype" - install -Dm644 pkgbuild/hype.desktop "$pkgdir/usr/share/applications/hype.desktop" - install -Dm644 pkgbuild/hype.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/hype.svg" - install -Dm644 LICENSE "$pkgdir/usr/share/licenses/hype/LICENSE" -} From 3f964f27c0fcafe12aa1ebaa725f66c9ac0b5b81 Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:54:01 +0100 Subject: [PATCH 19/19] Proof only: read dependencies from omarchy-pkgs#2573 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d09dcca..4933c40 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: - name: Install the package's dependencies # The package lives in omarchy-pkgs, so its PKGBUILD is the one list of what the app needs and CI reads it rather than keeping a copy. run: | - curl -fsSL "https://raw.githubusercontent.com/omacom/omarchy-pkgs/master/pkgbuilds/${{ github.event.repository.name }}/PKGBUILD" -o "$RUNNER_TEMP/PKGBUILD" + curl -fsSL "https://raw.githubusercontent.com/omacom/omarchy-pkgs/8be89db82f2cdba1d6538fadf6de81929e7c12f4/pkgbuilds/${{ github.event.repository.name }}/PKGBUILD" -o "$RUNNER_TEMP/PKGBUILD" source "$RUNNER_TEMP/PKGBUILD" deps=("${depends[@]}" "${makedepends[@]}" "${checkdepends[@]}") pacman -S --noconfirm --needed base-devel cmake dbus "${deps[@]%%[<>=]*}"