feat: initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
/build/
|
||||||
|
/build-container/
|
||||||
|
/.cache/
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+13
@@ -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'
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#include "app/Application.h"
|
||||||
|
|
||||||
|
#include "app/EditorWindow.h"
|
||||||
|
#include "capture/PlaceholderCaptureBackend.h"
|
||||||
|
#include "capture/PortalCaptureBackend.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QCommandLineOption>
|
||||||
|
#include <QCommandLineParser>
|
||||||
|
#include <QMessageBox>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
class QApplication;
|
||||||
|
|
||||||
|
namespace ws::app {
|
||||||
|
|
||||||
|
class Application {
|
||||||
|
public:
|
||||||
|
explicit Application(QApplication& qt_app);
|
||||||
|
|
||||||
|
int run();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QApplication& qt_app_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ws::app
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
#include "app/EditorWindow.h"
|
||||||
|
|
||||||
|
#include <QActionGroup>
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QColorDialog>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QFileDialog>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QImageReader>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QScrollArea>
|
||||||
|
#include <QSpinBox>
|
||||||
|
#include <QStatusBar>
|
||||||
|
#include <QToolBar>
|
||||||
|
|
||||||
|
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<EditorWindow*>(this),
|
||||||
|
QStringLiteral("Save Screenshot"),
|
||||||
|
export_service_.default_file_name(),
|
||||||
|
QStringLiteral("PNG Files (*.png)")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ws::app
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "editor/EditorCanvas.h"
|
||||||
|
#include "export/ExportService.h"
|
||||||
|
#include "model/EditorDocument.h"
|
||||||
|
|
||||||
|
#include <QMainWindow>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QImage>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#include "capture/PlaceholderCaptureBackend.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QLinearGradient>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#include "capture/PortalCaptureBackend.h"
|
||||||
|
|
||||||
|
#include <QDBusConnection>
|
||||||
|
#include <QDBusInterface>
|
||||||
|
#include <QDBusObjectPath>
|
||||||
|
#include <QDBusReply>
|
||||||
|
#include <QEventLoop>
|
||||||
|
#include <QImageReader>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QUuid>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
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<QDBusObjectPath> 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
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "capture/CaptureBackend.h"
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QVariantMap>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
#include "editor/EditorCanvas.h"
|
||||||
|
|
||||||
|
#include <QInputDialog>
|
||||||
|
#include <QKeyEvent>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QPainter>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "model/EditorDocument.h"
|
||||||
|
|
||||||
|
#include <QPointF>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#include "export/ExportService.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QClipboard>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QImage>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#include "app/Application.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QLineF>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPainterPath>
|
||||||
|
#include <QPointF>
|
||||||
|
#include <QRectF>
|
||||||
|
#include <QString>
|
||||||
|
#include <QUuid>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <numbers>
|
||||||
|
|
||||||
|
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<double> / 3.0) * head_length,
|
||||||
|
std::cos(angle + std::numbers::pi_v<double> / 3.0) * head_length
|
||||||
|
);
|
||||||
|
const QPointF head_b = arrow_tip + QPointF(
|
||||||
|
std::sin(angle + std::numbers::pi_v<double> - std::numbers::pi_v<double> / 3.0) * head_length,
|
||||||
|
std::cos(angle + std::numbers::pi_v<double> - std::numbers::pi_v<double> / 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
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
#include "model/EditorDocument.h"
|
||||||
|
|
||||||
|
#include <QPainter>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
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<QUuid> 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<Annotation>& 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
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "model/Annotation.h"
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QImage>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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<Annotation>& 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<Annotation> annotations;
|
||||||
|
std::optional<QUuid> 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<Annotation> annotations_;
|
||||||
|
std::optional<QUuid> selected_id_;
|
||||||
|
AnnotationStyle current_style_;
|
||||||
|
std::vector<Snapshot> undo_stack_;
|
||||||
|
std::vector<Snapshot> redo_stack_;
|
||||||
|
bool dirty_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ws::model
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#include "model/EditorDocument.h"
|
||||||
|
|
||||||
|
#include <QtTest>
|
||||||
|
|
||||||
|
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"
|
||||||
Reference in New Issue
Block a user