feat: initial commit

This commit is contained in:
nikola
2026-05-19 14:53:37 +02:00
commit a8c5df7c5e
25 changed files with 1719 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/build/
/build-container/
/.cache/
+18
View File
@@ -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.
+60
View File
@@ -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"
)
+49
View File
@@ -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
+17
View File
@@ -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
+38
View File
@@ -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
+13
View File
@@ -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'
+80
View File
@@ -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
+19
View File
@@ -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
+229
View File
@@ -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
+44
View File
@@ -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
+25
View File
@@ -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
+59
View File
@@ -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
+12
View File
@@ -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
+146
View File
@@ -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
+33
View File
@@ -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
+189
View File
@@ -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
+38
View File
@@ -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
+47
View File
@@ -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
+17
View File
@@ -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
+14
View File
@@ -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();
}
+165
View File
@@ -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
+278
View File
@@ -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
+70
View File
@@ -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
+56
View File
@@ -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"