diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4933c40 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,45 @@ +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 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/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[@]%%[<>=]*}" + + - 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, 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 + + - 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/.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/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..e33601f --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,76 @@ +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, 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) +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; 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. + 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) +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. +find_package(Python3 COMPONENTS Interpreter) +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/README.md b/README.md index 555ecec..d63f8c0 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 | @@ -244,22 +245,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 in omarchy-pkgs](https://github.com/omacom/omarchy-pkgs/blob/master/pkgbuilds/hype/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 C++ 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 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/bin/test b/bin/test index d6be55e..080fc52 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 -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 "$@" 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 deleted file mode 100644 index 58f6058..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=('gcc' 'make') -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" -} 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: [ diff --git a/src/apptheme.cpp b/src/apptheme.cpp index 8d61bc6..4222510 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"); } +QList> readThemeColors(const QString &path) { + QList> 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.append({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,8 @@ 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)); - } - } + 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 c5080eb..5b4e517 100644 --- a/src/apptheme.h +++ b/src/apptheme.h @@ -1,9 +1,15 @@ #pragma once #include +#include #include +#include #include #include +// 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 Q_PROPERTY(QVariantMap colors READ colors NOTIFY changed) diff --git a/src/cli.cpp b/src/cli.cpp index 0c1588a..e9f3a46 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -249,12 +249,18 @@ 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); 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/src/deck.cpp b/src/deck.cpp index ce55a64..4a78fb8 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,8 @@ 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); - } - } + 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()) @@ -581,16 +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)); - 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)); - } - } + for (const auto &[key, value] : readThemeColors(m_themes.value(name))) + header = setScalar(header, "color_" + key, value); replaceHeader(header); } QStringList Deck::mediaPositions() const { return ::mediaPositions(); } @@ -1622,10 +1607,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/src/filedialog.cpp b/src/filedialog.cpp index fbc5fc7..eaa6f8d 100644 --- a/src/filedialog.cpp +++ b/src/filedialog.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -65,6 +66,17 @@ 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 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(); + }); 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/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..c604f77 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -53,6 +53,31 @@ 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) + 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_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) + 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) + 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) diff --git a/tests/tests.cpp b/tests/tests.cpp index 15ece15..c8f6f05 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -53,6 +54,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 +72,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 +173,25 @@ 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."); + + // 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; @@ -204,6 +227,28 @@ static void write(const QString &path, const QString &content) { qPrintable(value)); } } + void slidesReadThemeColorFormats() { + QTemporaryDir omarchy; + const bool wasSet = qEnvironmentVariableIsSet("OMARCHY_PATH"); + const QByteArray previous = qgetenv("OMARCHY_PATH"); + 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"), + "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"; @@ -646,7 +691,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 +722,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 +783,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 +872,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 +1663,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 +1743,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 +1814,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 +1934,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 +2030,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 +2225,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 +3383,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 +3786,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