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