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:
1 parent
76ff506fd0
commit
ca7e62ac8d
59 files changed
+6515
-1
No files matched your search
@@ -0,0 +1,22 @@
|
||||
find_package(Catch2 3 QUIET)
|
||||
if(NOT Catch2_FOUND)
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(Catch2
|
||||
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
|
||||
GIT_TAG v3.7.1)
|
||||
FetchContent_MakeAvailable(Catch2)
|
||||
endif()
|
||||
|
||||
add_executable(alvin_tests
|
||||
test_core.cpp
|
||||
test_json.cpp)
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
target_sources(alvin_tests PRIVATE test_linux.cpp)
|
||||
target_link_libraries(alvin_tests PRIVATE PkgConfig::XKBCOMMON)
|
||||
endif()
|
||||
# Tests exercise internal helpers too, so they see the private source tree.
|
||||
target_include_directories(alvin_tests PRIVATE ${PROJECT_SOURCE_DIR}/src)
|
||||
target_link_libraries(alvin_tests PRIVATE alvin::alvin Catch2::Catch2WithMain)
|
||||
|
||||
include(Catch)
|
||||
catch_discover_tests(alvin_tests)
|
||||
@@ -0,0 +1,66 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "alvin/frame.hpp"
|
||||
#include "alvin/keys.hpp"
|
||||
#include "alvin/types.hpp"
|
||||
|
||||
using namespace alvin;
|
||||
|
||||
TEST_CASE("key names round-trip and aliases resolve") {
|
||||
CHECK(keyFromName("A") == Key::A);
|
||||
CHECK(keyFromName("ctrl") == Key::LeftCtrl);
|
||||
CHECK(keyFromName("Control") == Key::LeftCtrl);
|
||||
CHECK(keyFromName("esc") == Key::Escape);
|
||||
CHECK(keyFromName("F12") == Key::F12);
|
||||
CHECK_FALSE(keyFromName("nope"));
|
||||
for (int k = 1; k < static_cast<int>(Key::Count_); ++k) {
|
||||
auto key = static_cast<Key>(k);
|
||||
INFO("key index " << k);
|
||||
CHECK(keyFromName(keyName(key)) == key);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("chords parse in order") {
|
||||
auto c = parseChord("ctrl+shift+t");
|
||||
REQUIRE(c);
|
||||
CHECK(*c == std::vector{Key::LeftCtrl, Key::LeftShift, Key::T});
|
||||
|
||||
CHECK(parseChord(" super + enter ") == std::vector{Key::LeftSuper, Key::Enter});
|
||||
CHECK(parseChord("ctrl++") == std::vector{Key::LeftCtrl, Key::Equal});
|
||||
CHECK_FALSE(parseChord("ctrl+"));
|
||||
CHECK_FALSE(parseChord(""));
|
||||
CHECK_FALSE(parseChord("ctrl+bogus"));
|
||||
}
|
||||
|
||||
TEST_CASE("rect union ignores empty rects") {
|
||||
Rect a{0, 0, 10, 10}, b{20, -5, 5, 5};
|
||||
CHECK(a.united(b) == Rect{0, -5, 25, 15});
|
||||
CHECK(Rect{}.united(a) == a);
|
||||
CHECK(a.united(Rect{}) == a);
|
||||
CHECK(a.contains({9, 9}));
|
||||
CHECK_FALSE(a.contains({10, 0}));
|
||||
}
|
||||
|
||||
TEST_CASE("frame maps between pixels and desktop coordinates") {
|
||||
// A 1.5x scaled monitor: 300x150 pixels covering 200x100 logical at (1000, 50).
|
||||
Frame f(300, 150);
|
||||
f.setDesktopRect({1000, 50, 200, 100});
|
||||
CHECK(f.toDesktop({0, 0}) == Point{1000, 50});
|
||||
CHECK(f.toDesktop({150, 75}) == Point{1100, 100});
|
||||
CHECK(f.fromDesktop({1100, 100}) == Point{150, 75});
|
||||
|
||||
Frame c = f.crop({150, 0, 1000, 75}); // clipped to the frame
|
||||
CHECK(c.width() == 150);
|
||||
CHECK(c.height() == 75);
|
||||
CHECK(c.desktopRect() == Rect{1100, 50, 100, 50});
|
||||
}
|
||||
|
||||
TEST_CASE("frame pixel reads BGRA") {
|
||||
Frame f(2, 1);
|
||||
f.data()[4] = 1; // B
|
||||
f.data()[5] = 2; // G
|
||||
f.data()[6] = 3; // R
|
||||
f.data()[7] = 4; // A
|
||||
CHECK(f.pixel(1, 0) == Color{3, 2, 1, 4});
|
||||
CHECK(f.pixel(5, 5) == Color{});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "common/json.hpp"
|
||||
|
||||
using namespace alvin;
|
||||
|
||||
TEST_CASE("json parses a Hyprland client entry") {
|
||||
auto v = json::parse(R"([{
|
||||
"address": "0x55a4165aec60",
|
||||
"at": [2903, 58], "size": [2834, 1539],
|
||||
"workspace": {"id": 8, "name": "8"},
|
||||
"hidden": false, "title": "Café \"quoted\"", "pid": 3163
|
||||
}])");
|
||||
REQUIRE(v);
|
||||
const auto& c = (*v)[0];
|
||||
CHECK(c["address"].string() == "0x55a4165aec60");
|
||||
CHECK(c["at"][0].number() == 2903);
|
||||
CHECK(c["size"][1].number() == 1539);
|
||||
CHECK(c["workspace"]["name"].string() == "8");
|
||||
CHECK(c["hidden"].boolean(true) == false);
|
||||
CHECK(c["title"].string() == "Caf\xc3\xa9 \"quoted\"");
|
||||
CHECK(c["missing"].isNull());
|
||||
CHECK(c["missing"]["deeper"].isNull());
|
||||
}
|
||||
|
||||
TEST_CASE("json rejects malformed input") {
|
||||
CHECK_FALSE(json::parse("{"));
|
||||
CHECK_FALSE(json::parse("[1,]"));
|
||||
CHECK_FALSE(json::parse("{\"a\" 1}"));
|
||||
CHECK_FALSE(json::parse("[1] trailing"));
|
||||
CHECK(json::parse(" {} "));
|
||||
CHECK(json::parse("\"\\ud83d\\ude00\"")->string() == "\xf0\x9f\x98\x80");
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <linux/input-event-codes.h>
|
||||
|
||||
#include "linux/keycodes.hpp"
|
||||
#include "linux/xkb.hpp"
|
||||
|
||||
using namespace alvin;
|
||||
using namespace alvin::linux_impl;
|
||||
|
||||
TEST_CASE("utf8 decoding") {
|
||||
auto t = decodeUtf8("a\xc3\xa9\xe2\x82\xac\xf0\x9f\x98\x80");
|
||||
REQUIRE(t);
|
||||
CHECK(*t == U"aé€\U0001F600");
|
||||
CHECK_FALSE(decodeUtf8("\xc3"));
|
||||
CHECK_FALSE(decodeUtf8("\xff"));
|
||||
}
|
||||
|
||||
TEST_CASE("us layout strokes") {
|
||||
auto km = XkbKeymap::fromNames("us");
|
||||
REQUIRE(km);
|
||||
|
||||
auto a = km->strokeFor(U'a');
|
||||
REQUIRE(a);
|
||||
CHECK(a->key == KEY_A);
|
||||
CHECK(a->modifiers.empty());
|
||||
|
||||
auto upper = km->strokeFor(U'A');
|
||||
REQUIRE(upper);
|
||||
CHECK(upper->key == KEY_A);
|
||||
CHECK(upper->modifiers == std::vector<std::uint32_t>{KEY_LEFTSHIFT});
|
||||
|
||||
auto bang = km->strokeFor(U'!');
|
||||
REQUIRE(bang);
|
||||
CHECK(bang->key == KEY_1);
|
||||
|
||||
CHECK(km->strokeFor(U'\n')->key == KEY_ENTER);
|
||||
CHECK_FALSE(km->strokeFor(U'é')); // not on plain US
|
||||
CHECK_FALSE(km->strokesFor(U"oké"));
|
||||
}
|
||||
|
||||
TEST_CASE("synthesized keymaps compile and map codes 1..n") {
|
||||
std::u32string alphabet = U"hé\U0001F600\n";
|
||||
auto source = synthesizeKeymap(alphabet);
|
||||
REQUIRE(source);
|
||||
auto km = XkbKeymap::fromString(*source);
|
||||
REQUIRE(km);
|
||||
for (std::size_t i = 0; i < alphabet.size(); ++i) {
|
||||
auto s = km->strokeFor(alphabet[i]);
|
||||
REQUIRE(s);
|
||||
CHECK(s->key == i + 1);
|
||||
CHECK(s->modifiers.empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("every key maps to an evdev code") {
|
||||
for (int k = 1; k < static_cast<int>(Key::Count_); ++k) {
|
||||
INFO("key index " << k);
|
||||
CHECK(toEvdev(static_cast<Key>(k)) != 0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user