diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..39f0ef9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,73 @@ +# Changelog + +Every release of Effector, newest first. The version lives in one place, the +`project()` line of `CMakeLists.txt`, and each release is a `vX.Y.Z` tag on the +commit that made it. The interface shows this file under Changelog in its menu. + +## [0.5.0] - 2026-09-26 + +### Added +- A version number: `effector --version` prints it, and About Effector in the + menu shows it. +- This changelog, readable from the interface under Changelog in the menu. +- Open Recent in the project menu, listing the projects opened or saved here. +- An optional shadow or glow under the image overlay, with an offset, size, + spread, colour and opacity. +- Reordering the chain by dragging a row. + +### Fixed +- The params' scrollbar no longer covers the spin buttons. + +## [0.4.0] - 2026-09-23 + +### Added +- Eight chaotic effects: slit shift, channel split, pixel sort, corruption, + shatter, warp, bit crush and feedback. None needs sources, so any of them can + lead a chain. +- The sidebar split into Chain and Setup tabs. +- A rotation for the text effect's placement. +- Text stages labelled in the chain list with their words. + +### Changed +- The preview stays centred however long the status line gets. +- The status line stays short, with the full chain and setup in its tooltip. +- The chain/params split fits the chain's rows until it is dragged; a + double-click on the handle fits it again. +- The mouse wheel scrolls past spin buttons that do not have the focus. + +## [0.3.0] - 2026-09-19 + +### Added +- Named styles: several text stages can share one font and colour, and editing + the style moves every stage that follows it. +- Switching a chain stage off without taking it apart, in the interface and + with `--disable NAME` on the command line. + +### Changed +- The interface opens on an empty chain, and shows the sources only when the + chain uses them. + +## [0.2.0] - 2026-09-18 + +### Added +- Effect chains: stages cascade, each handed the previous one's output, built in + the sidebar or with repeated `-e`. +- Blur, film grain, black and white, image overlay, text and tiles effects. +- Projects saved and opened as plain JSON. +- Standard web and print canvas formats (`--format`, `--dpi`). +- Effects declare whether they need source images, so a chain led by blur + renders with none loaded. + +### Changed +- The chain comes first in the sidebar. + +### Fixed +- The canvas size is checked before it is narrowed to an int. +- Projects are written and read in the C locale, and keep the seed whole. + +## [0.1.0] - 2026-09-18 + +### Added +- The C++20 port of the Python prototype: a GTK 4 interface and a headless + command line over pluggable effects, starting with scanlines. +- Source images load in the background, with a progress window and Cancel. diff --git a/CLAUDE.md b/CLAUDE.md index 7a5b652..2d96ad5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,20 @@ Headless render (no display needed, the fastest end-to-end smoke test): build/effector -o /tmp/out.png -s 800x600 --strip-height 6 --seed 1 a.jpg b.png ``` +### Versions and the changelog + +The version is the `project()` line of `CMakeLists.txt`, and nowhere else. +CMake writes it, together with the text of `CHANGELOG.md`, into a generated +`core/version.hpp` (`kVersion`, `kChangelog`, from `src/core/version.hpp.in`) +in the build tree. `--version` prints it, and the header-bar menu shows it +under About Effector and the changelog under Changelog. The `version` core test +fails unless the newest `## [X.Y.Z]` entry in `CHANGELOG.md` is the current +version, so a bump and its changelog entry go in together. Each release is an +annotated `vX.Y.Z` tag on the commit that made it. When you add a user-visible +change, add a line to the newest changelog entry, or start a new entry if that +one is already tagged. The Changelog window reads only headings, `- ` bullets +(wrapped onto indented lines) and `` `code` ``, so keep the file to those. + ### Running the GUI from this shell GTK picks Wayland from an inherited `WAYLAND_DISPLAY` and will not draw into an diff --git a/CMakeLists.txt b/CMakeLists.txt index d1c9210..f6b371b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(effector VERSION 0.1.0 LANGUAGES CXX) +project(effector VERSION 0.5.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -21,6 +21,14 @@ pkg_check_modules(PANGOCAIRO REQUIRED IMPORTED_TARGET pangocairo) find_package(Threads REQUIRED) +# --- version: the one number, and the changelog that goes with it ------------ +# The version is the project() line above; each release is a vX.Y.Z tag on the +# commit that set it. CHANGELOG.md is baked in so the interface can show it, +# and editing it re-runs the configure step. +file(READ ${CMAKE_CURRENT_SOURCE_DIR}/CHANGELOG.md EFFECTOR_CHANGELOG) +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS CHANGELOG.md) +configure_file(src/core/version.hpp.in ${CMAKE_CURRENT_BINARY_DIR}/generated/core/version.hpp @ONLY) + # --- core: effects and image handling, no GUI --------------------------------- add_library(effector_core STATIC src/core/canvas.cpp @@ -48,7 +56,7 @@ add_library(effector_core STATIC src/effects/tiles.cpp src/effects/warp.cpp ) -target_include_directories(effector_core PUBLIC src) +target_include_directories(effector_core PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated) # The core is GUI-free: it only needs gdk-pixbuf for decoding and encoding, # so headless rendering works without a display. target_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF PkgConfig::PANGOCAIRO Threads::Threads) diff --git a/README.md b/README.md index 8b22ab4..d33e748 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ installs it to `~/.local/bin`. build/effector # GTK interface build/effector --list-effects build/effector --help +build/effector --version ``` Headless rendering, no display needed: @@ -60,6 +61,17 @@ batch. Small batches finish before the window would be drawn, so it only appears if the decode is still going after 250 ms. On a terminal the headless mode keeps a single `reading n/total` line updated on stderr; redirected output stays clean. +## Versions + +Releases are listed in [CHANGELOG.md](CHANGELOG.md) and tagged `vX.Y.Z` +(`git tag -l 'v*'`). `effector --version` prints the version, and the menu in +the interface has Changelog and About Effector. + +To cut a release: bump the `project()` version in `CMakeLists.txt`, move the +changes into a new `## [X.Y.Z] - date` entry at the top of `CHANGELOG.md` (the +core tests fail until the two agree), commit, and tag it: +`git tag -a vX.Y.Z -m "Effector X.Y.Z"`. + ## Effects ### scanlines diff --git a/src/cli/options.cpp b/src/cli/options.cpp index 24e952d..e5b1e3a 100644 --- a/src/cli/options.cpp +++ b/src/cli/options.cpp @@ -61,6 +61,8 @@ Options parseOptions(int argc, char** argv) { if (arg == "-h" || arg == "--help") { options.showHelp = true; + } else if (arg == "-V" || arg == "--version") { + options.showVersion = true; } else if (arg == "--list-effects") { options.listEffects = true; } else if (arg == "-e" || arg == "--effect") { @@ -184,6 +186,7 @@ std::string usage(const char* programName) { << " --disable NAME keep this effect in the chain but skip it\n" << " --seed N random seed (default: random)\n" << " --list-effects list available effects and exit\n" + << " -V, --version show the version and exit\n" << " -h, --help show this help and exit\n"; out << "\nCanvas formats (--format):\n"; diff --git a/src/cli/options.hpp b/src/cli/options.hpp index 6cb4d2b..6a39c43 100644 --- a/src/cli/options.hpp +++ b/src/cli/options.hpp @@ -27,6 +27,7 @@ struct Options { ParamValues params; // only the keys the user actually passed bool listEffects = false; bool showHelp = false; + bool showVersion = false; }; // Throws EffectError on bad input. Effect flags are generated from the diff --git a/src/core/version.hpp.in b/src/core/version.hpp.in new file mode 100644 index 0000000..de2fb2a --- /dev/null +++ b/src/core/version.hpp.in @@ -0,0 +1,16 @@ +#pragma once + +// Generated by CMake from src/core/version.hpp.in; edit that, not the copy in +// the build tree. The version comes from the project() line of CMakeLists.txt +// and the changelog from CHANGELOG.md, both read at configure time. + +#include + +namespace effector { + +inline constexpr std::string_view kVersion = "@PROJECT_VERSION@"; + +// CHANGELOG.md as it stood when the build was configured, as Markdown. +inline constexpr std::string_view kChangelog = R"changelog(@EFFECTOR_CHANGELOG@)changelog"; + +} // namespace effector diff --git a/src/gui/window.cpp b/src/gui/window.cpp index 60cd42a..18cbe2f 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -13,6 +13,7 @@ #include "core/project.hpp" #include "core/registry.hpp" #include "core/rng.hpp" +#include "core/version.hpp" namespace effector { namespace { @@ -1893,6 +1894,8 @@ Gtk::Widget& EffectorWindow::buildMainMenu() { }); // Never enabled: it only stands in for an empty list. add_action("no-recent")->set_enabled(false); + add_action("show-changelog", sigc::mem_fun(*this, &EffectorWindow::showChangelog)); + add_action("about", sigc::mem_fun(*this, &EffectorWindow::showAbout)); auto menu = Gio::Menu::create(); auto open = Gio::Menu::create(); @@ -1908,6 +1911,10 @@ Gtk::Widget& EffectorWindow::buildMainMenu() { save->append("Save Project", "win.save-project"); save->append("Save Project As…", "win.save-project-as"); menu->append_section(save); + auto help = Gio::Menu::create(); + help->append("Changelog", "win.show-changelog"); + help->append("About Effector", "win.about"); + menu->append_section(help); // The shortcuts hang off the window rather than the application, which the // window does not belong to yet while it is being built. @@ -2071,6 +2078,153 @@ void EffectorWindow::rebuildRecentMenu() { if (recent.empty()) recentMenu_->append("No Recent Projects", "win.no-recent"); } +// ------------------------------------------------------------ changelog --- + +namespace { + +// Lays CHANGELOG.md out in a text buffer. It reads only the little Markdown +// the file uses -- headings, bullets wrapped onto indented lines, and `code` +// -- which is all a changelog needs. +void fillChangelog(const Glib::RefPtr& buffer, std::string_view markdown) { + auto tags = buffer->get_tag_table(); + auto tag = [&](const char* name, auto&& configure) { + auto made = Gtk::TextTag::create(name); + configure(*made); + tags->add(made); + }; + tag("title", [](Gtk::TextTag& t) { + t.property_weight() = Pango::Weight::BOLD; + t.property_scale() = 1.6; + t.property_pixels_below_lines() = 6; + }); + tag("release", [](Gtk::TextTag& t) { + t.property_weight() = Pango::Weight::BOLD; + t.property_scale() = 1.3; + t.property_pixels_above_lines() = 18; + t.property_pixels_below_lines() = 2; + }); + tag("section", [](Gtk::TextTag& t) { + t.property_weight() = Pango::Weight::BOLD; + t.property_pixels_above_lines() = 8; + t.property_pixels_below_lines() = 2; + }); + tag("item", [](Gtk::TextTag& t) { + t.property_left_margin() = 28; + t.property_indent() = -14; + t.property_pixels_below_lines() = 3; + }); + tag("para", [](Gtk::TextTag& t) { t.property_pixels_below_lines() = 6; }); + tag("code", [](Gtk::TextTag& t) { t.property_family() = "monospace"; }); + + // Gather each block -- a heading, a bullet, a paragraph -- whole first, + // since a bullet or a paragraph goes on over several lines. + struct Block { + std::string kind; + std::string text; + }; + std::vector blocks; + bool open = false; // the last block still takes continuation lines + std::size_t pos = 0; + while (pos <= markdown.size()) { + auto end = markdown.find('\n', pos); + if (end == std::string_view::npos) end = markdown.size(); + std::string line(markdown.substr(pos, end - pos)); + pos = end + 1; + + const auto first = line.find_first_not_of(' '); + if (first == std::string::npos) { + open = false; + } else if (line.rfind("# ", 0) == 0) { + blocks.push_back({"title", line.substr(2)}); + open = false; + } else if (line.rfind("## ", 0) == 0) { + // "[0.5.0] - 2026-09-26" reads as "0.5.0 — 2026-09-26". + std::string text = line.substr(3); + std::erase(text, '['); + std::erase(text, ']'); + if (const auto dash = text.find(" - "); dash != std::string::npos) { + text.replace(dash, 3, " — "); + } + blocks.push_back({"release", text}); + open = false; + } else if (line.rfind("### ", 0) == 0) { + blocks.push_back({"section", line.substr(4)}); + open = false; + } else if (line.rfind("- ", 0) == 0) { + blocks.push_back({"item", "• " + line.substr(2)}); + open = true; + } else if (open) { + blocks.back().text += " " + line.substr(first); + } else { + blocks.push_back({"para", line.substr(first)}); + open = true; + } + } + + auto at = buffer->end(); + for (std::size_t i = 0; i < blocks.size(); ++i) { + const auto& block = blocks[i]; + // Split on the backticks: every odd piece is code. + std::size_t start = 0; + bool code = false; + while (start <= block.text.size()) { + auto tick = block.text.find('`', start); + if (tick == std::string::npos) tick = block.text.size(); + if (tick > start) { + std::vector names{block.kind}; + if (code) names.emplace_back("code"); + at = buffer->insert_with_tags_by_name(at, block.text.substr(start, tick - start), names); + } + start = tick + 1; + code = !code; + } + if (i + 1 < blocks.size()) at = buffer->insert_with_tag(at, "\n", block.kind); + } +} + +} // namespace + +void EffectorWindow::showChangelog() { + if (!changelogWindow_) { + changelogWindow_ = std::make_unique(); + changelogWindow_->set_title("Changelog"); + changelogWindow_->set_transient_for(*this); + changelogWindow_->set_default_size(560, 640); + changelogWindow_->set_hide_on_close(true); + + auto* text = Gtk::make_managed(); + text->set_name("changelog"); + text->set_editable(false); + text->set_cursor_visible(false); + text->set_wrap_mode(Gtk::WrapMode::WORD); + text->set_left_margin(18); + text->set_right_margin(18); + text->set_top_margin(12); + text->set_bottom_margin(18); + fillChangelog(text->get_buffer(), kChangelog); + + auto* scroller = Gtk::make_managed(); + scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); + scroller->set_child(*text); + changelogWindow_->set_child(*scroller); + } + changelogWindow_->present(); +} + +void EffectorWindow::showAbout() { + if (!aboutDialog_) { + aboutDialog_ = std::make_unique(); + aboutDialog_->set_transient_for(*this); + aboutDialog_->set_modal(true); + aboutDialog_->set_hide_on_close(true); + aboutDialog_->set_program_name("Effector"); + aboutDialog_->set_version(std::string(kVersion)); + aboutDialog_->set_comments("Image effects, cascaded through a chain."); + aboutDialog_->set_logo_icon_name("applications-graphics"); + } + aboutDialog_->present(); +} + void EffectorWindow::onSaveProject() { if (projectPath_.empty()) { onSaveProjectAs(); diff --git a/src/gui/window.hpp b/src/gui/window.hpp index 8f64b43..4129737 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -108,6 +108,8 @@ private: void selectStage(int index); void onStageSelected(); Gtk::Widget& buildMainMenu(); // the header menu: new, open, save project + void showChangelog(); + void showAbout(); Project currentProject() const; void applyProject(const Project& project, const std::string& path); void clearSources(); @@ -209,6 +211,9 @@ private: Gtk::Button* addButton_ = nullptr; // Built fresh with the progress window, and null whenever it is closed. std::unique_ptr loadWindow_; + // Opened from the menu on first use, then shown again rather than rebuilt. + std::unique_ptr changelogWindow_; + std::unique_ptr aboutDialog_; Gtk::ProgressBar* loadBar_ = nullptr; Gtk::Label* loadFileLabel_ = nullptr; Gtk::Box sourcesSection_; // hidden while the chain needs no sources diff --git a/src/main.cpp b/src/main.cpp index fe5aa36..f063dd9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,6 +13,7 @@ #include "core/image.hpp" #include "core/registry.hpp" #include "core/rng.hpp" +#include "core/version.hpp" #include "gui/app.hpp" namespace { @@ -78,6 +79,10 @@ int main(int argc, char** argv) { std::cout << effector::usage(argv[0]); return 0; } + if (options.showVersion) { + std::cout << "effector " << effector::kVersion << "\n"; + return 0; + } if (options.listEffects) { for (const auto& effect : effector::effects()) { std::cout << effect->name() << "\t" << effect->title() << " - " diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index c659bb8..1a791f7 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -24,6 +24,7 @@ #include "core/registry.hpp" #include "core/rng.hpp" #include "core/style.hpp" +#include "core/version.hpp" #include "effects/blur.hpp" #include "effects/grain.hpp" #include "effects/monochrome.hpp" @@ -1715,6 +1716,21 @@ int main() { std::filesystem::remove_all(dir); } + std::cout << "version\n"; + { + // A release is the version in CMakeLists.txt and its entry at the top + // of CHANGELOG.md; bumping one without the other fails here. + const std::string_view log = kChangelog; + const auto newest = log.find("\n## ["); + check(newest != std::string_view::npos && + log.substr(newest + 5).starts_with(std::string(kVersion) + "]"), + "the newest changelog entry is this version"); + const auto options = parsedOptions({"--version"}); + check(options && options->showVersion, "--version is understood"); + const auto shortOptions = parsedOptions({"-V"}); + check(shortOptions && shortOptions->showVersion, "and so is -V"); + } + std::cout << (failures == 0 ? "\nall checks passed\n" : "\n" + std::to_string(failures) + " failed\n"); return failures == 0 ? 0 : 1; } diff --git a/tests/gui_tests.cpp b/tests/gui_tests.cpp index 42e1724..f7a7fce 100644 --- a/tests/gui_tests.cpp +++ b/tests/gui_tests.cpp @@ -16,6 +16,7 @@ #include "cli/options.hpp" #include "core/project.hpp" #include "core/registry.hpp" +#include "core/version.hpp" #include "gui/window.hpp" namespace { @@ -375,6 +376,45 @@ std::vector recentEntries(Gtk::Window& window) { return labels; } +// The version and the changelog, each a menu entry away. +void testHelpWindows(Gtk::Window& window) { + std::cout << "changelog and about\n"; + auto toplevel = [](const std::string& title) -> Gtk::Window* { + for (auto* w : Gtk::Window::list_toplevels()) { + if (w->get_title() == title && w->get_visible()) return w; + } + return nullptr; + }; + + window.activate_action("win.show-changelog"); + pump(200); + auto* changelog = toplevel("Changelog"); + check(changelog != nullptr, "Changelog opens a window"); + auto* text = changelog != nullptr ? named(*changelog, "changelog") : nullptr; + const std::string shown = text != nullptr ? std::string(text->get_buffer()->get_text()) : ""; + check(shown.find(std::string(effector::kVersion) + " — ") != std::string::npos, + "listing this version as a release"); + check(shown.find("[") == std::string::npos && shown.find("`") == std::string::npos && + shown.find("### ") == std::string::npos, + "with the Markdown laid out rather than shown"); + if (changelog != nullptr) changelog->close(); + pump(50); + check(toplevel("Changelog") == nullptr, "and closes"); + + window.activate_action("win.about"); + pump(200); + Gtk::AboutDialog* about = nullptr; + for (auto* w : Gtk::Window::list_toplevels()) { + if (auto* dialog = dynamic_cast(w); dialog != nullptr && dialog->get_visible()) { + about = dialog; + } + } + check(about != nullptr && about->get_version() == std::string(effector::kVersion), + "About Effector shows the version"); + if (about != nullptr) about->close(); + pump(50); +} + void testRecentProjects(Gtk::Window& window, const std::filesystem::path& dir) { std::cout << "recent projects\n"; check(recentEntries(window) == std::vector{"No Recent Projects"}, @@ -425,6 +465,7 @@ int main(int argc, char** argv) { window->present(); pump(300); runTests(*window); + testHelpWindows(*window); testRecentProjects(*window, dataDir / "projects"); app->remove_window(*window); });