diff --git a/.github/workflows/linux-native-cairo-build.yml b/.github/workflows/linux-native-cairo-build.yml new file mode 100644 index 00000000..aaf6f591 --- /dev/null +++ b/.github/workflows/linux-native-cairo-build.yml @@ -0,0 +1,84 @@ +name: Linux native Cairo build + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: linux-cairo-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install native dependencies + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends \ + build-essential cmake ninja-build pkg-config \ + libcairo2-dev libx11-dev xvfb + + - name: Configure + run: >- + cmake -S . -B build -G Ninja + -DCMAKE_BUILD_TYPE=Release + -DEGE_DEFAULT_BACKEND=CAIRO + -DEGE_BUILD_DEMO=ON + -DEGE_BUILD_TEMP=OFF + -DEGE_BUILD_TEST=ON + -DEGE_ENABLE_WINDOW_TESTS=ON + -DEGE_ENABLE_CAMERA_TESTS=ON + -DEGE_ENABLE_CAMERA_CAPTURE=ON + + - name: Build + run: cmake --build build --parallel 2 + + - name: Build all portable demos + run: | + cmake --build build --target demos --parallel 2 + test -x build/demo/camera_base + test -x build/demo/camera_wave + + - name: Build ccap tests and CLI + run: | + cmake -S 3rdparty/ccap -B build/ccap-tests -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCCAP_BUILD_CLI=ON \ + -DCCAP_BUILD_TESTS=ON \ + -DCCAP_BUILD_EXAMPLES=OFF \ + -DCCAP_ENABLE_FILE_PLAYBACK=OFF \ + -DCCAP_ENABLE_VIDEO_WRITER=OFF + cmake --build build/ccap-tests --parallel 2 + + - name: Test ccap, headless rendering and X11 integration + run: | + sudo install -m 666 /dev/null /dev/video99 + trap 'sudo unlink /dev/video99' EXIT + export EGE_TEST_V4L2_PATH=/dev/video99 + export LD_PRELOAD="$PWD/build/tests/native/libege_native_fake_v4l2.so" + ctest --test-dir build/ccap-tests --output-on-failure + unset LD_PRELOAD + xvfb-run -a ctest --test-dir build --output-on-failure + + - name: Enforce lightweight GUI dependency boundary + shell: bash + run: | + dependencies="$(ldd build/tests/native/ege_native_linux_window_smoke)" + echo "$dependencies" + if grep -Eiq 'lib(Qt|gtk|gdk|wx|SDL|glfw|webkit|electron|pango)' <<<"$dependencies"; then + echo "Unexpected GUI framework dependency detected" >&2 + exit 1 + fi + test -s build/libgraphics.a + size -t build/libgraphics.a | tail -1 diff --git a/BUILD.md b/BUILD.md index 8a0427ae..7cd4aefa 100644 --- a/BUILD.md +++ b/BUILD.md @@ -1,8 +1,8 @@ # EGE 编译指南 -EGE 源码使用 CMake 3.13 或更高版本构建。Windows 默认使用 GDI;macOS 现在可用 +EGE 源码使用 CMake 3.13 或更高版本构建。Windows 默认使用 GDI;macOS 可用 AppleClang 直接生成 Mach-O 原生程序,默认绘制后端为 Core Graphics,窗口后端为 -AppKit。macOS 原生构建不需要 MinGW、Wine 或 OpenGL。 +AppKit;Linux 默认使用 Cairo 绘制与 Xlib 窗口。原生构建不需要 MinGW、Wine 或 OpenGL。 EGE 的可选子模块由 Git submodule 管理。CMake 配置阶段不会访问网络或修改 源码目录。默认关闭 camera 时不需要拉取 `ccap`;如果需要 camera,请在配置前执行: @@ -12,8 +12,37 @@ git submodule update --init --recursive 3rdparty/ccap ``` 请安装 CMake 和目标平台的原生编译器。macOS 使用 Xcode Command Line Tools;Windows -使用 MSVC 或 MinGW-w64。Linux 主机仍可通过显式 toolchain 交叉编译 Windows 版,但 -Linux 原生 Cairo 后端尚未实现,配置会 fail-fast,不会默认生成依赖 Wine 的 `.exe`。 +使用 MSVC 或 MinGW-w64;Linux 使用 GCC/Clang、Cairo 与 X11 开发包。Linux 主机仍可 +通过显式 toolchain 交叉编译 Windows 版,但默认会生成原生 ELF。 + +## Linux 原生 Cairo/Xlib 构建 + +Debian/Ubuntu 安装依赖并构建: + +```sh +sudo apt-get install build-essential cmake ninja-build pkg-config libcairo2-dev libx11-dev +cmake -S . -B build/native-cairo -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DEGE_DEFAULT_BACKEND=CAIRO \ + -DEGE_ENABLE_OPENGL=OFF \ + -DEGE_ENABLE_CAMERA_CAPTURE=OFF \ + -DEGE_BUILD_TEST=ON \ + -DEGE_BUILD_DEMO=ON \ + -DEGE_BUILD_TEMP=OFF +cmake --build build/native-cairo --parallel +cmake --build build/native-cairo --target demos --parallel +ctest --test-dir build/native-cairo --output-on-failure +``` + +窗口集成测试额外需要 `xvfb`,配置时加入 +`-DEGE_ENABLE_WINDOW_TESTS=ON`,再运行 +`xvfb-run -a ctest --test-dir build/native-cairo --output-on-failure`。 +完整 Linux CI 验收还会递归检出 `ccap`,设置 +`-DEGE_ENABLE_CAMERA_CAPTURE=ON -DEGE_ENABLE_CAMERA_TESTS=ON`,并使用测试专用的 +用户态 V4L2 仿真器运行相机枚举、格式协商、mmap streaming、YUYV→BGRA 和 +`CameraFrame`/`IMAGE` 像素测试。仿真器不进入正式库,也不新增运行时依赖。 +默认只直接链接系统 `libcairo` 和 `libX11`,不链接 GTK、wxWidgets、SDL 或 Pango。 +更详细的设计与依赖取舍见 [Linux native backend](docs/linux-native-backend.md)。 ## macOS 原生 Core Graphics 构建 @@ -67,6 +96,9 @@ bash tasks.sh --debug --show-config 脚本会拒绝删除仓库根目录或无法确认属于本项目的自定义目录, 但调用前仍应检查 `--show-config` 输出。 +Linux 上同一脚本会显式选择 `CAIRO`,使用 `build/linux/Debug` 或 +`build/linux/Release`,`--run` 直接启动无扩展名 ELF,不再追加 `.exe` 或调用 Wine。 + `utils/release.sh` 在 macOS 上生成的 AppleClang 静态库位于 `Release/lib/macOS`。脚本分别构建 arm64/x86_64,用 `lipo` 合成 universal archive,检查每个 slice 的 macOS 11.0 标记,并使用与官方包相同的 @@ -89,14 +121,15 @@ Windows 专用的 `utils/release-msvc.sh`、`utils/release-mingw.sh` 和 `utils/release.sh` 的 macOS 路径也不清理工作树,并可用上述环境变量完全隔离输出。 `utils/test-run-demos.sh --directory ` 可在交互式桌面会话中启动 -已构建 demo:macOS 直接运行 Mach-O,Linux 对 Windows `.exe` 明确使用 Wine。 +已构建 demo:macOS 直接运行 Mach-O,Linux 优先直接运行原生 ELF;目录中只有 +Windows `.exe` 时才明确使用 Wine。 camera demo 默认排除,只有显式传 `--include-camera` 才会触发其权限/设备路径。 ### 逐像素 CPU buffer 语义 -macOS 后端以 CPU `PixelSurface` 作为图像像素的权威存储。`getbuffer()` 返回可直接 +macOS 与 Linux 后端都以 CPU `PixelSurface` 作为图像像素的权威存储。`getbuffer()` 返回可直接 读写的、从上到下排列的连续像素:每行是 `width * sizeof(color_t)` 字节,在小端 -macOS 上数值表示为 `0xAARRGGBB`,内存字节顺序为预乘 Alpha 的 BGRA。Core Graphics +平台上数值表示为 `0xAARRGGBB`,内存字节顺序为预乘 Alpha 的 BGRA。Core Graphics/Cairo 直接绘制到同一块 CPU buffer,所以常规逐像素读写不发生 GPU readback 或 CPU/GPU 双向同步。指针在对应 `IMAGE` 不重建、不缩放且不销毁期间有效。 @@ -134,9 +167,9 @@ sudo apt-get install mingw-w64 wine sudo pacman -S mingw-w64 wine ``` -Linux 原生后端尚未完成,因此 Linux 发布和 CI 继续通过 -`cmake/toolchains/mingw-w64.cmake` 显式生成 Windows 静态库及 `.exe`。macOS -不再支持或发布这条交叉编译路径。 +Linux 默认构建原生 Cairo/Xlib 后端。只有明确传入 +`-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/mingw-w64.cmake` 时才生成 Windows +静态库及 `.exe`。macOS 不再支持或发布这条交叉编译路径。 ## 基本编译步骤 diff --git a/CMakeLists.txt b/CMakeLists.txt index 274bde24..9b0ff959 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,6 +34,7 @@ if(EGE_CLEAR_OPTIONS_CACHE AND EGE_IS_ROOT_PROJECT) EGE_BUILD_DEMO EGE_BUILD_TEST EGE_ENABLE_WINDOW_TESTS + EGE_ENABLE_CAMERA_TESTS EGE_BUILD_TEMP EGE_DEFAULT_BACKEND EGE_ENABLE_OPENGL @@ -52,6 +53,8 @@ option(EGE_BUILD_DEMO "Build EGE demos" ${EGE_IS_ROOT_PROJECT}) option(EGE_BUILD_TEST "Build EGE tests" ${EGE_IS_ROOT_PROJECT}) option(EGE_ENABLE_WINDOW_TESTS "Register tests that create visible native windows (never enabled by default)" OFF) +option(EGE_ENABLE_CAMERA_TESTS + "Register tests that exercise a virtual native camera (never enabled by default)" OFF) option(EGE_BUILD_TEMP "Build EGE temporary programs" ${EGE_IS_ROOT_PROJECT}) if(NOT DEFINED EGE_DISABLE_DEBUG_INFO) diff --git a/README.md b/README.md index b1bc64fa..85ddc80a 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ [![MinGW Windows Build](https://github.com/x-ege/xege/actions/workflows/mingw-windows-build.yml/badge.svg)](https://github.com/x-ege/xege/actions/workflows/mingw-windows-build.yml) [![MinGW Linux Cross-Compile Build](https://github.com/x-ege/xege/actions/workflows/mingw-crosscompile-build.yml/badge.svg)](https://github.com/x-ege/xege/actions/workflows/mingw-crosscompile-build.yml) [![macOS Native CoreGraphics Build](https://github.com/x-ege/xege/actions/workflows/macos-native-coregraphics-build.yml/badge.svg)](https://github.com/x-ege/xege/actions/workflows/macos-native-coregraphics-build.yml) +[![Linux Native Cairo Build](https://github.com/x-ege/xege/actions/workflows/linux-native-cairo-build.yml/badge.svg)](https://github.com/x-ege/xege/actions/workflows/linux-native-cairo-build.yml) [![License](https://img.shields.io/badge/license-LGPL--2.1-blue.svg)](LICENSE) -[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS-lightgrey.svg)](https://github.com/x-ege/xege) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/x-ege/xege) EGE (Easy Graphics Engine) 是一个提供类似 BGI (`graphics.h`) 接口的简易绘图库, -支持 Windows GDI 和 macOS Core Graphics/AppKit 原生后端,专为 C/C++ 初学者设计。 +支持 Windows GDI、macOS Core Graphics/AppKit 和 Linux Cairo/Xlib 原生后端,专为 C/C++ 初学者设计。 > 原生 macOS 支持已合入 `master`,将在下一个正式 SDK 发布; > 已发布的 v25.11 不包含这个后端。当前可从源码构建或使用 CI 预览制品。 @@ -64,6 +65,7 @@ EGE 支持以下 开发工具/编译器: | Code::Blocks | 支持 | 已测试版本: 25.03 (最新) | | MinGW / MinGW-w64 | 支持 | ✅ 支持, SDK 发布时会基于最新版本进行测试 | | AppleClang / Xcode Command Line Tools | macOS | ✅ Core Graphics + AppKit 原生 Mach-O 构建,不需要 Wine | +| GCC / Clang | Linux | ✅ Cairo + Xlib 原生 ELF 构建;Wayland 会话通过 XWayland 运行 | | 老版本 Visual Studio | 2010 ~ 2015 | ⚠️ 支持,但不推荐(不支持 C++17) | | Visual C++ 6.0 | aka vc6.0 | ⚠️ 旧版支持,可从[官网](https://xege.org/install_and_config)下载内嵌版本 | | Dev-C++ | 支持 | ⚠️ 十年未更新, 不那么推荐
已测试版本: 5.11 (最新) | @@ -75,9 +77,9 @@ EGE 支持以下 开发工具/编译器: |------|----------|------| | Windows | 稳定 | GDI/GDI+ 后端,保留 HWND/HDC、资源和 `sys_edit` 等 Win32 能力 | | macOS 11.0+ | `master` 预览 | Core Graphics/AppKit 原生后端,支持 arm64/x86_64;`MUSIC` 使用 AVFAudio | -| Linux 原生 | 未实现 | Cairo 后端尚未完成;Linux CI 当前是 MinGW Windows 交叉编译 | +| Linux 原生 | `master` 预览 | Cairo/Xlib CPU 后端;只直接依赖系统 `libcairo` 与 `libX11`,Wayland 通过 XWayland | -macOS 上的 Win32 句柄/资源接口(如 `attachHWND`、`seticon(int)`、 +macOS/Linux 上的 Win32 句柄/资源接口(如 `attachHWND`、`seticon(int)`、 `getHDC`)只保留源码兼容签名,不等价于 Windows 资源模型;`sys_edit` 仍仅在 Windows 实现。`graph_star` 是 Win32 屏保 demo,不在 macOS 构建。 摄像头 demo 首次运行时会请求系统权限。 @@ -131,10 +133,10 @@ EGE 提供官方 IDE 插件,让项目配置更加简单: | 特点 | 说明 | |------|------| -| 零依赖轻量级 | 使用 `stb_image` 和 `sdefl/sinfl` 替代 `libpng`/`zlib`,无外部依赖,单库即可使用 | -| 直接像素访问 | `getbuffer` 返回 CPU 权威的顶向下、连续预乘 BGRA 像素;macOS Core Graphics 直接复用该 buffer,无 GPU readback | -| 抗锯齿支持 | Windows 使用 GDI+;macOS 的 Core Graphics 后端与 `ege_enable_aa` 共享抗锯齿状态 | -| 预乘 Alpha 优化 | 默认使用 PRGB32;Windows 可调用 `AlphaBlend`,macOS 在 CPU PixelSurface 上混合并由 Core Graphics 呈现 | +| 轻量依赖 | 图片编解码使用内置 `stb_image` 和 `sdefl/sinfl`;Linux 只链接系统 Cairo/X11,不引入 GUI 框架 | +| 直接像素访问 | `getbuffer` 返回 CPU 权威的顶向下、连续预乘 BGRA 像素;Core Graphics/Cairo 直接复用该 buffer,无 GPU readback | +| 抗锯齿支持 | Windows 使用 GDI+;macOS Core Graphics 与 Linux Cairo 后端共享 `ege_enable_aa` 状态 | +| 预乘 Alpha 优化 | 默认使用 PRGB32;Windows 可调用 `AlphaBlend`,macOS/Linux 在 CPU PixelSurface 上混合后原生呈现 | | 多图像格式支持 | 支持 PNG, JPEG, BMP, GIF, TGA, PSD, HDR 等常见图像格式 | | 灵活的图像操作 | 支持图像旋转、缩放、透明贴图、Alpha 滤镜等高级变换 | | 坐标变换系统 | 提供 `ege_transform_*` 系列函数,支持平移、旋转、缩放等矩阵变换 | @@ -162,8 +164,19 @@ cmake --build build/native --target xege `11.0` 是当前原生库的最低支持版本;可以显式设置更高值, 但发布制品和 CI 会固定在 11.0,避免继承构建机的最新 SDK 版本。 -Linux 原生 Cairo 后端尚未实现,当前会在 CMake 配置阶段 fail-fast。OpenGL -实验分支只作为构建分层和后端接口参考,不默认启用。 +Linux 原生最小构建为: + +```sh +sudo apt-get install build-essential cmake pkg-config libcairo2-dev libx11-dev +cmake -S . -B build/linux \ + -DEGE_DEFAULT_BACKEND=CAIRO \ + -DEGE_ENABLE_OPENGL=OFF \ + -DEGE_ENABLE_CAMERA_CAPTURE=OFF +cmake --build build/linux --target xege +``` + +实现边界、依赖与测试方法见 [Linux native backend](docs/linux-native-backend.md)。 +OpenGL 实验分支只作为构建分层和后端接口参考,不默认启用。 ## 社区与支持 diff --git a/cmake/EgeBackends.cmake b/cmake/EgeBackends.cmake index d27ce9fc..6ab00086 100644 --- a/cmake/EgeBackends.cmake +++ b/cmake/EgeBackends.cmake @@ -136,22 +136,28 @@ function(ege_configure_backend target) "${EGE_CORETEXT_FRAMEWORK}" "${EGE_IMAGEIO_FRAMEWORK}") elseif(EGE_RESOLVED_BACKEND STREQUAL "CAIRO") + set(_ege_cairo_required_sources + src/backend/linux/CairoRenderTarget.cpp + src/backend/linux/LinuxWindow.cpp) + foreach(_ege_source IN LISTS _ege_cairo_required_sources) + if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_ege_source}") + message(FATAL_ERROR + "The Cairo backend is incomplete: missing ${_ege_source}") + endif() + endforeach() _ege_add_existing_sources(${target} _ege_cairo_sources - src/backend/linux/CairoSurface.cpp src/backend/linux/CairoRenderTarget.cpp src/backend/linux/LinuxWindow.cpp ) - if(NOT _ege_cairo_sources) - message(FATAL_ERROR - "The Cairo backend is not implemented yet. Expected explicit " - "sources under src/backend/linux.") - endif() find_package(PkgConfig REQUIRED) pkg_check_modules(EGE_CAIRO REQUIRED IMPORTED_TARGET cairo) + pkg_check_modules(EGE_X11 REQUIRED IMPORTED_TARGET x11) target_compile_definitions(${target} PRIVATE EGE_BACKEND_CAIRO=1) target_include_directories(${target} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/backend/linux") - target_link_libraries(${target} PRIVATE PkgConfig::EGE_CAIRO) + target_link_libraries(${target} PRIVATE + PkgConfig::EGE_CAIRO + PkgConfig::EGE_X11) elseif(EGE_RESOLVED_BACKEND STREQUAL "OPENGL") target_compile_definitions(${target} PRIVATE EGE_BACKEND_OPENGL=1) endif() diff --git a/cmake/README.md b/cmake/README.md index 93a50aba..48add8f4 100644 --- a/cmake/README.md +++ b/cmake/README.md @@ -9,9 +9,10 @@ macOS/Linux 不再自动切换为 Windows 交叉编译。 | --- | --- | --- | | `EGE_BUILD_DEMO` | 根项目 ON | 生成 demo 目标 | | `EGE_BUILD_TEST` | 根项目 ON | 注册目标平台的 CTest 套件 | -| `EGE_ENABLE_WINDOW_TESTS` | OFF | 注册会显示窗口的 macOS 测试;不应在 headless CI 开启 | +| `EGE_ENABLE_WINDOW_TESTS` | OFF | 注册真实窗口测试;Linux CI 可在 Xvfb 中开启 | +| `EGE_ENABLE_CAMERA_TESTS` | OFF | 注册 Linux 用户态虚拟 V4L2 相机测试;需要同时启用 camera capture | | `EGE_BUILD_TEMP` | 根项目 ON | 构建 gitignored `temp/` 中的本地程序 | -| `EGE_DEFAULT_BACKEND` | `AUTO` | Windows→`GDI`,macOS→`COREGRAPHICS`;Linux `CAIRO` 尚未实现 | +| `EGE_DEFAULT_BACKEND` | `AUTO` | Windows→`GDI`,macOS→`COREGRAPHICS`,Linux→`CAIRO` | | `EGE_ENABLE_OPENGL` | OFF | 实验后端开关;当前源码不完整时 fail-fast | | `EGE_ENABLE_CAMERA_CAPTURE` | 动态 | C++17 且 ccap 子模块完整时 ON,否则 OFF | | `EGE_ENABLE_CPP17` | 编译器探测 | 启用 C++17 内部路径 | @@ -43,19 +44,19 @@ file build/native-coregraphics/demo/graph_5star `EGE_DEFAULT_BACKEND=AUTO` 的解析规则是:Windows 使用 `GDI`,macOS 使用 `COREGRAPHICS`,Linux 使用 `CAIRO`。也可以显式传入 `GDI|COREGRAPHICS|CAIRO|OPENGL`;平台与后端不兼容时,配置阶段会直接报错。 -当前 Linux Cairo 实现源码尚未接入,因此 Linux 原生 `AUTO`/`CAIRO` 会明确 -fail-fast;不会隐式改为 MinGW 并生成 `.exe`。 +Linux `CAIRO` 后端使用系统 Cairo 与 Xlib,直接生成原生 ELF;不会隐式改为 +MinGW 并生成 `.exe`。 OpenGL 不是原生构建的前提。只有明确传入 `-DEGE_ENABLE_OPENGL=ON` 时才会 查找 OpenGL 和 GLFW;把默认后端设为 `OPENGL` 时也必须同时启用该选项。 旧 OpenGL 分支只作为 CMake 分层与后端接口的参考;它不是默认后端,也不是 Core Graphics 构建依赖。当源码树没有 OpenGL 实现时,显式启用会 fail-fast。 -## macOS 像素内存契约 +## 原生像素内存契约 -Core Graphics 后端将 CPU `PixelSurface` 作为权威像素存储。内存为顶向下、紧密排列 +Core Graphics 与 Cairo 后端将 CPU `PixelSurface` 作为权威像素存储。内存为顶向下、紧密排列 的预乘 BGRA,小端数值为 `0xAARRGGBB`,stride 等于 `width * sizeof(color_t)`。 -`getbuffer()` 直接返回这块 CPU 内存,Core Graphics 也直接绘制到同一块内存,因而 +`getbuffer()` 直接返回这块 CPU 内存,原生渲染器也直接绘制到同一块内存,因而 逐像素读写不需要 GPU readback。在 `IMAGE` 重建、resize 或销毁后,应重新获取 指针。 @@ -84,3 +85,6 @@ git submodule update --init --recursive 3rdparty/ccap 未初始化 ccap 时,camera 默认关闭;如果显式设置 `EGE_ENABLE_CAMERA_CAPTURE=ON`,配置会给出可操作的错误信息。 +Linux CI 还会设置 `EGE_ENABLE_CAMERA_TESTS=ON`,通过测试专用的用户态 +V4L2 仿真器验证设备枚举、格式协商、流式抓帧和 `CameraFrame` 转换;该仿真器 +只编译进测试目标,不进入 `libgraphics.a` 或发布包。 diff --git a/demo/CMakeLists.txt b/demo/CMakeLists.txt index 2f57e5a5..1702dc11 100644 --- a/demo/CMakeLists.txt +++ b/demo/CMakeLists.txt @@ -46,6 +46,10 @@ macro(ege_add_executable name source) if(CMAKE_CXX_COMPILER_ID MATCHES "GNU") if(CMAKE_HOST_UNIX) target_compile_definitions(${name} PRIVATE _FORTIFY_SOURCE=0) + endif() + # The MinGW SDK demos are distributed as self-contained Windows binaries. + # Native Linux demos must remain dynamically linked to system Cairo/X11. + if(MINGW) target_link_options(${name} PRIVATE -static) endif() diff --git a/demo/camera_wave.cpp b/demo/camera_wave.cpp index 7da97de0..ebaf0d5c 100644 --- a/demo/camera_wave.cpp +++ b/demo/camera_wave.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include // 文本本地化宏定义 diff --git a/demo/game_gomoku.cpp b/demo/game_gomoku.cpp index 2d34001f..d4a1b3cc 100644 --- a/demo/game_gomoku.cpp +++ b/demo/game_gomoku.cpp @@ -8,6 +8,7 @@ */ #include "graphics.h" +#include #include /// 是否禁用音效. 如果存在编译问题, 可以把下面这行的值改成 0 @@ -734,4 +735,4 @@ int main() closegraph(); return 0; -} \ No newline at end of file +} diff --git a/docs/linux-native-backend.md b/docs/linux-native-backend.md new file mode 100644 index 00000000..48ebd502 --- /dev/null +++ b/docs/linux-native-backend.md @@ -0,0 +1,89 @@ +# Linux native backend + +## Decision + +XEGE uses a small native Linux stack: + +- **Xlib** owns windows, events, keyboard input, mouse input, cursors and frame presentation. +- **Cairo image surfaces** implement the `RenderTarget` drawing contract. +- **PixelSurface** remains the single CPU-authoritative premultiplied ARGB buffer used by `IMAGE`, `getbuffer()` and presentation. +- **XWayland** provides the initial Wayland compatibility path. A direct Wayland window adapter can be added later without replacing the renderer. + +This deliberately avoids Qt, wxWidgets, GTK, SDL, GLFW and Pango. The only direct Linux packages required by the default backend are the distribution-provided X11 and Cairo development packages. Camera capture remains the existing optional ccap feature. + +## Why this is the default + +| Choice | Direct framework dependencies | Binary/deployment impact | Fit for XEGE | +| --- | --- | --- | --- | +| Xlib + Cairo | `libX11`, `libcairo` | Uses common system libraries; no GUI framework payload | Selected: smallest implementation that preserves the CPU pixel contract | +| wxWidgets | wxWidgets plus GTK on common Linux builds | Large toolkit and transitive widget stack | Rejected: duplicates XEGE's window and drawing abstractions | +| GTK + Cairo | GTK, GLib/GObject and related desktop libraries | Cairo rendering fits, but the application inherits a full GUI runtime | Rejected: unnecessary widget/runtime surface | +| SDL2 + Cairo | SDL2 plus Cairo | Portable and convenient but adds another abstraction and runtime | Rejected: XEGE already owns its platform abstraction | +| GLFW + OpenGL | GLFW, OpenGL loader/driver stack | Good optional accelerated backend, not a minimal CPU default | Retained only as a future opt-in backend | +| Direct Wayland + Cairo | Wayland client, xkbcommon and protocol generation | Lean at runtime but considerably more lifecycle/input work | Deferred until native Wayland is worth the maintenance cost | + +The Linux backend adds no vendored code. Dynamic linking also prevents Cairo and X11 from being copied into XEGE's static archive or application package. Cairo's own distribution dependencies remain the operating system's responsibility. + +## Architecture + +1. Public EGE drawing calls resolve an `IMAGE` and its `RenderTarget`. +2. `CairoRenderTarget` draws directly into `PixelSurface`; there is no upload/download staging buffer. +3. Boolean ROP2 primitives render coverage into a reusable scratch surface, then perform exact straight-RGB operations while preserving destination alpha and restoring valid premultiplied pixels. +4. `LinuxWindow::present()` performs one row copy into an XImage and submits it with `XPutImage`. +5. Xlib events are converted to XEGE's existing Win32-compatible virtual-key, mouse and Unicode event contract. + +The first version intentionally favors a small, inspectable implementation over XShm or GPU acceleration. Those can be introduced behind the existing interfaces if profiling demonstrates a need. + +## Implemented surface + +- Window create/show/hide/title/position/resize/topmost/borderless/cursor control. +- WM close negotiation, resize, focus, keyboard, UTF-8 text, mouse buttons, double-clicks, motion and wheel events. +- CPU presentation and headless `EGE_HEADLESS=1` rendering. +- Lines and styles, fills and patterns, rectangles, rounded rectangles, ellipses, arcs, sectors, polygons and flood fills. +- Viewports, affine transforms, ROP2 writing modes and premultiplied alpha. +- Copy/stretch/transparent/alpha/rotate/affine image transfers and blur. +- Cairo toy-font text measurement and drawing, plus narrow/wide string consistency. +- Native input box with UTF-8 value handling. + +Cairo's toy-font API intentionally avoids a Pango dependency. It handles ordinary UTF-8 text but does not promise advanced script shaping or desktop font fallback. If that becomes a requirement, it should be an optional text module rather than a dependency of the native backend. + +## Build + +On Debian/Ubuntu: + +```sh +sudo apt-get install build-essential cmake ninja-build pkg-config libcairo2-dev libx11-dev +cmake -S . -B build -G Ninja \ + -DEGE_DEFAULT_BACKEND=CAIRO \ + -DEGE_ENABLE_CAMERA_CAPTURE=OFF +cmake --build build +``` + +For all native tests, including the X11 integration smoke test: + +```sh +sudo apt-get install xvfb +cmake -S . -B build -G Ninja \ + -DEGE_DEFAULT_BACKEND=CAIRO \ + -DEGE_BUILD_TEST=ON \ + -DEGE_ENABLE_WINDOW_TESTS=ON \ + -DEGE_ENABLE_CAMERA_CAPTURE=ON \ + -DEGE_ENABLE_CAMERA_TESTS=ON +cmake --build build +sudo install -m 666 /dev/null /dev/video99 +xvfb-run -a ctest --test-dir build --output-on-failure +sudo unlink /dev/video99 +``` + +The camera test preloads a test-only userspace V4L2 implementation for +`/dev/video99`. It exercises enumeration, format negotiation, mmap streaming, +YUYV-to-BGRA conversion and the public EGE camera/image bridge without loading a +kernel module or adding a runtime dependency. The Linux workflow also builds the +ccap CLI and runs its complete test suite against the same virtual device. + +## Packaging and compatibility + +- The default is dynamically linked to system Cairo and X11. Applications do not ship wxWidgets/GTK/SDL or an embedded browser/runtime. +- X11 desktops run directly. Wayland desktops run through the widely available XWayland compatibility server. +- The static `libgraphics.a` contains only XEGE code; system library code is resolved when the application links. +- `EGE_HEADLESS=1` skips the X connection while retaining the same Cairo rendering path for tests and server-side image generation. diff --git a/src/backend/linux/CairoRenderTarget.cpp b/src/backend/linux/CairoRenderTarget.cpp new file mode 100644 index 00000000..c6d969fe --- /dev/null +++ b/src/backend/linux/CairoRenderTarget.cpp @@ -0,0 +1,1284 @@ +#include "backend/linux/CairoRenderTarget.h" +#include "encodeconv.h" + +#include +#include +#include +#include +#include +#include + +namespace ege +{ +namespace backend +{ +namespace +{ + +constexpr double kPi = 3.14159265358979323846; + +unsigned int channel(color_t color, unsigned int shift) noexcept +{ + return (color >> shift) & 0xFFU; +} + +unsigned int scaleByte(unsigned int value, unsigned int factor) noexcept +{ + return (value * factor + 127U) / 255U; +} + +color_t pack(unsigned int alpha, unsigned int red, unsigned int green, unsigned int blue) noexcept +{ + return (std::min(255U, alpha) << 24U) | + (std::min(255U, red) << 16U) | + (std::min(255U, green) << 8U) | + std::min(255U, blue); +} + +bool patternUsesForeground(FillStyle style, int x, int y) noexcept +{ + const int slash = (x + y) & 7; + const int backslash = (x - y) & 7; + switch (style) { + case FILL_EMPTY: return false; + case FILL_HORIZONTAL: return (y & 7) == 0; + case FILL_LIGHT_SLASH: return slash == 0; + case FILL_SLASH: return slash <= 1; + case FILL_BACKSLASH: return backslash <= 1; + case FILL_LIGHT_BACKSLASH: return backslash == 0; + case FILL_HATCH: return (x & 7) == 0 || (y & 7) == 0; + case FILL_CROSS_HATCH: return slash <= 1 || backslash <= 1; + case FILL_INTERLEAVE: + return ((y & 7) == 0 && (x & 7) < 4) || ((y & 7) == 4 && (x & 7) >= 4); + case FILL_WIDE_DOT: return (x & 7) == 0 && (y & 7) == 0; + case FILL_CLOSE_DOT: return (x & 3) == 0 && (y & 3) == 0; + case FILL_USER: + case FILL_SOLID: + default: return true; + } +} + +void setSourceColor(cairo_t* context, color_t color, bool storedPremultiplied = false) +{ + const unsigned int alpha = channel(color, 24); + double red = channel(color, 16) / 255.0; + double green = channel(color, 8) / 255.0; + double blue = channel(color, 0) / 255.0; + if (storedPremultiplied && alpha != 0) { + red = std::min(1.0, red * 255.0 / alpha); + green = std::min(1.0, green * 255.0 / alpha); + blue = std::min(1.0, blue * 255.0 / alpha); + } + cairo_set_source_rgba(context, red, green, blue, alpha / 255.0); +} + +cairo_line_cap_t lineCap(RTLineCap cap) +{ + switch (cap) { + case RT_LINECAP_ROUND: return CAIRO_LINE_CAP_ROUND; + case RT_LINECAP_SQUARE: return CAIRO_LINE_CAP_SQUARE; + default: return CAIRO_LINE_CAP_BUTT; + } +} + +cairo_line_join_t lineJoin(RTLineJoin join) +{ + switch (join) { + case RT_LINEJOIN_BEVEL: return CAIRO_LINE_JOIN_BEVEL; + case RT_LINEJOIN_ROUND: return CAIRO_LINE_JOIN_ROUND; + default: return CAIRO_LINE_JOIN_MITER; + } +} + +} // namespace + +CairoRenderTarget::CairoRenderTarget(int width, int height, bool onScreen) : + onScreen_(onScreen), viewportRight_(width), viewportBottom_(height) +{ + transforms_.push_back({1.0, 0.0, 0.0, 1.0, 0.0, 0.0}); + if (!resize(width, height, false)) { + throw std::runtime_error("Unable to create CairoRenderTarget"); + } +} + +CairoRenderTarget::~CairoRenderTarget() +{ + if (rasterCairo_ != nullptr) cairo_destroy(rasterCairo_); + if (rasterSurface_ != nullptr) cairo_surface_destroy(rasterSurface_); + if (cairo_ != nullptr) cairo_destroy(cairo_); + if (cairoSurface_ != nullptr) cairo_surface_destroy(cairoSurface_); +} + +bool CairoRenderTarget::valid() const noexcept +{ + return surface_ != nullptr && cairoSurface_ != nullptr && cairo_ != nullptr && + cairo_surface_status(cairoSurface_) == CAIRO_STATUS_SUCCESS && + cairo_status(cairo_) == CAIRO_STATUS_SUCCESS; +} + +void CairoRenderTarget::recreateCairoSurface() +{ + if (rasterCairo_ != nullptr) { cairo_destroy(rasterCairo_); rasterCairo_ = nullptr; } + if (rasterSurface_ != nullptr) { cairo_surface_destroy(rasterSurface_); rasterSurface_ = nullptr; } + if (cairo_ != nullptr) { cairo_destroy(cairo_); cairo_ = nullptr; } + if (cairoSurface_ != nullptr) { cairo_surface_destroy(cairoSurface_); cairoSurface_ = nullptr; } + + cairoSurface_ = cairo_image_surface_create_for_data( + reinterpret_cast(surface_->data()), CAIRO_FORMAT_ARGB32, + getWidth(), getHeight(), static_cast(surface_->strideBytes())); + cairo_ = cairo_create(cairoSurface_); + + rasterScratch_.reset(new PixelSurface(surface_->width(), surface_->height())); + rasterSurface_ = cairo_image_surface_create_for_data( + reinterpret_cast(rasterScratch_->data()), CAIRO_FORMAT_ARGB32, + getWidth(), getHeight(), static_cast(rasterScratch_->strideBytes())); + rasterCairo_ = cairo_create(rasterSurface_); +} + +bool CairoRenderTarget::resize(int width, int height, bool preservePixels) +{ + if (width <= 0 || height <= 0 || + width > std::numeric_limits::max() / static_cast(sizeof(color_t))) { + return false; + } + try { + std::unique_ptr replacement( + new PixelSurface(static_cast(width), static_cast(height))); + if (preservePixels && surface_ != nullptr) { + const std::size_t copyWidth = std::min(replacement->width(), surface_->width()); + const std::size_t copyHeight = std::min(replacement->height(), surface_->height()); + for (std::size_t y = 0; y < copyHeight; ++y) { + std::memcpy(replacement->row(y), surface_->row(y), copyWidth * sizeof(color_t)); + } + } + surface_ = std::move(replacement); + recreateCairoSurface(); + viewportLeft_ = 0; + viewportTop_ = 0; + viewportRight_ = width; + viewportBottom_ = height; + return valid(); + } catch (...) { + return false; + } +} + +int CairoRenderTarget::getWidth() const +{ + return surface_ != nullptr ? static_cast(surface_->width()) : 0; +} + +int CairoRenderTarget::getHeight() const +{ + return surface_ != nullptr ? static_cast(surface_->height()) : 0; +} + +bool CairoRenderTarget::isOnScreen() const { return onScreen_; } + +void CairoRenderTarget::setLineColor(color_t color) { lineColor_ = color; } +void CairoRenderTarget::setFillColor(color_t color) { fillColor_ = color; fillPatternColor_ = color; } +void CairoRenderTarget::setTextColor(color_t color) { textColor_ = color; } +void CairoRenderTarget::setBkColor(color_t color) { backgroundColor_ = color; } +void CairoRenderTarget::setBkMode(bool opaque) { backgroundOpaque_ = opaque; } +void CairoRenderTarget::setLineWidth(float width) { lineWidth_ = std::max(1.0f, width); } + +void CairoRenderTarget::setLineStyle(LineStyle style, unsigned short pattern, int thickness) +{ + lineStyle_ = style; + linePattern_ = pattern; + lineThickness_ = std::max(1, thickness); + lineWidth_ = static_cast(lineThickness_); +} + +void CairoRenderTarget::setLineCap(RTLineCap startCap, RTLineCap endCap) +{ + startCap_ = startCap; + endCap_ = endCap; +} + +void CairoRenderTarget::setLineJoin(RTLineJoin join, float miterLimit) +{ + lineJoin_ = join; + miterLimit_ = std::max(1.0f, miterLimit); +} + +void CairoRenderTarget::setFillStyle(FillStyle style, color_t color) +{ + fillStyle_ = style; + fillPatternColor_ = color; + fillColor_ = color; +} + +void CairoRenderTarget::setRasterOp(RasterOp operation) { rasterOp_ = operation; } + +void CairoRenderTarget::setWritingMode(int mode) +{ + writingMode_ = mode; + if (mode >= static_cast(ROP_BLACK) && mode <= static_cast(ROP_WHITE)) { + rasterOp_ = static_cast(mode); + } +} + +void CairoRenderTarget::setAntialiasing(bool enabled) { antialiasing_ = enabled; } +color_t CairoRenderTarget::getLineColor() const { return lineColor_; } +color_t CairoRenderTarget::getFillColor() const { return fillColor_; } +color_t CairoRenderTarget::getTextColor() const { return textColor_; } +color_t CairoRenderTarget::getBkColor() const { return backgroundColor_; } +FillStyle CairoRenderTarget::getFillStyle() const { return fillStyle_; } + +void CairoRenderTarget::setViewport(int left, int top, int right, int bottom, bool clip) +{ + viewportLeft_ = left; + viewportTop_ = top; + viewportRight_ = right; + viewportBottom_ = bottom; + viewportClip_ = clip; +} + +void CairoRenderTarget::getViewport(int* left, int* top, int* right, int* bottom, int* clip) const +{ + if (left != nullptr) *left = viewportLeft_; + if (top != nullptr) *top = viewportTop_; + if (right != nullptr) *right = viewportRight_; + if (bottom != nullptr) *bottom = viewportBottom_; + if (clip != nullptr) *clip = viewportClip_ ? 1 : 0; +} + +void CairoRenderTarget::clearViewport() +{ + const int left = std::clamp(viewportLeft_, 0, getWidth()); + const int top = std::clamp(viewportTop_, 0, getHeight()); + const int right = std::clamp(viewportRight_, left, getWidth()); + const int bottom = std::clamp(viewportBottom_, top, getHeight()); + const color_t stored = premultiply(backgroundColor_); + cairo_surface_flush(cairoSurface_); + for (int y = top; y < bottom; ++y) { + std::fill(surface_->row(static_cast(y)) + left, + surface_->row(static_cast(y)) + right, stored); + } + cairo_surface_mark_dirty_rectangle(cairoSurface_, left, top, right - left, bottom - top); +} + +void CairoRenderTarget::pushTransform() { transforms_.push_back(transforms_.back()); } +void CairoRenderTarget::popTransform() { if (transforms_.size() > 1) transforms_.pop_back(); } +void CairoRenderTarget::resetTransform() { transforms_.back() = {1, 0, 0, 1, 0, 0}; } + +void CairoRenderTarget::translate(float dx, float dy) +{ + auto& m = transforms_.back(); + m[4] += m[0] * dx + m[2] * dy; + m[5] += m[1] * dx + m[3] * dy; +} + +void CairoRenderTarget::rotate(float angle) +{ + auto& m = transforms_.back(); + const double c = std::cos(angle), s = std::sin(angle); + const std::array old = m; + m[0] = old[0] * c + old[2] * s; + m[1] = old[1] * c + old[3] * s; + m[2] = old[2] * c - old[0] * s; + m[3] = old[3] * c - old[1] * s; +} + +void CairoRenderTarget::scale(float sx, float sy) +{ + auto& m = transforms_.back(); + m[0] *= sx; m[1] *= sx; m[2] *= sy; m[3] *= sy; +} + +void CairoRenderTarget::setTransformMatrix(const float* matrix) +{ + if (matrix != nullptr) { + transforms_.back() = {matrix[0], matrix[1], matrix[3], matrix[4], matrix[6], matrix[7]}; + } +} + +void CairoRenderTarget::moveTo(int x, int y) { currentX_ = x; currentY_ = y; } +void CairoRenderTarget::moveRel(int dx, int dy) { currentX_ += dx; currentY_ += dy; } +int CairoRenderTarget::getCurrentX() const { return currentX_; } +int CairoRenderTarget::getCurrentY() const { return currentY_; } + +cairo_t* CairoRenderTarget::drawingContext() +{ + return drawingToScratch_ ? rasterCairo_ : cairo_; +} + +void CairoRenderTarget::beginDraw(color_t color, bool fill) +{ + drawingToScratch_ = rasterOp_ != ROP_COPY; + if (drawingToScratch_) { + rasterScratch_->clear(0U); + cairo_surface_mark_dirty(rasterSurface_); + } + cairo_t* context = drawingContext(); + cairo_save(context); + cairo_identity_matrix(context); + if (viewportClip_) { + const int left = std::max(0, viewportLeft_); + const int top = std::max(0, viewportTop_); + const int right = std::min(getWidth(), viewportRight_); + const int bottom = std::min(getHeight(), viewportBottom_); + cairo_rectangle(context, left, top, std::max(0, right - left), std::max(0, bottom - top)); + cairo_clip(context); + } + const auto& t = transforms_.back(); + cairo_matrix_t matrix; + cairo_matrix_init(&matrix, t[0], t[1], t[2], t[3], + t[4] + viewportLeft_, t[5] + viewportTop_); + cairo_set_matrix(context, &matrix); + cairo_set_antialias(context, + rasterOp_ == ROP_COPY && antialiasing_ ? CAIRO_ANTIALIAS_DEFAULT : CAIRO_ANTIALIAS_NONE); + cairo_set_line_width(context, lineWidth_); + cairo_set_line_cap(context, lineCap(startCap_ == endCap_ ? startCap_ : RT_LINECAP_FLAT)); + cairo_set_line_join(context, lineJoin(lineJoin_)); + cairo_set_miter_limit(context, miterLimit_); + + double dash[16]; + int count = 0; + switch (lineStyle_) { + case LINE_DASHED: dash[0] = 6; dash[1] = 3; count = 2; break; + case LINE_DOTTED: dash[0] = 1; dash[1] = 2; count = 2; break; + case LINE_DASHDOT: dash[0] = 6; dash[1] = 2; dash[2] = 1; dash[3] = 2; count = 4; break; + case LINE_DASHDOTDOT: + dash[0] = 6; dash[1] = 2; dash[2] = 1; dash[3] = 2; dash[4] = 1; dash[5] = 2; count = 6; break; + case LINE_USER: + for (int bit = 15; bit >= 0 && count < 16; --bit) { + const bool on = (linePattern_ & (1U << bit)) != 0; + if (count == 0 || ((count & 1) == 0) != on) dash[count++] = 1.0; + else dash[count - 1] += 1.0; + } + break; + default: break; + } + cairo_set_dash(context, count == 0 ? nullptr : dash, count, 0.0); + color_t source = color; + if (drawingToScratch_) source = 0xFF000000U | (color & 0x00FFFFFFU); + setSourceColor(context, source, false); + (void)fill; +} + +void CairoRenderTarget::mergeRasterScratch() +{ + if (!drawingToScratch_) return; + cairo_surface_flush(rasterSurface_); + cairo_surface_flush(cairoSurface_); + for (int y = 0; y < getHeight(); ++y) { + color_t* destination = surface_->row(static_cast(y)); + const color_t* source = rasterScratch_->row(static_cast(y)); + for (int x = 0; x < getWidth(); ++x) { + const unsigned int coverage = channel(source[x], 24); + if (coverage == 0) continue; + const color_t operand = 0xFF000000U | (source[x] & 0x00FFFFFFU); + const color_t result = applyPrimitiveRasterOp(destination[x], operand, rasterOp_); + destination[x] = coverage == 255 ? result : + blendPremultiplied(destination[x], premultiply(result), static_cast(coverage)); + } + } + cairo_surface_mark_dirty(cairoSurface_); + drawingToScratch_ = false; +} + +void CairoRenderTarget::endStroke() +{ + cairo_t* context = drawingContext(); + if (lineStyle_ != LINE_NONE) cairo_stroke(context); else cairo_new_path(context); + cairo_restore(context); + mergeRasterScratch(); +} + +void CairoRenderTarget::fillCurrentPath() +{ + cairo_t* context = drawingContext(); + if (fillStyle_ == FILL_EMPTY) { + cairo_new_path(context); + return; + } + if (fillStyle_ == FILL_SOLID || fillStyle_ == FILL_USER) { + cairo_fill(context); + return; + } + color_t cells[64]; + const color_t foreground = premultiply(fillPatternColor_); + const color_t background = backgroundOpaque_ ? premultiply(backgroundColor_) : 0U; + for (int y = 0; y < 8; ++y) { + for (int x = 0; x < 8; ++x) { + cells[y * 8 + x] = patternUsesForeground(fillStyle_, x, y) ? foreground : background; + } + } + cairo_surface_t* tile = cairo_image_surface_create_for_data( + reinterpret_cast(cells), CAIRO_FORMAT_ARGB32, 8, 8, 32); + cairo_pattern_t* pattern = cairo_pattern_create_for_surface(tile); + cairo_pattern_set_extend(pattern, CAIRO_EXTEND_REPEAT); + cairo_set_source(context, pattern); + cairo_fill(context); + cairo_pattern_destroy(pattern); + cairo_surface_destroy(tile); +} + +void CairoRenderTarget::endFill() +{ + fillCurrentPath(); + cairo_restore(drawingContext()); + mergeRasterScratch(); +} + +void CairoRenderTarget::appendRoundedRectangle( + double x, double y, double width, double height, double rx, double ry) +{ + cairo_t* context = drawingContext(); + rx = std::max(0.0, std::min(std::abs(width) * 0.5, std::abs(rx))); + ry = std::max(0.0, std::min(std::abs(height) * 0.5, std::abs(ry))); + if (rx == 0.0 || ry == 0.0) { cairo_rectangle(context, x, y, width, height); return; } + cairo_save(context); + cairo_translate(context, x, y); + cairo_scale(context, rx, ry); + const double right = width / rx, bottom = height / ry; + cairo_new_sub_path(context); + cairo_arc(context, right - 1.0, 1.0, 1.0, -kPi / 2.0, 0.0); + cairo_arc(context, right - 1.0, bottom - 1.0, 1.0, 0.0, kPi / 2.0); + cairo_arc(context, 1.0, bottom - 1.0, 1.0, kPi / 2.0, kPi); + cairo_arc(context, 1.0, 1.0, 1.0, kPi, 3.0 * kPi / 2.0); + cairo_close_path(context); + cairo_restore(context); +} + +void CairoRenderTarget::appendEllipseArc(double x, double y, double radiusX, double radiusY, + double startAngle, double endAngle, bool reverse) +{ + cairo_t* context = drawingContext(); + if (radiusX <= 0.0 || radiusY <= 0.0) return; + cairo_save(context); + cairo_translate(context, x, y); + cairo_scale(context, radiusX, radiusY); + if (reverse) cairo_arc_negative(context, 0, 0, 1, startAngle, endAngle); + else cairo_arc(context, 0, 0, 1, startAngle, endAngle); + cairo_restore(context); +} + +void CairoRenderTarget::appendPolygon(const int* points, int count, bool close) +{ + cairo_t* context = drawingContext(); + if (points == nullptr || count <= 0) return; + cairo_move_to(context, points[0], points[1]); + for (int i = 1; i < count; ++i) cairo_line_to(context, points[i * 2], points[i * 2 + 1]); + if (close) cairo_close_path(context); +} + +void CairoRenderTarget::drawLine(int x1, int y1, int x2, int y2) +{ + if (lineStyle_ == LINE_NONE) return; + const float offset = !antialiasing_ && + (static_cast(std::lround(lineWidth_)) & 1) ? 0.5f : 0.0f; + beginDraw(lineColor_, false); + cairo_move_to(drawingContext(), x1 + offset, y1 + offset); + cairo_line_to(drawingContext(), x2 + offset, y2 + offset); + endStroke(); +} + +void CairoRenderTarget::drawLineF(float x1, float y1, float x2, float y2) +{ + beginDraw(lineColor_, false); + cairo_move_to(drawingContext(), x1, y1); + cairo_line_to(drawingContext(), x2, y2); + endStroke(); +} + +void CairoRenderTarget::lineTo(int x, int y) +{ + drawLine(currentX_, currentY_, x, y); + currentX_ = x; + currentY_ = y; +} + +void CairoRenderTarget::lineRel(int dx, int dy) { lineTo(currentX_ + dx, currentY_ + dy); } + +void CairoRenderTarget::drawRect(int x, int y, int width, int height) +{ + if (width <= 0 || height <= 0) return; + beginDraw(lineColor_, false); + cairo_rectangle(drawingContext(), x + 0.5, y + 0.5, + std::max(0, width - 1), std::max(0, height - 1)); + endStroke(); +} + +void CairoRenderTarget::fillRect(int x, int y, int width, int height) +{ + if (width <= 0 || height <= 0) return; + beginDraw(fillPatternColor_, true); + cairo_rectangle(drawingContext(), x, y, width, height); + endFill(); +} + +void CairoRenderTarget::drawRoundRect( + int x, int y, int width, int height, int ellipseWidth, int ellipseHeight) +{ + if (width <= 0 || height <= 0) return; + beginDraw(lineColor_, false); + appendRoundedRectangle(x + 0.5, y + 0.5, std::max(0, width - 1), + std::max(0, height - 1), ellipseWidth * 0.5, ellipseHeight * 0.5); + endStroke(); +} + +void CairoRenderTarget::fillRoundRect( + int x, int y, int width, int height, int ellipseWidth, int ellipseHeight) +{ + if (width <= 0 || height <= 0) return; + beginDraw(fillPatternColor_, true); + appendRoundedRectangle(x, y, width, height, ellipseWidth * 0.5, ellipseHeight * 0.5); + endFill(); +} + +void CairoRenderTarget::draw3DBar( + int x, int y, int width, int height, int depth, int requestedFillStyle) +{ + const FillStyle savedStyle = fillStyle_; + if (requestedFillStyle >= static_cast(FILL_EMPTY) && + requestedFillStyle <= static_cast(FILL_USER)) { + fillStyle_ = static_cast(requestedFillStyle); + } + fillRect(x, y, width, height); + fillStyle_ = savedStyle; + drawRect(x, y, width, height); + const int topFace[] = {x, y, x + depth, y - depth, + x + width + depth, y - depth, x + width, y}; + const int sideFace[] = {x + width, y, x + width + depth, y - depth, + x + width + depth, y + height - depth, x + width, y + height}; + drawPolygon(topFace, 4); + drawPolygon(sideFace, 4); +} + +void CairoRenderTarget::drawCircle(int x, int y, int radius) +{ + drawEllipse(x, y, 0, 360, radius, radius); +} + +void CairoRenderTarget::fillCircle(int x, int y, int radius) +{ + fillEllipse(x, y, 0, 360, radius, radius); +} + +void CairoRenderTarget::drawEllipse( + int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) +{ + if (radiusX <= 0 || radiusY <= 0) return; + beginDraw(lineColor_, false); + appendEllipseArc(x, y, radiusX, radiusY, + -startAngle * kPi / 180.0, -endAngle * kPi / 180.0, true); + endStroke(); +} + +void CairoRenderTarget::fillEllipse( + int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) +{ + if (radiusX <= 0 || radiusY <= 0) return; + beginDraw(fillPatternColor_, true); + appendEllipseArc(x, y, radiusX, radiusY, + -startAngle * kPi / 180.0, -endAngle * kPi / 180.0, true); + cairo_close_path(drawingContext()); + endFill(); +} + +void CairoRenderTarget::drawSector( + int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) +{ + if (radiusX <= 0 || radiusY <= 0) return; + beginDraw(lineColor_, false); + cairo_move_to(drawingContext(), x, y); + appendEllipseArc(x, y, radiusX, radiusY, + -startAngle * kPi / 180.0, -endAngle * kPi / 180.0, true); + cairo_close_path(drawingContext()); + endStroke(); +} + +void CairoRenderTarget::fillSector( + int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) +{ + if (radiusX <= 0 || radiusY <= 0) return; + beginDraw(fillPatternColor_, true); + cairo_move_to(drawingContext(), x, y); + appendEllipseArc(x, y, radiusX, radiusY, + -startAngle * kPi / 180.0, -endAngle * kPi / 180.0, true); + cairo_close_path(drawingContext()); + endFill(); +} + +void CairoRenderTarget::drawPie( + int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) +{ + drawSector(x, y, startAngle, endAngle, radiusX, radiusY); +} + +void CairoRenderTarget::fillPie( + int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) +{ + fillSector(x, y, startAngle, endAngle, radiusX, radiusY); +} + +void CairoRenderTarget::drawArc( + int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) +{ + drawEllipse(x, y, startAngle, endAngle, radiusX, radiusY); +} + +void CairoRenderTarget::drawChord( + int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) +{ + if (radiusX <= 0 || radiusY <= 0) return; + beginDraw(lineColor_, false); + appendEllipseArc(x, y, radiusX, radiusY, + -startAngle * kPi / 180.0, -endAngle * kPi / 180.0, true); + cairo_close_path(drawingContext()); + endStroke(); +} + +void CairoRenderTarget::drawPolygon(const int* points, int count) +{ + if (points == nullptr || count < 2) return; + beginDraw(lineColor_, false); + appendPolygon(points, count, true); + endStroke(); +} + +void CairoRenderTarget::fillPolygon(const int* points, int count) +{ + if (points == nullptr || count < 3) return; + beginDraw(fillPatternColor_, true); + appendPolygon(points, count, true); + endFill(); +} + +void CairoRenderTarget::drawPolyline(const int* points, int count) +{ + if (points == nullptr || count < 2) return; + beginDraw(lineColor_, false); + appendPolygon(points, count, false); + endStroke(); +} + +bool CairoRenderTarget::insideClip(int x, int y) const +{ + if (x < 0 || y < 0 || x >= getWidth() || y >= getHeight()) return false; + return !viewportClip_ || (x >= viewportLeft_ && y >= viewportTop_ && + x < viewportRight_ && y < viewportBottom_); +} + +color_t& CairoRenderTarget::pixelAt(int x, int y) +{ + return surface_->row(static_cast(y))[x]; +} + +color_t CairoRenderTarget::pixelAt(int x, int y) const +{ + return surface_->row(static_cast(y))[x]; +} + +color_t CairoRenderTarget::applyRasterOp( + color_t destination, color_t source, RasterOp operation) noexcept +{ + switch (operation) { + case ROP_BLACK: return 0x00000000U; + case ROP_NOTMERGEPEN: return ~(destination | source); + case ROP_MASKNOTPEN: return destination & ~source; + case ROP_NOTCOPYPEN: return ~source; + case ROP_MASKPENNOT: return source & ~destination; + case ROP_NOT: return ~destination; + case ROP_XOR: return destination ^ source; + case ROP_NOTMASKPEN: return ~(destination & source); + case ROP_AND: return destination & source; + case ROP_NOTXORPEN: return ~(destination ^ source); + case ROP_NOP: return destination; + case ROP_MERGENOTPEN: return destination | ~source; + case ROP_COPY: return source; + case ROP_MERGEPENNOT: return source | ~destination; + case ROP_OR: return destination | source; + case ROP_WHITE: return 0xFFFFFFFFU; + default: return source; + } +} + +color_t CairoRenderTarget::applyPrimitiveRasterOp( + color_t destination, color_t source, RasterOp operation) noexcept +{ + if (operation == ROP_NOP) return destination; + const unsigned int alpha = channel(destination, 24); + const auto unpremultiplyChannel = [alpha](unsigned int value) { + return alpha == 0 ? 0U : std::min(255U, (value * 255U + alpha / 2U) / alpha); + }; + const color_t destinationRGB = + (unpremultiplyChannel(channel(destination, 16)) << 16U) | + (unpremultiplyChannel(channel(destination, 8)) << 8U) | + unpremultiplyChannel(channel(destination, 0)); + const color_t sourceRGB = source & 0x00FFFFFFU; + color_t result = destinationRGB; + switch (operation) { + case ROP_BLACK: result = 0x000000U; break; + case ROP_NOTMERGEPEN: result = ~(destinationRGB | sourceRGB); break; + case ROP_MASKNOTPEN: result = destinationRGB & ~sourceRGB; break; + case ROP_NOTCOPYPEN: result = ~sourceRGB; break; + case ROP_MASKPENNOT: result = sourceRGB & ~destinationRGB; break; + case ROP_NOT: result = ~destinationRGB; break; + case ROP_XOR: result = destinationRGB ^ sourceRGB; break; + case ROP_NOTMASKPEN: result = ~(destinationRGB & sourceRGB); break; + case ROP_AND: result = destinationRGB & sourceRGB; break; + case ROP_NOTXORPEN: result = ~(destinationRGB ^ sourceRGB); break; + case ROP_NOP: result = destinationRGB; break; + case ROP_MERGENOTPEN: result = destinationRGB | ~sourceRGB; break; + case ROP_COPY: result = sourceRGB; break; + case ROP_MERGEPENNOT: result = sourceRGB | ~destinationRGB; break; + case ROP_OR: result = destinationRGB | sourceRGB; break; + case ROP_WHITE: result = 0x00FFFFFFU; break; + default: result = sourceRGB; break; + } + return pack(alpha, scaleByte(channel(result, 16), alpha), + scaleByte(channel(result, 8), alpha), scaleByte(channel(result, 0), alpha)); +} + +color_t CairoRenderTarget::premultiply(color_t straight) noexcept +{ + const unsigned int alpha = channel(straight, 24); + return pack(alpha, scaleByte(channel(straight, 16), alpha), + scaleByte(channel(straight, 8), alpha), scaleByte(channel(straight, 0), alpha)); +} + +color_t CairoRenderTarget::blendPremultiplied( + color_t destination, color_t source, unsigned char factor) noexcept +{ + const unsigned int sourceAlpha = scaleByte(channel(source, 24), factor); + const unsigned int inverse = 255U - sourceAlpha; + return pack(sourceAlpha + scaleByte(channel(destination, 24), inverse), + scaleByte(channel(source, 16), factor) + scaleByte(channel(destination, 16), inverse), + scaleByte(channel(source, 8), factor) + scaleByte(channel(destination, 8), inverse), + scaleByte(channel(source, 0), factor) + scaleByte(channel(destination, 0), inverse)); +} + +color_t CairoRenderTarget::blendStraight( + color_t destination, color_t source, unsigned char factor) noexcept +{ + return blendPremultiplied(destination, premultiply(source), factor); +} + +void CairoRenderTarget::writePixel(int x, int y, color_t color, bool useRasterOp) +{ + if (!insideClip(x, y)) return; + color_t& destination = pixelAt(x, y); + destination = useRasterOp ? applyRasterOp(destination, color, rasterOp_) : color; + cairo_surface_mark_dirty_rectangle(cairoSurface_, x, y, 1, 1); +} + +void CairoRenderTarget::putPixel(int x, int y, color_t color) +{ + const auto& m = transforms_.back(); + const int px = static_cast(std::lround(m[0] * x + m[2] * y + m[4])) + viewportLeft_; + const int py = static_cast(std::lround(m[1] * x + m[3] * y + m[5])) + viewportTop_; + writePixel(px, py, color); +} + +color_t CairoRenderTarget::getPixel(int x, int y) const +{ + const int px = x + viewportLeft_, py = y + viewportTop_; + return insideClip(px, py) ? pixelAt(px, py) : 0U; +} + +void CairoRenderTarget::putPixelAlpha(int x, int y, color_t color) +{ + const int px = x + viewportLeft_, py = y + viewportTop_; + if (insideClip(px, py)) writePixel(px, py, blendStraight(pixelAt(px, py), color), false); +} + +void CairoRenderTarget::putPixelSaveAlpha(int x, int y, color_t color) +{ + const int px = x + viewportLeft_, py = y + viewportTop_; + if (insideClip(px, py)) writePixel(px, py, + (pixelAt(px, py) & 0xFF000000U) | (color & 0x00FFFFFFU), false); +} + +void CairoRenderTarget::putPixelAlphaBlend( + int x, int y, color_t color, unsigned char alphaFactor) +{ + const int px = x + viewportLeft_, py = y + viewportTop_; + if (insideClip(px, py)) writePixel(px, py, + blendStraight(pixelAt(px, py), color, alphaFactor), false); +} + +void CairoRenderTarget::putPixels(int count, const int* points) +{ + if (points == nullptr || count <= 0) return; + for (int i = 0; i < count; ++i) putPixel(points[i * 2], points[i * 2 + 1], lineColor_); +} + +void CairoRenderTarget::floodFillInternal(int x, int y, color_t boundary, bool surfaceMode) +{ + if (fillStyle_ == FILL_EMPTY) return; + const int seedX = x + viewportLeft_, seedY = y + viewportTop_; + if (!insideClip(seedX, seedY)) return; + cairo_surface_flush(cairoSurface_); + const color_t target = pixelAt(seedX, seedY); + const color_t compare = surfaceMode ? premultiply(boundary) : boundary; + const bool seedMatches = surfaceMode ? + ((target & 0x00FFFFFFU) == (compare & 0x00FFFFFFU)) : + ((target & 0x00FFFFFFU) != (compare & 0x00FFFFFFU)); + if (!seedMatches) return; + const color_t storedFill = premultiply(fillPatternColor_); + const color_t storedBackground = backgroundOpaque_ ? premultiply(backgroundColor_) : target; + std::vector visited(static_cast(getWidth()) * getHeight(), 0); + std::vector stack; + stack.push_back(seedY * getWidth() + seedX); + while (!stack.empty()) { + const int index = stack.back(); stack.pop_back(); + if (visited[static_cast(index)] != 0) continue; + visited[static_cast(index)] = 1; + const int px = index % getWidth(), py = index / getWidth(); + if (!insideClip(px, py)) continue; + const color_t value = pixelAt(px, py); + const bool matches = surfaceMode ? + ((value & 0x00FFFFFFU) == (compare & 0x00FFFFFFU)) : + ((value & 0x00FFFFFFU) != (compare & 0x00FFFFFFU)); + if (!matches) continue; + pixelAt(px, py) = patternUsesForeground(fillStyle_, px, py) ? storedFill : storedBackground; + if (px > 0) stack.push_back(index - 1); + if (px + 1 < getWidth()) stack.push_back(index + 1); + if (py > 0) stack.push_back(index - getWidth()); + if (py + 1 < getHeight()) stack.push_back(index + getWidth()); + } + cairo_surface_mark_dirty(cairoSurface_); +} + +void CairoRenderTarget::floodFill(int x, int y, color_t borderColor) +{ + floodFillInternal(x, y, borderColor, false); +} + +void CairoRenderTarget::floodFillSurface(int x, int y, color_t surfaceColor) +{ + floodFillInternal(x, y, surfaceColor, true); +} + +void CairoRenderTarget::clear(color_t color) +{ + cairo_surface_flush(cairoSurface_); + surface_->clear(premultiply(color)); + cairo_surface_mark_dirty(cairoSurface_); +} + +CairoRenderTarget::SourceImage CairoRenderTarget::captureSource( + RenderTarget* source, int x, int y, int width, int height) +{ + SourceImage result = {std::max(0, width), std::max(0, height), {}}; + if (source == nullptr || width <= 0 || height <= 0) return result; + source->flush(); + const color_t* pixels = source->getPixelBuffer(); + if (pixels == nullptr) return result; + result.pixels.assign(static_cast(width) * height, 0U); + for (int row = 0; row < height; ++row) { + const int sourceY = y + row; + if (sourceY < 0 || sourceY >= source->getHeight()) continue; + for (int column = 0; column < width; ++column) { + const int sourceX = x + column; + if (sourceX >= 0 && sourceX < source->getWidth()) { + result.pixels[static_cast(row) * width + column] = + pixels[static_cast(sourceY) * source->getWidth() + sourceX]; + } + } + } + return result; +} + +color_t CairoRenderTarget::sample(const SourceImage& source, float x, float y, bool smooth) noexcept +{ + if (source.width <= 0 || source.height <= 0 || source.pixels.empty()) return 0; + if (!smooth) { + const int sx = std::clamp(static_cast(std::floor(x + 0.5f)), 0, source.width - 1); + const int sy = std::clamp(static_cast(std::floor(y + 0.5f)), 0, source.height - 1); + return source.pixels[static_cast(sy) * source.width + sx]; + } + const float cx = std::clamp(x, 0.0f, static_cast(source.width - 1)); + const float cy = std::clamp(y, 0.0f, static_cast(source.height - 1)); + const int x0 = static_cast(std::floor(cx)), y0 = static_cast(std::floor(cy)); + const int x1 = std::min(source.width - 1, x0 + 1), y1 = std::min(source.height - 1, y0 + 1); + const float fx = cx - x0, fy = cy - y0; + const color_t p00 = source.pixels[static_cast(y0) * source.width + x0]; + const color_t p10 = source.pixels[static_cast(y0) * source.width + x1]; + const color_t p01 = source.pixels[static_cast(y1) * source.width + x0]; + const color_t p11 = source.pixels[static_cast(y1) * source.width + x1]; + auto interpolate = [=](unsigned int shift) { + const float top = channel(p00, shift) + (channel(p10, shift) - channel(p00, shift)) * fx; + const float bottom = channel(p01, shift) + (channel(p11, shift) - channel(p01, shift)) * fx; + return static_cast(std::lround(top + (bottom - top) * fy)); + }; + return pack(interpolate(24), interpolate(16), interpolate(8), interpolate(0)); +} + +void CairoRenderTarget::stretchTransfer(int dstX, int dstY, int dstWidth, int dstHeight, + const SourceImage& source, ImageAlphaFormat format, unsigned char alpha, bool smooth, bool blend) +{ + if (dstWidth <= 0 || dstHeight <= 0 || source.width <= 0 || source.height <= 0) return; + cairo_surface_flush(cairoSurface_); + for (int y = 0; y < dstHeight; ++y) { + const float sy = (y + 0.5f) * source.height / dstHeight - 0.5f; + for (int x = 0; x < dstWidth; ++x) { + const int px = dstX + x + viewportLeft_, py = dstY + y + viewportTop_; + if (!insideClip(px, py)) continue; + const float sx = (x + 0.5f) * source.width / dstWidth - 0.5f; + color_t value = sample(source, sx, sy, smooth); + if (format == IMAGE_ALPHA_STRAIGHT) value = premultiply(value); + else if (format == IMAGE_ALPHA_OPAQUE) value = 0xFF000000U | (value & 0x00FFFFFFU); + pixelAt(px, py) = blend ? blendPremultiplied(pixelAt(px, py), value, alpha) : + applyRasterOp(pixelAt(px, py), value, rasterOp_); + } + } + cairo_surface_mark_dirty(cairoSurface_); +} + +void CairoRenderTarget::blit( + int dstX, int dstY, RenderTarget* source, int srcX, int srcY, int width, int height) +{ + stretchTransfer(dstX, dstY, width, height, + captureSource(source, srcX, srcY, width, height), + IMAGE_ALPHA_PREMULTIPLIED, 255, false, false); +} + +void CairoRenderTarget::blitStretch(int dstX, int dstY, int dstWidth, int dstHeight, + RenderTarget* source, int srcX, int srcY, int srcWidth, int srcHeight) +{ + stretchTransfer(dstX, dstY, dstWidth, dstHeight, + captureSource(source, srcX, srcY, srcWidth, srcHeight), + IMAGE_ALPHA_PREMULTIPLIED, 255, false, false); +} + +void CairoRenderTarget::alphaBlend(int dstX, int dstY, int dstWidth, int dstHeight, + RenderTarget* source, int srcX, int srcY, int srcWidth, int srcHeight, + unsigned char alpha, ImageAlphaFormat format, bool smooth) +{ + stretchTransfer(dstX, dstY, dstWidth, dstHeight, + captureSource(source, srcX, srcY, srcWidth, srcHeight), format, alpha, smooth, true); +} + +void CairoRenderTarget::alphaTransparent(int dstX, int dstY, RenderTarget* source, + int srcX, int srcY, int width, int height, color_t transparentColor, unsigned char alpha) +{ + const SourceImage snapshot = captureSource(source, srcX, srcY, width, height); + cairo_surface_flush(cairoSurface_); + for (int y = 0; y < snapshot.height; ++y) { + for (int x = 0; x < snapshot.width; ++x) { + const color_t value = snapshot.pixels[static_cast(y) * snapshot.width + x]; + if ((value & 0x00FFFFFFU) == (transparentColor & 0x00FFFFFFU)) continue; + const int px = dstX + x + viewportLeft_, py = dstY + y + viewportTop_; + if (insideClip(px, py)) pixelAt(px, py) = blendPremultiplied(pixelAt(px, py), value, alpha); + } + } + cairo_surface_mark_dirty(cairoSurface_); +} + +void CairoRenderTarget::withAlpha(int dstX, int dstY, int dstWidth, int dstHeight, + RenderTarget* source, int srcX, int srcY, int srcWidth, int srcHeight, bool smooth) +{ + alphaBlend(dstX, dstY, dstWidth, dstHeight, source, srcX, srcY, + srcWidth, srcHeight, 255, IMAGE_ALPHA_PREMULTIPLIED, smooth); +} + +void CairoRenderTarget::alphaFilter(int dstX, int dstY, int width, int height, + RenderTarget* source, int srcX, int srcY, unsigned char alpha) +{ + alphaBlend(dstX, dstY, width, height, source, srcX, srcY, + width, height, alpha, IMAGE_ALPHA_PREMULTIPLIED, false); +} + +void CairoRenderTarget::rotateBlend(int dstX, int dstY, int dstWidth, int dstHeight, + RenderTarget* source, int srcX, int srcY, int srcWidth, int srcHeight, + float angle, float centerX, float centerY, bool transparent, int alpha, bool smooth) +{ + rotateZoomBlend(dstX, dstY, dstWidth, dstHeight, source, srcX, srcY, + srcWidth, srcHeight, angle, centerX, centerY, 1.0f, 1.0f, transparent, alpha, smooth); +} + +void CairoRenderTarget::rotateZoomBlend(int dstX, int dstY, int dstWidth, int dstHeight, + RenderTarget* source, int srcX, int srcY, int srcWidth, int srcHeight, + float angle, float centerX, float centerY, float zoomX, float zoomY, + bool transparent, int alpha, bool smooth) +{ + if (dstWidth <= 0 || dstHeight <= 0 || zoomX == 0.0f || zoomY == 0.0f) return; + const SourceImage snapshot = captureSource(source, srcX, srcY, srcWidth, srcHeight); + const float cosine = std::cos(angle), sine = std::sin(angle); + const unsigned char factor = static_cast(std::clamp(alpha < 0 ? 255 : alpha, 0, 255)); + cairo_surface_flush(cairoSurface_); + for (int y = 0; y < dstHeight; ++y) { + for (int x = 0; x < dstWidth; ++x) { + const float dx = (x - centerX) / zoomX, dy = (y - centerY) / zoomY; + const float sx = cosine * dx + sine * dy + centerX; + const float sy = -sine * dx + cosine * dy + centerY; + if (sx < -0.5f || sy < -0.5f || sx >= snapshot.width - 0.5f || sy >= snapshot.height - 0.5f) continue; + const color_t value = sample(snapshot, sx, sy, smooth); + if (transparent && value == 0) continue; + const int px = dstX - static_cast(std::lround(centerX)) + x + viewportLeft_; + const int py = dstY - static_cast(std::lround(centerY)) + y + viewportTop_; + if (insideClip(px, py)) pixelAt(px, py) = alpha >= 0 ? + blendPremultiplied(pixelAt(px, py), value, factor) : + applyRasterOp(pixelAt(px, py), value, rasterOp_); + } + } + cairo_surface_mark_dirty(cairoSurface_); +} + +void CairoRenderTarget::blitAffine(RenderTarget* source, int srcX, int srcY, + int srcWidth, int srcHeight, const float* points, bool premultipliedAlpha, bool smooth) +{ + if (points == nullptr || srcWidth <= 0 || srcHeight <= 0) return; + const SourceImage snapshot = captureSource(source, srcX, srcY, srcWidth, srcHeight); + const float p0x = points[0], p0y = points[1]; + const float ux = points[2] - p0x, uy = points[3] - p0y; + const float vx = points[6] - p0x, vy = points[7] - p0y; + const float determinant = ux * vy - uy * vx; + if (std::abs(determinant) < 1e-8f) return; + float minX = points[0], maxX = points[0], minY = points[1], maxY = points[1]; + for (int i = 1; i < 4; ++i) { + minX = std::min(minX, points[i * 2]); maxX = std::max(maxX, points[i * 2]); + minY = std::min(minY, points[i * 2 + 1]); maxY = std::max(maxY, points[i * 2 + 1]); + } + cairo_surface_flush(cairoSurface_); + for (int y = static_cast(std::floor(minY)); y < static_cast(std::ceil(maxY)); ++y) { + for (int x = static_cast(std::floor(minX)); x < static_cast(std::ceil(maxX)); ++x) { + const float dx = x + 0.5f - p0x, dy = y + 0.5f - p0y; + const float u = (dx * vy - dy * vx) / determinant; + const float v = (ux * dy - uy * dx) / determinant; + if (u < 0 || u > 1 || v < 0 || v > 1) continue; + color_t value = sample(snapshot, u * (srcWidth - 1), v * (srcHeight - 1), smooth); + if (!premultipliedAlpha) value = premultiply(value); + const int px = x + viewportLeft_, py = y + viewportTop_; + if (insideClip(px, py)) pixelAt(px, py) = + blendPremultiplied(pixelAt(px, py), value); + } + } + cairo_surface_mark_dirty(cairoSurface_); +} + +void CairoRenderTarget::filterBlur(int dstX, int dstY, int width, int height, float intensity) +{ + if (width <= 0 || height <= 0 || intensity <= 0) return; + const int left = std::max(0, dstX + viewportLeft_); + const int top = std::max(0, dstY + viewportTop_); + const int right = std::min(getWidth(), dstX + viewportLeft_ + width); + const int bottom = std::min(getHeight(), dstY + viewportTop_ + height); + if (left >= right || top >= bottom) return; + const int clippedWidth = right - left, clippedHeight = bottom - top; + const int radius = std::max(1, static_cast(std::ceil(intensity))); + std::vector source(static_cast(clippedWidth) * clippedHeight); + cairo_surface_flush(cairoSurface_); + for (int y = 0; y < clippedHeight; ++y) { + std::memcpy(source.data() + static_cast(y) * clippedWidth, + surface_->row(top + y) + left, static_cast(clippedWidth) * sizeof(color_t)); + } + for (int y = 0; y < clippedHeight; ++y) { + for (int x = 0; x < clippedWidth; ++x) { + unsigned long long sums[4] = {0, 0, 0, 0}; unsigned int count = 0; + for (int sy = std::max(0, y - radius); sy <= std::min(clippedHeight - 1, y + radius); ++sy) { + for (int sx = std::max(0, x - radius); sx <= std::min(clippedWidth - 1, x + radius); ++sx) { + const color_t value = source[static_cast(sy) * clippedWidth + sx]; + sums[0] += channel(value, 24); sums[1] += channel(value, 16); + sums[2] += channel(value, 8); sums[3] += channel(value, 0); ++count; + } + } + pixelAt(left + x, top + y) = pack(sums[0] / count, sums[1] / count, + sums[2] / count, sums[3] / count); + } + } + cairo_surface_mark_dirty(cairoSurface_); +} + +void CairoRenderTarget::setFont(int height, int width, const char* face, + int escapement, int orientation, int weight, bool italic, bool underline, bool strikeout) +{ + font_.height = height != 0 ? height : 16; + font_.width = width; + font_.face = face != nullptr && face[0] != '\0' ? face : "sans"; + font_.escapement = escapement; + font_.orientation = orientation; + font_.weight = weight; + font_.italic = italic; + font_.underline = underline; + font_.strikeout = strikeout; +} + +void CairoRenderTarget::getFont(int* height, int* width, char* face, int faceCapacity, + int* escapement, int* orientation, int* weight, bool* italic, + bool* underline, bool* strikeout) const +{ + if (height != nullptr) *height = font_.height; + if (width != nullptr) *width = font_.width; + if (face != nullptr && faceCapacity > 0) { + std::strncpy(face, font_.face.c_str(), static_cast(faceCapacity - 1)); + face[faceCapacity - 1] = '\0'; + } + if (escapement != nullptr) *escapement = font_.escapement; + if (orientation != nullptr) *orientation = font_.orientation; + if (weight != nullptr) *weight = font_.weight; + if (italic != nullptr) *italic = font_.italic; + if (underline != nullptr) *underline = font_.underline; + if (strikeout != nullptr) *strikeout = font_.strikeout; +} + +void CairoRenderTarget::setTextJustify(TextHAlign horizontal, TextVAlign vertical) +{ + horizontalAlign_ = horizontal; + verticalAlign_ = vertical; +} + +std::string CairoRenderTarget::wideToUtf8(const wchar_t* text) const +{ + return text != nullptr ? ege::w2utf8(text) : std::string(); +} + +void CairoRenderTarget::configureFont() const +{ + cairo_select_font_face(cairo_, font_.face.c_str(), + font_.italic ? CAIRO_FONT_SLANT_ITALIC : CAIRO_FONT_SLANT_NORMAL, + font_.weight >= 600 ? CAIRO_FONT_WEIGHT_BOLD : CAIRO_FONT_WEIGHT_NORMAL); + cairo_set_font_size(cairo_, std::max(1, std::abs(font_.height))); +} + +void CairoRenderTarget::textExtents(const char* text, cairo_text_extents_t* textExtents, + cairo_font_extents_t* fontExtents) const +{ + cairo_save(cairo_); + configureFont(); + if (textExtents != nullptr) cairo_text_extents(cairo_, text != nullptr ? text : "", textExtents); + if (fontExtents != nullptr) cairo_font_extents(cairo_, fontExtents); + cairo_restore(cairo_); +} + +void CairoRenderTarget::measureText(const char* text, float* width, float* height) const +{ + cairo_text_extents_t extents = {}; + cairo_font_extents_t fontExtents = {}; + textExtents(text, &extents, &fontExtents); + const double widthScale = font_.width > 0 ? + static_cast(font_.width) / std::max(1, std::abs(font_.height)) : 1.0; + if (width != nullptr) *width = static_cast(extents.x_advance * widthScale); + if (height != nullptr) *height = static_cast(fontExtents.height); +} + +void CairoRenderTarget::measureText(const wchar_t* text, float* width, float* height) const +{ + const std::string utf8 = wideToUtf8(text); + measureText(utf8.c_str(), width, height); +} + +int CairoRenderTarget::getTextWidth(const char* text) const +{ + float width = 0; measureText(text, &width, nullptr); return static_cast(std::lround(width)); +} + +int CairoRenderTarget::getTextWidth(const wchar_t* text) const +{ + float width = 0; measureText(text, &width, nullptr); return static_cast(std::lround(width)); +} + +int CairoRenderTarget::getTextHeight(const char* text) const +{ + float height = 0; measureText(text, nullptr, &height); return static_cast(std::lround(height)); +} + +int CairoRenderTarget::getTextHeight(const wchar_t* text) const +{ + float height = 0; measureText(text, nullptr, &height); return static_cast(std::lround(height)); +} + +void CairoRenderTarget::drawTextUtf8(float x, float y, const char* text) +{ + if (text == nullptr) return; + cairo_text_extents_t textMetrics = {}; + cairo_font_extents_t fontMetrics = {}; + textExtents(text, &textMetrics, &fontMetrics); + const double widthScale = font_.width > 0 ? + static_cast(font_.width) / std::max(1, std::abs(font_.height)) : 1.0; + const double textWidth = textMetrics.x_advance * widthScale; + const double textHeight = fontMetrics.height; + double drawX = x, drawY = y; + if (horizontalAlign_ == TEXT_CENTER) drawX -= textWidth * 0.5; + else if (horizontalAlign_ == TEXT_RIGHT) drawX -= textWidth; + if (verticalAlign_ == TEXT_MIDDLE) drawY -= textHeight * 0.5; + else if (verticalAlign_ == TEXT_BOTTOM) drawY -= textHeight; + + if (backgroundOpaque_) { + const FillStyle savedStyle = fillStyle_; + const color_t savedColor = fillPatternColor_; + fillStyle_ = FILL_SOLID; + fillPatternColor_ = backgroundColor_; + fillRect(static_cast(std::floor(drawX)), static_cast(std::floor(drawY)), + static_cast(std::ceil(textWidth)), static_cast(std::ceil(textHeight))); + fillStyle_ = savedStyle; + fillPatternColor_ = savedColor; + } + + beginDraw(textColor_, true); + cairo_t* context = drawingContext(); + cairo_select_font_face(context, font_.face.c_str(), + font_.italic ? CAIRO_FONT_SLANT_ITALIC : CAIRO_FONT_SLANT_NORMAL, + font_.weight >= 600 ? CAIRO_FONT_WEIGHT_BOLD : CAIRO_FONT_WEIGHT_NORMAL); + cairo_set_font_size(context, std::max(1, std::abs(font_.height))); + cairo_translate(context, drawX, drawY + fontMetrics.ascent); + cairo_rotate(context, -font_.escapement * kPi / 1800.0); + cairo_scale(context, widthScale, 1.0); + cairo_move_to(context, 0, 0); + cairo_show_text(context, text); + const double lineWidth = std::max(1.0, textHeight / 14.0); + cairo_set_line_width(context, lineWidth / std::max(0.01, widthScale)); + if (font_.underline) { + cairo_move_to(context, 0, fontMetrics.descent * 0.45); + cairo_line_to(context, textMetrics.x_advance, fontMetrics.descent * 0.45); + cairo_stroke(context); + } + if (font_.strikeout) { + cairo_move_to(context, 0, -fontMetrics.ascent * 0.42); + cairo_line_to(context, textMetrics.x_advance, -fontMetrics.ascent * 0.42); + cairo_stroke(context); + } + cairo_restore(context); + mergeRasterScratch(); +} + +void CairoRenderTarget::drawText(float x, float y, const char* text) { drawTextUtf8(x, y, text); } + +void CairoRenderTarget::drawText(float x, float y, const wchar_t* text) +{ + const std::string utf8 = wideToUtf8(text); drawTextUtf8(x, y, utf8.c_str()); +} + +color_t* CairoRenderTarget::getPixelBuffer() +{ + cairo_surface_flush(cairoSurface_); return surface_->data(); +} + +const color_t* CairoRenderTarget::getPixelBuffer() const +{ + cairo_surface_flush(cairoSurface_); return surface_->data(); +} + +color_t* CairoRenderTarget::getPixelBufferForWrite(int x, int y, int width, int height) +{ + (void)x; (void)y; (void)width; (void)height; + cairo_surface_flush(cairoSurface_); return surface_->data(); +} + +bool CairoRenderTarget::updatePixelBuffer(int x, int y, int width, int height, + const color_t* pixels, int pitchBytes) +{ + const std::size_t rowBytes = width > 0 ? static_cast(width) * sizeof(color_t) : 0; + if (pixels == nullptr || width <= 0 || height <= 0 || x < 0 || y < 0 || + width > getWidth() - x || height > getHeight() - y || pitchBytes < 0 || + static_cast(pitchBytes) < rowBytes) return false; + cairo_surface_flush(cairoSurface_); + const unsigned char* source = reinterpret_cast(pixels); + for (int row = 0; row < height; ++row) { + std::memmove(surface_->row(static_cast(y + row)) + x, + source + static_cast(row) * pitchBytes, rowBytes); + } + cairo_surface_mark_dirty_rectangle(cairoSurface_, x, y, width, height); + return true; +} + +void CairoRenderTarget::flush() { cairo_surface_flush(cairoSurface_); } +void CairoRenderTarget::present() { flush(); } + +} // namespace backend +} // namespace ege diff --git a/src/backend/linux/CairoRenderTarget.h b/src/backend/linux/CairoRenderTarget.h new file mode 100644 index 00000000..6cebab4b --- /dev/null +++ b/src/backend/linux/CairoRenderTarget.h @@ -0,0 +1,255 @@ +#ifndef EGE_BACKEND_LINUX_CAIRO_RENDER_TARGET_H +#define EGE_BACKEND_LINUX_CAIRO_RENDER_TARGET_H + +#include "backend/interface/PixelSurface.h" +#include "backend/interface/RenderTarget.h" + +#include + +#include +#include +#include +#include + +namespace ege +{ +namespace backend +{ + +/** + * Linux CPU renderer backed directly by PixelSurface. + * + * Cairo's native-endian CAIRO_FORMAT_ARGB32 is premultiplied 0xAARRGGBB on + * little-endian Linux, exactly matching PixelSurface. There is no staging + * image and getPixelBuffer() always returns the authoritative storage. + */ +class CairoRenderTarget final : public RenderTarget +{ +public: + explicit CairoRenderTarget(int width, int height, bool onScreen = false); + ~CairoRenderTarget() override; + + CairoRenderTarget(const CairoRenderTarget&) = delete; + CairoRenderTarget& operator=(const CairoRenderTarget&) = delete; + + bool valid() const noexcept; + bool resize(int width, int height, bool preservePixels = false); + + int getWidth() const override; + int getHeight() const override; + bool isOnScreen() const override; + + void setLineColor(color_t color) override; + void setFillColor(color_t color) override; + void setTextColor(color_t color) override; + void setBkColor(color_t color) override; + void setBkMode(bool opaque) override; + void setLineWidth(float width) override; + void setLineStyle(LineStyle style, unsigned short pattern, int thickness) override; + void setLineCap(RTLineCap startCap, RTLineCap endCap) override; + void setLineJoin(RTLineJoin join, float miterLimit) override; + void setFillStyle(FillStyle style, color_t color) override; + void setRasterOp(RasterOp rop) override; + void setWritingMode(int mode) override; + void setAntialiasing(bool enabled) override; + + color_t getLineColor() const override; + color_t getFillColor() const override; + color_t getTextColor() const override; + color_t getBkColor() const override; + FillStyle getFillStyle() const override; + + void setViewport(int left, int top, int right, int bottom, bool clip) override; + void getViewport(int* left, int* top, int* right, int* bottom, int* clip) const override; + void clearViewport() override; + + void pushTransform() override; + void popTransform() override; + void resetTransform() override; + void translate(float dx, float dy) override; + void rotate(float angle) override; + void scale(float sx, float sy) override; + void setTransformMatrix(const float* matrix) override; + + void moveTo(int x, int y) override; + void moveRel(int dx, int dy) override; + int getCurrentX() const override; + int getCurrentY() const override; + + void drawLine(int x1, int y1, int x2, int y2) override; + void drawLineF(float x1, float y1, float x2, float y2) override; + void lineTo(int x, int y) override; + void lineRel(int dx, int dy) override; + void drawRect(int x, int y, int width, int height) override; + void fillRect(int x, int y, int width, int height) override; + void drawRoundRect(int x, int y, int width, int height, int ellipseWidth, int ellipseHeight) override; + void fillRoundRect(int x, int y, int width, int height, int ellipseWidth, int ellipseHeight) override; + void draw3DBar(int x, int y, int width, int height, int depth, int fillStyle) override; + void drawCircle(int x, int y, int radius) override; + void fillCircle(int x, int y, int radius) override; + void drawEllipse(int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) override; + void fillEllipse(int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) override; + void drawSector(int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) override; + void fillSector(int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) override; + void drawPie(int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) override; + void fillPie(int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) override; + void drawArc(int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) override; + void drawChord(int x, int y, int startAngle, int endAngle, int radiusX, int radiusY) override; + void drawPolygon(const int* points, int count) override; + void fillPolygon(const int* points, int count) override; + void drawPolyline(const int* points, int count) override; + + void putPixel(int x, int y, color_t color) override; + color_t getPixel(int x, int y) const override; + void putPixelAlpha(int x, int y, color_t color) override; + void putPixelSaveAlpha(int x, int y, color_t color) override; + void putPixelAlphaBlend(int x, int y, color_t color, unsigned char alphaFactor) override; + void putPixels(int count, const int* points) override; + + void floodFill(int x, int y, color_t borderColor) override; + void floodFillSurface(int x, int y, color_t surfaceColor) override; + void clear(color_t color) override; + + void blit(int dstX, int dstY, RenderTarget* source, int srcX, int srcY, int width, int height) override; + void blitStretch(int dstX, int dstY, int dstWidth, int dstHeight, RenderTarget* source, int srcX, int srcY, + int srcWidth, int srcHeight) override; + void alphaBlend(int dstX, int dstY, int dstWidth, int dstHeight, RenderTarget* source, int srcX, int srcY, + int srcWidth, int srcHeight, unsigned char alpha, ImageAlphaFormat format, bool smooth) override; + void alphaTransparent(int dstX, int dstY, RenderTarget* source, int srcX, int srcY, int width, int height, + color_t transparentColor, unsigned char alpha) override; + void withAlpha(int dstX, int dstY, int dstWidth, int dstHeight, RenderTarget* source, int srcX, int srcY, + int srcWidth, int srcHeight, bool smooth) override; + void alphaFilter(int dstX, int dstY, int width, int height, RenderTarget* source, int srcX, int srcY, + unsigned char alpha) override; + void rotateBlend(int dstX, int dstY, int dstWidth, int dstHeight, RenderTarget* source, int srcX, int srcY, + int srcWidth, int srcHeight, float angle, float centerX, float centerY, bool transparent, int alpha, + bool smooth) override; + void rotateZoomBlend(int dstX, int dstY, int dstWidth, int dstHeight, RenderTarget* source, int srcX, int srcY, + int srcWidth, int srcHeight, float angle, float centerX, float centerY, float zoomX, float zoomY, + bool transparent, int alpha, bool smooth) override; + void blitAffine(RenderTarget* source, int srcX, int srcY, int srcWidth, int srcHeight, + const float* destinationPoints, bool premultipliedAlpha, bool smooth) override; + void filterBlur(int dstX, int dstY, int width, int height, float intensity) override; + + void setFont(int height, int width, const char* face, int escapement, int orientation, int weight, bool italic, + bool underline, bool strikeout) override; + void getFont(int* height, int* width, char* face, int faceCapacity, int* escapement, int* orientation, + int* weight, bool* italic, bool* underline, bool* strikeout) const override; + void setTextJustify(TextHAlign horizontal, TextVAlign vertical) override; + void drawText(float x, float y, const char* text) override; + void drawText(float x, float y, const wchar_t* text) override; + int getTextWidth(const char* text) const override; + int getTextWidth(const wchar_t* text) const override; + int getTextHeight(const char* text) const override; + int getTextHeight(const wchar_t* text) const override; + void measureText(const char* text, float* width, float* height) const override; + void measureText(const wchar_t* text, float* width, float* height) const override; + + color_t* getPixelBuffer() override; + const color_t* getPixelBuffer() const override; + color_t* getPixelBufferForWrite(int x, int y, int width, int height) override; + bool updatePixelBuffer(int x, int y, int width, int height, const color_t* pixels, int pitchBytes) override; + + void flush() override; + void present() override; + +private: + struct SourceImage + { + int width; + int height; + std::vector pixels; + }; + + struct FontState + { + int height = 16; + int width = 0; + std::string face = "sans"; + int escapement = 0; + int orientation = 0; + int weight = 400; + bool italic = false; + bool underline = false; + bool strikeout = false; + }; + + void recreateCairoSurface(); + cairo_t* drawingContext(); + void mergeRasterScratch(); + void beginDraw(color_t color, bool fill); + void endStroke(); + void endFill(); + void appendRoundedRectangle(double x, double y, double width, double height, double rx, double ry); + void appendEllipseArc(double x, double y, double radiusX, double radiusY, + double startAngle, double endAngle, bool reverse = false); + void appendPolygon(const int* points, int count, bool close); + void fillCurrentPath(); + bool insideClip(int x, int y) const; + color_t& pixelAt(int x, int y); + color_t pixelAt(int x, int y) const; + void writePixel(int x, int y, color_t color, bool useRasterOp = true); + void floodFillInternal(int x, int y, color_t boundary, bool surfaceMode); + std::string wideToUtf8(const wchar_t* text) const; + void configureFont() const; + void textExtents(const char* text, cairo_text_extents_t* textExtents, + cairo_font_extents_t* fontExtents) const; + void drawTextUtf8(float x, float y, const char* text); + + static color_t applyRasterOp(color_t destination, color_t source, RasterOp operation) noexcept; + static color_t applyPrimitiveRasterOp(color_t destination, color_t source, RasterOp operation) noexcept; + static color_t premultiply(color_t straight) noexcept; + static color_t blendPremultiplied(color_t destination, color_t source, + unsigned char factor = 255) noexcept; + static color_t blendStraight(color_t destination, color_t source, + unsigned char factor = 255) noexcept; + static SourceImage captureSource(RenderTarget* source, int x, int y, int width, int height); + static color_t sample(const SourceImage& source, float x, float y, bool smooth) noexcept; + void stretchTransfer(int dstX, int dstY, int dstWidth, int dstHeight, const SourceImage& source, + ImageAlphaFormat format, unsigned char alpha, bool smooth, bool blend); + + std::unique_ptr surface_; + cairo_surface_t* cairoSurface_ = nullptr; + cairo_t* cairo_ = nullptr; + std::unique_ptr rasterScratch_; + cairo_surface_t* rasterSurface_ = nullptr; + cairo_t* rasterCairo_ = nullptr; + bool drawingToScratch_ = false; + bool onScreen_; + bool antialiasing_ = false; + + color_t lineColor_ = 0xFFFFFFFFU; + color_t fillColor_ = 0xFFFFFFFFU; + color_t textColor_ = 0xFFFFFFFFU; + color_t backgroundColor_ = 0xFF000000U; + bool backgroundOpaque_ = false; + float lineWidth_ = 1.0f; + LineStyle lineStyle_ = LINE_SOLID; + unsigned short linePattern_ = 0xFFFFU; + int lineThickness_ = 1; + RTLineCap startCap_ = RT_LINECAP_FLAT; + RTLineCap endCap_ = RT_LINECAP_FLAT; + RTLineJoin lineJoin_ = RT_LINEJOIN_MITER; + float miterLimit_ = 10.0f; + FillStyle fillStyle_ = FILL_SOLID; + color_t fillPatternColor_ = 0xFFFFFFFFU; + RasterOp rasterOp_ = ROP_COPY; + int writingMode_ = 0; + + int viewportLeft_ = 0; + int viewportTop_ = 0; + int viewportRight_ = 0; + int viewportBottom_ = 0; + bool viewportClip_ = false; + std::vector > transforms_; + int currentX_ = 0; + int currentY_ = 0; + FontState font_; + TextHAlign horizontalAlign_ = TEXT_LEFT; + TextVAlign verticalAlign_ = TEXT_TOP; +}; + +} // namespace backend +} // namespace ege + +#endif diff --git a/src/backend/linux/LinuxWindow.cpp b/src/backend/linux/LinuxWindow.cpp new file mode 100644 index 00000000..e82a6629 --- /dev/null +++ b/src/backend/linux/LinuxWindow.cpp @@ -0,0 +1,412 @@ +#include "backend/linux/LinuxWindow.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +std::uint32_t windowsVirtualKey(KeySym key) +{ + if (key >= XK_a && key <= XK_z) return static_cast('A' + key - XK_a); + if (key >= XK_A && key <= XK_Z) return static_cast(key); + if (key >= XK_0 && key <= XK_9) return static_cast(key); + if (key >= XK_F1 && key <= XK_F24) return static_cast(0x70 + key - XK_F1); + if (key >= XK_KP_0 && key <= XK_KP_9) return static_cast(0x60 + key - XK_KP_0); + switch (key) { + case XK_BackSpace: return 0x08; + case XK_Tab: case XK_ISO_Left_Tab: return 0x09; + case XK_Return: case XK_KP_Enter: return 0x0D; + case XK_Shift_L: return 0xA0; + case XK_Shift_R: return 0xA1; + case XK_Control_L: return 0xA2; + case XK_Control_R: return 0xA3; + case XK_Alt_L: case XK_Meta_L: return 0xA4; + case XK_Alt_R: case XK_Meta_R: return 0xA5; + case XK_Super_L: return 0x5B; + case XK_Super_R: return 0x5C; + case XK_Caps_Lock: return 0x14; + case XK_Escape: return 0x1B; + case XK_space: return 0x20; + case XK_Page_Up: return 0x21; + case XK_Page_Down: return 0x22; + case XK_End: return 0x23; + case XK_Home: return 0x24; + case XK_Left: return 0x25; + case XK_Up: return 0x26; + case XK_Right: return 0x27; + case XK_Down: return 0x28; + case XK_Print: return 0x2C; + case XK_Insert: return 0x2D; + case XK_Delete: return 0x2E; + case XK_KP_Multiply: return 0x6A; + case XK_KP_Add: return 0x6B; + case XK_KP_Subtract: return 0x6D; + case XK_KP_Decimal: return 0x6E; + case XK_KP_Divide: return 0x6F; + case XK_semicolon: return 0xBA; + case XK_equal: return 0xBB; + case XK_comma: return 0xBC; + case XK_minus: return 0xBD; + case XK_period: return 0xBE; + case XK_slash: return 0xBF; + case XK_grave: return 0xC0; + case XK_bracketleft: return 0xDB; + case XK_backslash: return 0xDC; + case XK_bracketright: return 0xDD; + case XK_apostrophe: return 0xDE; + default: return 0; + } +} + +void emitUtf8(ege::WindowEventSink* sink, const char* text, int size) +{ + if (!sink || !text) return; + for (int i = 0; i < size;) { + const unsigned char first = static_cast(text[i++]); + std::uint32_t cp = first; + int remaining = 0; + if ((first & 0xE0u) == 0xC0u) { cp = first & 0x1Fu; remaining = 1; } + else if ((first & 0xF0u) == 0xE0u) { cp = first & 0x0Fu; remaining = 2; } + else if ((first & 0xF8u) == 0xF0u) { cp = first & 0x07u; remaining = 3; } + else if (first >= 0x80u) continue; + if (i + remaining > size) break; + bool valid = true; + for (int j = 0; j < remaining; ++j) { + const unsigned char next = static_cast(text[i++]); + if ((next & 0xC0u) != 0x80u) { valid = false; break; } + cp = (cp << 6u) | (next & 0x3Fu); + } + if (valid && cp <= 0x10FFFFu && !(cp >= 0xD800u && cp <= 0xDFFFu)) sink->onText(cp); + } +} + +int egeButton(unsigned int button) +{ + switch (button) { + case Button1: return 0; + case Button3: return 1; + case Button2: return 2; + case 8: return 3; + case 9: return 4; + default: return -1; + } +} + +} // namespace + +namespace ege +{ +namespace backend +{ + +struct LinuxWindow::Impl +{ + Display* display = nullptr; + ::Window window = 0; + GC gc = nullptr; + Atom wmDelete = None; + XIM inputMethod = nullptr; + XIC inputContext = nullptr; + Cursor hiddenCursor = None; + int width = 0; + int height = 0; + bool closed = true; + WindowEventSink* sink = nullptr; + std::array clickTime{}; + std::array clickX{}; + std::array clickY{}; + std::array keyStates{}; +}; + +LinuxWindow::LinuxWindow() : impl_(new Impl) {} + +LinuxWindow::~LinuxWindow() +{ + close(); +} + +bool LinuxWindow::primaryScreenSize(int* width, int* height) +{ + Display* display = XOpenDisplay(nullptr); + if (!display) return false; + const int screen = DefaultScreen(display); + if (width) *width = DisplayWidth(display, screen); + if (height) *height = DisplayHeight(display, screen); + XCloseDisplay(display); + return true; +} + +bool LinuxWindow::create(int width, int height, const char* title, + const WindowOptions& options, WindowEventSink* eventSink) +{ + close(); + if (width <= 0 || height <= 0) return false; + impl_->display = XOpenDisplay(nullptr); + if (!impl_->display) return false; + const int screen = DefaultScreen(impl_->display); + XSetWindowAttributes attributes{}; + attributes.event_mask = ExposureMask | StructureNotifyMask | KeyPressMask | KeyReleaseMask + | ButtonPressMask | ButtonReleaseMask | PointerMotionMask | FocusChangeMask; + attributes.override_redirect = False; + impl_->window = XCreateWindow(impl_->display, RootWindow(impl_->display, screen), + 0, 0, static_cast(width), static_cast(height), 0, + CopyFromParent, InputOutput, CopyFromParent, CWEventMask | CWOverrideRedirect, &attributes); + if (!impl_->window) { close(); return false; } + impl_->gc = XCreateGC(impl_->display, impl_->window, 0, nullptr); + impl_->wmDelete = XInternAtom(impl_->display, "WM_DELETE_WINDOW", False); + XSetWMProtocols(impl_->display, impl_->window, &impl_->wmDelete, 1); + impl_->closed = false; + setTitle(title); + if (options.borderless) { + struct MotifHints { unsigned long flags, functions, decorations; long inputMode; unsigned long status; }; + const MotifHints hints{2, 0, 0, 0, 0}; + const Atom property = XInternAtom(impl_->display, "_MOTIF_WM_HINTS", False); + XChangeProperty(impl_->display, impl_->window, property, property, 32, + PropModeReplace, reinterpret_cast(&hints), 5); + } + if (options.topmost) { + const Atom state = XInternAtom(impl_->display, "_NET_WM_STATE", False); + const Atom above = XInternAtom(impl_->display, "_NET_WM_STATE_ABOVE", False); + XChangeProperty(impl_->display, impl_->window, state, XA_ATOM, 32, + PropModeReplace, reinterpret_cast(&above), 1); + } + XSetLocaleModifiers(""); + impl_->inputMethod = XOpenIM(impl_->display, nullptr, nullptr, nullptr); + if (impl_->inputMethod) { + impl_->inputContext = XCreateIC(impl_->inputMethod, + XNInputStyle, XIMPreeditNothing | XIMStatusNothing, + XNClientWindow, impl_->window, XNFocusWindow, impl_->window, nullptr); + } + impl_->width = width; + impl_->height = height; + impl_->sink = eventSink; + return true; +} + +void LinuxWindow::show() { if (!impl_->closed) { XMapRaised(impl_->display, impl_->window); XFlush(impl_->display); } } +void LinuxWindow::hide() { if (!impl_->closed) { XUnmapWindow(impl_->display, impl_->window); XFlush(impl_->display); } } +void LinuxWindow::setTitle(const char* title) +{ + if (impl_->closed || !impl_->window) return; + const char* value = title ? title : ""; + XStoreName(impl_->display, impl_->window, value); + const Atom utf8 = XInternAtom(impl_->display, "UTF8_STRING", False); + const Atom netName = XInternAtom(impl_->display, "_NET_WM_NAME", False); + XChangeProperty(impl_->display, impl_->window, netName, utf8, 8, + PropModeReplace, reinterpret_cast(value), + static_cast(std::strlen(value))); +} +void LinuxWindow::setSize(int width, int height) { if (!impl_->closed && width > 0 && height > 0) XResizeWindow(impl_->display, impl_->window, width, height); } +void LinuxWindow::setPosition(int x, int y) { if (!impl_->closed) XMoveWindow(impl_->display, impl_->window, x, y); } + +void LinuxWindow::setCursorVisible(bool visible) +{ + if (impl_->closed) return; + if (visible) { + XUndefineCursor(impl_->display, impl_->window); + } else { + if (impl_->hiddenCursor == None) { + const char bits[1] = {0}; + Pixmap bitmap = XCreateBitmapFromData(impl_->display, impl_->window, bits, 1, 1); + XColor black{}; + impl_->hiddenCursor = XCreatePixmapCursor(impl_->display, bitmap, bitmap, &black, &black, 0, 0); + XFreePixmap(impl_->display, bitmap); + } + XDefineCursor(impl_->display, impl_->window, impl_->hiddenCursor); + } + XFlush(impl_->display); +} + +void LinuxWindow::close() +{ + if (impl_->inputContext) { XDestroyIC(impl_->inputContext); impl_->inputContext = nullptr; } + if (impl_->inputMethod) { XCloseIM(impl_->inputMethod); impl_->inputMethod = nullptr; } + if (impl_->display && impl_->hiddenCursor != None) XFreeCursor(impl_->display, impl_->hiddenCursor); + impl_->hiddenCursor = None; + if (impl_->display && impl_->gc) XFreeGC(impl_->display, impl_->gc); + impl_->gc = nullptr; + if (impl_->display && impl_->window) XDestroyWindow(impl_->display, impl_->window); + impl_->window = 0; + if (impl_->display) XCloseDisplay(impl_->display); + impl_->display = nullptr; + impl_->width = 0; + impl_->height = 0; + impl_->sink = nullptr; + impl_->keyStates.fill(false); + impl_->closed = true; +} + +bool LinuxWindow::isClosed() const { return impl_->closed; } + +void LinuxWindow::processEvents() +{ + if (impl_->closed) return; + while (XPending(impl_->display) > 0) { + XEvent event{}; + XNextEvent(impl_->display, &event); + if (XFilterEvent(&event, impl_->window)) continue; + switch (event.type) { + case ClientMessage: + if (static_cast(event.xclient.data.l[0]) == impl_->wmDelete + && (!impl_->sink || impl_->sink->onCloseRequested())) { close(); return; } + break; + case ConfigureNotify: + if (event.xconfigure.width != impl_->width || event.xconfigure.height != impl_->height) { + impl_->width = event.xconfigure.width; + impl_->height = event.xconfigure.height; + if (impl_->sink) impl_->sink->onResize(impl_->width, impl_->height); + } + break; + case FocusIn: if (impl_->inputContext) XSetICFocus(impl_->inputContext); break; + case FocusOut: + if (impl_->inputContext) XUnsetICFocus(impl_->inputContext); + if (impl_->sink) { + for (std::size_t key = 0; key < impl_->keyStates.size(); ++key) { + if (impl_->keyStates[key]) impl_->sink->onKey(static_cast(key), false, false); + } + } + impl_->keyStates.fill(false); + break; + case MotionNotify: if (impl_->sink) impl_->sink->onMouseMove(event.xmotion.x, event.xmotion.y); break; + case ButtonPress: + case ButtonRelease: { + if (!impl_->sink) break; + const bool pressed = event.type == ButtonPress; + if (pressed && (event.xbutton.button == Button4 || event.xbutton.button == Button5 + || event.xbutton.button == 6 || event.xbutton.button == 7)) { + const float dx = event.xbutton.button == 6 ? -1.0f : (event.xbutton.button == 7 ? 1.0f : 0.0f); + const float dy = event.xbutton.button == Button4 ? 1.0f : (event.xbutton.button == Button5 ? -1.0f : 0.0f); + impl_->sink->onMouseWheel(dx, dy, event.xbutton.x, event.xbutton.y); + break; + } + const int button = egeButton(event.xbutton.button); + if (button < 0) break; + int clicks = 1; + if (pressed && event.xbutton.time - impl_->clickTime[button] <= 400 + && std::abs(event.xbutton.x - impl_->clickX[button]) <= 4 + && std::abs(event.xbutton.y - impl_->clickY[button]) <= 4) clicks = 2; + if (pressed) { impl_->clickTime[button] = event.xbutton.time; impl_->clickX[button] = event.xbutton.x; impl_->clickY[button] = event.xbutton.y; } + impl_->sink->onMouseButton(button, pressed, event.xbutton.x, event.xbutton.y, clicks); + break; + } + case KeyPress: + case KeyRelease: { + bool pressed = event.type == KeyPress; + bool repeat = false; + if (!pressed && XPending(impl_->display) > 0) { + XEvent next{}; + XPeekEvent(impl_->display, &next); + if (next.type == KeyPress && next.xkey.keycode == event.xkey.keycode + && next.xkey.time == event.xkey.time) { XNextEvent(impl_->display, &event); repeat = pressed = true; } + } + KeySym symbol = NoSymbol; + char buffer[64]{}; + int length = 0; + if (pressed && impl_->inputContext) { + Status status = 0; + length = Xutf8LookupString(impl_->inputContext, &event.xkey, buffer, + static_cast(sizeof(buffer)), &symbol, &status); + if (status == XBufferOverflow) length = 0; + } else { + length = XLookupString(&event.xkey, buffer, static_cast(sizeof(buffer)), &symbol, nullptr); + } + const std::uint32_t key = windowsVirtualKey(symbol); + if (key < impl_->keyStates.size()) impl_->keyStates[key] = pressed; + if (impl_->sink && key) impl_->sink->onKey(key, pressed, repeat); + if (pressed && length > 0) emitUtf8(impl_->sink, buffer, length); + break; + } + default: break; + } + } +} + +void LinuxWindow::present(const std::uint32_t* pixels, int width, int height, + std::size_t strideBytes) +{ + if (impl_->closed || !pixels || width <= 0 || height <= 0) return; + const int screen = DefaultScreen(impl_->display); + XImage* image = XCreateImage(impl_->display, DefaultVisual(impl_->display, screen), + static_cast(DefaultDepth(impl_->display, screen)), ZPixmap, 0, nullptr, + static_cast(width), static_cast(height), 32, 0); + if (!image) return; + image->data = static_cast(std::malloc(static_cast(image->bytes_per_line) * height)); + if (!image->data) { image->data = nullptr; XDestroyImage(image); return; } + const int copyBytes = std::min(image->bytes_per_line, width * 4); + for (int y = 0; y < height; ++y) { + std::memcpy(image->data + static_cast(y) * image->bytes_per_line, + reinterpret_cast(pixels) + static_cast(y) * strideBytes, + static_cast(copyBytes)); + } + XPutImage(impl_->display, impl_->window, impl_->gc, image, 0, 0, 0, 0, + static_cast(std::min(width, impl_->width)), + static_cast(std::min(height, impl_->height))); + XDestroyImage(image); + XFlush(impl_->display); +} + +void* LinuxWindow::getNativeHandle() const { return reinterpret_cast(static_cast(impl_->window)); } +int LinuxWindow::getWidth() const { return impl_->width; } +int LinuxWindow::getHeight() const { return impl_->height; } + +bool LinuxWindow::inputBox(const char* title, const char* prompt, std::string* value) +{ + if (!value) return false; + Display* display = XOpenDisplay(nullptr); + if (!display) return false; + const int screen = DefaultScreen(display); + ::Window window = XCreateSimpleWindow(display, RootWindow(display, screen), 100, 100, 520, 90, 1, + BlackPixel(display, screen), WhitePixel(display, screen)); + const std::string caption = std::string(title ? title : "Input") + " - " + (prompt ? prompt : ""); + XStoreName(display, window, caption.c_str()); + XSelectInput(display, window, ExposureMask | KeyPressMask); + Atom wmDelete = XInternAtom(display, "WM_DELETE_WINDOW", False); + XSetWMProtocols(display, window, &wmDelete, 1); + XMapRaised(display, window); + GC gc = XCreateGC(display, window, 0, nullptr); + std::string input = *value; + bool accepted = false; + bool done = false; + while (!done) { + XEvent event{}; + XNextEvent(display, &event); + if (event.type == ClientMessage) done = true; + else if (event.type == Expose) { + XClearWindow(display, window); + XDrawString(display, window, gc, 12, 35, prompt ? prompt : "", static_cast(std::strlen(prompt ? prompt : ""))); + XDrawString(display, window, gc, 12, 65, input.c_str(), static_cast(input.size())); + } else if (event.type == KeyPress) { + char bytes[64]{}; + KeySym symbol = NoSymbol; + const int length = XLookupString(&event.xkey, bytes, sizeof(bytes), &symbol, nullptr); + if (symbol == XK_Return || symbol == XK_KP_Enter) { accepted = true; done = true; } + else if (symbol == XK_Escape) done = true; + else if (symbol == XK_BackSpace && !input.empty()) { + std::size_t start = input.size() - 1; + while (start > 0 && (static_cast(input[start]) & 0xC0u) == 0x80u) --start; + input.erase(start); + } + else if (length > 0) input.append(bytes, static_cast(length)); + XClearArea(display, window, 0, 0, 0, 0, True); + } + } + if (accepted) *value = input; + XFreeGC(display, gc); + XDestroyWindow(display, window); + XCloseDisplay(display); + return accepted; +} + +} // namespace backend +} // namespace ege diff --git a/src/backend/linux/LinuxWindow.h b/src/backend/linux/LinuxWindow.h new file mode 100644 index 00000000..40fd5413 --- /dev/null +++ b/src/backend/linux/LinuxWindow.h @@ -0,0 +1,52 @@ +#ifndef EGE_BACKEND_LINUX_LINUX_WINDOW_H +#define EGE_BACKEND_LINUX_LINUX_WINDOW_H + +#include "backend/interface/Window.h" + +#include +#include + +namespace ege +{ +namespace backend +{ + +/** A small Xlib window used by the native Cairo backend. */ +class LinuxWindow final : public Window +{ +public: + LinuxWindow(); + ~LinuxWindow() override; + + LinuxWindow(const LinuxWindow&) = delete; + LinuxWindow& operator=(const LinuxWindow&) = delete; + + static bool primaryScreenSize(int* width, int* height); + static bool inputBox(const char* title, const char* prompt, std::string* value); + + bool create(int width, int height, const char* title, + const WindowOptions& options, WindowEventSink* eventSink) override; + void show() override; + void hide() override; + void setTitle(const char* title) override; + void setSize(int width, int height) override; + void setPosition(int x, int y) override; + void setCursorVisible(bool visible) override; + void close() override; + bool isClosed() const override; + void processEvents() override; + void present(const std::uint32_t* pixels, int width, int height, + std::size_t strideBytes) override; + void* getNativeHandle() const override; + int getWidth() const override; + int getHeight() const override; + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace backend +} // namespace ege + +#endif diff --git a/src/camera_capture.cpp b/src/camera_capture.cpp index e511d74a..b39ef1b5 100644 --- a/src/camera_capture.cpp +++ b/src/camera_capture.cpp @@ -8,6 +8,7 @@ #if EGE_ENABLE_CAMERA_CAPTURE #include +#include #include #include #include diff --git a/src/ege_gdiplus_fallback.cpp b/src/ege_gdiplus_fallback.cpp index 4badec7c..13d672a3 100644 --- a/src/ege_gdiplus_fallback.cpp +++ b/src/ege_gdiplus_fallback.cpp @@ -2477,36 +2477,51 @@ void ege_path_widen(ege_path* path, float lineWidth, const ege_transform_matrix* const float halfWidth = lineWidth * 0.5f; for (std::size_t figureIndex = 0; figureIndex < source.size(); ++figureIndex) { const FlatFigure& figure = source[figureIndex]; - if (figure.points.empty()) continue; + if (figure.points.size() < 2) continue; const std::size_t edgeCount = figure.closed ? figure.points.size() : figure.points.size() - 1; + std::vector normals(edgeCount); for (std::size_t edge = 0; edge < edgeCount; ++edge) { const ege_point& first = figure.points[edge]; const ege_point& second = figure.points[(edge + 1) % figure.points.size()]; const float dx = second.x - first.x; const float dy = second.y - first.y; const float length = std::sqrt(dx * dx + dy * dy); - if (length <= 1e-6f) continue; - const float nx = -dy * halfWidth / length; - const float ny = dx * halfWidth / length; - FlatFigure quad; - quad.closed = true; - quad.points.push_back({first.x + nx, first.y + ny}); - quad.points.push_back({second.x + nx, second.y + ny}); - quad.points.push_back({second.x - nx, second.y - ny}); - quad.points.push_back({first.x - nx, first.y - ny}); - widened.push_back(quad); - } - for (std::size_t vertex = 0; vertex < figure.points.size(); ++vertex) { - FlatFigure cap; - cap.closed = true; - const int segments = 16; - for (int segment = 0; segment < segments; ++segment) { - const float angle = 2.0f * kPi * segment / segments; - cap.points.push_back({figure.points[vertex].x + std::cos(angle) * halfWidth, - figure.points[vertex].y + std::sin(angle) * halfWidth}); - } - widened.push_back(cap); + if (length > 1e-6f) normals[edge] = {-dy * halfWidth / length, dx * halfWidth / length}; } + const auto offsetVertex = [&](std::size_t vertex, float side) { + if (!figure.closed && vertex == 0) return ege_point{ + figure.points[0].x + side * normals[0].x, + figure.points[0].y + side * normals[0].y}; + if (!figure.closed && vertex + 1 == figure.points.size()) return ege_point{ + figure.points[vertex].x + side * normals[edgeCount - 1].x, + figure.points[vertex].y + side * normals[edgeCount - 1].y}; + const std::size_t previous = (vertex + edgeCount - 1) % edgeCount; + const std::size_t next = vertex % edgeCount; + const ege_point p = figure.points[vertex]; + const ege_point a = figure.points[previous]; + const ege_point b = figure.points[(next + 1) % figure.points.size()]; + const float d1x = p.x - a.x, d1y = p.y - a.y; + const float d2x = b.x - p.x, d2y = b.y - p.y; + const float denominator = d1x * d2y - d1y * d2x; + if (std::abs(denominator) <= 1e-6f) return ege_point{ + p.x + side * (normals[previous].x + normals[next].x) * 0.5f, + p.y + side * (normals[previous].y + normals[next].y) * 0.5f}; + const float qx = side * (normals[next].x - normals[previous].x); + const float qy = side * (normals[next].y - normals[previous].y); + const float amount = (qx * d2y - qy * d2x) / denominator; + const ege_point candidate = { + p.x + side * normals[previous].x + amount * d1x, + p.y + side * normals[previous].y + amount * d1y}; + return point_distance(candidate, p) <= lineWidth * 10.0f + ? candidate : ege_point{p.x + side * normals[next].x, p.y + side * normals[next].y}; + }; + FlatFigure outline; + outline.closed = true; + for (std::size_t vertex = 0; vertex < figure.points.size(); ++vertex) + outline.points.push_back(offsetVertex(vertex, 1.0f)); + for (std::size_t vertex = figure.points.size(); vertex-- > 0;) + outline.points.push_back(offsetVertex(vertex, -1.0f)); + widened.push_back(outline); } assign_flattened(*data, widened); data->fillMode = FILLMODE_WINDING; @@ -2602,7 +2617,10 @@ bool ege_path_inpath(const ege_path* path, float x, float y, PCIMAGE pimg) point_in_figures(flatten_figures(*data), data->fillMode, source.x, source.y); } -static bool path_point_in_stroke(const ege_path* path, float x, float y, float lineWidth) +static bool path_point_in_stroke(const ege_path* path, float x, float y, float lineWidth, + int lineStyle = PS_SOLID, + line_cap_type startCap = LINECAP_FLAT, + line_cap_type endCap = LINECAP_FLAT) { const PathData* data = path_data(path); if (data == NULL) return false; @@ -2612,9 +2630,28 @@ static bool path_point_in_stroke(const ege_path* path, float x, float y, float l const FlatFigure& figure = figures[figureIndex]; if (figure.points.size() < 2) continue; const std::size_t edgeCount = figure.closed ? figure.points.size() : figure.points.size() - 1; + float distanceAlongFigure = 0.0f; for (std::size_t edge = 0; edge < edgeCount; ++edge) { - if (distance_to_segment(x, y, figure.points[edge], - figure.points[(edge + 1) % figure.points.size()]) <= tolerance) return true; + const ege_point& first = figure.points[edge]; + const ege_point& second = figure.points[(edge + 1) % figure.points.size()]; + const float dx = second.x - first.x, dy = second.y - first.y; + const float length = std::sqrt(dx * dx + dy * dy); + if (length <= 1e-6f) continue; + const float projection = ((x - first.x) * dx + (y - first.y) * dy) / length; + float minimum = 0.0f, maximum = length; + if (!figure.closed && edge == 0 && startCap == LINECAP_SQUARE) minimum -= tolerance; + if (!figure.closed && edge + 1 == edgeCount && endCap == LINECAP_SQUARE) maximum += tolerance; + if (projection >= minimum && projection <= maximum && + distance_to_segment(x, y, first, second) <= tolerance) { + float onLength = 1e9f, offLength = 0.0f; + if (lineStyle == PS_DASH) { onLength = 3.0f; offLength = 3.0f; } + else if (lineStyle == PS_DOT) { onLength = 1.0f; offLength = 2.0f; } + else if (lineStyle == PS_DASHDOT) { onLength = 3.0f; offLength = 2.0f; } + const float period = onLength + offLength; + if (offLength == 0.0f || std::fmod(distanceAlongFigure + std::max(0.0f, projection), period) < onLength) + return true; + } + distanceAlongFigure += length; } } return false; @@ -2652,7 +2689,8 @@ bool ege_path_instroke(const ege_path* path, float x, float y, PCIMAGE pimg) y - static_cast(image->m_vpt.top)}; ege_point source; return inverse_transform_point(viewportLocal, transform, source) && - path_point_in_stroke(path, source.x, source.y, image->m_linewidth); + path_point_in_stroke(path, source.x, source.y, image->m_linewidth, + image->m_linestyle.linestyle, image->m_linestartcap, image->m_lineendcap); #endif } @@ -2876,6 +2914,10 @@ void ege_path_addtext(ege_path* path, float x, float y, const char* text, float if (add_coretext_outlines(path, x, y, utf8, height, fontName, fontStyle)) return; #else (void)typeface; + const std::wstring wide = utf82w(std::string(text, text + count).c_str()); + ege_path_addtext(path, x, y, wide.c_str(), height, + static_cast(wide.size()), static_cast(NULL), fontStyle); + return; #endif std::unique_ptr drawable(new(std::nothrow) bool[count]); if (!drawable && count != 0) return; diff --git a/src/egegapi.cpp b/src/egegapi.cpp index f605cfbe..62dd8668 100644 --- a/src/egegapi.cpp +++ b/src/egegapi.cpp @@ -24,6 +24,8 @@ #if defined(EGE_BACKEND_COREGRAPHICS) #include "backend/macos/MacWindow.h" +#elif defined(EGE_BACKEND_CAIRO) +#include "backend/linux/LinuxWindow.h" #endif #include @@ -3017,7 +3019,7 @@ int inputbox_getline(const wchar_t* title, const wchar_t* text, LPWSTR buf, int getflush(); return ret; } -#elif defined(EGE_BACKEND_COREGRAPHICS) +#elif defined(EGE_BACKEND_COREGRAPHICS) || defined(EGE_BACKEND_CAIRO) static std::size_t completeUTF8PrefixLength( const std::string& value, std::size_t capacity) { @@ -3038,7 +3040,11 @@ int inputbox_getline(const char* title, const char* text, LPSTR buf, int len) } buf[0] = '\0'; std::string value; +#if defined(EGE_BACKEND_COREGRAPHICS) if (!backend::MacWindow::inputBox(title, text, &value)) { +#else + if (!backend::LinuxWindow::inputBox(title, text, &value)) { +#endif return 0; } const std::size_t count = completeUTF8PrefixLength( @@ -3057,7 +3063,11 @@ int inputbox_getline(const wchar_t* title, const wchar_t* text, LPWSTR buf, int std::string value; const std::string titleUTF8 = w2utf8(title ? title : L""); const std::string textUTF8 = w2utf8(text ? text : L""); +#if defined(EGE_BACKEND_COREGRAPHICS) if (!backend::MacWindow::inputBox( +#else + if (!backend::LinuxWindow::inputBox( +#endif titleUTF8.c_str(), textUTF8.c_str(), &value)) { return 0; } diff --git a/src/graphics.cpp b/src/graphics.cpp index da72b4f1..66700371 100644 --- a/src/graphics.cpp +++ b/src/graphics.cpp @@ -51,6 +51,8 @@ #include "window.h" #if defined(EGE_BACKEND_COREGRAPHICS) #include "backend/macos/MacWindow.h" +#elif defined(EGE_BACKEND_CAIRO) +#include "backend/linux/LinuxWindow.h" #endif #ifdef _ITERATOR_DEBUG_LEVEL @@ -89,7 +91,7 @@ namespace ege // 静态分配,零初始化 struct _graph_setting graph_setting; -#if defined(EGE_BACKEND_COREGRAPHICS) +#if defined(EGE_BACKEND_COREGRAPHICS) || defined(EGE_BACKEND_CAIRO) static bool is_headless_mode() { const char* value = getenv("EGE_HEADLESS"); @@ -468,6 +470,10 @@ void setmode(int gdriver, int gmode) int desktopWidth = 640; int desktopHeight = 480; (void)backend::MacWindow::primaryScreenSize(&desktopWidth, &desktopHeight); +#elif defined(EGE_BACKEND_CAIRO) + int desktopWidth = 640; + int desktopHeight = 480; + (void)backend::LinuxWindow::primaryScreenSize(&desktopWidth, &desktopHeight); #endif pg->dc_w = (short)(gmode & 0xFFFF); pg->dc_h = (short)((unsigned int)gmode >> 16); @@ -476,6 +482,8 @@ void setmode(int gdriver, int gmode) pg->dc_w = rect.right - rect.left; #elif defined(EGE_BACKEND_COREGRAPHICS) pg->dc_w = desktopWidth; +#elif defined(EGE_BACKEND_CAIRO) + pg->dc_w = desktopWidth; #else pg->dc_w = 640; #endif @@ -485,6 +493,8 @@ void setmode(int gdriver, int gmode) pg->dc_h = rect.bottom - rect.top; #elif defined(EGE_BACKEND_COREGRAPHICS) pg->dc_h = desktopHeight; +#elif defined(EGE_BACKEND_CAIRO) + pg->dc_h = desktopHeight; #else pg->dc_h = 480; #endif @@ -713,7 +723,7 @@ static void push_mouse_msg(struct _graph_setting* pg, UINT message, WPARAM wpara pg->msgmouse_queue->push(msg); } -#if defined(EGE_BACKEND_COREGRAPHICS) +#if defined(EGE_BACKEND_COREGRAPHICS) || defined(EGE_BACKEND_CAIRO) class NativeWindowEventSink final : public WindowEventSink { public: @@ -1290,6 +1300,28 @@ void initgraph(int* gdriver, int* gmode, const char* path) graph_init(pg); } pg->init_sem.add_permit(); +#elif defined(EGE_BACKEND_CAIRO) + pg->window = NULL; + pg->hwnd = NULL; + if (!is_headless_mode()) { + static NativeWindowEventSink nativeEventSink(pg); + pg->window = new backend::LinuxWindow(); + WindowOptions windowOptions; + windowOptions.borderless = (g_initoption & INIT_NOBORDER) != 0; + windowOptions.topmost = (g_initoption & INIT_TOPMOST) != 0; + if (!pg->window->create(width, height, "EGE Window", windowOptions, &nativeEventSink)) { + delete pg->window; + pg->window = NULL; + pg->exit_window = 1; + pg->exit_flag = 1; + return; + } + pg->hwnd = reinterpret_cast(pg->window->getNativeHandle()); + } + if (pg->dc == 0) { + graph_init(pg); + } + pg->init_sem.add_permit(); #else #error "No native EGE window backend is configured for this target" #endif diff --git a/src/image.cpp b/src/image.cpp index e626bf56..60c82d8c 100644 --- a/src/image.cpp +++ b/src/image.cpp @@ -117,6 +117,8 @@ inline FILE* openWideFile(const wchar_t* filename, const wchar_t* mode) { #include "image.h" #ifdef EGE_BACKEND_COREGRAPHICS #include "backend/macos/CoreGraphicsRenderTarget.h" +#elif defined(EGE_BACKEND_CAIRO) +#include "backend/linux/CairoRenderTarget.h" #endif // #ifdef _ITERATOR_DEBUG_LEVEL // #undef _ITERATOR_DEBUG_LEVEL @@ -470,6 +472,16 @@ void IMAGE::initimage(HDC refDC, int width, int height) m_pBuffer = NULL; internal_panic(L"Fatal Error: create Core Graphics bitmap context failed in 'IMAGE::initimage'"); } +#elif defined(EGE_BACKEND_CAIRO) + try { + m_renderTarget = new backend::CairoRenderTarget( + std::max(1, width), std::max(1, height), false); + m_pBuffer = reinterpret_cast(m_renderTarget->getPixelBuffer()); + } catch (const std::exception&) { + m_renderTarget = NULL; + m_pBuffer = NULL; + internal_panic(L"Fatal Error: create Cairo bitmap context failed in 'IMAGE::initimage'"); + } #else m_pBuffer = width > 0 && height > 0 ? new DWORD[width * height]() : NULL; #endif @@ -621,7 +633,7 @@ int IMAGE::updatebuffer(int x, int y, int width, int height, image_storage_mode IMAGE::getStorageMode() const { -#ifdef EGE_BACKEND_COREGRAPHICS +#if defined(EGE_BACKEND_COREGRAPHICS) || defined(EGE_BACKEND_CAIRO) return IMAGE_STORAGE_CPU_BITMAP; #else return m_renderTarget ? IMAGE_STORAGE_GPU : IMAGE_STORAGE_CPU_BITMAP; @@ -636,9 +648,9 @@ int IMAGE::setStorageMode(image_storage_mode mode, bool preservePixels) if (mode == getStorageMode()) { return grOk; } -#ifdef EGE_BACKEND_COREGRAPHICS +#if defined(EGE_BACKEND_COREGRAPHICS) || defined(EGE_BACKEND_CAIRO) (void)preservePixels; - // Core Graphics always draws directly into the CPU-authoritative surface. + // Native CPU renderers always draw directly into the authoritative surface. return grInvalidMode; #else if (mode == IMAGE_STORAGE_GPU) { @@ -984,6 +996,21 @@ int IMAGE::resize_f(int width, int height) } m_pBuffer = reinterpret_cast(target->getPixelBuffer()); } +#elif defined(EGE_BACKEND_CAIRO) + if (m_renderTarget && (width != oldWindowSize.width || height != oldWindowSize.height)) { + backend::CairoRenderTarget* target = + dynamic_cast(m_renderTarget); + if (regenerateTexture) { + gentexture(false); + } + if (!target || !target->resize(std::max(1, width), std::max(1, height), false)) { + if (regenerateTexture) { + gentexture(true); + } + return grAllocError; + } + m_pBuffer = reinterpret_cast(target->getPixelBuffer()); + } #endif m_width = width; diff --git a/src/image_ex.cpp b/src/image_ex.cpp index f62461be..be0e7b93 100644 --- a/src/image_ex.cpp +++ b/src/image_ex.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include namespace ege diff --git a/src/window.cpp b/src/window.cpp index 65d9104d..481b20f4 100644 --- a/src/window.cpp +++ b/src/window.cpp @@ -5,6 +5,8 @@ #if defined(EGE_BACKEND_COREGRAPHICS) #include "backend/macos/MacWindow.h" +#elif defined(EGE_BACKEND_CAIRO) +#include "backend/linux/LinuxWindow.h" #endif #define STYLE_NORMAL (WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_CLIPCHILDREN) @@ -194,6 +196,11 @@ void getParentSize(int* width, int* height) *width = 640; *height = 480; } +#elif defined(EGE_BACKEND_CAIRO) + if (!backend::LinuxWindow::primaryScreenSize(width, height)) { + *width = 640; + *height = 480; + } #else *width = 640; *height = 480; diff --git a/tasks.sh b/tasks.sh index 40045f7d..f61175c4 100755 --- a/tasks.sh +++ b/tasks.sh @@ -42,6 +42,12 @@ function isNativeMacOS() { ! cmakeDefinitionEquals "CMAKE_SYSTEM_NAME" "Windows" } +function isNativeLinux() { + [[ "$(uname -s)" == "Linux" ]] && ! isWindows && + ! hasCMakeDefinition "CMAKE_TOOLCHAIN_FILE" && + ! cmakeDefinitionEquals "CMAKE_SYSTEM_NAME" "Windows" +} + function hasCMakeDefinition() { local definition="$1" local argument @@ -151,6 +157,10 @@ function getBuildDir() { echo "$base_dir/macos/$CMAKE_BUILD_TYPE" return fi + if isNativeLinux; then + echo "$base_dir/linux/$CMAKE_BUILD_TYPE" + return + fi # MinGW 和其他编译器需要手动区分 Debug/Release 目录 echo "$base_dir/$CMAKE_BUILD_TYPE" @@ -482,12 +492,26 @@ if isNativeMacOS; then fi fi +if isNativeLinux; then + if ! hasCMakeDefinition "EGE_DEFAULT_BACKEND"; then + CMAKE_CONFIG_DEFINE+=("-DEGE_DEFAULT_BACKEND=CAIRO") + fi + if ! hasCMakeDefinition "EGE_ENABLE_OPENGL"; then + CMAKE_CONFIG_DEFINE+=("-DEGE_ENABLE_OPENGL=OFF") + fi + if ! hasCMakeDefinition "EGE_ENABLE_WINDOW_TESTS"; then + CMAKE_CONFIG_DEFINE+=("-DEGE_ENABLE_WINDOW_TESTS=OFF") + fi +fi + # 参数解析完成后,初始化 CMAKE_BUILD_DIR CMAKE_BUILD_DIR="$(getBuildDir)" export CMAKE_BUILD_DIR echo "Build directory: $CMAKE_BUILD_DIR (BUILD_TYPE: $CMAKE_BUILD_TYPE)" if isNativeMacOS; then echo "macOS native build: AppleClang/CoreGraphics (headless tests by default)" +elif isNativeLinux; then + echo "Linux native build: Cairo/Xlib (headless tests by default)" fi if [[ "$DO_SHOW_CONFIG" == true ]]; then @@ -565,13 +589,17 @@ if [[ "$DO_TEST_RELEASE_LIBS" == true ]]; then mkdir -p "$OUTPUT_DIR" echo "Copying executables to $OUTPUT_DIR" cd "$CMAKE_BUILD_DIR" - if isNativeMacOS; then - # Standalone prebuilt-package demos are extensionless Mach-O - # executables in the build root. Exclude CMake probes and metadata. + if isNativeMacOS || isNativeLinux; then + # Native package demos are extensionless executables. Exclude + # CMake probes and metadata. find . -maxdepth 2 -type f -perm -111 -print0 | while IFS= read -r -d '' file; do [[ "$file" == */CMakeFiles/* ]] && continue - file "$file" | grep -q "Mach-O.*executable" || continue + if isNativeMacOS; then + file "$file" | grep -q "Mach-O.*executable" || continue + else + file "$file" | grep -q "ELF.*executable" || continue + fi relative_path="${file#./}" mkdir -p "$OUTPUT_DIR/$(dirname "$relative_path")" cp "$file" "$OUTPUT_DIR/$relative_path" @@ -598,8 +626,8 @@ if [[ -n "$RUN_EXECUTABLE" ]]; then [[ "$RUN_EXECUTABLE" == *.exe ]] || RUN_EXECUTABLE="${RUN_EXECUTABLE}.exe" exe_path="$CMAKE_BUILD_DIR/demo/$CMAKE_BUILD_TYPE/$RUN_EXECUTABLE" else - if isNativeMacOS; then - # Native CMake targets are extensionless Mach-O executables. + if isNativeMacOS || isNativeLinux; then + # Native CMake targets are extensionless executables. RUN_EXECUTABLE="${RUN_EXECUTABLE%.exe}" elif [[ "$RUN_EXECUTABLE" != *.exe ]] && ! isWindows; then # Non-native Unix invocations historically cross-compile Windows. @@ -608,7 +636,7 @@ if [[ -n "$RUN_EXECUTABLE" ]]; then exe_path="$CMAKE_BUILD_DIR/demo/$RUN_EXECUTABLE" fi - if isWindows || isNativeMacOS; then + if isWindows || isNativeMacOS || isNativeLinux; then echo "run $exe_path" "$exe_path" else diff --git a/tests/native/CMakeLists.txt b/tests/native/CMakeLists.txt index 47b403ee..126b257b 100644 --- a/tests/native/CMakeLists.txt +++ b/tests/native/CMakeLists.txt @@ -1,4 +1,6 @@ -if(NOT APPLE OR NOT EGE_RESOLVED_BACKEND STREQUAL "COREGRAPHICS") +if(NOT (APPLE AND EGE_RESOLVED_BACKEND STREQUAL "COREGRAPHICS") + AND NOT (CMAKE_SYSTEM_NAME STREQUAL "Linux" + AND EGE_RESOLVED_BACKEND STREQUAL "CAIRO")) return() endif() @@ -55,11 +57,13 @@ ege_add_native_test(ege_native_control_focus_contract native.ege_control_focus_contract ege_control_focus_contract_test.cpp) -ege_add_native_test(ege_native_music_contract - native.ege_music_contract - ege_music_contract_test.cpp) -target_compile_definitions(ege_native_music_contract PRIVATE - EGE_TEST_ARTIFACT_DIR="${CMAKE_BINARY_DIR}/test-artifacts") +if(APPLE) + ege_add_native_test(ege_native_music_contract + native.ege_music_contract + ege_music_contract_test.cpp) + target_compile_definitions(ege_native_music_contract PRIVATE + EGE_TEST_ARTIFACT_DIR="${CMAKE_BINARY_DIR}/test-artifacts") +endif() add_executable(ege_native_process_exit_contract ege_process_exit_contract.cpp) @@ -154,3 +158,59 @@ if(APPLE AND EGE_RESOLVED_BACKEND STREQUAL "COREGRAPHICS") "${EGE_TEST_COREGRAPHICS_FRAMEWORK}") endif() endif() + +if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND EGE_RESOLVED_BACKEND STREQUAL "CAIRO") + ege_add_native_test(ege_native_cairo_render_target + native.cairo_render_target + cairo_render_target_test.cpp) + target_link_libraries(ege_native_cairo_render_target PRIVATE + PkgConfig::EGE_CAIRO) + + ege_add_native_test(ege_native_api_smoke native.ege_api_smoke + ege_api_smoke.cpp) + target_compile_definitions(ege_native_api_smoke PRIVATE + EGE_TEST_ARTIFACT_DIR="${CMAKE_BINARY_DIR}/test-artifacts") + set_tests_properties(native.ege_api_smoke PROPERTIES + ENVIRONMENT "EGE_HEADLESS=1" + TIMEOUT 15) + + if(EGE_ENABLE_WINDOW_TESTS) + ege_add_native_test(ege_native_linux_window_smoke + native.linux_window_smoke + linux_window_smoke.cpp) + target_link_libraries(ege_native_linux_window_smoke PRIVATE + PkgConfig::EGE_X11) + set_tests_properties(native.linux_window_smoke PROPERTIES + RUN_SERIAL TRUE + TIMEOUT 15) + endif() + + if(EGE_ENABLE_CAMERA_TESTS) + if(NOT EGE_ENABLE_CAMERA_CAPTURE) + message(FATAL_ERROR + "EGE_ENABLE_CAMERA_TESTS=ON requires EGE_ENABLE_CAMERA_CAPTURE=ON") + endif() + + add_library(ege_native_fake_v4l2 SHARED fake_v4l2_preload.cpp) + target_compile_features(ege_native_fake_v4l2 PRIVATE cxx_std_17) + target_link_libraries(ege_native_fake_v4l2 PRIVATE ${CMAKE_DL_LIBS}) + + add_executable(ege_native_camera_capture_virtual + ege_camera_capture_virtual_test.cpp) + target_compile_features(ege_native_camera_capture_virtual PRIVATE cxx_std_17) + target_include_directories(ege_native_camera_capture_virtual PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}") + target_compile_definitions(ege_native_camera_capture_virtual PRIVATE + EGE_TEST_ARTIFACT_DIR="${CMAKE_BINARY_DIR}/test-artifacts") + target_link_libraries(ege_native_camera_capture_virtual PRIVATE xege) + + add_test(NAME native.ege_camera_capture_virtual + COMMAND "${CMAKE_COMMAND}" -E env + "EGE_TEST_V4L2_PATH=/dev/video99" + "LD_PRELOAD=$" + "$") + set_tests_properties(native.ege_camera_capture_virtual PROPERTIES + RUN_SERIAL TRUE + TIMEOUT 15) + endif() +endif() diff --git a/tests/native/TEST_MATRIX.md b/tests/native/TEST_MATRIX.md index 8059e757..b347fc1f 100644 --- a/tests/native/TEST_MATRIX.md +++ b/tests/native/TEST_MATRIX.md @@ -1,15 +1,15 @@ # Native backend test matrix This matrix makes the implemented native surface auditable without claiming -that every legacy Win32-compatible API has a macOS equivalent. `Runtime` means a +that every legacy Win32-compatible API has a native equivalent. `Runtime` means a deterministic executable assertion; `Compile/link` means every public header is compiled and linked against `xege`; `Platform exclusion` is a checked build condition, not a pass by omission. -| Area | Evidence | Current native-macOS coverage | +| Area | Evidence | Native macOS/Linux coverage | | --- | --- | --- | | Public C/C++ headers (English and Chinese) | `native.public_headers_en`, `native.public_headers_zh` | Compile/link | -| CPU pixel surface and raster operation core | `native.pixel_surface`, `native.coregraphics_surface`, `native.coregraphics_render_target` | Runtime | +| CPU pixel surface and raster operation core | `native.pixel_surface`, `native.coregraphics_surface`, `native.coregraphics_render_target`, `native.cairo_render_target` | Runtime | | Public pixel, primitive, viewport and blit APIs | `native.ege_raster_image_contract` | Runtime, exact pixel assertions | | Enhanced GDI+-compatible API (curves, paths, transforms, gradients and textures) | `native.ege_enhanced_api_contract` calls every public enhanced declaration and overload | Compile/link plus deterministic geometry, pixel, invalid-input and lifetime assertions | | Save/decode image APIs | `native.ege_raster_image_contract` produces `raster-contract.png` and `.bmp`, then decodes and recognises line/shape pixels | Runtime + visual artifact | @@ -17,8 +17,8 @@ condition, not a pass by omission. | Control-tree focus lifetime | `native.ege_control_focus_contract` | Runtime detach, reparent and intermediate-destruction assertions | | MUSIC file/error lifecycle | `native.ege_music_contract` | Runtime with generated silent WAV plus missing/corrupt inputs; no audible playback or hardware-output assertion | | Process teardown | `native.ege_process_exit_contract` | Runtime subprocess verifies EGE preserves the application's non-zero return code | -| Global canvas and public API | `native.ege_api_smoke` produces `ege-api-headless.png` | Runtime without NSApplication or NSWindow; saved image is decoded and checked pixel-by-pixel | -| Native window/options/event bridge | `native.mac_window_smoke` covers sizing/styles, presentation lifetime, keyboard/text, left/right modifiers, double-click/X buttons, and backend close veto | Visible manual opt-in only with `EGE_ENABLE_WINDOW_TESTS=ON`; absent from the default CTest suite | +| Global canvas and public API | `native.ege_api_smoke` produces `ege-api-headless.png` | Runtime without AppKit/X11; saved image is decoded and checked pixel-by-pixel | +| Native window/options/event bridge | `native.mac_window_smoke`, `native.linux_window_smoke` cover sizing, presentation, keyboard/text, mouse/wheel/double-click and close veto | Opt-in with `EGE_ENABLE_WINDOW_TESTS=ON`; Linux CI runs the Xlib test under Xvfb | | Public Objective-C++ header interop | `native.public_objcxx_headers` compiles EGE/AppKit headers in both include orders | Headless; part of the default CTest suite | | Public close adapter | `native.public_close_callback_contract` verifies the public `SetCloseHandler` notification in a sentinel child process | Visible manual opt-in only; the sentinel detects accidental `exit(0)` during teardown | | Demo programs | `demos` build target; optional `demo.*.launch` tests | Build-only by default; visible launches require `EGE_ENABLE_WINDOW_TESTS=ON` | @@ -26,8 +26,9 @@ condition, not a pass by omission. | macOS prebuilt SDK | `Release Package` and `macOS Native CoreGraphics Build` workflows | Universal `arm64`/`x86_64` archive validation and all-demo link against the packaged CMake configuration | | Windows compatibility | `cmake.mingw_toolchain_contract` plus Windows/Linux workflows | The local contract validates toolchain isolation and only configures when a compiler exists; actual library/demo builds belong to Windows or Linux-MinGW CI | | Camera samples | `camera_base`, `camera_wave`; enabling camera capture requires the `3rdparty/ccap` sources to already be present (normally via a recursive submodule checkout; CMake never initializes the submodule) | Compile/link in the `demos` target; live camera startup is an explicit hardware test so ordinary CTest does not access devices or show permission UI | +| Linux camera capture bridge | `native.ege_camera_capture_virtual` | Opt-in `/dev/video99` placeholder plus userspace V4L2 emulation covers device enumeration, format negotiation, mmap streaming, YUYV-to-BGRA conversion, `CameraFrame`/`IMAGE` and saved-PNG assertions; enabled in Linux CI without camera hardware | | `graph_star` | Windows screensaver with Win32 preview-parent APIs | Windows-only exclusion on macOS/Linux | -| Linux native backend | Cairo selection currently fails fast because no backend is implemented | Known implementation gap, not accepted as covered | +| Linux native backend | `Linux native Cairo build` workflow | Recursive ccap checkout, all 570 ccap tests with a virtual V4L2 camera, Cairo/Xlib build, 16 XEGE contracts, camera demos, Xvfb window integration and dependency-boundary check | ## Acceptance commands @@ -53,6 +54,11 @@ raster contract and the `initgraph` global-canvas contract render without a window, encode their result, decode it through EGE, and assert characteristic pixels. Default test commands must never enable `EGE_ENABLE_WINDOW_TESTS`. +Linux CI uses the equivalent Cairo configuration with +`EGE_ENABLE_WINDOW_TESTS=ON`, `EGE_ENABLE_CAMERA_TESTS=ON`, and runs CTest +inside `xvfb-run`. + Known exclusions are part of the contract: `sys_edit`, Win32 handles/resources, -`graph_star`, live camera permission/device behavior, audible output, Intel-Mac -runtime, and a native Linux backend are not covered by the default native suite. +`graph_star`, live camera permission/device behavior, audible output and +Intel-Mac runtime are not covered by the default native suite. Advanced text +shaping is intentionally outside the minimal Cairo toy-font backend. diff --git a/tests/native/cairo_render_target_test.cpp b/tests/native/cairo_render_target_test.cpp new file mode 100644 index 00000000..103b8c8b --- /dev/null +++ b/tests/native/cairo_render_target_test.cpp @@ -0,0 +1,123 @@ +#include "backend/linux/CairoRenderTarget.h" + +#include +#include +#include +#include + +namespace +{ +int failures = 0; +#define CHECK(value) do { if (!(value)) { std::cerr << __FILE__ << ':' << __LINE__ \ + << ": check failed: " #value << '\n'; ++failures; } } while (false) + +using ege::backend::CairoRenderTarget; + +void testSurfaceAndRasterOperations() +{ + CairoRenderTarget target(641, 3); + ege::color_t* stable = target.getPixelBuffer(); + target.clear(0xFF010203U); + target.putPixel(640, 2, 0xFF112233U); + CHECK(target.valid()); + CHECK(target.getPixelBuffer() == stable); + CHECK(target.getPixel(640, 2) == 0xFF112233U); + CHECK(target.resize(643, 4, true)); + CHECK(target.getPixel(640, 2) == 0xFF112233U); + CHECK(!target.resize(0, 4)); + + target.setRasterOp(ege::ROP_COPY); + target.putPixel(1, 1, 0x12345678U); + target.setRasterOp(ege::ROP_XOR); + target.putPixel(1, 1, 0x00FF00FFU); + CHECK(target.getPixel(1, 1) == (0x12345678U ^ 0x00FF00FFU)); + + target.setRasterOp(ege::ROP_COPY); + target.setViewport(2, 1, 6, 4, true); + target.putPixel(0, 0, 0xFFFFFFFFU); + CHECK(target.getPixelBuffer()[1 * 643 + 2] == 0xFFFFFFFFU); + target.putPixel(5, 0, 0xFFABCDEFU); + CHECK(std::find(target.getPixelBuffer(), target.getPixelBuffer() + 643 * 4, + 0xFFABCDEFU) == target.getPixelBuffer() + 643 * 4); +} + +void testPrimitivesTransfersAndAlpha() +{ + CairoRenderTarget source(2, 2); + source.getPixelBuffer()[0] = 0xFFFF0000U; + source.getPixelBuffer()[1] = 0xFF00FF00U; + source.getPixelBuffer()[2] = 0xFF0000FFU; + source.getPixelBuffer()[3] = 0xFFFFFFFFU; + + CairoRenderTarget target(48, 48); + target.clear(0); + target.blitStretch(0, 0, 4, 4, &source, 0, 0, 2, 2); + CHECK(target.getPixel(0, 0) == 0xFFFF0000U); + CHECK(target.getPixel(3, 3) == 0xFFFFFFFFU); + + target.clear(0); + target.setLineColor(0xFFFFFFFFU); + target.setFillStyle(ege::FILL_SOLID, 0xFF00FF00U); + target.drawLineF(1.5f, 1.5f, 20.5f, 1.5f); + target.fillRect(2, 4, 8, 6); + target.fillCircle(20, 20, 4); + const int triangle[] = {30, 3, 44, 3, 37, 15}; + target.fillPolygon(triangle, 3); + target.flush(); + CHECK(target.getPixel(8, 1) == 0xFFFFFFFFU); + CHECK(target.getPixel(5, 6) == 0xFF00FF00U); + CHECK(target.getPixel(20, 20) == 0xFF00FF00U); + CHECK(target.getPixel(37, 7) == 0xFF00FF00U); + + CairoRenderTarget alphaSource(1, 1); + alphaSource.getPixelBuffer()[0] = 0x80800000U; + target.clear(0xFF000000U); + target.withAlpha(0, 0, 1, 1, &alphaSource, 0, 0, 1, 1, false); + CHECK(target.getPixel(0, 0) == 0xFF800000U); + + target.clear(0); + target.putPixel(20, 20, 0xFFFFFFFFU); + target.filterBlur(18, 18, 5, 5, 1.0f); + CHECK(target.getPixel(20, 20) != 0xFFFFFFFFU); + CHECK(target.getPixel(19, 20) != 0); +} + +void testTextAndExternalUpdates() +{ + CairoRenderTarget target(320, 100); + const ege::color_t rows[] = { + 0xFF010203U, 0xFF040506U, 0xDEADBEEFU, + 0xFF070809U, 0xFF0A0B0CU, 0xCAFEBABEU}; + CHECK(target.updatePixelBuffer(1, 1, 2, 2, rows, + 3 * static_cast(sizeof(ege::color_t)))); + CHECK(target.getPixel(2, 2) == 0xFF0A0B0CU); + CHECK(!target.updatePixelBuffer(319, 99, 2, 2, rows, 8)); + + target.clear(0); + target.setFont(24, 0, "sans", 0, 0, 700, false, false, false); + float utf8Width = 0, utf8Height = 0, wideWidth = 0, wideHeight = 0; + target.measureText("Hello, world", &utf8Width, &utf8Height); + target.measureText(L"Hello, world", &wideWidth, &wideHeight); + CHECK(utf8Width > 20 && utf8Height > 10); + CHECK(std::abs(utf8Width - wideWidth) < 1 && std::abs(utf8Height - wideHeight) < 1); + target.setTextColor(0xFFFFFFFFU); + target.drawText(4, 4, "Native UTF-8"); + target.drawText(4, 40, L"Linux Cairo"); + target.flush(); + CHECK(std::count_if(target.getPixelBuffer(), target.getPixelBuffer() + 320 * 100, + [](ege::color_t pixel) { return pixel != 0; }) > 50); +} +} + +int main() +{ + testSurfaceAndRasterOperations(); + testPrimitivesTransfersAndAlpha(); + testTextAndExternalUpdates(); + if (failures) { + std::cerr << failures << " CairoRenderTarget check(s) failed\n"; + return 1; + } + std::cout << "CairoRenderTarget checks passed\n"; + return 0; +} diff --git a/tests/native/ege_api_smoke.cpp b/tests/native/ege_api_smoke.cpp index 0e751192..5b1bf689 100644 --- a/tests/native/ege_api_smoke.cpp +++ b/tests/native/ege_api_smoke.cpp @@ -55,7 +55,7 @@ int main() ege::setfillcolor(fill); ege::bar(20, 10, 30, 20); if (ege::getpixel(25, 15) != fill) { - return fail("Core Graphics primitive drawing did not update the CPU surface"); + return fail("native primitive drawing did not update the CPU surface"); } ege::PIMAGE image = ege::newimage(13, 7); diff --git a/tests/native/ege_camera_capture_virtual_test.cpp b/tests/native/ege_camera_capture_virtual_test.cpp new file mode 100644 index 00000000..73f9553b --- /dev/null +++ b/tests/native/ege_camera_capture_virtual_test.cpp @@ -0,0 +1,89 @@ +#include "test_support.h" + +#include + +#include +#include +#include +#include + +namespace +{ +void checkGray(ege::color_t color, int minimum, int maximum) +{ + const int red = EGEGET_R(color); + const int green = EGEGET_G(color); + const int blue = EGEGET_B(color); + EGE_CHECK(EGEGET_A(color) == 255); + EGE_CHECK(red >= minimum && red <= maximum); + EGE_CHECK(std::abs(red - green) <= 3); + EGE_CHECK(std::abs(red - blue) <= 3); +} +} + +int main() +{ + EGE_CHECK(ege::hasCameraCaptureModule()); + EGE_CHECK(std::filesystem::exists("/dev/video99")); + + ege::CameraCapture camera; + auto devices = camera.findDeviceNames(); + EGE_CHECK(devices.count == 1); + EGE_CHECK(devices.info != nullptr); + if (devices.count > 0 && devices.info) { + EGE_CHECK(std::string(devices.info[0].name) == "EGE Virtual Camera"); + } + + camera.setFrameSize(640, 480); + camera.setFrameRate(30.0); + EGE_CHECK(camera.open("/dev/video99", false)); + EGE_CHECK(camera.isOpened()); + + auto resolutions = camera.getDeviceSupportedResolutions(); + EGE_CHECK(resolutions.count == 1); + if (resolutions.count > 0 && resolutions.info) { + EGE_CHECK(resolutions.info[0].width == 640); + EGE_CHECK(resolutions.info[0].height == 480); + } + + EGE_CHECK(camera.start()); + EGE_CHECK(camera.isStarted()); + std::shared_ptr frame = camera.grabFrame(1000); + EGE_CHECK(frame != nullptr); + if (!frame) return ege_test::finish("EGE virtual camera capture"); + + EGE_CHECK(frame->getWidth() == 640); + EGE_CHECK(frame->getHeight() == 480); + EGE_CHECK(frame->getLineSizeInBytes() >= 640 * 4); + EGE_CHECK(frame->getData() != nullptr); + + ege::PIMAGE image = frame->getImage(); + EGE_CHECK(image != nullptr); + if (image) { + EGE_CHECK(ege::getwidth(image) == 640); + EGE_CHECK(ege::getheight(image) == 480); + checkGray(ege::getpixel(80, 80, image), 10, 35); + checkGray(ege::getpixel(480, 80, image), 75, 105); + checkGray(ege::getpixel(80, 360, image), 150, 180); + checkGray(ege::getpixel(480, 360, image), 225, 255); + + const auto artifact = ege_test::artifacts() / "virtual-camera-frame.png"; + EGE_CHECK(ege::savepng(image, artifact.string().c_str(), false) == ege::grOk); + EGE_CHECK(std::filesystem::is_regular_file(artifact)); + EGE_CHECK(std::filesystem::file_size(artifact) > 64); + } + + ege::PIMAGE copy = frame->copyImage(); + EGE_CHECK(copy != nullptr); + if (copy && image) { + EGE_CHECK(ege_test::checksum(copy) == ege_test::checksum(image)); + } + ege::delimage(copy); + + frame.reset(); + camera.stop(); + EGE_CHECK(!camera.isStarted()); + camera.close(); + EGE_CHECK(!camera.isOpened()); + return ege_test::finish("EGE virtual camera capture"); +} diff --git a/tests/native/fake_v4l2_preload.cpp b/tests/native/fake_v4l2_preload.cpp new file mode 100644 index 00000000..2a4ee017 --- /dev/null +++ b/tests/native/fake_v4l2_preload.cpp @@ -0,0 +1,251 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr std::uint32_t kWidth = 640; +constexpr std::uint32_t kHeight = 480; +constexpr std::size_t kFrameBytes = kWidth * kHeight * 2; +constexpr unsigned int kBufferCount = 4; + +std::mutex stateMutex; +std::unordered_set cameraFds; +std::unordered_map buffers; +unsigned int nextBuffer = 0; + +template +Function nextSymbol(const char* name) +{ + return reinterpret_cast(dlsym(RTLD_NEXT, name)); +} + +const char* virtualPath() +{ + const char* path = std::getenv("EGE_TEST_V4L2_PATH"); + return path && *path ? path : "/dev/video99"; +} + +bool isCameraFd(int fd) +{ + std::lock_guard lock(stateMutex); + return cameraFds.count(fd) != 0; +} + +void fillFormat(v4l2_format* format) +{ + format->type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + format->fmt.pix.width = kWidth; + format->fmt.pix.height = kHeight; + format->fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV; + format->fmt.pix.field = V4L2_FIELD_NONE; + format->fmt.pix.bytesperline = kWidth * 2; + format->fmt.pix.sizeimage = kFrameBytes; + format->fmt.pix.colorspace = V4L2_COLORSPACE_SMPTE170M; +} + +void fillFrame(unsigned char* output) +{ + for (std::uint32_t y = 0; y < kHeight; ++y) { + for (std::uint32_t x = 0; x < kWidth; x += 2) { + const bool right = x >= kWidth / 2; + const bool bottom = y >= kHeight / 2; + const unsigned char luminance = bottom + ? static_cast(right ? 224 : 160) + : static_cast(right ? 96 : 32); + const std::size_t offset = (static_cast(y) * kWidth + x) * 2; + output[offset + 0] = luminance; + output[offset + 1] = 128; + output[offset + 2] = luminance; + output[offset + 3] = 128; + } + } +} + +} + +extern "C" int open(const char* path, int flags, ...) +{ + using Function = int (*)(const char*, int, ...); + Function realOpen = nextSymbol("open"); + mode_t mode = 0; + if (flags & O_CREAT) { + va_list arguments; + va_start(arguments, flags); + mode = static_cast(va_arg(arguments, int)); + va_end(arguments); + } + + int fd = std::strcmp(path, virtualPath()) == 0 + ? realOpen("/dev/null", O_RDWR | O_NONBLOCK) + : ((flags & O_CREAT) ? realOpen(path, flags, mode) : realOpen(path, flags)); + if (fd >= 0 && std::strcmp(path, virtualPath()) == 0) { + std::lock_guard lock(stateMutex); + cameraFds.insert(fd); + } + return fd; +} + +extern "C" int open64(const char* path, int flags, ...) +{ + using Function = int (*)(const char*, int, ...); + Function realOpen = nextSymbol("open64"); + mode_t mode = 0; + if (flags & O_CREAT) { + va_list arguments; + va_start(arguments, flags); + mode = static_cast(va_arg(arguments, int)); + va_end(arguments); + } + + int fd = std::strcmp(path, virtualPath()) == 0 + ? realOpen("/dev/null", O_RDWR | O_NONBLOCK) + : ((flags & O_CREAT) ? realOpen(path, flags, mode) : realOpen(path, flags)); + if (fd >= 0 && std::strcmp(path, virtualPath()) == 0) { + std::lock_guard lock(stateMutex); + cameraFds.insert(fd); + } + return fd; +} + +extern "C" int close(int fd) +{ + { + std::lock_guard lock(stateMutex); + cameraFds.erase(fd); + } + using Function = int (*)(int); + return nextSymbol("close")(fd); +} + +extern "C" int ioctl(int fd, unsigned long request, ...) +{ + va_list arguments; + va_start(arguments, request); + void* argument = va_arg(arguments, void*); + va_end(arguments); + + if (!isCameraFd(fd)) { + using Function = int (*)(int, unsigned long, ...); + return nextSymbol("ioctl")(fd, request, argument); + } + + switch (request) { + case VIDIOC_QUERYCAP: { + auto* capability = static_cast(argument); + std::memset(capability, 0, sizeof(*capability)); + std::strncpy(reinterpret_cast(capability->driver), "ege-test", sizeof(capability->driver) - 1); + std::strncpy(reinterpret_cast(capability->card), "EGE Virtual Camera", sizeof(capability->card) - 1); + capability->capabilities = V4L2_CAP_VIDEO_CAPTURE | V4L2_CAP_STREAMING; + capability->device_caps = capability->capabilities; + return 0; + } + case VIDIOC_ENUM_FMT: { + auto* format = static_cast(argument); + if (format->index != 0) { errno = EINVAL; return -1; } + format->type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + format->pixelformat = V4L2_PIX_FMT_YUYV; + std::strncpy(reinterpret_cast(format->description), "YUYV", sizeof(format->description) - 1); + return 0; + } + case VIDIOC_ENUM_FRAMESIZES: { + auto* size = static_cast(argument); + if (size->index != 0 || size->pixel_format != V4L2_PIX_FMT_YUYV) { + errno = EINVAL; + return -1; + } + size->type = V4L2_FRMSIZE_TYPE_DISCRETE; + size->discrete.width = kWidth; + size->discrete.height = kHeight; + return 0; + } + case VIDIOC_G_FMT: + case VIDIOC_S_FMT: + fillFormat(static_cast(argument)); + return 0; + case VIDIOC_REQBUFS: { + auto* requestBuffers = static_cast(argument); + if (requestBuffers->count != 0) requestBuffers->count = kBufferCount; + return 0; + } + case VIDIOC_QUERYBUF: { + auto* buffer = static_cast(argument); + if (buffer->index >= kBufferCount) { errno = EINVAL; return -1; } + buffer->length = kFrameBytes; + buffer->m.offset = buffer->index * kFrameBytes; + return 0; + } + case VIDIOC_QBUF: + case VIDIOC_STREAMON: + case VIDIOC_STREAMOFF: + return 0; + case VIDIOC_DQBUF: { + auto* buffer = static_cast(argument); + std::lock_guard lock(stateMutex); + buffer->index = nextBuffer++ % kBufferCount; + buffer->bytesused = kFrameBytes; + auto found = buffers.find(buffer->index); + if (found == buffers.end()) { errno = EIO; return -1; } + fillFrame(found->second); + return 0; + } + default: + errno = EINVAL; + return -1; + } +} + +extern "C" void* mmap(void* address, size_t length, int protection, int flags, int fd, off_t offset) +{ + using Function = void* (*)(void*, size_t, int, int, int, off_t); + Function realMmap = nextSymbol("mmap"); + if (!isCameraFd(fd)) return realMmap(address, length, protection, flags, fd, offset); + + void* result = realMmap(address, length, protection, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (result != MAP_FAILED) { + std::lock_guard lock(stateMutex); + buffers[static_cast(offset / kFrameBytes)] = static_cast(result); + } + return result; +} + +extern "C" int munmap(void* address, size_t length) +{ + { + std::lock_guard lock(stateMutex); + for (auto iterator = buffers.begin(); iterator != buffers.end(); ++iterator) { + if (iterator->second == address) { + buffers.erase(iterator); + break; + } + } + } + using Function = int (*)(void*, size_t); + return nextSymbol("munmap")(address, length); +} + +extern "C" int poll(pollfd* fileDescriptors, nfds_t count, int timeout) +{ + if (count == 1 && isCameraFd(fileDescriptors[0].fd)) { + timespec delay{0, 5 * 1000 * 1000}; + nanosleep(&delay, nullptr); + fileDescriptors[0].revents = POLLIN; + return 1; + } + using Function = int (*)(pollfd*, nfds_t, int); + return nextSymbol("poll")(fileDescriptors, count, timeout); +} diff --git a/tests/native/linux_window_smoke.cpp b/tests/native/linux_window_smoke.cpp new file mode 100644 index 00000000..e97401b3 --- /dev/null +++ b/tests/native/linux_window_smoke.cpp @@ -0,0 +1,120 @@ +#include "backend/linux/LinuxWindow.h" + +#include +#include +#include + +#include +#include +#include + +namespace +{ +class RecordingSink final : public ege::WindowEventSink +{ +public: + bool onCloseRequested() override { ++closes; return allowClose; } + void onResize(int width, int height) override { ++resizes; lastWidth = width; lastHeight = height; } + void onKey(std::uint32_t key, bool pressed, bool repeat) override + { keys.push_back(key); keyPressed.push_back(pressed); repeated |= repeat; } + void onText(std::uint32_t codepoint) override { text.push_back(codepoint); } + void onMouseMove(int, int) override { ++moves; } + void onMouseButton(int button, bool, int, int, int clicks) override + { lastButton = button; maxClicks = clicks > maxClicks ? clicks : maxClicks; } + void onMouseWheel(float, float dy, int, int) override { wheel += dy; } + + int closes = 0, resizes = 0, moves = 0, lastWidth = 0, lastHeight = 0; + int lastButton = -1, maxClicks = 0; + float wheel = 0; + bool allowClose = false, repeated = false; + std::vector keys, text; + std::vector keyPressed; +}; + +int fail(const char* message) +{ + std::cerr << "LinuxWindow smoke failed: " << message << '\n'; + return 1; +} + +void send(Display* display, ::Window window, XEvent* event, long mask) +{ + event->xany.display = display; + event->xany.window = window; + XSendEvent(display, window, False, mask, event); + XSync(display, False); +} +} + +int main() +{ + int screenWidth = 0, screenHeight = 0; + if (!ege::backend::LinuxWindow::primaryScreenSize(&screenWidth, &screenHeight) + || screenWidth <= 0 || screenHeight <= 0) return fail("screen size unavailable"); + + RecordingSink sink; + ege::backend::LinuxWindow window; + if (!window.create(96, 64, "EGE Linux smoke", {}, &sink)) return fail("create failed"); + if (window.isClosed() || window.getWidth() != 96 || window.getHeight() != 64) return fail("initial size invalid"); + window.show(); + window.setTitle("EGE native Xlib smoke"); + window.setPosition(12, 12); + window.setSize(80, 48); + + Display* display = XOpenDisplay(nullptr); + if (!display) return fail("second X connection failed"); + const ::Window native = static_cast<::Window>(reinterpret_cast(window.getNativeHandle())); + XWindowAttributes attributes{}; + XGetWindowAttributes(display, native, &attributes); + if (attributes.map_state == IsUnmapped) return fail("window was not mapped"); + + XEvent motion{}; + motion.type = MotionNotify; + motion.xmotion.x = 7; + motion.xmotion.y = 8; + send(display, native, &motion, PointerMotionMask); + + XEvent button{}; + button.type = ButtonPress; + button.xbutton.button = Button1; + button.xbutton.x = 7; + button.xbutton.y = 8; + button.xbutton.time = 100; + send(display, native, &button, ButtonPressMask); + button.xbutton.time = 200; + send(display, native, &button, ButtonPressMask); + button.xbutton.button = Button4; + send(display, native, &button, ButtonPressMask); + + XEvent key{}; + key.type = KeyPress; + key.xkey.keycode = XKeysymToKeycode(display, XK_a); + key.xkey.state = 0; + send(display, native, &key, KeyPressMask); + key.type = KeyRelease; + send(display, native, &key, KeyReleaseMask); + + std::vector pixels(80 * 48, 0xFF336699U); + window.present(pixels.data(), 80, 48, 80 * sizeof(std::uint32_t)); + window.processEvents(); + if (window.getWidth() != 80 || window.getHeight() != 48 || sink.resizes == 0) return fail("resize event missing"); + if (sink.moves == 0 || sink.lastButton != 0 || sink.maxClicks != 2 || sink.wheel <= 0) return fail("mouse mapping invalid"); + if (sink.keys.size() < 2 || sink.keys.front() != 'A' || sink.keyPressed.front() != true) return fail("key mapping invalid"); + + Atom wmDelete = XInternAtom(display, "WM_DELETE_WINDOW", False); + XEvent close{}; + close.type = ClientMessage; + close.xclient.message_type = XInternAtom(display, "WM_PROTOCOLS", False); + close.xclient.format = 32; + close.xclient.data.l[0] = static_cast(wmDelete); + send(display, native, &close, NoEventMask); + window.processEvents(); + if (window.isClosed() || sink.closes != 1) return fail("rejected close was not preserved"); + sink.allowClose = true; + send(display, native, &close, NoEventMask); + window.processEvents(); + if (!window.isClosed() || sink.closes != 2) return fail("accepted close did not close"); + XCloseDisplay(display); + std::cout << "LinuxWindow smoke passed\n"; + return 0; +} diff --git a/utils/test-run-demos.sh b/utils/test-run-demos.sh index 8ca88564..7a1052e9 100755 --- a/utils/test-run-demos.sh +++ b/utils/test-run-demos.sh @@ -54,7 +54,7 @@ if [[ -d "$RELEASE_DIR/demo" ]]; then SEARCH_ROOT="$RELEASE_DIR/demo" fi -if [[ "$HOST_SYSTEM" == "Darwin" ]]; then +if [[ "$HOST_SYSTEM" == "Darwin" || "$HOST_SYSTEM" == "Linux" ]]; then while IFS= read -r candidate; do case "$candidate" in */CMakeFiles/*|*/tests/*|*/Testing/*) continue ;; @@ -62,11 +62,30 @@ if [[ "$HOST_SYSTEM" == "Darwin" ]]; then if [[ "$INCLUDE_CAMERA" != true && $(basename "$candidate") == camera_* ]]; then continue fi - if [[ -x "$candidate" ]] && - file "$candidate" | grep -q "Mach-O.*executable"; then + if [[ -x "$candidate" ]] && { + { [[ "$HOST_SYSTEM" == "Darwin" ]] && file "$candidate" | grep -q "Mach-O.*executable"; } || + { [[ "$HOST_SYSTEM" == "Linux" ]] && file "$candidate" | grep -q "ELF.*executable"; }; }; then DEMO_FILES+=("$candidate") fi done < <(find "$SEARCH_ROOT" -type f -perm -111 2>/dev/null | sort) + + # A Linux directory can still intentionally contain MinGW demo artifacts. + # Fall back to Wine only when no native ELF demos were found. + if [[ "$HOST_SYSTEM" == "Linux" && ${#DEMO_FILES[@]} -eq 0 ]]; then + while IFS= read -r candidate; do + if [[ "$INCLUDE_CAMERA" != true && $(basename "$candidate") == camera_*.exe ]]; then + continue + fi + DEMO_FILES+=("$candidate") + done < <(find "$SEARCH_ROOT" -type f -name "*.exe" 2>/dev/null | sort) + if [[ ${#DEMO_FILES[@]} -gt 0 ]]; then + if ! command -v wine >/dev/null 2>&1; then + echo "Error: Wine is required to launch Windows demos on Linux" >&2 + exit 1 + fi + RUNNER=(wine) + fi + fi else while IFS= read -r candidate; do if [[ "$INCLUDE_CAMERA" != true && $(basename "$candidate") == camera_*.exe ]]; then @@ -74,13 +93,6 @@ else fi DEMO_FILES+=("$candidate") done < <(find "$SEARCH_ROOT" -type f -name "*.exe" 2>/dev/null | sort) - if [[ "$HOST_SYSTEM" == "Linux" ]]; then - if ! command -v wine >/dev/null 2>&1; then - echo "Error: Wine is required to launch Windows demos on Linux" >&2 - exit 1 - fi - RUNNER=(wine) - fi fi if [[ ${#DEMO_FILES[@]} -eq 0 ]]; then