Add Alvin library: input, capture, windows and vision

A cross-platform C++23 library with a platform-neutral public API and
runtime-selected backends:

- Input: Wayland virtual pointer/keyboard (Hyprland, wlroots), libei via
  the RemoteDesktop portal (GNOME, KDE), uinput, and Win32 SendInput
- Capture: ScreenCast portal + PipeWire, and Win32 GDI
- Windows: Hyprland IPC and Win32 enumeration
- Monitors via wl_output/xdg-output and EnumDisplayMonitors
- Optional OpenCV template matching (alvin::vision)

Also adds the `alvin` CLI, Catch2 unit tests, install rules with
find_package(Alvin) support, API docs (docs/API.md and the Obsidian
vault), and README instructions for both ways of consuming the library.

Windows backends are written but not yet compiled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aeth3randClaude Opus 5.5 committed 2026-09-27 13:58:20 -04:00
1 parent 76ff506fd0
commit ca7e62ac8d
59 files changed
+6515 -1

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
#include <algorithm>
#include <cctype>
#include <span>
#include <thread>
#include "alvin/capture.hpp"
#include "alvin/error.hpp"
#include "alvin/input.hpp"
#include "alvin/types.hpp"
#include "alvin/window.hpp"
#include "platform.hpp"
namespace alvin {
std::string_view toString(ErrorCode code) noexcept {
switch (code) {
case ErrorCode::Unknown: return "unknown";
case ErrorCode::Unsupported: return "unsupported";
case ErrorCode::NotAvailable: return "not available";
case ErrorCode::PermissionDenied: return "permission denied";
case ErrorCode::Cancelled: return "cancelled";
case ErrorCode::Timeout: return "timeout";
case ErrorCode::InvalidArgument: return "invalid argument";
case ErrorCode::NotFound: return "not found";
case ErrorCode::Io: return "i/o error";
case ErrorCode::Protocol: return "protocol error";
}
return "unknown";
}
std::string_view toString(InputBackend backend) noexcept {
switch (backend) {
case InputBackend::Auto: return "auto";
case InputBackend::WlrVirtual: return "wlr";
case InputBackend::LibEi: return "libei";
case InputBackend::UInput: return "uinput";
case InputBackend::SendInput: return "sendinput";
}
return "unknown";
}
std::string_view toString(CaptureBackend backend) noexcept {
switch (backend) {
case CaptureBackend::Auto: return "auto";
case CaptureBackend::Portal: return "portal";
case CaptureBackend::Gdi: return "gdi";
}
return "unknown";
}
std::string_view toString(WindowBackend backend) noexcept {
switch (backend) {
case WindowBackend::Auto: return "auto";
case WindowBackend::Hyprland: return "hyprland";
case WindowBackend::Win32: return "win32";
}
return "unknown";
}
Rect Rect::united(const Rect& o) const {
if (empty()) return o;
if (o.empty()) return *this;
int l = std::min(x, o.x), t = std::min(y, o.y);
int r = std::max(right(), o.right()), b = std::max(bottom(), o.bottom());
return {l, t, r - l, b - t};
}
Result<std::vector<Monitor>> listMonitors() { return detail::listMonitors(); }
Result<Rect> desktopBounds() {
auto monitors = listMonitors();
if (!monitors) return std::unexpected(monitors.error());
Rect box;
for (const auto& m : *monitors) box = box.united(m.bounds);
if (box.empty()) return fail(ErrorCode::NotFound, "no monitors reported");
return box;
}
// ---- Input ----
Result<std::unique_ptr<Input>> Input::create(const InputOptions& options) { return detail::createInput(options); }
void Input::pause() const {
if (eventDelay_.count() > 0) std::this_thread::sleep_for(eventDelay_);
}
Status Input::click(MouseButton button, int count) {
for (int i = 0; i < count; ++i) {
if (auto s = buttonDown(button); !s) return s;
pause();
if (auto s = buttonUp(button); !s) return s;
if (i + 1 < count) pause();
}
return {};
}
Status Input::clickAt(Point p, MouseButton button, int count) {
if (auto s = moveTo(p); !s) return s;
pause();
return click(button, count);
}
Status Input::drag(Point from, Point to, MouseButton button) {
if (auto s = moveTo(from); !s) return s;
pause();
if (auto s = buttonDown(button); !s) return s;
// Intermediate steps so drag thresholds and hover handlers fire.
constexpr int steps = 10;
for (int i = 1; i <= steps; ++i) {
pause();
Point p{from.x + (to.x - from.x) * i / steps, from.y + (to.y - from.y) * i / steps};
if (auto s = moveTo(p); !s) {
(void)buttonUp(button);
return s;
}
}
pause();
return buttonUp(button);
}
Status Input::keyPress(Key key) {
if (auto s = keyDown(key); !s) return s;
pause();
return keyUp(key);
}
// Presses keys in order and releases them in reverse. Always releases what it
// pressed, even after a failure, so no key is left stuck down.
static Status pressChord(Input& input, std::span<const Key> keys, void (*pause)(const Input&)) {
std::vector<Key> pressed;
Status result;
for (Key k : keys) {
result = input.keyDown(k);
if (!result) break;
pressed.push_back(k);
pause(input);
}
for (auto it = pressed.rbegin(); it != pressed.rend(); ++it) {
auto s = input.keyUp(*it);
if (result && !s) result = s;
}
return result;
}
Status Input::chord(std::initializer_list<Key> keys) {
return pressChord(*this, std::span(keys.begin(), keys.size()), [](const Input& in) { in.pause(); });
}
Status Input::chord(std::string_view text) {
auto keys = parseChord(text);
if (!keys) return fail(ErrorCode::InvalidArgument, "cannot parse key chord '" + std::string(text) + "'");
return pressChord(*this, *keys, [](const Input& in) { in.pause(); });
}
// ---- Capture / windows ----
Result<std::unique_ptr<Capture>> Capture::create(const CaptureOptions& options) { return detail::createCapture(options); }
Result<std::unique_ptr<WindowManager>> WindowManager::create(WindowBackend backend) {
return detail::createWindowManager(backend);
}
static bool containsNoCase(std::string_view haystack, std::string_view needle) {
auto it = std::search(haystack.begin(), haystack.end(), needle.begin(), needle.end(), [](char a, char b) {
return std::tolower(static_cast<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b));
});
return it != haystack.end();
}
Result<std::optional<WindowInfo>> WindowManager::find(std::string_view needle) {
auto windows = list();
if (!windows) return std::unexpected(windows.error());
for (auto& w : *windows)
if (containsNoCase(w.title, needle) || containsNoCase(w.appId, needle)) return std::optional{std::move(w)};
return std::optional<WindowInfo>{};
}
} // namespace alvin
+68
View File
@@ -0,0 +1,68 @@
#include <algorithm>
#include <cmath>
#include <cstring>
#include <fstream>
#include "alvin/frame.hpp"
namespace alvin {
Frame::Frame(int width, int height)
: width_(std::max(width, 0)),
height_(std::max(height, 0)),
data_(static_cast<std::size_t>(width_) * height_ * 4, 0),
desktopRect_{0, 0, width_, height_} {}
Color Frame::pixel(int x, int y) const {
if (x < 0 || y < 0 || x >= width_ || y >= height_) return {};
const std::uint8_t* p = row(y) + x * 4;
return {p[2], p[1], p[0], p[3]};
}
Point Frame::toDesktop(Point px) const {
if (width_ == 0 || height_ == 0) return px;
double sx = static_cast<double>(desktopRect_.width) / width_;
double sy = static_cast<double>(desktopRect_.height) / height_;
return {desktopRect_.x + static_cast<int>(std::lround(px.x * sx)),
desktopRect_.y + static_cast<int>(std::lround(px.y * sy))};
}
Point Frame::fromDesktop(Point p) const {
if (desktopRect_.width == 0 || desktopRect_.height == 0) return p;
double sx = static_cast<double>(width_) / desktopRect_.width;
double sy = static_cast<double>(height_) / desktopRect_.height;
return {static_cast<int>(std::lround((p.x - desktopRect_.x) * sx)),
static_cast<int>(std::lround((p.y - desktopRect_.y) * sy))};
}
Frame Frame::crop(Rect r) const {
int x0 = std::clamp(r.x, 0, width_), y0 = std::clamp(r.y, 0, height_);
int x1 = std::clamp(r.right(), 0, width_), y1 = std::clamp(r.bottom(), 0, height_);
Frame out(x1 - x0, y1 - y0);
for (int y = 0; y < out.height_; ++y) std::memcpy(out.row(y), row(y0 + y) + x0 * 4, out.stride());
Point tl = toDesktop({x0, y0}), br = toDesktop({x1, y1});
out.desktopRect_ = {tl.x, tl.y, br.x - tl.x, br.y - tl.y};
out.timestampNs_ = timestampNs_;
return out;
}
Status Frame::savePpm(const std::filesystem::path& path) const {
std::ofstream f(path, std::ios::binary);
if (!f) return fail(ErrorCode::Io, "cannot open " + path.string());
f << "P6\n" << width_ << ' ' << height_ << "\n255\n";
std::vector<char> line(static_cast<std::size_t>(width_) * 3);
for (int y = 0; y < height_; ++y) {
const std::uint8_t* src = row(y);
for (int x = 0; x < width_; ++x) {
line[x * 3 + 0] = static_cast<char>(src[x * 4 + 2]);
line[x * 3 + 1] = static_cast<char>(src[x * 4 + 1]);
line[x * 3 + 2] = static_cast<char>(src[x * 4 + 0]);
}
f.write(line.data(), static_cast<std::streamsize>(line.size()));
}
if (!f) return fail(ErrorCode::Io, "write failed for " + path.string());
return {};
}
} // namespace alvin
+215
View File
@@ -0,0 +1,215 @@
#include "json.hpp"
#include <cctype>
#include <charconv>
#include <cstdlib>
namespace alvin::json {
namespace {
const Value kNull{};
const Array kEmptyArray{};
const Object kEmptyObject{};
class Parser {
public:
explicit Parser(std::string_view s) : s_(s) {}
std::optional<Value> document() {
auto v = value(0);
skipSpace();
if (!v || pos_ != s_.size()) return std::nullopt;
return v;
}
private:
static constexpr int kMaxDepth = 256;
void skipSpace() {
while (pos_ < s_.size() && std::isspace(static_cast<unsigned char>(s_[pos_]))) ++pos_;
}
bool consume(char c) {
skipSpace();
if (pos_ < s_.size() && s_[pos_] == c) {
++pos_;
return true;
}
return false;
}
bool literal(std::string_view word) {
if (s_.substr(pos_, word.size()) != word) return false;
pos_ += word.size();
return true;
}
std::optional<Value> value(int depth) {
if (depth > kMaxDepth) return std::nullopt;
skipSpace();
if (pos_ >= s_.size()) return std::nullopt;
char c = s_[pos_];
if (c == '{') return object(depth);
if (c == '[') return array(depth);
if (c == '"') {
auto str = string();
if (!str) return std::nullopt;
return Value{std::move(*str)};
}
if (literal("true")) return Value{true};
if (literal("false")) return Value{false};
if (literal("null")) return Value{nullptr};
return number();
}
std::optional<Value> number() {
std::size_t start = pos_;
while (pos_ < s_.size() && (std::isdigit(static_cast<unsigned char>(s_[pos_])) || s_[pos_] == '-' ||
s_[pos_] == '+' || s_[pos_] == '.' || s_[pos_] == 'e' || s_[pos_] == 'E'))
++pos_;
if (start == pos_) return std::nullopt;
std::string text(s_.substr(start, pos_ - start));
char* end = nullptr;
double d = std::strtod(text.c_str(), &end);
if (end != text.c_str() + text.size()) return std::nullopt;
return Value{d};
}
static void appendUtf8(std::string& out, char32_t cp) {
if (cp < 0x80) {
out += static_cast<char>(cp);
} else if (cp < 0x800) {
out += static_cast<char>(0xC0 | (cp >> 6));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
out += static_cast<char>(0xE0 | (cp >> 12));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else {
out += static_cast<char>(0xF0 | (cp >> 18));
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
}
}
std::optional<unsigned> hex4() {
if (pos_ + 4 > s_.size()) return std::nullopt;
unsigned v = 0;
auto r = std::from_chars(s_.data() + pos_, s_.data() + pos_ + 4, v, 16);
if (r.ptr != s_.data() + pos_ + 4) return std::nullopt;
pos_ += 4;
return v;
}
std::optional<std::string> string() {
if (!consume('"')) return std::nullopt;
std::string out;
while (pos_ < s_.size()) {
char c = s_[pos_++];
if (c == '"') return out;
if (c != '\\') {
out += c;
continue;
}
if (pos_ >= s_.size()) return std::nullopt;
switch (char e = s_[pos_++]) {
case '"': case '\\': case '/': out += e; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'n': out += '\n'; break;
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'u': {
auto hi = hex4();
if (!hi) return std::nullopt;
char32_t cp = *hi;
if (cp >= 0xD800 && cp < 0xDC00 && s_.substr(pos_, 2) == "\\u") {
pos_ += 2;
auto lo = hex4();
if (!lo) return std::nullopt;
cp = 0x10000 + ((cp - 0xD800) << 10) + (*lo - 0xDC00);
}
appendUtf8(out, cp);
break;
}
default: return std::nullopt;
}
}
return std::nullopt;
}
std::optional<Value> array(int depth) {
consume('[');
auto arr = std::make_shared<Array>();
if (consume(']')) return Value{arr};
do {
auto v = value(depth + 1);
if (!v) return std::nullopt;
arr->push_back(std::move(*v));
} while (consume(','));
if (!consume(']')) return std::nullopt;
return Value{arr};
}
std::optional<Value> object(int depth) {
consume('{');
auto obj = std::make_shared<Object>();
if (consume('}')) return Value{obj};
do {
skipSpace();
auto key = string();
if (!key || !consume(':')) return std::nullopt;
auto v = value(depth + 1);
if (!v) return std::nullopt;
obj->insert_or_assign(std::move(*key), std::move(*v));
} while (consume(','));
if (!consume('}')) return std::nullopt;
return Value{obj};
}
std::string_view s_;
std::size_t pos_ = 0;
};
} // namespace
bool Value::boolean(bool fallback) const {
auto* b = std::get_if<bool>(&v);
return b ? *b : fallback;
}
double Value::number(double fallback) const {
auto* d = std::get_if<double>(&v);
return d ? *d : fallback;
}
std::string Value::string(std::string fallback) const {
auto* s = std::get_if<std::string>(&v);
return s ? *s : fallback;
}
const Array& Value::array() const {
auto* a = std::get_if<std::shared_ptr<Array>>(&v);
return a ? **a : kEmptyArray;
}
const Object& Value::object() const {
auto* o = std::get_if<std::shared_ptr<Object>>(&v);
return o ? **o : kEmptyObject;
}
const Value& Value::operator[](std::string_view key) const {
const auto& o = object();
auto it = o.find(key);
return it == o.end() ? kNull : it->second;
}
const Value& Value::operator[](std::size_t index) const {
const auto& a = array();
return index < a.size() ? a[index] : kNull;
}
std::optional<Value> parse(std::string_view text) { return Parser(text).document(); }
} // namespace alvin::json
+42
View File
@@ -0,0 +1,42 @@
#pragma once
// A deliberately small JSON reader, enough for compositor IPC replies.
// Not a general-purpose library: no serialisation, numbers are doubles.
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <variant>
#include <vector>
namespace alvin::json {
struct Value;
using Array = std::vector<Value>;
using Object = std::map<std::string, Value, std::less<>>;
struct Value {
std::variant<std::nullptr_t, bool, double, std::string, std::shared_ptr<Array>, std::shared_ptr<Object>> v;
bool isNull() const { return std::holds_alternative<std::nullptr_t>(v); }
bool isObject() const { return std::holds_alternative<std::shared_ptr<Object>>(v); }
bool isArray() const { return std::holds_alternative<std::shared_ptr<Array>>(v); }
// Lenient accessors: return the fallback on type mismatch.
bool boolean(bool fallback = false) const;
double number(double fallback = 0) const;
std::string string(std::string fallback = {}) const;
const Array& array() const; // empty array if not an array
const Object& object() const; // empty object if not an object
// Member lookup; returns a null value if missing or not an object.
const Value& operator[](std::string_view key) const;
// Element lookup; returns a null value if out of range or not an array.
const Value& operator[](std::size_t index) const;
};
std::optional<Value> parse(std::string_view text);
} // namespace alvin::json
+125
View File
@@ -0,0 +1,125 @@
#include <array>
#include <cctype>
#include <string>
#include <utility>
#include "alvin/keys.hpp"
namespace alvin {
namespace {
struct NamedKey {
std::string_view name;
Key key;
};
// First entry for each key is its canonical name.
constexpr NamedKey kNames[] = {
{"a", Key::A}, {"b", Key::B}, {"c", Key::C}, {"d", Key::D}, {"e", Key::E}, {"f", Key::F},
{"g", Key::G}, {"h", Key::H}, {"i", Key::I}, {"j", Key::J}, {"k", Key::K}, {"l", Key::L},
{"m", Key::M}, {"n", Key::N}, {"o", Key::O}, {"p", Key::P}, {"q", Key::Q}, {"r", Key::R},
{"s", Key::S}, {"t", Key::T}, {"u", Key::U}, {"v", Key::V}, {"w", Key::W}, {"x", Key::X},
{"y", Key::Y}, {"z", Key::Z},
{"0", Key::Num0}, {"1", Key::Num1}, {"2", Key::Num2}, {"3", Key::Num3}, {"4", Key::Num4},
{"5", Key::Num5}, {"6", Key::Num6}, {"7", Key::Num7}, {"8", Key::Num8}, {"9", Key::Num9},
{"f1", Key::F1}, {"f2", Key::F2}, {"f3", Key::F3}, {"f4", Key::F4}, {"f5", Key::F5},
{"f6", Key::F6}, {"f7", Key::F7}, {"f8", Key::F8}, {"f9", Key::F9}, {"f10", Key::F10},
{"f11", Key::F11}, {"f12", Key::F12}, {"f13", Key::F13}, {"f14", Key::F14}, {"f15", Key::F15},
{"f16", Key::F16}, {"f17", Key::F17}, {"f18", Key::F18}, {"f19", Key::F19}, {"f20", Key::F20},
{"f21", Key::F21}, {"f22", Key::F22}, {"f23", Key::F23}, {"f24", Key::F24},
{"escape", Key::Escape}, {"esc", Key::Escape},
{"enter", Key::Enter}, {"return", Key::Enter},
{"tab", Key::Tab},
{"backspace", Key::Backspace},
{"space", Key::Space},
{"minus", Key::Minus}, {"-", Key::Minus},
{"equal", Key::Equal}, {"=", Key::Equal},
{"leftbracket", Key::LeftBracket}, {"[", Key::LeftBracket},
{"rightbracket", Key::RightBracket}, {"]", Key::RightBracket},
{"backslash", Key::Backslash}, {"\\", Key::Backslash},
{"semicolon", Key::Semicolon}, {";", Key::Semicolon},
{"apostrophe", Key::Apostrophe}, {"'", Key::Apostrophe},
{"grave", Key::Grave}, {"`", Key::Grave},
{"comma", Key::Comma}, {",", Key::Comma},
{"period", Key::Period}, {".", Key::Period},
{"slash", Key::Slash}, {"/", Key::Slash},
{"capslock", Key::CapsLock}, {"numlock", Key::NumLock}, {"scrolllock", Key::ScrollLock},
{"shift", Key::LeftShift}, {"lshift", Key::LeftShift}, {"rshift", Key::RightShift},
{"ctrl", Key::LeftCtrl}, {"control", Key::LeftCtrl}, {"lctrl", Key::LeftCtrl}, {"rctrl", Key::RightCtrl},
{"alt", Key::LeftAlt}, {"lalt", Key::LeftAlt}, {"ralt", Key::RightAlt}, {"altgr", Key::RightAlt},
{"super", Key::LeftSuper}, {"win", Key::LeftSuper}, {"meta", Key::LeftSuper}, {"cmd", Key::LeftSuper},
{"lsuper", Key::LeftSuper}, {"rsuper", Key::RightSuper},
{"menu", Key::Menu},
{"insert", Key::Insert}, {"delete", Key::Delete}, {"del", Key::Delete},
{"home", Key::Home}, {"end", Key::End},
{"pageup", Key::PageUp}, {"pgup", Key::PageUp}, {"pagedown", Key::PageDown}, {"pgdn", Key::PageDown},
{"left", Key::Left}, {"right", Key::Right}, {"up", Key::Up}, {"down", Key::Down},
{"printscreen", Key::PrintScreen}, {"print", Key::PrintScreen}, {"pause", Key::Pause},
{"kp0", Key::Kp0}, {"kp1", Key::Kp1}, {"kp2", Key::Kp2}, {"kp3", Key::Kp3}, {"kp4", Key::Kp4},
{"kp5", Key::Kp5}, {"kp6", Key::Kp6}, {"kp7", Key::Kp7}, {"kp8", Key::Kp8}, {"kp9", Key::Kp9},
{"kpdecimal", Key::KpDecimal}, {"kpdivide", Key::KpDivide}, {"kpmultiply", Key::KpMultiply},
{"kpsubtract", Key::KpSubtract}, {"kpadd", Key::KpAdd}, {"kpenter", Key::KpEnter},
{"mute", Key::VolumeMute}, {"volumedown", Key::VolumeDown}, {"volumeup", Key::VolumeUp},
{"playpause", Key::MediaPlayPause}, {"stop", Key::MediaStop},
{"next", Key::MediaNext}, {"previous", Key::MediaPrevious}, {"prev", Key::MediaPrevious},
};
std::string lower(std::string_view s) {
std::string out(s);
for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return out;
}
} // namespace
std::optional<Key> keyFromName(std::string_view name) {
auto n = lower(name);
for (const auto& e : kNames)
if (e.name == n) return e.key;
return std::nullopt;
}
std::string_view keyName(Key key) {
for (const auto& e : kNames)
if (e.key == key) return e.name;
return "unknown";
}
std::optional<std::vector<Key>> parseChord(std::string_view chord) {
// "ctrl++" means ctrl plus the '+' key, which is the Equal key position on US layouts.
bool plusKey = false;
if (chord == "+") return std::vector<Key>{Key::Equal};
if (chord.ends_with("++")) {
plusKey = true;
chord.remove_suffix(2);
}
std::vector<Key> keys;
while (true) {
auto end = chord.find('+');
auto token = chord.substr(0, end);
while (!token.empty() && std::isspace(static_cast<unsigned char>(token.front()))) token.remove_prefix(1);
while (!token.empty() && std::isspace(static_cast<unsigned char>(token.back()))) token.remove_suffix(1);
auto key = keyFromName(token);
if (!key) return std::nullopt;
keys.push_back(*key);
if (end == std::string_view::npos) break;
chord.remove_prefix(end + 1);
}
if (plusKey) keys.push_back(Key::Equal);
return keys;
}
bool isModifier(Key key) {
switch (key) {
case Key::LeftShift: case Key::RightShift:
case Key::LeftCtrl: case Key::RightCtrl:
case Key::LeftAlt: case Key::RightAlt:
case Key::LeftSuper: case Key::RightSuper:
return true;
default:
return false;
}
}
} // namespace alvin
+20
View File
@@ -0,0 +1,20 @@
#pragma once
// Entry points every platform directory (src/linux, src/windows, ...) implements.
#include <memory>
#include <vector>
#include "alvin/capture.hpp"
#include "alvin/input.hpp"
#include "alvin/types.hpp"
#include "alvin/window.hpp"
namespace alvin::detail {
Result<std::unique_ptr<Input>> createInput(const InputOptions& options);
Result<std::unique_ptr<Capture>> createCapture(const CaptureOptions& options);
Result<std::unique_ptr<WindowManager>> createWindowManager(WindowBackend backend);
Result<std::vector<Monitor>> listMonitors();
} // namespace alvin::detail