diff --git a/CLAUDE.md b/CLAUDE.md index 9468031..4734c47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,20 +26,23 @@ Outputs - Windows: `out/build/Debug/{neui_example.exe, neui.lib, neui-win32host. **Tests**: `tests/` is a Tier-1 header-only unit suite (`neui_tests`) over the portable logic in `hosts/shared/*.h` - links no host and no backend, builds everywhere including the null platform. Toggle with `-DNEUI_BUILD_TESTS=OFF`. Run directly or via `ctest --test-dir out/build -C Debug`. Linux-only extra targets: `neui_cairo_smoke` (offscreen Cairo, ctest-registered) and `neui_embed_smoke` (fake-DAW embedding, needs a live X display). Windows-only: `neui_embed_smoke_win32` (fake-DAW embedding into a foreign HWND; asserts the client-area contract and that the first `NEUI_EVENT_RESIZE` is in logical px - only meaningful on a scaled display; built but not ctest-registered). +**LVGL host - EXPERIMENTAL, off by default, paused pending hardware.** `-DNEUI_WITH_LVGL=ON` (Windows only) swaps the xpl host's backend + platform layer for `neui-backend-lvgl` + `platform_lvgl.cpp` and adds `neui_lvgl_example`. It is a prototype: several host features are stubbed and it is not for product work. **Read `docs/host-lvgl.md` before touching it** (build flags, what works, what is stubbed); design record + measurements in `plans/lvgl-host-approach-c.md`. + ## Per-platform host + backend selection - **Windows**: `neui-win32host` + `neui-xplhost`; backend `neui-backend-d2d`; xpl platform `platform_win32.cpp`. - **macOS**: `neui-macoshost` + `neui-xplhost`; backend `neui-backend-cg`; xpl platform `platform_macos.mm`. - **Linux** (X11): `neui-xplhost` only (no native host); backend `neui-backend-cairo` (software, blitted via XShm/XPutImage); xpl platform `platform_linux.cpp`. Clipboard (CLIPBOARD + PRIMARY + INCR), full XDND v5, neui-drawn message box, in-frame menubar, XI2 smooth scroll, D-Bus theme tracking, and DAW-embedding seams all live here - **see `docs/host-linux.md`**. - **Other**: `neui-xplhost`; backend `neui-backend-null`; xpl platform `platform_null.cpp`. +- **LVGL** (opt-in, **experimental**, Windows only): `neui-xplhost`; backend `neui-backend-lvgl`; xpl platform `platform_lvgl.cpp`. Replaces the Windows pairing above when `-DNEUI_WITH_LVGL=ON` - **see `docs/host-lvgl.md`**. Top-level CMakeLists gates each platform-specific subdirectory; the example links the native host only when present. ## Architecture (file map) -- **Public C API** `include/neui/`: `neui.h` (`neui_init` + `neui_register` + `neui_get_api`); sub-headers under `d/`: `api.h`, `keys.h`, `widgets.h`, `events.h`, `items.h`, `tree.h`, `attrs.h`, `clipboard.h`, `dnd.h`, `commands.h`, `renderer.h`, `painter.h`, `gradient.h`, `path_style.h`, `assets.h`, `compound.h`, `behavior.h`, `component.h`, `filter.h`, `grid.h`, `scroll.h`, `menu.h`, `theme.h`, `notify.h`, `metrics.h`, `embed.h`. +- **Public C API** `include/neui/`: `neui.h` (`neui_init` + `neui_register` + `neui_get_api`); sub-headers under `d/`: `api.h`, `keys.h`, `widgets.h`, `events.h`, `items.h`, `tree.h`, `attrs.h`, `clipboard.h`, `dnd.h`, `commands.h`, `renderer.h`, `painter.h`, `gradient.h`, `path_style.h`, `assets.h`, `compound.h`, `behavior.h`, `component.h`, `filter.h`, `grid.h`, `scroll.h`, `menu.h`, `resource.h`, `theme.h`, `notify.h`, `metrics.h`, `embed.h`. - **Core library** `src/neui.c`: host registry + `neui_init()` (fans out to per-host registration wrappers gated by `NEUI_HAS_*HOST`). Also `src/mujson.{h,cpp}`: `neui::mujson`, a minimal JSON-*like* parser (bare keys, `//` + `/* */` comments, single trailing comma, `\uXXXX`, depth-cap 128) compiled into `libneui`; `parse(str)->object_t` / `serialize(object_t)`; Tier-1 tested via `tests/test_mujson.cpp`. -- **Shared portable utilities** `hosts/shared/`, header-only, ODR-safe via `inline`: `tree.h` (`Tree`), `attrs.h` (`AttrBag` + `attr_as_float` + `k_well_known_attrs`), `asset_store.h` (`AssetStore` slot table), `clipboard_item.h`, `dnd_dispatch.h`, `dnd_modifier_suggest.h`, `edit_history.h`, `text_edit.h` (`TextEditState` shared by every text-input surface), `shortcut_format.h`, `theme_palette.h` (`ColorRole` / `Palette` / `current_palette`), `compound.h`, `behavior.h` / `behavior_runtime.h`, `image_filter.h`, `filter_graph.h`, `grid_model.h` / `widget_paint_grid.h`, `scrollbar.h`, `scroll_kinetics.h`, `widget_section_scroll.h`, `painter.h`, `widget_font.h`, `widget_paint_knob.h` / `_section.h` / `_compound.h`, `component_loader.h`. Platform-specific shared code under `hosts/shared/{win32,macos,linux}/`. +- **Shared portable utilities** `hosts/shared/`, header-only, ODR-safe via `inline`: `tree.h` (`Tree`), `attrs.h` (`AttrBag` + `attr_as_float` + `k_well_known_attrs`), `asset_store.h` (`AssetStore` slot table), `clipboard_item.h`, `dnd_dispatch.h`, `dnd_modifier_suggest.h`, `edit_history.h`, `text_edit.h` (`TextEditState` shared by every text-input surface), `shortcut_format.h`, `theme_palette.h` (`ColorRole` / `Palette` / `current_palette`), `compound.h`, `behavior.h` / `behavior_runtime.h`, `image_filter.h`, `filter_graph.h`, `grid_model.h` / `widget_paint_grid.h`, `scrollbar.h`, `scroll_kinetics.h`, `widget_section_scroll.h`, `painter.h`, `widget_font.h`, `widget_paint_knob.h` / `_section.h` / `_compound.h`, `component_loader.h`, `image_loader_stb.h` (the stb-based `platform_load_image` for platform layers with no OS imaging framework - Linux and the LVGL host). Platform-specific shared code under `hosts/shared/{win32,macos,linux}/`. - **Win32 Host** `hosts/win32/`: native HWND host. `window.cpp` WinMain + pump; `host.{cpp,h}` Session + `WidgetData`; `widgets.cpp` full API; `asset_manager_w32.h`. - **macOS Host** `hosts/macos/`: native AppKit host (`neui.host.macos`). `host.{h,mm}` Session; `widgets.mm` full API; `window.mm` (NSApp + `NEUINativeContentView` `isFlipped=YES` + `NEUINativePaintedView`); `asset_manager_macos.h`. - **Crossplatform Host** `hosts/crossplatform/`: polymorphic widget hierarchy (`neui.host.crossplatform`). `host.{h,cpp}` `WidgetData` base + per-type subclasses (`FrameWidget` … `GridWidget`) with virtuals; `widgets.cpp` full API + `make_widget()`; `platform.h` cross-cutting seam (window / menubar / image / clipboard / IME / modal / focus); per-OS impls `platform_{win32.cpp,macos.mm,linux.cpp,null.cpp}`. @@ -55,7 +58,7 @@ App: `APP_QUIT`. Mouse: `MOUSE_MOVE/ENTER/LEAVE`, `MOUSE_BUTTON_DOWN/UP/CLICK/DB - **Host registry** - `neui_register(id, api)` at startup; `neui_get_api(NULL)` returns first registered. IDs: `"neui.host.win32"`, `"neui.host.macos"`, `"neui.host.crossplatform"`. Clients call `neui_init()` once to register every compiled-in host (gated on `NEUI_HAS_*HOST`). Order: native first, then xpl. - **Per-host registration wrappers** - each host static lib exposes `extern "C"` wrappers (`neui_register_xplhost` / `_win32host` / `_macoshost`); they double as the linker forced-symbol references pulling the host's objects out of its static lib. -- **Named interface dispatch** - `get_interface(sess, name)` with version suffix (`/0`). Active: `NEUI_API_WIDGETS/_ITEMS/_TREE/_ATTRS/_CLIPBOARD/_DND/_COMMANDS/_ASSETS/_COMPOUND/_BEHAVIOR/_FILTER/_GRID/_SCROLL/_NOTIFY/_EMBED`. Optional client-side: `_MENU_CLIENT`, `_THEME_CLIENT`, `_GRID_CLIENT`. `_FILTER` is itself optional host-side (a host without off-screen surfaces may return nullptr); `_EMBED` (`d/embed.h`, DAW embedding for PLUGWINDOW) is exposed by the xpl host only. +- **Named interface dispatch** - `get_interface(sess, name)` with version suffix (`/0`). Active: `NEUI_API_WIDGETS/_ITEMS/_TREE/_ATTRS/_CLIPBOARD/_DND/_COMMANDS/_ASSETS/_COMPOUND/_BEHAVIOR/_FILTER/_GRID/_SCROLL/_NOTIFY/_EMBED`. Optional client-side: `_MENU_CLIENT`, `_THEME_CLIENT`, `_GRID_CLIENT`, `_RESOURCE_CLIENT` (client supplies resource bytes by name - images / fonts / component JSON / filmstrip sidecars - asked *before* the host's own filesystem + embedded-resource lookup; see `docs/rendering-and-assets.md`). `_FILTER` is itself optional host-side (a host without off-screen surfaces may return nullptr); `_EMBED` (`d/embed.h`, DAW embedding for PLUGWINDOW) is exposed by the xpl host only. - **Session model** - 32-bit ID; slot-reused vector. Client passes `neui_client_t` with `get_interface` callback; host passes opaque token back on every callback. - **Widget IDs** - upper 16 = owning session id, lower 16 = tree slot. Every API entry validates via `get_session_for_widget`; cross-session handles silently dropped. Sentinels (`widget_root = 0`, `widget_none = UINT32_MAX`) pass. Stale-after-slot-reuse not detected (deferred). - **Deferred HWND** (win32) - logical state stored immediately; HWND / HMENU / HACCEL / HICON created on `widget_show()`; pending state flushed in `create_child_windows()`. Guard every API call with `hwnd == nullptr`. @@ -174,6 +177,7 @@ Deep per-subsystem detail lives in `docs/`. **Read the relevant file before doin - **Theme palette; frame resize / icon / focus** -> `docs/theming.md`. - **Per-widget internals & enabled/disabled** -> `docs/widget-internals.md`. MULTILINE perf, hover/pressed visuals, DBLCLK->CLICK parity, disabled state per host. - **Linux (X11 + Cairo) host internals** -> `docs/host-linux.md`. Selections + INCR clipboard, XDND, in-frame menubar, XI2 smooth scroll, D-Bus theme, embedding seams. +- **LVGL host (EXPERIMENTAL)** -> `docs/host-lvgl.md`. Opt-in embedded substrate for the xpl host: build flags, retained mirror layer, what works, what is stubbed, why it is paused pending hardware. - **Deferred / known-limitation list** -> `docs/deferred-issues.md`. - **Design rationale for shipped features** -> `docs/design-notes.md`. **Component-widget authoring how-to** -> `docs/component-widgets-howto.md`. @@ -184,5 +188,6 @@ Active plans (`plans/`) are open or deferred work only - completed plans were re - `win32-pointer-and-directmanipulation.md` - WM_POINTER pen/touch + DirectManipulation smooth-scroll on scrolling SECTION + GRID (deferred; binding spec for when it lands). - `winui3-host.md` - third host backend feasibility analysis (deferred indefinitely). - `wasm-host.md` - WebAssembly / Canvas-2D host feasibility analysis (deferred; phased path documented). -- `lvgl-port.md` - neui-on-LVGL feasibility investigation (no implementation proposed). +- `lvgl-port.md` - neui-on-LVGL feasibility investigation that picked the approach (superseded by the prototype below; kept for the impedance-mismatch analysis). +- `lvgl-host-approach-c.md` - the **experimental** LVGL host: design record, per-milestone detail and all measurements. Prototype built + evaluated; **work paused until real target hardware is available** (the open items - appearance cache, embedded display driver / VGLite - need a panel and an MCU to evaluate). Reader-facing status: `docs/host-lvgl.md`. - `how-to-port.md` - reference playbook for new-platform ports. diff --git a/CMakeLists.txt b/CMakeLists.txt index 1e3a856..af3e00e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,6 +19,14 @@ option(NEUI_BUILD_TESTS "Build the neui unit tests" ${NEUI_IS_TOP_LEVEL # don't break the build - the project's documented default); CI turns it ON # (-DNEUI_WERROR=ON) so a new warning fails the build on every host. option(NEUI_WERROR "Treat compiler warnings as errors" OFF) +# EXPERIMENTAL LVGL host (docs/host-lvgl.md; design record + measurements in +# plans/lvgl-host-approach-c.md): a rendering backend + xpl platform layer that +# draw through LVGL. OFF by default - existing builds are unaffected, and this is +# not for product work: several host features are stubbed and the remaining work +# is paused until real target hardware is available. When ON, LVGL is fetched +# from git (see backends/lvgl/). Windows-only; configuration FAILS on other +# platforms (no silent fallback). +option(NEUI_WITH_LVGL "EXPERIMENTAL: build the LVGL host + backend (see docs/host-lvgl.md)" OFF) # iOS is an APPLE platform too, so it must be detected distinctly from macOS: # the CoreGraphics backend is shared (gated on APPLE), but the AppKit macOS @@ -139,6 +147,20 @@ endif() if(UNIX AND NOT APPLE) add_subdirectory("backends/cairo") endif() +# LVGL backend + platform prototype - gated; FetchContent of LVGL lives in the +# subdirectory. Must fail hard on platforms that can't run the LVGL host. +if(NEUI_WITH_LVGL) + if(NOT WIN32) + message(FATAL_ERROR + "NEUI_WITH_LVGL is currently supported only on Windows (it uses LVGL's " + "native Windows display driver, LV_USE_WINDOWS). This platform cannot " + "run the LVGL host - configure with -DNEUI_WITH_LVGL=OFF.") + endif() + message(STATUS + "neui: EXPERIMENTAL LVGL host enabled - prototype only, several host " + "features are stubbed. See docs/host-lvgl.md.") + add_subdirectory("backends/lvgl") +endif() add_subdirectory("backends/null") add_subdirectory("hosts/crossplatform") @@ -442,6 +464,35 @@ if(APPLE) target_link_libraries(neui_overlay_example PRIVATE neui-macoshost) endif() +# Client resource provider example (NEUI_API_RESOURCE_CLIENT). The client builds +# a BMP in memory at startup and hands the bytes to the host on demand, so the +# image resolves under a name that exists nowhere on disk. WIN32 subsystem like +# the other GUI examples - the native Win32 host's window/pump lives behind +# WinMain, so a console-subsystem build never gets a frame. +if(MSVC) + add_executable(neui_resource_client_example WIN32 + examples/resource_client_example.cpp + examples/neui_example.rc) + target_link_options(neui_resource_client_example PRIVATE "/MANIFEST:NO") + set_property(TARGET neui_resource_client_example APPEND PROPERTY + LINK_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/examples/neui_example.manifest") +elseif(WIN32) + add_executable(neui_resource_client_example WIN32 + examples/resource_client_example.cpp) +elseif(APPLE) + add_executable(neui_resource_client_example MACOSX_BUNDLE + examples/resource_client_example.cpp) +else() + add_executable(neui_resource_client_example examples/resource_client_example.cpp) +endif() +target_link_libraries(neui_resource_client_example PRIVATE neui neui-xplhost) +if(WIN32) + target_link_libraries(neui_resource_client_example PRIVATE neui-win32host) +endif() +if(APPLE) + target_link_libraries(neui_resource_client_example PRIVATE neui-macoshost) +endif() + # Arc example. Value-driven arc / ring / pie compound layers # (NEUI_COMPOUND_LAYER_ARC) - ring, elliptical ring, filled wedge, and a # center-polarity bipolar arc, each bound to NEUI_PARAM_VALUE. @@ -632,6 +683,23 @@ if(APPLE) target_link_libraries(neui_tabview_example PRIVATE neui-macoshost) endif() +# LVGL host prototype example (plans/lvgl-host-approach-c.md): the knob-heavy +# measurement screen for the Milestone 3 FPS/CPU evaluation. Console app so +# LVGL's perf monitor (LOG_MODE) prints sysmon lines to stdout. Pinned to the +# crossplatform host, which this build pairs with the LVGL platform+backend. +if(NEUI_WITH_LVGL) + add_executable(neui_lvgl_example examples/lvgl_example.cpp) + target_link_libraries(neui_lvgl_example PRIVATE neui neui-xplhost) + if(WIN32) + target_link_libraries(neui_lvgl_example PRIVATE neui-win32host) + endif() + # The IMAGE-widget check loads myimage.png from next to the executable. + add_custom_command(TARGET neui_lvgl_example POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/examples/myimage.png" + "$/myimage.png") +endif() + # Minimal README example - input + button + label echo. Pure C; mirrors the # code block in README.md verbatim. Useful as a smoke test that the public # C API stays usable from a one-file client. diff --git a/README.md b/README.md index a32f658..f4913a3 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,7 @@ This includes using fonts, colors and sizes that are defined from the target sys * iOS / iPadOS (implemented, native UIKit host + crossplatform host) * crossplatform for all of the above * embedded (DAW plugin windows via `NEUI_API_EMBED` on windows / macOS / linux, incl. X11 host run-loop integration) - -## audio plugin UIs - -For audio plugin editors (VST3 / CLAP / AU adapters), **prefer the crossplatform host** - select it explicitly with `neui_get_api("neui.host.crossplatform")` instead of taking the default. It renders pixel-identically on every platform and is the only host that implements `NEUI_API_EMBED` (``), which parents a `NEUI_W_PLUGWINDOW` into the DAW-provided native parent (HWND on windows, NSView* on macOS, X11 Window on linux). In embedded mode neui owns no event loop - never call `run()` or `pump_once()` from a plugin; on windows/macOS the DAW's own pump services the embedded frame, on linux register `embed->event_fd()` with the host run loop and drive `embed->pump_and_tick()` from its timer. The native win32/macOS hosts are for standalone applications and do not support embedding. +* bare-metal / MCU panels via LVGL (**experimental** prototype, opt-in and Windows-hosted for now; work resumes when target hardware is available - see `docs/host-lvgl.md`) ## how to use diff --git a/backends/lvgl/CMakeLists.txt b/backends/lvgl/CMakeLists.txt new file mode 100644 index 0000000..d174fe0 --- /dev/null +++ b/backends/lvgl/CMakeLists.txt @@ -0,0 +1,77 @@ +cmake_minimum_required(VERSION 3.15) + +# LVGL rendering backend for the crossplatform host (prototype - see +# plans/lvgl-host-approach-c.md). Windows-only for now; the top-level +# CMakeLists gates this subdirectory on NEUI_WITH_LVGL and fails hard on +# unsupported platforms. +if(NOT NEUI_WITH_LVGL) + return() +endif() + +include(FetchContent) + +# LVGL configuration: repo-local override-style lv_conf.h, generated from +# lv_conf.h.in so the framebuffer depth is a configure-time choice +# (-DNEUI_LVGL_COLOR_DEPTH=32 for XRGB8888, =16 for RGB565). No LVGL demos or +# examples compiled into the build. +set(NEUI_LVGL_COLOR_DEPTH 32 CACHE STRING + "LV_COLOR_DEPTH for the LVGL host build (32 = XRGB8888, 16 = RGB565)") +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/lv_conf.h.in" + "${CMAKE_CURRENT_BINARY_DIR}/lv_conf.h" @ONLY) +set(LV_BUILD_CONF_PATH "${CMAKE_CURRENT_BINARY_DIR}/lv_conf.h" CACHE PATH + "neui: LVGL configuration header" FORCE) +set(CONFIG_LV_BUILD_DEMOS OFF CACHE BOOL "" FORCE) +set(CONFIG_LV_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + +# Pinned to the exact upstream commit this backend was written and measured +# against. LVGL master churns the very APIs lvgl_backend.cpp uses (the +# lv_draw_vector_dsc_* naming, the lv_obj flag setters that already log +# LV_LOG_DEPRECATED), so tracking `master` meant a fresh configure could fail to +# compile for reasons unrelated to any neui change - and, because CMake caches +# the clone, an existing tree would silently keep building an older revision. +# Bump deliberately, re-running the measurements in plans/lvgl-host-approach-c.md. +# +# GIT_SHALLOW is off: a shallow clone checks out a branch tip, which cannot +# resolve a bare commit hash. The full clone is a one-time configure cost on a +# build that goes on to compile all of LVGL + ThorVG anyway. +FetchContent_Declare(lvgl + GIT_REPOSITORY https://github.com/lvgl/lvgl.git + GIT_TAG 066d8db0b54819223357731f68961a90b3d785b4 # master, 2026-07-30 +) + +# neui compiles at /W4 (its warnings-as-linter policy), but that directory +# property would also apply to the fetched LVGL + ThorVG sources and drown +# the build in third-party noise. Drop the high-warning flags from this +# directory's inherited options and re-apply them to neui's own targets below. +# This MUST cover the NEUI_WERROR flags too: leaving /WX (or -Werror) on the +# directory turns the first third-party warning in LVGL or ThorVG into a hard +# build failure that no neui change can fix - the exact outcome this scrub +# exists to prevent. Keep in sync with the top-level CMakeLists. +get_directory_property(_neui_dir_opts COMPILE_OPTIONS) +set(_neui_own_warnings "") +if(MSVC) + set(_neui_own_warnings /W4 /wd4100) + if(NEUI_WERROR) + list(APPEND _neui_own_warnings /WX /wd4996) + endif() +else() + set(_neui_own_warnings -Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers) + if(NEUI_WERROR) + list(APPEND _neui_own_warnings -Werror -Wno-error=deprecated-declarations) + endif() +endif() +foreach(_flag IN LISTS _neui_own_warnings) + list(REMOVE_ITEM _neui_dir_opts "${_flag}") +endforeach() +set_directory_properties(PROPERTIES COMPILE_OPTIONS "${_neui_dir_opts}") + +FetchContent_MakeAvailable(lvgl) + +# The rendering backend proper: neui_render_backend_t over LVGL draw tasks. +# lvgl is a PUBLIC dependency so consumers (the xpl host's platform_lvgl.cpp) +# inherit the include dirs + LV_CONF_PATH definition. +add_library(neui-backend-lvgl lvgl_backend.cpp) +target_compile_options(neui-backend-lvgl PRIVATE ${_neui_own_warnings}) +target_include_directories(neui-backend-lvgl + PUBLIC ${PROJECT_SOURCE_DIR}/include) +target_link_libraries(neui-backend-lvgl PUBLIC lvgl) diff --git a/backends/lvgl/lv_conf.h.in b/backends/lvgl/lv_conf.h.in new file mode 100644 index 0000000..8d808e2 --- /dev/null +++ b/backends/lvgl/lv_conf.h.in @@ -0,0 +1,73 @@ +/* neui LVGL host prototype configuration (plans/lvgl-host-approach-c.md). + * + * TEMPLATE: backends/lvgl/CMakeLists.txt runs configure_file() over this to + * produce the lv_conf.h LVGL builds against (LV_BUILD_CONF_PATH). Minimal + * override-style config: every option not set here takes its + * lv_conf_internal.h default. + */ +#ifndef LV_CONF_H +#define LV_CONF_H + +/* Framebuffer depth: 32 = XRGB8888 (desktop-like), 16 = RGB565 (MCU-like). + * Selected at configure time via -DNEUI_LVGL_COLOR_DEPTH=32|16. */ +#define LV_COLOR_DEPTH @NEUI_LVGL_COLOR_DEPTH@ + +/* ~60 FPS refresh cap (default 33 ms caps animated measurement at 30). */ +#define LV_DEF_REFR_PERIOD 16 + +/* Desktop prototype: use the C library directly instead of LVGL's built-in + * fixed-size pool (ThorVG scratch + TinyTTF glyph caches would exhaust the + * 64K default). */ +#define LV_USE_STDLIB_MALLOC LV_STDLIB_CLIB +#define LV_USE_STDLIB_STRING LV_STDLIB_CLIB +#define LV_USE_STDLIB_SPRINTF LV_STDLIB_CLIB + +/* The LVGL native Windows driver requires the OS abstraction: its HWND lives + * on a dedicated thread and every WndProc entry takes lv_lock(). */ +#define LV_USE_OS LV_OS_WINDOWS +#define LV_USE_WINDOWS 1 + +/* With LV_USE_OS enabled the SW renderer runs draw tasks on a worker thread; + * ThorVG requires at least a 32K stack there (compile-time enforced). */ +#define LV_DRAW_THREAD_STACK_SIZE (64 * 1024) + +/* Scalable text for neui's family-name + arbitrary-px font model + * (open question resolved: Tiny TTF, not FreeType, for the prototype). */ +#define LV_USE_TINY_TTF 1 +#define LV_TINY_TTF_FILE_SUPPORT 1 +#define LV_TINY_TTF_CACHE_GLYPH_CNT 256 +#define LV_TINY_TTF_CACHE_KERNING_CNT 256 + +/* Anti-aliased vector paths (KNOB arcs, compound PATH layers) via LVGL's + * bundled ThorVG (open question resolved: ThorVG, not a hand-rolled + * scanline rasteriser). */ +/* neui gradients carry an arbitrary stop list (neui_gradient_t is a pointer + + * count); LVGL's gradient descriptors are fixed-size arrays and it silently + * truncates - and logs - anything longer. The default of 2 would reduce every + * multi-stop compound gradient to a plain two-colour ramp. */ +#define LV_GRADIENT_MAX_STOPS 16 + +#define LV_USE_FLOAT 1 /* required by LV_USE_MATRIX */ +#define LV_USE_MATRIX 1 +#define LV_USE_VECTOR_GRAPHIC 1 +#define LV_USE_THORVG 1 +#define LV_USE_THORVG_INTERNAL 1 + +/* FPS + CPU counters for the Milestone 3 measurements. LOG_MODE prints + * "sysmon: N FPS ..." lines to stdout instead of drawing an overlay label - + * capturable by scripts, and the overlay repaint can't pollute the + * Milestone 2 idle / dirty-rect verification. */ +#define LV_USE_SYSMON 1 +#define LV_USE_PERF_MONITOR 1 +#define LV_USE_PERF_MONITOR_LOG_MODE 1 + +/* Offscreen re-render of the object tree - used for pixel-level verification + * without touching the driver's private framebuffer. */ +#define LV_USE_SNAPSHOT 1 + +/* Surface LVGL warnings on stdout during bring-up. */ +#define LV_USE_LOG 1 +#define LV_LOG_LEVEL LV_LOG_LEVEL_WARN +#define LV_LOG_PRINTF 1 + +#endif /* LV_CONF_H */ diff --git a/backends/lvgl/lvgl_backend.cpp b/backends/lvgl/lvgl_backend.cpp new file mode 100644 index 0000000..70d265b --- /dev/null +++ b/backends/lvgl/lvgl_backend.cpp @@ -0,0 +1,1277 @@ +// LVGL rendering backend for the crossplatform host (prototype). +// +// Maps neui_render_backend_t onto LVGL v9 draw tasks issued into the layer +// bound by the platform layer around each draw dispatch (bind_layer / +// unbind_layer in lvgl_backend.h): +// +// fill_rect / draw_rect -> lv_draw_fill / lv_draw_border +// draw_text / measure_text -> lv_draw_label / lv_text_get_size over +// Tiny TTF instances resolved by +// (family, weight, size) from Windows fonts +// path + gradients -> the ThorVG vector pipeline +// (lv_draw_vector_*; arcs flattened to +// cubics so sweep semantics match D2D/Cairo) +// clip stack -> save / intersect / restore of the layer's +// _clip_area (draw tasks snapshot it) +// transform stack -> software 2x3 CTM; translation/scale take +// the fast integer path, general affines run +// through the vector matrix +// bitmaps -> lv_draw_image over ARGB8888_PREMULTIPLIED +// +// Deferred for the prototype (graceful nulls, per the plan): off-screen +// contexts (SURFACE assets degrade to asset_none), font registration. +// +// IMPORTANT lifetime rule: LVGL draw tasks execute after the dispatch +// returns, so nothing transient may be referenced by a task. Label text is +// copied (text_local = 1), vector paths are deep-copied at add_path, and +// per-draw sub-image descriptors live in a per-ctx arena flushed at the next +// bind (by which time the previous refresh has completed). + +#include "lvgl_backend.h" + +#include + +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace neui_lvgl_backend +{ + // ------------------------------------------------------------------------- + // 2x3 affine CTM (column-vector convention: p' = M * p). + + struct Mat23 { + float a = 1.0f, b = 0.0f, c = 0.0f, d = 1.0f, tx = 0.0f, ty = 0.0f; + }; + + // r = t o m (apply m first, then t) - post-multiply semantics. + static Mat23 mat_mul(const Mat23& t, const Mat23& m) + { + Mat23 r; + r.a = t.a * m.a + t.c * m.b; + r.b = t.b * m.a + t.d * m.b; + r.c = t.a * m.c + t.c * m.d; + r.d = t.b * m.c + t.d * m.d; + r.tx = t.a * m.tx + t.c * m.ty + t.tx; + r.ty = t.b * m.tx + t.d * m.ty + t.ty; + return r; + } + + static bool mat_is_axis_aligned(const Mat23& m) + { + return std::fabs(m.b) < 1e-4f && std::fabs(m.c) < 1e-4f; + } + + // ------------------------------------------------------------------------- + // Render context + + struct SubImage; // per-draw image descriptor arena entry (below) + + struct LvglCtx { + lv_layer_t* layer = nullptr; + int32_t base_x = 0, base_y = 0; + uint32_t width = 0, height = 0; // logical size (frame client area) + + Mat23 tf; + std::vector tf_stack; + + lv_area_t bind_clip{}; // layer clip at bind time + std::vector clip_stack; + + std::vector alpha_stack; + + struct FontSel { std::string family; int weight = 0; }; + std::vector font_stack; + + // Path state (raw untransformed coords; CTM applied at fill/stroke time). + lv_vector_path_t* path = nullptr; + neui_fill_rule_t fill_rule = NEUI_FILL_RULE_NONZERO; + bool has_current = false; + float cur_x = 0.0f, cur_y = 0.0f; + float sub_start_x = 0.0f, sub_start_y = 0.0f; + + // Vector-task batch: consecutive fill/stroke path ops accumulate into ONE + // lv_draw_vector task, flushed at the next non-path draw / clip change / + // unbind. Matters enormously on non-32bpp targets, where LVGL's SW vector + // fallback pays a full-layer ARGB8888 round-trip PER TASK (a KNOB is ~6 + // path ops -> 1 round-trip instead of 6). + lv_draw_vector_dsc_t* batch = nullptr; + + std::vector> subimage_arena; + + // NOTE: does NOT touch subimage_arena - pending LVGL draw tasks may + // still reference those descriptors (retained mode re-binds per widget + // within one refresh). collect_deferred() frees them post-refresh. + void reset_paint_state() + { + tf = Mat23{}; + tf_stack.clear(); + clip_stack.clear(); + alpha_stack.clear(); + font_stack.clear(); + } + }; + + static float current_alpha(LvglCtx* c) + { + float a = 1.0f; + for (float f : c->alpha_stack) a *= f; + return a; + } + + static lv_color_t to_color(uint32_t argb) + { + return lv_color_make(static_cast(argb >> 16), + static_cast(argb >> 8), + static_cast(argb)); + } + + static lv_opa_t fold_opa(LvglCtx* c, uint32_t argb) + { + float a = static_cast(argb >> 24) * current_alpha(c); + if (a < 0.0f) a = 0.0f; + if (a > 255.0f) a = 255.0f; + return static_cast(a + 0.5f); + } + + // Map a logical point through the CTM + base offset into display coords. + static void map_point(LvglCtx* c, float x, float y, float* ox, float* oy) + { + *ox = static_cast(c->base_x) + c->tf.a * x + c->tf.c * y + c->tf.tx; + *oy = static_cast(c->base_y) + c->tf.b * x + c->tf.d * y + c->tf.ty; + } + + // Map an axis-aligned logical rect to a display-coord lv_area_t (AABB of + // the four mapped corners, so it stays correct under any CTM - callers on + // the fast paths only use it when the CTM is axis-aligned). + static lv_area_t map_rect(LvglCtx* c, float x, float y, float w, float h) + { + float x0, y0, x1, y1, x2, y2, x3, y3; + map_point(c, x, y, &x0, &y0); + map_point(c, x + w, y, &x1, &y1); + map_point(c, x, y + h, &x2, &y2); + map_point(c, x + w, y + h, &x3, &y3); + float minx = std::fmin(std::fmin(x0, x1), std::fmin(x2, x3)); + float miny = std::fmin(std::fmin(y0, y1), std::fmin(y2, y3)); + float maxx = std::fmax(std::fmax(x0, x1), std::fmax(x2, x3)); + float maxy = std::fmax(std::fmax(y0, y1), std::fmax(y2, y3)); + lv_area_t a; + a.x1 = static_cast(std::lround(minx)); + a.y1 = static_cast(std::lround(miny)); + a.x2 = static_cast(std::lround(maxx)) - 1; + a.y2 = static_cast(std::lround(maxy)) - 1; + return a; + } + + static lv_area_t intersect(const lv_area_t& a, const lv_area_t& b) + { + lv_area_t r = a; + if (b.x1 > r.x1) r.x1 = b.x1; + if (b.y1 > r.y1) r.y1 = b.y1; + if (b.x2 < r.x2) r.x2 = b.x2; + if (b.y2 < r.y2) r.y2 = b.y2; + return r; // may be degenerate (x2 < x1) - nothing draws then + } + + // Submit + drop the pending batched vector task, if any. Must run before + // any non-vector draw (z-order), any clip change (tasks snapshot the + // layer's clip at submission), end of frame, and unbind. + static void flush_vector_batch(LvglCtx* c) + { + if (!c->batch) return; + lv_draw_vector(c->batch); + lv_draw_vector_dsc_delete(c->batch); + c->batch = nullptr; + } + + // The batch descriptor, created lazily on the first path op of a run. + // Per-path state (transform, fill/stroke) is set before each add_path; + // the caller must set BOTH halves every time since descriptor state + // persists across add_path calls within one batch. + static lv_draw_vector_dsc_t* vector_batch(LvglCtx* c) + { + if (!c->batch) c->batch = lv_draw_vector_dsc_create(c->layer); + return c->batch; + } + + // Reset stroke style state to the plain-stroke defaults (a previous + // batched path may have left caps / joins / dashes behind). + static void reset_stroke_style(lv_draw_vector_dsc_t* v) + { + lv_draw_vector_dsc_set_stroke_cap(v, LV_VECTOR_STROKE_CAP_BUTT); + lv_draw_vector_dsc_set_stroke_join(v, LV_VECTOR_STROKE_JOIN_MITER); + lv_draw_vector_dsc_set_stroke_miter_limit(v, 4); + lv_draw_vector_dsc_set_stroke_dash(v, nullptr, 0); + } + + // The full CTM + base offset as an lv_matrix_t for the vector pipeline. + static lv_matrix_t vector_matrix(LvglCtx* c) + { + lv_matrix_t m; + m.m[0][0] = c->tf.a; m.m[0][1] = c->tf.c; m.m[0][2] = c->tf.tx + static_cast(c->base_x); + m.m[1][0] = c->tf.b; m.m[1][1] = c->tf.d; m.m[1][2] = c->tf.ty + static_cast(c->base_y); + m.m[2][0] = 0.0f; m.m[2][1] = 0.0f; m.m[2][2] = 1.0f; + return m; + } + + // ------------------------------------------------------------------------- + // LVGL lock hooks (see lvgl_backend.h). Installed by the platform layer; + // absent, the guard is a no-op. + + static void (*g_lv_lock)() = nullptr; + static void (*g_lv_unlock)() = nullptr; + + void set_lock_hooks(void (*lock)(), void (*unlock)()) + { + g_lv_lock = lock; + g_lv_unlock = unlock; + } + + struct LvGuard { + LvGuard() { if (g_lv_lock) g_lv_lock(); } + ~LvGuard() { if (g_lv_unlock) g_lv_unlock(); } + LvGuard(const LvGuard&) = delete; + LvGuard& operator=(const LvGuard&) = delete; + }; + + // ------------------------------------------------------------------------- + // Fonts: (family, weight, size) -> Tiny TTF instance over a Windows font + // file. Factory-level caches; every call is on the UI thread. + + struct FontFile { + std::vector bytes; + }; + + static std::map>& font_files() + { + static std::map> m; + return m; + } + + struct FontInstanceKey { + const FontFile* file; + int size_px; + bool operator<(const FontInstanceKey& o) const + { + if (file != o.file) return file < o.file; + return size_px < o.size_px; + } + }; + + static std::map& font_instances() + { + static std::map m; + return m; + } + + // Front cache keyed on what the caller actually passes. The file-resolution + // path below builds strings and touches the filesystem, which is far too + // expensive for a hot paint path (an INPUTBOX repaint alone issues one + // measure_text per caret / selection query, a MULTILINE one per visual row). + struct FontLookupKey { + std::string family; + int weight; + int size_px; + bool operator<(const FontLookupKey& o) const + { + if (size_px != o.size_px) return size_px < o.size_px; + if (weight != o.weight) return weight < o.weight; + return family < o.family; + } + }; + + static std::map& font_lookup() + { + static std::map m; + return m; + } + + static std::string lower_nospace(const char* s) + { + std::string out; + for (; s && *s; ++s) { + char ch = *s; + if (ch == ' ') continue; + if (ch >= 'A' && ch <= 'Z') ch = static_cast(ch - 'A' + 'a'); + out.push_back(ch); + } + return out; + } + + // Resolved once - draw_text / measure_text run per glyph run and per caret + // query, so nothing on that path may issue a syscall or build a path string. + static const std::string& windows_fonts_dir() + { + static const std::string dir = [] { + char buf[MAX_PATH] = {}; + UINT n = GetWindowsDirectoryA(buf, MAX_PATH); + std::string d = (n > 0 && n < MAX_PATH) ? std::string(buf) : "C:\\Windows"; + return d + "\\Fonts\\"; + }(); + return dir; + } + + static std::shared_ptr load_font_file(const std::string& path) + { + auto it = font_files().find(path); + if (it != font_files().end()) return it->second; + + std::shared_ptr ff; + FILE* f = nullptr; + if (fopen_s(&f, path.c_str(), "rb") == 0 && f) { + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + if (sz > 0) { + ff = std::make_shared(); + ff->bytes.resize(static_cast(sz)); + if (fread(ff->bytes.data(), 1, static_cast(sz), f) != + static_cast(sz)) + ff.reset(); + } + fclose(f); + } + font_files()[path] = ff; // negative results cached too + return ff; + } + + // Resolve (family, weight) to a loaded font file. Well-known Windows + // families get their real file names; anything else tries + // ".ttf" and falls back to Segoe UI. + static std::shared_ptr resolve_font_file(const char* family, int weight) + { + const bool bold = weight >= 600; + const std::string& dir = windows_fonts_dir(); + std::string fam = lower_nospace(family); + + struct Known { const char* fam; const char* normal; const char* bold; }; + static const Known known[] = { + { "", "segoeui.ttf", "segoeuib.ttf" }, + { "segoeui", "segoeui.ttf", "segoeuib.ttf" }, + { "arial", "arial.ttf", "arialbd.ttf" }, + { "consolas", "consola.ttf", "consolab.ttf" }, + { "tahoma", "tahoma.ttf", "tahomabd.ttf" }, + { "verdana", "verdana.ttf", "verdanab.ttf" }, + { "couriernew", "cour.ttf", "courbd.ttf" }, + { "timesnewroman", "times.ttf", "timesbd.ttf" }, + }; + for (const auto& k : known) { + if (fam == k.fam) { + if (auto ff = load_font_file(dir + (bold ? k.bold : k.normal))) return ff; + break; + } + } + if (!fam.empty()) { + if (auto ff = load_font_file(dir + fam + (bold ? "b.ttf" : ".ttf"))) return ff; + if (auto ff = load_font_file(dir + fam + ".ttf")) return ff; + } + if (auto ff = load_font_file(dir + (bold ? "segoeuib.ttf" : "segoeui.ttf"))) + return ff; + return load_font_file(dir + "segoeui.ttf"); + } + + // `c` may be null: measure_text is called from the host's non-painting + // sizing paths (ComboBoxWidget::drop_width, popup_total_width), which have no + // bound context. Those get the default selection (family "", weight 0) and an + // identity CTM - the same thing d2d_measure_text does with a null ctx. + static const lv_font_t* resolve_font(LvglCtx* c, float font_size) + { + const char* family = ""; + int weight = 0; + if (c && !c->font_stack.empty()) { + family = c->font_stack.back().family.c_str(); + weight = c->font_stack.back().weight; + } + + // Fold any CTM scale into the pixel size (rare; text is normally drawn + // under translation-only transforms). + const float scale = (c && mat_is_axis_aligned(c->tf)) ? std::fabs(c->tf.d) : 1.0f; + int size_px = static_cast(std::lround(font_size * scale)); + if (size_px < 1) size_px = 1; + + FontLookupKey key{ family, weight, size_px }; + auto lit = font_lookup().find(key); + if (lit != font_lookup().end()) return lit->second; + + const lv_font_t* result = nullptr; + if (auto ff = resolve_font_file(family, weight)) { + FontInstanceKey ikey{ ff.get(), size_px }; + auto it = font_instances().find(ikey); + if (it != font_instances().end()) { + result = it->second; + } else { + // Allocates through LVGL and registers glyph caches - guard it, this + // runs on the first use of a (font, size) pair, which can be a click + // handler outside any draw dispatch. + LvGuard guard; + lv_font_t* font = lv_tiny_ttf_create_data( + ff->bytes.data(), ff->bytes.size(), size_px); + font_instances()[ikey] = font; // null cached too (falls back below) + result = font; + } + } + if (!result) result = lv_font_get_default(); + font_lookup()[key] = result; + return result; + } + + // ------------------------------------------------------------------------- + // Context lifecycle. native_handle is unused (the platform layer owns the + // lv_display); a ctx is just the paint-state bundle bound to layers later. + + static neui_render_ctx_t NEUI_ABI lb_create_context(void*, uint32_t w, uint32_t h) + { + auto* c = new LvglCtx(); + c->width = w; + c->height = h; + return c; + } + + static void NEUI_ABI lb_destroy_context(neui_render_ctx_t raw) + { + auto* c = static_cast(raw); + if (!c) return; + { + // Window teardown, not a draw dispatch: these free through LVGL's + // allocator, which a display thread can be inside under lv_lock. + LvGuard guard; + if (c->batch) lv_draw_vector_dsc_delete(c->batch); + if (c->path) lv_vector_path_delete(c->path); + } + delete c; + } + + static void NEUI_ABI lb_resize(neui_render_ctx_t raw, uint32_t w, uint32_t h) + { + auto* c = static_cast(raw); + if (!c) return; + c->width = w; + c->height = h; + } + + void bind_layer(neui_render_ctx_t raw, _lv_layer_t* layer, + int32_t base_x, int32_t base_y) + { + auto* c = static_cast(raw); + if (!c) return; + if (c->batch) { + // Defensive: a leftover batch references a stale layer - drop it. + lv_draw_vector_dsc_delete(c->batch); + c->batch = nullptr; + } + c->layer = layer; + c->base_x = base_x; + c->base_y = base_y; + c->reset_paint_state(); + if (layer) c->bind_clip = layer->_clip_area; + } + + void unbind_layer(neui_render_ctx_t raw) + { + auto* c = static_cast(raw); + if (!c) return; + flush_vector_batch(c); // before the clip restore - tasks snapshot it + if (c->layer) c->layer->_clip_area = c->bind_clip; + c->layer = nullptr; + c->clip_stack.clear(); + } + + void collect_deferred(neui_render_ctx_t raw) + { + auto* c = static_cast(raw); + if (c) c->subimage_arena.clear(); + } + + // ------------------------------------------------------------------------- + // Frame + solid shapes + + static void NEUI_ABI lb_fill_rect(neui_render_ctx_t raw, + float x, float y, float w, float h, + uint32_t argb); + + static void NEUI_ABI lb_begin_frame(neui_render_ctx_t raw, uint32_t clear_argb) + { + auto* c = static_cast(raw); + if (!c || !c->layer) return; + // Stacks reset per contract. (bind_layer already did this, but paint_frame + // may begin several logical frames on one binding in principle.) + lv_area_t keep_clip = c->layer->_clip_area; + c->reset_paint_state(); + c->layer->_clip_area = keep_clip; + lb_fill_rect(raw, 0.0f, 0.0f, + static_cast(c->width), static_cast(c->height), + clear_argb | 0xFF000000u); + } + + static void NEUI_ABI lb_end_frame(neui_render_ctx_t raw) + { + // Presentation is LVGL's job (flush_cb after the refresh); just make sure + // no batched vector task is left pending. + if (auto* c = static_cast(raw)) flush_vector_batch(c); + } + + // Fill an arbitrary quad via the vector pipeline (general-affine fallback). + static void fill_rect_vector(LvglCtx* c, float x, float y, float w, float h, + uint32_t argb) + { + lv_draw_vector_dsc_t* v = vector_batch(c); + if (!v) return; + lv_vector_path_t* p = lv_vector_path_create(LV_VECTOR_PATH_QUALITY_MEDIUM); + lv_vector_path_append_rectangle(p, x, y, w, h, 0.0f, 0.0f); + lv_matrix_t m = vector_matrix(c); + lv_draw_vector_dsc_set_transform(v, &m); + lv_draw_vector_dsc_set_fill_color(v, to_color(argb)); + lv_draw_vector_dsc_set_fill_opa(v, fold_opa(c, argb)); + lv_draw_vector_dsc_set_fill_rule(v, LV_VECTOR_FILL_NONZERO); + lv_draw_vector_dsc_set_stroke_opa(v, LV_OPA_TRANSP); + lv_draw_vector_dsc_add_path(v, p); + lv_vector_path_delete(p); + } + + static void NEUI_ABI lb_fill_rect(neui_render_ctx_t raw, + float x, float y, float w, float h, + uint32_t argb) + { + auto* c = static_cast(raw); + if (!c || !c->layer || w <= 0.0f || h <= 0.0f) return; + if (!mat_is_axis_aligned(c->tf)) { fill_rect_vector(c, x, y, w, h, argb); return; } + flush_vector_batch(c); // keep z-order vs the lv_draw_fill task below + + lv_draw_fill_dsc_t dsc; + lv_draw_fill_dsc_init(&dsc); + dsc.color = to_color(argb); + dsc.opa = fold_opa(c, argb); + dsc.radius = 0; + lv_area_t a = map_rect(c, x, y, w, h); + lv_draw_fill(c->layer, &dsc, &a); + } + + static void NEUI_ABI lb_draw_rect(neui_render_ctx_t raw, + float x, float y, float w, float h, + float stroke_width, uint32_t argb) + { + auto* c = static_cast(raw); + if (!c || !c->layer || w <= 0.0f || h <= 0.0f) return; + + if (!mat_is_axis_aligned(c->tf)) { + // General affine: stroke the rect as a vector path. + lv_draw_vector_dsc_t* v = vector_batch(c); + if (!v) return; + lv_vector_path_t* p = lv_vector_path_create(LV_VECTOR_PATH_QUALITY_MEDIUM); + lv_vector_path_append_rectangle(p, x, y, w, h, 0.0f, 0.0f); + lv_matrix_t m = vector_matrix(c); + lv_draw_vector_dsc_set_transform(v, &m); + lv_draw_vector_dsc_set_fill_opa(v, LV_OPA_TRANSP); + lv_draw_vector_dsc_set_stroke_color(v, to_color(argb)); + lv_draw_vector_dsc_set_stroke_opa(v, fold_opa(c, argb)); + lv_draw_vector_dsc_set_stroke_width(v, stroke_width); + reset_stroke_style(v); + lv_draw_vector_dsc_add_path(v, p); + lv_vector_path_delete(p); + return; + } + flush_vector_batch(c); // lv_draw_border below must stay in z-order + + // D2D's DrawRectangle strokes CENTERED on the rect edge; lv_draw_border + // draws inside its area. Expand by half the stroke so the border + // straddles the edge the same way. + lv_draw_border_dsc_t dsc; + lv_draw_border_dsc_init(&dsc); + dsc.color = to_color(argb); + dsc.opa = fold_opa(c, argb); + dsc.width = static_cast(std::lround(stroke_width < 1.0f ? 1.0f : stroke_width)); + dsc.radius = 0; + dsc.side = LV_BORDER_SIDE_FULL; + const float half = stroke_width * 0.5f; + lv_area_t a = map_rect(c, x - half, y - half, w + stroke_width, h + stroke_width); + lv_draw_border(c->layer, &dsc, &a); + } + + static float NEUI_ABI lb_get_scale_factor(neui_render_ctx_t) + { + return 1.0f; // fixed-scale prototype (plan: get_scale_factor may be fixed) + } + + static void NEUI_ABI lb_update_dpi(neui_render_ctx_t, uint32_t) {} + + // ------------------------------------------------------------------------- + // Text + + static void NEUI_ABI lb_draw_text(neui_render_ctx_t raw, + float x, float y, float w, float h, + const char* text, float font_size, + uint32_t argb) + { + auto* c = static_cast(raw); + if (!c || !c->layer || !text || !*text || w <= 0.0f || h <= 0.0f) return; + + flush_vector_batch(c); // text must draw above earlier batched paths + + const lv_font_t* font = resolve_font(c, font_size); + lv_draw_label_dsc_t dsc; + lv_draw_label_dsc_init(&dsc); + dsc.text = text; + dsc.text_local = 1; // tasks run later - LVGL must copy the string + dsc.font = font; + dsc.color = to_color(argb); + dsc.opa = fold_opa(c, argb); + // NO wrap, for parity with every other backend: D2D sets + // DWRITE_WORD_WRAPPING_NO_WRAP, Cairo / CG break only on an explicit '\n'. + // Overflow clips at the rect edge (bracket below). Without this LVGL would + // word-wrap at the rect width, which also breaks the single-line caret / + // selection math the text widgets do against measure_text. + dsc.flag = LV_TEXT_FLAG_EXPAND; + + lv_area_t a = map_rect(c, x, y, w, h); + + // Vertical centering of the text block inside the rect, matching D2D + // (DWRITE_PARAGRAPH_ALIGNMENT_CENTER), Cairo and CG. Widget paints pass the + // full widget rect and rely on it. LVGL always draws from coords.y1, so + // position a tight measured box instead: EXPAND makes the label ignore the + // box width for line breaking, so the box stays tight (cheap draw-task + // dependency bookkeeping) while the text still never wraps. + lv_point_t block{}; + lv_text_get_size(&block, text, font, dsc.letter_space, dsc.line_space, + LV_COORD_MAX, LV_TEXT_FLAG_NONE); + const int32_t box_h = a.y2 - a.y1 + 1; + lv_area_t la = a; + la.y1 = a.y1 + static_cast( + std::lround((box_h - block.y) * 0.5f)); + la.y2 = la.y1 + (block.y > 0 ? block.y : 1) - 1; + la.x2 = la.x1 + (block.x > 0 ? block.x : 1) - 1; + + lv_area_t saved = c->layer->_clip_area; + c->layer->_clip_area = intersect(saved, a); + lv_draw_label(c->layer, &dsc, &la); + c->layer->_clip_area = saved; + } + + static float NEUI_ABI lb_measure_text(neui_render_ctx_t raw, + const char* text, int text_len, + float font_size) + { + // NOTE: raw may legitimately be null - the host's non-painting sizing paths + // (ComboBoxWidget::drop_width, popup_total_width) measure without a bound + // context, exactly as they do against d2d_measure_text. Returning 0 there + // collapses combo drop widths and popup menu widths to their minimums. + auto* c = static_cast(raw); + if (!text || !*text) return 0.0f; + + // Measuring fills the Tiny TTF glyph cache, i.e. mutates LVGL state - and + // this is reachable from the host's input handlers, not just from paint. + LvGuard guard; + const lv_font_t* font = resolve_font(c, font_size); + if (!font) return 0.0f; + + // lv_text_get_size wants a NUL-terminated string; honour the byte limit. + std::string tmp; + const char* s = text; + if (text_len >= 0) { + tmp.assign(text, static_cast(text_len)); + s = tmp.c_str(); + } + lv_point_t size{}; + lv_text_get_size(&size, s, font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + return static_cast(size.x); + } + + // ------------------------------------------------------------------------- + // Clip stack + + static void NEUI_ABI lb_push_clip(neui_render_ctx_t raw, + float x, float y, float w, float h) + { + auto* c = static_cast(raw); + if (!c || !c->layer) return; + flush_vector_batch(c); // tasks snapshot the clip at submission + c->clip_stack.push_back(c->layer->_clip_area); + lv_area_t want = map_rect(c, x, y, w, h); + c->layer->_clip_area = intersect(c->layer->_clip_area, want); + } + + static void NEUI_ABI lb_pop_clip(neui_render_ctx_t raw) + { + auto* c = static_cast(raw); + if (!c || !c->layer || c->clip_stack.empty()) return; + flush_vector_batch(c); // tasks snapshot the clip at submission + c->layer->_clip_area = c->clip_stack.back(); + c->clip_stack.pop_back(); + } + + // ------------------------------------------------------------------------- + // Bitmaps. Handles own a premultiplied-ARGB8888 lv_image_dsc_t; sub-rect + // draws allocate a per-draw descriptor in the ctx arena (freed at the next + // bind, after the refresh that consumed it has finished). + + struct LvglBitmap { + lv_image_dsc_t dsc{}; + std::vector pixels; + float scale = 1.0f; + }; + + struct SubImage { + lv_image_dsc_t dsc{}; + }; + + static void* NEUI_ABI lb_create_bitmap(neui_render_ctx_t, + uint32_t width_px, uint32_t height_px, + const uint8_t* bgra, float scale) + { + if (!bgra || width_px == 0 || height_px == 0) return nullptr; + auto* bmp = new LvglBitmap(); + bmp->pixels.assign(bgra, bgra + static_cast(width_px) * height_px * 4); + bmp->scale = (scale > 0.0f) ? scale : 1.0f; + bmp->dsc.header.magic = LV_IMAGE_HEADER_MAGIC; + bmp->dsc.header.cf = LV_COLOR_FORMAT_ARGB8888_PREMULTIPLIED; + bmp->dsc.header.w = width_px; + bmp->dsc.header.h = height_px; + bmp->dsc.header.stride = width_px * 4; + bmp->dsc.data = bmp->pixels.data(); + bmp->dsc.data_size = static_cast(bmp->pixels.size()); + return bmp; + } + + static void NEUI_ABI lb_destroy_bitmap(neui_render_ctx_t, void* bitmap) + { + delete static_cast(bitmap); + } + + static void NEUI_ABI lb_draw_bitmap(neui_render_ctx_t raw, void* bitmap, + float src_x, float src_y, + float src_w, float src_h, + float dst_x, float dst_y, + float dst_w, float dst_h, + uint32_t tint) + { + auto* c = static_cast(raw); + auto* bmp = static_cast(bitmap); + if (!c || !c->layer || !bmp || dst_w <= 0.0f || dst_h <= 0.0f) return; + flush_vector_batch(c); // keep z-order vs the image task below + + // Source rect: logical -> physical pixels of the bitmap. + const float s = bmp->scale; + int32_t sx = static_cast(std::lround(src_x * s)); + int32_t sy = static_cast(std::lround(src_y * s)); + int32_t sw = static_cast(std::lround(src_w * s)); + int32_t sh = static_cast(std::lround(src_h * s)); + if (sw <= 0 || sh <= 0) { sx = 0; sy = 0; sw = static_cast(bmp->dsc.header.w); sh = static_cast(bmp->dsc.header.h); } + if (sx < 0) sx = 0; + if (sy < 0) sy = 0; + if (sx + sw > static_cast(bmp->dsc.header.w)) sw = static_cast(bmp->dsc.header.w) - sx; + if (sy + sh > static_cast(bmp->dsc.header.h)) sh = static_cast(bmp->dsc.header.h) - sy; + if (sw <= 0 || sh <= 0) return; + + // Sub-image descriptor pointing into the owning bitmap's pixels (stride + // stays the full row). Arena-owned: must outlive the deferred draw task. + auto sub = std::make_unique(); + sub->dsc = bmp->dsc; + sub->dsc.header.w = static_cast(sw); + sub->dsc.header.h = static_cast(sh); + sub->dsc.data = bmp->pixels.data() + + static_cast(sy) * bmp->dsc.header.stride + + static_cast(sx) * 4; + + lv_draw_image_dsc_t dsc; + lv_draw_image_dsc_init(&dsc); + dsc.src = &sub->dsc; + // `tint` is a multiplicative ARGB (renderer.h): its alpha scales the draw + // opacity on top of the ctx alpha stack, its RGB multiplies the pixels. + // d2d/cg fold both into a colour-matrix; cairo paints at alpha then + // MULTIPLYs the RGB through the image's own alpha mask. + dsc.opa = fold_opa(c, tint); + if ((tint & 0x00FFFFFFu) != 0x00FFFFFFu) { + // LVGL offers no multiply - only recolor, a mix toward a flat colour. At + // full strength that reproduces the multiply exactly for the dominant + // case (a white / greyscale glyph or icon colourised by the tint RGB). + // A darkening grey tint over already-coloured pixels flattens rather than + // scaling; accepted prototype approximation. Driving recolor_opa from the + // ALPHA byte, as this did, is wrong twice over: it made a translucent + // tint fully opaque and turned "no colour change" into a 50% white wash. + dsc.recolor = to_color(tint); + dsc.recolor_opa = LV_OPA_COVER; + } + // Place the image's natural (physical-px) extent at the dst origin and + // scale it around the top-left pivot onto the dst rect. Fold in any + // axis-aligned CTM scale via the mapped dst rect. + lv_area_t dst = map_rect(c, dst_x, dst_y, dst_w, dst_h); + const float dst_wpx = static_cast(dst.x2 - dst.x1 + 1); + const float dst_hpx = static_cast(dst.y2 - dst.y1 + 1); + dsc.scale_x = static_cast(std::lround(256.0f * dst_wpx / static_cast(sw))); + dsc.scale_y = static_cast(std::lround(256.0f * dst_hpx / static_cast(sh))); + dsc.pivot.x = 0; + dsc.pivot.y = 0; + + lv_area_t coords; + coords.x1 = dst.x1; + coords.y1 = dst.y1; + coords.x2 = dst.x1 + sw - 1; + coords.y2 = dst.y1 + sh - 1; + + // Keep the transformed output inside the dst rect. + lv_area_t saved = c->layer->_clip_area; + c->layer->_clip_area = intersect(saved, dst); + lv_draw_image(c->layer, &dsc, &coords); + c->layer->_clip_area = saved; + + c->subimage_arena.push_back(std::move(sub)); + } + + // ------------------------------------------------------------------------- + // Path API. Verbs collect raw logical coords; the CTM is applied at fill / + // stroke time through the vector matrix (matches D2D, where the target + // transform applies when the geometry is drawn). + + static void NEUI_ABI lb_begin_path(neui_render_ctx_t raw) + { + auto* c = static_cast(raw); + if (!c) return; + if (c->path) lv_vector_path_clear(c->path); + else c->path = lv_vector_path_create(LV_VECTOR_PATH_QUALITY_MEDIUM); + c->fill_rule = NEUI_FILL_RULE_NONZERO; + c->has_current = false; + } + + static void NEUI_ABI lb_move_to(neui_render_ctx_t raw, float x, float y) + { + auto* c = static_cast(raw); + if (!c || !c->path) return; + lv_fpoint_t p{ x, y }; + lv_vector_path_move_to(c->path, &p); + c->has_current = true; + c->cur_x = x; c->cur_y = y; + c->sub_start_x = x; c->sub_start_y = y; + } + + static void NEUI_ABI lb_line_to(neui_render_ctx_t raw, float x, float y) + { + auto* c = static_cast(raw); + if (!c || !c->path) return; + if (!c->has_current) { lb_move_to(raw, x, y); return; } + lv_fpoint_t p{ x, y }; + lv_vector_path_line_to(c->path, &p); + c->cur_x = x; c->cur_y = y; + } + + static void NEUI_ABI lb_cubic_to(neui_render_ctx_t raw, float c1x, float c1y, + float c2x, float c2y, float x, float y) + { + auto* c = static_cast(raw); + if (!c || !c->path) return; + if (!c->has_current) lb_move_to(raw, c1x, c1y); + lv_fpoint_t p1{ c1x, c1y }, p2{ c2x, c2y }, p3{ x, y }; + lv_vector_path_cubic_to(c->path, &p1, &p2, &p3); + c->cur_x = x; c->cur_y = y; + } + + static void NEUI_ABI lb_quad_to(neui_render_ctx_t raw, float cx, float cy, + float x, float y) + { + auto* c = static_cast(raw); + if (!c || !c->path) return; + if (!c->has_current) lb_move_to(raw, cx, cy); + lv_fpoint_t p1{ cx, cy }, p2{ x, y }; + lv_vector_path_quad_to(c->path, &p1, &p2); + c->cur_x = x; c->cur_y = y; + } + + // Append a centre-parameterised arc as cubic segments. Sweep direction is + // implicit from start -> end (Y-down, positive sweep = clockwise on + // screen), matching the D2D / CG / Cairo backends: an existing current + // point connects to the arc start with a line (Cairo semantics). + static void NEUI_ABI lb_arc(neui_render_ctx_t raw, + float cx, float cy, float radius, + float start_rad, float end_rad) + { + auto* c = static_cast(raw); + if (!c || !c->path || radius <= 0.0f) return; + + const float sx = cx + radius * std::cos(start_rad); + const float sy = cy + radius * std::sin(start_rad); + if (c->has_current) lb_line_to(raw, sx, sy); + else lb_move_to(raw, sx, sy); + + float sweep = end_rad - start_rad; + if (sweep == 0.0f) return; + const int nseg = static_cast(std::ceil(std::fabs(sweep) / (3.14159265f / 2.0f))); + const float step = sweep / static_cast(nseg); + const float k = 4.0f / 3.0f * std::tan(step / 4.0f); + + float a0 = start_rad; + for (int i = 0; i < nseg; ++i) { + const float a1 = a0 + step; + const float c0x = std::cos(a0), c0y = std::sin(a0); + const float c1x = std::cos(a1), c1y = std::sin(a1); + lv_fpoint_t p1{ cx + radius * (c0x - k * c0y), cy + radius * (c0y + k * c0x) }; + lv_fpoint_t p2{ cx + radius * (c1x + k * c1y), cy + radius * (c1y - k * c1x) }; + lv_fpoint_t p3{ cx + radius * c1x, cy + radius * c1y }; + lv_vector_path_cubic_to(c->path, &p1, &p2, &p3); + a0 = a1; + } + c->cur_x = cx + radius * std::cos(end_rad); + c->cur_y = cy + radius * std::sin(end_rad); + } + + static void NEUI_ABI lb_close_path(neui_render_ctx_t raw) + { + auto* c = static_cast(raw); + if (!c || !c->path) return; + lv_vector_path_close(c->path); + c->cur_x = c->sub_start_x; + c->cur_y = c->sub_start_y; + } + + static void NEUI_ABI lb_set_fill_rule(neui_render_ctx_t raw, neui_fill_rule_t rule) + { + auto* c = static_cast(raw); + if (!c) return; + c->fill_rule = rule; + } + + static void apply_stroke_style(lv_draw_vector_dsc_t* v, + const neui_stroke_style_t* style) + { + if (!style) return; + switch (style->cap) { + case NEUI_LINE_CAP_ROUND: lv_draw_vector_dsc_set_stroke_cap(v, LV_VECTOR_STROKE_CAP_ROUND); break; + case NEUI_LINE_CAP_SQUARE: lv_draw_vector_dsc_set_stroke_cap(v, LV_VECTOR_STROKE_CAP_SQUARE); break; + default: lv_draw_vector_dsc_set_stroke_cap(v, LV_VECTOR_STROKE_CAP_BUTT); break; + } + switch (style->join) { + case NEUI_LINE_JOIN_ROUND: lv_draw_vector_dsc_set_stroke_join(v, LV_VECTOR_STROKE_JOIN_ROUND); break; + case NEUI_LINE_JOIN_BEVEL: lv_draw_vector_dsc_set_stroke_join(v, LV_VECTOR_STROKE_JOIN_BEVEL); break; + default: lv_draw_vector_dsc_set_stroke_join(v, LV_VECTOR_STROKE_JOIN_MITER); break; + } + const float miter = style->miter_limit > 0.0f ? style->miter_limit : 4.0f; + lv_draw_vector_dsc_set_stroke_miter_limit(v, static_cast(miter)); + if (style->dash_array && style->dash_count > 0) { + // (dash_offset is not expressible through the LVGL vector API - the + // pattern starts at the path start; acceptable for the prototype.) + std::vector dashes(style->dash_array, + style->dash_array + style->dash_count); + lv_draw_vector_dsc_set_stroke_dash(v, dashes.data(), + static_cast(dashes.size())); + } + } + + static void set_vector_gradient(lv_draw_vector_dsc_t* v, + const neui_gradient_t* grad, + LvglCtx* c, bool stroke) + { + // lv_conf.h raises LV_GRADIENT_MAX_STOPS to 16 for us, but neui's stop list + // is unbounded - clamp here rather than letting LVGL truncate with a + // per-frame LV_LOG_WARN on stdout. + uint32_t stop_count = grad->stop_count; + if (stop_count > LV_GRADIENT_MAX_STOPS) stop_count = LV_GRADIENT_MAX_STOPS; + std::vector stops(stop_count); + for (uint32_t i = 0; i < stop_count; ++i) { + const auto& s = grad->stops[i]; + float off = s.offset; + if (off < 0.0f) off = 0.0f; + if (off > 1.0f) off = 1.0f; + stops[i].color = to_color(s.argb); + stops[i].opa = fold_opa(c, s.argb); + stops[i].frac = static_cast(off * 255.0f + 0.5f); + } + lv_vector_gradient_spread_t spread = LV_VECTOR_GRADIENT_SPREAD_PAD; + if (grad->extend == NEUI_GRADIENT_EXTEND_REPEAT) spread = LV_VECTOR_GRADIENT_SPREAD_REPEAT; + if (grad->extend == NEUI_GRADIENT_EXTEND_MIRROR) spread = LV_VECTOR_GRADIENT_SPREAD_REFLECT; + + if (stroke) { + if (grad->kind == NEUI_GRADIENT_RADIAL) + lv_draw_vector_dsc_set_stroke_radial_gradient(v, grad->start_x, grad->start_y, grad->radius); + else + lv_draw_vector_dsc_set_stroke_linear_gradient(v, grad->start_x, grad->start_y, + grad->end_x, grad->end_y); + lv_draw_vector_dsc_set_stroke_gradient_spread(v, spread); + lv_draw_vector_dsc_set_stroke_gradient_color_stops(v, stops.data(), + static_cast(stops.size())); + } else { + if (grad->kind == NEUI_GRADIENT_RADIAL) + lv_draw_vector_dsc_set_fill_radial_gradient(v, grad->start_x, grad->start_y, grad->radius); + else + lv_draw_vector_dsc_set_fill_linear_gradient(v, grad->start_x, grad->start_y, + grad->end_x, grad->end_y); + lv_draw_vector_dsc_set_fill_gradient_spread(v, spread); + lv_draw_vector_dsc_set_fill_gradient_color_stops(v, stops.data(), + static_cast(stops.size())); + } + } + + static void NEUI_ABI lb_fill_path(neui_render_ctx_t raw, uint32_t argb) + { + auto* c = static_cast(raw); + if (!c || !c->layer || !c->path) return; + lv_draw_vector_dsc_t* v = vector_batch(c); + if (!v) return; + lv_matrix_t m = vector_matrix(c); + lv_draw_vector_dsc_set_transform(v, &m); + lv_draw_vector_dsc_set_fill_color(v, to_color(argb)); + lv_draw_vector_dsc_set_fill_opa(v, fold_opa(c, argb)); + lv_draw_vector_dsc_set_fill_rule(v, c->fill_rule == NEUI_FILL_RULE_EVENODD + ? LV_VECTOR_FILL_EVENODD + : LV_VECTOR_FILL_NONZERO); + lv_draw_vector_dsc_set_stroke_opa(v, LV_OPA_TRANSP); + lv_draw_vector_dsc_add_path(v, c->path); + } + + static void stroke_path_impl(LvglCtx* c, float stroke_width, uint32_t argb, + const neui_gradient_t* grad, + const neui_stroke_style_t* style) + { + if (!c || !c->layer || !c->path || stroke_width <= 0.0f) return; + lv_draw_vector_dsc_t* v = vector_batch(c); + if (!v) return; + lv_matrix_t m = vector_matrix(c); + lv_draw_vector_dsc_set_transform(v, &m); + lv_draw_vector_dsc_set_fill_opa(v, LV_OPA_TRANSP); + lv_draw_vector_dsc_set_stroke_width(v, stroke_width); + if (grad) { + lv_draw_vector_dsc_set_stroke_opa(v, LV_OPA_COVER); + set_vector_gradient(v, grad, c, /*stroke=*/true); + } else { + lv_draw_vector_dsc_set_stroke_color(v, to_color(argb)); + lv_draw_vector_dsc_set_stroke_opa(v, fold_opa(c, argb)); + } + reset_stroke_style(v); + apply_stroke_style(v, style); + lv_draw_vector_dsc_add_path(v, c->path); + } + + static void NEUI_ABI lb_stroke_path(neui_render_ctx_t raw, float stroke_width, + uint32_t argb) + { + stroke_path_impl(static_cast(raw), stroke_width, argb, + nullptr, nullptr); + } + + static void NEUI_ABI lb_stroke_path_styled(neui_render_ctx_t raw, + float stroke_width, uint32_t argb, + const neui_stroke_style_t* style) + { + stroke_path_impl(static_cast(raw), stroke_width, argb, + nullptr, style); + } + + static void NEUI_ABI lb_stroke_path_gradient(neui_render_ctx_t raw, + float stroke_width, + const neui_gradient_t* grad, + const neui_stroke_style_t* style) + { + if (!grad || grad->stop_count < 2 || !grad->stops) return; + stroke_path_impl(static_cast(raw), stroke_width, 0, + grad, style); + } + + static void NEUI_ABI lb_fill_path_gradient(neui_render_ctx_t raw, + const neui_gradient_t* grad) + { + auto* c = static_cast(raw); + if (!c || !c->layer || !c->path) return; + if (!grad || grad->stop_count < 2 || !grad->stops) return; + lv_draw_vector_dsc_t* v = vector_batch(c); + if (!v) return; + lv_matrix_t m = vector_matrix(c); + lv_draw_vector_dsc_set_transform(v, &m); + lv_draw_vector_dsc_set_fill_opa(v, LV_OPA_COVER); + set_vector_gradient(v, grad, c, /*stroke=*/false); + lv_draw_vector_dsc_set_fill_rule(v, c->fill_rule == NEUI_FILL_RULE_EVENODD + ? LV_VECTOR_FILL_EVENODD + : LV_VECTOR_FILL_NONZERO); + lv_draw_vector_dsc_set_stroke_opa(v, LV_OPA_TRANSP); + lv_draw_vector_dsc_add_path(v, c->path); + } + + static void NEUI_ABI lb_fill_rect_gradient(neui_render_ctx_t raw, + float x, float y, float w, float h, + const neui_gradient_t* grad) + { + auto* c = static_cast(raw); + if (!c || !c->layer || w <= 0.0f || h <= 0.0f) return; + if (!grad || grad->stop_count < 2 || !grad->stops) return; + lv_draw_vector_dsc_t* v = vector_batch(c); + if (!v) return; + lv_vector_path_t* p = lv_vector_path_create(LV_VECTOR_PATH_QUALITY_MEDIUM); + lv_vector_path_append_rectangle(p, x, y, w, h, 0.0f, 0.0f); + lv_matrix_t m = vector_matrix(c); + lv_draw_vector_dsc_set_transform(v, &m); + lv_draw_vector_dsc_set_fill_opa(v, LV_OPA_COVER); + set_vector_gradient(v, grad, c, /*stroke=*/false); + lv_draw_vector_dsc_set_fill_rule(v, LV_VECTOR_FILL_NONZERO); + lv_draw_vector_dsc_set_stroke_opa(v, LV_OPA_TRANSP); + lv_draw_vector_dsc_add_path(v, p); + lv_vector_path_delete(p); + } + + // ------------------------------------------------------------------------- + // Transform / alpha / font stacks + + static void NEUI_ABI lb_push_transform(neui_render_ctx_t raw) + { + auto* c = static_cast(raw); + if (c) c->tf_stack.push_back(c->tf); + } + + static void NEUI_ABI lb_pop_transform(neui_render_ctx_t raw) + { + auto* c = static_cast(raw); + if (!c || c->tf_stack.empty()) return; + c->tf = c->tf_stack.back(); + c->tf_stack.pop_back(); + } + + static void NEUI_ABI lb_translate(neui_render_ctx_t raw, float dx, float dy) + { + auto* c = static_cast(raw); + if (!c) return; + Mat23 m; m.tx = dx; m.ty = dy; + c->tf = mat_mul(c->tf, m); + } + + static void NEUI_ABI lb_rotate(neui_render_ctx_t raw, float radians) + { + auto* c = static_cast(raw); + if (!c) return; + Mat23 m; + m.a = std::cos(radians); m.b = std::sin(radians); + m.c = -m.b; m.d = m.a; + c->tf = mat_mul(c->tf, m); + } + + static void NEUI_ABI lb_scale(neui_render_ctx_t raw, float sx, float sy) + { + auto* c = static_cast(raw); + if (!c) return; + Mat23 m; m.a = sx; m.d = sy; + c->tf = mat_mul(c->tf, m); + } + + static uint32_t NEUI_ABI lb_get_context_generation(neui_render_ctx_t) + { + return 0u; // software rendering - no device loss + } + + static void NEUI_ABI lb_push_alpha(neui_render_ctx_t raw, float factor) + { + auto* c = static_cast(raw); + if (!c) return; + if (factor < 0.0f) factor = 0.0f; + if (factor > 1.0f) factor = 1.0f; + c->alpha_stack.push_back(factor); + } + + static void NEUI_ABI lb_pop_alpha(neui_render_ctx_t raw) + { + auto* c = static_cast(raw); + if (c && !c->alpha_stack.empty()) c->alpha_stack.pop_back(); + } + + static void NEUI_ABI lb_push_font(neui_render_ctx_t raw, + const char* family_utf8, int weight) + { + auto* c = static_cast(raw); + if (!c) return; + LvglCtx::FontSel sel; + sel.family = family_utf8 ? family_utf8 : ""; + sel.weight = weight; + c->font_stack.push_back(std::move(sel)); + } + + static void NEUI_ABI lb_pop_font(neui_render_ctx_t raw) + { + auto* c = static_cast(raw); + if (c && !c->font_stack.empty()) c->font_stack.pop_back(); + } + + // ------------------------------------------------------------------------- + // Deferred for the prototype: off-screen surfaces + font registration. + + static neui_render_ctx_t NEUI_ABI lb_create_offscreen_context(uint32_t, uint32_t, float) + { + return nullptr; // SURFACE assets degrade to asset_none (null-backend precedent) + } + + static bool NEUI_ABI lb_read_pixels_bgra(neui_render_ctx_t, uint8_t*) + { + return false; + } + + static bool NEUI_ABI lb_register_font(const uint8_t*, uint32_t, + char* out_family, uint32_t cap, uint64_t* out_token) + { + if (out_family && cap) out_family[0] = '\0'; + if (out_token) *out_token = 0; + return false; + } + + static bool NEUI_ABI lb_register_font_file(const char*, char* out_family, + uint32_t cap, uint64_t* out_token) + { + if (out_family && cap) out_family[0] = '\0'; + if (out_token) *out_token = 0; + return false; + } + + static void NEUI_ABI lb_unregister_font(uint64_t) {} + + // ------------------------------------------------------------------------- + + static neui_render_backend_t backend = { + NEUI_VERSION, + lb_create_context, + lb_destroy_context, + lb_resize, + lb_begin_frame, + lb_end_frame, + lb_fill_rect, + lb_draw_rect, + lb_get_scale_factor, + lb_update_dpi, + lb_draw_text, + lb_measure_text, + lb_push_clip, + lb_pop_clip, + lb_create_bitmap, + lb_destroy_bitmap, + lb_draw_bitmap, + lb_begin_path, + lb_move_to, + lb_line_to, + lb_arc, + lb_close_path, + lb_fill_path, + lb_stroke_path, + lb_push_transform, + lb_pop_transform, + lb_translate, + lb_rotate, + lb_scale, + lb_get_context_generation, + lb_push_alpha, + lb_pop_alpha, + lb_push_font, + lb_pop_font, + lb_create_offscreen_context, + lb_read_pixels_bgra, + lb_register_font, + lb_register_font_file, + lb_unregister_font, + lb_fill_rect_gradient, + lb_fill_path_gradient, + lb_cubic_to, + lb_quad_to, + lb_set_fill_rule, + lb_stroke_path_styled, + lb_stroke_path_gradient, + }; + + neui_render_backend_t* get_backend() { return &backend; } + +} // namespace neui_lvgl_backend diff --git a/backends/lvgl/lvgl_backend.h b/backends/lvgl/lvgl_backend.h new file mode 100644 index 0000000..1dac388 --- /dev/null +++ b/backends/lvgl/lvgl_backend.h @@ -0,0 +1,44 @@ +#pragma once +#include +#include + +// LVGL rendering backend for the crossplatform host (prototype - see +// plans/lvgl-host-approach-c.md). Unlike the window-bound D2D / CG / Cairo +// backends, a context here is not permanently attached to a drawable: LVGL +// hands out a target layer only inside a draw event, so the platform layer +// binds / unbinds the active lv_layer_t around each paint dispatch. + +struct _lv_layer_t; // matches LVGL's typedef struct _lv_layer_t lv_layer_t + +namespace neui_lvgl_backend +{ + neui_render_backend_t* get_backend(); + + // Not every backend entry point runs inside a draw dispatch: measure_text is + // called from the host's non-painting sizing paths and destroy_context from + // window teardown, yet both touch LVGL global state (Tiny TTF instances and + // their glyph caches, the vector path allocator) that a display thread may be + // inside under lv_lock. The platform layer owns the lock policy - it alone + // knows when the current thread is already inside an LVGL callback - so it + // installs a re-entrant lock / unlock pair here at init. Without them the + // backend does no locking at all (single-threaded LVGL builds). + void set_lock_hooks(void (*lock)(), void (*unlock)()); + + // Bind the LVGL layer this ctx draws into for the duration of one draw + // dispatch (LV_EVENT_DRAW_MAIN / DRAW_POST). (base_x, base_y) is the + // display-coordinate position the ctx's logical origin maps to. Binding + // resets the transform / clip / alpha / font stacks (the per-frame reset + // the other backends do in begin_frame). + void bind_layer(neui_render_ctx_t ctx, _lv_layer_t* layer, + int32_t base_x, int32_t base_y); + + // Unbind after the dispatch returns; restores the layer's original clip. + void unbind_layer(neui_render_ctx_t ctx); + + // Free per-draw deferred resources (sub-image descriptors referenced by + // LVGL draw tasks). Call once per loop turn AFTER lv_timer_handler has + // returned - the refresh that consumed the tasks has completed by then. + // Must NOT be called between draw dispatches of the same refresh (retained + // mode binds per widget while tasks are still pending). + void collect_deferred(neui_render_ctx_t ctx); +} diff --git a/docs/compound-behavior-component.md b/docs/compound-behavior-component.md index 139ed1d..d487f24 100644 --- a/docs/compound-behavior-component.md +++ b/docs/compound-behavior-component.md @@ -30,5 +30,5 @@ A **component** is `NEUI_ASSET_KIND_COMPONENT` (`8`) - a reusable, JSON-authored **Public API** (vtable-appended, no new `get_interface` entry). On `neui_asset_api_t` (`include/neui/d/assets.h`): `create_component_from_string(json,len,env)` / `create_component_from_file(path,env)` → a COMPONENT `neui_asset_t` (owns its compound+behavior+path-loaded layer assets; `destroy` releases them; `get_kind`→COMPONENT, `get_size`→default size); `component_param_count` / `component_param_at` (the `params` manifest); `serialize_component(component,out,cap,indent)` → re-emit the document (designer round-trip; minimal-diff, structure only, no per-instance values - asset layers serialize back by name). On `neui_widget_api_t` (`include/neui/d/widgets.h`): `create_from_component(parent,asset,x,y,w,h)` (create CUSTOMDRAW + attach compound+behavior + stamp defaults; `w/h<=0` = component default size). `set_asset` is also COMPONENT-aware (attach-to-existing CUSTOMDRAW: both slots + stamp). Per-instance values via the normal attr API. Types `neui_component_env_t` (base_dir + `resolve_asset` callback for injected/in-memory handles, consulted before path mode) + `neui_component_param_t` live in `assets.h` (avoids a circular include); `include/neui/d/component.h` is the doc umbrella + full JSON schema. -**Implementation**: host-side, not a client helper - the parser lives in the core lib, so parse+materialize run inside the host. The host-agnostic walker `hosts/shared/component_loader.h::build_component` drives the public compound/behavior/asset api vtables (handed in by each host); `serialize_component` introspects the built `CompoundAsset`/`BehaviorAsset` back to a `mujson::object_t`. The COMPONENT store entry (`hosts/shared/asset_store.h` `AssetEntry::comp_*` + `allocate_component`) owns the sub-assets + round-trip metadata (name, assets map, handle→name). Per-host glue (the 5 asset thunks + `create_from_component` + the COMPONENT branch in `set_asset` + COMPONENT case in `get_size`) is wired in all three hosts (`hosts/{crossplatform,win32}/widgets.cpp`, `hosts/macos/widgets.mm`). Every JSON field maps 1:1 onto an existing compound-layer / behavior-handler prop; `as_num` accepts both int and double arms, colors accept `#AARRGGBB` or a bare int. Verify: `tests/test_component_loader.cpp` (Tier-1, fake api vtables - load + serialize round-trip + minimal-diff) and `examples/component_knob_example.cpp` → `neui_component_knob_example` (loads `components/knob.json`, 4 knobs via both instantiate paths, an injected SURFACE pointer, + a serialize dump). **Note: `AssetEntry` grew with the `comp_*` fields - a full rebuild is required (incremental builds with stale objects compiled against the old struct size corrupt the entry).** +**Implementation**: host-side, not a client helper - the parser lives in the core lib, so parse+materialize run inside the host. The host-agnostic walker `hosts/shared/component_loader.h::build_component` drives the public compound/behavior/asset api vtables (handed in by each host). Layer assets go through **one non-vtable seam**, `ComponentApis::bitmap_from_name(user, name, base_dir)`, installed by every host: the public asset API addresses images by path, so without it the loader would have to join `base_dir` onto the document's raw `assets` entry before the client resource provider ever saw the name (`docs/rendering-and-assets.md`, `d/resource.h`). Resolution order per asset is unchanged - `env.resolve_asset` → provider → `base_dir`-joined file - only the provider now gets the raw name plus the directory. With no hook installed the loader falls back to `create_from_file(join_path(base_dir, entry))` exactly as before. `serialize_component` introspects the built `CompoundAsset`/`BehaviorAsset` back to a `mujson::object_t`. The COMPONENT store entry (`hosts/shared/asset_store.h` `AssetEntry::comp_*` + `allocate_component`) owns the sub-assets + round-trip metadata (name, assets map, handle→name). Per-host glue (the 5 asset thunks + `create_from_component` + the COMPONENT branch in `set_asset` + COMPONENT case in `get_size`) is wired in all three hosts (`hosts/{crossplatform,win32}/widgets.cpp`, `hosts/macos/widgets.mm`). Every JSON field maps 1:1 onto an existing compound-layer / behavior-handler prop; `as_num` accepts both int and double arms, colors accept `#AARRGGBB` or a bare int. Verify: `tests/test_component_loader.cpp` (Tier-1, fake api vtables - load + serialize round-trip + minimal-diff) and `examples/component_knob_example.cpp` → `neui_component_knob_example` (loads `components/knob.json`, 4 knobs via both instantiate paths, an injected SURFACE pointer, + a serialize dump). **Note: `AssetEntry` grew with the `comp_*` fields - a full rebuild is required (incremental builds with stale objects compiled against the old struct size corrupt the entry).** diff --git a/docs/deferred-issues.md b/docs/deferred-issues.md index 6cab427..91ce44a 100644 --- a/docs/deferred-issues.md +++ b/docs/deferred-issues.md @@ -16,5 +16,5 @@ - **Compound layer kinds** beyond `text` / `asset` / `rect` / `path` / `qr` / `group`: template format specs (`{key:.2f}`); SVG-mini string form for `set_path`. (Cubic/quadratic bezier on PATH now ship - `NEUI_PATH_CMD_CUBIC_TO`/`_QUAD_TO`, `neui_path_cmd_t.args` widened to `[6]`, **full rebuild required** like the `AssetEntry` note. The `group` container layer now ships too - `NEUI_COMPOUND_LAYER_GROUP` + `add_child_layer`, see the Compound drawables section.) - **Behavior detent / plateau modifier** - per-handler "sticky values" that resist near specific points (distinct from `steps`); behavior pen pressure / tilt (pen surfaces as mouse); `cursor` prop no-op; wheel modifier-fine needs a `neui_event_wheel_t` payload extension. - **GRID sort follow-ups** - STRING kind is byte-level `strcmp` (locale-aware compare deferred); no per-column custom comparator callback (clients pre-format or pick STRING / INT / FLOAT / NATURAL); no built-in DATE / TIME kind. -- **Embedded PLUGWINDOW (`NEUI_API_EMBED`) follow-ups** - the embedded frame does not auto-track parent resizes (the plugin adapter drives size explicitly via `widgets->set_size` after host resize negotiation - correct for plugin formats, where size is negotiated, but a free-following mode could add an autoresize/frame-observer opt-in); win32 embedded frames don't receive `WM_DPICHANGED` (it goes to the DAW's top-level - a monitor change mid-session keeps the old scale until recreate); embedded keyboard focus is click-to-focus only - it works (win32 `SetFocus` on `WM_LBUTTONDOWN` for a `WS_CHILD` frame, macOS `makeFirstResponder:` in `NEUIView mouseDown:`, both skipped for standalone frames which the OS focuses itself) but the frame does not participate in the DAW's own tab loop; the native win32/macOS hosts do not implement `NEUI_API_EMBED` (xpl host only - by design, plugin builds select `neui.host.crossplatform`). +- **LVGL host gaps** - not listed individually here: that host is **experimental** and opt-in, so its stubs (clipboard / DnD / IME / native menubar / message boxes / off-screen surfaces / font registration / DPI scaling) are a scope boundary rather than deferred work on a shipped feature. The full list, and why the remaining work waits for real hardware, is in `docs/host-lvgl.md`. diff --git a/docs/host-lvgl.md b/docs/host-lvgl.md new file mode 100644 index 0000000..82ac44f --- /dev/null +++ b/docs/host-lvgl.md @@ -0,0 +1,72 @@ + + +## LVGL host (EXPERIMENTAL) + +> **Status: experimental prototype. Not supported for product work, and not built by default.** +> It exists to answer one question - can neui render through LVGL on embedded hardware - and it +> answered it on a desktop stand-in. **Further work is paused until real target hardware is +> available**; the two things that matter next (a per-widget appearance cache and an embedded +> display driver / VGLite path) cannot be evaluated without a panel and an MCU in the loop. +> Design record, per-milestone detail and all measurements: `plans/lvgl-host-approach-c.md`. + +**What it is**: a second rendering substrate for the *existing* crossplatform host, not a new host. +With `-DNEUI_WITH_LVGL=ON` the xpl host pairs with `backends/lvgl/` (`neui-backend-lvgl`) + +`platform_lvgl.cpp` instead of `neui-backend-d2d` + `platform_win32.cpp`. Client code is unchanged +and unaware: the public API surface is identical, so what is experimental is the *host feature set* +below, not the client contract. + +**Build** (Windows only - configure `FATAL_ERROR`s elsewhere, because the prototype drives the LVGL +Windows display driver): + +```bash +cmake -B out/build-lvgl -DNEUI_WITH_LVGL=ON [-DNEUI_LVGL_COLOR_DEPTH=32|16] +cmake --build out/build-lvgl --config Debug +``` + +LVGL is fetched from git via FetchContent and **pinned to commit `066d8db0`** (the revision every +measurement was taken against; move to a release tag once one carries the `lv_draw_vector_dsc_*` +API). `lv_conf.h` is generated from `backends/lvgl/lv_conf.h.in`; `NEUI_LVGL_COLOR_DEPTH` selects +the framebuffer depth (32 bpp default, 16 = RGB565). Adds one target, `neui_lvgl_example` (console +app; it prints frame timings to stdout). + +**Retained vs whole-frame**: by default each neui widget gets a passive `lv_obj` mirror so a local +interaction repaints one widget rect instead of the screen ("Option C"). Set `NEUI_LVGL_RETAINED=0` +at runtime to fall back to the whole-frame walk in the same binary - the comparison the measurements +in the plan are built on. + +**Works**: the full `neui_render_backend_t` (fills, borders, text through per-size Tiny TTF +instances, the whole path model through ThorVG, linear/radial gradients, clip stack, 2x3 CTM, +bitmaps), the retained mirror layer with per-widget invalidation and correct child paint order, +mouse / key / wheel input including **smooth-scroll kinetics** for the scrolling SECTION and the +GRID (the shared `scroll_kinetics` integrators on a 16 ms `lv_timer`), images through +`stb_image` (`hosts/shared/image_loader_stb.h`), and everything portable that rides on those: +compound / behavior / component assets, the GRID, and the client resource provider +(`docs/rendering-and-assets.md`). + +**Stubbed or absent** (the honest list - none of these are bugs, they were out of the prototype's +scope): + +- Clipboard, drag-and-drop, IME, native menubar, message boxes. +- Off-screen surfaces: `create_surface` returns `asset_none`, so SURFACE assets and the whole SVG + filter graph are unreachable. +- Font registration (`create_font*`) returns false; family names resolve from `C:\Windows\Fonts` + by family + weight instead. +- No DPI scaling: logical px == LVGL px == physical px. +- Closing a frame hides its window rather than destroying it (the LVGL Windows driver's display + watchdog `exit(0)`s the process when the last display dies mid-loop). A production port runs its + own display driver, which an embedded target does anyway. +- Dialogs are resizable; overlay changes (combo drop, popup menu, toast) still invalidate the whole + frame. + +**Known cost to be aware of on 565 targets**: LVGL's software vector path renders ThorVG only into +ARGB8888/XRGB8888, so on RGB565 every vector task round-trips through a temporary full-framebuffer +ARGB buffer. The backend mitigates this by coalescing consecutive path draws into one +`lv_draw_vector` task; the residue is the reason VGLite-class hardware is the production answer for +path chrome at 565. Numbers for both depths are in the plan. + +**Test-automation note** (this bit tricks everyone): `WM_MOUSEWHEEL`'s `lParam` carries SCREEN +coordinates while the button messages carry CLIENT coordinates, and `PrintWindow` renders the whole +window (title bar included) into the target DC, so a client-sized bitmap is offset by the +non-client frame. Drive synthetic input from a widget's `create()` coordinates rather than from +measured screenshot pixels, and make the sending process Per-Monitor-V2 aware or Windows +DPI-virtualises the coordinates on the way in. diff --git a/docs/rendering-and-assets.md b/docs/rendering-and-assets.md index bb9691b..d7399dd 100644 --- a/docs/rendering-and-assets.md +++ b/docs/rendering-and-assets.md @@ -38,6 +38,34 @@ A **filter** is `NEUI_ASSET_KIND_FILTER` (`9`) - the SVG `` model: a mut **Convenience wrappers** (named recipes over the same engine, all on `neui_asset_api_t`): `surface_blur(sigma_x, sigma_y)`, `surface_drop_shadow(dx, dy, sigma, argb)`, `surface_inner_shadow(dx, dy, sigma, argb)`, `surface_glow(sigma, argb)` (zero-offset coloured drop shadow), `surface_tint(argb)` (colourise: replace RGB, keep coverage), `surface_desaturate(amount)` (saturate(1-amount)), `surface_elevation(level)` (Material ambient+key black shadow), `surface_bevel(dx, dy, sigma, light_argb, dark_argb)` (light + dark inner bands). Each builds a transient `FilterAsset` via a `filter_build_*` recipe (`filter_graph.h`) and runs `AssetStore::apply_filter_asset` - one code path with `apply_filter`. Inner-shadow/bevel use the `feComposite arithmetic` (k2=-1,k3=1) band trick + `feComposite "in"` clip. Distances are **logical px**; call **after** `paint_surface` (re-apply after each re-paint); no-op on non-SURFACE handles. Tier-1 tested in `tests/test_filter_graph.cpp` (engine + every recipe builder, host-independent, always runs) + `test_image_filter.cpp` (blur primitive); demoed by `neui_filter_knob_example` (builds the designer's drop-shadow `fe*` chain primitive-for-primitive and applies it to the baked knob face). +## Client resource provider (`d/resource.h`, `NEUI_API_RESOURCE_CLIENT`) + +An **optional client-side interface** (same shape as `_MENU_CLIENT` / `_THEME_CLIENT` / `_GRID_CLIENT`: fetched once via `client->get_interface(token, NEUI_API_RESOURCE_CLIENT)` in each host's `Session` constructor) that lets a client hand neui **resource bytes** for a name. Purpose: every media path in neui used to take a filesystem path, so a client keeping its assets in its own container - plugin bundle, executable resource section, encrypted pack, flash on an MCU with no filesystem at all - had no way in. Design + rationale: `plans/client-resource-provider.md`. + +**Kinds** (`neui_resource_kind_t`, reserve new values at the next unused integer): `IMAGE` (encoded PNG/JPG/BMP - `create_from_file`, `create_filmstrip_from_file`, an `NEUI_W_IMAGE` widget's `set_text`, component asset references), `FONT` (TTF/OTF/TTC - `create_font_from_file`), `COMPONENT` (component JSON - `create_component_from_file`), `SIDECAR` (the filmstrip layout JSON `create_filmstrip_from_file` discovers). `kinds_mask` on the interface (0 = all) keeps a client that only serves images off the font / component paths entirely. + +**Order is client FIRST, then the host's own resolution** - for a container-backed client the host's `@Nx` ladder is a guaranteed miss (up to three failed decodes on paths that will never exist), and on an embedded target there is no filesystem to fall back to. For images the effective order is **client -> embedded resource -> file** (the Win32 `RT "PNG"` lookup lives inside `load_image_bgra8_w32`). Bytes that fail to decode or parse count as a **miss** and resolution continues to the host's own lookup, so a buggy provider cannot shadow a good file. + +**Scale**: the client is asked **once per (name, scale band)**, never once per `@Nx` variant. The request carries `scale_hint` (the display scale being resolved for); the reply declares the HiDPI factor of what it actually returned in `neui_resource_bytes_t::scale` (same meaning as `create_bitmap`'s `scale`). The `@2x` / `@3x` filename convention stays a *filesystem* convention and does not leak into the interface - a client uses whatever naming its own container has. The `name` passed is always the client's original string, never host-rewritten. + +**Ownership**: `provide` hands back **borrowed** bytes plus an opaque `release_token`; the host copies or decodes and calls `release` before the triggering API call returns, so the blob need not outlive the call (point at a resource section, a mapping, or a fresh allocation). `release` may be NULL for static blobs. + +**Reentrancy**: `provide` runs on the UI thread inside the triggering call - and that is **sometimes a paint** (the IMAGE widget resolves lazily on first draw). It must not call back into neui and must return promptly; on the experimental LVGL host (`docs/host-lvgl.md`) a paint holds the global LVGL lock, so blocking there stalls the refresh and re-entering neui deadlocks. + +**Resolution cache** (`AssetStore::image_route`, `hosts/shared/asset_store.h`): `(name, scale bucket) -> ImageRoute {found, from_client, name, file_path, cache_key, scale, req_scale}`, **misses included**. This is a prerequisite, not an optimisation: the path-keyed tier behind `NEUI_W_IMAGE` resolves once per widget per frame, and `resolve_path` probes the `@Nx` ladder by fully *decoding* each candidate - so before this cache an IMAGE widget paid an image decode every frame just to answer "which variant?", and a client-first provider would have been called every frame too. Notes on the tier that are easy to get wrong: + +- **A cold load costs ONE `provide` and ONE decode.** Both probes have to decode to answer their question ("do these bytes work?" / "does this candidate exist?"), so the winning pixels are *parked* (`ProbePixels`) and handed to the load that wanted them. A decline costs one call, ever. +- **`cache_key` carries the scale band on a client route** (`client_cache_key`, `"\x01client\x01\x01"`) - a provider may answer one name with different pixels per band, and the derived path-keyed caches hold one `AssetEntry` per key, so a band-less key would serve the first band's bitmap at every other scale. A filesystem route keys on the resolved `@Nx` path, which already differs per variant. +- **`name` (what to re-ask the provider with) and `file_path` (the resolved `@Nx` variant) are separate fields**, and `req_scale` is the display scale the route was resolved *for* - so the fetch sends the provider the same `scale_hint` the probe did, and the entry records the scale the provider declares for the bytes it actually handed over. +- **Fallback survives the probe.** A route cached as `from_client` whose provider later declines (or hands over bytes that no longer decode) falls back to the `@Nx` ladder on that load rather than failing for the rest of the session. +- **Negative results are sticky** (v0), with three ways out: a scale in a *different band* resolves independently (narrower than "a DPI change re-resolves" - 125% and 200% are both band 1); an explicit `create_from_file` / `create_filmstrip_from_file` **re-probes** a cached miss, so a resource published late still loads, while the per-frame tier deliberately does not; `clear_image_routes()` and `clear()` drop them outright. + +**Plumbing**: `ResourceProvider` (`hosts/shared/resource_provider.h`) is the two-word binding each host installs via `set_resource_provider`, plus `with_bytes` (ask + release around a lambda) and `read_bytes` (ask-then-read-the-file, used by the FONT / COMPONENT / SIDECAR sites). Byte decoding needs a new platform seam, `platform_load_image_bytes` (xpl) and `Loader::load_memory` in each policy: `stbi_load_from_memory` (Linux + LVGL, `hosts/shared/image_loader_stb.h`), WIC over `SHCreateMemStream` (win32 - the resource branch of the path loader now routes through the same function), `CGImageSourceCreateWithData` (macOS / iOS), `nullptr` (null platform). Verify: `tests/test_resource_provider.cpp` (Tier-1: order, at-most-once probing, negative caching, scale reporting, `kinds_mask`, `release` pairing, undecodable-bytes fallthrough). Example: `examples/resource_client_example.cpp` → `neui_resource_client_example` (builds a BMP in memory at startup and serves it under `generated.bmp`, a name that exists nowhere on disk, drawn three ways: an IMAGE widget, a CUSTOMDRAW via an explicit asset handle, and a COMPONENT whose *document* is served from memory too - `widgets/meter.json`, whose `"assets"` block references the same image, so the run also demonstrates the raw-name + `base_dir` contract, printed in the window as `name="generated.bmp" base_dir="widgets"`). + +**Component-document assets** reach the provider as the **raw `assets`-map entry plus that document's `base_dir`**, never pre-joined - a client keying its container on `"knob_bg.png"` is not asked for `"res/knob_bg.png"`. The public asset API takes a path and so cannot express that, hence `ComponentApis::bitmap_from_name` (`hosts/shared/component_loader.h`): an optional byte hook every host installs (`component_bitmap_from_name` in each `widgets.cpp` / `.mm`) that calls `AssetStore::allocate_from_file(name, scale, base_dir)`. `base_dir` scopes the route-cache entry (two documents may use one name for different images), is handed to the provider as `neui_resource_request_t::base_dir`, and is joined onto the name only by the store's own filesystem fallback. With no hook installed (the Tier-1 fakes, any other embedder) the loader still does `create_from_file(join_path(base_dir, entry))` as before. `neui_component_env_t::resolve_asset` (handle-level, per-call) runs **before** the provider, unchanged. Verify: `tests/test_component_loader.cpp` ("the byte hook gets the raw name plus base_dir") + `tests/test_resource_provider.cpp` ("base_dir is passed alongside the name, not joined"). + +**v0 limitation**: only the IMAGE kind is cached. FONT / COMPONENT / SIDECAR go through `ResourceProvider::read_bytes` and re-ask on every load call (N component widgets built from one document = N calls; sidecar discovery asks for two candidate names), which is the same shape as re-reading the file. Documented in `d/resource.h` itself, since that is what clients read. + ## Font loading A **font** is `NEUI_ASSET_KIND_FONT` (`7`) - a client-registered font file (TTF / OTF / TTC) made resolvable for text rendering **without installing it system-wide** (primary use case: an audio plugin bundling a `.ttf` in its resources). The `neui_asset_t` handle owns only the *registration lifetime*; the font is referenced for drawing by its **family-name string** (`NEUI_ATTR_FONT_FAMILY` / `painter->push_font`), exactly like a system font - no handle-based addressing reaches the draw path. Design rationale: `docs/design-notes.md`. diff --git a/examples/lvgl_example.cpp b/examples/lvgl_example.cpp new file mode 100644 index 0000000..e652fbe --- /dev/null +++ b/examples/lvgl_example.cpp @@ -0,0 +1,275 @@ +// neui-on-LVGL prototype example + Milestone 3 measurement screen +// (plans/lvgl-host-approach-c.md). A knob-heavy audio panel - the profile +// that dominates embedded render cost: 8 painted rotary KNOBs with live +// value labels inside a SECTION, channel BUTTONs, a Bright CHECKBOX, a +// preset-name INPUTBOX and a description text block. +// +// Runs on the crossplatform host, which the NEUI_WITH_LVGL build pairs with +// the LVGL platform + backend. Console app: LVGL's perf monitor runs in +// LOG_MODE and prints "sysmon: N FPS ..." lines per second. +// +// Measurement hooks: +// - idle: just leave the window alone (no sysmon lines = 0 refresh) +// - knob drag: drag any knob (or drive synthetic WM_MOUSE* input) +// - full-screen: press 'S' (or click [Stress]) - every knob value animates +// every frame via WIDGET_PREUPDATE, invalidating the whole +// panel continuously until toggled off. + +#include "neui/neui.h" +#include +#include +#include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#endif + +static const char* k_knob_names[8] = { + "Gain", "Bass", "Middle", "Treble", "Presence", "Reverb", "Volume", "Master" +}; + +struct AppState { + neui_api_t* neui = nullptr; + neui_widget_api_t* widgets = nullptr; + neui_attr_api_t* attrs = nullptr; + neui_session_t session = { 0 }; + + uint32_t win_id = 0; + uint32_t knob_id[8] = {}; + uint32_t vlabel_id[8] = {}; + uint32_t stress_button_id = 0; + uint32_t chan_button_id[3] = {}; + uint32_t chan_label_id = 0; + + bool stress = false; + uint32_t stress_phase = 0; +}; + +static void set_knob_value_text(AppState* app, int i, float v) +{ + char buf[32]; + snprintf(buf, sizeof(buf), "%s %d%%", k_knob_names[i], (int)(v * 100.0f + 0.5f)); + app->widgets->set_text(app->session, { app->vlabel_id[i] }, buf); +} + +static bool NEUI_ABI onevent(void* token, neui_event_t* event) +{ + auto* app = static_cast(token); + if (!app) return false; + + switch (event->type) { + + case NEUI_EVENT_APP_QUIT: + return true; // allow close + + case NEUI_EVENT_VALUE_CHANGED: { + // A knob was dragged - mirror the value into its label (this is the + // per-widget invalidation path Milestone 2 bounds to two rects). + uint32_t wid = event->data.value.widget.id; + for (int i = 0; i < 8; ++i) { + if (wid == app->knob_id[i]) { + set_knob_value_text(app, i, event->data.value.value); + return true; + } + } + return false; + } + + case NEUI_EVENT_WIDGET_PREUPDATE: { + // Full-screen stress animation: advance every knob a little each + // frame. Gated on knob[0] so the phase advances once per paint pass. + // (PREUPDATE carries the raw tree slot - mask the session half.) + if (!app->stress || + (event->data.preupdate.widget.id & 0xFFFFu) != (app->knob_id[0] & 0xFFFFu)) + return false; + app->stress_phase++; + for (int i = 0; i < 8; ++i) { + float v = 0.5f + 0.45f * sinf((float)app->stress_phase * 0.05f + + (float)i * 0.7f); + app->attrs->set_float(app->session, { app->knob_id[i] }, + NEUI_PARAM_VALUE, v); + set_knob_value_text(app, i, v); + } + // Keep the repaint loop alive (the invalidation lands after this + // paint pass completes). + app->widgets->invalidate(app->session, { app->win_id }); + return true; + } + + case NEUI_EVENT_MOUSE_BUTTON_CLICK: { + uint32_t wid = event->data.mouse.widget.id; + if (wid == app->stress_button_id) { + app->stress = !app->stress; + app->widgets->set_text(app->session, { app->stress_button_id }, + app->stress ? "Stress: ON" : "Stress: OFF"); + if (app->stress) + app->widgets->invalidate(app->session, { app->win_id }); + return true; + } + for (int c = 0; c < 3; ++c) { + if (wid == app->chan_button_id[c]) { + static const char* names[3] = { "Channel: Clean", "Channel: Crunch", + "Channel: Lead" }; + app->widgets->set_text(app->session, { app->chan_label_id }, names[c]); + return true; + } + } + return false; + } + + case NEUI_EVENT_KEYDOWN: { + // 'S' toggles the stress animation from anywhere. + if (event->data.key.keycode == 'S') { + app->stress = !app->stress; + app->widgets->set_text(app->session, { app->stress_button_id }, + app->stress ? "Stress: ON" : "Stress: OFF"); + if (app->stress) + app->widgets->invalidate(app->session, { app->win_id }); + return true; + } + return false; + } + + default: + return false; + } +} + +static void NEUI_ABI ondestroy(void*, neui_widget_t, void*) {} + +static neui_widget_client_t widget_client = { NEUI_VERSION, ondestroy, onevent }; + +static void* NEUI_ABI get_interface(void*, const char* iface) +{ + if (iface && strcmp(iface, NEUI_API_WIDGETS) == 0) return &widget_client; + return nullptr; +} + +static neui_client_t host_client = { NEUI_VERSION, get_interface }; + +int main() +{ + // Unbuffered stdout so the perf monitor's sysmon lines arrive in real time + // when redirected to a file / pipe (measurement scripts read them live). + setvbuf(stdout, nullptr, _IONBF, 0); + + AppState app; + + neui_init(); + app.neui = neui_get_api("neui.host.crossplatform"); + if (!app.neui) { + fprintf(stderr, "crossplatform host not available\n"); + return 1; + } + + neui_session_t sess = app.neui->create_session(&host_client, &app); + app.session = sess; + app.widgets = (neui_widget_api_t*)app.neui->get_interface(sess, NEUI_API_WIDGETS); + app.attrs = (neui_attr_api_t*) app.neui->get_interface(sess, NEUI_API_ATTRS); + if (!app.widgets || !app.attrs) return 1; + + // 800x480 logical client - the embedded reference panel size. + auto win = app.widgets->create(sess, widget_none, NEUI_W_APPWINDOW, + 120, 120, 800, 480, nullptr); + app.win_id = win.id; + app.widgets->set_text(sess, win, "neui LVGL prototype - amp panel"); + + // --- Top band: title, channel buttons, bright checkbox, preset name ------ + auto title = app.widgets->create(sess, win, NEUI_W_LABEL, 12, 10, 240, 22, nullptr); + app.widgets->set_text(sess, title, "TubeAmp 800 - Edit"); + + static const char* chan_names[3] = { "Clean", "Crunch", "Lead" }; + for (int c = 0; c < 3; ++c) { + auto b = app.widgets->create(sess, win, NEUI_W_BUTTON, + 260 + c * 78, 8, 70, 26, nullptr); + app.widgets->set_text(sess, b, chan_names[c]); + app.chan_button_id[c] = b.id; + } + + auto bright = app.widgets->create(sess, win, NEUI_W_CHECKBOX, 508, 12, 80, 20, nullptr); + app.widgets->set_text(sess, bright, "Bright"); + + auto preset = app.widgets->create(sess, win, NEUI_W_INPUTBOX, 600, 8, 188, 26, nullptr); + app.widgets->set_text(sess, preset, "Lead Solo 4"); + + auto chan_label = app.widgets->create(sess, win, NEUI_W_LABEL, 12, 40, 240, 18, nullptr); + app.widgets->set_text(sess, chan_label, "Channel: Clean"); + app.chan_label_id = chan_label.id; + + auto stress = app.widgets->create(sess, win, NEUI_W_BUTTON, 688, 40, 100, 24, nullptr); + app.widgets->set_text(sess, stress, "Stress: OFF"); + app.stress_button_id = stress.id; + + // --- Amp section: 8 knobs in two rows, value label under each ------------ + auto section = app.widgets->create(sess, win, NEUI_W_SECTION, 10, 70, 780, 330, nullptr); + app.widgets->set_text(sess, section, "AMPLIFIER"); + + const int kw = 120, kh = 110; // knob bounds (incl. value-text strip) + const int cell_w = 780 / 4; + for (int i = 0; i < 8; ++i) { + const int col = i % 4, row = i / 4; + const int cx = col * cell_w + (cell_w - kw) / 2; + const int cy = 10 + row * 150; + + auto k = app.widgets->create(sess, section, NEUI_W_KNOB, cx, cy, kw, kh, nullptr); + app.knob_id[i] = k.id; + const float v0 = 0.25f + 0.07f * (float)i; + app.attrs->set_float(sess, k, NEUI_PARAM_DEFAULT, 0.5f); + app.attrs->set_float(sess, k, NEUI_PARAM_VALUE, v0); + + auto vl = app.widgets->create(sess, section, NEUI_W_LABEL, + cx + 10, cy + kh + 2, kw - 20, 16, nullptr); + app.vlabel_id[i] = vl.id; + char buf[32]; + snprintf(buf, sizeof(buf), "%s %d%%", k_knob_names[i], (int)(v0 * 100.0f + 0.5f)); + app.widgets->set_text(sess, vl, buf); + } + + // --- Bottom verification strip: IMAGE widget + font-size / family checks -- + // The image box is exactly the source's 4:3 aspect (myimage.png is 500x375), + // so the aspect-preserving fit must fill the box edge to edge - any + // letterboxing or stretching flags a draw_bitmap scaling bug. + auto image = app.widgets->create(sess, win, NEUI_W_IMAGE, 12, 402, 96, 72, nullptr); + { + char path[MAX_PATH] = "myimage.png"; +#ifdef _WIN32 + // Resolve next to the executable so the example works from any CWD. + char exe[MAX_PATH]; + DWORD n = GetModuleFileNameA(nullptr, exe, MAX_PATH); + if (n > 0 && n < MAX_PATH) { + char* slash = strrchr(exe, '\\'); + if (slash) { + snprintf(path, sizeof(path), "%.*s\\myimage.png", + (int)(slash - exe), exe); + } + } +#endif + app.widgets->set_text(sess, image, path); + } + + // Font-size checks: Segoe UI's cap height is 0.700 em, so the ink height of + // an all-caps string must be ~0.70x the requested NEUI_ATTR_FONT_SIZE + // (12 -> ~8.4 px, 16 -> ~11.2, 22 -> ~15.4, 32 -> ~22.4). The last label + // verifies family resolution (Consolas is visibly monospaced). + static const struct { int size; int x; int w; } k_font_rows[] = { + { 12, 150, 70 }, { 16, 230, 80 }, { 22, 320, 100 }, { 32, 430, 130 }, + }; + for (const auto& fr : k_font_rows) { + auto l = app.widgets->create(sess, win, NEUI_W_LABEL, + fr.x, 466 - (int)(fr.size * 1.35f), + fr.w, (int)(fr.size * 1.35f) + 4, nullptr); + char buf[24]; + snprintf(buf, sizeof(buf), "HHH %d", fr.size); + app.widgets->set_text(sess, l, buf); + app.attrs->set_float(sess, l, NEUI_ATTR_FONT_SIZE, (float)fr.size); + } + auto mono = app.widgets->create(sess, win, NEUI_W_LABEL, 580, 444, 200, 24, nullptr); + app.widgets->set_text(sess, mono, "Consolas iiiWWW"); + app.attrs->set_float(sess, mono, NEUI_ATTR_FONT_SIZE, 16.0f); + app.attrs->set_string(sess, mono, NEUI_ATTR_FONT_FAMILY, "Consolas"); + + app.widgets->show(sess, win); + bool ok = app.neui->run(sess); + app.neui->endsession(sess); + return ok ? 0 : 1; +} diff --git a/examples/resource_client_example.cpp b/examples/resource_client_example.cpp new file mode 100644 index 0000000..3a52722 --- /dev/null +++ b/examples/resource_client_example.cpp @@ -0,0 +1,319 @@ +// Client resource provider example (NEUI_API_RESOURCE_CLIENT). +// +// Demonstrates a client that keeps its assets somewhere neui knows nothing +// about. Nothing here touches the filesystem: the image is BUILT IN MEMORY at +// startup and handed to the host on demand, under a name that does not exist as +// a file anywhere. The same seam serves a plugin bundle, an executable resource +// section, an encrypted pack, or flash on an MCU. +// +// Three widgets reference the generated asset by name: +// * an IMAGE widget via set_text("generated.bmp") - the framework loads it +// lazily on first paint, through the provider; +// * an explicit assets->create_from_file("generated.bmp") handle, drawn by a +// CUSTOMDRAW widget, showing the same name resolving via the asset API; +// * a COMPONENT built from a document that is ALSO served from memory, whose +// "assets" block references the same image. This is the interesting case for +// the name contract: the document lives at "widgets/meter.json", so its +// base_dir is "widgets", and the provider is asked for the raw entry +// ("generated.bmp") with base_dir passed ALONGSIDE - never joined into +// "widgets/generated.bmp", which this client would decline. The status lines +// print the exact name and base_dir the provider saw, so the contract is +// visible rather than implied. +// +// A BMP is generated rather than a PNG so the example stays self-contained with +// no encoder: the header is 54 bytes and every platform decoder reads it. + +#include + +#include +#include +#include +#include + +namespace { + +// --- The client's "container": a BGR bottom-up BMP built at startup --------- + +std::vector g_bmp; // the whole file image, header included +int g_provide_calls = 0; +int g_release_calls = 0; +float g_last_hint = 0.0f; + +void put_u32(std::vector& v, uint32_t x) +{ + v.push_back((uint8_t)(x & 0xFF)); + v.push_back((uint8_t)((x >> 8) & 0xFF)); + v.push_back((uint8_t)((x >> 16) & 0xFF)); + v.push_back((uint8_t)((x >> 24) & 0xFF)); +} +void put_u16(std::vector& v, uint16_t x) +{ + v.push_back((uint8_t)(x & 0xFF)); + v.push_back((uint8_t)((x >> 8) & 0xFF)); +} + +// 24-bit BMP: a teal-to-magenta gradient with a 2px dark border, so it is +// obvious at a glance that the pixels came from here and not from a file. +void build_bmp(int w, int h) +{ + const int row_raw = w * 3; + const int pad = (4 - (row_raw % 4)) % 4; + const int row = row_raw + pad; + const uint32_t pixels_size = (uint32_t)(row * h); + const uint32_t offset = 14 + 40; + + g_bmp.clear(); + g_bmp.reserve(offset + pixels_size); + + // BITMAPFILEHEADER + g_bmp.push_back('B'); g_bmp.push_back('M'); + put_u32(g_bmp, offset + pixels_size); + put_u16(g_bmp, 0); put_u16(g_bmp, 0); + put_u32(g_bmp, offset); + // BITMAPINFOHEADER + put_u32(g_bmp, 40); + put_u32(g_bmp, (uint32_t)w); + put_u32(g_bmp, (uint32_t)h); // positive = bottom-up + put_u16(g_bmp, 1); // planes + put_u16(g_bmp, 24); // bpp + put_u32(g_bmp, 0); // BI_RGB + put_u32(g_bmp, pixels_size); + put_u32(g_bmp, 2835); put_u32(g_bmp, 2835); // ~72 dpi + put_u32(g_bmp, 0); put_u32(g_bmp, 0); + + for (int y = h - 1; y >= 0; --y) { // bottom-up rows + for (int x = 0; x < w; ++x) { + const bool border = (x < 2 || y < 2 || x >= w - 2 || y >= h - 2); + uint8_t r, g, b; + if (border) { r = 32; g = 32; b = 40; } + else { + const float u = (float)x / (float)(w - 1); + const float v = (float)y / (float)(h - 1); + r = (uint8_t)(40.0f + 200.0f * u); + g = (uint8_t)(190.0f - 120.0f * v); + b = (uint8_t)(150.0f + 90.0f * v); + } + g_bmp.push_back(b); g_bmp.push_back(g); g_bmp.push_back(r); // BGR + } + for (int p = 0; p < pad; ++p) g_bmp.push_back(0); + } +} + +// --- The client's second "file": a component document, also in memory -------- +// +// Referenced by the path below, which is what gives it a base_dir of "widgets". +// Its "assets" entry is the RAW image name this client knows - the host must ask +// for exactly that, with "widgets" in base_dir. + +const char* k_asset_name = "generated.bmp"; +const char* k_component_name = "widgets/meter.json"; + +const char* k_component_json = R"json({ + "component": "meter", + "size": [104, 104], + "params": [ + { "key": "neui.param.value", "default": 0.7, "min": 0, "max": 1, "label": "Level" } + ], + "assets": { "face": "generated.bmp" }, + "layers": [ + { "kind": "asset", "z": 0, "anchor": ["top", "top"], "size": [96, 80], + "offset": [0, 4], "asset": "face" }, + { "kind": "rect", "z": 1, "anchor": ["center", "center"], "size": [104, 104], + "stroke_color": "#FF202024", "stroke_width": 2 }, + { "kind": "text", "z": 2, "anchor": ["bottom", "bottom"], "size": ["fill", 18], + "text": "from client bytes", "font_size": 11, "color": "#FF202024", + "align": ["center", "center"] } + ] +})json"; + +// What the provider was asked for on behalf of the component's asset - printed +// in the UI, because "was it joined onto base_dir?" is the whole point. +std::string g_comp_asset_name; +std::string g_comp_asset_dir; + +// --- The provider itself --------------------------------------------------- +// +// Serves exactly two names, one image and one component document. Everything +// else is declined, which lets the host fall back to its normal filesystem / +// embedded-resource resolution. + +bool NEUI_ABI res_provide(void* /*token*/, const neui_resource_request_t* req, + neui_resource_bytes_t* out) +{ + ++g_provide_calls; + if (!req->name) return false; + + if (req->kind == NEUI_RESOURCE_KIND_COMPONENT) { + if (strcmp(req->name, k_component_name) != 0) return false; + out->data = (const uint8_t*)k_component_json; + out->len = (uint32_t)strlen(k_component_json); + out->release_token = nullptr; + return true; + } + + if (req->kind != NEUI_RESOURCE_KIND_IMAGE) return false; + if (strcmp(req->name, k_asset_name) != 0) return false; + + // base_dir is set only for a name that came out of a component document. A + // client with per-document asset tables would key on it; here it is recorded + // so the UI can show that the name arrived raw and the directory separately. + if (req->base_dir) { + g_comp_asset_name = req->name; + g_comp_asset_dir = req->base_dir; + } + + // scale_hint is what the host would resolve @2x / @3x for. We only have the + // one resolution, so report it as 1.0 via `scale` and ignore the hint - a real + // client would pick its closest variant here. + g_last_hint = req->scale_hint; + + out->data = g_bmp.data(); // borrowed - only for this call + out->len = (uint32_t)g_bmp.size(); + out->scale = 1.0f; + out->release_token = nullptr; // nothing to free: it is a member buffer + return true; +} + +void NEUI_ABI res_release(void* /*token*/, const neui_resource_bytes_t* /*res*/) +{ + ++g_release_calls; // no-op for a static buffer; counted to show the pairing +} + +neui_resource_client_t resource_client = { + NEUI_VERSION, + // Images + component documents; the mask keeps this client off the font path + // and off the filmstrip-sidecar path entirely. + NEUI_RESOURCE_MASK_IMAGE | NEUI_RESOURCE_MASK_COMPONENT, + res_provide, + res_release, +}; + +// --- App ------------------------------------------------------------------- + +struct App { + neui_api_t* neui = nullptr; + neui_session_t sess = {}; + neui_widget_api_t* widgets = nullptr; + neui_asset_api_t* assets = nullptr; + neui_asset_t handle = asset_none; + uint32_t canvas_id = 0; + uint32_t status_id = 0; +}; +App app; + +bool NEUI_ABI onevent(void* /*token*/, neui_event_t* event) +{ + if (event->type == NEUI_EVENT_WIDGET_PAINT && + event->data.paint.widget.id == app.canvas_id) { + auto* p = event->data.paint.p; + auto* api = event->data.paint.painter_api; + const float w = event->data.paint.width; + const float h = event->data.paint.height; + api->fill_rect(p, 0, 0, w, h, 0xFF202024u); + if (app.handle.id != asset_none.id) + api->draw_asset(p, app.handle, 4, 4, w - 8, h - 8); + return true; + } + return false; +} + +neui_widget_client_t widget_client = { NEUI_VERSION, nullptr, onevent }; + +neui_client_t host_client = { + NEUI_VERSION, + [](void* /*token*/, const char* iface) -> void* { + if (!strcmp(iface, NEUI_API_WIDGETS)) return &widget_client; + // This is the whole opt-in: return the interface and the host starts asking + // for bytes before it looks anywhere itself. + if (!strcmp(iface, NEUI_API_RESOURCE_CLIENT)) return &resource_client; + return nullptr; + } +}; + +} // namespace + +int main() +{ + build_bmp(96, 96); + + neui_init(); + // Pinned to the crossplatform host (as examples/main.cpp does): its IMAGE + // widget resolves lazily from the path-keyed tier during PAINT, which is the + // more interesting provider path, and in an LVGL build this is the LVGL host. + app.neui = neui_get_api("neui.host.crossplatform"); + if (!app.neui) return 1; + app.sess = app.neui->create_session(&host_client, &app); + app.widgets = (neui_widget_api_t*)app.neui->get_interface(app.sess, NEUI_API_WIDGETS); + app.assets = (neui_asset_api_t*) app.neui->get_interface(app.sess, NEUI_API_ASSETS); + if (!app.widgets) return 1; + + // Content is 3 columns of ~104px art at y=40..144 (widest right edge 300+104), + // labels at y=148, and 3 status lines 616 wide ending at y=240; 640x272 holds + // that with a margin on all sides. + auto win = app.widgets->create(app.sess, widget_none, NEUI_W_APPWINDOW, + 120, 120, 640, 272, nullptr); + app.widgets->set_text(app.sess, win, "neui - client resource provider"); + + auto title = app.widgets->create(app.sess, win, NEUI_W_LABEL, 12, 10, 616, 20, nullptr); + app.widgets->set_text(app.sess, title, + "Everything below comes from client memory, not a file:"); + + // 1. IMAGE widget by name. The framework resolves this lazily on first paint, + // which is the path that goes through the provider. + auto img = app.widgets->create(app.sess, win, NEUI_W_IMAGE, 12, 40, 96, 96, nullptr); + app.widgets->set_text(app.sess, img, k_asset_name); + auto l1 = app.widgets->create(app.sess, win, NEUI_W_LABEL, 12, 148, 132, 18, nullptr); + app.widgets->set_text(app.sess, l1, "IMAGE (set_text)"); + + // 2. Explicit asset handle by the same name, drawn by a CUSTOMDRAW. + if (app.assets) + app.handle = app.assets->create_from_file(app.sess, k_asset_name); + auto canvas = app.widgets->create(app.sess, win, NEUI_W_CUSTOMDRAW, + 156, 40, 104, 104, nullptr); + app.canvas_id = canvas.id; + auto l2 = app.widgets->create(app.sess, win, NEUI_W_LABEL, 156, 148, 132, 18, nullptr); + app.widgets->set_text(app.sess, l2, "CUSTOMDRAW handle"); + + // 3. A COMPONENT whose document AND whose layer asset both come from the + // provider. The document path gives it base_dir "widgets"; the asset inside + // it is named "generated.bmp" and must arrive that way, or this client + // declines and the face is blank. + if (app.assets) { + neui_asset_t comp = app.assets->create_component_from_file(app.sess, + k_component_name, nullptr); + if (comp.id != asset_none.id) + app.widgets->create_from_component(app.sess, win, comp, 300, 40, 104, 104); + } + auto l3 = app.widgets->create(app.sess, win, NEUI_W_LABEL, 300, 148, 160, 18, nullptr); + app.widgets->set_text(app.sess, l3, "COMPONENT doc + asset"); + + // Counts as of the loads above. The IMAGE widget resolves lazily on its first + // paint, so its own provide() calls land after this. + auto status = app.widgets->create(app.sess, win, NEUI_W_LABEL, 12, 172, 616, 20, nullptr); + app.status_id = status.id; + char buf[192]; + std::snprintf(buf, sizeof(buf), + "%zu BMP bytes in memory; %d provide() / %d release() so far, " + "scale hint %.1f", + g_bmp.size(), g_provide_calls, g_release_calls, g_last_hint); + app.widgets->set_text(app.sess, status, buf); + + // The name contract, printed rather than asserted: raw entry + separate dir. + auto prov = app.widgets->create(app.sess, win, NEUI_W_LABEL, 12, 196, 616, 20, nullptr); + char buf2[256]; + std::snprintf(buf2, sizeof(buf2), + "component doc \"%s\" -> its asset asked as name=\"%s\" base_dir=\"%s\"", + k_component_name, + g_comp_asset_name.empty() ? "(never asked)" : g_comp_asset_name.c_str(), + g_comp_asset_dir.empty() ? "(none)" : g_comp_asset_dir.c_str()); + app.widgets->set_text(app.sess, prov, buf2); + + auto note = app.widgets->create(app.sess, win, NEUI_W_LABEL, 12, 220, 616, 20, nullptr); + app.widgets->set_text(app.sess, note, + "Neither generated.bmp nor widgets/meter.json exists on disk."); + + app.widgets->show(app.sess, win); + app.neui->run(app.sess); + app.neui->endsession(app.sess); + return 0; +} diff --git a/hosts/crossplatform/CMakeLists.txt b/hosts/crossplatform/CMakeLists.txt index a58f3ce..dc464de 100644 --- a/hosts/crossplatform/CMakeLists.txt +++ b/hosts/crossplatform/CMakeLists.txt @@ -14,7 +14,19 @@ target_include_directories(neui-xplhost target_link_libraries(neui-xplhost PRIVATE neui neui-qrcodegen) -if(WIN32) +if(WIN32 AND NEUI_WITH_LVGL) + # LVGL host prototype (plans/lvgl-host-approach-c.md): the xpl host is + # built with the LVGL platform layer + backend instead of the native + # Win32/D2D pairing. The registered host id stays neui.host.crossplatform; + # backend + platform are paired per build, exactly like Linux. + target_sources(neui-xplhost PRIVATE platform_lvgl.cpp) + target_compile_definitions(neui-xplhost PRIVATE NEUI_PLATFORM_LVGL=1) + target_include_directories(neui-xplhost SYSTEM PRIVATE + ${PROJECT_SOURCE_DIR}/third_party/stb) + # windowsapp.lib as in the native-Win32 branch below: host.cpp compiles the + # C++/WinRT theme provider on every _WIN32 build, LVGL or not. + target_link_libraries(neui-xplhost PRIVATE neui-backend-lvgl windowsapp) +elseif(WIN32) target_sources(neui-xplhost PRIVATE platform_win32.cpp) # windowsapp.lib supplies RoGetActivationFactory / RoOriginateLanguageException # required by the C++/WinRT projection used in theme_provider_win32.h. diff --git a/hosts/crossplatform/asset_manager.cpp b/hosts/crossplatform/asset_manager.cpp index 1c26388..3642d0e 100644 --- a/hosts/crossplatform/asset_manager.cpp +++ b/hosts/crossplatform/asset_manager.cpp @@ -8,16 +8,19 @@ namespace neui_detail { - bool AssetManager::load_pixels(const std::string& path, AssetEntry& entry) + // Decode the source `route` designates (a resolved @Nx file, or bytes from the + // client resource provider) into `entry`. entry.scale comes from the decode, + // not from the route: on a client route the provider declares the HiDPI factor + // of the bytes it just handed over, and drawing depends on those matching. + bool AssetManager::load_pixels(const ImageRoute& route, AssetEntry& entry) { uint32_t w = 0, h = 0; - uint8_t* raw = XplImageLoader::load(path.c_str(), &w, &h); - if (!raw) return false; + float scale = route.scale; + if (!decode_route(route, entry.pixels, &w, &h, &scale)) return false; entry.width_px = w; entry.height_px = h; - entry.pixels.assign(raw, raw + static_cast(w) * h * 4); - XplImageLoader::free_pixels(raw); + entry.scale = scale; return true; } @@ -26,16 +29,18 @@ namespace neui_detail { if (name.empty() || !backend || !ctx || !backend->create_bitmap) return nullptr; - // Find or resolve and load the entry. - std::string resolved = resolve_path(name, scale); - if (resolved.empty()) return nullptr; + // Find or resolve and load the entry. image_route is CACHED (misses too), + // which matters here above all: this runs once per IMAGE widget per frame, + // and resolution used to probe the @Nx ladder by decoding each candidate - + // a full image decode every frame purely to answer "which variant?". + const ImageRoute& route = image_route(name, scale); + if (!route.found) return nullptr; - auto it = _cache.find(resolved); + auto it = _cache.find(route.cache_key); if (it == _cache.end()) { AssetEntry entry; - entry.scale = scale_of_resolved(name, resolved); - if (!load_pixels(resolved, entry)) return nullptr; - it = _cache.emplace(resolved, std::move(entry)).first; + if (!load_pixels(route, entry)) return nullptr; + it = _cache.emplace(route.cache_key, std::move(entry)).first; } AssetEntry& e = it->second; @@ -68,15 +73,14 @@ namespace neui_detail float* width_out, float* height_out) { if (name.empty()) return false; - std::string resolved = resolve_path(name, scale); - if (resolved.empty()) return false; + const ImageRoute& route = image_route(name, scale); + if (!route.found) return false; - auto it = _cache.find(resolved); + auto it = _cache.find(route.cache_key); if (it == _cache.end()) { AssetEntry entry; - entry.scale = scale_of_resolved(name, resolved); - if (!load_pixels(resolved, entry)) return false; - it = _cache.emplace(resolved, std::move(entry)).first; + if (!load_pixels(route, entry)) return false; + it = _cache.emplace(route.cache_key, std::move(entry)).first; } const AssetEntry& e = it->second; diff --git a/hosts/crossplatform/asset_manager.h b/hosts/crossplatform/asset_manager.h index 90e3da4..5a6e57e 100644 --- a/hosts/crossplatform/asset_manager.h +++ b/hosts/crossplatform/asset_manager.h @@ -1,4 +1,5 @@ #pragma once +#include // size_t for the platform_load_image_bytes redeclaration #include #include @@ -11,6 +12,8 @@ namespace xpl_host // loader policy below doesn't need the full platform.h. uint8_t* platform_load_image(const char* path, uint32_t* width_out, uint32_t* height_out); + uint8_t* platform_load_image_bytes(const uint8_t* data, size_t len, + uint32_t* width_out, uint32_t* height_out); void platform_free_image(uint8_t* pixels); } @@ -22,6 +25,9 @@ namespace neui_detail { static uint8_t* load(const char* path, uint32_t* w_px, uint32_t* h_px) { return xpl_host::platform_load_image(path, w_px, h_px); } + static uint8_t* load_memory(const uint8_t* data, size_t len, + uint32_t* w_px, uint32_t* h_px) + { return xpl_host::platform_load_image_bytes(data, len, w_px, h_px); } static void free_pixels(uint8_t* p) { xpl_host::platform_free_image(p); } }; @@ -54,13 +60,15 @@ namespace neui_detail void clear(neui_render_backend_t* backend); private: - // Cache keyed by resolved file path so different scale lookups that - // resolve to the same file share a single AssetEntry. + // Cache keyed by ImageRoute::cache_key - the resolved @Nx file path, or a + // per-scale-band synthetic key for client-provided bytes - so different + // scale lookups that resolve to the same source share a single AssetEntry, + // and ones that resolve to DIFFERENT pixels never collide. std::unordered_map _cache; - // Attempts to load pixels from path. Returns true and populates entry - // on success. - static bool load_pixels(const std::string& path, AssetEntry& entry); + // Decodes the source `route` designates into `entry`. Returns true on + // success. + bool load_pixels(const ImageRoute& route, AssetEntry& entry); }; } // namespace neui_detail diff --git a/hosts/crossplatform/host.cpp b/hosts/crossplatform/host.cpp index dfa3f58..c240723 100644 --- a/hosts/crossplatform/host.cpp +++ b/hosts/crossplatform/host.cpp @@ -234,6 +234,20 @@ namespace xpl_host _grid_client = static_cast( _client->get_interface(token, NEUI_API_GRID_CLIENT)); + // Opt-in client resource provider. Consulted BEFORE the host's own + // filesystem / embedded-resource lookup for images, fonts, component + // documents and filmstrip sidecars, so a client can keep its assets in its + // own container. The asset store owns the binding (it needs it on the image + // path); the font / component / sidecar sites read it back from there. + _resource_client = static_cast( + _client->get_interface(token, NEUI_API_RESOURCE_CLIENT)); + if (_resource_client) { + neui_detail::ResourceProvider provider; + provider.client = _resource_client; + provider.token = token; + _asset_manager.set_resource_provider(provider); + } + // System-theme tracking. xpl host always follows the system theme; // the listener invalidates frames so paint pulls the current palette. // macOS provider is set up earlier in platform_init (which runs once at @@ -784,6 +798,12 @@ namespace xpl_host void Session::set_focus(uint32_t new_idx) { if (new_idx == _focused_widget) return; + // Captured BEFORE the assignment below - both invalidate paths need the + // widget that is LOSING focus (set_hovered / set_pressed keep old_idx for + // the same reason). Reading _focused_widget after the store makes clearing + // focus (new_idx == 0) invalidate nothing, leaving a stale caret / focus + // ring on screen until an unrelated repaint. + const uint32_t prev_focus = _focused_widget; // If the open combo is losing focus, close its overlay. if (_open_combo != 0 && _open_combo == _focused_widget && new_idx != _focused_widget) @@ -818,9 +838,16 @@ namespace xpl_host ensure_widget_visible(new_idx); } - uint32_t ref = (new_idx != 0) ? new_idx : _focused_widget; +#ifdef NEUI_PLATFORM_LVGL + // Focus decorations are per-widget: repaint the losing + gaining widgets + // only (whole-frame fallback happens inside the hook when needed). + platform_retained_widget_invalidate(this, new_idx); + platform_retained_widget_invalidate(this, prev_focus); +#else + uint32_t ref = (new_idx != 0) ? new_idx : prev_focus; void* frame = find_parent_native_handle(ref); if (frame) platform_invalidate(frame); +#endif } void Session::set_hovered(uint32_t new_idx) @@ -854,11 +881,16 @@ namespace xpl_host // Repaint the frame so widgets whose paint reacts to .hovered (BUTTON, ...) // swap visuals. Hover transitions happen at human pointer speed - cheap. +#ifdef NEUI_PLATFORM_LVGL + platform_retained_widget_invalidate(this, old_idx); + platform_retained_widget_invalidate(this, new_idx); +#else uint32_t ref = (new_idx != 0) ? new_idx : old_idx; if (ref != 0) { if (void* frame = find_parent_native_handle(ref)) platform_invalidate(frame); } +#endif } void Session::set_pressed(uint32_t new_idx) @@ -876,11 +908,16 @@ namespace xpl_host // Same rationale as set_hovered: frame repaint so .pressed-aware widgets // (BUTTON) flip to their pressed visual immediately. +#ifdef NEUI_PLATFORM_LVGL + platform_retained_widget_invalidate(this, old_idx); + platform_retained_widget_invalidate(this, new_idx); +#else uint32_t ref = (new_idx != 0) ? new_idx : old_idx; if (ref != 0) { if (void* frame = find_parent_native_handle(ref)) platform_invalidate(frame); } +#endif } void Session::on_dpi_changed(uint32_t widget_index, uint32_t new_dpi) @@ -1059,6 +1096,10 @@ namespace xpl_host st.scroll_y == st.last_notified_y) return; st.last_notified_x = st.scroll_x; st.last_notified_y = st.scroll_y; +#ifdef NEUI_PLATFORM_LVGL + // Children move with the scroll offset - re-sync the mirror tree. + platform_retained_tree_changed(session, index); +#endif neui_event_t ev{}; ev.type = NEUI_EVENT_SCROLL_CHANGED; ev.data.scroll.widget.id = widget_id; @@ -1285,6 +1326,9 @@ namespace xpl_host pw.height = pw_h; pw.visible = (i == selected); } +#ifdef NEUI_PLATFORM_LVGL + if (session) platform_retained_tree_changed(session, index); +#endif } void TabViewWidget::select_tab(int ni) @@ -1468,8 +1512,12 @@ namespace xpl_host void WidgetData::repaint() { if (session) { +#ifdef NEUI_PLATFORM_LVGL + platform_retained_widget_invalidate(session, index); +#else void* frame = session->find_parent_native_handle(index); if (frame) platform_invalidate(frame); +#endif } } @@ -2279,6 +2327,103 @@ namespace xpl_host neui_detail::set_active_palette_override(prev_override); } +#ifdef NEUI_PLATFORM_LVGL + // ------------------------------------------------------------------------- + // Retained-mode paint entries (LVGL Option C prototype). One widget per + // call, dispatched from the widget mirror's LVGL draw events; the palette / + // focus / PREUPDATE / disabled-dim environment matches paint_frame's walk. + + // The frame ancestor that owns widget_index's window (0 = none found). + static uint32_t owning_frame_index(Session* s, uint32_t widget_index) + { + uint32_t idx = widget_index; + while (idx != 0 && s->_widgets.exists(idx)) { + if (s->_widgets[idx].native_handle) return idx; + idx = s->_widgets.get_parent(idx); + } + return 0; + } + + // RAII palette bracket matching paint_frame's selection for the frame. + struct RetainedPaletteBracket { + const neui_detail::Palette* prev; + RetainedPaletteBracket(Session* s, uint32_t frame_index) + : prev(neui_detail::active_palette_override_ptr()) + { + bool follow = false; + if (frame_index != 0 && s->_widgets.exists(frame_index)) { + auto& fw = s->_widgets[frame_index]; + if (fw.attrs && fw.attrs->get_int(NEUI_ATTR_FOLLOW_SYSTEM_THEME, 0) != 0) + follow = true; + } + neui_detail::set_active_palette_override( + follow ? &s->_effective_palette : &s->_frozen_palette); + } + ~RetainedPaletteBracket() { neui_detail::set_active_palette_override(prev); } + }; + + void Session::paint_widget_retained(neui_render_ctx_t ctx, + uint32_t widget_index, + bool after_children) + { + if (!_backend || !ctx) return; + if (widget_index == 0 || !_widgets.exists(widget_index)) return; + auto& wd = _widgets[widget_index]; + if (wd.native_handle || wd.is_menubar() || !wd.visible || + wd.width <= 0 || wd.height <= 0) + return; + + RetainedPaletteBracket palette(this, owning_frame_index(this, widget_index)); + const uint32_t focus_for_paint = _os_focused ? _focused_widget : 0; + + if (!after_children) { + if (wd.emit_events) { + neui_event_t pre{}; + pre.type = NEUI_EVENT_WIDGET_PREUPDATE; + pre.data.preupdate.widget.id = widget_index; + dispatch_event(&pre); + } + bool dim = !wd.enabled && _backend->push_alpha && _backend->pop_alpha; + if (dim) _backend->push_alpha(ctx, 0.5f); + wd.paint(_backend, ctx, widget_index == focus_for_paint); + if (dim) _backend->pop_alpha(ctx); + } else { + wd.paint_after_children(_backend, ctx, widget_index == focus_for_paint); + } + } + + void Session::paint_frame_background_retained(neui_render_ctx_t ctx, + uint32_t frame_index) + { + if (!_backend || !ctx) return; + WidgetData* fw = get_widget(frame_index); + if (!fw) return; + const uint32_t clear = frame_clear_color(frame_index); + _backend->fill_rect(ctx, 0.0f, 0.0f, + static_cast(fw->width), + static_cast(fw->height), + clear | 0xFF000000u); + } + + void Session::paint_overlays_retained(neui_render_ctx_t ctx, + uint32_t frame_index) + { + if (!_backend || !ctx) return; + RetainedPaletteBracket palette(this, frame_index); + if (_open_combo != 0 && _widgets.exists(_open_combo)) { + auto parents = _widgets.get_all_parents(_open_combo); + bool in_frame = false; + for (uint32_t p : parents) if (p == frame_index) { in_frame = true; break; } + if (in_frame) { + auto* cb = dynamic_cast(&_widgets[_open_combo]); + if (cb) cb->paint_overlay(_backend, ctx); + } + } + paint_popup_menu(ctx); + paint_toast(ctx, frame_index); + } +#endif // NEUI_PLATFORM_LVGL + // ------------------------------------------------------------------------- // Tab stop / focus cycling diff --git a/hosts/crossplatform/host.h b/hosts/crossplatform/host.h index bd5d750..a2faa6b 100644 --- a/hosts/crossplatform/host.h +++ b/hosts/crossplatform/host.h @@ -862,6 +862,26 @@ namespace xpl_host // Called by the platform layer when WM_PAINT / equivalent fires. void paint_frame(neui_render_ctx_t ctx, uint32_t parent_index); +#ifdef NEUI_PLATFORM_LVGL + // Retained-mode paint entries (LVGL Option C prototype). The LVGL + // platform dispatches one widget at a time from per-object draw events; + // these wrap the widget paint in the same palette-override bracket / + // focus gating / PREUPDATE / disabled-dim mechanics paint_frame applies + // during its whole-tree walk, so widget paint code sees an identical + // environment on both paths. + // - after_children=false: PREUPDATE + wd.paint (parent-local coords) + // - after_children=true: wd.paint_after_children (widget-local coords) + void paint_widget_retained(neui_render_ctx_t ctx, uint32_t widget_index, + bool after_children); + // The begin_frame(clear) substitute: fill the frame rect with the + // frame's effective background colour. + void paint_frame_background_retained(neui_render_ctx_t ctx, + uint32_t frame_index); + // Overlay pass drawn above every widget mirror (combo drop, popup menu, + // toast), palette-bracketed like paint_frame's overlay tail. + void paint_overlays_retained(neui_render_ctx_t ctx, uint32_t frame_index); +#endif + // The 0xAARRGGBB colour paint_frame clears the frame to (per-frame // NEUI_ATTR_BACKGROUND override, else the theme frame_bg under the frame's // effective palette). Self-contained: applies and restores the palette @@ -1170,6 +1190,12 @@ namespace xpl_host // commits an in-place cell edit (ENTER inside the editor). neui_grid_client_t* _grid_client = nullptr; + // Optional client resource provider (NEUI_API_RESOURCE_CLIENT). Asked for + // bytes before the host tries the filesystem / embedded resources. Kept + // here for symmetry with the other opt-in client interfaces; the live + // binding used by the load paths is _asset_manager.resource_provider(). + neui_resource_client_t* _resource_client = nullptr; + // System-theme listener handle. The xpl host always tracks the system // theme; on_theme_changed invalidates every frame so paint pulls the // updated palette. diff --git a/hosts/crossplatform/platform.h b/hosts/crossplatform/platform.h index 230a69a..b46bca7 100644 --- a/hosts/crossplatform/platform.h +++ b/hosts/crossplatform/platform.h @@ -1,4 +1,7 @@ #pragma once +#include // size_t - is NOT required to declare ::size_t + // (libstdc++ does not; MSVC / libc++ happen to, which is why + // platform_load_image_bytes below only broke on the GCC job) #include #include @@ -144,6 +147,13 @@ namespace xpl_host // physical pixel dimensions of the decoded image. uint8_t* platform_load_image(const char* path, uint32_t* width_out, uint32_t* height_out); + + // Same, for encoded image bytes already in memory. Used by the client + // resource provider path (NEUI_API_RESOURCE_CLIENT), which hands over bytes + // rather than a path - a client may keep its assets in a container, or on a + // target with no filesystem at all. Release with platform_free_image. + uint8_t* platform_load_image_bytes(const uint8_t* data, size_t len, + uint32_t* width_out, uint32_t* height_out); void platform_free_image(uint8_t* pixels); // Run the platform message loop. @@ -386,4 +396,24 @@ namespace xpl_host void platform_set_cursor(int kind /* CursorKind */); +#ifdef NEUI_PLATFORM_LVGL + // ------------------------------------------------------------------------- + // Retained-mode seams (LVGL Option C prototype - plans/lvgl-host-approach-c.md). + // Only compiled on the LVGL platform; the shared host calls them from + // #ifdef NEUI_PLATFORM_LVGL blocks so every other platform stays + // byte-for-byte unchanged. + + // A single widget's pixels changed (text, attr, value, hover/press/focus + // state). The platform invalidates that widget's mirror lv_obj only - + // falling back to a whole-frame invalidate when no mirror exists yet or + // retained mode is disabled at runtime (NEUI_LVGL_RETAINED=0). + void platform_retained_widget_invalidate(Session* session, uint32_t widget_index); + + // Structure or geometry changed under this widget's frame (create / + // destroy / show / hide / set_pos / set_size / tab-page reflow / section + // scroll). The platform re-syncs the mirror tree (positions, sizes, + // visibility, abs coords) before the next LVGL refresh. + void platform_retained_tree_changed(Session* session, uint32_t widget_index); +#endif + } // namespace xpl_host diff --git a/hosts/crossplatform/platform_ios.mm b/hosts/crossplatform/platform_ios.mm index 1b80cda..99acb6a 100644 --- a/hosts/crossplatform/platform_ios.mm +++ b/hosts/crossplatform/platform_ios.mm @@ -2336,6 +2336,13 @@ void platform_apply_size_constraints(void* /*nh*/, int /*minw*/, int /*minh*/, return neui_detail::load_image_bgra8_ios(path, width_out, height_out); } + uint8_t* platform_load_image_bytes(const uint8_t* data, size_t len, + uint32_t* width_out, uint32_t* height_out) + { + return neui_detail::load_image_bgra8_ios_memory(data, len, + width_out, height_out); + } + void platform_free_image(uint8_t* pixels) { delete[] pixels; } // System clipboard. Delegates to hosts/shared/ios/clipboard_ios.h diff --git a/hosts/crossplatform/platform_linux.cpp b/hosts/crossplatform/platform_linux.cpp index fb529bf..a1372e4 100644 --- a/hosts/crossplatform/platform_linux.cpp +++ b/hosts/crossplatform/platform_linux.cpp @@ -33,7 +33,7 @@ // This TU emits the single stb_image implementation for the Linux host. #define STB_IMAGE_IMPLEMENTATION #define STBI_NO_STDIO_DEPRECATED -#include "../shared/linux/image_loader_linux.h" +#include "../shared/image_loader_stb.h" #include #include @@ -2258,11 +2258,17 @@ namespace uint8_t* platform_load_image(const char* path, uint32_t* width_out, uint32_t* height_out) { - return neui_detail::load_image_bgra8_linux(path, width_out, height_out); + return neui_detail::load_image_bgra8_stb(path, width_out, height_out); + } + uint8_t* platform_load_image_bytes(const uint8_t* data, size_t len, + uint32_t* width_out, uint32_t* height_out) + { + return neui_detail::load_image_bgra8_stb_memory(data, len, + width_out, height_out); } void platform_free_image(uint8_t* pixels) { - neui_detail::free_image_bgra8_linux(pixels); + neui_detail::free_image_bgra8_stb(pixels); } // ---- Clipboard: X11 CLIPBOARD selection (hosts/shared/linux). ------------- diff --git a/hosts/crossplatform/platform_lvgl.cpp b/hosts/crossplatform/platform_lvgl.cpp new file mode 100644 index 0000000..ee42de2 --- /dev/null +++ b/hosts/crossplatform/platform_lvgl.cpp @@ -0,0 +1,1709 @@ +// LVGL platform layer for the crossplatform host (prototype - see +// plans/lvgl-host-approach-c.md). Windows-only for now: each neui frame is an +// LVGL display created through LVGL's native Windows driver (LV_USE_WINDOWS). +// +// Threading model (dictated by the driver): every LVGL display window lives +// on its own thread with its own message pump, and every WndProc entry takes +// lv_lock(). neui's Session is single-threaded, so this layer +// - subclasses the driver HWND and forwards raw input messages into a +// mutex-guarded queue (display thread -> main thread), and +// - drains that queue on the main thread, interleaved with +// lv_timer_handler(), translating messages into the same Session calls +// platform_win32.cpp makes (hit-test -> hover -> focus -> dispatch). +// +// Rendering (Milestone 1 - Approach A baseline): the frame's LVGL screen +// object carries a LV_EVENT_DRAW_MAIN callback that binds the neui-backend- +// lvgl context to the event layer and runs Session::paint_frame - i.e. neui's +// full redraw-the-world walk into one LVGL surface. platform_invalidate maps +// to lv_obj_invalidate(screen). The Milestone 2 retained per-widget layer +// replaces exactly this part. +// +// Coordinate model: LVGL application-mode windows have client area == +// display resolution == physical pixels, so neui logical px, LVGL px and +// physical px are all 1:1 (wd.dpi stays 96, get_scale_factor 1.0). No DPI +// scaling in the prototype - matching a fixed-pixel embedded panel. +// +// Prototype limitations (deliberate, per plan): clipboard / DnD / IME / menus +// / message boxes are no-op stubs; closing a frame hides its window instead +// of destroying it (the driver's display watchdog exit(0)s the process when +// the last display dies mid-loop); frames are not repositionable after +// creation. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "host.h" +#include "platform.h" +#include "../../backends/lvgl/lvgl_backend.h" + +#include +#include +#include + +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include + +// platform_load_image uses the shared stb decoder (the WIC loader the native +// Windows platform layer uses needs COM, which this host never initialises). +// This TU emits the single stb_image implementation for the LVGL build. +#define STB_IMAGE_IMPLEMENTATION +#define STBI_WINDOWS_UTF8 +#include "../shared/image_loader_stb.h" + +namespace xpl_host +{ + // ------------------------------------------------------------------------- + // Globals + + struct WindowData; + + // Retained mode (Milestone 2 - Option C): one passive lv_obj per neui + // widget. MirrorRef is the draw-event user_data (owned by the entry); + // MirrorEntry.body is the clipped content container for SECTION / TABVIEW + // style widgets whose children are positioned body-relative. + struct MirrorRef { + WindowData* w; + uint32_t idx; + }; + struct MirrorEntry { + lv_obj_t* obj = nullptr; + lv_obj_t* body = nullptr; + lv_obj_t* parent = nullptr; // parent obj at last sync (slot-reuse guard) + std::unique_ptr ref; + // Body rect + scroll used at the last sync. SECTION / TABVIEW layouts are + // computed during PAINT (zeros before the first one), so the draw + // callback compares and re-marks the tree dirty when they move. + int32_t synced_body_x = 0, synced_body_y = 0; + int32_t synced_body_w = 0, synced_body_h = 0; + int32_t synced_scroll_x = 0, synced_scroll_y = 0; + }; + + struct WindowData { + Session* session = nullptr; + uint32_t widget_index = 0; + lv_display_t* display = nullptr; + HWND hwnd = nullptr; + lv_obj_t* screen = nullptr; + WNDPROC prev_proc = nullptr; + lv_timer_t* toast_timer = nullptr; + // Spring-back heartbeat for smooth scroll, shared by the scrolling SECTION + // and the GRID (a "both" section can overscroll on both axes in one + // gesture, so one timer steps whatever is bouncing). 0 = not bouncing. + lv_timer_t* bounce_timer = nullptr; + uint32_t bouncing_section = 0; + uint32_t bouncing_grid = 0; + bool closed = false; + // Set instead of calling lv_obj_invalidate when an invalidation arrives + // from inside an LVGL draw dispatch (client PREUPDATE / PAINT handlers + // writing attrs -> WidgetData::repaint) - LVGL forbids invalidating + // while rendering. Flushed right after lv_timer_handler returns. + bool pending_invalidate = false; + // Retained-mode state (main thread only). + std::unordered_map mirrors; + bool tree_dirty = false; + std::vector pending_widget_invals; + // Input-drain state (main thread only). + bool tracking_mouse = false; // display thread only + uint16_t pending_surrogate = 0; + uint32_t last_click_ms = 0; // double-click synthesis + int last_click_x = -10000; + int last_click_y = -10000; + }; + + struct InputMsg { + WindowData* w = nullptr; + UINT msg = 0; + WPARAM wp = 0; + LPARAM lp = 0; + uint32_t mods = 0; // bit0 shift, bit1 ctrl, bit2 alt (platform_win32 layout) + }; + + static CRITICAL_SECTION g_queue_lock; + static std::deque g_queue; + static HANDLE g_wake = nullptr; + static bool g_inited = false; + static bool g_quit = false; + static int g_appwindow_count = 0; + static std::vector g_windows; + + // Milestone 2 runtime switch: retained per-widget mirrors (Option C) vs the + // Milestone 1 whole-frame baseline (Approach A). Defaults ON; set + // NEUI_LVGL_RETAINED=0 to measure the baseline with the same binary. + static bool g_retained = true; + + static void read_retained_env() + { + char buf[8] = {}; + size_t n = 0; + if (getenv_s(&n, buf, sizeof(buf), "NEUI_LVGL_RETAINED") == 0 && n > 0) + g_retained = !(buf[0] == '0'); + } + + // Non-zero while running inside an LVGL callback (draw event / lv timer), + // where lv_timer_handler already holds the global LVGL lock. The lock + // guard below must not re-lock there - the OSAL mutex is not recursive. + static int t_inside_lv = 0; + + struct LvLockGuard { + bool locked; + LvLockGuard() : locked(t_inside_lv == 0) { if (locked) lv_lock(); } + ~LvLockGuard() { if (locked) lv_unlock(); } + }; + + // Same policy exposed to the render backend, which performs LVGL allocations + // of its own outside any draw dispatch (Tiny TTF instance creation and glyph + // cache fills from measure_text on the host's non-painting sizing paths; the + // vector path / dsc deletes in destroy_context). Those mutate LVGL's global + // allocator + cache state, which a display thread can be inside under + // lv_lock. Counting into t_inside_lv keeps the guard re-entrant, since the + // OSAL mutex is not, and makes nested backend calls see the lock as held. + static void backend_lv_lock() + { + if (t_inside_lv == 0) lv_lock(); + ++t_inside_lv; + } + + static void backend_lv_unlock() + { + if (--t_inside_lv == 0) lv_unlock(); + } + + // Deferred main-thread USER32 work. + // + // lv_timer_handler holds the global LVGL lock for the whole refresh, and the + // draw callbacks dispatch WIDGET_PREUPDATE / WIDGET_PAINT into client code + // that may legally call widgets->set_text / set_pos, or show / close a + // dialog. Those land on SetWindowTextW / SetWindowPos / EnableWindow / + // SetForegroundWindow, which BLOCK until the target window's own thread + // processes the sent message - and that thread's WndProc takes lv_lock on + // entry. Calling them while holding the lock is a hard deadlock, so queue + // them and run them from the main loop once the lock is released. + static std::vector> g_deferred_calls; + + static bool defer_if_locked(std::function fn) + { + if (t_inside_lv == 0) return false; + g_deferred_calls.push_back(std::move(fn)); + SetEvent(g_wake); // make sure the loop wakes to run it + return true; + } + + static void run_deferred_calls() + { + while (!g_deferred_calls.empty()) { + std::vector> batch; + batch.swap(g_deferred_calls); + for (auto& fn : batch) fn(); + } + } + + // ------------------------------------------------------------------------- + // Display-thread side: subclass WndProc -> input queue + + static void enqueue(WindowData* w, UINT msg, WPARAM wp, LPARAM lp) + { + uint32_t mods = 0; + if (GetKeyState(VK_SHIFT) & 0x8000) mods |= 1; + if (GetKeyState(VK_CONTROL) & 0x8000) mods |= 2; + if (GetKeyState(VK_MENU) & 0x8000) mods |= 4; + { + EnterCriticalSection(&g_queue_lock); + g_queue.push_back(InputMsg{ w, msg, wp, lp, mods }); + LeaveCriticalSection(&g_queue_lock); + } + SetEvent(g_wake); + } + + static LRESULT CALLBACK subclass_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) + { + auto* w = reinterpret_cast(GetPropW(hwnd, L"neui.lvgl.wdata")); + if (!w) return DefWindowProcW(hwnd, msg, wp, lp); + + switch (msg) { + case WM_MOUSEMOVE: + if (!w->tracking_mouse) { + TRACKMOUSEEVENT tme = { sizeof(tme), TME_LEAVE, hwnd, 0 }; + TrackMouseEvent(&tme); + w->tracking_mouse = true; + } + enqueue(w, msg, wp, lp); + break; + + case WM_MOUSELEAVE: + w->tracking_mouse = false; + enqueue(w, msg, wp, lp); + break; + + case WM_LBUTTONDOWN: + SetCapture(hwnd); // keep move messages during out-of-window drags + enqueue(w, msg, wp, lp); + break; + + case WM_LBUTTONUP: + ReleaseCapture(); + enqueue(w, msg, wp, lp); + break; + + case WM_RBUTTONDOWN: + case WM_RBUTTONUP: + case WM_MOUSEWHEEL: + case WM_MOUSEHWHEEL: + case WM_KEYDOWN: + case WM_KEYUP: + case WM_CHAR: + case WM_SIZE: + case WM_SETFOCUS: + case WM_KILLFOCUS: + enqueue(w, msg, wp, lp); + break; + + case WM_CLOSE: + // Consume: the driver's DefWindowProc would DestroyWindow, delete the + // display, and the driver watchdog would exit(0) the process. neui + // decides on the main thread instead (APP_QUIT event). + enqueue(w, msg, wp, lp); + return 0; + + default: + break; + } + return CallWindowProcW(w->prev_proc, hwnd, msg, wp, lp); + } + + // ------------------------------------------------------------------------- + // Main-thread side: the paint bridge (Milestone 1: whole frame -> screen) + + static void screen_draw_cb(lv_event_t* e) + { + auto* w = static_cast(lv_event_get_user_data(e)); + if (!w || !w->session) return; + auto* wd = w->session->get_widget(w->widget_index); + if (!wd || !wd->render_ctx) return; + lv_layer_t* layer = lv_event_get_layer(e); + if (!layer) return; + + ++t_inside_lv; + neui_lvgl_backend::bind_layer(wd->render_ctx, layer, 0, 0); + w->session->paint_frame(wd->render_ctx, w->widget_index); + neui_lvgl_backend::unbind_layer(wd->render_ctx); + --t_inside_lv; + } + + // ------------------------------------------------------------------------- + // Milestone 2 (Option C): retained lv_obj mirror per widget. + // + // The screen object paints the frame background (DRAW_MAIN, below all + // mirrors) and the overlays (DRAW_POST, above all mirrors: combo drop, + // popup menu, toast). Each widget mirror paints its widget in DRAW_MAIN + // (parent-relative coords, exactly like the immediate-mode walk) and its + // after-children pass in DRAW_POST (widget-local coords). LVGL owns + // invalidation: lv_obj_invalidate on a mirror repaints only objects + // intersecting that widget's rect. + + static neui_render_ctx_t frame_ctx_of(WindowData* w) + { + if (!w || !w->session) return nullptr; + auto* fwd = w->session->get_widget(w->widget_index); + return fwd ? fwd->render_ctx : nullptr; + } + + static void frame_bg_draw_cb(lv_event_t* e) + { + auto* w = static_cast(lv_event_get_user_data(e)); + neui_render_ctx_t ctx = frame_ctx_of(w); + lv_layer_t* layer = lv_event_get_layer(e); + if (!ctx || !layer) return; + ++t_inside_lv; + neui_lvgl_backend::bind_layer(ctx, layer, 0, 0); + w->session->paint_frame_background_retained(ctx, w->widget_index); + neui_lvgl_backend::unbind_layer(ctx); + --t_inside_lv; + } + + static void frame_overlay_draw_cb(lv_event_t* e) + { + auto* w = static_cast(lv_event_get_user_data(e)); + neui_render_ctx_t ctx = frame_ctx_of(w); + lv_layer_t* layer = lv_event_get_layer(e); + if (!ctx || !layer) return; + ++t_inside_lv; + neui_lvgl_backend::bind_layer(ctx, layer, 0, 0); + w->session->paint_overlays_retained(ctx, w->widget_index); + neui_lvgl_backend::unbind_layer(ctx); + --t_inside_lv; + } + + static void widget_draw_cb(lv_event_t* e) + { + auto* ref = static_cast(lv_event_get_user_data(e)); + WindowData* w = ref ? ref->w : nullptr; + if (!w || !w->session) return; + Session* s = w->session; + if (!s->_widgets.exists(ref->idx)) return; + neui_render_ctx_t ctx = frame_ctx_of(w); + lv_layer_t* layer = lv_event_get_layer(e); + if (!ctx || !layer) return; + + auto* obj = static_cast(lv_event_get_target(e)); + lv_area_t coords; + lv_obj_get_coords(obj, &coords); + auto& wd = s->_widgets[ref->idx]; + + ++t_inside_lv; + // wd.paint draws at the widget's parent-relative (x, y): bind the base so + // that position lands on the mirror's display-coord origin. + neui_lvgl_backend::bind_layer(ctx, layer, coords.x1 - wd.x, coords.y1 - wd.y); + s->paint_widget_retained(ctx, ref->idx, /*after_children=*/false); + neui_lvgl_backend::unbind_layer(ctx); + --t_inside_lv; + + // SECTION / TABVIEW body layout is computed by the paint above; if it + // moved since the last sync (first paint in particular - the layout + // reads zeros before it), re-sync so the body container + children + // follow. Converges one refresh later. + if (const auto* slay = wd.section_layout_ptr()) { + auto it = w->mirrors.find(ref->idx); + if (it != w->mirrors.end()) { + auto* sst = wd.scroll_state_ptr(); + const int sx = sst ? sst->scroll_x : 0; + const int sy = sst ? sst->scroll_y : 0; + MirrorEntry& me = it->second; + if (me.synced_body_x != slay->body_x || me.synced_body_y != slay->body_y || + me.synced_body_w != slay->body_w || me.synced_body_h != slay->body_h || + me.synced_scroll_x != sx || me.synced_scroll_y != sy) + w->tree_dirty = true; + } + } + } + + static void widget_draw_post_cb(lv_event_t* e) + { + auto* ref = static_cast(lv_event_get_user_data(e)); + WindowData* w = ref ? ref->w : nullptr; + if (!w || !w->session) return; + Session* s = w->session; + if (!s->_widgets.exists(ref->idx)) return; + neui_render_ctx_t ctx = frame_ctx_of(w); + lv_layer_t* layer = lv_event_get_layer(e); + if (!ctx || !layer) return; + + auto* obj = static_cast(lv_event_get_target(e)); + lv_area_t coords; + lv_obj_get_coords(obj, &coords); + + ++t_inside_lv; + // paint_after_children runs in widget-local coords (the immediate-mode + // walk has translate(x, y) active there) - base is the mirror origin. + neui_lvgl_backend::bind_layer(ctx, layer, coords.x1, coords.y1); + s->paint_widget_retained(ctx, ref->idx, /*after_children=*/true); + neui_lvgl_backend::unbind_layer(ctx); + --t_inside_lv; + } + + static void make_passive(lv_obj_t* obj) + { + lv_obj_remove_style_all(obj); + lv_obj_set_clickable(obj, false); + lv_obj_set_scrollable(obj, false); + lv_obj_set_click_focusable(obj, false); + } + + // neui widgets draw centered 1px borders / focus outlines that extend a + // hair past the widget rect; give every mirror a small ext draw margin so + // LVGL neither clips them nor misses them during invalidation. + static void widget_ext_draw_cb(lv_event_t* e) + { + lv_event_set_ext_draw_size(e, 4); + } + + // One pass of the mirror sync: walk the widget tree exactly like + // paint_widgets_recursive's geometry logic (abs-coord recompute, SECTION / + // TABVIEW body offsets and scroll), creating / positioning / hiding the + // mirror objects to match. LVGL invalidates whatever actually moved. + // + // `z_next` is the next child index to claim inside `parent_obj`. LVGL paints a + // container's children in child-list order, so that order has to track the + // widget tree's paint order - and lv_obj_create / lv_obj_set_parent both APPEND. + // Without this, a widget created or reparented into the MIDDLE of an existing + // sibling row would draw on top of the siblings that come after it (the + // mid-order-insert gap in plans/lvgl-host-approach-c.md). The walk visits + // objects in paint order, so claiming indices sequentially is exactly right. + static void sync_children(WindowData* w, uint32_t parent_index, + lv_obj_t* parent_obj, + int parent_abs_x, int parent_abs_y, + int origin_x, int origin_y, + std::unordered_set& live, + int& z_next) + { + Session* s = w->session; + for (uint32_t idx = s->_widgets.child(parent_index); idx != 0; + idx = s->_widgets.next(idx)) { + if (!s->_widgets.exists(idx)) continue; + auto& wd = s->_widgets[idx]; + wd.abs_x = parent_abs_x + wd.x; + wd.abs_y = parent_abs_y + wd.y; + + const bool paintable = !wd.native_handle && !wd.is_menubar() && + wd.width > 0 && wd.height > 0; + MirrorEntry& me = w->mirrors[idx]; + live.insert(idx); + + if (paintable) { + if (me.obj && me.parent != parent_obj) { + // Our container changed: the tree slot was reused under a different + // parent, or an ancestor stopped painting and handed its children up + // (see the descent note below). REPARENT rather than delete + rebuild - + // lv_obj_delete takes the whole subtree with it, which would leave + // every descendant's MirrorEntry::obj pointing at a freed object for + // the rest of this pass (and it deletes it again when it gets there). + lv_obj_set_parent(me.obj, parent_obj); + me.parent = parent_obj; + } + if (!me.obj) { + me.obj = lv_obj_create(parent_obj); + me.parent = parent_obj; + make_passive(me.obj); + // neui's immediate-mode walk does not clip children to their + // parent (only SECTION bodies clip); match that. + lv_obj_set_overflow_visible(me.obj, true); + me.ref = std::make_unique(MirrorRef{ w, idx }); + lv_obj_add_event_cb(me.obj, widget_draw_cb, LV_EVENT_DRAW_MAIN, me.ref.get()); + lv_obj_add_event_cb(me.obj, widget_draw_post_cb, LV_EVENT_DRAW_POST, me.ref.get()); + lv_obj_add_event_cb(me.obj, widget_ext_draw_cb, LV_EVENT_REFR_EXT_DRAW_SIZE, nullptr); + } + lv_obj_set_pos(me.obj, origin_x + wd.x, origin_y + wd.y); + lv_obj_set_size(me.obj, wd.width, wd.height); + lv_obj_set_hidden(me.obj, !wd.visible); + } else if (me.obj) { + lv_obj_set_hidden(me.obj, true); + } + + // Claim this widget's slot in the container's paint order. A hidden mirror + // still occupies a slot (it stays in the child list), so it is counted too - + // otherwise every later sibling would be off by one. Compared first so a + // steady-state sync, where the order already matches, costs nothing. + if (me.obj) { + if (lv_obj_get_index(me.obj) != z_next) lv_obj_move_to_index(me.obj, z_next); + ++z_next; + } + + // Descent. Container + origins for the children. The children mirror under + // this widget's own object only while that object stands in for the widget: + // a mirror hidden because the widget stopped PAINTING (shrunk to zero size, + // gained a native handle) must not become their container, because + // LV_OBJ_FLAG_HIDDEN takes the whole subtree with it while + // paint_widgets_recursive skips only the widget itself and still paints its + // children - its one descent gate is wd.visible. Those children go to our + // own container with this widget's offset folded in, exactly like a widget + // that never had a mirror. A mirror hidden because the widget is INVISIBLE + // is the opposite case - there the subtree is meant to disappear too, so it + // stays the container. + const bool use_mirror = me.obj && (paintable || !wd.visible); + lv_obj_t* container = use_mirror ? me.obj : parent_obj; + int child_origin_x = use_mirror ? 0 : origin_x + wd.x; + int child_origin_y = use_mirror ? 0 : origin_y + wd.y; + int abs_ox = wd.abs_x; + int abs_oy = wd.abs_y; + + const auto* slay = wd.section_layout_ptr(); + auto* sst = wd.scroll_state_ptr(); + if (slay && use_mirror) { + const int sx = sst ? sst->scroll_x : 0; + const int sy = sst ? sst->scroll_y : 0; + if (!me.body) { + me.body = lv_obj_create(me.obj); + make_passive(me.body); + // The body container CLIPS its children (LVGL default) - the + // SECTION body clip the immediate-mode walk pushes explicitly. + } + lv_obj_set_pos(me.body, slay->body_x, slay->body_y); + lv_obj_set_size(me.body, slay->body_w, slay->body_h); + me.synced_body_x = slay->body_x; + me.synced_body_y = slay->body_y; + me.synced_body_w = slay->body_w; + me.synced_body_h = slay->body_h; + me.synced_scroll_x = sx; + me.synced_scroll_y = sy; + container = me.body; + child_origin_x = -sx; + child_origin_y = -sy; + abs_ox = wd.abs_x + slay->body_x - sx; + abs_oy = wd.abs_y + slay->body_y - sy; + } + + // Children that went into a container of ours start a fresh index space; + // children handed UP to our own container keep claiming slots after us, so + // they paint after this widget and before the next sibling - matching the + // immediate-mode walk. + int child_z = 0; + sync_children(w, idx, container, abs_ox, abs_oy, + child_origin_x, child_origin_y, live, + (container == parent_obj) ? z_next : child_z); + } + } + + static void sync_mirror_tree(WindowData* w) + { + if (!w->session || !w->screen) return; + LvLockGuard lock; + std::unordered_set live; + int z_next = 0; + sync_children(w, w->widget_index, w->screen, 0, 0, 0, 0, live, z_next); + + // Sweep mirrors whose widget vanished. Deleting an lv_obj deletes its + // subtree, so delete only the roots of dead subtrees (entries whose + // parent obj itself belongs to a dead entry are freed by that delete). + std::unordered_set dead_objs; + for (auto& kv : w->mirrors) { + if (live.count(kv.first)) continue; + if (kv.second.obj) dead_objs.insert(kv.second.obj); + if (kv.second.body) dead_objs.insert(kv.second.body); + } + for (auto it = w->mirrors.begin(); it != w->mirrors.end();) { + if (live.count(it->first)) { ++it; continue; } + if (it->second.obj && !dead_objs.count(it->second.parent)) + lv_obj_delete(it->second.obj); + it = w->mirrors.erase(it); + } + } + + static void sync_dirty_trees() + { + if (!g_retained) return; + for (auto* w : g_windows) { + if (!w->tree_dirty) continue; + w->tree_dirty = false; + sync_mirror_tree(w); + } + } + + // ------------------------------------------------------------------------- + // Input drain (main thread) + + static uint32_t wheel_lines(int raw_delta, bool horizontal) + { + UINT n = 3; + SystemParametersInfoW(horizontal ? SPI_GETWHEELSCROLLCHARS + : SPI_GETWHEELSCROLLLINES, 0, &n, 0); + (void)raw_delta; + return n; + } + + static void dispatch_key_to_focused(Session* sess, neui_event_type_t type, + uint32_t keycode, uint32_t mods) + { + if (!sess) return; + uint32_t fw = sess->_focused_widget; + if (fw == 0 || !sess->_widgets.exists(fw)) return; + auto& wd = sess->_widgets[fw]; + bool consumed = false; + if (wd.emit_events) { + neui_event_t ev = {}; + ev.type = type; + ev.data.key = { { wd.widget_id }, keycode, mods }; + consumed = sess->dispatch_event(&ev); + } + if (!consumed) + sess->handle_input_key(type, keycode, mods); + } + + static void invalidate_window(WindowData* w) + { + if (!w || !w->screen) return; + if (t_inside_lv > 0) { w->pending_invalidate = true; return; } + LvLockGuard lock; + lv_obj_invalidate(w->screen); + } + + // Invalidate one widget's mirror from INSIDE an lv_timer callback: the LVGL + // lock is already held there, so this must not take LvLockGuard (it is not + // recursive) - the toast timer invalidates the same way. Falls back to the + // whole screen in baseline mode or before the mirror exists. + static void invalidate_widget_in_timer(WindowData* w, uint32_t widget_index) + { + if (!w || !w->screen) return; + if (g_retained) { + auto it = w->mirrors.find(widget_index); + if (it != w->mirrors.end() && it->second.obj) { + lv_obj_invalidate(it->second.obj); + return; + } + } + lv_obj_invalidate(w->screen); + } + + // ------------------------------------------------------------------------- + // Smooth-scroll kinetics (scrolling SECTION + GRID) + // + // The dynamics are the shared, Tier-1-tested integrators every other platform + // drives (hosts/shared/scroll_kinetics.h, reached through + // widget_section_scroll.h / grid_model.h) - only two things are per-platform: + // where a pixel-precise wheel delta comes from, and what runs the spring-back + // heartbeat. Here that is one 16 ms lv_timer per window, the same shape as the + // toast timer; platform_win32 uses SetTimer(16) and platform_linux a 16 ms tick. + + // The scrolling SECTION that owns the wheel at `hit`: the hit widget itself, or + // its nearest ancestor carrying a SectionScrollState. 0 = none. (Twin of + // platform_win32 / platform_linux's find_scrolling_section.) + static uint32_t find_scrolling_section(Session* s, uint32_t hit) + { + auto* hw = s->get_widget(hit); + if (hw && hw->scroll_state_ptr()) return hit; + for (uint32_t pidx : s->_widgets.get_all_parents(hit)) { + auto* pw = s->get_widget(pidx); + if (pw && pw->scroll_state_ptr()) return pidx; + } + return 0; + } + + // Spring-back tick. Steps whichever of the two is overscrolled, invalidates it, + // and deletes itself once nothing is moving - the lv_timer twin of + // platform_win32's XPL_SECTION_BOUNCE_TIMER_ID / XPL_GRID_BOUNCE_TIMER_ID + // WM_TIMER arms. Runs with the LVGL lock already held (see + // invalidate_widget_in_timer), and t_inside_lv is raised so a client + // SCROLL_CHANGED handler that writes attrs defers its invalidate instead of + // re-locking. + static void bounce_timer_cb(lv_timer_t* t) + { + auto* w = static_cast(lv_timer_get_user_data(t)); + if (!w || !w->session) { lv_timer_delete(t); return; } + using namespace neui_detail; + + ++t_inside_lv; + bool more = false; + + if (w->bouncing_section != 0) { + auto* sw = w->session->get_widget(w->bouncing_section); + SectionScrollState* st = sw ? sw->scroll_state_ptr() : nullptr; + const SectionLayout* L = sw ? sw->section_layout_ptr() : nullptr; + if (st && L) { + const bool mv = section_scroll_bounce_step(*st, *L, false); + const bool mh = section_scroll_bounce_step(*st, *L, true); + invalidate_widget_in_timer(w, w->bouncing_section); + sw->notify_scroll_changed(); + if (mv || mh) more = true; + else w->bouncing_section = 0; + } else { + w->bouncing_section = 0; // section died mid-bounce + } + } + + if (w->bouncing_grid != 0) { + auto* gw = w->session->get_widget(w->bouncing_grid); + GridModel* model = gw ? gw->grid_model_ptr() : nullptr; + if (model) { + auto cfg = grid_read_config(gw->attrs.get()); + GridViewport vp = grid_compute_viewport(*model, gw->width, gw->height, + cfg.row_h, cfg.header_h); + const bool mg = grid_scroll_bounce_step(*model, vp, cfg.row_h); + invalidate_widget_in_timer(w, w->bouncing_grid); + if (mg) more = true; + else w->bouncing_grid = 0; + } else { + w->bouncing_grid = 0; + } + } + + --t_inside_lv; + + if (!more) { + w->bounce_timer = nullptr; + lv_timer_delete(t); // deleting the running timer is supported by LVGL + } + } + + // Arm (or re-arm) the heartbeat. Called from the input drain, which runs + // OUTSIDE lv_timer_handler, so it takes the lock itself. + static void start_bounce_timer(WindowData* w) + { + if (!w) return; + LvLockGuard lock; + if (w->bounce_timer) lv_timer_reset(w->bounce_timer); + else w->bounce_timer = lv_timer_create(bounce_timer_cb, 16, w); + } + + // Feed a pixel-precise wheel delta into a scrolling SECTION's per-axis + // kinetics. dv / dh are logical px in the kinetics' sign convention (positive + // dv = scroll up, positive dh = scroll left), matching + // section_kinetic_wheel_w32 / _linux exactly - including the asymmetric + // single-axis fallback (a horizontal-only section absorbs a plain vertical + // wheel, because a classic wheel has no horizontal axis; a vertical-only + // section ignores explicit horizontal input rather than re-aiming it) and the + // NEUI_ATTR_SCROLL_KINETICS gate (STEPPED hard-clamps and never bounces). + static void section_kinetic_wheel_lvgl(WindowData* w, uint32_t sec_idx, + double dv, double dh) + { + using namespace neui_detail; + Session* s = w->session; + auto* sw = s->get_widget(sec_idx); + SectionScrollState* st = sw ? sw->scroll_state_ptr() : nullptr; + const SectionLayout* L = sw ? sw->section_layout_ptr() : nullptr; + if (!st || !L) return; + + const bool has_v = section_axis_has_v(st->axis); + const bool has_h = section_axis_has_h(st->axis); + if (!has_v && has_h && dh == 0.0 && dv != 0.0) { dh = dv; dv = 0.0; } + + const int kin_mode = section_read_kinetics_mode(sw->attrs.get()); + const bool smooth = scroll_kinetics_smooth_enabled( + kin_mode, /*platform_default_smooth=*/false); + + bool changed = false, start_bounce = false; + if (smooth) { + ScrollWheelAction act_v{}, act_h{}; + if (has_v && dv != 0.0) { + ScrollWheelInput in; + in.precise = true; // px-true input - enables the rubber band + in.delta_px = dv; + act_v = section_scroll_wheel_kinetic(*st, *L, in, false); + } + if (has_h && dh != 0.0) { + ScrollWheelInput in; + in.precise = true; + in.delta_px = dh; + act_h = section_scroll_wheel_kinetic(*st, *L, in, true); + } + changed = act_v.changed || act_h.changed; + start_bounce = act_v.start_bounce || act_h.start_bounce; + } else { + if (has_v && dv != 0.0 && section_scroll_step_px(*st, *L, dv, false)) + changed = true; + if (has_h && dh != 0.0 && section_scroll_step_px(*st, *L, dh, true)) + changed = true; + } + + if (changed) { + // The mirror's own draw pass notices synced_scroll_* moved and re-marks the + // tree dirty, which repositions the body's children on the next turn - the + // same route the stepped path already takes. + platform_retained_widget_invalidate(s, sec_idx); + sw->notify_scroll_changed(); + } + if (start_bounce) { + w->bouncing_section = sec_idx; + start_bounce_timer(w); + } + } + + static void flush_pending_invalidates() + { + for (auto* w : g_windows) { + if (w->pending_invalidate && w->screen) { + w->pending_invalidate = false; + w->pending_widget_invals.clear(); // whole frame covers them + LvLockGuard lock; + lv_obj_invalidate(w->screen); + } + if (!w->pending_widget_invals.empty()) { + LvLockGuard lock; + for (uint32_t idx : w->pending_widget_invals) { + auto it = w->mirrors.find(idx); + if (it != w->mirrors.end() && it->second.obj) + lv_obj_invalidate(it->second.obj); + else if (w->screen) + lv_obj_invalidate(w->screen); + } + w->pending_widget_invals.clear(); + } + } + } + + // Owning frame's WindowData for a widget (nullptr when the widget has no + // frame ancestor with a live window yet). + static WindowData* find_window(void* native_handle); + + static WindowData* window_for_widget(Session* s, uint32_t widget_index) + { + if (!s || widget_index == 0) return nullptr; + // The widget may BE the frame (invalidate / repaint on a frame widget): + // find_parent_native_handle walks strict ancestors only. + void* native = nullptr; + if (s->_widgets.exists(widget_index) && s->_widgets[widget_index].native_handle) + native = s->_widgets[widget_index].native_handle; + else + native = s->find_parent_native_handle(widget_index); + return native ? find_window(native) : nullptr; + } + + // ---- Retained-mode seams (called from host.cpp / widgets.cpp) ----------- + + void platform_retained_widget_invalidate(Session* session, uint32_t widget_index) + { + WindowData* w = window_for_widget(session, widget_index); + if (!w) return; + if (!g_retained) { invalidate_window(w); return; } + auto it = w->mirrors.find(widget_index); + if (it == w->mirrors.end() || !it->second.obj) { + // No mirror yet (created this frame, or the frame widget itself): + // fall back to the whole-frame invalidate. + invalidate_window(w); + return; + } + if (t_inside_lv > 0) { + w->pending_widget_invals.push_back(widget_index); + return; + } + LvLockGuard lock; + lv_obj_invalidate(it->second.obj); + } + + void platform_retained_tree_changed(Session* session, uint32_t widget_index) + { + WindowData* w = window_for_widget(session, widget_index); + if (!w) return; + if (!g_retained) { invalidate_window(w); return; } + w->tree_dirty = true; + SetEvent(g_wake); + } + + static void handle_close_request(WindowData* w); + + static void drain_one(const InputMsg& m) + { + WindowData* w = m.w; + Session* s = w ? w->session : nullptr; + if (!s) return; + auto* fwd = s->get_widget(w->widget_index); + if (!fwd) return; + +#ifdef NEUI_LVGL_INPUT_TRACE + if (m.msg != WM_MOUSEMOVE) + printf("drain: msg=0x%04x wp=%llx x=%d y=%d\n", m.msg, + (unsigned long long)m.wp, GET_X_LPARAM(m.lp), GET_Y_LPARAM(m.lp)); +#endif + + switch (m.msg) { + + case WM_MOUSEMOVE: { + const float lx = static_cast(GET_X_LPARAM(m.lp)); + const float ly = static_cast(GET_Y_LPARAM(m.lp)); + if (s->_popup_active) { s->handle_popup_hover(lx, ly); break; } + if (s->handle_combo_scroll_drag(ly)) break; + if (s->handle_combo_hover(lx, ly)) break; + + uint32_t hit = s->widget_at(lx, ly, w->widget_index); + s->set_hovered(hit); + + uint32_t pressed = s->_pressed_widget; + uint32_t target = (pressed != 0 && (m.wp & MK_LBUTTON)) ? pressed : hit; + if (target != 0) { + if (auto* hw = s->get_widget(target)) { + neui_event_t ev = {}; + ev.type = NEUI_EVENT_MOUSE_MOVE; + ev.data.mouse.widget = { hw->widget_id }; + ev.data.mouse.x = static_cast(lx); + ev.data.mouse.y = static_cast(ly); + ev.data.mouse.buttonmap = static_cast(m.wp) + & (MK_LBUTTON | MK_RBUTTON | MK_MBUTTON); + s->dispatch_mouse_event(target, &ev); + } + } + break; + } + + case WM_MOUSELEAVE: + s->set_hovered(0); + break; + + case WM_LBUTTONDOWN: { + const float lx = static_cast(GET_X_LPARAM(m.lp)); + const float ly = static_cast(GET_Y_LPARAM(m.lp)); + + // Double-click synthesis: the LVGL window class lacks CS_DBLCLKS, so + // fold a rapid same-spot second DOWN into MOUSE_BUTTON_DBLCLICK + // (widgets that ignore DBLCLICK still see the later UP -> CLICK, + // matching the win32 host's parity behaviour). + const uint32_t now = static_cast(GetTickCount64()); + const int ix = static_cast(lx), iy = static_cast(ly); + bool dblclick = (now - w->last_click_ms) <= GetDoubleClickTime() && + std::abs(ix - w->last_click_x) <= 4 && + std::abs(iy - w->last_click_y) <= 4; + w->last_click_ms = dblclick ? 0 : now; // third click = plain again + w->last_click_x = ix; + w->last_click_y = iy; + + if (s->_popup_active) { s->handle_popup_click(lx, ly); break; } + if (s->handle_toast_click(w->widget_index, lx, ly)) break; + if (s->handle_combo_click(lx, ly)) break; + + uint32_t hit = s->widget_at(lx, ly, w->widget_index); + s->set_focus(hit); + s->set_pressed(hit); + if (hit != 0) { + if (auto* hw = s->get_widget(hit)) { + neui_event_t ev = {}; + ev.type = dblclick ? NEUI_EVENT_MOUSE_BUTTON_DBLCLICK + : NEUI_EVENT_MOUSE_BUTTON_DOWN; + ev.data.mouse.widget = { hw->widget_id }; + ev.data.mouse.x = ix; + ev.data.mouse.y = iy; + ev.data.mouse.buttonmap = static_cast(m.wp); + s->dispatch_mouse_event(hit, &ev); + } + } + break; + } + + case WM_LBUTTONUP: { + if (s->_combo_sb_dragging) { s->_combo_sb_dragging = false; break; } + const float lx = static_cast(GET_X_LPARAM(m.lp)); + const float ly = static_cast(GET_Y_LPARAM(m.lp)); + uint32_t hit = s->widget_at(lx, ly, w->widget_index); + uint32_t pressed = s->_pressed_widget; + s->set_pressed(0); + if (hit != 0) { + if (auto* hw = s->get_widget(hit)) { + neui_event_t ev = {}; + ev.data.mouse.widget = { hw->widget_id }; + ev.data.mouse.x = static_cast(lx); + ev.data.mouse.y = static_cast(ly); + ev.data.mouse.buttonmap = 0; + ev.type = NEUI_EVENT_MOUSE_BUTTON_UP; + s->dispatch_mouse_event(hit, &ev); + if (hit == pressed) { + ev.type = NEUI_EVENT_MOUSE_BUTTON_CLICK; + s->dispatch_mouse_event(hit, &ev); + } + } + } + break; + } + + case WM_RBUTTONDOWN: { + const float lx = static_cast(GET_X_LPARAM(m.lp)); + const float ly = static_cast(GET_Y_LPARAM(m.lp)); + if (s->_popup_active) { s->handle_popup_click(lx, ly); break; } + uint32_t hit = s->widget_at(lx, ly, w->widget_index); + if (hit == 0) break; + if (auto* hw = s->get_widget(hit)) { + neui_event_t ev = {}; + ev.type = NEUI_EVENT_MOUSE_RBUTTON_DOWN; + ev.data.mouse.widget = { hw->widget_id }; + ev.data.mouse.x = static_cast(lx); + ev.data.mouse.y = static_cast(ly); + s->dispatch_mouse_event(hit, &ev); + } + break; + } + + case WM_RBUTTONUP: { + const float lx = static_cast(GET_X_LPARAM(m.lp)); + const float ly = static_cast(GET_Y_LPARAM(m.lp)); + uint32_t hit = s->widget_at(lx, ly, w->widget_index); + if (hit == 0) break; + if (auto* hw = s->get_widget(hit)) { + neui_event_t ev = {}; + ev.type = NEUI_EVENT_MOUSE_RBUTTON_UP; + ev.data.mouse.widget = { hw->widget_id }; + ev.data.mouse.x = static_cast(lx); + ev.data.mouse.y = static_cast(ly); + s->dispatch_mouse_event(hit, &ev); + } + break; + } + + case WM_MOUSEWHEEL: + case WM_MOUSEHWHEEL: { + const bool horizontal = (m.msg == WM_MOUSEHWHEEL); + POINT pt = { GET_X_LPARAM(m.lp), GET_Y_LPARAM(m.lp) }; // screen coords + ScreenToClient(w->hwnd, &pt); + const float lx = static_cast(pt.x); + const float ly = static_cast(pt.y); + + const int raw = GET_WHEEL_DELTA_WPARAM(m.wp); + const int lines = static_cast(wheel_lines(raw, horizontal)); + int delta = (raw * lines) / WHEEL_DELTA; + const bool is_horiz = horizontal || (m.wp & MK_SHIFT) != 0; + + // The combo drop list scrolls on the vertical convention - intercept + // before the horizontal sign flip below, so shift+wheel over an open + // list scrolls the same direction a plain wheel does (platform_win32 + // hands handle_combo_wheel the unflipped delta too). + if (!horizontal && s->handle_combo_wheel(lx, ly, delta)) break; + + // WM_MOUSEHWHEEL's positive delta means "to the right", the opposite of + // the vertical convention; platform_win32 flips it the same way. Shift + + // vertical wheel is the same gesture. + if (is_horiz) delta = -delta; + + uint32_t hit = s->widget_at(lx, ly, w->widget_index); + if (hit == 0) break; + auto* hw = s->get_widget(hit); + if (!hw) break; + + // GRID in SMOOTH mode: the pixel-precise delta goes to the shared + // kinetics instead of the line-quantized `delta` above (the integrator + // owns its own pixel accumulator), same as platform_win32. + if (neui_detail::GridModel* model = hw->grid_model_ptr()) { + using namespace neui_detail; + auto cfg = grid_read_config(hw->attrs.get()); + if (grid_smooth_enabled(cfg, /*platform_default_smooth=*/false)) { + GridViewport vp = grid_compute_viewport(*model, hw->width, hw->height, + cfg.row_h, cfg.header_h); + GridWheelInput in; + in.precise = true; + in.delta_px = ((double)raw / (double)WHEEL_DELTA) + * (double)lines * (double)cfg.row_h; + GridWheelAction act = grid_scroll_wheel(*model, vp, cfg.row_h, in); + if (act.changed) platform_retained_widget_invalidate(s, hit); + if (act.start_bounce) { + w->bouncing_grid = hit; + start_bounce_timer(w); + } + break; + } + } + + neui_event_t ev = {}; + ev.type = NEUI_EVENT_MOUSE_WHEEL; + ev.data.wheel.widget = { hw->widget_id }; + ev.data.wheel.x = static_cast(lx); + ev.data.wheel.y = static_cast(ly); + ev.data.wheel.delta = delta; + ev.data.wheel.is_horizontal = is_horiz ? 1 : 0; + + // Scrolling SECTION (the hit or its nearest such ancestor): widgets below + // it get first refusal through a bounded bubble; when nothing below + // consumes, the section eats the wheel through its kinetics. + const uint32_t sec_idx = find_scrolling_section(s, hit); + if (sec_idx != 0) { + if (hit != sec_idx && s->dispatch_wheel_event(hit, &ev, sec_idx)) break; + const double px = ((double)raw / (double)WHEEL_DELTA) + * (double)lines * neui_detail::SECTION_WHEEL_LINE_PX; + // is_horiz covers both a real WM_MOUSEHWHEEL and shift+wheel; the sign + // flip matches the line path's "wheel-up = scroll-right" convention. + if (is_horiz) section_kinetic_wheel_lvgl(w, sec_idx, 0.0, -px); + else section_kinetic_wheel_lvgl(w, sec_idx, px, 0.0); + break; + } + + // Otherwise the wheel bubbles normally (stepped). + s->dispatch_wheel_event(hit, &ev); + break; + } + + case WM_KEYDOWN: { + if (s->_popup_active && + s->handle_popup_key(static_cast(m.wp))) + break; + if (m.wp == VK_TAB) { + s->focus_next(!(m.mods & 1)); + break; + } + dispatch_key_to_focused(s, NEUI_EVENT_KEYDOWN, + static_cast(m.wp), m.mods); + break; + } + + case WM_KEYUP: + dispatch_key_to_focused(s, NEUI_EVENT_KEYUP, + static_cast(m.wp), m.mods); + break; + + case WM_CHAR: { + // Assemble UTF-16 surrogate pairs into full codepoints (same logic + // as platform_win32.cpp). + const uint16_t ch = static_cast(m.wp); + uint32_t codepoint; + if (ch >= 0xD800 && ch <= 0xDBFF) { w->pending_surrogate = ch; break; } + if (ch >= 0xDC00 && ch <= 0xDFFF) { + if (w->pending_surrogate == 0) break; + codepoint = 0x10000u + + (static_cast(w->pending_surrogate - 0xD800) << 10) + + static_cast(ch - 0xDC00); + w->pending_surrogate = 0; + } else { + w->pending_surrogate = 0; + codepoint = ch; + } + dispatch_key_to_focused(s, NEUI_EVENT_KEYCHAR, codepoint, m.mods); + break; + } + + case WM_SIZE: { + if (m.wp == SIZE_MINIMIZED) break; + const int cw = LOWORD(m.lp); + const int ch = HIWORD(m.lp); + if (cw <= 0 || ch <= 0) break; + s->resize_render_ctx(w->widget_index, static_cast(cw), + static_cast(ch)); + fwd->width = cw; // logical == physical == LVGL px + fwd->height = ch; + neui_event_t ev = {}; + ev.type = NEUI_EVENT_RESIZE; + ev.data.resize.widget = { fwd->widget_id }; + ev.data.resize.width = cw; + ev.data.resize.height = ch; + s->dispatch_event(&ev); + invalidate_window(w); + break; + } + + case WM_SETFOCUS: + case WM_KILLFOCUS: { + const bool gained = (m.msg == WM_SETFOCUS); + s->_os_focused = gained; + if (s->_focused_widget != 0 && s->_widgets.exists(s->_focused_widget)) { + auto& wd = s->_widgets[s->_focused_widget]; + if (wd.emit_events) { + neui_event_t ev = {}; + ev.type = NEUI_EVENT_WIDGET_FOCUS; + ev.data.focus.widget = { wd.widget_id }; + ev.data.focus.focused = gained; + s->dispatch_event(&ev); + } + } + invalidate_window(w); + break; + } + + case WM_CLOSE: + handle_close_request(w); + break; + + default: + break; + } + } + + static void drain_input() + { + for (;;) { + InputMsg m; + { + EnterCriticalSection(&g_queue_lock); + if (g_queue.empty()) { LeaveCriticalSection(&g_queue_lock); return; } + m = g_queue.front(); + g_queue.pop_front(); + LeaveCriticalSection(&g_queue_lock); + } + drain_one(m); + if (g_quit) return; + } + } + + // Close semantics: APPWINDOW asks the client (APP_QUIT event) and, when + // allowed, hides the window + decrements the quit count; DIALOG re-enables + // its owner and unwinds a modal pump. Windows are hidden, not destroyed + // (see the file header note about the driver watchdog). + // + // This is the bookkeeping half - it asks the client nothing. The APP_QUIT veto + // belongs to handle_close_request alone, so the teardown path + // (platform_destroy_window) cannot re-enter the client mid-destroy. + static void close_window_silently(WindowData* w) + { + Session* s = w->session; + if (!s || w->closed) return; + auto* wd = s->get_widget(w->widget_index); + + w->closed = true; + ShowWindowAsync(w->hwnd, SW_HIDE); + if (!wd) return; + + if (wd->type && !strcmp(wd->type, NEUI_W_APPWINDOW)) { + if (--g_appwindow_count <= 0) g_quit = true; + return; + } + + // DIALOG (and PLUGWINDOW): for modal dialogs re-enable + refocus the owner + // and drop the nested pump so widget_show returns. Both USER32 calls block + // on the owner's thread, so they go through the deferring seams. + if (wd->is_dialog() && wd->owner_index != 0 && + s->_widgets.exists(wd->owner_index)) { + bool is_modal = !wd->attrs || wd->attrs->get_int(NEUI_ATTR_MODAL, 1) != 0; + void* owner_native = s->_widgets[wd->owner_index].native_handle; + if (is_modal && owner_native) { + platform_set_window_enabled(owner_native, true); + platform_activate_window(owner_native); + } + } + if (auto* fw = dynamic_cast(wd)) + fw->modal_pump_active = false; + } + + static void handle_close_request(WindowData* w) + { + Session* s = w->session; + if (!s || w->closed) return; + auto* wd = s->get_widget(w->widget_index); + if (!wd) return; + + if (wd->type && !strcmp(wd->type, NEUI_W_APPWINDOW)) { + neui_event_t ev = {}; + ev.type = NEUI_EVENT_APP_QUIT; + if (!s->dispatch_event(&ev)) return; // client vetoed the close + } + close_window_silently(w); + } + + // ------------------------------------------------------------------------- + // Platform seam: init / backend / windows / loop + + void platform_init() + { + if (g_inited) return; + g_inited = true; + InitializeCriticalSection(&g_queue_lock); + g_wake = CreateEventW(nullptr, FALSE, FALSE, nullptr); + read_retained_env(); + lv_init(); // also runs lv_windows_platform_init() under LV_USE_WINDOWS + // Let the backend guard the LVGL allocations it makes outside a draw + // dispatch (font instances / glyph caches from measure_text, path deletes + // in destroy_context) with this layer's re-entrant lock policy. + neui_lvgl_backend::set_lock_hooks(backend_lv_lock, backend_lv_unlock); + } + + neui_render_backend_t* platform_get_backend() + { + return neui_lvgl_backend::get_backend(); + } + + static std::wstring to_wide(const char* utf8) + { + if (!utf8 || !*utf8) return L""; + int n = MultiByteToWideChar(CP_UTF8, 0, utf8, -1, nullptr, 0); + if (n <= 0) return L""; + std::wstring out(static_cast(n - 1), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, utf8, -1, &out[0], n); + return out; + } + + static void create_lvgl_window(Session* session, uint32_t widget_index, + WidgetData& wd) + { + platform_init(); + + // NOTE: must NOT hold lv_lock() here - lv_windows_create_display blocks + // on its display thread's WM_CREATE, whose WndProc itself takes the lock. + lv_display_t* disp = lv_windows_create_display( + to_wide(wd.text.c_str()).c_str(), + wd.width, wd.height, + 100, // zoom + false, // allow_dpi_override: keep the driver's DPI bookkeeping + false); // application mode: client area == display resolution + if (!disp) return; + + LvLockGuard lock; // main thread, outside lv_timer_handler + + HWND hwnd = lv_windows_get_display_window_handle(disp); + + auto* w = new WindowData(); + w->session = session; + w->widget_index = widget_index; + w->display = disp; + w->hwnd = hwnd; + w->screen = lv_display_get_screen_active(disp); + g_windows.push_back(w); + + // Strip the theme style from the screen; neui paints the background. + lv_obj_remove_style_all(w->screen); + if (g_retained) { + // Option C: screen paints frame bg below the widget mirrors and the + // overlays above them; widgets are mirrored by sync_mirror_tree. + lv_obj_add_event_cb(w->screen, frame_bg_draw_cb, LV_EVENT_DRAW_MAIN, w); + lv_obj_add_event_cb(w->screen, frame_overlay_draw_cb, LV_EVENT_DRAW_POST, w); + w->tree_dirty = true; + } else { + // Approach A baseline: the whole frame paints into the screen object. + lv_obj_add_event_cb(w->screen, screen_draw_cb, LV_EVENT_DRAW_MAIN, w); + } + + // Input bridge: context prop + WndProc subclass (the window lives on the + // driver's thread; the subclass only enqueues). Read and store the chained + // proc BEFORE swapping: the window is already being pumped by the driver's + // own thread, so subclass_proc can run the instant the swap takes effect - + // and it tail-calls CallWindowProcW(w->prev_proc, ...). + w->prev_proc = reinterpret_cast( + GetWindowLongPtrW(hwnd, GWLP_WNDPROC)); + SetPropW(hwnd, L"neui.lvgl.wdata", w); + SetWindowLongPtrW(hwnd, GWLP_WNDPROC, + reinterpret_cast(subclass_proc)); + + wd.native_handle = hwnd; + wd.dpi = 96; // logical == LVGL px == physical (see header) + + auto* backend = platform_get_backend(); + if (backend) + wd.render_ctx = backend->create_context( + disp, static_cast(wd.width), static_cast(wd.height)); + + if (wd.type && !strcmp(wd.type, NEUI_W_APPWINDOW)) + ++g_appwindow_count; + } + + void platform_create_appwindow(Session* session, uint32_t widget_index, + WidgetData& wd) + { + create_lvgl_window(session, widget_index, wd); + } + + void platform_create_plugwindow(Session* session, uint32_t widget_index, + WidgetData& wd) + { + create_lvgl_window(session, widget_index, wd); + } + + void platform_create_dialog(Session* session, uint32_t widget_index, + WidgetData& wd, void* /*owner_native*/) + { + // Own display window; owner blocking happens via + // platform_set_window_enabled from widget_show. (Resizable in the + // prototype - the driver offers no per-window style control.) + create_lvgl_window(session, widget_index, wd); + } + + static WindowData* find_window(void* native_handle) + { + for (auto* w : g_windows) + if (w->hwnd == native_handle) return w; + return nullptr; + } + + // The driver owns the HWND + lv_display, and its watchdog exit(0)s the + // process if a display dies mid-loop (see the file header), so a destroyed + // frame keeps its window - hidden - for the process lifetime. Everything neui + // put on top of it does go away here: the retained mirror objects, the screen + // draw callbacks, the toast timer, the WndProc subclass, and any input still + // queued for this window. + // + // The WindowData itself is retired rather than freed: subclass_proc runs on + // the driver's thread and a call may already be in flight holding this + // pointer, with no way to join that thread. It is inert once `session` is + // null - drain_one and every draw callback bail on that. + static std::vector g_retired_windows; + + static void retire_window(WindowData* w) + { + // 1. Stop the display thread referencing it, then drop what it already + // queued (those InputMsgs would otherwise drain against a dead window). + if (w->hwnd) { + if (w->prev_proc) + SetWindowLongPtrW(w->hwnd, GWLP_WNDPROC, + reinterpret_cast(w->prev_proc)); + RemovePropW(w->hwnd, L"neui.lvgl.wdata"); + } + { + EnterCriticalSection(&g_queue_lock); + for (auto it = g_queue.begin(); it != g_queue.end();) + it = (it->w == w) ? g_queue.erase(it) : it + 1; + LeaveCriticalSection(&g_queue_lock); + } + + // 2. Release the LVGL objects neui created. Every mirror (and SECTION body) + // is a descendant of the screen, so one clean drops the whole tree; the + // MirrorRefs die with the map entries afterwards. + { + LvLockGuard lock; + if (w->toast_timer) { + lv_timer_delete(w->toast_timer); + w->toast_timer = nullptr; + } + // Same for the spring-back heartbeat: it holds this WindowData and would + // tick against a dead session on the next lv_timer_handler. + if (w->bounce_timer) { + lv_timer_delete(w->bounce_timer); + w->bounce_timer = nullptr; + } + w->bouncing_section = 0; + w->bouncing_grid = 0; + if (w->screen) { + lv_obj_clean(w->screen); + lv_obj_remove_event_cb(w->screen, screen_draw_cb); + lv_obj_remove_event_cb(w->screen, frame_bg_draw_cb); + lv_obj_remove_event_cb(w->screen, frame_overlay_draw_cb); + } + } + w->mirrors.clear(); + w->pending_widget_invals.clear(); + w->pending_invalidate = false; + + // 3. Out of g_windows so the per-turn walks skip it, and inert for anything + // that still holds the pointer. + w->session = nullptr; + w->screen = nullptr; + g_windows.erase(std::remove(g_windows.begin(), g_windows.end(), w), + g_windows.end()); + g_retired_windows.push_back(w); + } + + void platform_set_embed_parent(Session*, uint32_t, unsigned long) {} + int platform_embed_event_fd(void*) { return -1; } + void platform_embed_pump_and_tick(void*) {} + + void platform_destroy_window(WidgetData& wd) + { + WindowData* w = find_window(wd.native_handle); + if (w) { + // Programmatic destroy (client closing a dialog, or session teardown): + // run the close bookkeeping WITHOUT asking the client. handle_close_request + // would dispatch APP_QUIT into a client whose widgets are being destroyed, + // and a veto there would leave the window visible while the render context + // below is freed anyway. + close_window_silently(w); + retire_window(w); + } + if (wd.render_ctx) { + auto* backend = platform_get_backend(); + if (backend && wd.session) { + wd.session->_asset_manager.release_context(wd.render_ctx, backend); + backend->destroy_context(wd.render_ctx); + } + wd.render_ctx = nullptr; + } + wd.native_handle = nullptr; + } + + void platform_show_window(void* native_handle) + { + if (!native_handle) return; + ShowWindowAsync(static_cast(native_handle), SW_SHOW); + if (WindowData* w = find_window(native_handle)) { + w->closed = false; + if (g_retained) w->tree_dirty = true; // widgets created pre-show + invalidate_window(w); + } + } + + void platform_hide_window(void* native_handle) + { + if (native_handle) + ShowWindowAsync(static_cast(native_handle), SW_HIDE); + } + + // The four seams below all issue BLOCKING cross-thread USER32 calls - see the + // defer_if_locked note near the top of the file. Each captures its arguments + // by value so the deferred copy stays valid. + + void platform_set_window_enabled(void* native_handle, bool enabled) + { + HWND hwnd = static_cast(native_handle); + if (!hwnd) return; + if (defer_if_locked([hwnd, enabled] { + EnableWindow(hwnd, enabled ? TRUE : FALSE); + })) + return; + EnableWindow(hwnd, enabled ? TRUE : FALSE); + } + + void platform_activate_window(void* native_handle) + { + HWND hwnd = static_cast(native_handle); + if (!hwnd) return; + if (defer_if_locked([hwnd] { SetForegroundWindow(hwnd); })) return; + SetForegroundWindow(hwnd); + } + + void platform_set_window_title(void* native_handle, const char* text) + { + HWND hwnd = static_cast(native_handle); + if (!hwnd) return; + std::wstring wide = to_wide(text ? text : ""); + if (defer_if_locked([hwnd, wide] { SetWindowTextW(hwnd, wide.c_str()); })) + return; + SetWindowTextW(hwnd, wide.c_str()); + } + + void platform_set_window_pos(void* native_handle, + int x, int y, int w, int h, uint32_t /*dpi*/) + { + // Reposition only; resizing must go through the driver's WM_SIZE path, + // which our subclass sees and forwards. Sizes here are the client area, + // so grow by the current non-client frame. + HWND hwnd = static_cast(native_handle); + if (!hwnd) return; + if (defer_if_locked([hwnd, x, y, w, h] { + platform_set_window_pos(hwnd, x, y, w, h, 96); + })) + return; + RECT wr = { 0, 0, w, h }; + DWORD style = static_cast(GetWindowLongPtrW(hwnd, GWL_STYLE)); + DWORD ex_style = static_cast(GetWindowLongPtrW(hwnd, GWL_EXSTYLE)); + AdjustWindowRectEx(&wr, style, FALSE, ex_style); + SetWindowPos(hwnd, nullptr, x, y, wr.right - wr.left, wr.bottom - wr.top, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + void platform_post_close(void* native_handle) + { + if (native_handle) + PostMessageW(static_cast(native_handle), WM_CLOSE, 0, 0); + } + + void platform_invalidate(void* native_handle) + { + invalidate_window(find_window(native_handle)); + } + + // Toast heartbeat: a 16 ms lv_timer that invalidates the frame; the paint + // pass advances the toast phase exactly like the other platforms. + static void toast_timer_cb(lv_timer_t* t) + { + auto* w = static_cast(lv_timer_get_user_data(t)); + ++t_inside_lv; // lv_timer_handler already holds the LVGL lock + if (w && w->screen) lv_obj_invalidate(w->screen); + --t_inside_lv; + } + + void platform_start_toast_animation(void* native_handle) + { + WindowData* w = find_window(native_handle); + if (!w) return; + LvLockGuard lock; + if (w->toast_timer) lv_timer_reset(w->toast_timer); + else w->toast_timer = lv_timer_create(toast_timer_cb, 16, w); + } + + void platform_stop_toast_animation(void* native_handle) + { + WindowData* w = find_window(native_handle); + if (!w || !w->toast_timer) return; + LvLockGuard lock; + lv_timer_delete(w->toast_timer); + w->toast_timer = nullptr; + } + + uint64_t platform_now_ms() + { + return GetTickCount64(); + } + + int platform_message_box(void*, const char*, const char*, uint32_t) + { + return 0; // prototype stub + } + + float platform_get_scale_factor(void*) + { + return 1.0f; + } + + uint8_t* platform_load_image(const char* path, + uint32_t* width_out, uint32_t* height_out) + { + return neui_detail::load_image_bgra8_stb(path, width_out, height_out); + } + + uint8_t* platform_load_image_bytes(const uint8_t* data, size_t len, + uint32_t* width_out, uint32_t* height_out) + { + return neui_detail::load_image_bgra8_stb_memory(data, len, + width_out, height_out); + } + + void platform_free_image(uint8_t* pixels) + { + neui_detail::free_image_bgra8_stb(pixels); + } + + // One main-loop turn: drain queued input, run LVGL timers/refresh, wait for + // the next wake (input arrival or timer deadline). + // Free the backends' per-draw deferred resources once the refresh that + // consumed them has finished (lv_timer_handler returned). + static void collect_deferred_all() + { + for (auto* w : g_windows) { + if (!w->session) continue; + if (auto* fwd = w->session->get_widget(w->widget_index)) + if (fwd->render_ctx) + neui_lvgl_backend::collect_deferred(fwd->render_ctx); + } + } + + static void loop_turn() + { + drain_input(); + if (g_quit) return; + sync_dirty_trees(); + uint32_t wait = lv_timer_handler(); + collect_deferred_all(); + sync_dirty_trees(); // a draw dispatch may have marked dirt + flush_pending_invalidates(); + run_deferred_calls(); // blocking USER32 work a draw dispatch queued + if (g_quit) return; + if (wait == LV_NO_TIMER_READY) wait = 10; + else if (wait > 10) wait = 10; + if (wait) WaitForSingleObject(g_wake, wait); + } + + bool platform_run() + { + while (!g_quit) + loop_turn(); + return true; + } + + bool platform_pump_once() + { + if (g_quit) return false; + drain_input(); + if (!g_quit) { + sync_dirty_trees(); + lv_timer_handler(); + collect_deferred_all(); + sync_dirty_trees(); + flush_pending_invalidates(); + run_deferred_calls(); + } + return !g_quit; + } + + bool platform_run_modal_until(bool* keep_running) + { + if (!keep_running) return true; + while (*keep_running && !g_quit) + loop_turn(); + return !g_quit; + } + + // ------------------------------------------------------------------------- + // Clipboard / DnD / menubar / polish - prototype stubs (compiled out per + // the plan; neui degrades gracefully, same as the null platform). + + bool platform_clipboard_set_text(const char*, uint32_t) { return false; } + int platform_clipboard_get_text(char*, int) { return 0; } + bool platform_clipboard_has_text() { return false; } + void platform_clipboard_set_primary(const char*, uint32_t) {} + int platform_clipboard_get_primary(char*, int) { return 0; } + bool platform_clipboard_write_item(const neui_detail::DataItem&) { return false; } + bool platform_clipboard_read_item(neui_detail::DataItem&) { return false; } + + bool platform_dnd_register_window(void*, void*, uint32_t) { return false; } + void platform_dnd_unregister_window(void*) {} + uint32_t platform_dnd_begin_drag(void*, neui_detail::DataItem*, uint32_t, + void*, int, int) { return 0; } + void* platform_make_drag_preview(const uint8_t*, uint32_t, uint32_t, float) + { return nullptr; } + + bool platform_menubar_in_frame() { return false; } + int platform_frame_extra_top_inset(void*, bool) { return 0; } + void* platform_menubar_create(uint32_t) { return nullptr; } + void platform_menubar_destroy(void*) {} + void platform_menubar_attach(void*, void*) {} + void platform_menubar_refresh(void*) {} + void* platform_menubar_add_popup(void*, const char*) { return nullptr; } + void platform_menubar_add_item(void*, uint32_t, const char*) {} + void platform_menubar_add_separator(void*, uint32_t) {} + void platform_menubar_remove_popup(void*, void*) {} + void platform_menubar_remove_item(void*, uint32_t) {} + void platform_menubar_enable_item(void*, uint32_t, bool) {} + void platform_menubar_enable_popup(void*, void*, bool) {} + void platform_menubar_check_item(void*, uint32_t, bool) {} + void platform_menubar_set_item_text(void*, uint32_t, const char*) {} + void platform_menubar_set_item_shortcut(void*, uint32_t, uint32_t, uint32_t) {} + + void platform_set_window_icon(WidgetData&, const char*) {} + void platform_apply_size_constraints(void*, int, int, int, int) {} + void platform_set_cursor(int) {} + +} // namespace xpl_host diff --git a/hosts/crossplatform/platform_macos.mm b/hosts/crossplatform/platform_macos.mm index 2d2ae3d..3dc21bd 100644 --- a/hosts/crossplatform/platform_macos.mm +++ b/hosts/crossplatform/platform_macos.mm @@ -1919,6 +1919,13 @@ void platform_apply_size_constraints(void* native_handle, return neui_detail::load_image_bgra8_macos(path, width_out, height_out); } + uint8_t* platform_load_image_bytes(const uint8_t* data, size_t len, + uint32_t* width_out, uint32_t* height_out) + { + return neui_detail::load_image_bgra8_macos_memory(data, len, + width_out, height_out); + } + void platform_free_image(uint8_t* pixels) { delete[] pixels; } // ------------------------------------------------------------------------- diff --git a/hosts/crossplatform/platform_null.cpp b/hosts/crossplatform/platform_null.cpp index fb47215..cad9231 100644 --- a/hosts/crossplatform/platform_null.cpp +++ b/hosts/crossplatform/platform_null.cpp @@ -94,6 +94,13 @@ namespace xpl_host return nullptr; } + uint8_t* platform_load_image_bytes(const uint8_t* /*data*/, size_t /*len*/, + uint32_t* /*width_out*/, + uint32_t* /*height_out*/) + { + return nullptr; // no decoder on the null platform + } + void platform_free_image(uint8_t* /*pixels*/) {} // System clipboard - no-ops on platforms without one. diff --git a/hosts/crossplatform/platform_win32.cpp b/hosts/crossplatform/platform_win32.cpp index 1652a47..d80355e 100644 --- a/hosts/crossplatform/platform_win32.cpp +++ b/hosts/crossplatform/platform_win32.cpp @@ -1957,6 +1957,13 @@ namespace xpl_host return neui_detail::load_image_bgra8_w32(path, width_out, height_out); } + uint8_t* platform_load_image_bytes(const uint8_t* data, size_t len, + uint32_t* width_out, uint32_t* height_out) + { + return neui_detail::load_image_bgra8_w32_memory(data, len, + width_out, height_out); + } + void platform_free_image(uint8_t* pixels) { neui_detail::free_image_bgra8_w32(pixels); diff --git a/hosts/crossplatform/widgets.cpp b/hosts/crossplatform/widgets.cpp index 6360997..3c7ee07 100644 --- a/hosts/crossplatform/widgets.cpp +++ b/hosts/crossplatform/widgets.cpp @@ -1,6 +1,5 @@ #include #include -#include #include #include #include @@ -114,6 +113,31 @@ namespace xpl_host s->_widgets.remove(idx); } + // ------------------------------------------------------------------------- + // Invalidation helpers. On the LVGL retained platform a content change + // invalidates only that widget's mirror object and a structural change + // re-syncs the mirror tree; every other platform keeps the historical + // whole-frame invalidate. + + static void invalidate_widget_visual(Session* s, uint32_t idx) + { +#ifdef NEUI_PLATFORM_LVGL + platform_retained_widget_invalidate(s, idx); +#else + if (void* frame = s->find_parent_native_handle(idx)) + platform_invalidate(frame); +#endif + } + + static void notify_tree_changed(Session* s, uint32_t idx) + { +#ifdef NEUI_PLATFORM_LVGL + platform_retained_tree_changed(s, idx); +#else + (void)s; (void)idx; +#endif + } + // ------------------------------------------------------------------------- // Widget factory - creates the right derived type for each widget type string. @@ -241,6 +265,8 @@ namespace xpl_host s->_menubars.push_back(idx); } + notify_tree_changed(s, idx); + return IndexToWidget(s->_session_id, idx); } @@ -277,6 +303,9 @@ namespace xpl_host client->get_interface(token, NEUI_API_WIDGETS)); } } + // Retained mirrors: mark BEFORE the subtree is removed so the hook can + // still resolve the owning frame through the ancestor chain. + notify_tree_changed(s, idx); destroy_recursive(s, idx, client_api, token); // Re-clamp the selection + re-apply page visibility/geometry, then repaint @@ -362,6 +391,7 @@ namespace xpl_host } } else { wd.visible = true; + notify_tree_changed(s, idx); } } @@ -373,10 +403,12 @@ namespace xpl_host if (!s->_widgets.exists(idx)) return; auto& wd = s->_widgets[idx]; - if (wd.is_frame() && wd.native_handle) + if (wd.is_frame() && wd.native_handle) { platform_hide_window(wd.native_handle); - else + } else { wd.visible = false; + notify_tree_changed(s, idx); + } } static void NEUI_ABI w_set_pos(neui_session_t session, neui_widget_t widget, @@ -392,6 +424,8 @@ namespace xpl_host if (wd.is_frame() && wd.native_handle) platform_set_window_pos(wd.native_handle, x, y, width, height, wd.dpi); + else + notify_tree_changed(s, idx); } static void NEUI_ABI w_set_size(neui_session_t session, neui_widget_t widget, @@ -407,6 +441,8 @@ namespace xpl_host if (wd.is_frame() && wd.native_handle) platform_set_window_pos(wd.native_handle, wd.x, wd.y, width, height, wd.dpi); + else + notify_tree_changed(s, idx); } static void NEUI_ABI w_set_emit_events(neui_session_t session, @@ -450,8 +486,7 @@ namespace xpl_host // showing the old text until some unrelated event forces a paint. For a // top-level frame this returns nullptr (no parent HWND) and no-ops, which // is correct - the title bar was already updated above. - if (void* frame = s->find_parent_native_handle(idx)) - platform_invalidate(frame); + invalidate_widget_visual(s, idx); } // Forward decls: COMPONENT attach helper + the one-call instantiate thunk. @@ -511,8 +546,7 @@ namespace xpl_host } // Trigger a repaint via the owning frame. - if (void* frame = s->find_parent_native_handle(idx)) - platform_invalidate(frame); + invalidate_widget_visual(s, idx); } static int NEUI_ABI w_get_text(neui_session_t session, @@ -621,8 +655,7 @@ namespace xpl_host if (!enabled && s->_focused_widget == idx) s->focus_next(true); // Repaint so the dim alpha bracket picks up the new state. - if (void* frame = s->find_parent_native_handle(idx)) - platform_invalidate(frame); + invalidate_widget_visual(s, idx); } static bool NEUI_ABI w_get_enabled(neui_session_t session, @@ -697,9 +730,9 @@ namespace xpl_host if (!s->_widgets.exists(idx)) return; // Map any widget invalidation to a frame-level repaint - on the xpl // host the entire frame paints in one pass, so per-widget invalidation - // would have to invalidate the frame anyway. - if (void* frame = s->find_parent_native_handle(idx)) - platform_invalidate(frame); + // would have to invalidate the frame anyway. (The LVGL retained platform + // narrows this to the widget's mirror object.) + invalidate_widget_visual(s, idx); } neui_widget_api_t widgets_api = { @@ -764,10 +797,8 @@ namespace xpl_host // NEUI_ATTR_TRISTATE, NEUI_ATTR_STEPS etc.), so a runtime change has // to invalidate the owning frame. Frames handle their own side // effects above (size constraints). - if (!wd.is_frame()) { - if (void* frame = s->find_parent_native_handle(idx)) - platform_invalidate(frame); - } + if (!wd.is_frame()) + invalidate_widget_visual(s, idx); return 1; } @@ -806,10 +837,8 @@ namespace xpl_host // change has to invalidate the owning frame. Frames go through the // icon_path branch above for their one live-update key; everything // else just invalidates the parent frame. - if (!wd.is_frame()) { - if (void* frame = s->find_parent_native_handle(idx)) - platform_invalidate(frame); - } + if (!wd.is_frame()) + invalidate_widget_visual(s, idx); return 1; } @@ -850,10 +879,8 @@ namespace xpl_host // clearing a bound {token} or a value on a compound widget), so // invalidate the owning frame - mirroring the a_set_* path above. // Without this the widget shows stale pixels until an unrelated repaint. - if (!wd.is_frame()) { - if (void* frame = s->find_parent_native_handle(idx)) - platform_invalidate(frame); - } + if (!wd.is_frame()) + invalidate_widget_visual(s, idx); return 1; } @@ -885,10 +912,8 @@ namespace xpl_host // SLIDER, NEUI_ATTR_ROTATION on IMAGE, etc.). Invalidate the owning // frame so the next paint pulls fresh values. Frames don't currently // read any float attr in paint, but skip them for symmetry. - if (!wd.is_frame()) { - if (void* frame = s->find_parent_native_handle(idx)) - platform_invalidate(frame); - } + if (!wd.is_frame()) + invalidate_widget_visual(s, idx); return 1; } @@ -2267,6 +2292,22 @@ namespace xpl_host for (auto a : built.owned_assets) as_destroy(session, a); } + // ComponentApis::bitmap_from_name - see component_loader.h. A layer asset named + // in a component document reaches the client resource provider as the raw + // "assets" entry plus that document's base_dir, which the public path-taking + // create_from_file cannot express; the store joins them for its own filesystem + // fallback. + static neui_asset_t component_bitmap_from_name(void* user, const char* name, + const char* base_dir) + { + auto* s = static_cast(user); + if (!s || !name) return asset_none; + uint32_t slot = s->_asset_manager.allocate_from_file( + name, best_asset_scale(s), base_dir); + if (slot == 0) return asset_none; + return pack_asset(s->_session_id, slot); + } + static neui_asset_t NEUI_ABI as_create_component_from_string( neui_session_t session, const char* json, uint32_t len, const neui_component_env_t* env) @@ -2277,6 +2318,8 @@ namespace xpl_host apis.asset = &asset_api; apis.compound = &compound_api; apis.behavior = &behavior_api; + apis.bitmap_from_name = component_bitmap_from_name; + apis.user = s; neui_detail::BuiltComponent built = neui_detail::build_component(session, json, len, env, apis); if (!built.ok) { release_built_component(session, built); return asset_none; } @@ -2292,11 +2335,11 @@ namespace xpl_host auto* s = get_session(session); if (!s || !path_utf8) return asset_none; - // Read the whole file (std::ifstream avoids the fopen /W4 deprecation). - std::ifstream in(path_utf8, std::ios::binary); - if (!in) return asset_none; - std::string data((std::istreambuf_iterator(in)), - std::istreambuf_iterator()); + // Client resource provider first, then the file (shared read-or-ask helper). + std::string data; + if (!s->_asset_manager.resource_provider().read_bytes( + NEUI_RESOURCE_KIND_COMPONENT, path_utf8, data)) + return asset_none; // Default env.base_dir to the file's directory so relative asset paths // resolve next to the .json. A caller-supplied env wins. @@ -3034,8 +3077,7 @@ namespace xpl_host static void grid_invalidate(Session* s, GridWidget* g) { if (!s || !g) return; - void* frame = s->find_parent_native_handle(g->index); - if (frame) platform_invalidate(frame); + invalidate_widget_visual(s, g->index); } static int NEUI_ABI gr_add_column(neui_session_t session, neui_widget_t widget, diff --git a/hosts/ios/asset_manager_ios.h b/hosts/ios/asset_manager_ios.h index c1940d6..f73abd6 100644 --- a/hosts/ios/asset_manager_ios.h +++ b/hosts/ios/asset_manager_ios.h @@ -21,6 +21,9 @@ namespace ios_host { static uint8_t* load(const char* path, uint32_t* w_px, uint32_t* h_px) { return neui_detail::load_image_bgra8_ios(path, w_px, h_px); } + static uint8_t* load_memory(const uint8_t* data, size_t len, + uint32_t* w_px, uint32_t* h_px) + { return neui_detail::load_image_bgra8_ios_memory(data, len, w_px, h_px); } static void free_pixels(uint8_t* p) { neui_detail::free_image_bgra8(p); } }; diff --git a/hosts/ios/host.h b/hosts/ios/host.h index bdbb3ee..d2d04ea 100644 --- a/hosts/ios/host.h +++ b/hosts/ios/host.h @@ -285,6 +285,11 @@ namespace ios_host // Optional grid-cell-edit validation callback (fetched once; unused in v1 // since GRID is stubbed). neui_grid_client_t* _grid_client = nullptr; + + // Optional client resource provider (NEUI_API_RESOURCE_CLIENT). Asked for + // bytes before this host tries the bundle / disk. The live binding used by + // the load paths is _asset_manager.resource_provider(). + neui_resource_client_t* _resource_client = nullptr; }; // Process-wide session registry (defined in host.mm). diff --git a/hosts/ios/host.mm b/hosts/ios/host.mm index c0e4221..bd20fd1 100644 --- a/hosts/ios/host.mm +++ b/hosts/ios/host.mm @@ -54,6 +54,17 @@ _client->get_interface(token, NEUI_API_WIDGETS)); _grid_client = static_cast( _client->get_interface(token, NEUI_API_GRID_CLIENT)); + + // Opt-in client resource provider: asked for image / font / component / + // sidecar bytes before this host tries the bundle or the disk. + _resource_client = static_cast( + _client->get_interface(token, NEUI_API_RESOURCE_CLIENT)); + if (_resource_client) { + neui_detail::ResourceProvider provider; + provider.client = _resource_client; + provider.token = token; + _asset_manager.set_resource_provider(provider); + } } } diff --git a/hosts/ios/widgets.mm b/hosts/ios/widgets.mm index 430bcfd..5ed3715 100644 --- a/hosts/ios/widgets.mm +++ b/hosts/ios/widgets.mm @@ -25,7 +25,6 @@ #include #include -#include namespace ios_host { @@ -1620,6 +1619,22 @@ static void release_built_component_ios(neui_session_t session, for (auto a : built.owned_assets) as_destroy(session, a); } + // ComponentApis::bitmap_from_name - see component_loader.h. A layer asset named + // in a component document reaches the client resource provider as the raw + // "assets" entry plus that document's base_dir, which the public path-taking + // create_from_file cannot express; the store joins them for its own filesystem + // fallback. + static neui_asset_t component_bitmap_from_name(void* user, const char* name, + const char* base_dir) + { + auto* s = static_cast(user); + if (!s || !name) return asset_none; + float scale = (float)UIScreen.mainScreen.scale; + if (scale <= 0) scale = 1.0f; + uint32_t slot = s->_asset_manager.allocate_from_file(name, scale, base_dir); + return slot ? pack_asset_ios(s->session_id(), slot) : asset_none; + } + static neui_asset_t NEUI_ABI as_create_component_from_string( neui_session_t session, const char* json, uint32_t len, const neui_component_env_t* env) @@ -1630,6 +1645,8 @@ static neui_asset_t NEUI_ABI as_create_component_from_string( apis.asset = &asset_api; apis.compound = &compound_api; apis.behavior = &behavior_api; + apis.bitmap_from_name = component_bitmap_from_name; + apis.user = s; neui_detail::BuiltComponent built = neui_detail::build_component(session, json, len, env, apis); if (!built.ok) { release_built_component_ios(session, built); return asset_none; } @@ -1644,10 +1661,11 @@ static neui_asset_t NEUI_ABI as_create_component_from_file( { auto* s = get_session(session); if (!s || !path_utf8) return asset_none; - std::ifstream in(path_utf8, std::ios::binary); - if (!in) return asset_none; - std::string data((std::istreambuf_iterator(in)), - std::istreambuf_iterator()); + // Client resource provider first, then the file (shared read-or-ask helper). + std::string data; + if (!s->_asset_manager.resource_provider().read_bytes( + NEUI_RESOURCE_KIND_COMPONENT, path_utf8, data)) + return asset_none; neui_component_env_t local{}; const neui_component_env_t* use_env = env; static thread_local std::string base_keep; diff --git a/hosts/macos/asset_manager_macos.h b/hosts/macos/asset_manager_macos.h index 39194f6..3e62b52 100644 --- a/hosts/macos/asset_manager_macos.h +++ b/hosts/macos/asset_manager_macos.h @@ -21,6 +21,9 @@ namespace macos_host { static uint8_t* load(const char* path, uint32_t* w_px, uint32_t* h_px) { return neui_detail::load_image_bgra8_macos(path, w_px, h_px); } + static uint8_t* load_memory(const uint8_t* data, size_t len, + uint32_t* w_px, uint32_t* h_px) + { return neui_detail::load_image_bgra8_macos_memory(data, len, w_px, h_px); } static void free_pixels(uint8_t* p) { neui_detail::free_image_bgra8(p); } }; diff --git a/hosts/macos/host.h b/hosts/macos/host.h index b270c2a..29959c2 100644 --- a/hosts/macos/host.h +++ b/hosts/macos/host.h @@ -285,6 +285,11 @@ namespace macos_host // Fetched once at session create time; called when the user commits a // grid in-place cell edit. neui_grid_client_t* _grid_client = nullptr; + + // Optional client resource provider (NEUI_API_RESOURCE_CLIENT). Asked for + // bytes before this host tries the bundle / disk. The live binding used by + // the load paths is _asset_manager.resource_provider(). + neui_resource_client_t* _resource_client = nullptr; }; // Process-wide session registry (defined in host.mm). Slot index + 1 is diff --git a/hosts/macos/host.mm b/hosts/macos/host.mm index 72b269a..be205bd 100644 --- a/hosts/macos/host.mm +++ b/hosts/macos/host.mm @@ -42,6 +42,17 @@ _client->get_interface(token, NEUI_API_WIDGETS)); _grid_client = static_cast( _client->get_interface(token, NEUI_API_GRID_CLIENT)); + + // Opt-in client resource provider: asked for image / font / component / + // sidecar bytes before this host tries the bundle or the disk. + _resource_client = static_cast( + _client->get_interface(token, NEUI_API_RESOURCE_CLIENT)); + if (_resource_client) { + neui_detail::ResourceProvider provider; + provider.client = _resource_client; + provider.token = token; + _asset_manager.set_resource_provider(provider); + } } } diff --git a/hosts/macos/widgets.mm b/hosts/macos/widgets.mm index d371dbf..73a8c2e 100644 --- a/hosts/macos/widgets.mm +++ b/hosts/macos/widgets.mm @@ -9,7 +9,6 @@ #import -#include #include #include "host.h" #include "checkbox_image.h" @@ -2417,6 +2416,22 @@ static void release_built_component_macos(neui_session_t session, for (auto a : built.owned_assets) a_destroy(session, a); } + // ComponentApis::bitmap_from_name - see component_loader.h. A layer asset named + // in a component document reaches the client resource provider as the raw + // "assets" entry plus that document's base_dir, which the public path-taking + // create_from_file cannot express; the store joins them for its own filesystem + // fallback. + static neui_asset_t component_bitmap_from_name(void* user, const char* name, + const char* base_dir) + { + auto* s = static_cast(user); + if (!s || !name) return asset_none; + uint32_t slot = s->_asset_manager.allocate_from_file( + name, best_asset_scale_macos(), base_dir); + if (slot == 0) return asset_none; + return pack_asset_macos(s->session_id(), slot); + } + static neui_asset_t NEUI_ABI a_create_component_from_string( neui_session_t session, const char* json, uint32_t len, const neui_component_env_t* env) @@ -2427,6 +2442,8 @@ static neui_asset_t NEUI_ABI a_create_component_from_string( apis.asset = &asset_api; apis.compound = &compound_api; apis.behavior = &behavior_api; + apis.bitmap_from_name = component_bitmap_from_name; + apis.user = s; neui_detail::BuiltComponent built = neui_detail::build_component(session, json, len, env, apis); if (!built.ok) { release_built_component_macos(session, built); return asset_none; } @@ -2441,10 +2458,11 @@ static neui_asset_t NEUI_ABI a_create_component_from_file( { auto* s = get_session(session); if (!s || !path_utf8) return asset_none; - std::ifstream in(path_utf8, std::ios::binary); - if (!in) return asset_none; - std::string data((std::istreambuf_iterator(in)), - std::istreambuf_iterator()); + // Client resource provider first, then the file (shared read-or-ask helper). + std::string data; + if (!s->_asset_manager.resource_provider().read_bytes( + NEUI_RESOURCE_KIND_COMPONENT, path_utf8, data)) + return asset_none; neui_component_env_t local{}; const neui_component_env_t* use_env = env; static thread_local std::string base_keep; diff --git a/hosts/shared/asset_store.h b/hosts/shared/asset_store.h index 75707ac..f6ae2d6 100644 --- a/hosts/shared/asset_store.h +++ b/hosts/shared/asset_store.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -17,6 +18,7 @@ #include "filmstrip_recognize.h" // FilmstripLayout + sidecar/filename discovery #include "image_filter.h" // image_gaussian_blur_bgra #include "filter_graph.h" // FilterAsset + evaluate_filter (SVG fe* engine) +#include "resource_provider.h" // ResourceProvider (NEUI_API_RESOURCE_CLIENT) // Session-scoped asset slot table shared by all three hosts. Each host // previously carried a near-identical manager (xpl AssetManager, win32 @@ -29,6 +31,10 @@ // // Decode `path` into a heap BGRA8-premultiplied buffer; null on // // failure. Ownership returns to free_pixels. // static uint8_t* load(const char* path, uint32_t* w_px, uint32_t* h_px); +// // Same, from encoded bytes already in memory - the client resource +// // provider path (NEUI_API_RESOURCE_CLIENT) has no path to hand over. +// static uint8_t* load_memory(const uint8_t* data, size_t len, +// uint32_t* w_px, uint32_t* h_px); // static void free_pixels(uint8_t* p); // }; // @@ -164,31 +170,202 @@ namespace neui_detail return alloc_slot(std::move(entry)); } + // --- Image source resolution (cached) ---------------------------------- + // + // Where a (name, scale) pair's bytes actually come from. CACHED, including + // misses, because the callers sit on the paint path: the path-keyed tier + // backing NEUI_W_IMAGE resolves once per IMAGE widget per frame, and + // resolution probes the @Nx candidate ladder by *decoding* each candidate + // (resolve_path below). Before this cache existed an IMAGE widget paid a + // full image decode every single frame purely to answer "which variant?". + // + // Keyed on the scale BUCKET rather than the raw scale, because that is all + // resolve_path's candidate order depends on - so 1.25 / 1.5 / 1.75 all share + // one entry. + // + // Negative results are sticky (v0 decision), with three ways out: + // * a scale that lands in a DIFFERENT band resolves independently. Note + // this is genuinely narrower than "a DPI change re-resolves": 125% and + // 200% are both band 1, so moving between those two displays reuses the + // cached outcome. + // * an explicit allocate_from_file (create_from_file and friends - a + // client-initiated load, not a per-frame resolve) RE-PROBES a cached + // miss, so a resource published late still appears. The per-frame + // path-keyed tier deliberately does not. + // * clear_image_routes() / clear() drop them outright. + struct ImageRoute { + bool found = false; + // Bytes come from the client resource provider rather than the + // filesystem (see probe_image_route). + bool from_client = false; + // The resource name exactly as it was asked for: the string to re-ask the + // provider with, and the base name the @Nx ladder resolves from. + std::string name; + // Resolved @Nx filesystem variant. Empty on a client route - those bytes + // have no path, which is why `name` is kept separately. + std::string file_path; + // The component document's base_dir when this name came from one, else + // empty. Passed to the provider as neui_resource_request_t::base_dir and + // joined onto `name` for the filesystem ladder - the whole point being that + // the client sees the raw name it wrote plus the directory separately, + // never a pre-joined path (). + std::string base_dir; + // Stable key for derived path-keyed caches: the resolved path for a file + // route, a synthetic per-band key for a client route (see + // client_cache_key - the band has to be in it). + std::string cache_key; + // HiDPI factor of what decode_route yields. + float scale = 1.0f; + // The display scale this route was resolved FOR - the scale_hint to re-ask + // the provider with, so the fetch sees the same request the probe did. + float req_scale = 1.0f; + }; + + // resolve_path's candidate order depends only on which band `scale` is in. + static int scale_bucket(float scale) + { + return scale > 2.0f ? 2 : (scale > 1.0f ? 1 : 0); + } + + // `refresh_miss` re-probes an entry cached as a MISS (see the sticky-negative + // note above); a cached HIT is always reused. `base_dir` is the component + // document a name came from, if any - it scopes the cache entry, so the same + // asset name in two documents resolves independently. + const ImageRoute& image_route(const std::string& name, float scale, + bool refresh_miss = false, + const char* base_dir = nullptr) + { + static const ImageRoute k_no_route; + if (name.empty()) return k_no_route; + if (scale <= 0.0f) scale = 1.0f; + + const std::string dir = base_dir ? base_dir : ""; + std::string key = route_key(name, scale_bucket(scale), dir); + auto it = _routes.find(key); + if (it != _routes.end()) { + if (it->second.found || !refresh_miss) return it->second; + it->second = probe_image_route(name, scale, dir); + return it->second; + } + return _routes.emplace(std::move(key), probe_image_route(name, scale, dir)) + .first->second; + } + + void clear_image_routes() + { + _routes.clear(); + _probe = ProbePixels{}; + } + + // Installed by the host at session-create time from + // client->get_interface(NEUI_API_RESOURCE_CLIENT). Absent (the default), + // every resolution below behaves exactly as it did before the interface + // existed. + void set_resource_provider(const ResourceProvider& p) + { + _provider = p; + clear_image_routes(); // cached misses predate the provider + } + const ResourceProvider& resource_provider() const { return _provider; } + + // Decode whatever `route` designates into `out_px` (BGRA8 premultiplied), + // reporting the pixel size and - in *scale_out - the HiDPI factor of what was + // ACTUALLY decoded. On a client route that is the scale the provider declares + // on THIS call, which need not be the one the probe saw: a provider is free + // to answer the same request with a different variant, and the entry must + // record the scale of the pixels it is holding or the bitmap draws at the + // wrong logical size. Returns false on failure (out_px left empty). + bool decode_route(const ImageRoute& route, std::vector& out_px, + uint32_t* w_px, uint32_t* h_px, float* scale_out) + { + out_px.clear(); + if (!route.found) return false; + + uint32_t w = 0, h = 0; + float sc = route.scale; + + if (_probe.valid && _probe.key == route.cache_key) { + // The probe already decoded exactly these pixels - take them rather + // than pay a second decode (and, on a client route, a second provide). + out_px = std::move(_probe.pixels); + w = _probe.w_px; + h = _probe.h_px; + sc = _probe.scale; + _probe = ProbePixels{}; + } else if (route.from_client) { + const bool ok = _provider.with_bytes( + NEUI_RESOURCE_KIND_IMAGE, route.name.c_str(), route.req_scale, + route.base_dir.empty() ? nullptr : route.base_dir.c_str(), + [&](const uint8_t* data, uint32_t len, float s) { + uint32_t dw = 0, dh = 0; + uint8_t* raw = Loader::load_memory(data, len, &dw, &dh); + if (!adopt_pixels(raw, dw, dh, out_px)) return false; + w = dw; h = dh; sc = s; + return true; + }); + if (!ok) { + // The provider validated once at probe time but has now declined, or + // handed over bytes that no longer decode. Fall back to the filesystem + // instead of failing the load for the rest of the session: "a buggy + // provider cannot shadow a good file" has to hold for every load, not + // just for the one probe that decided the route. + out_px.clear(); + DecodedImage file_px; + const std::string fs_name = fs_name_of(route.name, route.base_dir); + const std::string fallback = resolve_path(fs_name, route.req_scale, + &file_px); + if (fallback.empty() || file_px.pixels.empty()) return false; + out_px = std::move(file_px.pixels); + w = file_px.w_px; + h = file_px.h_px; + sc = scale_of_resolved(fs_name, fallback); + } + } else { + uint32_t dw = 0, dh = 0; + uint8_t* raw = Loader::load(route.file_path.c_str(), &dw, &dh); + if (!adopt_pixels(raw, dw, dh, out_px)) return false; + w = dw; h = dh; + } + + if (w_px) *w_px = w; + if (h_px) *h_px = h; + if (scale_out) *scale_out = sc; + return true; + } + // Allocate a slot from a file path (resolves @2x / @3x variants when // the requested scale > 1.0). Returns 0 on failure. - uint32_t allocate_from_file(const std::string& name, float scale) + // + // `base_dir` is for ONE caller: the component loader's byte hook + // (ComponentApis::bitmap_from_name). A name out of a component document's + // "assets" map must reach the client resource provider raw, with the + // document's directory alongside it rather than joined on - see + // . The filesystem fallback below joins them itself, so + // this is equivalent to the old create_from_file(join_path(dir, name)) when + // no provider answers. + uint32_t allocate_from_file(const std::string& name, float scale, + const char* base_dir = nullptr) { if (name.empty()) return 0; if (scale <= 0.0f) scale = 1.0f; - std::string resolved = resolve_path(name, scale); - if (resolved.empty()) return 0; + // refresh_miss: this is an explicit, client-initiated load, so a resource + // that was missing earlier gets another chance (see the sticky-negative + // note on ImageRoute). The per-frame tier in the xpl AssetManager does not + // pass it. + const ImageRoute& route = image_route(name, scale, /*refresh_miss=*/true, + base_dir); + if (!route.found) return 0; + auto entry = std::make_unique(); uint32_t w_px = 0, h_px = 0; - uint8_t* raw = Loader::load(resolved.c_str(), &w_px, &h_px); - if (!raw || w_px == 0 || h_px == 0) { - Loader::free_pixels(raw); - return 0; - } + float got_scale = route.scale; + if (!decode_route(route, entry->pixels, &w_px, &h_px, &got_scale)) return 0; - auto entry = std::make_unique(); entry->kind = NEUI_ASSET_KIND_BITMAP; entry->width_px = w_px; entry->height_px = h_px; - entry->scale = scale_of_resolved(name, resolved); - entry->pixels.assign(raw, raw + static_cast(w_px) * h_px * 4); - Loader::free_pixels(raw); - + entry->scale = got_scale; return alloc_slot(std::move(entry)); } @@ -299,7 +476,8 @@ namespace neui_detail { if (frame_count == 0) { FilmstripLayout lay; - if (!filmstrip_discover_from_path(name, horizontal, lay)) return 0; + if (!filmstrip_discover_from_path(name, horizontal, lay, &_provider)) + return 0; return allocate_filmstrip_grid_from_file(name, scale, lay.cols, lay.rows, lay.gutter, backend); } @@ -393,7 +571,24 @@ namespace neui_detail uint32_t allocate_font_from_file(const std::string& path, neui_render_backend_t* backend) { - if (path.empty() || !backend || !backend->register_font_file) return 0; + if (path.empty() || !backend) return 0; + + // Client first: a client keeping its fonts in a container hands over + // bytes, which is the in-memory registration path rather than the + // backend's read-the-file-yourself one. A blob the backend rejects falls + // through to the file below (decision 9). + if (_provider.serves(NEUI_RESOURCE_KIND_FONT)) { + uint32_t slot = 0; + _provider.with_bytes( + NEUI_RESOURCE_KIND_FONT, path.c_str(), 0.0f, nullptr, + [&](const uint8_t* data, uint32_t len, float) { + slot = allocate_font(data, len, backend); + return slot != 0; + }); + if (slot != 0) return slot; + } + + if (!backend->register_font_file) return 0; auto entry = std::make_unique(); entry->kind = NEUI_ASSET_KIND_FONT; @@ -731,6 +926,10 @@ namespace neui_detail } _handles.clear(); _free_slots.clear(); + // Resolution outcomes describe the assets just torn down, so they go with + // them - otherwise a full asset reset could not recover a sticky MISS for + // a resource that has since appeared. + clear_image_routes(); } // --- Path helpers (shared with derived path-keyed caches) -------------- @@ -744,6 +943,24 @@ namespace neui_detail else { base_out = name.substr(0, dot); ext_out = name.substr(dot); } } + // A decoded BGRA8-premultiplied image, owned by a vector so no raw + // Loader buffer is ever in flight across a return. + struct DecodedImage { + std::vector pixels; + uint32_t w_px = 0, h_px = 0; + }; + + // Move a Loader buffer into `out` and release it. Returns false for a failed + // or zero-size decode (still releasing whatever came back). + static bool adopt_pixels(uint8_t* raw, uint32_t w, uint32_t h, + std::vector& out) + { + if (!raw || w == 0 || h == 0) { Loader::free_pixels(raw); return false; } + out.assign(raw, raw + static_cast(w) * h * 4); + Loader::free_pixels(raw); + return true; + } + // Resolves the best available path for the requested scale, with a // higher-res fallback so a deployment that ships only @2x (or @3x) // still loads on a 96-DPI display - the bitmap is downscaled at draw @@ -751,7 +968,12 @@ namespace neui_detail // scale > 2.0 -> @3x -> @2x -> base // scale > 1.0 -> @2x -> base -> @3x // else -> base -> @2x -> @3x - static std::string resolve_path(const std::string& name, float scale) + // Existence is tested by DECODING a candidate (there is no stat() in the + // Loader policy), so pass `keep` to be handed the winning candidate's pixels + // instead of throwing them away - that is what keeps a cold load down to one + // decode rather than one to probe plus one for real. + static std::string resolve_path(const std::string& name, float scale, + DecodedImage* keep = nullptr) { std::string base, ext; split_ext(name, base, ext); @@ -773,7 +995,14 @@ namespace neui_detail uint32_t w = 0, h = 0; uint8_t* raw = Loader::load(p.c_str(), &w, &h); if (raw) { - Loader::free_pixels(raw); + if (keep) { + if (adopt_pixels(raw, w, h, keep->pixels)) { + keep->w_px = w; + keep->h_px = h; + } + } else { + Loader::free_pixels(raw); + } return p; } } @@ -792,6 +1021,132 @@ namespace neui_detail } protected: + // One resolution attempt for a (name, scale bucket) pair. Client first + // (decision 1 in plans/client-resource-provider.md): for a client whose + // assets live in a container the filesystem ladder below is a guaranteed + // miss - up to three failed decodes on paths that will never exist - and on + // an embedded target there may be no filesystem at all. + // + // The client probe DECODES what it gets back, which is what makes decision 9 + // work: bytes that do not decode are treated as a miss and resolution + // continues to the filesystem, so a broken provider cannot shadow a good + // file. Those pixels are PARKED (park_probe) and handed to the first + // decode_route for the same route, so a cold load costs one provide() and + // one decode - and none per frame, which is the cost that actually mattered. + ImageRoute probe_image_route(const std::string& name, float scale, + const std::string& base_dir) + { + ImageRoute r; + r.name = name; + r.base_dir = base_dir; + r.req_scale = scale; + _probe = ProbePixels{}; // anything a previous probe parked is stale now + + if (_provider.serves(NEUI_RESOURCE_KIND_IMAGE)) { + DecodedImage got; + float got_scale = 1.0f; + const bool usable = _provider.with_bytes( + NEUI_RESOURCE_KIND_IMAGE, name.c_str(), scale, + base_dir.empty() ? nullptr : base_dir.c_str(), + [&](const uint8_t* data, uint32_t len, float s) { + uint32_t w = 0, h = 0; + uint8_t* raw = Loader::load_memory(data, len, &w, &h); + if (!adopt_pixels(raw, w, h, got.pixels)) return false; + got.w_px = w; + got.h_px = h; + got_scale = s; + return true; + }); + if (usable) { + r.found = true; + r.from_client = true; + r.cache_key = client_cache_key(name, scale_bucket(scale), base_dir); + r.scale = got_scale; + park_probe(r.cache_key, std::move(got), got_scale); + return r; + } + } + + DecodedImage file_px; + const std::string fs_name = fs_name_of(name, base_dir); + const std::string resolved = resolve_path(fs_name, scale, &file_px); + if (!resolved.empty()) { + r.found = true; + r.file_path = resolved; + r.cache_key = resolved; + r.scale = scale_of_resolved(fs_name, resolved); + park_probe(r.cache_key, std::move(file_px), r.scale); + } + return r; + } + + // The filesystem name a route resolves from: the resource name, joined onto + // its component document's base_dir when it has one. (join_path lives in + // component_loader.h, which this header already depends on for + // BuiltComponent.) + static std::string fs_name_of(const std::string& name, + const std::string& base_dir) + { + return base_dir.empty() ? name : cl_detail::join_path(base_dir, name); + } + + // Cache key for a route whose bytes came from the client: those bytes have no + // path, and the scale BAND has to be part of the key. A provider may + // legitimately answer one name with different pixels per band (that is what + // scale_hint is for), and the derived path-keyed caches store one AssetEntry + // per key - drop the band and every band after the first would be served the + // first one's bitmap at the wrong resolution. base_dir is in it for the same + // reason: two component documents may use one name for different images. + static std::string client_cache_key(const std::string& name, int bucket, + const std::string& base_dir) + { + return std::string("\x01") + "client\x01" + static_cast('0' + bucket) + + '\x01' + base_dir + '\x02' + name; // neither byte occurs in a path + } + + // (base_dir, name, scale bucket) -> resolution outcome key. Folded into one + // string so the map can hash rather than compare strings down a tree - this + // sits on the per-frame paint path. + static std::string route_key(const std::string& name, int bucket, + const std::string& base_dir) + { + std::string k = name + '\x01' + static_cast('0' + bucket); + if (!base_dir.empty()) k = base_dir + '\x02' + k; + return k; + } + + // Pixels a probe has already decoded, waiting for the first decode_route of + // that same route. probe_image_route must decode to answer "are these bytes + // usable?" and resolve_path must decode to answer "does this candidate + // exist?"; parking the result is what stops the real load decoding a second + // time. At most ONE image is held, and it is dropped by the next probe, by + // the decode that consumes it, or by clear_image_routes(). + struct ProbePixels { + bool valid = false; + std::string key; // ImageRoute::cache_key it is for + std::vector pixels; // BGRA8 premultiplied + uint32_t w_px = 0, h_px = 0; + float scale = 1.0f; + }; + ProbePixels _probe; + + void park_probe(const std::string& key, DecodedImage&& img, float scale) + { + if (img.pixels.empty() || img.w_px == 0 || img.h_px == 0) return; + _probe.valid = true; + _probe.key = key; + _probe.pixels = std::move(img.pixels); + _probe.w_px = img.w_px; + _probe.h_px = img.h_px; + _probe.scale = scale; + } + + // route_key(name, bucket) -> resolution outcome, misses included. + std::unordered_map _routes; + + // Optional client byte provider; empty unless the host installed one. + ResourceProvider _provider; + // Slot table for handle-based assets (public neui_asset_api_t). // _handles[0] is intentionally unused so slot 0 maps to "invalid". std::vector> _handles; diff --git a/hosts/shared/component_loader.h b/hosts/shared/component_loader.h index cf3527d..eb6c780 100644 --- a/hosts/shared/component_loader.h +++ b/hosts/shared/component_loader.h @@ -32,13 +32,17 @@ // Host-side component loader. Parses a JSON component document (neui::mujson) // and materializes it into a COMPOUND (visual) + a BEHAVIOR (input) by driving // the public compound / behavior / asset API vtables - the SAME calls a client -// would make by hand. It is host-agnostic (it only touches the passed-in api -// pointers, never a host's internal Session or asset store), so it lives once -// in hosts/shared and is compiled into every host. The host wraps the returned -// BuiltComponent in a NEUI_ASSET_KIND_COMPONENT store entry. +// would make by hand. It is host-agnostic (it only touches what ComponentApis +// hands it, never a host's internal Session or asset store type), so it lives +// once in hosts/shared and is compiled into every host. The host wraps the +// returned BuiltComponent in a NEUI_ASSET_KIND_COMPONENT store entry. // -// build_component() does NOT touch the asset store, so it is unit-testable in -// isolation with fake api vtables (see tests/test_component_loader.cpp). +// The one non-vtable seam is ComponentApis::bitmap_from_name, an opaque host +// callback for layer assets; it exists because the public asset API addresses +// images by PATH and a component-referenced name must reach the client resource +// provider unjoined (see the struct). It is optional, so build_component() is +// still unit-testable in isolation with fake api vtables and no asset store at +// all (see tests/test_component_loader.cpp). namespace neui_detail { @@ -97,6 +101,24 @@ namespace neui_detail neui_asset_api_t* asset = nullptr; neui_compound_api_t* compound = nullptr; neui_behavior_api_t* behavior = nullptr; + + // Optional byte hook for layer assets, installed by every host. It exists + // because the public asset API takes a PATH: without it the loader has to + // join base_dir onto the document's raw "assets" entry before the store (and + // therefore the client resource provider) ever sees the name, which breaks + // the contract in that a client is asked for the name it + // itself wrote, with the document's directory passed alongside. + // + // Semantics: create an owned BITMAP asset for `name` exactly as written, + // scoped to `base_dir` (NULL when the document has none) - ask the client + // resource provider first, then fall back to base_dir-joined file resolution. + // Returns asset_none when neither has it. `user` is the host's session. + // + // When absent (the Tier-1 fakes, any other embedder) the loader falls back to + // asset->create_from_file on the joined path, which is what it always did. + neui_asset_t (*bitmap_from_name)(void* user, const char* name, + const char* base_dir) = nullptr; + void* user = nullptr; }; namespace cl_detail @@ -375,8 +397,19 @@ namespace neui_detail return a; // borrowed - not owned by component } } - std::string full = join_path(base_dir, hint_path); - neui_asset_t a = apis.asset->create_from_file(session, full.c_str()); + // Byte hook when the host installed one: it hands the RAW "assets" entry + // plus base_dir to the client resource provider and falls back to the + // joined file itself, so it fully replaces the path branch below (going on + // to try create_from_file as well would just re-probe the same miss under a + // second cache key). + neui_asset_t a = asset_none; + if (apis.bitmap_from_name) { + a = apis.bitmap_from_name(apis.user, hint_path.c_str(), + base_dir.empty() ? nullptr : base_dir.c_str()); + } else { + const std::string full = join_path(base_dir, hint_path); + a = apis.asset->create_from_file(session, full.c_str()); + } if (a.id != asset_none.id) { out.owned_assets.push_back(a); // component-owned out.asset_handle_names.emplace_back(a.id, name); diff --git a/hosts/shared/filmstrip_recognize.h b/hosts/shared/filmstrip_recognize.h index ae20228..7033ae7 100644 --- a/hosts/shared/filmstrip_recognize.h +++ b/hosts/shared/filmstrip_recognize.h @@ -4,8 +4,6 @@ #include #include #include -#include -#include #include #include @@ -13,6 +11,7 @@ // uses - hosts don't carry src/ on their include path. #include "../../src/mujson.h" #include "mujson_accessors.h" // obj_get / as_num / as_str (shared with component_loader) +#include "resource_provider.h" // ResourceProvider - sidecars are client-providable // Filmstrip recognition helpers. There is NO reliable in-band marker that a // PNG/JPG is a frame strip, so recognition is a layered, opt-in convention @@ -151,16 +150,22 @@ namespace neui_detail // ".json" then ".json" sidecar, then the filename token (which // yields a count only, so default_horizontal picks the axis). Returns false // if nothing matches. + // `provider` (optional) is the client resource provider: each sidecar + // candidate is offered to it as NEUI_RESOURCE_KIND_SIDECAR before the + // filesystem, so a filmstrip whose image comes out of a client container can + // carry its layout document there too. inline bool filmstrip_discover_from_path(const std::string& path, bool default_horizontal, - FilmstripLayout& out) + FilmstripLayout& out, + const ResourceProvider* provider = nullptr) { + const ResourceProvider no_provider; + const ResourceProvider& rp = provider ? *provider : no_provider; + auto try_sidecar = [&](const std::string& p) -> bool { - std::ifstream f(p, std::ios::binary); - if (!f) return false; - std::ostringstream ss; - ss << f.rdbuf(); - return filmstrip_parse_sidecar(ss.str(), out, default_horizontal); + std::string text; + if (!rp.read_bytes(NEUI_RESOURCE_KIND_SIDECAR, p.c_str(), text)) return false; + return filmstrip_parse_sidecar(text, out, default_horizontal); }; if (try_sidecar(path + ".json")) return true; diff --git a/hosts/shared/image_loader_stb.h b/hosts/shared/image_loader_stb.h new file mode 100644 index 0000000..0285a9a --- /dev/null +++ b/hosts/shared/image_loader_stb.h @@ -0,0 +1,90 @@ +#pragma once + +// stb_image-based file loader - the fallback decoder for platform layers with +// no OS imaging framework worth calling (Linux/X11, and the LVGL prototype host +// on Windows, which runs without COM). Decodes any stb-supported format +// (PNG / JPG / BMP / GIF / ...) into a heap-allocated BGRA8-premultiplied, +// top-down buffer matching what the cairo / d2d / cg / lvgl backends expect. +// The macOS / native-Windows counterparts are +// hosts/shared/macos/image_loader_macos.h and +// hosts/shared/win32/image_loader_win32.h (ImageIO / WIC). +// +// Platform-neutral by design - no OS guard - so every stb-based platform layer +// shares one copy of the premultiply + overflow-guard logic. +// +// stb_image's implementation must be emitted in exactly ONE translation unit: +// the includer defines STB_IMAGE_IMPLEMENTATION before including this header +// (platform_linux.cpp and platform_lvgl.cpp do, one per build). Every other +// includer gets declarations only. + +#include + +#include +#include + +namespace neui_detail +{ + // Shared tail of both entry points: take stb's straight-RGBA8 output and + // convert to the new[]-allocated BGRA8-premultiplied buffer the backends want. + // Frees `rgba` either way. + inline uint8_t* stb_rgba_to_bgra8_premul(unsigned char* rgba, int w, int h, + uint32_t* width_out, + uint32_t* height_out) + { + if (!rgba) return nullptr; + if (w <= 0 || h <= 0) { stbi_image_free(rgba); return nullptr; } + + // Guard the BGRA byte-count multiply against size_t overflow before we + // new[] it. (stb would already have failed to allocate an absurd image, + // but make the bound explicit rather than rely on that.) + size_t count = static_cast(w) * static_cast(h); + if (count == 0 || count > (SIZE_MAX / 4)) { stbi_image_free(rgba); return nullptr; } + uint8_t* out = new uint8_t[count * 4]; + for (size_t i = 0; i < count; ++i) { + uint32_t r = rgba[i * 4 + 0]; + uint32_t g = rgba[i * 4 + 1]; + uint32_t b = rgba[i * 4 + 2]; + uint32_t a = rgba[i * 4 + 3]; + // RGBA (straight) -> BGRA premultiplied (round-to-nearest). + out[i * 4 + 0] = static_cast((b * a + 127) / 255); + out[i * 4 + 1] = static_cast((g * a + 127) / 255); + out[i * 4 + 2] = static_cast((r * a + 127) / 255); + out[i * 4 + 3] = static_cast(a); + } + stbi_image_free(rgba); + + if (width_out) *width_out = static_cast(w); + if (height_out) *height_out = static_cast(h); + return out; + } + + // Decode `path` into a new[]-allocated BGRA8-premultiplied buffer. Caller + // releases via free_image_bgra8_stb. Returns nullptr on failure. + inline uint8_t* load_image_bgra8_stb(const char* path, + uint32_t* width_out, + uint32_t* height_out) + { + if (!path || !*path) return nullptr; + int w = 0, h = 0, n = 0; + unsigned char* rgba = stbi_load(path, &w, &h, &n, 4); // force RGBA8 + return stb_rgba_to_bgra8_premul(rgba, w, h, width_out, height_out); + } + + // Same, from encoded bytes already in memory - the client resource provider + // path (NEUI_API_RESOURCE_CLIENT) has no path to hand over. stb decodes the + // whole blob in one call; note stb is NOT incremental, so a true streaming + // form (stbi_load_from_callbacks) would buy nothing here. + inline uint8_t* load_image_bgra8_stb_memory(const uint8_t* data, size_t len, + uint32_t* width_out, + uint32_t* height_out) + { + if (!data || len == 0 || len > static_cast(INT_MAX)) return nullptr; + int w = 0, h = 0, n = 0; + unsigned char* rgba = stbi_load_from_memory( + data, static_cast(len), &w, &h, &n, 4); // force RGBA8 + return stb_rgba_to_bgra8_premul(rgba, w, h, width_out, height_out); + } + + inline void free_image_bgra8_stb(uint8_t* pixels) { delete[] pixels; } + +} // namespace neui_detail diff --git a/hosts/shared/ios/image_loader_ios.h b/hosts/shared/ios/image_loader_ios.h index 880c8ad..371551a 100644 --- a/hosts/shared/ios/image_loader_ios.h +++ b/hosts/shared/ios/image_loader_ios.h @@ -8,7 +8,9 @@ #import #import +#include #include +#include // iOS image-loading helpers for the xpl host (hosts/crossplatform/platform_ios.mm). // The CGImageSource + CGBitmapContextCreate BGRA8-premultiplied core is identical @@ -56,23 +58,16 @@ namespace neui_detail return ns_path; // let the caller's CGImageSource fail naturally } - // Decode `path` into a heap-allocated BGRA8-premultiplied buffer. Caller - // releases via `free_image_bgra8`. Returns nullptr on failure. - inline uint8_t* load_image_bgra8_ios(const char* path, - uint32_t* width_out, - uint32_t* height_out) + // Shared tail of both entry points: rasterise a decoded CGImage into a + // new[]-allocated BGRA8-premultiplied buffer. Releases `img`. Defined + // identically in image_loader_macos.h - only one of the two headers is + // compiled per platform (TARGET_OS_IPHONE gate), so no ODR clash, same as + // free_image_bgra8 below. + inline uint8_t* cg_image_to_bgra8_premul(CGImageRef img, + uint32_t* width_out, + uint32_t* height_out) { - NSString* ns_path = resolve_image_path_ios(path); - if (!ns_path) return nullptr; - NSURL* url = [NSURL fileURLWithPath:ns_path]; - if (!url) return nullptr; - - CGImageSourceRef src = CGImageSourceCreateWithURL((__bridge CFURLRef)url, NULL); - if (!src) return nullptr; - CGImageRef img = CGImageSourceCreateImageAtIndex(src, 0, NULL); - CFRelease(src); if (!img) return nullptr; - size_t w = CGImageGetWidth(img); size_t h = CGImageGetHeight(img); if (w == 0 || h == 0) { @@ -80,9 +75,20 @@ namespace neui_detail return nullptr; } + // Bound the byte count before allocating: these dimensions can come from a + // container header in bytes a client resource provider handed over, and the + // sibling stb / WIC loaders carry the same guard. + if (w > (SIZE_MAX / 4) || h > (SIZE_MAX / (w * 4))) { + CGImageRelease(img); + return nullptr; + } size_t row_bytes = w * 4; size_t total = row_bytes * h; - uint8_t* buf = new uint8_t[total](); + uint8_t* buf = new (std::nothrow) uint8_t[total](); + if (!buf) { + CGImageRelease(img); + return nullptr; + } CGColorSpaceRef cs = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); CGContextRef ctx = CGBitmapContextCreate( @@ -104,6 +110,45 @@ namespace neui_detail return buf; } + // Decode `path` into a heap-allocated BGRA8-premultiplied buffer. Caller + // releases via `free_image_bgra8`. Returns nullptr on failure. + inline uint8_t* load_image_bgra8_ios(const char* path, + uint32_t* width_out, + uint32_t* height_out) + { + NSString* ns_path = resolve_image_path_ios(path); + if (!ns_path) return nullptr; + NSURL* url = [NSURL fileURLWithPath:ns_path]; + if (!url) return nullptr; + + CGImageSourceRef src = CGImageSourceCreateWithURL((__bridge CFURLRef)url, NULL); + if (!src) return nullptr; + CGImageRef img = CGImageSourceCreateImageAtIndex(src, 0, NULL); + CFRelease(src); + return cg_image_to_bgra8_premul(img, width_out, height_out); + } + + // Same, from encoded bytes already in memory - the client resource provider + // path (NEUI_API_RESOURCE_CLIENT) has no path to hand over. + inline uint8_t* load_image_bgra8_ios_memory(const uint8_t* data, size_t len, + uint32_t* width_out, + uint32_t* height_out) + { + if (!data || len == 0) return nullptr; + // No-copy CFData over the borrowed bytes: ImageIO decodes within this call, + // and the provider contract keeps `data` alive for its duration. + CFDataRef cf = CFDataCreateWithBytesNoCopy( + kCFAllocatorDefault, (const UInt8*)data, (CFIndex)len, kCFAllocatorNull); + if (!cf) return nullptr; + + CGImageSourceRef src = CGImageSourceCreateWithData(cf, NULL); + if (!src) { CFRelease(cf); return nullptr; } + CGImageRef img = CGImageSourceCreateImageAtIndex(src, 0, NULL); + CFRelease(src); + CFRelease(cf); + return cg_image_to_bgra8_premul(img, width_out, height_out); + } + // Defined identically in image_loader_macos.h; only one of the two is // compiled per platform (TARGET_OS_IPHONE gate), so no ODR clash. inline void free_image_bgra8(uint8_t* pixels) { delete[] pixels; } diff --git a/hosts/shared/linux/image_loader_linux.h b/hosts/shared/linux/image_loader_linux.h deleted file mode 100644 index cecd4bd..0000000 --- a/hosts/shared/linux/image_loader_linux.h +++ /dev/null @@ -1,61 +0,0 @@ -#pragma once - -#if defined(__linux__) || (defined(__unix__) && !defined(__APPLE__)) - -// Linux image loader - mirror of hosts/shared/macos/image_loader_macos.h, -// using the vendored stb_image (third_party/stb/stb_image.h). Decodes any -// stb-supported format (PNG / JPG / BMP / GIF / ...) into a heap-allocated -// BGRA8-premultiplied, top-down buffer matching what the cairo / d2d / cg -// backends expect. -// -// stb_image's implementation must be emitted in exactly ONE translation unit: -// the includer defines STB_IMAGE_IMPLEMENTATION before including this header -// (platform_linux.cpp does). Every other includer gets declarations only. - -#include - -#include - -namespace neui_detail -{ - // Decode `path` into a new[]-allocated BGRA8-premultiplied buffer. Caller - // releases via free_image_bgra8_linux. Returns nullptr on failure. - inline uint8_t* load_image_bgra8_linux(const char* path, - uint32_t* width_out, - uint32_t* height_out) - { - if (!path || !*path) return nullptr; - int w = 0, h = 0, n = 0; - unsigned char* rgba = stbi_load(path, &w, &h, &n, 4); // force RGBA8 - if (!rgba) return nullptr; - if (w <= 0 || h <= 0) { stbi_image_free(rgba); return nullptr; } - - // Guard the BGRA byte-count multiply against size_t overflow before we - // new[] it. (stbi_load would already have failed to allocate an absurd - // image, but make the bound explicit rather than rely on that.) - size_t count = static_cast(w) * static_cast(h); - if (count == 0 || count > (SIZE_MAX / 4)) { stbi_image_free(rgba); return nullptr; } - uint8_t* out = new uint8_t[count * 4]; - for (size_t i = 0; i < count; ++i) { - uint32_t r = rgba[i * 4 + 0]; - uint32_t g = rgba[i * 4 + 1]; - uint32_t b = rgba[i * 4 + 2]; - uint32_t a = rgba[i * 4 + 3]; - // RGBA (straight) -> BGRA premultiplied (round-to-nearest). - out[i * 4 + 0] = static_cast((b * a + 127) / 255); - out[i * 4 + 1] = static_cast((g * a + 127) / 255); - out[i * 4 + 2] = static_cast((r * a + 127) / 255); - out[i * 4 + 3] = static_cast(a); - } - stbi_image_free(rgba); - - if (width_out) *width_out = static_cast(w); - if (height_out) *height_out = static_cast(h); - return out; - } - - inline void free_image_bgra8_linux(uint8_t* pixels) { delete[] pixels; } - -} // namespace neui_detail - -#endif // linux diff --git a/hosts/shared/macos/image_loader_macos.h b/hosts/shared/macos/image_loader_macos.h index 7b551c8..19b2813 100644 --- a/hosts/shared/macos/image_loader_macos.h +++ b/hosts/shared/macos/image_loader_macos.h @@ -6,7 +6,9 @@ #import #import +#include #include +#include // macOS image-loading helpers shared by both the xpl host // (`hosts/crossplatform/platform_macos.mm`) and the native macOS host @@ -46,23 +48,13 @@ namespace neui_detail return ns_path; // let the caller's CGImageSource fail naturally } - // Decode `path` into a heap-allocated BGRA8-premultiplied buffer. Caller - // releases via `free_image_bgra8`. Returns nullptr on failure. - inline uint8_t* load_image_bgra8_macos(const char* path, + // Shared tail of both entry points: rasterise a decoded CGImage into a + // new[]-allocated BGRA8-premultiplied buffer. Releases `img`. + inline uint8_t* cg_image_to_bgra8_premul(CGImageRef img, uint32_t* width_out, uint32_t* height_out) { - NSString* ns_path = resolve_image_path_macos(path); - if (!ns_path) return nullptr; - NSURL* url = [NSURL fileURLWithPath:ns_path]; - if (!url) return nullptr; - - CGImageSourceRef src = CGImageSourceCreateWithURL((__bridge CFURLRef)url, NULL); - if (!src) return nullptr; - CGImageRef img = CGImageSourceCreateImageAtIndex(src, 0, NULL); - CFRelease(src); if (!img) return nullptr; - size_t w = CGImageGetWidth(img); size_t h = CGImageGetHeight(img); if (w == 0 || h == 0) { @@ -70,9 +62,20 @@ namespace neui_detail return nullptr; } + // Bound the byte count before allocating: these dimensions can come from a + // container header in bytes a client resource provider handed over, and the + // sibling stb / WIC loaders carry the same guard. + if (w > (SIZE_MAX / 4) || h > (SIZE_MAX / (w * 4))) { + CGImageRelease(img); + return nullptr; + } size_t row_bytes = w * 4; size_t total = row_bytes * h; - uint8_t* buf = new uint8_t[total](); + uint8_t* buf = new (std::nothrow) uint8_t[total](); + if (!buf) { + CGImageRelease(img); + return nullptr; + } CGColorSpaceRef cs = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); CGContextRef ctx = CGBitmapContextCreate( @@ -94,6 +97,45 @@ namespace neui_detail return buf; } + // Decode `path` into a heap-allocated BGRA8-premultiplied buffer. Caller + // releases via `free_image_bgra8`. Returns nullptr on failure. + inline uint8_t* load_image_bgra8_macos(const char* path, + uint32_t* width_out, + uint32_t* height_out) + { + NSString* ns_path = resolve_image_path_macos(path); + if (!ns_path) return nullptr; + NSURL* url = [NSURL fileURLWithPath:ns_path]; + if (!url) return nullptr; + + CGImageSourceRef src = CGImageSourceCreateWithURL((__bridge CFURLRef)url, NULL); + if (!src) return nullptr; + CGImageRef img = CGImageSourceCreateImageAtIndex(src, 0, NULL); + CFRelease(src); + return cg_image_to_bgra8_premul(img, width_out, height_out); + } + + // Same, from encoded bytes already in memory - the client resource provider + // path (NEUI_API_RESOURCE_CLIENT) has no path to hand over. + inline uint8_t* load_image_bgra8_macos_memory(const uint8_t* data, size_t len, + uint32_t* width_out, + uint32_t* height_out) + { + if (!data || len == 0) return nullptr; + // No-copy CFData over the borrowed bytes: ImageIO decodes within this call, + // and the provider contract keeps `data` alive for its duration. + CFDataRef cf = CFDataCreateWithBytesNoCopy( + kCFAllocatorDefault, (const UInt8*)data, (CFIndex)len, kCFAllocatorNull); + if (!cf) return nullptr; + + CGImageSourceRef src = CGImageSourceCreateWithData(cf, NULL); + if (!src) { CFRelease(cf); return nullptr; } + CGImageRef img = CGImageSourceCreateImageAtIndex(src, 0, NULL); + CFRelease(src); + CFRelease(cf); + return cg_image_to_bgra8_premul(img, width_out, height_out); + } + inline void free_image_bgra8(uint8_t* pixels) { delete[] pixels; } } // namespace neui_detail diff --git a/hosts/shared/resource_provider.h b/hosts/shared/resource_provider.h new file mode 100644 index 0000000..edfafc9 --- /dev/null +++ b/hosts/shared/resource_provider.h @@ -0,0 +1,103 @@ +#pragma once + +#include +#include + +#include + +// Session-scoped binding of the optional client resource provider +// (NEUI_API_RESOURCE_CLIENT, ) plus the "ask the client, +// then read the file" helper every byte-native load path shares. +// +// Why a value type rather than the client pointer directly: AssetStore needs the +// provider on the image path but must not learn about client tokens or the +// per-host Session type, so it holds a copy of this two-word struct that each +// host fills in at session-create time. +// +// ODR-safe: header-only, everything inline. + +namespace neui_detail +{ + // Read a whole file into `out`. Kept here so the four hosts stop duplicating + // the ifstream dance (they each had their own copy for font loading). + inline bool read_file_bytes(const char* path, std::string& out) + { + if (!path || !*path) return false; + std::ifstream in(path, std::ios::binary); + if (!in) return false; + out.assign((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + return !out.empty(); + } + + struct ResourceProvider + { + neui_resource_client_t* client = nullptr; + void* token = nullptr; + + // Does the client answer this kind at all? kinds_mask == 0 means all. + bool serves(neui_resource_kind_t kind) const + { + if (!client || !client->provide) return false; + return client->kinds_mask == 0u || + (client->kinds_mask & NEUI_RESOURCE_MASK(kind)) != 0u; + } + + // Ask the client for `name`. On a hit, invoke fn(data, len, scale) -> bool + // and release the bytes before returning, so the borrow never outlives the + // call. Returns fn's verdict, or false when the client declined. + // + // fn returning false means "these bytes were unusable" and is deliberately + // indistinguishable from a decline to the caller: a provider that hands back + // a corrupt blob must not shadow a perfectly good file, so every caller + // falls through to its own resolution either way. + template + bool with_bytes(neui_resource_kind_t kind, const char* name, + float scale_hint, const char* base_dir, Fn&& fn) const + { + if (!serves(kind) || !name || !*name) return false; + + neui_resource_request_t req{}; + req.kind = kind; + req.name = name; + req.scale_hint = scale_hint; + req.base_dir = base_dir; + + neui_resource_bytes_t got{}; + if (!client->provide(token, &req, &got)) return false; + + // release() is promised exactly once for every provide() that returned + // true (), so the pairing has to survive an exception + // unwinding out of `fn`: every consumer ends in an allocation that can + // throw (a decode into new[] / a std::string assign), and a client that + // allocated or mapped this blob per request would leak it. + struct ReleaseGuard { + neui_resource_client_t* c; + void* t; + const neui_resource_bytes_t* b; + ~ReleaseGuard() { if (c->release) c->release(t, b); } + } guard{ client, token, &got }; + + if (!got.data || got.len == 0) return false; + return fn(got.data, got.len, got.scale > 0.0f ? got.scale : 1.0f); + } + + // The byte-native kinds (FONT / COMPONENT / SIDECAR): ask the client, then + // fall back to reading the file. `path` doubles as the resource name, which + // is what the client passed in to begin with. + bool read_bytes(neui_resource_kind_t kind, const char* path, + std::string& out, const char* base_dir = nullptr) const + { + out.clear(); + const bool from_client = with_bytes( + kind, path, 0.0f, base_dir, + [&out](const uint8_t* data, uint32_t len, float) { + out.assign(reinterpret_cast(data), len); + return true; + }); + if (from_client) return true; + return read_file_bytes(path, out); + } + }; + +} // namespace neui_detail diff --git a/hosts/shared/win32/image_loader_win32.h b/hosts/shared/win32/image_loader_win32.h index 7d315e8..5a0955c 100644 --- a/hosts/shared/win32/image_loader_win32.h +++ b/hosts/shared/win32/image_loader_win32.h @@ -8,6 +8,7 @@ #include #include +#include #include #pragma comment(lib, "Windowscodecs") @@ -43,6 +44,93 @@ namespace neui_detail return fac; } + // Shared tail: run an initialised WIC decoder's first frame through a format + // converter into a new[]-allocated buffer. Releases nothing the caller owns. + inline uint8_t* wic_decode_frame_w32(IWICImagingFactory* wic, + IWICBitmapDecoder* decoder, + const WICPixelFormatGUID& pixel_format, + uint32_t* width_out, + uint32_t* height_out) + { + IWICBitmapFrameDecode* frame = nullptr; + IWICFormatConverter* converter = nullptr; + uint8_t* result = nullptr; + + HRESULT hr = decoder->GetFrame(0, &frame); + if (FAILED(hr)) goto cleanup; + + hr = wic->CreateFormatConverter(&converter); + if (FAILED(hr)) goto cleanup; + + hr = converter->Initialize(frame, pixel_format, + WICBitmapDitherTypeNone, nullptr, 0.0, + WICBitmapPaletteTypeMedianCut); + if (FAILED(hr)) goto cleanup; + + { + UINT w = 0, h = 0; + converter->GetSize(&w, &h); + if (w == 0 || h == 0) goto cleanup; + + // GetSize reports the dimensions the container's HEADER declares, without + // decoding a pixel - and these bytes reach us straight from a client + // resource provider via load_image_bgra8_w32_memory. So bound the buffer + // math in 64 bits: a blob declaring 65536x65536 would otherwise truncate + // stride * h to 0 in uint32_t and hand CopyPixels a zero-byte allocation + // with a 256 KB stride. (Same guard the stb loader carries.) + const uint64_t stride64 = static_cast(w) * 4u; + const uint64_t size64 = stride64 * h; + if (size64 == 0 || size64 > 0xFFFFFFFFull) goto cleanup; + + const uint32_t stride = static_cast(stride64); + const uint32_t size = static_cast(size64); + // nothrow: a decoder fed untrusted bytes reports failure by returning + // nullptr (documented below), and this runs on the paint path. + result = new (std::nothrow) uint8_t[size]; + if (!result) goto cleanup; + hr = converter->CopyPixels(nullptr, stride, size, result); + if (FAILED(hr)) { delete[] result; result = nullptr; goto cleanup; } + + if (width_out) *width_out = w; + if (height_out) *height_out = h; + } + + cleanup: + if (converter) converter->Release(); + if (frame) frame->Release(); + return result; + } + + // Decode encoded image bytes already in memory - the client resource provider + // path (NEUI_API_RESOURCE_CLIENT) has no path to hand over. Also the engine + // behind the embedded-resource branch of load_image_bgra8_w32 below. + // Caller releases via `free_image_bgra8_w32`. Returns nullptr on failure. + inline uint8_t* load_image_bgra8_w32_memory(const uint8_t* data, size_t len, + uint32_t* width_out, + uint32_t* height_out, + const WICPixelFormatGUID& pixel_format + = GUID_WICPixelFormat32bppPBGRA) + { + if (!data || len == 0 || len > 0xFFFFFFFFull) return nullptr; + IWICImagingFactory* wic = wic_factory_w32(); + if (!wic) return nullptr; + + IStream* stream = SHCreateMemStream(static_cast(data), + static_cast(len)); + if (!stream) return nullptr; + + IWICBitmapDecoder* decoder = nullptr; + uint8_t* result = nullptr; + if (SUCCEEDED(wic->CreateDecoderFromStream( + stream, nullptr, WICDecodeMetadataCacheOnLoad, &decoder))) { + result = wic_decode_frame_w32(wic, decoder, pixel_format, + width_out, height_out); + decoder->Release(); + } + stream->Release(); + return result; + } + // Decode `path` into a heap-allocated BGRA8 buffer. // `pixel_format` selects the WIC output format: // - GUID_WICPixelFormat32bppPBGRA (default) - premultiplied alpha, @@ -67,17 +155,9 @@ namespace neui_detail std::wstring wpath(n - 1, L'\0'); MultiByteToWideChar(CP_UTF8, 0, path, -1, &wpath[0], n); - IWICBitmapDecoder* decoder = nullptr; - IWICBitmapFrameDecode* frame = nullptr; - IWICFormatConverter* converter = nullptr; - IStream* res_stream = nullptr; - uint8_t* result = nullptr; - HRESULT hr = S_OK; - // Embedded "PNG" resource: name = the caller-supplied path wrapped // in literal quotes (the rc.exe quirk above). Resource memory has - // process lifetime, so the IStream-over-buffer stays valid until - // we release it after decode. + // process lifetime, so decoding straight out of it is safe. std::wstring res_name = L"\"" + wpath + L"\""; if (HMODULE hMod = GetModuleHandleW(nullptr)) { if (HRSRC hRes = FindResourceW(hMod, res_name.c_str(), L"PNG")) { @@ -85,57 +165,25 @@ namespace neui_detail HGLOBAL hGlob = LoadResource(hMod, hRes); if (hGlob && rsize > 0) { if (void* rdata = LockResource(hGlob)) { - res_stream = SHCreateMemStream( - static_cast(rdata), rsize); - if (res_stream) { - hr = wic->CreateDecoderFromStream( - res_stream, nullptr, - WICDecodeMetadataCacheOnLoad, &decoder); - if (FAILED(hr)) { decoder = nullptr; } // try file fallback - } + if (uint8_t* res = load_image_bgra8_w32_memory( + static_cast(rdata), rsize, + width_out, height_out, pixel_format)) + return res; + // Fall through to the file branch on a bad resource. } } } } - if (!decoder) { - hr = wic->CreateDecoderFromFilename( - wpath.c_str(), nullptr, GENERIC_READ, - WICDecodeMetadataCacheOnLoad, &decoder); - } - if (FAILED(hr)) goto cleanup; + IWICBitmapDecoder* decoder = nullptr; + if (FAILED(wic->CreateDecoderFromFilename( + wpath.c_str(), nullptr, GENERIC_READ, + WICDecodeMetadataCacheOnLoad, &decoder))) + return nullptr; - hr = decoder->GetFrame(0, &frame); - if (FAILED(hr)) goto cleanup; - - hr = wic->CreateFormatConverter(&converter); - if (FAILED(hr)) goto cleanup; - - hr = converter->Initialize(frame, pixel_format, - WICBitmapDitherTypeNone, nullptr, 0.0, - WICBitmapPaletteTypeMedianCut); - if (FAILED(hr)) goto cleanup; - - { - UINT w = 0, h = 0; - converter->GetSize(&w, &h); - if (w == 0 || h == 0) goto cleanup; - - uint32_t stride = w * 4; - uint32_t size = stride * h; - result = new uint8_t[size]; - hr = converter->CopyPixels(nullptr, stride, size, result); - if (FAILED(hr)) { delete[] result; result = nullptr; goto cleanup; } - - *width_out = w; - *height_out = h; - } - - cleanup: - if (converter) converter->Release(); - if (frame) frame->Release(); - if (decoder) decoder->Release(); - if (res_stream) res_stream->Release(); + uint8_t* result = wic_decode_frame_w32(wic, decoder, pixel_format, + width_out, height_out); + decoder->Release(); return result; } diff --git a/hosts/win32/asset_manager_w32.h b/hosts/win32/asset_manager_w32.h index 8f01857..4173c53 100644 --- a/hosts/win32/asset_manager_w32.h +++ b/hosts/win32/asset_manager_w32.h @@ -16,6 +16,9 @@ namespace win32_host { static uint8_t* load(const char* path, uint32_t* w_px, uint32_t* h_px) { return neui_detail::load_image_bgra8_w32(path, w_px, h_px); } + static uint8_t* load_memory(const uint8_t* data, size_t len, + uint32_t* w_px, uint32_t* h_px) + { return neui_detail::load_image_bgra8_w32_memory(data, len, w_px, h_px); } static void free_pixels(uint8_t* p) { delete[] p; } }; diff --git a/hosts/win32/host.cpp b/hosts/win32/host.cpp index 01c13b9..25f133b 100644 --- a/hosts/win32/host.cpp +++ b/hosts/win32/host.cpp @@ -122,6 +122,17 @@ namespace win32_host _grid_client = static_cast( _client->get_interface(token, NEUI_API_GRID_CLIENT)); + // Opt-in client resource provider: asked for image / font / component / + // sidecar bytes before this host tries the embedded resources or the disk. + _resource_client = static_cast( + _client->get_interface(token, NEUI_API_RESOURCE_CLIENT)); + if (_resource_client) { + neui_detail::ResourceProvider provider; + provider.client = _resource_client; + provider.token = token; + _asset_manager.set_resource_provider(provider); + } + // Bring up the system-theme provider (idempotent across sessions) and // subscribe so this session can re-apply DWM dark mode + invalidate // its frames when light/dark or accent changes. diff --git a/hosts/win32/host.h b/hosts/win32/host.h index 6074bba..e469e4b 100644 --- a/hosts/win32/host.h +++ b/hosts/win32/host.h @@ -493,6 +493,11 @@ namespace win32_host { // commits an in-place cell edit (ENTER inside the editor). neui_grid_client_t* _grid_client = nullptr; + // Optional client resource provider (NEUI_API_RESOURCE_CLIENT). Asked for + // bytes before this host tries the embedded resources / disk. The live + // binding used by the load paths is _asset_manager.resource_provider(). + neui_resource_client_t* _resource_client = nullptr; + // System-theme listener handle (singleton listener in // theme_provider_win32.h). Registered in ctor, unregistered in dtor. uint32_t _theme_listener_handle = 0; diff --git a/hosts/win32/widgets.cpp b/hosts/win32/widgets.cpp index 192eb20..a5b916f 100644 --- a/hosts/win32/widgets.cpp +++ b/hosts/win32/widgets.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include "window.h" // provides get_hinstance(), ChildSubclassProc #include "../../backends/d2d/d2d_backend.h" #include "../shared/win32/clipboard_win32.h" @@ -5542,6 +5541,22 @@ namespace win32_host for (auto a : built.owned_assets) as_destroy(session, a); } + // ComponentApis::bitmap_from_name - see component_loader.h. A layer asset named + // in a component document reaches the client resource provider as the raw + // "assets" entry plus that document's base_dir, which the public path-taking + // create_from_file cannot express; the store joins them for its own filesystem + // fallback. + static neui_asset_t component_bitmap_from_name(void* user, const char* name, + const char* base_dir) + { + auto* s = static_cast(user); + if (!s || !name) return asset_none; + uint32_t slot = s->_asset_manager.allocate_from_file( + name, best_asset_scale_w32(), base_dir); + if (slot == 0) return asset_none; + return pack_asset_w32(s->session_id(), slot); + } + static neui_asset_t NEUI_ABI as_create_component_from_string( neui_session_t session, const char* json, uint32_t len, const neui_component_env_t* env) @@ -5552,6 +5567,8 @@ namespace win32_host apis.asset = &asset_api; apis.compound = &compound_api; apis.behavior = &behavior_api; + apis.bitmap_from_name = component_bitmap_from_name; + apis.user = s; neui_detail::BuiltComponent built = neui_detail::build_component(session, json, len, env, apis); if (!built.ok) { release_built_component_w32(session, built); return asset_none; } @@ -5566,10 +5583,11 @@ namespace win32_host { auto* s = get_session(session); if (!s || !path_utf8) return asset_none; - std::ifstream in(path_utf8, std::ios::binary); - if (!in) return asset_none; - std::string data((std::istreambuf_iterator(in)), - std::istreambuf_iterator()); + // Client resource provider first, then the file (shared read-or-ask helper). + std::string data; + if (!s->_asset_manager.resource_provider().read_bytes( + NEUI_RESOURCE_KIND_COMPONENT, path_utf8, data)) + return asset_none; neui_component_env_t local{}; const neui_component_env_t* use_env = env; static thread_local std::string base_keep; diff --git a/include/neui/d/resource.h b/include/neui/d/resource.h new file mode 100644 index 0000000..efd0bb1 --- /dev/null +++ b/include/neui/d/resource.h @@ -0,0 +1,176 @@ +#pragma once + +#include +#include +#include "api.h" + +// Optional client-side interface for supplying resource BYTES. +// +// The host calls client->get_interface(token, NEUI_API_RESOURCE_CLIENT) once +// per session at create time, exactly like NEUI_API_MENU_CLIENT. If a non-null +// neui_resource_client_t is returned, the host asks the client for a resource +// BEFORE trying to locate it itself. +// +// Purpose: let a client keep its assets in its own container - a plugin bundle, +// an executable resource section, an encrypted pack, flash on an MCU, or bytes +// generated at runtime - without neui knowing anything about that container. +// Before this interface, every path in neui that consumes media took a +// filesystem path, so an all-embedded deployment had no way in (and an embedded +// target may have no filesystem at all). +// +// ORDER: client first, then the host's own resolution. For images the host's own +// step is the @2x / @3x candidate ladder plus, on Win32, the embedded RT "PNG" +// resource lookup - so the effective order is +// client -> embedded resource -> file. +// Bytes that fail to decode or parse are treated as a miss and the host +// continues to its own resolution rather than failing the load - so a buggy +// provider cannot shadow a file that is perfectly good. That holds for every +// load, not just the first: a route that once came from the client but later +// yields nothing falls back to the filesystem too. +// +// HOW OFTEN provide() IS CALLED - it differs by kind: +// * IMAGE: the host caches resolution outcomes, misses included, so the client +// is asked ONCE per (base_dir, name, scale band) per session and never +// re-probed - a name from a component document is scoped to that document. In +// particular `provide` is NEVER called per frame, even though the framework's +// IMAGE widget resolves its source on every paint, and a client that declines +// is not asked again. (Negative outcomes are sticky in v0; an explicit +// create_from_file re-probes one, a repaint does not.) +// * FONT / COMPONENT / SIDECAR: not cached - each create_font_from_file / +// create_component_from_file / filmstrip discovery asks again, exactly as it +// would re-read the file. So N component widgets built from one document cost +// N calls, and sidecar discovery asks for two different candidate names +// (".json", then ".json"). Keep provide() cheap; do not treat it +// as a once-per-session event for these kinds. +// +// Complementary to neui_component_env_t::resolve_asset (), +// which is consulted before this one when loading a component document. +// resolve_asset answers "which existing asset HANDLE does this name mean" and +// can return a runtime-built compound / painted surface that has no byte form; +// this interface answers "here are the BYTES". Full order for an asset named +// inside a component document: +// env.resolve_asset -> resource_client->provide -> filesystem. +// Both hooks see the SAME name string - the raw entry from the document's +// "assets" map, never a base_dir-joined path - so one client-side lookup table +// can serve both. The document's directory arrives separately in `base_dir` +// below; only the host's own filesystem fallback joins the two. +// +// THREADING / REENTRANCY: provide() and release() are called on the UI thread, +// synchronously, inside the neui call that triggered the load. That call is +// sometimes a PAINT (the framework's NEUI_W_IMAGE widget loads lazily on first +// draw). So provide() must not call back into any neui API, and must return +// promptly - on hosts that hold a renderer lock across a frame, blocking there +// stalls the refresh and re-entering neui can deadlock. Do I/O only. + +#ifdef __cplusplus +extern "C" { +#endif + +#define NEUI_API_RESOURCE_CLIENT \ + "com.defiantnerd.neui.extension.resource.client/0" + + // What the host is trying to load. Reserve new values at the next unused + // integer so old client builds stay forward-compatible (same rule as + // neui_asset_kind_t). + typedef enum neui_resource_kind { + NEUI_RESOURCE_KIND_NONE = 0, + // Encoded image bytes (PNG / JPG / BMP / whatever the platform decodes). + // Requested by create_from_file, create_filmstrip_from_file, a + // NEUI_W_IMAGE widget's set_text, and component asset references. + NEUI_RESOURCE_KIND_IMAGE = 1, + // Font file bytes (TTF / OTF / TTC). Requested by create_font_from_file. + NEUI_RESOURCE_KIND_FONT = 2, + // Component document (UTF-8 JSON). Requested by + // create_component_from_file. + NEUI_RESOURCE_KIND_COMPONENT = 3, + // Filmstrip layout sidecar (UTF-8 JSON) - the ".json" / ".json" + // frame-count document create_filmstrip_from_file discovers. + NEUI_RESOURCE_KIND_SIDECAR = 4, + // Reserved: do NOT renumber. + // NEUI_RESOURCE_KIND_SVG = 5, + // NEUI_RESOURCE_KIND_AUDIO = 6, + } neui_resource_kind_t; + + // Bit for neui_resource_client_t::kinds_mask. +#define NEUI_RESOURCE_MASK(kind) (1u << (uint32_t)(kind)) +#define NEUI_RESOURCE_MASK_IMAGE NEUI_RESOURCE_MASK(NEUI_RESOURCE_KIND_IMAGE) +#define NEUI_RESOURCE_MASK_FONT NEUI_RESOURCE_MASK(NEUI_RESOURCE_KIND_FONT) +#define NEUI_RESOURCE_MASK_COMPONENT NEUI_RESOURCE_MASK(NEUI_RESOURCE_KIND_COMPONENT) +#define NEUI_RESOURCE_MASK_SIDECAR NEUI_RESOURCE_MASK(NEUI_RESOURCE_KIND_SIDECAR) + + // Host-allocated, passed by const pointer. Fields may be APPENDED in a future + // version, so a client must never copy this struct by value or assume its + // size; read the fields it knows. + typedef struct neui_resource_request { + neui_resource_kind_t kind; + + // The name exactly as the client originally passed it (to create_from_file, + // set_text on an IMAGE widget, create_font_from_file, ...), or the raw entry + // from a component document's "assets" map. NOT a host-rewritten variant: no + // "@2x" suffix appended - the @Nx convention stays a filesystem convention - + // and no base_dir joined on. The client looks its own container up by the + // name it already knows. + const char* name; + + // IMAGE only (0.0 for every other kind): the display scale the host is + // resolving for - 1.0 / 2.0 / 3.0, or a fractional Windows scale. This is a + // HINT. The client may return any resolution it has and reports what it + // actually returned in neui_resource_bytes_t::scale; the host asks ONCE per + // name and scale band, never once per @Nx variant. + float scale_hint; + + // When the resource is referenced from inside a component document, that + // document's base_dir - so a client can disambiguate same-named assets + // belonging to two different components, and so `name` above can stay the raw + // entry. NULL for every other load, and NULL for a document with no base_dir. + // A client that ignores it still works whenever its names are unique. + const char* base_dir; + } neui_resource_request_t; + + typedef struct neui_resource_bytes { + // Borrowed by the host for the duration of the provide() -> release() pair + // only, which completes before the triggering neui call returns. Point it + // at a resource section, a container mapping, a member buffer, or a fresh + // allocation - whatever suits; nothing needs to outlive the call. + const uint8_t* data; + uint32_t len; + + // IMAGE only: HiDPI factor of the returned pixels - the same meaning as + // neui_asset_api::create_bitmap's `scale`, so an @2x sheet returned here + // must say 2.0 or it will draw at twice its intended size. 0.0 = treat as + // 1.0. Ignored for other kinds. + float scale; + + // Opaque client cookie, echoed back to release(). Lets one provider hand + // out static blobs, heap buffers and mappings and tell them apart. + void* release_token; + } neui_resource_bytes_t; + + typedef struct neui_resource_client { + uint32_t neui_version; + + // Which kinds this client answers, as a bitwise OR of NEUI_RESOURCE_MASK_*. + // 0 means "all kinds". Kinds outside the mask are never passed to provide() + // - cheaper than every client opening with `if (kind != IMAGE) return + // false;`, and it keeps a client that only overrides images off the font + // and component load paths entirely. + uint32_t kinds_mask; + + // Fill *out and return true, or return false to let the host resolve the + // resource its own way (the pre-existing behaviour). Call frequency differs + // by kind - at most once per (name, scale band) per session for IMAGE, once + // per load call for the other kinds; see "HOW OFTEN" in the file header. + // `out` is zeroed by the host before the call. + bool (NEUI_ABI *provide)(void* token, + const neui_resource_request_t* req, + neui_resource_bytes_t* out); + + // Called exactly once for every provide() that returned true, after the + // host has copied / decoded the bytes and before the triggering neui call + // returns. May be NULL when the client hands out only static blobs. + void (NEUI_ABI *release)(void* token, const neui_resource_bytes_t* res); + } neui_resource_client_t; + +#ifdef __cplusplus +} +#endif diff --git a/include/neui/neui.h b/include/neui/neui.h index 2960f35..7ed64ae 100644 --- a/include/neui/neui.h +++ b/include/neui/neui.h @@ -23,6 +23,7 @@ #include "d/dnd.h" #include "d/commands.h" #include "d/menu.h" +#include "d/resource.h" #include "d/theme.h" #include "d/grid.h" #include "d/scroll.h" diff --git a/plans/client-resource-provider.md b/plans/client-resource-provider.md new file mode 100644 index 0000000..4ea0829 --- /dev/null +++ b/plans/client-resource-provider.md @@ -0,0 +1,431 @@ +# Client resource provider (NEUI_API_RESOURCE_CLIENT) + +**Status: EXECUTED 2026-07-31.** Design reviewed and approved, then implemented end to end - +see "Implementation notes" at the bottom for what shipped and the one documented deviation. +Reference docs now live in `docs/rendering-and-assets.md` ("Client resource provider"). + +## Goal + +Let a client supply resource bytes for a name. The host asks the client first, with hints about +what it is looking for, and falls back to its own filesystem / embedded-resource resolution when +the client declines. The client returns the complete byte blob (no streaming, no partial reads). + +(This section was written before the order was decided; see "Decisions taken" below - the review +settled on client-first rather than the fallback-only shape sketched here.) + +This closes a gap that already bites: the LVGL host has no resource loader at all, so +`neui_example`'s `.rc`-embedded `lemur.jpg` shows the failed-load placeholder there while the +native Windows host loads it from the EXE. More generally it lets a client ship assets inside +its own container (plugin bundle, VST3 resource dir, encrypted pack, generated at runtime) +without neui knowing anything about that container. + +## Scope decision: which resources + +Everything neui currently reads from the filesystem, so the extension is not immediately +half-useful: + +| Resource | What reads it today | Bytes are | Consumer | +|---|---|---|---| +| Image (bitmap / filmstrip) | `AssetStore::allocate_from_file` + `resolve_path` (`hosts/shared/asset_store.h:169,754`), `AssetManager::get_bitmap` (`hosts/crossplatform/asset_manager.cpp:24`) via `Loader::load` -> `platform_load_image` / WIC / ImageIO | encoded PNG / JPG / BMP | needs a **decode-from-memory** path (see below) | +| Font | `as_create_font_from_file` (`hosts/crossplatform/widgets.cpp:2204`, `hosts/win32/widgets.cpp:5496`, `hosts/macos/widgets.mm:2444`, `hosts/ios/widgets.mm:1647`) via `std::ifstream` | TTF / OTF / TTC | already bytes-native (`create_font(data, len)`) | +| Component | `as_create_component_from_file` (`hosts/crossplatform/widgets.cpp:2260`) via `std::ifstream` | UTF-8 JSON | already bytes-native (`create_component_from_string`) | +| Filmstrip sidecar | `hosts/shared/filmstrip_recognize.h:159` via `std::ifstream` | UTF-8 JSON | parsed in place | + +Fonts, components and sidecars are nearly free - they read a file into a buffer and hand the +buffer on, so the hook is a two-line substitution. Images are the expensive one, because the +current seam is *path in, decoded pixels out*; there is no way to feed it bytes. + +## Proposed API + +New header `include/neui/d/resource.h`, following the `NEUI_API_*_CLIENT` pattern +(`menu.h` / `theme.h` / `grid.h`): the host calls `client->get_interface(token, ...)` once at +session-create time and caches the pointer, exactly like `_menu_client` / `_grid_client` / +`_theme_client` in `Session::Session` (`hosts/crossplatform/host.cpp:227-263`). + +```c +#define NEUI_API_RESOURCE_CLIENT \ + "com.defiantnerd.neui.extension.resource.client/0" + +// What the host was trying to load. Reserve new values at the next unused +// integer so old client builds stay forward-compatible (same rule as +// neui_asset_kind_t). +typedef enum neui_resource_kind { + NEUI_RESOURCE_KIND_NONE = 0, + NEUI_RESOURCE_KIND_IMAGE = 1, // encoded PNG / JPG / BMP bytes + NEUI_RESOURCE_KIND_FONT = 2, // TTF / OTF / TTC bytes + NEUI_RESOURCE_KIND_COMPONENT = 3, // component JSON (UTF-8) + NEUI_RESOURCE_KIND_SIDECAR = 4, // filmstrip layout JSON (UTF-8) + // reserved: _SVG = 5, _AUDIO = 6, ... +} neui_resource_kind_t; + +typedef struct neui_resource_request { + neui_resource_kind_t kind; + // The name exactly as the client originally passed it (to create_from_file, + // set_text on an IMAGE, create_font_from_file, ...). NOT a host-rewritten + // variant: no "@2x" suffix, no base_dir prefix. The client looks up its own + // container by the name it knows. + const char* name; + // Display scale the host is resolving for (1.0 / 2.0 / 3.0). IMAGE only; + // 0.0 for every other kind. The client may return any resolution it has and + // reports what it actually returned in neui_resource_bytes_t::scale. + float scale_hint; + // For a resource referenced from inside a component document, the component's + // base_dir (so a client can disambiguate same-named assets of two components). + // NULL otherwise. + const char* base_dir; +} neui_resource_request_t; + +typedef struct neui_resource_bytes { + const uint8_t* data; // borrowed by the host for the duration of the call only + uint32_t len; + // IMAGE only: HiDPI factor of the returned pixels (1.0 / 2.0 / 3.0), i.e. + // the same meaning as create_bitmap's `scale`. 0.0 = "treat as 1.0". + // Ignored for other kinds. + float scale; + // Opaque client cookie echoed back to release(). Lets the client hand out a + // heap buffer, an mmap, or a static blob and know which on release. + void* release_token; +} neui_resource_bytes_t; + +typedef struct neui_resource_client { + uint32_t neui_version; + + // (As shipped: called BEFORE the host's own lookup - decision 1.) Fill *out + // and return true, or return false to let the host resolve it its own way. + // The host copies / decodes the bytes before returning and then calls + // release(), so `data` need only stay valid for the duration of this call. + bool (NEUI_ABI *provide)(void* token, + const neui_resource_request_t* req, + neui_resource_bytes_t* out); + + // Called exactly once for every provide() that returned true, before the + // host's originating API call returns. May be NULL (static blobs). + void (NEUI_ABI *release)(void* token, const neui_resource_bytes_t* res); +} neui_resource_client_t; +``` + +### Why these choices + +- **Borrow + `release`, not host-owns.** Every consumer already copies or transforms the bytes + immediately (`create_font` copies, `create_component_from_string` parses, an image decodes to + new pixels), so the host never needs to retain the blob. Borrowing lets a client point + straight at an `RT_RCDATA` resource or a `std::vector` member with zero copies on its side, + and `release_token` still supports a freshly-allocated buffer. `release` is called before the + triggering API call returns, so lifetime reasoning is trivial on both sides. +- **One call per logical name, with `scale_hint`** - this is the answer to the resolution-hint + question. The host does *not* ask three times for `knob@3x.png`, `knob@2x.png`, `knob.png`. + It asks once for `knob.png` with `scale_hint = 2.0` and the client returns whatever variant it + has, declaring the real scale in `out->scale`. The client owns its own naming convention + (`@2x`, `_2x`, a subdirectory, one master rendered on demand); the host's `@Nx` filename + convention stays a *filesystem* convention and does not leak into the extension. +- **`name` is the client's original string.** A client that stores `"knob.png"` should not have + to recognise `"assets/knob@2x.png"`. `base_dir` is passed separately for the component case + rather than pre-joined. +- **No `struct_size` field.** Matches the existing `neui_component_env_t` / `neui_component_param_t` + convention; the `/0` suffix in the interface name is the version gate. The request struct is + host-allocated and passed by const pointer, so appending a field is safe for old clients as + long as they never copy it by value (documented in the header). See open question 3. + +## Resolution order + +**DECIDED: client first, then the host tries itself.** Matches the existing +`neui_component_env_t::resolve_asset` convention and enables overriding shipped files (user +skins, themed packs) rather than only filling gaps. + +``` +create_from_file("knob.png") / IMAGE set_text / create_font_from_file / ... + 1. resource_client->provide({IMAGE, "knob.png", scale_hint}, &bytes) + hit -> platform_load_image_bytes -> pixels, entry.scale = bytes.scale, release(), done + miss -> fall through + 2. resolve_path(name, scale): @Nx candidate ladder -> Loader::load (win32 also tries the + RT "PNG" resource inside load_image_bgra8_w32, so the effective order is + client -> embedded resource -> file) + 3. neither -> asset_none (today's behaviour, unchanged) +``` + +A client that does not implement the interface sees zero cost and zero behaviour change, exactly +as before. A client that *does* implement it is now on the hot path of every resource load, not +just the failing ones - which is why the resolution cache below is a hard prerequisite rather +than an optimisation, and why `provide` must be cheap and must not call back into neui. + +## Host-side plumbing + +1. **`Session`** gains `neui_resource_client_t* _resource_client` fetched in the constructor next + to `_menu_client` / `_grid_client`. Same in the native win32 / macOS / iOS hosts. +2. **`AssetStore`** (`hosts/shared/asset_store.h`) gains a settable provider, because the + `Loader` is a compile-time *static* policy and the client callback is per-session state: + ```cpp + using ProvideFn = bool (*)(void* user, const ResourceReq&, ResourceBytes&); + using ReleaseFn = void (*)(void* user, const ResourceBytes&); + void set_resource_provider(ProvideFn, ReleaseFn, void* user); + ``` + `ResourceReq` / `ResourceBytes` are small internal mirrors of the public structs so + `hosts/shared/` keeps compiling without the public header pulling in host types (it already + includes the public headers, so plain reuse is also fine - open question 4). + `resolve_path` and `scale_of_resolved` are currently `static`; the provider path needs + instance state, so `allocate_from_file` grows a non-static resolution step. The two static + helpers stay for the derived path-keyed cache in the xpl `AssetManager`. +3. **`Loader` policy** gains `load_memory(const uint8_t*, size_t, uint32_t* w, uint32_t* h)` + alongside `load` / `free_pixels`, in all four policies (`XplImageLoader`, `W32ImageLoader`, + `MacOSImageLoader`, `IOSImageLoader`). +4. **Fonts / components / sidecars**: one `if (!read_file(...)) ask_client(...)` at each of the + sites tabulated above. Note `as_create_font_from_file` is duplicated per host - factor the + read+fallback into a shared helper in `hosts/shared/` rather than editing four copies. + +## New platform seam: decode from memory + +`hosts/crossplatform/platform.h` gains the sibling of the existing loader: + +```c + // Decode an in-memory encoded image (PNG / JPG / BMP / ...) to BGRA8 + // premultiplied pixels. Same ownership contract as platform_load_image: + // release with platform_free_image. Returns nullptr on failure. + uint8_t* platform_load_image_bytes(const uint8_t* data, size_t len, + uint32_t* width_out, uint32_t* height_out); +``` + +Per platform, all four already have the primitive: + +- **Linux + LVGL**: `stbi_load_from_memory` in `hosts/shared/image_loader_stb.h`. Factor the + premultiply loop so `load_image_bgra8_stb` and a new `load_image_bgra8_stb_memory` share it. +- **win32**: `image_loader_win32.h` already builds an `IStream` over a buffer with + `SHCreateMemStream` for the resource path - extract that into + `load_image_bgra8_w32_memory(data, len, ...)` and have the resource path call it. +- **macOS / iOS**: `CGImageSourceCreateWithData(CFDataCreateWithBytesNoCopy(...))`, mirroring + the existing `load_image_bgra8_macos`. +- **null**: returns nullptr. + +`stbi_load_from_callbacks` (a true pull stream) is deliberately **not** used: stb is not +incremental (it can only rewind inside a 128-byte sniff buffer) and the whole point of the +"client provides the complete blob" simplification is that we never need it. If a streaming +variant is ever wanted, it can be added as a second entry point without disturbing this one. + +## Prerequisite: the per-paint resolve cost (must fix, or this hook fires every frame) + +`AssetManager::get_bitmap` and `get_logical_size` call `resolve_path(name, scale)` on **every +call**, i.e. once per IMAGE widget per paint - and `resolve_path` tests each candidate by fully +decoding it and throwing the pixels away (`asset_store.h:772-779`). So an IMAGE widget already +pays a full image decode per frame today, before its cache lookup. That is a pre-existing +performance bug, but it becomes a correctness problem for this design: bolting the provider onto +`resolve_path` unchanged would call `provide` (and decode its bytes) once per frame per widget, +and would call it forever for a genuinely missing resource. + +With client-first this gets sharper: without a cache, a client that implements the interface would +be called once per IMAGE widget per frame even for resources that resolve perfectly well from +disk. So the design requires, as step 1 of implementation: + +- A **resolution cache** keyed on `(name, scale)` storing the outcome: `client` (bytes came from + the provider), the resolved filesystem variant + actual scale, or `missing`. All three cached, + so `provide` is called **at most once per (name, scale)** per session. +- **DECIDED for v0: negative results are sticky.** They clear on a DPI change (which already + forces re-resolution at the new scale) and at session teardown. No explicit invalidation entry + point yet; if a client needs to publish resources late, add a "forget resolution failures" call + on `NEUI_API_ASSETS` as a vtable-append later. + +This is worth doing on its own merits - it removes a decode per frame per IMAGE widget. + +## Threading and reentrancy contract (to document in the header) + +- `provide` / `release` are called on the UI thread, synchronously, inside the neui call that + triggered the load. +- That call is **sometimes a paint**: the xpl path-keyed tier loads lazily from `get_bitmap` + during widget paint. So `provide` must not call back into any neui API, and must not block for + long - on the LVGL host a paint runs under the global LVGL lock, so blocking there stalls the + refresh (and calling into neui would deadlock). Document as: do I/O only, return quickly, no + neui calls. +- With the resolution cache above, the paint-time call happens at most once per resource, which + makes this constraint tolerable. Without it, it would not be. + +## Rejected alternatives + +- **Client returns a handle, not bytes** (i.e. extend `neui_component_env_t::resolve_asset` to a + session-wide hook). Requires the client to have already built the asset, which needs the very + bytes path we are adding; and it cannot serve fonts / sidecars, which are not assets. +- **A VFS abstraction** (client implements open/read/seek/close). More power than the stated + requirement, needs the streaming decode path stb cannot provide, and pushes lifetime and + reentrancy complexity into the client. The blob form can be widened to this later without + breaking the interface. +- **Client pre-registers blobs up front** (`register_resource(name, bytes)`). Simple, but forces + the client to eagerly load everything it *might* need, which defeats the purpose for large + packs. Worth noting it is trivially implementable *on top of* this pull interface by a client. + +## Decisions taken (all approved 2026-07-31 - this section is the binding spec) + +1. **Order: client first, then the host.** The deciding argument is the motivating deployment, not + skinning: for a client whose assets live in a container (plugin bundle, embedded resource, MCU + flash) host-first would pay a guaranteed miss ladder - up to three failed decodes on paths that + will never exist - before asking the one place the bytes actually are. On an embedded target + there may be no filesystem at all. (Section "Resolution order".) +2. **Negative cache: sticky for v0**, cleared on DPI change / session teardown. No invalidation + entry point yet. (Section "Prerequisite".) +3. **Plain structs, no `struct_size`.** Matches `neui_component_env_t`; the request is + host-allocated and const, so appending a field stays safe for old clients, and the `/0` suffix + in the interface name is the version gate. Header documents "never copy the request by value". +4. **`KIND_SIDECAR` stays its own value**, distinct from `KIND_COMPONENT`, so a client can answer + off `kind` alone without parsing the name. +5. **`out->scale` is the whole scale protocol.** One call per (name, scale bucket); the client + picks what to return and declares its scale. No "ask me again at 1.0" round trip. +6. **All four hosts in v0** (xpl + win32 + macOS + iOS). Images land in the shared `AssetStore` + that all four already use; fonts / components go through one shared read-or-ask helper instead + of four copies. The only genuinely per-host work is `load_memory` in each `Loader` policy. +7. **Both component hooks are kept, chained `env.resolve_asset` -> `provide` -> filesystem.** + Primary reason: it is the only strictly backward-compatible order - `resolve_asset` already + runs first and already beats path mode, so inserting `provide` between it and the filesystem + changes nothing for existing code. Secondary: they answer different questions and neither + subsumes the other. `resolve_asset` answers "which existing handle does this name mean" and can + return a runtime-built compound / painted SURFACE / self-tagged filmstrip that has no byte form, + with borrowed handles shareable across documents; `provide` answers "here are the bytes". The + one overlapping case (component references an image whose bytes the client has embedded) is + handled *badly* by `resolve_asset` today - the client has bytes, not a path, so it would need + `create_bitmap` with pre-decoded pixels, i.e. its own image decoder. `neui_component_env_t` is + NOT deprecated: it is per-call with a per-call `user`, so two documents can load with different + asset tables, which a session-wide provider structurally cannot express. + Both hooks see the **same** name string - the raw entry from the component's JSON `assets` map, + never a `base_dir`-joined path - so one client lookup table can serve both. +8. **`kinds_mask` on the interface** (0 = all kinds). With client-first, a client that only + overrides images would otherwise be called for every font and component load. +9. **Client bytes that fail to decode / parse are treated as a miss**, and resolution continues to + the filesystem ladder rather than failing the load. Client-first means a buggy provider could + otherwise shadow a perfectly good file, and falling through costs nothing. +10. **`hosts/shared/` reuses the public request / bytes structs** rather than mirroring them (it + already includes the public headers for `neui_asset_kind_t` / `neui_render_backend_t`). The + store holds `void* user` + function pointers; each host installs a thunk that calls + `_resource_client->provide(_token, ...)`, so the store never learns about client tokens. + +## Implementation order (once approved) + +1. Resolution cache + negative caching in `AssetStore` / `AssetManager` (independent win, no API + change). Tier-1 test with a counting fake `Loader`. +2. `platform_load_image_bytes` seam + the four platform impls + `Loader::load_memory`. No + behaviour change yet; `image_loader_win32.h`'s resource path switches to it as the first + consumer. +3. `include/neui/d/resource.h` + `Session::_resource_client` acquisition in all hosts. +4. Wire the fallback: images (via `AssetStore`), then fonts / components / sidecars (via a shared + read-or-ask helper). +5. LVGL host: no `.rc` support of its own, so it becomes the natural demo - `neui_example`'s + `lemur.jpg` loads once a resource client is supplied. +6. Docs: `docs/rendering-and-assets.md` gets a "Client resource provider" section; CLAUDE.md's + optional-client-interface list gains `_RESOURCE_CLIENT`; example client in `examples/`. +7. Tier-1 tests: fallback order, at-most-once `provide` per (name, scale), scale reporting, + `release` pairing, negative caching. + +--- + +## Implementation notes (2026-07-31) + +Shipped in the planned order. Verified on Windows: the normal D2D build, both LVGL builds (32bpp +and RGB565) and the Tier-1 suite. macOS / iOS / Linux code was written against the existing +loaders in those trees but not compiled here (no toolchain on this machine) - the edits are the +mechanical mirror of the win32/stb ones. + +**New files** +- `include/neui/d/resource.h` - the public interface (also added to `neui.h` and CLAUDE.md's list). +- `hosts/shared/resource_provider.h` - `ResourceProvider` (the binding each host installs) plus + `with_bytes` / `read_bytes` / `read_file_bytes`. +- `tests/test_resource_provider.cpp` - 8 Tier-1 cases (order, at-most-once probing, negative + caching + `clear_image_routes`, scale bands, `kinds_mask`, `release` pairing, undecodable-bytes + fallthrough, absent-client no-op). +- `examples/resource_client_example.cpp` / `neui_resource_client_example` - builds a 96x96 BMP in + memory at startup and serves it under `generated.bmp`, a name that exists nowhere on disk. Drawn + twice: by an `NEUI_W_IMAGE` widget (`set_text`, lazy resolve on first paint) and by a CUSTOMDRAW + through an explicit `create_from_file` handle. A BMP avoids needing an encoder in the example. + +**Resolution cache** (step 1, the prerequisite): `AssetStore::image_route(name, scale)` returns a +cached `ImageRoute`, keyed on the scale BUCKET (`scale_bucket`: <=1 / <=2 / >2 - all `resolve_path` +actually branches on, so 1.25 / 1.5 / 1.75 share one entry). `AssetManager::get_bitmap` and +`get_logical_size` now route through it, which removes the per-frame-per-IMAGE-widget image decode +that `resolve_path` was paying to answer "which `@Nx` variant?". `load_pixels` takes an +`ImageRoute` instead of a path so the client-bytes branch shares it. + +**Decode-from-memory seam** (step 2): `platform_load_image_bytes` (declared in `platform.h`, +implemented in all five platform layers) + `Loader::load_memory` in all four policies. The +premultiply / rasterise tails were factored so path and memory forms share them: +`stb_rgba_to_bgra8_premul`, `wic_decode_frame_w32`, `cg_image_to_bgra8_premul` (x2 - the macOS and +iOS headers stay mutually exclusive per `TARGET_OS_IPHONE`, same ODR reasoning as +`free_image_bgra8`). `image_loader_win32.h`'s embedded-resource branch now decodes through +`load_image_bgra8_w32_memory`, so it is the first consumer of the new entry point. + +**Hook sites**: images in `AssetStore::probe_image_route`; fonts in +`AssetStore::allocate_font_from_file` (client bytes go to the in-memory `allocate_font`, so all +four hosts get it from the shared store rather than four copies); component documents at each +host's `create_component_from_file` (three lines each, via `read_bytes`); filmstrip sidecars via +`filmstrip_discover_from_path`'s new optional `const ResourceProvider*`. + +### Deviation from the approved spec (one) - SINCE CLOSED + +Decision 7 said both component hooks see the raw `assets`-map name plus `base_dir` separately. As +first implemented, an image referenced from inside a component document reached the provider as the +**`base_dir`-joined path**, because `build_component` holds only the public `neui_asset_api_t`, +whose `create_from_file` takes a path, so the join happened before the store (and therefore the +provider) was reached. + +**Closed the same day** by follow-up 1, the `ComponentApis` byte hook: + +- `ComponentApis` gained `bitmap_from_name(user, name, base_dir)` + `user`. When set it fully + replaces the `create_from_file(join_path(...))` branch in `build_component`'s `resolve_asset` + (going on to try the path form as well would only re-probe the same miss under a second cache + key). When unset - the Tier-1 fakes, any other embedder - the loader behaves exactly as before. +- All four hosts install `component_bitmap_from_name`, a three-line thunk calling + `AssetStore::allocate_from_file(name, scale, base_dir)`. +- `AssetStore` resolution became `base_dir`-aware end to end: `ImageRoute::base_dir`, `route_key` / + `client_cache_key` scoped by it (two documents may use one name for different images), the + provider request carries it (so `neui_resource_request_t::base_dir` is now live rather than + permanently NULL), and only the filesystem ladder joins it onto the name (`fs_name_of`, reusing + `cl_detail::join_path`). +- `env.resolve_asset` still runs first and is unaffected; the chain is unchanged in order, only in + what the provider is told. + +### Code-review fixes (2026-07-31, same day) + +A `/code-review --max` pass over the branch found the following, all fixed: + +- **Client routes collapsed across scale bands.** `ImageRoute::cache_key` for a client route was + `"\x01client\x01" + name` with no band in it, so the xpl `AssetManager`'s path-keyed `_cache` + served the first-resolved band's bitmap at every other scale. Now `client_cache_key(name, band)`. +- **`decode_route` re-asked with the wrong hint and dropped the reply's scale.** It passed the + cached `route.scale` as `scale_hint` (not the display scale) and ignored the `float` the provider + declared on the second call, so the entry could record one variant's scale while holding + another's pixels. `ImageRoute` now carries `req_scale`, and `decode_route` reports the decoded + scale out. +- **No filesystem fallback after the probe.** A route cached as `from_client` failed permanently if + the provider later declined, defeating decision 9 for every load but the probe. `decode_route` + now falls back to the `@Nx` ladder. +- **Sticky misses had no reachable invalidation** and decision 2's "clears on a DPI change" was + false for 125% <-> 200% (same band). `clear()` now drops routes with the assets, and an explicit + `allocate_from_file` re-probes a cached miss (the per-frame tier still does not) - which is + follow-up 2 solved without a new public API, since the regression only ever bit explicit, + client-initiated loads. +- **`ImageRoute::path` meant two different things** depending on `from_client`; split into `name` + and `file_path`. +- **The route cache was a `std::map`** with a `pair` key on the per-frame paint path; + now `unordered_map` with the band folded into the key string. +- **`with_bytes` leaked the borrow if `fn` threw** (every consumer ends in an allocation that can); + the provide/release pair is now RAII. +- **`wic_decode_frame_w32` had no overflow guard** on `stride * h` - reachable from client-supplied + bytes, where the sibling stb path is guarded. Now bounded in 64 bits, with a nothrow allocation + so an untrusted blob reports failure instead of throwing out of a paint. +- **`Session::set_focus` invalidated nothing when clearing focus** (it read `_focused_widget` after + overwriting it); the LVGL arm added `prev_focus` but left the other one broken. Hoisted. +- **The LVGL mirror sync hid whole subtrees**: a widget that stopped painting had its hidden mirror + used as its children's container. It now hands them to its own container with the offset folded + in, matching `paint_widgets_recursive` (whose only descent gate is `visible`). Reparenting also + switched from delete + rebuild to `lv_obj_set_parent`, which no longer leaves descendant + `MirrorEntry::obj` pointers dangling mid-pass. +- **`d/resource.h` documented a contract the code did not honour** (raw name + `base_dir` for + component assets; "at most once per resource per session" for every kind). The header now states + the shipped behaviour, including `base_dir` being reserved-and-always-NULL. +- Dead `` / `` includes removed from the five files whose `ifstream` use moved + into `resource_provider.h`. + +### Follow-ups + +1. ~~The `ComponentApis` byte hook~~ - done, see "Deviation ... SINCE CLOSED" above. `base_dir` is + live; the only remaining name-shape gap is that FONT / COMPONENT / SIDECAR loads still carry no + `base_dir` (they are addressed by path today and nothing references them from inside a document). +2. ~~Invalidation beyond sticky-until-DPI-change~~ - addressed by the re-probe on explicit loads plus + `clear()`. A `NEUI_API_ASSETS` "forget resolution failures" entry point is still the answer if a + client ever needs to re-resolve what the *per-frame* tier cached as missing. +3. ~~`allocate_from_file` decodes twice on a cold FILESYSTEM load~~ - closed: `resolve_path` takes an + optional `DecodedImage*` and the winning candidate's pixels are parked for the load that wanted + them. Same mechanism removes the second `provide()` on a cold client load. +4. Compile the macOS / iOS / Linux edits. diff --git a/plans/lvgl-host-approach-c.md b/plans/lvgl-host-approach-c.md new file mode 100644 index 0000000..8d060a2 --- /dev/null +++ b/plans/lvgl-host-approach-c.md @@ -0,0 +1,450 @@ +# Handoff: neui-on-LVGL host - Approach C prototype & evaluation + +Status: **EXPERIMENTAL - built and evaluated 2026-07-30, work PAUSED pending real hardware.** +All milestones (M0-M3) are done and the results are appended at the bottom of this file, but this +was always a **prototype + evaluation, not production**: the host is opt-in +(`-DNEUI_WITH_LVGL=ON`), several features are stubbed, and the two remaining items that matter (a +per-widget appearance cache and an embedded display driver / VGLite path) cannot be judged on a +desktop stand-in. They resume when target hardware is available. +Reader-facing status + build instructions: `docs/host-lvgl.md`. +Audience for the rest of this file: whoever picks that work up. + +## Goal (what "done" means for this handoff) + +Get neui's **crossplatform (xpl) host** rendering through **LVGL** using the retained-`lv_obj` +approach (Option C), **running on Windows on this dev machine**, so we can measure real FPS/CPU +and refine the performance estimate. The concrete deliverable is a running `neui_lvgl_example` +plus reported measurements (see Milestone 3). It does not need to be complete or embedded-ready. + +## How to work (important) + +- **Ask, don't guess.** When you reach an item in "Open questions" below (or any other real + fork), stop and ask the user with AskUserQuestion. Ask early rather than assuming. +- **Read first, in order:** this repo's `CLAUDE.md` (auto-loaded); **`lvgl.txt` section 1** (the + primitive-by-primitive backend spec + the D/W/G gaps - this is the real spec for the backend + work) and **section 6** (why Option C); `docs/rendering-and-assets.md` (the + `neui_render_backend_t` contract); `docs/host-linux.md` (the closest platform model - it draws + its own chrome); `plans/lvgl-port.md` (feature comparison + impedance mismatches); + `include/neui/d/renderer.h` and `painter.h` (the interfaces you implement/consume). +- **Measure in Release / RelWithDebInfo, not Debug** - Debug FPS is meaningless for estimation. +- Keep the build warning-clean (MSVC `/W4`, `C4100` suppressed), per `CLAUDE.md`. + +## Why Option C (brief; full detail in lvgl.txt sec 2 + 6) + +neui's xpl host is redraw-the-world immediate mode - whole-window invalidation, full surface +clear, full recursive tree walk, no dirty-rect, no widget cache - which is the dominant cost on +constrained hardware. Option C backs each neui widget with a **passive** `lv_obj` so LVGL owns +invalidation / dirty-rect / compositing while neui still owns widget logic, input, and pixels +(each object's draw event calls neui's existing per-widget paint). This prototype validates the +mechanism and measures it. (Rejected alternatives: A = one flat canvas, neui keeps whole-window +redraw, inherits the problem; B = map neui widgets onto `lv_button`/`lv_slider`/..., which +discards neui's widget set and hits severe feature gaps.) + +## Architecture - get this framing right + +LVGL is **not** a new registered host. It is three things: +1. a new backend `backends/lvgl/` -> `neui-backend-lvgl` implementing `neui_render_backend_t`; +2. a new xpl platform `hosts/crossplatform/platform_lvgl.cpp` implementing `xpl_host::*`; +3. **conditional retained-mode logic inside the shared xpl host** (`host.cpp`), active only on the + LVGL platform. + +The registered host stays `neui.host.crossplatform` (`neui_register_xplhost`). Backend + platform +are paired per build, exactly like Linux = `neui-backend-cairo` + `platform_linux.cpp`. Do not +invent a new host-registry id. + +## Build / CMake requirements (from the user) + +- Gate everything behind a CMake option: `option(NEUI_WITH_LVGL "Build the LVGL host + backend" OFF)`. +- When ON, pull LVGL via **FetchContent from git main** (`GIT_REPOSITORY https://github.com/lvgl/lvgl.git`, + `GIT_TAG master`); version pin is deferred - note it as a follow-up. Provide a repo-local + `lv_conf.h` (via `LV_CONF_PATH` or `LV_CONF_INCLUDE_SIMPLE`) enabling: a scalable font engine + (see Open questions), `LV_USE_PERF_MONITOR`, and the Windows display driver. +- **Configuration must FAIL (`message(FATAL_ERROR ...)`) if the LVGL host cannot run on the build + platform** - no silent fallback. On this Windows machine it must succeed. +- Windows display + input: prefer LVGL's native Windows backend (`LV_USE_WINDOWS`) to avoid an SDL + dependency (SDL2 is the fallback - ask if you'd rather). Drive `lv_tick` + `lv_timer_handler`. +- Add a prototype example target `neui_lvgl_example` that builds a representative measurement + screen (Milestone 3) and uses the crossplatform host (select via + `neui_get_api("neui.host.crossplatform")`, or link only the xpl host in this target). Existing + builds must be unaffected when `NEUI_WITH_LVGL=OFF` (the default). + +## The core mechanism (Phase 3) - the #1 risk, spike it FIRST + +**M0 spike (throwaway):** prove neui's backend can render into an LVGL per-object draw event. +In an `lv_obj`'s draw event (v9: `LV_EVENT_DRAW_MAIN`, get the target via `lv_event_get_layer(e)` +-> `lv_layer_t*`; verify against current main), wrap that layer as a `neui_render_ctx` and have +`neui-backend-lvgl` issue a `fill_rect` + `draw_text` at the object's coords. If clean, C is +viable. If not, **ask the user** before proceeding (documented fallback: draw all widgets into one +full-screen canvas = Approach A, still useful for estimation). + +Then build the retained layer: +- **Mirror tree:** each `WidgetData` gets a matching `lv_obj` (store the handle on `WidgetData` or + a side map keyed by widget id). Parent obj = parent widget's obj; z-order follows sibling order + (already matches neui's paint + hit-test order). Lifecycle choke points: `w_create` -> + `Tree::add_child` and `w_destroy` -> `Tree::remove` (`widgets.cpp:120-161, 166, 247`). +- **Per-object draw:** on the widget obj's draw event, bind the ctx to the event layer, set the + palette override, translate to the object's content origin, and call + `wd.paint(backend, ctx, is_focused)` then `paint_after_children`. Precedent: `CustomDrawWidget::paint` + already isolates via `translate(x,y)` + `push_clip` + a `neui_painter` (`host.cpp:2813-2855`). +- **Move parent-applied mechanics** out of `paint_widgets_recursive`/`paint_frame` onto the obj + layer: SECTION clip+scroll (`host.cpp:2133-2165`), disabled-dim alpha (`2107-2110`), the + `paint_after_children` compound pass (`2161-2163`). Keep abs-coord recompute (`2092-2093`) - it + is still needed for hit-testing. +- **Per-widget invalidation:** reroute `w_invalidate` (`widgets.cpp:692-703`) and + `WidgetData::repaint()` (`host.cpp:1464-1470`) - today both collapse to whole-window + `platform_invalidate` - to `lv_obj_invalidate()` on the LVGL platform. +- **Geometry/visibility:** hook `w_set_pos`/`w_set_size`/`w_show`/`w_hide` (`widgets.cpp:382-410, + 294/368`) plus the ad-hoc mutators (`TabViewWidget::apply_page_geometry` `host.cpp:1279-1282`; + the Windows WM_SIZE resize path) to move/resize/show the mirror obj. Reparenting: none exists + (`Tree` has add/remove only) - destroy+recreate is fine for the prototype. +- **Overlays as LVGL top-layer objects**, not per-widget canvases: combo drop + (`host.cpp:3635-3665`), popup menu (`3918-...`), toast (`4869-...`). In-frame menubar + (`4301-...`) is Linux-only; on Windows the native menu path is used, so it is out of scope here. + Modal dialogs already map to separate native windows. +- **Input stays neui's.** Keep `widget_at` / `dispatch_mouse_event` (`host.cpp:483-533, + 2329-2351`); the LVGL indev feeds frame-local pointer/keys into `platform_lvgl`, which calls the + existing session methods. Keep the `lv_obj`s passive - do **not** enable LVGL's own + widget input/focus/scroll behaviours on them (except where you deliberately use obj scroll for + SECTION). + +## Backend scope for the prototype (lvgl.txt sec 1 is the full spec) + +- **Needed to render the example:** begin/end_frame + clear, `fill_rect`/`draw_rect`, + `draw_text`/`measure_text`, `push/pop_clip`, `push/pop_alpha`, `push/pop_font`, `draw_bitmap`, + and the **path API** (`begin_path`/`move_to`/`line_to`/`arc`/`close_path`/`fill_path`/`stroke_path`) + which the KNOB needs. `get_scale_factor`/`update_dpi` can return a fixed scale. +- **Text** needs a scalable font engine for arbitrary sizes (Open question: FreeType vs Tiny TTF). +- **Paths:** KNOB arcs must render for a meaningful measurement. A small scanline rasteriser or + LVGL's own arc/line primitives may suffice for the prototype; ThorVG/VGLite is the production + path (Open question). A minimal `fill_path` stub is OK to get first pixels, but land real KNOB + arcs before Milestone 3. +- **Defer/stub:** offscreen surfaces, gradients, font registration, the filter graph. Compile out + DnD/clipboard/IME. + +## Milestones (each independently verifiable) + +- **M0 - Spike:** per-object draw-into-event bridge proven (throwaway code). +- **M1 - Bring-up / baseline:** `NEUI_WITH_LVGL=ON` configures + builds on Windows; neui paints + the whole frame into a single LVGL surface (de-facto Approach A) in a window with working mouse + + keyboard. This is the measurement **baseline**. +- **M2 - Option C:** retained `lv_obj` per widget + per-widget invalidation + overlays on the top + layer. Verify a hover / knob-drag invalidates **only that object's rect** (use + `LV_USE_REFR_DEBUG` / the perf monitor, or log invalidated area); an idle screen = **0 repaints**. +- **M3 - Evaluation:** `neui_lvgl_example` renders a representative screen (buttons, labels, a + SECTION, an INPUTBOX, one or two KNOBs, a block of text, optionally a small GRID) with + `LV_USE_PERF_MONITOR` on. Record FPS + CPU for (a) idle, (b) a knob drag, (c) a full-screen + change, for **both M1 (baseline) and M2 (Option C)**, in Release/RelWithDebInfo. Report the + numbers back so we can refine the estimate. + +## Verification + +- Configure fails cleanly with `NEUI_WITH_LVGL=ON` on an unsupported platform; succeeds here. +- `neui_lvgl_example` runs, shows a window, responds to mouse + keyboard. +- M2 dirty-rect proof: idle = 0 repaints; hover/knob-drag repaints only that widget's rect. +- Tier-1 header tests (`neui_tests`) still pass; win32/macOS/Linux builds unaffected with + `NEUI_WITH_LVGL=OFF`. + +## Open questions - ASK the user when you reach these (do not guess) + +1. **Font engine:** FreeType vs Tiny TTF. +2. **Path fill:** scanline rasteriser vs LVGL vector (ThorVG) for the prototype (VGLite is later / + embedded-only). +3. **Windows LVGL driver:** native `LV_USE_WINDOWS` (no SDL) vs SDL2. (Recommend native Windows.) +4. **Framebuffer depth for measurement:** XRGB8888 (matches desktop) vs RGB565 (matches MCU), or + measure both. +5. **If the M0 spike shows per-object draw-into-event is impractical:** fall back to Approach A for + the prototype, or rethink? +6. **Representative screen:** which widgets/layout best mirror the real product, so the measured + numbers transfer to the estimate? + +--- + +## RESULTS (executed 2026-07-30) + +Open questions were resolved with the user before implementation: **Tiny TTF** (font engine), +**ThorVG** via `LV_USE_VECTOR_GRAPHIC` (path fill), **native `LV_USE_WINDOWS`** driver (no SDL), +**XRGB8888**, and a **knob-heavy audio panel** as the M3 screen. LVGL fetched from git master +(9.6.0-dev, commit 066d8db0, 2026-07-30); `FetchContent_Declare` now **pins that commit hash** +rather than tracking `master` (post-review follow-up, done - see the code-review pass below). + +### What was built + +- `backends/lvgl/` - `neui-backend-lvgl`: full `neui_render_backend_t` over LVGL draw tasks. + Rects/borders via `lv_draw_fill`/`lv_draw_border`; text via `lv_draw_label` over per-(file, + size) Tiny TTF instances resolved from `C:\Windows\Fonts` by family+weight (registration API + stubbed); the whole path model (arcs flattened to cubics, fill rules, styled strokes, + linear/radial gradients on fill+stroke) via the ThorVG vector pipeline, with consecutive + path ops batched into a single vector task (see the RGB565 findings); clip stack by + save/intersect/restore of `layer->_clip_area`; SW 2x3 CTM (axis-aligned fast path, general + affine through the vector matrix); bitmaps as `ARGB8888_PREMULTIPLIED` image dscs (sub-rect + draws via a per-ctx arena that outlives the deferred draw tasks). Off-screen surfaces return + null (SURFACE assets degrade to `asset_none`), so the filter graph is unreachable - per plan. +- `hosts/crossplatform/platform_lvgl.cpp` - one LVGL display (own Win32 window+thread, driver- + managed) per neui frame; input captured by subclassing the driver HWND and queueing raw + messages to the main thread (the driver thread takes `lv_lock()` in its WndProc, so Session + calls must stay on the `lv_timer_handler` thread); drain replicates platform_win32's dispatch + (hit-test -> hover -> focus/pressed -> events, popup/toast/combo hooks, Tab traversal, + surrogate assembly, synthesized double-click - the LVGL window class lacks CS_DBLCLKS). + Clipboard/DnD/IME/menubar/message-box are stubs per plan; images decode via stb_image. +- **Option C retained layer** (runtime switch `NEUI_LVGL_RETAINED`, default ON; `0` = the M1 + whole-frame baseline in the same binary): a passive `lv_obj` mirror per widget, synced from + the widget tree by a dirty-flag walk that also maintains `abs_x/abs_y` (hit-testing) and gives + SECTION/TABVIEW a clipped body container obj (children positioned body-relative minus scroll). + Mirrors draw via `LV_EVENT_DRAW_MAIN` (`Session::paint_widget_retained` - palette bracket + + PREUPDATE + disabled-dim identical to the walk) and `LV_EVENT_DRAW_POST` + (`paint_after_children`); the screen obj paints frame bg below and overlays (combo drop, popup + menu, toast) above everything. Per-widget invalidation is routed through two `#ifdef + NEUI_PLATFORM_LVGL` seams (`platform_retained_widget_invalidate` / `_tree_changed`) called + from `WidgetData::repaint`, set_focus/hovered/pressed, set_text/set_asset/attr setters, + w_invalidate, and the structural mutators (create/destroy/show/hide/set_pos/set_size, + tab-page reflow, section scroll). Invalidations arriving inside a draw dispatch are deferred + (LVGL forbids invalidating while rendering) and flushed after `lv_timer_handler`. +- `neui_lvgl_example` (`examples/lvgl_example.cpp`) - the measurement screen: 8 KNOBs with live + value labels in a SECTION, channel buttons, checkbox, inputbox, text block; 'S' toggles a + full-screen all-knobs animation driven from WIDGET_PREUPDATE. + +### Milestone outcomes + +- **M0 spike: PASS.** Per-object draw-into-event works; deferred draw tasks require copying + transient data (`text_local=1`; vector paths are deep-copied at `add_path`); ThorVG arcs and + `_clip_area` clipping work inside draw events; `lv_obj_invalidate` bounds the dirty AREA to + the object (a disjoint sibling never repainted). Note: LVGL is not a retained pixel cache - + ancestors overlapping the dirty rect re-issue draw tasks clipped to it (correct compositing). +- **M1: PASS.** Whole-frame Approach A renders the full panel through LVGL; real mouse (drag, + click, hover), keyboard (typing into INPUTBOX incl. caret), and clean APP_QUIT close verified. +- **M2: PASS.** Pixel-parity with M1. Idle = 0 redraws; a knob drag repaints only the knob + + label rects; the KNOB right-click "Reset to default" popup renders above the mirrors, its + nested modal pump runs, and the item click resets the value. +- **M3 measurements** below. + +### M3 measurements + +RelWithDebInfo, 800x480, LVGL SW renderer + ThorVG, `LV_DEF_REFR_PERIOD 16` (~60 FPS cap; the +pump waits in 10 ms slices, so ~45 FPS is the practical ceiling), Windows 11 ARM64 +(Snapdragon-class desktop core). Numbers from `LV_USE_PERF_MONITOR` LOG_MODE; `render` is the +average per-refresh render time - the platform-transferable figure. Framebuffer depth is a +configure option (`-DNEUI_LVGL_COLOR_DEPTH=32|16`, template `backends/lvgl/lv_conf.h.in`); both +depths were measured with the same binary layout and the same screen (8 KNOBs + labels + +buttons + INPUTBOX + one IMAGE widget + font-check labels), with the backend's vector-task +batching (below) in place. + +**XRGB8888 (LV_COLOR_DEPTH 32):** + +| Scenario | M1 baseline (whole-frame) | M2 Option C (retained) | +|-------------------------------|-------------------------------|-----------------------------| +| (a) idle | 0 redraws, render 0 ms | 0 redraws, render 0 ms | +| (b) knob drag (rotational) | render ~21 ms, CPU ~67% | render **~1.4 ms**, CPU ~31% | +| (c) full-screen (8-knob anim) | render ~15 ms, ~54 FPS | render ~16 ms, ~54 FPS | + +**RGB565 (LV_COLOR_DEPTH 16):** + +| Scenario | M1 baseline (whole-frame) | M2 Option C (retained) | +|-------------------------------|-------------------------------|-----------------------------| +| (a) idle | 0 redraws, render 0 ms | 0 redraws, render 0 ms | +| (b) knob drag (rotational) | render ~31 ms, CPU ~79% | render **~3.0 ms**, CPU ~31% | +| (c) full-screen (8-knob anim) | render ~25 ms, ~36 FPS | render ~26 ms, ~34 FPS | + +Reading of the numbers: + +- **The Option C mechanism does what it exists to do at both depths**: a local interaction + costs the widget's rect, not the screen - drag render is ~15x cheaper than the whole-frame + baseline at 32 bpp and ~10x at 565. The dirty AREA ratio is ~26x (knob+label ~15 k px^2 vs + 384 k px^2); per-refresh fixed overhead dominates at small areas on a fast desktop CPU, and + on an MCU where per-pixel fill is the bottleneck the win scales toward the area ratio. This + is the bound that makes 200 MHz-tier local interactions tractable (lvgl.txt sec 3). +- **Full-screen cost is mode-independent** at both depths, as expected - Option C bounds what + must repaint; it cannot reduce the price of genuinely repainting everything. +- **Idle is free in both modes and both depths** (0 redraws). +- **RGB565's real cost is the vector fallback, not the pixel format.** LVGL's SW vector path + (`lv_draw_sw_vector.c`) renders ThorVG only into ARGB8888/XRGB8888 targets; on any other + format EVERY vector draw task allocates, clears, renders into and blends down a temporary + ARGB8888 buffer sized to the LAYER - and in direct render mode the layer is the full + framebuffer, so even a knob-sized repaint pays an 800x480 round-trip per task. Measured + before mitigation: 565 full-frame ~52 ms, 565 knob drag 10-17 ms. +- **Backend mitigation (implemented): vector-task batching.** The backend now coalesces + consecutive `fill_path` / `stroke_path` calls into ONE `lv_draw_vector` task (per-path + fill/stroke state set before each `add_path`; flushed at any non-path draw, clip change, or + end of dispatch, preserving z-order and clip semantics). A KNOB paint becomes 1 vector task + instead of ~6. Result: 565 full-frame ~52 -> ~25 ms, 565 knob drag ~10-17 -> ~3 ms. 32 bpp is + largely indifferent (no temp buffer on that path). +- The remaining 565-vs-32 drag gap (~3.0 vs ~1.4 ms) is the one leftover per-task full- + framebuffer round-trip; it would shrink if LVGL sized the temp buffer to the task clip + (upstream improvement opportunity), and disappears entirely on VGLite-class hardware - which + remains the production answer for path chrome on 565 targets (lvgl.txt sec 5). Text / rects / + images are unaffected by the 565 fallback, and the flush is ~2x cheaper (half the bytes). +- **IMAGE widget cost (measured by adding one to the screen)**: an LVGL SW downscale blit of a + 500x375 ARGB source into a 96x72 box costs ~10 ms per full-frame repaint at 32 bpp (stress + drops 16 -> ~5 ms with the image hidden; the pre-image tables measured ~6 ms full-frame). + Scaled image draws are per-frame transform work in LVGL's SW renderer - production wants + pre-scaled / cached blits (part of the appearance-cache recommendation below). Option C + already avoids the cost for local interactions (the image repaints only when its rect is + touched). +- Anchor for the tier table in lvgl.txt: a knob-heavy 800x480 full frame through the LVGL SW + renderer + ThorVG costs ~5-6 ms at 32 bpp on this desktop-class ARM core (~15 ms with a + naively-scaled photo on screen); ~25 ms at RGB565 due to the vector fallback. The M1-vs-M2 + *ratios* (not the absolute times) are the transferable result. + +### Post-evaluation verification additions (same session) + +- **IMAGE widget verified** (`neui_lvgl_example` bottom strip): myimage.png (500x375) drawn + into an exactly-4:3 96x72 box fills it edge to edge with correct colours + alpha - the + backend's `draw_bitmap` scaling and premultiplied-BGRA conversion are correct. This test + caught a real retained-mode bug: per-draw sub-image descriptors were arena-freed at the next + `bind_layer`, but retained mode binds per WIDGET while LVGL's deferred draw tasks from the + same refresh still reference them (use-after-free -> noise). Fixed by freeing them only + after the refresh completes (`neui_lvgl_backend::collect_deferred`, called from the + platform's loop turn after `lv_timer_handler`). +- **Font sizes verified em-accurate**: labels at NEUI_ATTR_FONT_SIZE 12/16/22/32 measure ink + (cap) heights of 8/11/15/24 px against the Segoe UI cap-height expectation of 0.70 em = + 8.4/11.2/15.4/22.4 px (+-1 px AA fringe) - Tiny TTF's `ScaleForMappingEmToPixels` matches + the DirectWrite em-size semantics. `NEUI_ATTR_FONT_FAMILY` resolves (Consolas renders + visibly monospaced). Caveat: Tiny TTF instances are cached per integer pixel size, so + fractional neui sizes quantize to <=0.5 px. + +### Known prototype limitations (deliberate) + +- Windows-only; configure hard-fails elsewhere (`NEUI_WITH_LVGL` + `message(FATAL_ERROR)`). +- Closing a frame hides its window instead of destroying it - the LVGL Windows driver's display + watchdog `exit(0)`s the process when the last display dies mid-loop. Fine for the prototype; + a production port would run its own display driver (as an embedded target would anyway). +- Clipboard / DnD / IME / native menubar / message boxes stubbed; off-screen surfaces (SURFACE / + filter graph) unavailable; font registration API returns false (family names resolve from the + Windows fonts directory instead); no DPI scaling (logical px == LVGL px == physical px); + dialogs are resizable. (Smooth-scroll kinetics ARE wired now - see the follow-up pass below.) +- Overlay changes (combo/popup/toast) still invalidate the whole frame - transient, acceptable. +- LVGL is pinned to commit `066d8db0` (the revision measured here). Move to a release tag when + one carrying the `lv_draw_vector_dsc_*` API lands. +- Synthetic-input note for test automation: PostMessage'd mouse input to the driver window works + fine, but the *sender* must set the same DPI awareness as the app + (`SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)`) - otherwise Windows DPI-virtualizes the + message coordinates on the way in and a click at (310, 42) arrives as (465, 63) at 150% + scaling. (An earlier note here blamed PostMessage itself; the cause was the missing awareness.) + +### Suggested next steps (if the direction is pursued) + +1. ~~Move the retained layer from prototype to reviewed design (reparenting, mid-order sibling + inserts)~~ **done** - mirror teardown, the LVGL commit pin, reparenting and child paint order + are all closed; see "Retained-layer + kinetics pass" below. +2. Per-widget appearance cache (render-once-to-image for static-but-expensive chrome) - the + biggest remaining MCU win per lvgl.txt sec 7. **Still open, and now the top item**: it is a + design change (per-widget canvas + an invalidation contract for what counts as "appearance"), + not a wiring job, so it wants its own plan rather than an incremental patch. +3. An embedded display driver (fbdev / vendor flush_cb) replacing the Windows driver, and a + VGLite draw-unit path for the vector half on RT1176-class silicon. **Still open**, and + unverifiable on a desktop - it needs the target hardware in the loop. +4. ~~Wire smooth-scroll kinetics (the shared `scroll_kinetics` on a 16 ms lv_timer)~~ **done** for + the scrolling SECTION and the GRID; the remaining stubs (clipboard / DnD / IME / native + menubar / message boxes / off-screen surfaces) are untouched, per the product's needs. + +### Retained-layer + kinetics pass (2026-07-31, after the code-review pass) + +- **Child paint order is now maintained** (`sync_children`). LVGL paints a container's children in + child-list order and both `lv_obj_create` and `lv_obj_set_parent` APPEND, so any object that + arrives in the middle of an existing sibling row used to draw on top of the siblings after it. + The walk already visits objects in paint order, so each one claims the next index in its + container (`lv_obj_move_to_index`, compared against `lv_obj_get_index` first so a steady-state + sync costs nothing and triggers no invalidation). Hidden mirrors count too - they stay in the + child list, so skipping them would shift every later sibling. Note the widget API cannot insert + mid-order today (`Tree::add_child` appends and nothing calls `add_after`); what made this live is + the hand-up path, where a widget that stops painting gives its children to its own container + mid-list. +- **Reparenting** now uses `lv_obj_set_parent` instead of delete + rebuild, which also removed a + latent use-after-free: `lv_obj_delete` takes the subtree with it, leaving every descendant's + `MirrorEntry::obj` dangling for the rest of that pass (and the sweep would delete it again). +- **Smooth-scroll kinetics wired** through the same shared integrators the other platforms use: + `section_kinetic_wheel_lvgl` (twin of `section_kinetic_wheel_w32` / `_linux`, including the + asymmetric single-axis fallback and the `NEUI_ATTR_SCROLL_KINETICS` gate) plus the GRID + SMOOTH-mode branch, with one 16 ms `lv_timer` per window driving spring-back for whichever of the + two is overscrolled (the toast timer's shape; it self-deletes when nothing is moving and is torn + down with the window). Invalidation from inside the timer goes through + `invalidate_widget_in_timer` - the LVGL lock is already held there, so it must not take + `LvLockGuard`, and `t_inside_lv` is raised so a client SCROLL_CHANGED handler that writes attrs + defers its invalidate instead of re-locking. +- **Verified by synthetic input** against `neui_section_scroll_example` temporarily pinned to the + xpl host (the LVGL-pinned example has no scrolling SECTION): stepped mode scrolls exactly + 4 notches x 3 lines x `SECTION_WHEEL_LINE_PX` = 240 px; smooth mode rubber-bands to + `Scroll: (0, -33)` on an overscroll at the top and springs back to `(0, 0)`, with the client's + SCROLL_CHANGED label updating throughout. Note for future automation: WM_MOUSEWHEEL's lParam is + in SCREEN coords while the button messages carry CLIENT coords, and `PrintWindow` renders the + whole window (title bar included) into the target DC - so a client-sized bitmap is offset by the + non-client frame. Derive click targets from the example's `create()` coordinates, not from + measured screenshot pixels. + +--- + +## CODE-REVIEW PASS (2026-07-31) + +A review of the prototype branch found 15 issues; all were fixed. Verified by rebuilding both +`neui_lvgl_example` and `neui_example` against the LVGL host (warning-clean) and comparing the +rendered result side by side with the D2D build of the same example. + +**Rendering parity (`backends/lvgl/lvgl_backend.cpp`)** - all four were visible in every frame: + +- `draw_text` drew at the TOP of the passed rect while D2D + (`DWRITE_PARAGRAPH_ALIGNMENT_CENTER`), Cairo and CG all vertically center the text block in it - + and the widget paints pass the full widget rect and rely on that. Now measures the block and + positions a tight box centered in the rect. +- `draw_text` word-wrapped at the rect width; D2D is explicitly `NO_WRAP` and Cairo / CG break only + on an explicit `\n`. Now sets `LV_TEXT_FLAG_EXPAND` (which also keeps the draw-task area tight, + so the label never wraps regardless of the box width) and clips at the rect. +- `measure_text` returned 0 for a null ctx, but two host sizing paths pass null deliberately + (`ComboBoxWidget::drop_width`, `popup_total_width`) exactly as they do to `d2d_measure_text` - + collapsing combo drop widths and popup menu widths to their minimums. `resolve_font` now + tolerates a null ctx (default family / weight, identity CTM). +- `draw_bitmap` mapped the tint's ALPHA byte to LVGL's recolor *mix weight* and never folded it + into the draw opacity, so a translucent tint rendered fully opaque and "no colour change" + became a 50% white wash. Alpha now scales `dsc.opa`; the RGB drives a full-strength recolor + (LVGL has no multiply - documented approximation). +- Gradients were pushed with their full stop list while `LV_GRADIENT_MAX_STOPS` defaulted to 2, so + every 3+-stop gradient silently became a two-colour ramp plus a per-frame warning on stdout. + `lv_conf.h.in` raises it to 16 and the backend clamps rather than letting LVGL log. + +**Threading / lifetime (`hosts/crossplatform/platform_lvgl.cpp`)**: + +- The WndProc subclass was installed before `w->prev_proc` was stored. The window is already being + pumped by the driver's own thread, so `subclass_proc` could run with a null chained proc. The old + proc is now read with `GetWindowLongPtrW` and stored first. +- `SetWindowTextW` / `SetWindowPos` / `EnableWindow` / `SetForegroundWindow` block until the target + window's thread processes the sent message, and that thread's WndProc takes `lv_lock` on entry - + so calling them from client code running inside a draw dispatch (which holds the lock for the + whole refresh) is a hard hang. They now queue through `defer_if_locked` and run from the main + loop after `lv_timer_handler` returns. +- The backend performed LVGL allocations from unlocked paths (Tiny TTF instance + glyph-cache fills + on the first use of a font size, which can happen in a click handler; the vector deletes in + `destroy_context`). The platform layer now installs a re-entrant lock/unlock pair via + `neui_lvgl_backend::set_lock_hooks`. +- `platform_destroy_window` only nulled `session`: the WndProc subclass and window prop stayed + installed, the `WindowData` stayed in `g_windows` with a stale hwnd, every mirror `lv_obj` leaked, + and for a still-open frame it dispatched `APP_QUIT` into a client being torn down. Added + `retire_window` (unsubclass, drop queued input for that window, `lv_obj_clean` the screen, remove + the screen draw callbacks, delete the toast timer, out of `g_windows`) plus + `close_window_silently` for the teardown path. The `WindowData` allocation is retired to a + graveyard rather than freed - an in-flight `subclass_proc` on the driver's thread may still hold + the pointer and there is no way to join that thread. + +**Build**: + +- The `WIN32 AND NEUI_WITH_LVGL` branch of `hosts/crossplatform/CMakeLists.txt` dropped the + `windowsapp` link that `host.cpp`'s C++/WinRT theme provider needs on every `_WIN32` build. It + linked only because `neui_lvgl_example` also pulls `neui-win32host`; a client linking just + `neui` + `neui-xplhost` got unresolved WinRT externals. +- The directory-scope warning scrub in `backends/lvgl/CMakeLists.txt` removed `/W4 /wd4100` but not + `/WX`, so `-DNEUI_WERROR=ON -DNEUI_WITH_LVGL=ON` turned the first LVGL / ThorVG warning into a + hard error - the exact outcome the scrub exists to prevent. +- `GIT_TAG master` pinned to the measured commit (see above). + +**Cleanups**: + +- `resolve_font` re-ran full font-file resolution (`GetWindowsDirectoryA` + string building + two + `std::map` lookups) on *every* `draw_text` / `measure_text`, i.e. hundreds of times + per refresh on the paint hot path. Added a front cache on `(family, weight, size_px)` and hoisted + the fonts directory into a function-local static. +- `platform_load_image` re-implemented the shared stb loader and dropped its size-overflow guard. + The Linux-only `hosts/shared/linux/image_loader_linux.h` was platform-neutral all along: moved to + `hosts/shared/image_loader_stb.h` (`load_image_bgra8_stb`), now used by both platform layers. + Note the LVGL host therefore cannot load `.rc`-embedded images the way the WIC-based native + Windows loader does (pre-existing; `neui_example`'s resource-only `lemur.jpg` shows the + failed-load placeholder). +- The wheel handler computed `is_horiz` then re-tested the same predicate to negate the delta, and + negated *before* handing it to `handle_combo_wheel` - so shift+wheel scrolled an open combo drop + list backwards relative to a plain wheel. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a95358f..58f8758 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -20,6 +20,7 @@ add_executable(neui_tests test_clipboard_item.cpp test_asset_font.cpp test_asset_filmstrip.cpp + test_resource_provider.cpp test_image_filter.cpp test_filter_graph.cpp test_backend_util.cpp diff --git a/tests/test_asset_filmstrip.cpp b/tests/test_asset_filmstrip.cpp index 823071a..f5530e9 100644 --- a/tests/test_asset_filmstrip.cpp +++ b/tests/test_asset_filmstrip.cpp @@ -24,6 +24,8 @@ namespace { // so allocate_bitmap is used directly and the loader is never invoked. ------ struct FakeLoader { static uint8_t* load(const char*, uint32_t*, uint32_t*) { return nullptr; } + static uint8_t* load_memory(const uint8_t*, size_t, uint32_t*, uint32_t*) + { return nullptr; } static void free_pixels(uint8_t*) {} }; @@ -37,6 +39,9 @@ struct SizedLoader { if (h) *h = s_h; return static_cast(std::calloc((size_t)s_w * s_h * 4u, 1)); } + static uint8_t* load_memory(const uint8_t*, size_t, uint32_t* w, uint32_t* h) { + return load(nullptr, w, h); + } static void free_pixels(uint8_t* p) { std::free(p); } }; uint32_t SizedLoader::s_w = 0; diff --git a/tests/test_component_loader.cpp b/tests/test_component_loader.cpp index c603e5e..2760809 100644 --- a/tests/test_component_loader.cpp +++ b/tests/test_component_loader.cpp @@ -7,6 +7,7 @@ #include "neui_test.h" #include "component_loader.h" +#include #include #include #include @@ -500,6 +501,56 @@ TEST_CASE("component_loader: asset resolution via env callback") CHECK(c.owned_assets.size() == 1); // only the path-loaded one is owned } +TEST_CASE("component_loader: the byte hook gets the raw name plus base_dir") +{ + // ComponentApis::bitmap_from_name is how a host reaches its client resource + // provider for a layer asset. The whole point is the name it receives: the raw + // "assets"-map entry with base_dir passed ALONGSIDE, never joined on - a client + // keying its container on "knob_bg.png" must not be asked for "res/knob_bg.png" + // (). The hook fully replaces path mode, so create_from_file + // must not run at all. + static std::vector> asked; // name, base_dir + asked.clear(); + + reset_recorders(); + neui_asset_api_t a; neui_compound_api_t c; neui_behavior_api_t b; + ComponentApis apis = make_fake_apis(a, c, b); + apis.bitmap_from_name = [](void*, const char* name, + const char* base_dir) -> neui_asset_t { + asked.emplace_back(name ? name : "", base_dir ? base_dir : ""); + if (std::string(name) == "knob_bg.png") { neui_asset_t r; r.id = 4242; return r; } + return asset_none; // "indicator" declines -> stays unresolved + }; + + neui_component_env_t env{}; + env.base_dir = "res"; + neui_session_t sess; sess.session = 1; + BuiltComponent got = build_component(sess, kKnobJson, + (uint32_t)std::strlen(kKnobJson), &env, apis); + + REQUIRE(asked.size() == 2); + CHECK(asked[0].first == std::string("knob_bg.png")); // raw, no "res/" + CHECK(asked[0].second == std::string("res")); // passed separately + CHECK(asked[1].first == std::string("knob_move.png")); + CHECK(g_loaded_files.empty()); // path mode never ran + CHECK(got.owned_assets.size() == 1); // the hook's handle is owned + CHECK(got.owned_assets[0].id == 4242u); + + // env.resolve_asset still wins over the hook (order: resolve_asset -> provider + // -> filesystem), and a borrowed handle is not owned. + asked.clear(); + reset_recorders(); + env.resolve_asset = [](void*, const char* name, const char*) -> neui_asset_t { + if (std::string(name) == "bg") { neui_asset_t r; r.id = 7777; return r; } + return asset_none; + }; + BuiltComponent got2 = build_component(sess, kKnobJson, + (uint32_t)std::strlen(kKnobJson), &env, apis); + REQUIRE(asked.size() == 1); // only "indicator" + CHECK(asked[0].first == std::string("knob_move.png")); + CHECK(got2.owned_assets.empty()); // 7777 is borrowed +} + TEST_CASE("component_loader: malformed json fails gracefully") { BuiltComponent c = run_loader("{ this is : not valid ] ]"); diff --git a/tests/test_resource_provider.cpp b/tests/test_resource_provider.cpp new file mode 100644 index 0000000..823cf20 --- /dev/null +++ b/tests/test_resource_provider.cpp @@ -0,0 +1,479 @@ +#include "neui_test.h" + +// Tier-1 coverage for the client resource provider (NEUI_API_RESOURCE_CLIENT, +// ) as wired into AssetStore (hosts/shared/) plus the +// (name, scale-bucket) resolution cache it rides on +// (plans/client-resource-provider.md). +// +// What matters here and is easy to regress: +// * order - client is asked BEFORE the filesystem ladder +// * caching - resolution is probed once per (name, scale band); in +// particular NOT once per paint, which is what the cache +// exists for +// * one decode - the probe's pixels are parked for the load that wanted +// them, so a cold load decodes (and provides) once +// * band keys - one name served by the client at two scales gets two +// cache keys, so the derived path-keyed caches cannot serve +// the wrong resolution +// * negative cache - a declining client is not re-asked per paint, while an +// explicit load re-probes; clear() drops outcomes too +// * kinds_mask - a client that only serves images is never asked for fonts +// * release - paired with every provide() that returned true +// * bad bytes - a blob that fails to decode falls through to the file +// rather than shadowing it, on EVERY load and not just the +// probe that decided the route +// +// Uses a counting fake loader + a scripted fake client, so no host, no backend +// and no real files are involved. + +#include "asset_store.h" + +#include +#include +#include +#include + +using namespace neui_detail; + +namespace { + +// --- Fake loader counting both entry points, with a scripted set of paths that +// "exist" so the @Nx ladder can be steered. -------------------------------- +struct CountingLoader { + static std::vector s_existing; // paths load() succeeds for + static int s_path_calls; + static int s_mem_calls; + static bool s_mem_fails; // simulate undecodable bytes + static uint32_t s_w, s_h; + + static void reset(std::vector existing = {}) { + s_existing = std::move(existing); + s_path_calls = 0; + s_mem_calls = 0; + s_mem_fails = false; + s_w = 8; s_h = 4; + } + + static uint8_t* alloc(uint32_t* w, uint32_t* h) { + if (w) *w = s_w; + if (h) *h = s_h; + return static_cast(std::calloc((size_t)s_w * s_h * 4u, 1)); + } + + static uint8_t* load(const char* path, uint32_t* w, uint32_t* h) { + ++s_path_calls; + for (const auto& p : s_existing) + if (p == path) return alloc(w, h); + return nullptr; + } + static uint8_t* load_memory(const uint8_t*, size_t, uint32_t* w, uint32_t* h) { + ++s_mem_calls; + if (s_mem_fails) return nullptr; + return alloc(w, h); + } + static void free_pixels(uint8_t* p) { std::free(p); } +}; +std::vector CountingLoader::s_existing; +int CountingLoader::s_path_calls = 0; +int CountingLoader::s_mem_calls = 0; +bool CountingLoader::s_mem_fails = false; +uint32_t CountingLoader::s_w = 8; +uint32_t CountingLoader::s_h = 4; + +// --- Scripted fake client. Records every request; answers the names in +// `serves` with a dummy blob at `reply_scale`. ------------------------------ +struct FakeClient { + std::vector names; // requested names, in order + std::vector kinds; + std::vector hints; + std::vector dirs; // request base_dir ("" if NULL) + std::vector serves; // names it has bytes for + float reply_scale = 0.0f; // 0 -> "treat as 1.0" + int provides = 0; // returned true count + int releases = 0; + uint8_t blob[4] = { 1, 2, 3, 4 }; + + void reset() { + names.clear(); kinds.clear(); hints.clear(); dirs.clear(); + provides = releases = 0; + } +}; + +FakeClient g_fc; + +bool NEUI_ABI fc_provide(void* token, const neui_resource_request_t* req, + neui_resource_bytes_t* out) { + auto* fc = static_cast(token); + fc->names.push_back(req->name ? req->name : ""); + fc->kinds.push_back(req->kind); + fc->hints.push_back(req->scale_hint); + fc->dirs.push_back(req->base_dir ? req->base_dir : ""); + for (const auto& s : fc->serves) { + if (s == fc->names.back()) { + out->data = fc->blob; + out->len = sizeof(fc->blob); + out->scale = fc->reply_scale; + out->release_token = fc; + ++fc->provides; + return true; + } + } + return false; +} + +void NEUI_ABI fc_release(void* token, const neui_resource_bytes_t* res) { + auto* fc = static_cast(token); + CHECK(res->release_token == fc); // cookie round-trips + ++fc->releases; +} + +// Build a store with the fake client installed. kinds_mask 0 = all kinds. +ResourceProvider make_provider(neui_resource_client_t& iface, uint32_t kinds_mask) { + iface = neui_resource_client_t{}; + iface.kinds_mask = kinds_mask; + iface.provide = fc_provide; + iface.release = fc_release; + ResourceProvider p; + p.client = &iface; + p.token = &g_fc; + return p; +} + +} // namespace + +TEST_CASE("resource provider: client is asked before the filesystem") +{ + CountingLoader::reset({ "knob.png" }); // the file DOES exist + g_fc = FakeClient{}; + g_fc.serves = { "knob.png" }; + + AssetStore store; + neui_resource_client_t iface{}; + store.set_resource_provider(make_provider(iface, 0)); + + uint32_t slot = store.allocate_from_file("knob.png", 1.0f); + CHECK(slot != 0); + + // Client won: it was asked, and the @Nx ladder never ran. + CHECK(g_fc.names[0] == "knob.png"); + CHECK_EQ(CountingLoader::s_path_calls, 0); + // Cold-load cost: the validating probe's pixels are PARKED and handed to the + // load that wanted them, so one provide() and one decode - not two of each. + CHECK_EQ((int)g_fc.names.size(), 1); + CHECK_EQ(CountingLoader::s_mem_calls, 1); + CHECK_EQ(g_fc.provides, 1); + CHECK_EQ(g_fc.releases, 1); // release paired with every hit +} + +TEST_CASE("resource provider: a cold filesystem load decodes once, not twice") +{ + CountingLoader::reset({ "bg.png" }); + AssetStore store; // no provider at all + + CHECK(store.allocate_from_file("bg.png", 1.0f) != 0); + // resolve_path has to decode a candidate to know it exists; those pixels are + // parked for the load rather than thrown away. + CHECK_EQ(CountingLoader::s_path_calls, 1); +} + +TEST_CASE("resource provider: client routes do not collapse across scale bands") +{ + CountingLoader::reset(); + g_fc = FakeClient{}; + g_fc.serves = { "logo.png" }; + + AssetStore store; + neui_resource_client_t iface{}; + store.set_resource_provider(make_provider(iface, 0)); + + // Same name, two display scales. The client is free to answer each band with + // different pixels, so the two routes must not share a cache_key - a + // path-keyed cache downstream stores one entry per key, and collapsing them + // serves the first band's bitmap at every other scale. + const std::string k1 = store.image_route("logo.png", 1.0f).cache_key; + const std::string k2 = store.image_route("logo.png", 2.0f).cache_key; + CHECK(store.image_route("logo.png", 1.0f).from_client); + CHECK(!k1.empty()); + CHECK(k1 != k2); + // ... and neither may be mistakable for a filesystem path. + CHECK(k1.find("logo.png") != std::string::npos); + CHECK(k1[0] == '\x01'); +} + +TEST_CASE("resource provider: base_dir is passed alongside the name, not joined") +{ + // The component-document path (ComponentApis::bitmap_from_name -> here). The + // client must be asked for the raw "assets"-map entry with the document's + // directory as base_dir; only the filesystem fallback joins them. + CountingLoader::reset({ "res/b/knob.png" }); // the joined file exists + g_fc = FakeClient{}; + g_fc.serves = { "knob.png" }; // client knows the RAW name + g_fc.dirs.clear(); + + AssetStore store; + neui_resource_client_t iface{}; + store.set_resource_provider(make_provider(iface, 0)); + + CHECK(store.allocate_from_file("knob.png", 1.0f, "res/a") != 0); + REQUIRE((int)g_fc.names.size() == 1); + CHECK(g_fc.names[0] == "knob.png"); // NOT "res/a/knob.png" + CHECK(g_fc.dirs[0] == "res/a"); + CHECK_EQ(CountingLoader::s_path_calls, 0); // client won, no ladder + + // A different document, same asset name: its own route, its own provider call + // (two components may use one name for different images). + CHECK(store.allocate_from_file("knob.png", 1.0f, "res/b") != 0); + REQUIRE((int)g_fc.names.size() == 2); + CHECK(g_fc.dirs[1] == "res/b"); + + // And with no provider answer, the ladder resolves base_dir + name. + g_fc.serves.clear(); + AssetStore store2; + neui_resource_client_t iface2{}; + store2.set_resource_provider(make_provider(iface2, 0)); + CHECK(store2.allocate_from_file("knob.png", 1.0f, "res/b") != 0); + CHECK(store2.image_route("knob.png", 1.0f, false, "res/b").file_path + == std::string("res/b/knob.png")); + // A bare name with no base_dir is a different route and does not exist. + CHECK_EQ((int)store2.allocate_from_file("knob.png", 1.0f), 0); +} + +TEST_CASE("resource provider: a client route still falls back to the file") +{ + CountingLoader::reset({ "logo.png" }); // the file is there all along + g_fc = FakeClient{}; + g_fc.serves = { "logo.png" }; + + AssetStore store; + neui_resource_client_t iface{}; + store.set_resource_provider(make_provider(iface, 0)); + + CHECK(store.allocate_from_file("logo.png", 1.0f) != 0); + CHECK(store.image_route("logo.png", 1.0f).from_client); + CHECK_EQ(CountingLoader::s_path_calls, 0); + + // The provider now stops answering (a transient container failure, a bug on a + // second call). The route is already cached as from_client, but the load must + // still succeed off the filesystem instead of failing for the rest of the + // session - "a buggy provider cannot shadow a good file" applies to every + // load, not only to the probe that decided the route. + g_fc.serves.clear(); + CHECK(store.allocate_from_file("logo.png", 1.0f) != 0); + CHECK(CountingLoader::s_path_calls > 0); +} + +TEST_CASE("resource provider: raw name and scale hint reach the client") +{ + CountingLoader::reset(); + g_fc = FakeClient{}; + g_fc.serves = { "knob.png" }; + g_fc.reply_scale = 2.0f; + + AssetStore store; + neui_resource_client_t iface{}; + store.set_resource_provider(make_provider(iface, 0)); + + CountingLoader::s_w = 16; CountingLoader::s_h = 8; + uint32_t slot = store.allocate_from_file("knob.png", 2.0f); + CHECK(slot != 0); + + // The name is passed through verbatim - NOT rewritten to "knob@2x.png". + CHECK(g_fc.names[0] == "knob.png"); + CHECK_EQ(g_fc.hints[0], 2.0f); + CHECK_EQ(g_fc.kinds[0], NEUI_RESOURCE_KIND_IMAGE); + + // The client's declared scale is what the entry records, so the asset's + // logical size is 16x8 / 2 = 8x4. + const AssetEntry* e = store.get_slot(slot); + REQUIRE(e != nullptr); + CHECK_EQ(e->scale, 2.0f); + CHECK_EQ((int)e->width_px, 16); + CHECK_EQ((int)e->height_px, 8); +} + +TEST_CASE("resource provider: a declining client is asked once, not per lookup") +{ + CountingLoader::reset({ "bg.png" }); + g_fc = FakeClient{}; + g_fc.serves = {}; // declines everything + + AssetStore store; + neui_resource_client_t iface{}; + store.set_resource_provider(make_provider(iface, 0)); + + CHECK(store.allocate_from_file("bg.png", 1.0f) != 0); + const int after_first = CountingLoader::s_path_calls; + const int asked_once = (int)g_fc.names.size(); + CHECK_EQ(asked_once, 1); + + // Repeat lookups of the same (name, scale band) must not re-probe: no more + // client calls and no more ladder decodes. This is the property that keeps a + // per-frame IMAGE resolve off the decoder. + for (int i = 0; i < 5; ++i) + CHECK(store.image_route("bg.png", 1.0f).found); + CHECK_EQ((int)g_fc.names.size(), asked_once); + CHECK_EQ(CountingLoader::s_path_calls, after_first); + CHECK_EQ(g_fc.releases, 0); // nothing to release on a decline + + // Fractional scales inside one band (>1 and <=2) share a single entry, so a + // 125% / 175% display does not multiply the probing. + CHECK(store.image_route("bg.png", 1.25f).found); + const int after_band = (int)g_fc.names.size(); + CHECK_EQ(after_band, asked_once + 1); // the >1 band probed once + CHECK(store.image_route("bg.png", 1.75f).found); + CHECK_EQ((int)g_fc.names.size(), after_band); // and only once +} + +TEST_CASE("resource provider: a missing resource is negatively cached") +{ + CountingLoader::reset(); // nothing exists anywhere + g_fc = FakeClient{}; + g_fc.serves = {}; + + AssetStore store; + neui_resource_client_t iface{}; + store.set_resource_provider(make_provider(iface, 0)); + + CHECK_EQ((int)store.allocate_from_file("nope.png", 1.0f), 0); + const int probes = CountingLoader::s_path_calls; // the @Nx ladder ran once + CHECK(probes > 0); + + // The per-frame resolve is where stickiness has to hold: an IMAGE widget + // pointing at a missing file must not re-ask the client - or re-run the ladder + // - on every paint. (An explicit allocate_from_file deliberately DOES get + // another look; see "an explicit load re-probes a cached miss".) + for (int i = 0; i < 5; ++i) + CHECK(!store.image_route("nope.png", 1.0f).found); + + CHECK_EQ((int)g_fc.names.size(), 1); // asked once, ever + CHECK_EQ(CountingLoader::s_path_calls, probes); // ladder not re-run + + // clear_image_routes drops the sticky miss so a late-published resource can + // still appear (the v0 invalidation story). + store.clear_image_routes(); + CHECK_EQ((int)store.allocate_from_file("nope.png", 1.0f), 0); + CHECK_EQ((int)g_fc.names.size(), 2); +} + +TEST_CASE("resource provider: an explicit load re-probes a cached miss") +{ + CountingLoader::reset(); // late.png does not exist yet + AssetStore store; // no provider - filesystem only + + CHECK_EQ((int)store.allocate_from_file("late.png", 1.0f), 0); + + // The per-frame resolve stays sticky: a repaint must not re-run the @Nx ladder. + const int after_miss = CountingLoader::s_path_calls; + for (int i = 0; i < 5; ++i) CHECK(!store.image_route("late.png", 1.0f).found); + CHECK_EQ(CountingLoader::s_path_calls, after_miss); + + // An explicit create_from_file is a client-initiated load, so it gets another + // look - a file written after the first attempt (a downloader, a save-then- + // reload, a designer tool rewriting an asset) must not stay unloadable for the + // rest of the session. + CountingLoader::s_existing.push_back("late.png"); + CHECK(store.allocate_from_file("late.png", 1.0f) != 0); + CHECK(store.image_route("late.png", 1.0f).found); +} + +TEST_CASE("resource provider: clear() drops resolution outcomes with the assets") +{ + CountingLoader::reset(); + AssetStore store; + + CHECK(!store.image_route("later.png", 1.0f).found); + CountingLoader::s_existing.push_back("later.png"); + CHECK(!store.image_route("later.png", 1.0f).found); // still the cached miss + + store.clear(nullptr); // full asset reset + CHECK(store.image_route("later.png", 1.0f).found); +} + +TEST_CASE("resource provider: different scale bands resolve independently") +{ + CountingLoader::reset({ "k.png", "k@2x.png" }); + g_fc = FakeClient{}; + g_fc.serves = {}; + + AssetStore store; + neui_resource_client_t iface{}; + store.set_resource_provider(make_provider(iface, 0)); + + const AssetStore::ImageRoute& r1 = store.image_route("k.png", 1.0f); + CHECK(r1.file_path == "k.png"); + CHECK_EQ(r1.scale, 1.0f); + + const AssetStore::ImageRoute& r2 = store.image_route("k.png", 2.0f); + CHECK(r2.file_path == "k@2x.png"); + CHECK_EQ(r2.scale, 2.0f); + + // Two bands -> the client was consulted once per band, not once per variant. + CHECK_EQ((int)g_fc.names.size(), 2); +} + +TEST_CASE("resource provider: kinds_mask keeps a client off the kinds it declines") +{ + CountingLoader::reset({ "x.png" }); + g_fc = FakeClient{}; + g_fc.serves = { "x.png", "font.ttf" }; + + AssetStore store; + neui_resource_client_t iface{}; + // Images only: the font path must not reach the client at all. + store.set_resource_provider(make_provider(iface, NEUI_RESOURCE_MASK_IMAGE)); + + const ResourceProvider& p = store.resource_provider(); + CHECK(p.serves(NEUI_RESOURCE_KIND_IMAGE)); + CHECK(!p.serves(NEUI_RESOURCE_KIND_FONT)); + CHECK(!p.serves(NEUI_RESOURCE_KIND_COMPONENT)); + CHECK(!p.serves(NEUI_RESOURCE_KIND_SIDECAR)); + + CHECK(store.allocate_from_file("x.png", 1.0f) != 0); + const int image_asks = (int)g_fc.names.size(); // probe + fetch + + // A masked-out kind must not reach the client even though it has bytes for + // that name: read_bytes goes straight to the (non-existent) file and fails. + std::string out; + CHECK(!p.read_bytes(NEUI_RESOURCE_KIND_FONT, "font.ttf", out)); + CHECK_EQ((int)g_fc.names.size(), image_asks); + for (auto k : g_fc.kinds) CHECK_EQ(k, NEUI_RESOURCE_KIND_IMAGE); + + // mask 0 == every kind. + neui_resource_client_t all{}; + store.set_resource_provider(make_provider(all, 0)); + const ResourceProvider& q = store.resource_provider(); + CHECK(q.serves(NEUI_RESOURCE_KIND_IMAGE)); + CHECK(q.serves(NEUI_RESOURCE_KIND_FONT)); + CHECK(q.serves(NEUI_RESOURCE_KIND_SIDECAR)); +} + +TEST_CASE("resource provider: undecodable client bytes fall through to the file") +{ + CountingLoader::reset({ "logo.png" }); // the file is fine + CountingLoader::s_mem_fails = true; // the client's blob is not + g_fc = FakeClient{}; + g_fc.serves = { "logo.png" }; + + AssetStore store; + neui_resource_client_t iface{}; + store.set_resource_provider(make_provider(iface, 0)); + + uint32_t slot = store.allocate_from_file("logo.png", 1.0f); + CHECK(slot != 0); // loaded - from the FILE + + const AssetStore::ImageRoute& r = store.image_route("logo.png", 1.0f); + CHECK(!r.from_client); + CHECK(r.file_path == "logo.png"); + CHECK(CountingLoader::s_path_calls > 0); + CHECK_EQ(g_fc.releases, 1); // the rejected blob was released +} + +TEST_CASE("resource provider: absent client leaves resolution untouched") +{ + CountingLoader::reset({ "a.png" }); + AssetStore store; // no provider installed + + CHECK(store.allocate_from_file("a.png", 1.0f) != 0); + CHECK_EQ((int)store.allocate_from_file("b.png", 1.0f), 0); + CHECK_EQ(CountingLoader::s_mem_calls, 0); // byte path never entered +}