From a8c5df7c5ea195c08d967875bded5c3567d4396b Mon Sep 17 00:00:00 2001 From: nikola Date: Tue, 19 May 2026 14:53:37 +0200 Subject: [PATCH] feat: initial commit --- .gitignore | 3 + AGENTS.md | 18 ++ CMakeLists.txt | 60 +++++ README.md | 49 ++++ docker/Dockerfile.dev | 17 ++ docs/architecture.md | 38 +++ scripts/build-in-container.sh | 13 + src/app/Application.cpp | 80 +++++++ src/app/Application.h | 19 ++ src/app/EditorWindow.cpp | 229 ++++++++++++++++++ src/app/EditorWindow.h | 44 ++++ src/capture/CaptureBackend.h | 25 ++ src/capture/PlaceholderCaptureBackend.cpp | 59 +++++ src/capture/PlaceholderCaptureBackend.h | 12 + src/capture/PortalCaptureBackend.cpp | 146 ++++++++++++ src/capture/PortalCaptureBackend.h | 33 +++ src/editor/EditorCanvas.cpp | 189 +++++++++++++++ src/editor/EditorCanvas.h | 38 +++ src/export/ExportService.cpp | 47 ++++ src/export/ExportService.h | 17 ++ src/main.cpp | 14 ++ src/model/Annotation.h | 165 +++++++++++++ src/model/EditorDocument.cpp | 278 ++++++++++++++++++++++ src/model/EditorDocument.h | 70 ++++++ tests/unit/editor_document_test.cpp | 56 +++++ 25 files changed, 1719 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 docker/Dockerfile.dev create mode 100644 docs/architecture.md create mode 100755 scripts/build-in-container.sh create mode 100644 src/app/Application.cpp create mode 100644 src/app/Application.h create mode 100644 src/app/EditorWindow.cpp create mode 100644 src/app/EditorWindow.h create mode 100644 src/capture/CaptureBackend.h create mode 100644 src/capture/PlaceholderCaptureBackend.cpp create mode 100644 src/capture/PlaceholderCaptureBackend.h create mode 100644 src/capture/PortalCaptureBackend.cpp create mode 100644 src/capture/PortalCaptureBackend.h create mode 100644 src/editor/EditorCanvas.cpp create mode 100644 src/editor/EditorCanvas.h create mode 100644 src/export/ExportService.cpp create mode 100644 src/export/ExportService.h create mode 100644 src/main.cpp create mode 100644 src/model/Annotation.h create mode 100644 src/model/EditorDocument.cpp create mode 100644 src/model/EditorDocument.h create mode 100644 tests/unit/editor_document_test.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4376c49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/build/ +/build-container/ +/.cache/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d62c09f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# Project Rules - Wayland Shot + +## Scope +- Build a lightweight Wayland-first screenshot tool for KDE Plasma on Debian-based Linux. +- Primary target machine is the user's Lenovo laptop on KDE Wayland. +- v1 scope is `region` and `fullscreen` capture, annotation editing, `copy`, `save`, and `save as`. + +## Working Rules +- Prefer KDE-native integration paths first, then fall back to generic portal paths. +- Keep the runtime lean: no heavy background daemon for v1. +- Treat global shortcut support as KDE-first. Document any compositor conflicts before changing the default combo. +- Prioritize stable cancel paths, clipboard robustness, and editor responsiveness over feature breadth. +- Keep packaging simple and Debian-friendly. + +## Current Product Decisions +- Preferred stack: `C++ + Qt6`. +- Hotkey target: `Alt+Shift+F12`. KDE may not distinguish left and right modifiers for global shortcuts, so document that limitation explicitly in the app and docs until proven otherwise. +- Out of scope for initial v1: window capture, blur, upload/share, screenshot history, advanced object transforms, cross-desktop guarantees. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..4b61366 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,60 @@ +cmake_minimum_required(VERSION 3.24) +project(wayland-shot VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_AUTOUIC ON) + +find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets DBus Test) + +qt_standard_project_setup() + +set(WAYLAND_SHOT_SOURCES + src/main.cpp + src/app/Application.cpp + src/app/EditorWindow.cpp + src/capture/PlaceholderCaptureBackend.cpp + src/capture/PortalCaptureBackend.cpp + src/editor/EditorCanvas.cpp + src/export/ExportService.cpp + src/model/EditorDocument.cpp +) + +set(WAYLAND_SHOT_HEADERS + src/app/Application.h + src/app/EditorWindow.h + src/capture/CaptureBackend.h + src/capture/PlaceholderCaptureBackend.h + src/capture/PortalCaptureBackend.h + src/editor/EditorCanvas.h + src/export/ExportService.h + src/model/Annotation.h + src/model/EditorDocument.h +) + +qt_add_executable(wayland-shot + ${WAYLAND_SHOT_SOURCES} + ${WAYLAND_SHOT_HEADERS} +) + +target_include_directories(wayland-shot PRIVATE src) +target_link_libraries(wayland-shot PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::DBus) + +qt_add_executable(wayland-shot-editor-document-test + tests/unit/editor_document_test.cpp + src/model/EditorDocument.cpp + src/model/EditorDocument.h + src/model/Annotation.h +) + +target_include_directories(wayland-shot-editor-document-test PRIVATE src) +target_link_libraries(wayland-shot-editor-document-test PRIVATE Qt6::Core Qt6::Gui Qt6::Test) + +enable_testing() +add_test(NAME wayland-shot-editor-document-test COMMAND wayland-shot-editor-document-test) +set_tests_properties( + wayland-shot-editor-document-test + PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;LANG=C.UTF-8" +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..32cdde9 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Wayland Shot + +`Wayland Shot` is a KDE-first screenshot tool for Wayland sessions on Debian-based Linux. + +The first serious build targets: +- fast launch from `Alt+Shift+F12` +- `region` and `fullscreen` capture modes +- a lightweight annotation editor with `select`, `arrow`, `line`, `rectangle`, and `text` +- `copy`, `save`, and `save as` + +## Why Qt6/C++ + +This machine already ships a full Qt6 runtime and KDE session stack. Qt6 is the shortest path to a native-feeling KDE application with strong D-Bus, clipboard, image editing, and packaging support. + +## Current State + +This repository currently contains: +- the application skeleton +- a working editor document model +- an annotation canvas shell +- export plumbing for clipboard and PNG save +- a placeholder capture backend until the real KDE/portal capture backend lands + +## Planned Build Dependencies + +For the native build we expect these packages: +- `qt6-base-dev` +- `qt6-tools-dev` +- `qt6-tools-dev-tools` +- `qt6-wayland-dev` +- `qt6-svg-dev` +- `qt6-declarative-dev` +- `libkf6globalaccel-dev` if we link KDE global shortcut support directly + +## Build + +```bash +cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug +cmake --build build +ctest --test-dir build --output-on-failure +``` + +## v1 Boundary + +Deliberately cut from the first usable local build: +- window capture +- blur and pixelation tools +- screenshot history/gallery +- cross-desktop guarantees beyond KDE Wayland diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 0000000..e44eb1f --- /dev/null +++ b/docker/Dockerfile.dev @@ -0,0 +1,17 @@ +FROM debian:trixie-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + ninja-build \ + pkg-config \ + qt6-base-dev \ + qt6-base-dev-tools \ + qt6-svg-dev \ + qt6-wayland-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..14b544f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,38 @@ +# Architecture + +## Runtime Shape + +One binary, two entry paths: +- regular launch opens the editor with a placeholder or file-backed image +- `capture` mode resolves a capture backend, acquires an image, and opens the editor + +## Modules + +### `capture` +- `CaptureBackend` interface for `region` and `fullscreen` +- `PlaceholderCaptureBackend` exists now for integration and editor work +- planned `PortalCaptureBackend` +- planned `KdeCaptureBackend` + +### `model` +- `Annotation` data model +- `EditorDocument` for base image, annotations, selection, and history + +### `editor` +- `EditorCanvas` widget for drawing and interactions +- simple tool model for select, line, arrow, rectangle, and text + +### `export` +- `ExportService` for clipboard and PNG writes + +### `hotkey` +- planned KDE-first launcher integration +- preferred path is a KDE global shortcut that launches `wayland-shot capture --region` + +## Quality Priorities + +1. Capture and cancel path reliability +2. Hotkey and entrypoint reliability +3. Clipboard and save robustness +4. Editor responsiveness +5. Multi-monitor and HiDPI correctness diff --git a/scripts/build-in-container.sh b/scripts/build-in-container.sh new file mode 100755 index 0000000..ea0fbb1 --- /dev/null +++ b/scripts/build-in-container.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_NAME="${IMAGE_NAME:-wayland-shot-dev}" +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +docker build -t "${IMAGE_NAME}" -f "${PROJECT_ROOT}/docker/Dockerfile.dev" "${PROJECT_ROOT}" + +docker run --rm \ + -v "${PROJECT_ROOT}:/workspace" \ + -w /workspace \ + "${IMAGE_NAME}" \ + bash -lc 'cmake -S . -B build-container -G Ninja -DCMAKE_BUILD_TYPE=Debug && cmake --build build-container && ctest --test-dir build-container --output-on-failure' diff --git a/src/app/Application.cpp b/src/app/Application.cpp new file mode 100644 index 0000000..b0a2f78 --- /dev/null +++ b/src/app/Application.cpp @@ -0,0 +1,80 @@ +#include "app/Application.h" + +#include "app/EditorWindow.h" +#include "capture/PlaceholderCaptureBackend.h" +#include "capture/PortalCaptureBackend.h" + +#include +#include +#include +#include + +namespace ws::app { + +Application::Application(QApplication& qt_app) + : qt_app_(qt_app) +{ +} + +int Application::run() +{ + QCommandLineParser parser; + parser.setApplicationDescription(QStringLiteral("Wayland-first screenshot tool for KDE Plasma")); + parser.addHelpOption(); + + QCommandLineOption capture_option(QStringLiteral("capture"), QStringLiteral("Capture a new screenshot")); + QCommandLineOption mode_option( + QStringLiteral("mode"), + QStringLiteral("Capture mode: region or fullscreen"), + QStringLiteral("mode"), + QStringLiteral("region") + ); + QCommandLineOption image_option( + QStringLiteral("image"), + QStringLiteral("Open an existing image"), + QStringLiteral("path") + ); + + parser.addOption(capture_option); + parser.addOption(mode_option); + parser.addOption(image_option); + parser.process(qt_app_); + + EditorWindow window; + + if (parser.isSet(image_option)) { + window.load_image(parser.value(image_option)); + } else if (parser.isSet(capture_option)) { + capture::PortalCaptureBackend backend; + const auto mode = parser.value(mode_option) == QStringLiteral("fullscreen") + ? capture::CaptureMode::Fullscreen + : capture::CaptureMode::Region; + const auto result = backend.capture(mode); + if (result.cancelled) { + return 0; + } + + if (!result.error.isEmpty() || result.image.isNull()) { + capture::PlaceholderCaptureBackend fallback_backend; + window.set_image(fallback_backend.capture(mode).image); + window.show(); + QMessageBox::warning( + &window, + QStringLiteral("Capture Backend"), + QStringLiteral("Portal capture failed.\n\n%1\n\nA placeholder image was opened instead.") + .arg(result.error) + ); + return qt_app_.exec(); + } + + window.set_image(result.image); + } else { + capture::PlaceholderCaptureBackend backend; + window.set_image(backend.capture(capture::CaptureMode::Region).image); + } + + window.show(); + return qt_app_.exec(); +} + +} // namespace ws::app diff --git a/src/app/Application.h b/src/app/Application.h new file mode 100644 index 0000000..aa37ce2 --- /dev/null +++ b/src/app/Application.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +class QApplication; + +namespace ws::app { + +class Application { +public: + explicit Application(QApplication& qt_app); + + int run(); + +private: + QApplication& qt_app_; +}; + +} // namespace ws::app diff --git a/src/app/EditorWindow.cpp b/src/app/EditorWindow.cpp new file mode 100644 index 0000000..9ac38a6 --- /dev/null +++ b/src/app/EditorWindow.cpp @@ -0,0 +1,229 @@ +#include "app/EditorWindow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ws::app { + +namespace { + +QColor color_from_name(const QString& name) +{ + if (name == QStringLiteral("Sky")) { + return QColor("#0ea5e9"); + } + if (name == QStringLiteral("Mint")) { + return QColor("#10b981"); + } + if (name == QStringLiteral("Amber")) { + return QColor("#f59e0b"); + } + if (name == QStringLiteral("Rose")) { + return QColor("#ff5a36"); + } + return QColor(name); +} + +} // namespace + +EditorWindow::EditorWindow(QWidget* parent) + : QMainWindow(parent) +{ + auto* scroll = new QScrollArea(this); + scroll->setBackgroundRole(QPalette::Dark); + scroll->setWidgetResizable(false); + + canvas_ = new editor::EditorCanvas(&document_, scroll); + scroll->setWidget(canvas_); + + setCentralWidget(scroll); + resize(1440, 920); + + build_toolbar(); + statusBar()->showMessage(QStringLiteral("Ready")); + + connect(&document_, &model::EditorDocument::history_changed, this, [this]() { + undo_action_->setEnabled(document_.can_undo()); + redo_action_->setEnabled(document_.can_redo()); + }); + connect(&document_, &model::EditorDocument::dirty_changed, this, [this](bool) { update_window_title(); }); + + update_window_title(); +} + +void EditorWindow::set_image(const QImage& image) +{ + document_.set_base_image(image); + canvas_->resize(image.size()); + canvas_->setMinimumSize(image.size()); + update_window_title(); +} + +bool EditorWindow::load_image(const QString& path) +{ + QImageReader reader(path); + const QImage image = reader.read(); + if (image.isNull()) { + QMessageBox::warning( + this, + QStringLiteral("Open Image"), + QStringLiteral("Failed to load %1").arg(path) + ); + return false; + } + + current_path_ = path; + set_image(image); + document_.mark_clean(); + update_window_title(); + return true; +} + +void EditorWindow::build_toolbar() +{ + auto* toolbar = addToolBar(QStringLiteral("Main")); + toolbar->setMovable(false); + toolbar->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + + auto* open_action = toolbar->addAction(QStringLiteral("Open")); + connect(open_action, &QAction::triggered, this, [this]() { + const QString path = QFileDialog::getOpenFileName( + this, + QStringLiteral("Open Image"), + QString(), + QStringLiteral("Images (*.png *.jpg *.jpeg *.webp)") + ); + if (!path.isEmpty()) { + load_image(path); + } + }); + + auto* copy_action = toolbar->addAction(QStringLiteral("Copy")); + connect(copy_action, &QAction::triggered, this, [this]() { + if (export_service_.copy_to_clipboard(document_.render_to_image())) { + statusBar()->showMessage(QStringLiteral("Copied to clipboard"), 2500); + return; + } + + QMessageBox::warning(this, QStringLiteral("Copy"), QStringLiteral("Clipboard copy failed.")); + }); + + auto* save_action = toolbar->addAction(QStringLiteral("Save")); + connect(save_action, &QAction::triggered, this, [this]() { + const QString path = choose_save_path(false); + if (!path.isEmpty()) { + save_to_path(path); + } + }); + + auto* save_as_action = toolbar->addAction(QStringLiteral("Save As")); + connect(save_as_action, &QAction::triggered, this, [this]() { + const QString path = choose_save_path(true); + if (!path.isEmpty()) { + save_to_path(path); + } + }); + + toolbar->addSeparator(); + + undo_action_ = toolbar->addAction(QStringLiteral("Undo")); + redo_action_ = toolbar->addAction(QStringLiteral("Redo")); + undo_action_->setEnabled(false); + redo_action_->setEnabled(false); + + connect(undo_action_, &QAction::triggered, &document_, &model::EditorDocument::undo); + connect(redo_action_, &QAction::triggered, &document_, &model::EditorDocument::redo); + + toolbar->addSeparator(); + + tool_group_ = new QActionGroup(this); + tool_group_->setExclusive(true); + + const auto add_tool = [this, toolbar](const QString& label, model::Tool tool, bool checked = false) { + auto* action = toolbar->addAction(label); + action->setCheckable(true); + action->setChecked(checked); + tool_group_->addAction(action); + connect(action, &QAction::triggered, this, [this, tool]() { canvas_->set_tool(tool); }); + return action; + }; + + add_tool(QStringLiteral("Select"), model::Tool::Select, true); + add_tool(QStringLiteral("Arrow"), model::Tool::Arrow); + add_tool(QStringLiteral("Line"), model::Tool::Line); + add_tool(QStringLiteral("Rect"), model::Tool::Rectangle); + add_tool(QStringLiteral("Text"), model::Tool::Text); + + toolbar->addSeparator(); + + toolbar->addWidget(new QLabel(QStringLiteral("Color"), toolbar)); + color_combo_ = new QComboBox(toolbar); + color_combo_->addItems({QStringLiteral("Rose"), QStringLiteral("Sky"), QStringLiteral("Mint"), QStringLiteral("Amber")}); + toolbar->addWidget(color_combo_); + + toolbar->addWidget(new QLabel(QStringLiteral("Stroke"), toolbar)); + stroke_spin_ = new QSpinBox(toolbar); + stroke_spin_->setRange(1, 24); + stroke_spin_->setValue(4); + toolbar->addWidget(stroke_spin_); + + connect(color_combo_, &QComboBox::currentTextChanged, this, [this](const QString&) { apply_style_controls(); }); + connect(stroke_spin_, &QSpinBox::valueChanged, this, [this](int) { apply_style_controls(); }); + apply_style_controls(); +} + +void EditorWindow::update_window_title() +{ + const QString dirty_marker = document_.is_dirty() ? QStringLiteral(" *") : QString(); + const QString base = current_path_.isEmpty() ? QStringLiteral("Untitled Capture") : QFileInfo(current_path_).fileName(); + setWindowTitle(QStringLiteral("Wayland Shot - %1%2").arg(base, dirty_marker)); +} + +void EditorWindow::apply_style_controls() +{ + auto style = document_.current_style(); + style.color = color_from_name(color_combo_->currentText()); + style.stroke_width = stroke_spin_->value(); + document_.set_current_style(style); +} + +void EditorWindow::save_to_path(const QString& path) +{ + QString error; + if (!export_service_.save_png(document_.render_to_image(), path, &error)) { + QMessageBox::warning(this, QStringLiteral("Save"), error); + return; + } + + current_path_ = path; + document_.mark_clean(); + update_window_title(); + statusBar()->showMessage(QStringLiteral("Saved to %1").arg(path), 2500); +} + +QString EditorWindow::choose_save_path(bool force_prompt) const +{ + if (!force_prompt && !current_path_.isEmpty()) { + return current_path_; + } + + return QFileDialog::getSaveFileName( + const_cast(this), + QStringLiteral("Save Screenshot"), + export_service_.default_file_name(), + QStringLiteral("PNG Files (*.png)") + ); +} + +} // namespace ws::app diff --git a/src/app/EditorWindow.h b/src/app/EditorWindow.h new file mode 100644 index 0000000..bf51a71 --- /dev/null +++ b/src/app/EditorWindow.h @@ -0,0 +1,44 @@ +#pragma once + +#include "editor/EditorCanvas.h" +#include "export/ExportService.h" +#include "model/EditorDocument.h" + +#include + +class QActionGroup; +class QAction; +class QComboBox; +class QSpinBox; + +namespace ws::app { + +class EditorWindow : public QMainWindow { + Q_OBJECT + +public: + explicit EditorWindow(QWidget* parent = nullptr); + + void set_image(const QImage& image); + bool load_image(const QString& path); + +private: + void build_toolbar(); + void update_window_title(); + void apply_style_controls(); + void save_to_path(const QString& path); + [[nodiscard]] QString choose_save_path(bool force_prompt) const; + + model::EditorDocument document_; + editor::EditorCanvas* canvas_ = nullptr; + exporting::ExportService export_service_; + QString current_path_; + + QAction* undo_action_ = nullptr; + QAction* redo_action_ = nullptr; + QActionGroup* tool_group_ = nullptr; + QComboBox* color_combo_ = nullptr; + QSpinBox* stroke_spin_ = nullptr; +}; + +} // namespace ws::app diff --git a/src/capture/CaptureBackend.h b/src/capture/CaptureBackend.h new file mode 100644 index 0000000..e3ca1c8 --- /dev/null +++ b/src/capture/CaptureBackend.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace ws::capture { + +enum class CaptureMode { + Region, + Fullscreen, +}; + +struct CaptureResult { + QImage image; + bool cancelled = false; + QString error; +}; + +class CaptureBackend { +public: + virtual ~CaptureBackend() = default; + virtual CaptureResult capture(CaptureMode mode) = 0; +}; + +} // namespace ws::capture diff --git a/src/capture/PlaceholderCaptureBackend.cpp b/src/capture/PlaceholderCaptureBackend.cpp new file mode 100644 index 0000000..d45c150 --- /dev/null +++ b/src/capture/PlaceholderCaptureBackend.cpp @@ -0,0 +1,59 @@ +#include "capture/PlaceholderCaptureBackend.h" + +#include +#include +#include + +namespace ws::capture { + +CaptureResult PlaceholderCaptureBackend::capture(CaptureMode mode) +{ + QImage image(1600, 900, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::TextAntialiasing, true); + + QLinearGradient gradient(QPointF(0, 0), QPointF(image.width(), image.height())); + gradient.setColorAt(0.0, QColor("#081225")); + gradient.setColorAt(1.0, QColor("#142c4c")); + painter.fillRect(image.rect(), gradient); + + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(255, 255, 255, 18)); + painter.drawRoundedRect(QRectF(72, 72, 1456, 756), 28, 28); + + QFont title_font(QStringLiteral("Noto Sans"), 30, QFont::Bold); + QFont body_font(QStringLiteral("Noto Sans"), 18, QFont::Normal); + QFont mono_font(QStringLiteral("JetBrains Mono"), 16); + + painter.setPen(QColor("#f8fafc")); + painter.setFont(title_font); + painter.drawText(QRectF(120, 140, 1200, 60), QStringLiteral("Wayland Shot Placeholder Capture")); + + painter.setFont(body_font); + painter.setPen(QColor("#cbd5e1")); + painter.drawText( + QRectF(120, 220, 1200, 120), + Qt::TextWordWrap, + QStringLiteral( + "The editor shell is ready. The real KDE/portal capture backend is still being wired in. " + "This placeholder keeps UI, export, and annotation work moving in parallel." + ) + ); + + painter.setFont(mono_font); + painter.setPen(QColor("#7dd3fc")); + painter.drawText( + QRectF(120, 360, 1200, 120), + Qt::TextWordWrap, + QStringLiteral("mode=%1\ncaptured_at=%2") + .arg(mode == CaptureMode::Region ? QStringLiteral("region") : QStringLiteral("fullscreen")) + .arg(QDateTime::currentDateTime().toString(Qt::ISODate)) + ); + + return CaptureResult {.image = image, .cancelled = false, .error = {}}; +} + +} // namespace ws::capture diff --git a/src/capture/PlaceholderCaptureBackend.h b/src/capture/PlaceholderCaptureBackend.h new file mode 100644 index 0000000..410670d --- /dev/null +++ b/src/capture/PlaceholderCaptureBackend.h @@ -0,0 +1,12 @@ +#pragma once + +#include "capture/CaptureBackend.h" + +namespace ws::capture { + +class PlaceholderCaptureBackend : public CaptureBackend { +public: + CaptureResult capture(CaptureMode mode) override; +}; + +} // namespace ws::capture diff --git a/src/capture/PortalCaptureBackend.cpp b/src/capture/PortalCaptureBackend.cpp new file mode 100644 index 0000000..9793b15 --- /dev/null +++ b/src/capture/PortalCaptureBackend.cpp @@ -0,0 +1,146 @@ +#include "capture/PortalCaptureBackend.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr auto kPortalService = "org.freedesktop.portal.Desktop"; +constexpr auto kPortalPath = "/org/freedesktop/portal/desktop"; +constexpr auto kScreenshotInterface = "org.freedesktop.portal.Screenshot"; +constexpr auto kRequestInterface = "org.freedesktop.portal.Request"; + +QString make_handle_token() +{ + return QStringLiteral("wayland_shot_%1").arg(QUuid::createUuid().toString(QUuid::Id128)); +} + +} // namespace + +namespace ws::capture { + +PortalCaptureBackend::PortalCaptureBackend(QObject* parent) + : QObject(parent) +{ +} + +CaptureResult PortalCaptureBackend::capture(CaptureMode mode) +{ + response_received_ = false; + response_code_ = 2; + response_results_.clear(); + + auto connection = QDBusConnection::sessionBus(); + if (!connection.isConnected()) { + return CaptureResult {.image = {}, .cancelled = false, .error = QStringLiteral("Session D-Bus is not available.")}; + } + + QDBusInterface screenshot( + QStringLiteral(kPortalService), + QStringLiteral(kPortalPath), + QStringLiteral(kScreenshotInterface), + connection + ); + + if (!screenshot.isValid()) { + return CaptureResult {.image = {}, .cancelled = false, .error = QStringLiteral("Screenshot portal is not available.")}; + } + + QVariantMap options; + options.insert(QStringLiteral("handle_token"), make_handle_token()); + options.insert(QStringLiteral("modal"), true); + options.insert(QStringLiteral("interactive"), mode == CaptureMode::Region); + + QDBusReply reply = screenshot.call(QStringLiteral("Screenshot"), QString(), options); + if (!reply.isValid()) { + return CaptureResult {.image = {}, .cancelled = false, .error = reply.error().message()}; + } + + return wait_for_response(reply.value()); +} + +void PortalCaptureBackend::on_response(uint response_code, const QVariantMap& results) +{ + response_received_ = true; + response_code_ = response_code; + response_results_ = results; + emit response_arrived(); +} + +CaptureResult PortalCaptureBackend::wait_for_response(const QDBusObjectPath& handle) +{ + auto connection = QDBusConnection::sessionBus(); + const bool connected = connection.connect( + QStringLiteral(kPortalService), + handle.path(), + QStringLiteral(kRequestInterface), + QStringLiteral("Response"), + this, + SLOT(on_response(uint,QVariantMap)) + ); + + if (!connected) { + return CaptureResult { + .image = {}, + .cancelled = false, + .error = QStringLiteral("Failed to subscribe to portal response.") + }; + } + + QEventLoop loop; + QTimer timeout; + timeout.setSingleShot(true); + connect(this, &PortalCaptureBackend::response_arrived, &loop, &QEventLoop::quit); + connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); + timeout.start(60000); + loop.exec(); + + connection.disconnect( + QStringLiteral(kPortalService), + handle.path(), + QStringLiteral(kRequestInterface), + QStringLiteral("Response"), + this, + SLOT(on_response(uint,QVariantMap)) + ); + + if (!response_received_) { + return CaptureResult {.image = {}, .cancelled = false, .error = QStringLiteral("Portal request timed out.")}; + } + + if (response_code_ == 1) { + return CaptureResult {.image = {}, .cancelled = true, .error = {}}; + } + + if (response_code_ != 0) { + return CaptureResult { + .image = {}, + .cancelled = false, + .error = QStringLiteral("Portal request failed with response code %1.").arg(response_code_) + }; + } + + const QString uri = response_results_.value(QStringLiteral("uri")).toString(); + const QString local_file = QUrl(uri).toLocalFile(); + QImageReader reader(local_file); + const QImage image = reader.read(); + + if (image.isNull()) { + return CaptureResult { + .image = {}, + .cancelled = false, + .error = QStringLiteral("Portal returned %1 but the image could not be loaded.").arg(uri) + }; + } + + return CaptureResult {.image = image, .cancelled = false, .error = {}}; +} + +} // namespace ws::capture diff --git a/src/capture/PortalCaptureBackend.h b/src/capture/PortalCaptureBackend.h new file mode 100644 index 0000000..1f3addf --- /dev/null +++ b/src/capture/PortalCaptureBackend.h @@ -0,0 +1,33 @@ +#pragma once + +#include "capture/CaptureBackend.h" + +#include +#include + +class QDBusObjectPath; + +namespace ws::capture { + +class PortalCaptureBackend : public QObject, public CaptureBackend { + Q_OBJECT + +public: + explicit PortalCaptureBackend(QObject* parent = nullptr); + CaptureResult capture(CaptureMode mode) override; + +signals: + void response_arrived(); + +private slots: + void on_response(uint response_code, const QVariantMap& results); + +private: + CaptureResult wait_for_response(const QDBusObjectPath& handle); + + bool response_received_ = false; + uint response_code_ = 2; + QVariantMap response_results_; +}; + +} // namespace ws::capture diff --git a/src/editor/EditorCanvas.cpp b/src/editor/EditorCanvas.cpp new file mode 100644 index 0000000..81fca0a --- /dev/null +++ b/src/editor/EditorCanvas.cpp @@ -0,0 +1,189 @@ +#include "editor/EditorCanvas.h" + +#include +#include +#include +#include +#include + +namespace ws::editor { + +EditorCanvas::EditorCanvas(model::EditorDocument* document, QWidget* parent) + : QWidget(parent) + , document_(document) +{ + setFocusPolicy(Qt::StrongFocus); + setMouseTracking(true); + setAttribute(Qt::WA_OpaquePaintEvent); + + connect(document_, &model::EditorDocument::document_changed, this, [this]() { + if (!document_->base_image().isNull()) { + resize(document_->base_image().size()); + setMinimumSize(document_->base_image().size()); + } + update(); + }); + connect(document_, &model::EditorDocument::selection_changed, this, qOverload<>(&EditorCanvas::update)); +} + +void EditorCanvas::set_tool(model::Tool tool) +{ + tool_ = tool; +} + +model::Tool EditorCanvas::tool() const +{ + return tool_; +} + +void EditorCanvas::paintEvent(QPaintEvent*) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::TextAntialiasing, true); + + painter.fillRect(rect(), QColor("#0f172a")); + + if (!document_->base_image().isNull()) { + painter.drawImage(QPoint(0, 0), document_->base_image()); + } + + for (const auto& annotation : document_->annotations()) { + const bool selected = document_->selected_annotation() != nullptr + && annotation.id == document_->selected_annotation()->id; + annotation.paint(painter, selected); + } + + if (drawing_preview_ && tool_ != model::Tool::Select && tool_ != model::Tool::Text) { + model::Annotation preview; + preview.kind = tool_ == model::Tool::Arrow + ? model::AnnotationKind::Arrow + : tool_ == model::Tool::Line ? model::AnnotationKind::Line : model::AnnotationKind::Rectangle; + preview.style = document_->current_style(); + preview.line = QLineF(preview_start_, preview_end_); + preview.rect = QRectF(preview_start_, preview_end_); + preview.paint(painter, false); + } +} + +void EditorCanvas::mousePressEvent(QMouseEvent* event) +{ + if (event->button() != Qt::LeftButton) { + return; + } + + setFocus(); + + if (tool_ == model::Tool::Select) { + const bool changed = document_->select_at(event->position()); + Q_UNUSED(changed); + if (document_->selected_annotation() != nullptr + && document_->selected_annotation()->hit_test(event->position())) { + dragging_selected_ = true; + drag_origin_ = event->position(); + } + update(); + return; + } + + if (tool_ == model::Tool::Text) { + bool accepted = false; + const QString text = QInputDialog::getText( + this, + QStringLiteral("Text Annotation"), + QStringLiteral("Enter annotation text"), + QLineEdit::Normal, + {}, + &accepted + ); + + if (accepted && !text.trimmed().isEmpty()) { + model::Annotation annotation; + annotation.kind = model::AnnotationKind::Text; + annotation.style = document_->current_style(); + annotation.text_anchor = event->position(); + annotation.text = text.trimmed(); + document_->add_annotation(annotation); + } + return; + } + + preview_start_ = event->position(); + preview_end_ = event->position(); + drawing_preview_ = true; + update(); +} + +void EditorCanvas::mouseMoveEvent(QMouseEvent* event) +{ + if (dragging_selected_) { + const QPointF delta = event->position() - drag_origin_; + drag_origin_ = event->position(); + document_->move_selected_by(delta); + return; + } + + if (drawing_preview_) { + preview_end_ = event->position(); + update(); + } +} + +void EditorCanvas::mouseReleaseEvent(QMouseEvent* event) +{ + if (event->button() != Qt::LeftButton) { + return; + } + + if (dragging_selected_) { + dragging_selected_ = false; + return; + } + + if (!drawing_preview_) { + return; + } + + drawing_preview_ = false; + preview_end_ = event->position(); + commit_shape_annotation(preview_start_, preview_end_); + update(); +} + +void EditorCanvas::keyPressEvent(QKeyEvent* event) +{ + if (event->key() == Qt::Key_Delete || event->key() == Qt::Key_Backspace) { + document_->delete_selected(); + return; + } + + QWidget::keyPressEvent(event); +} + +void EditorCanvas::commit_shape_annotation(const QPointF& start, const QPointF& end) +{ + model::Annotation annotation; + annotation.style = document_->current_style(); + + switch (tool_) { + case model::Tool::Arrow: + annotation.kind = model::AnnotationKind::Arrow; + annotation.line = QLineF(start, end); + break; + case model::Tool::Line: + annotation.kind = model::AnnotationKind::Line; + annotation.line = QLineF(start, end); + break; + case model::Tool::Rectangle: + annotation.kind = model::AnnotationKind::Rectangle; + annotation.rect = QRectF(start, end).normalized(); + break; + case model::Tool::Select: + case model::Tool::Text: + return; + } + + document_->add_annotation(annotation); +} + +} // namespace ws::editor diff --git a/src/editor/EditorCanvas.h b/src/editor/EditorCanvas.h new file mode 100644 index 0000000..6d7248e --- /dev/null +++ b/src/editor/EditorCanvas.h @@ -0,0 +1,38 @@ +#pragma once + +#include "model/EditorDocument.h" + +#include +#include + +namespace ws::editor { + +class EditorCanvas : public QWidget { + Q_OBJECT + +public: + explicit EditorCanvas(model::EditorDocument* document, QWidget* parent = nullptr); + + void set_tool(model::Tool tool); + [[nodiscard]] model::Tool tool() const; + +protected: + void paintEvent(QPaintEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + void keyPressEvent(QKeyEvent* event) override; + +private: + void commit_shape_annotation(const QPointF& start, const QPointF& end); + + model::EditorDocument* document_; + model::Tool tool_ = model::Tool::Select; + bool dragging_selected_ = false; + bool drawing_preview_ = false; + QPointF drag_origin_; + QPointF preview_start_; + QPointF preview_end_; +}; + +} // namespace ws::editor diff --git a/src/export/ExportService.cpp b/src/export/ExportService.cpp new file mode 100644 index 0000000..e6c68d6 --- /dev/null +++ b/src/export/ExportService.cpp @@ -0,0 +1,47 @@ +#include "export/ExportService.h" + +#include +#include +#include +#include + +namespace ws::exporting { + +bool ExportService::copy_to_clipboard(const QImage& image) const +{ + if (image.isNull()) { + return false; + } + + QApplication::clipboard()->setImage(image, QClipboard::Clipboard); + return !QApplication::clipboard()->image().isNull(); +} + +bool ExportService::save_png(const QImage& image, const QString& path, QString* error_message) const +{ + if (image.isNull()) { + if (error_message != nullptr) { + *error_message = QStringLiteral("No image is available to save."); + } + return false; + } + + if (!image.save(path, "PNG")) { + if (error_message != nullptr) { + *error_message = QStringLiteral("Failed to write PNG file to %1").arg(path); + } + return false; + } + + return true; +} + +QString ExportService::default_file_name() const +{ + return QDir::homePath() + + QStringLiteral("/Pictures/wayland-shot-") + + QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-hhmmss")) + + QStringLiteral(".png"); +} + +} // namespace ws::exporting diff --git a/src/export/ExportService.h b/src/export/ExportService.h new file mode 100644 index 0000000..18e060e --- /dev/null +++ b/src/export/ExportService.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +class QWidget; + +namespace ws::exporting { + +class ExportService { +public: + bool copy_to_clipboard(const QImage& image) const; + bool save_png(const QImage& image, const QString& path, QString* error_message = nullptr) const; + [[nodiscard]] QString default_file_name() const; +}; + +} // namespace ws::exporting diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..0adc262 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,14 @@ +#include "app/Application.h" + +#include + +int main(int argc, char* argv[]) +{ + QApplication app(argc, argv); + QApplication::setApplicationName(QStringLiteral("wayland-shot")); + QApplication::setApplicationDisplayName(QStringLiteral("Wayland Shot")); + QApplication::setOrganizationName(QStringLiteral("codex-cli")); + + ws::app::Application application(app); + return application.run(); +} diff --git a/src/model/Annotation.h b/src/model/Annotation.h new file mode 100644 index 0000000..fa8bd57 --- /dev/null +++ b/src/model/Annotation.h @@ -0,0 +1,165 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ws::model { + +enum class Tool { + Select, + Arrow, + Line, + Rectangle, + Text, +}; + +enum class AnnotationKind { + Arrow, + Line, + Rectangle, + Text, +}; + +struct AnnotationStyle { + QColor color = QColor("#ff5a36"); + int stroke_width = 4; + QFont font = QFont(QStringLiteral("Noto Sans"), 18, QFont::DemiBold); +}; + +struct Annotation { + QUuid id = QUuid::createUuid(); + AnnotationKind kind = AnnotationKind::Line; + AnnotationStyle style; + QLineF line; + QRectF rect; + QPointF text_anchor; + QString text; + + [[nodiscard]] QRectF bounds() const + { + switch (kind) { + case AnnotationKind::Arrow: + case AnnotationKind::Line: + return QRectF(line.p1(), line.p2()).normalized().adjusted( + -style.stroke_width * 1.5, + -style.stroke_width * 1.5, + style.stroke_width * 1.5, + style.stroke_width * 1.5 + ); + case AnnotationKind::Rectangle: + return rect.normalized().adjusted( + -style.stroke_width, + -style.stroke_width, + style.stroke_width, + style.stroke_width + ); + case AnnotationKind::Text: { + QFontMetricsF metrics(style.font); + const QRectF text_rect = metrics.boundingRect(text); + return QRectF(text_anchor + QPointF(0.0, text_rect.top()), text_rect.size()) + .adjusted(-6.0, -6.0, 6.0, 6.0); + } + } + + return {}; + } + + void translate(const QPointF& delta) + { + switch (kind) { + case AnnotationKind::Arrow: + case AnnotationKind::Line: + line.translate(delta); + break; + case AnnotationKind::Rectangle: + rect.translate(delta); + break; + case AnnotationKind::Text: + text_anchor += delta; + break; + } + } + + [[nodiscard]] bool hit_test(const QPointF& point, qreal tolerance = 8.0) const + { + switch (kind) { + case AnnotationKind::Arrow: + case AnnotationKind::Line: { + QPainterPath path; + path.moveTo(line.p1()); + path.lineTo(line.p2()); + QPainterPathStroker stroker; + stroker.setWidth(style.stroke_width + tolerance); + return stroker.createStroke(path).contains(point); + } + case AnnotationKind::Rectangle: + return bounds().contains(point); + case AnnotationKind::Text: + return bounds().contains(point); + } + + return false; + } + + void paint(QPainter& painter, bool selected) const + { + painter.save(); + + QPen pen(style.color, style.stroke_width, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin); + painter.setPen(pen); + painter.setBrush(Qt::NoBrush); + + switch (kind) { + case AnnotationKind::Line: + painter.drawLine(line); + break; + case AnnotationKind::Arrow: { + painter.drawLine(line); + + const double angle = std::atan2(-line.dy(), line.dx()); + constexpr qreal head_length = 16.0; + const QPointF arrow_tip = line.p2(); + const QPointF head_a = arrow_tip + QPointF( + std::sin(angle + std::numbers::pi_v / 3.0) * head_length, + std::cos(angle + std::numbers::pi_v / 3.0) * head_length + ); + const QPointF head_b = arrow_tip + QPointF( + std::sin(angle + std::numbers::pi_v - std::numbers::pi_v / 3.0) * head_length, + std::cos(angle + std::numbers::pi_v - std::numbers::pi_v / 3.0) * head_length + ); + + painter.setBrush(style.color); + painter.drawPolygon(QPolygonF {arrow_tip, head_a, head_b}); + break; + } + case AnnotationKind::Rectangle: + painter.drawRect(rect.normalized()); + break; + case AnnotationKind::Text: + painter.setFont(style.font); + painter.drawText(text_anchor, text); + break; + } + + if (selected) { + QPen selection_pen(QColor("#0ea5e9"), 2, Qt::DashLine); + painter.setPen(selection_pen); + painter.setBrush(Qt::NoBrush); + painter.drawRect(bounds()); + } + + painter.restore(); + } +}; + +} // namespace ws::model diff --git a/src/model/EditorDocument.cpp b/src/model/EditorDocument.cpp new file mode 100644 index 0000000..90850de --- /dev/null +++ b/src/model/EditorDocument.cpp @@ -0,0 +1,278 @@ +#include "model/EditorDocument.h" + +#include + +#include + +namespace ws::model { + +EditorDocument::EditorDocument(QObject* parent) + : QObject(parent) +{ +} + +void EditorDocument::set_base_image(const QImage& image) +{ + base_image_ = image; + emit document_changed(); +} + +const QImage& EditorDocument::base_image() const +{ + return base_image_; +} + +void EditorDocument::add_annotation(const Annotation& annotation) +{ + const bool previous_dirty = dirty_; + push_undo_state(); + annotations_.push_back(annotation); + selected_id_ = annotation.id; + dirty_ = true; + redo_stack_.clear(); + emit document_changed(); + emit selection_changed(); + emit history_changed(); + emit_dirty_if_changed(previous_dirty); +} + +bool EditorDocument::select_at(const QPointF& point) +{ + std::optional next_selection; + for (auto it = annotations_.rbegin(); it != annotations_.rend(); ++it) { + if (it->hit_test(point)) { + next_selection = it->id; + break; + } + } + + if (next_selection == selected_id_) { + return false; + } + + selected_id_ = next_selection; + emit selection_changed(); + return true; +} + +void EditorDocument::clear_selection() +{ + if (!selected_id_.has_value()) { + return; + } + + selected_id_.reset(); + emit selection_changed(); +} + +bool EditorDocument::move_selected_by(const QPointF& delta) +{ + if (!selected_id_.has_value()) { + return false; + } + + auto* annotation = find_annotation(*selected_id_); + if (annotation == nullptr) { + return false; + } + + const bool previous_dirty = dirty_; + push_undo_state(); + annotation->translate(delta); + dirty_ = true; + redo_stack_.clear(); + emit document_changed(); + emit history_changed(); + emit_dirty_if_changed(previous_dirty); + return true; +} + +bool EditorDocument::update_annotation(const Annotation& annotation) +{ + auto* existing = find_annotation(annotation.id); + if (existing == nullptr) { + return false; + } + + const bool previous_dirty = dirty_; + push_undo_state(); + *existing = annotation; + dirty_ = true; + redo_stack_.clear(); + emit document_changed(); + emit history_changed(); + emit_dirty_if_changed(previous_dirty); + return true; +} + +bool EditorDocument::delete_selected() +{ + if (!selected_id_.has_value()) { + return false; + } + + const bool previous_dirty = dirty_; + push_undo_state(); + const auto selected = *selected_id_; + annotations_.erase( + std::remove_if( + annotations_.begin(), + annotations_.end(), + [&selected](const Annotation& annotation) { return annotation.id == selected; } + ), + annotations_.end() + ); + selected_id_.reset(); + dirty_ = true; + redo_stack_.clear(); + emit document_changed(); + emit selection_changed(); + emit history_changed(); + emit_dirty_if_changed(previous_dirty); + return true; +} + +const std::vector& EditorDocument::annotations() const +{ + return annotations_; +} + +const Annotation* EditorDocument::selected_annotation() const +{ + if (!selected_id_.has_value()) { + return nullptr; + } + + return find_annotation(*selected_id_); +} + +AnnotationStyle EditorDocument::current_style() const +{ + return current_style_; +} + +void EditorDocument::set_current_style(const AnnotationStyle& style) +{ + current_style_ = style; +} + +bool EditorDocument::can_undo() const +{ + return !undo_stack_.empty(); +} + +bool EditorDocument::can_redo() const +{ + return !redo_stack_.empty(); +} + +bool EditorDocument::undo() +{ + if (undo_stack_.empty()) { + return false; + } + + const bool previous_dirty = dirty_; + redo_stack_.push_back(Snapshot {annotations_, selected_id_}); + const Snapshot snapshot = undo_stack_.back(); + undo_stack_.pop_back(); + annotations_ = snapshot.annotations; + selected_id_ = snapshot.selected; + dirty_ = true; + emit document_changed(); + emit selection_changed(); + emit history_changed(); + emit_dirty_if_changed(previous_dirty); + return true; +} + +bool EditorDocument::redo() +{ + if (redo_stack_.empty()) { + return false; + } + + const bool previous_dirty = dirty_; + undo_stack_.push_back(Snapshot {annotations_, selected_id_}); + const Snapshot snapshot = redo_stack_.back(); + redo_stack_.pop_back(); + annotations_ = snapshot.annotations; + selected_id_ = snapshot.selected; + dirty_ = true; + emit document_changed(); + emit selection_changed(); + emit history_changed(); + emit_dirty_if_changed(previous_dirty); + return true; +} + +QImage EditorDocument::render_to_image() const +{ + QImage canvas = base_image_; + if (canvas.isNull()) { + canvas = QImage(1280, 720, QImage::Format_ARGB32_Premultiplied); + canvas.fill(QColor("#111827")); + } + + QPainter painter(&canvas); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::TextAntialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + + for (const auto& annotation : annotations_) { + annotation.paint(painter, false); + } + + return canvas; +} + +bool EditorDocument::is_dirty() const +{ + return dirty_; +} + +void EditorDocument::mark_clean() +{ + const bool previous_dirty = dirty_; + dirty_ = false; + emit_dirty_if_changed(previous_dirty); +} + +void EditorDocument::push_undo_state() +{ + undo_stack_.push_back(Snapshot {annotations_, selected_id_}); + if (undo_stack_.size() > 100) { + undo_stack_.erase(undo_stack_.begin()); + } +} + +void EditorDocument::emit_dirty_if_changed(bool previous_dirty) +{ + if (dirty_ != previous_dirty) { + emit dirty_changed(dirty_); + } +} + +Annotation* EditorDocument::find_annotation(const QUuid& id) +{ + const auto it = std::find_if( + annotations_.begin(), + annotations_.end(), + [&id](const Annotation& annotation) { return annotation.id == id; } + ); + + return it == annotations_.end() ? nullptr : &(*it); +} + +const Annotation* EditorDocument::find_annotation(const QUuid& id) const +{ + const auto it = std::find_if( + annotations_.begin(), + annotations_.end(), + [&id](const Annotation& annotation) { return annotation.id == id; } + ); + + return it == annotations_.end() ? nullptr : &(*it); +} + +} // namespace ws::model diff --git a/src/model/EditorDocument.h b/src/model/EditorDocument.h new file mode 100644 index 0000000..b01ef64 --- /dev/null +++ b/src/model/EditorDocument.h @@ -0,0 +1,70 @@ +#pragma once + +#include "model/Annotation.h" + +#include +#include + +#include +#include + +namespace ws::model { + +class EditorDocument : public QObject { + Q_OBJECT + +public: + explicit EditorDocument(QObject* parent = nullptr); + + void set_base_image(const QImage& image); + [[nodiscard]] const QImage& base_image() const; + + void add_annotation(const Annotation& annotation); + bool select_at(const QPointF& point); + void clear_selection(); + bool move_selected_by(const QPointF& delta); + bool update_annotation(const Annotation& annotation); + bool delete_selected(); + + [[nodiscard]] const std::vector& annotations() const; + [[nodiscard]] const Annotation* selected_annotation() const; + [[nodiscard]] AnnotationStyle current_style() const; + void set_current_style(const AnnotationStyle& style); + + [[nodiscard]] bool can_undo() const; + [[nodiscard]] bool can_redo() const; + bool undo(); + bool redo(); + + [[nodiscard]] QImage render_to_image() const; + [[nodiscard]] bool is_dirty() const; + void mark_clean(); + +signals: + void document_changed(); + void selection_changed(); + void history_changed(); + void dirty_changed(bool dirty); + +private: + struct Snapshot { + std::vector annotations; + std::optional selected; + }; + + void push_undo_state(); + void emit_dirty_if_changed(bool previous_dirty); + + [[nodiscard]] Annotation* find_annotation(const QUuid& id); + [[nodiscard]] const Annotation* find_annotation(const QUuid& id) const; + + QImage base_image_; + std::vector annotations_; + std::optional selected_id_; + AnnotationStyle current_style_; + std::vector undo_stack_; + std::vector redo_stack_; + bool dirty_ = false; +}; + +} // namespace ws::model diff --git a/tests/unit/editor_document_test.cpp b/tests/unit/editor_document_test.cpp new file mode 100644 index 0000000..44ed611 --- /dev/null +++ b/tests/unit/editor_document_test.cpp @@ -0,0 +1,56 @@ +#include "model/EditorDocument.h" + +#include + +using ws::model::Annotation; +using ws::model::AnnotationKind; +using ws::model::EditorDocument; + +class EditorDocumentTest : public QObject { + Q_OBJECT + +private slots: + void add_select_move_delete_cycle(); + void undo_redo_cycle(); +}; + +void EditorDocumentTest::add_select_move_delete_cycle() +{ + EditorDocument document; + + Annotation annotation; + annotation.kind = AnnotationKind::Rectangle; + annotation.rect = QRectF(10, 10, 100, 80); + + document.add_annotation(annotation); + QCOMPARE(document.annotations().size(), 1); + document.clear_selection(); + QVERIFY(document.select_at(QPointF(20, 20))); + QVERIFY(document.selected_annotation() != nullptr); + QVERIFY(document.move_selected_by(QPointF(12, 8))); + QCOMPARE(document.annotations().front().rect.topLeft(), QPointF(22, 18)); + QVERIFY(document.delete_selected()); + QCOMPARE(document.annotations().size(), 0); +} + +void EditorDocumentTest::undo_redo_cycle() +{ + EditorDocument document; + + Annotation line; + line.kind = AnnotationKind::Line; + line.line = QLineF(QPointF(0, 0), QPointF(50, 50)); + + document.add_annotation(line); + QCOMPARE(document.annotations().size(), 1); + QVERIFY(document.can_undo()); + QVERIFY(document.undo()); + QCOMPARE(document.annotations().size(), 0); + QVERIFY(document.can_redo()); + QVERIFY(document.redo()); + QCOMPARE(document.annotations().size(), 1); +} + +QTEST_MAIN(EditorDocumentTest) + +#include "editor_document_test.moc"