diff --git a/CLAUDE.md b/CLAUDE.md index 4734c47..c9f0b6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**neuilib** is an early-stage C/C++ GUI framework separating a client C API from platform host implementations. Windows, macOS, and Linux (X11 + Cairo, via the crossplatform host) are implemented; any other platform falls back to a null platform layer. Tier-1 unit tests in `tests/`. No dedicated linter; instead the build runs at a high warning level as the static-analysis safety net (MSVC `/W4`, GCC/AppleClang `-Wall -Wextra`), with `C4100`/`-Wunused-parameter` suppressed (fixed-signature params) - keep the build warning-clean. +**neuilib** is an early-stage C/C++ GUI framework separating a client C API from platform host implementations. Windows, macOS, Linux (X11 + Cairo, via the crossplatform host) and **iOS / iPadOS** are implemented; any other platform falls back to a null platform layer. iOS ships two hosts (native UIKit + crossplatform) and an iOS-only extension interface - **see `docs/host-ios.md`**. Tier-1 unit tests in `tests/`. No dedicated linter; instead the build runs at a high warning level as the static-analysis safety net (MSVC `/W4`, GCC/AppleClang `-Wall -Wextra`), with `C4100`/`-Wunused-parameter` suppressed (fixed-signature params) - keep the build warning-clean. ## Build @@ -24,7 +24,7 @@ cmake -B out/build -G Xcode && cmake --build out/build --config Debug Outputs - Windows: `out/build/Debug/{neui_example.exe, neui.lib, neui-win32host.lib, neui-xplhost.lib, neui-backend-d2d.lib}`. macOS: `out/build/Debug/{neui_example.app, libneui.a}` + per-subdir `libneui-*.a`. Example apps (CMake targets): `neui_example`, `neui_grid_example`, `neui_section_scroll_example`, `neui_surface_example`, `neui_dnd_example`, `neui_dnd_source_example`, `neui_tabview_example`, `neui_font_loading_example`, `neui_filter_knob_example`, `neui_path_example`, `neui_overlay_example` (transparent CUSTOMDRAW overlay - DirectComposition on win32), `neui_arc_example` (value-driven arc / ring / pie compound layer), and `readme_example`. -**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). +**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). **On iOS the suite is bundled as an app** that CI installs and launches on a simulator, so the UIKit-backed `tests/test_ios_api_device.mm` runs on every push. **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`. @@ -33,6 +33,7 @@ Outputs - Windows: `out/build/Debug/{neui_example.exe, neui.lib, neui-win32host. - **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`**. +- **iOS / iPadOS**: `neui-ioshost` (native UIKit) + `neui-xplhost`; backend `neui-backend-cg`; xpl platform `platform_ios.mm`. Both are registered and can be linked together - **see `docs/host-ios.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`**. @@ -40,25 +41,26 @@ Top-level CMakeLists gates each platform-specific subdirectory; the example link ## 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`, `resource.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`, `ios.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`, `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}/`. +- **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,ios}/` - the iOS directory also holds `ios_api.h`, the whole `NEUI_API_IOS` implementation shared by both iOS hosts. - **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}`. +- **iOS Host** `hosts/ios/`: native UIKit host (`neui.host.ios`). `host.{h,mm}` Session + `get_interface`; `widgets.mm` full API; `window.mm` (`UIWindow` / `NEUINativeIOSViewController` / `NEUINativeIOSContentView` / `NEUINativeIOSPaintedView`); `application_ios.mm` (`NEUIApplication`, iPad menu-bar contribution); `asset_manager_ios.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,ios.mm,linux.cpp,null.cpp}`. - **Backends** `backends/`: `d2d/` (Direct2D), `cg/` (CoreGraphics), `cairo/` (Linux software), `null/` (no-op). Backend-agnostic math in `backends/shared/backend_util.h`. Backend interface + per-backend draw/path/gradient/surface/font details: `docs/rendering-and-assets.md`. ## Events (`d/events.h`) -App: `APP_QUIT`. Mouse: `MOUSE_MOVE/ENTER/LEAVE`, `MOUSE_BUTTON_DOWN/UP/CLICK/DBLCLICK`, `MOUSE_RBUTTON_DOWN/UP`, `MOUSE_WHEEL`. Key: `KEYDOWN/KEYCHAR/KEYUP`. Widget: `WIDGET_UPDATED/PREUPDATE/FOCUS/PAINT`, `CHECKBOX_CHANGED`, `RESIZE`, `VALUE_CHANGED`, `ATTR_CHANGED`, `SCROLL_CHANGED`, `METRICS_CHANGED`, `GESTURE_BEGIN/END`. Item: `ITEM_SELECTED`. Tree: `TREE_ITEM_SELECTED/ACTIVATED`. Grid: `GRID_ROW_SELECTED`, `GRID_CELL_SELECTED`, `GRID_CELL_CLICKED`, `GRID_ROW_ACTIVATED`, `GRID_COLUMN_RESIZED`, `GRID_SORT_CHANGED`, `GRID_CELL_EDIT_BEGIN`, `GRID_CELL_CHANGED`, `GRID_CELL_EDIT_CANCEL`. DnD: `DND_ENTER/MOVE/LEAVE/DROP`. Tab: `TAB_DESELECTED/SELECTED`. +App: `APP_QUIT`. Mouse: `MOUSE_MOVE/ENTER/LEAVE`, `MOUSE_BUTTON_DOWN/UP/CLICK/DBLCLICK`, `MOUSE_RBUTTON_DOWN/UP`, `MOUSE_WHEEL`. Key: `KEYDOWN/KEYCHAR/KEYUP`. Widget: `WIDGET_UPDATED/PREUPDATE/FOCUS/PAINT`, `CHECKBOX_CHANGED`, `RESIZE`, `VALUE_CHANGED`, `ATTR_CHANGED`, `SCROLL_CHANGED`, `METRICS_CHANGED`, `GESTURE_BEGIN/END`. iOS-only: `IOS_ENVIRONMENT_CHANGED` (its own category, `d/ios.h`). Item: `ITEM_SELECTED`. Tree: `TREE_ITEM_SELECTED/ACTIVATED`. Grid: `GRID_ROW_SELECTED`, `GRID_CELL_SELECTED`, `GRID_CELL_CLICKED`, `GRID_ROW_ACTIVATED`, `GRID_COLUMN_RESIZED`, `GRID_SORT_CHANGED`, `GRID_CELL_EDIT_BEGIN`, `GRID_CELL_CHANGED`, `GRID_CELL_EDIT_CANCEL`. DnD: `DND_ENTER/MOVE/LEAVE/DROP`. Tab: `TAB_DESELECTED/SELECTED`. `WIDGET_PAINT` fires only on `NEUI_W_CUSTOMDRAW` (and only when no compound asset is attached). `CHECKBOX_CHANGED` fires on every user-driven toggle. `VALUE_CHANGED` is the widget-scoped user-driven event for native KNOB / SLIDER; `ATTR_CHANGED` (`{ widget, attr_key, value }`) is the parallel event a behavior asset fires when it writes through. `GESTURE_BEGIN` / `GESTURE_END` (`{ widget, attr_key, value }`) bracket a run of user-driven value changes for host-automation begin/end edits (VST3 `beginEdit`/`endEdit`, CLAP `GESTURE_BEGIN/END`): a pointer grab on KNOB / SLIDER / a behavior `DRAG_*` handler pairs grab..release (even when the value never moves); one-shot changes (wheel tick, value keys, double-click / context reset, `CLICK_*`) fire an implicit begin+change+end triple only when the value actually moved; programmatic sets never fire them. `KEYDOWN.modifiers` + accelerator modifiers share `NEUI_KMOD_*` bits. `NEUI_MK_*` mouse-modifier bits (matching Win32 `MK_*`) live in ``; `NEUI_MK_ALT` reserved but not yet populated. Every event payload carries a `.widget` - see **Writing client code** below. **Mouse / wheel `x`/`y` are WIDGET-local on every host** (origin = the `.widget` top-left, matching `WIDGET_PAINT` and the DnD payload): the native hosts get this from the per-widget HWND/NSView; the single-surface xpl host translates frame-local input to the target's origin in `dispatch_mouse_event` / `dispatch_wheel_event` (the internal `WidgetData::on_mouse_event` handlers still run in frame-local and subtract `abs_x/abs_y` themselves). ## Key Design Patterns -- **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`, `_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. +- **Host registry** - `neui_register(id, api)` at startup; `neui_get_api(NULL)` returns first registered. IDs: `"neui.host.win32"`, `"neui.host.macos"`, `"neui.host.ios"`, `"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` / `_ioshost`); 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`, `_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. `_IOS` (`d/ios.h`, iOS specialities - idle timer, screen-edge gestures, status bar, orientation, accessibility, battery / thermal, haptics, keyboard inset) is exposed by the two iOS hosts only. **Per-host seams are ADDED, not assigned** (`NEUI_API_IOS` and `NEUI_API_METRICS` both): two hosts for one platform can be linked together and xpl registers last, so a single slot silently ends up holding xpl's - see `docs/host-ios.md`. - **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`. @@ -176,6 +178,7 @@ Deep per-subsystem detail lives in `docs/`. **Read the relevant file before doin - **Menus, routed commands, dialogs, notifications** -> `docs/menus-commands-dialogs.md`. Routed commands, popup menus, modal dialogs, toasts + message boxes, keyboard shortcuts / accelerators. - **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. +- **iOS / iPadOS host internals** -> `docs/host-ios.md`. The two iOS hosts, what each already handles (safe area, Dynamic Type, dark mode), the iOS-only `NEUI_API_IOS`, and the both-hosts-in-one-binary seam rule. **iOS is implemented - anything calling it unported is stale.** - **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`. diff --git a/TODO.md b/TODO.md index 8cbe8ff..d3cfa8b 100644 --- a/TODO.md +++ b/TODO.md @@ -146,8 +146,9 @@ Remaining: - **Multi-level redo on win32 native.** `NEUI_CMD_REDO` maps to `EM_UNDO` (single-level toggle). Clients that need multi-level redo should select the xpl host's text widgets (full `EditHistory`). -- **Other platform ports** (Linux/X11, Linux/Wayland, iOS, Android, - embedded). Playbook in `plans/how-to-port.md`. +- **Other platform ports** (Linux/Wayland, Android, embedded). Playbook + in `plans/how-to-port.md`. Linux/X11 and iOS/iPadOS are DONE - iOS ships + two hosts plus the `NEUI_API_IOS` extension; see `docs/host-ios.md`. ## Theme diff --git a/docs/attributes.md b/docs/attributes.md index b3d8186..3de0e10 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -2,7 +2,7 @@ ## Attribute API -`NEUI_API_ATTRS`. String-keyed bag per widget (`std::unique_ptr` on `WidgetData`, lazy). API: `set_int`/`get_int(default)`, `set_float`/`get_float(default)`, `set_string`/`get_string`, `has`, `remove`. Type-strict: wrong-kind returns the default. Well-known keys are debug-asserted to match their documented kind at set time via `k_well_known_attrs` (`hosts/shared/attrs.h`); release silently stores the wrong kind so reads keep returning the default. **A new `NEUI_ATTR_*` / `NEUI_PARAM_*` macro needs a matching row in `k_well_known_attrs`.** Session-level: `set_session_int`/`get_session_int`. `NEUI_ATTR_THEME_MODE` is the only session key with behaviour today. +`NEUI_API_ATTRS`. String-keyed bag per widget (`std::unique_ptr` on `WidgetData`, lazy). API: `set_int`/`get_int(default)`, `set_float`/`get_float(default)`, `set_string`/`get_string`, `has`, `remove`. Type-strict: wrong-kind returns the default. Well-known keys are debug-asserted to match their documented kind at set time via `k_well_known_attrs` (`hosts/shared/attrs.h`); release silently stores the wrong kind so reads keep returning the default. **A new `NEUI_ATTR_*` / `NEUI_PARAM_*` macro needs a matching row in `k_well_known_attrs`.** Session-level: `set_session_int`/`get_session_int`. Two session keys have behaviour today: `NEUI_ATTR_THEME_MODE`, and `NEUI_IOS_CHECKBOX_STYLE` (`neui.ios.checkbox_style`, read once at checkbox creation by the native iOS host). **Well-known keys** (all `neui.attr.`; macros `NEUI_ATTR_*`): @@ -46,7 +46,7 @@ | `grid.scroll_mode` | int | GRID | Wheel kinetics. `NEUI_GRID_SCROLL_PLATFORM=0` (default - macOS = smooth, Win32/null = stepped), `_STEPPED=1` (row-quantized, hard-clamp), `_SMOOTH=2` (pixel-precise + rubber-band + 60 Hz spring-back). Live. Superseded by `scroll_kinetics` when both are set; kept as a GRID-only back-compat alias. | | `scroll_kinetics` | int | SECTION, GRID | Generic wheel-kinetics selector. `NEUI_SCROLL_KINETICS_PLATFORM=0` (default - macOS = smooth, Win32/null = stepped), `_STEPPED=1` (hard-clamp, no rubber-band, no momentum), `_SMOOTH=2` (rubber-band + 60 Hz spring-back). Numeric values match `NEUI_GRID_SCROLL_*`. On SECTION, STEPPED resyncs the kinetics integrator (via `section_scroll_step_px`) so a later flip to SMOOTH starts cleanly. On GRID this attr takes precedence over `grid.scroll_mode` when both are set. Live. | -Namespace `neui.attr.*` reserved; clients use their own. Host-specific reserved: `neui.win32.*`, `neui.macos.*`, `neui.linux.*`. Unknown keys stored but inert. +Namespace `neui.attr.*` reserved; clients use their own. Host-specific reserved: `neui.win32.*`, `neui.macos.*`, `neui.linux.*`, `neui.ios.*`. Unknown keys stored but inert. `neui.ios.*` keys are session-level and exempt from `k_well_known_attrs`; the rest of the iOS-specific surface is an interface, not attributes - `NEUI_API_IOS` (`d/ios.h`), see `docs/host-ios.md`. ## Scroll API diff --git a/docs/design-notes.md b/docs/design-notes.md index d6954f9..a61f49b 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -140,6 +140,16 @@ verbatim text remains in git history** if the complete narrative is ever needed. - **Classic core-button scroll stays active and is suppressed only after the first real XI2 scroll arrives** (`g_xi2_scroll_seen`) - servers/XWayland without scroll valuators degrade cleanly to stepped scroll and never double-count. The same flag flips the PLATFORM kinetics default to SMOOTH on Linux, so the default is data-driven by actual device capability rather than hardcoded per-OS. - **Reused the existing host-neutral kinetics math** (`scroll_kinetics.h` / `grid_model.h` / `widget_section_scroll.h`) and the existing `dispatch_wheel_event` ancestor-routing - the Linux job was only feeding pixel-precise deltas and extending the existing 16 ms timerfd heartbeat to step active grid/section bounces. +## iOS host extension (`NEUI_API_IOS`) + +- **An interface, not more `neui.ios.*` session keys**: `NEUI_IOS_CHECKBOX_STYLE` stays - a write-once creation-time rendering choice is what a session attribute is for. It does not generalise: `set_session_int` is int-only (no float for brightness), carries no frame argument, cannot express an action like firing a haptic, and reads back what the client wrote rather than what the device holds. +- **Safe-area insets deliberately left out**: already portable via `NEUI_API_METRICS`, already real on both iOS hosts, and an Android host can implement the same seam from `WindowInsets`. Mirroring them into an iOS-shaped API would make a portable concept look platform-specific. +- **One event with a bitmask, not eight event types**: orientation, power, thermal, battery, accessibility and keyboard change independently and rarely, so the client re-reads what it cares about. Its own event category keeps iOS-only events filterable; Dynamic Type stayed on `METRICS_CHANGED` where clients already handle it. +- **Shared implementation + per-host seams**: UIKit's globals are identical on both iOS hosts, which differ only in resolving a frame to its `UIViewController` and in walking their own registry - the same shape `hosts/shared/metrics.h` uses. Frame chrome lives in a shared table keyed by widget id rather than in two different `WidgetData` structs. +- **Seams are registries, not slots**: `neui_init()` registers the native host then xpl, so an assigned slot always ends up holding xpl's, and for a native-host client it fails silently. This bit `NEUI_API_METRICS` too - `safe_area_insets` measured zeros in `examples/ios`, masked because `get_client_rect` computes its top inset directly. Both now ADD; the frame lookups try each until one claims the frame. +- **Never handed out inert**: the xpl host returns `NEUI_API_EMBED` on iOS although embedding is unsupported there, so a NULL check passes on a dead capability. `NEUI_API_IOS` implements every method and reports failure in the return value instead. +- **Costs stated at the call site**: the first battery query turns on `batteryMonitoringEnabled` for the process; `set_screen_brightness` writes a system-wide setting, so the host restores it on session teardown. The idle-timer hold is refcounted per session for the same reason. + ## Crossplatform host (early sketch) *This was an early design sketch, superseded by the shipped xpl host - it captured initial intent, not the final implementation. Retained here only for the founding premises.* diff --git a/docs/host-ios.md b/docs/host-ios.md new file mode 100644 index 0000000..3f2afbc --- /dev/null +++ b/docs/host-ios.md @@ -0,0 +1,103 @@ + + +## iOS / iPadOS hosts + +> **Implemented and shipping.** Anything in this repo still calling iOS unported is stale. +> CI builds both hosts and runs the unit suite on a simulator on every push. + +### Two hosts + +| | `neui.host.ios` | `neui.host.crossplatform` on iOS | +|---|---|---| +| Where | `hosts/ios/` (`neui-ioshost`) | `platform_ios.mm` (`neui-xplhost`) | +| Widgets | native UIKit controls, one `UIView` each | painted into one `NEUIView` per frame | +| Pick it when | it should feel like an iOS app | it must match the desktop build pixel for pixel | + +Both use `neui-backend-cg`. `neui_init()` registers **ios first**, so `neui_get_api(NULL)` +returns the native one; ask by id and fall back to `"neui.host.crossplatform"`. Both can be +linked into one binary — `examples/ios` does — which is what the seam rule below is about. + +Object graph, same shape on both: `UIWindowScene` → `UIWindow` (owned by the frame's +`WidgetData`, `+1` retained) → `NEUINativeIOSViewController` / `NEUIViewController` → +content view → per-widget `UIView`s (native host only). `get_native_handle` gives the +`UIWindow*` for a frame, the `UIView*` for a child. + +**Units: logical pixels are UIKit points, 1:1.** No conversion; backing scale reaches the +backend separately as `wd.dpi = 96 * screen.scale`. + +### Already handled — do not reimplement + +- **Safe-area insets** — `metrics->safe_area_insets`, real on both hosts. `get_client_rect` + already subtracts the **top** inset (safe area + the 44 px hamburger band when a MENUBAR is + present). *Left, right and bottom are reported but not subtracted — do that yourself.* +- **Dynamic Type** — `metrics->ui_scale` and every `NEUI_METRIC_*` are already scaled, and an + explicit `NEUI_ATTR_FONT_SIZE` is routed through `UIFontMetrics`. **Do not scale again.** +- **`NEUI_EVENT_METRICS_CHANGED`** on Dynamic Type, rotation and safe-area change. +- **Dark mode** via `NEUI_ATTR_FOLLOW_SYSTEM_THEME`; `@2x`/`@3x` asset selection. + +### Not available here + +- No menu bar on iPhone (a MENUBAR becomes the hamburger button; iPad 26+ gets a real one). +- `notify->message_box` returns `NEUI_MB_IOS_PENDING` — `UIAlertController` is async. +- `UIApplicationMain` owns the run loop: `run()` returns immediately, never call `pump_once()`. + Build the UI from `scene:willConnectToSession:`. +- `dnd->begin_drag` is a no-op; attach a `DRAG_SOURCE` behavior asset. +- Nothing moves content out from under the keyboard — ask `NEUI_API_IOS::keyboard_inset`. + +### `NEUI_API_IOS` + +Reference: **`include/neui/d/ios.h`**. Idle timer, screen-edge gesture deferral, status bar, +orientation, forced appearance, Dynamic Type *category*, accessibility switches, device / +battery / thermal / Low Power Mode, haptics, keyboard inset. + +```c +neui_ios_api_t* ios = (neui_ios_api_t*)api->get_interface(sess, NEUI_API_IOS); +if (ios) ios->set_idle_timer_disabled(sess, 1); // NULL on every non-iOS host +``` + +Optional by contract, like `NEUI_API_EMBED` and `NEUI_API_METRICS`, but never inert: a host +that returns it implements every method, and a call that cannot be answered says so in its +return value. Changes arrive as one `NEUI_EVENT_IOS_ENVIRONMENT_CHANGED` carrying a +`NEUI_IOS_ENV_*` mask; Dynamic Type stays on `METRICS_CHANGED`, where clients already handle it. + +Implementation is shared in `hosts/shared/ios/ios_api.h` — UIKit's globals are the same on both +hosts. Only two things are per-host, and they are seams: resolving a frame to its +`UIViewController`, and walking the host's own registry to deliver the event. Frame chrome +(status bar, home indicator, deferred edges, orientations) lives in a shared table keyed by +widget id; each host's view controller reads it back in its `preferredStatusBarStyle` and +friends, falling through to `super` when the client set nothing. + +### Seams must be ADDED, not assigned + +**The trap.** `neui_init()` registers the native host and *then* xpl, whose `platform_init()` +runs last. An assigned single-slot seam therefore always ends up holding **xpl's** — and for a +native-host client it fails silently: the frame resolves to nothing, so `safe_area_insets` +reports zeros and the environment event reaches nobody. Both were real bugs, both measured in +`examples/ios`. + +So `NEUI_API_IOS` and `NEUI_API_METRICS` both keep **lists**. Each host calls +`ios_add_*_seam` / `metrics_add_*_seam`; the frame lookups try each until one claims the frame +(a frame belongs to exactly one host) and the event broadcast runs all of them. Adding the same +pointer twice is a no-op, so a repeated `register_host()` is safe. Covered by +`tests/test_metrics.cpp`. + +### Build and test + +```sh +cmake -B out/ios -G Xcode -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_SYSROOT=iphonesimulator -DCMAKE_OSX_ARCHITECTURES=arm64 +cmake --build out/ios --config Debug +xcrun simctl install booted out/ios/tests/Debug-iphonesimulator/neui_tests.app +xcrun simctl launch --console-pty booted org.neui.tests +``` + +`NEUI_IOS` is a **CMake variable, not a preprocessor define** — sources are selected by CMake. +The exception is `hosts/crossplatform/host.cpp`, compiled everywhere, which needs a real macro +for its `NEUI_API_IOS` lines: `NEUI_PLATFORM_IOS=1`, minted in the xpl host's `elseif(NEUI_IOS)` +branch (mirroring `NEUI_PLATFORM_LVGL=1`). + +`tests/test_ios_api.cpp` pins the enum and bit-flag ABI and runs on all four CI jobs. +`tests/test_ios_api_device.mm` is iOS-only and exercises the real implementation on the +simulator — the process-global queries plus the session and frame bookkeeping. It has a plain +`main()` and no `UIApplication`, so anything frame-scoped (status bar, orientation, keyboard) is +exercised by `examples/ios` instead. diff --git a/examples/ios/SceneDelegate.mm b/examples/ios/SceneDelegate.mm index 500622f..dc9b235 100644 --- a/examples/ios/SceneDelegate.mm +++ b/examples/ios/SceneDelegate.mm @@ -37,6 +37,9 @@ neui_dnd_api_t* dnd = nullptr; neui_behavior_api_t* behavior = nullptr; neui_asset_api_t* assets = nullptr; + // NEUI_API_IOS (d/ios.h). Optional by contract: NULL on every host but the + // two iOS ones, so every use below is guarded. + neui_ios_api_t* ios = nullptr; neui_session_t s = {}; neui_widget_t win = {}; neui_widget_t mb = {}; @@ -279,6 +282,27 @@ bool onevent(void* token, neui_event_t* e) return false; } + // iOS environment moved: orientation, power, thermal, battery, one of the + // accessibility switches, or the keyboard. One event with a bitmask, so a + // client re-reads only what it cares about (d/ios.h). + if (e->type == NEUI_EVENT_IOS_ENVIRONMENT_CHANGED && a->ios) { + const uint32_t ch = e->data.ios_env.changed; + std::printf("[neui-ios] ENV_CHANGED 0x%02x%s%s%s%s%s%s\n", (unsigned)ch, + (ch & NEUI_IOS_ENV_ORIENTATION) ? " orientation" : "", + (ch & NEUI_IOS_ENV_LOW_POWER) ? " low-power" : "", + (ch & NEUI_IOS_ENV_THERMAL) ? " thermal" : "", + (ch & NEUI_IOS_ENV_BATTERY) ? " battery" : "", + (ch & NEUI_IOS_ENV_ACCESSIBILITY) ? " a11y" : "", + (ch & NEUI_IOS_ENV_KEYBOARD) ? " keyboard" : ""); + char buf[160]; + std::snprintf(buf, sizeof buf, + "iOS env 0x%02x - orientation %d, keyboard %d px", + (unsigned)ch, (int)a->ios->orientation(a->s, a->win), + a->ios->keyboard_inset(a->s, a->win)); + a->w->set_text(a->s, a->label, buf); + return false; + } + // Menu activation: the hamburger UIMenu routes picks through // dispatch_menu_event, which fires TREE_ITEM_ACTIVATED for client items (and // invokes the focused widget for built-in commands like Copy first). React to @@ -319,6 +343,9 @@ bool onevent(void* token, neui_event_t* e) a->w->get_text(a->s, a->input, in_buf, sizeof in_buf); std::snprintf(out_buf, sizeof out_buf, "You typed: %s", in_buf); a->w->set_text(a->s, a->label, out_buf); + // A haptic tick to go with it - the cheapest way to see NEUI_API_IOS do + // something physical. Silent in the simulator, by UIKit's own rules. + if (a->ios) a->ios->haptic(a->s, NEUI_IOS_HAPTIC_LIGHT); // Also surface a toast so the Submit button is a one-tap toast trigger. if (a->notify) { char toast_buf[360]; @@ -448,6 +475,28 @@ void build_ui() g_app.dnd = (neui_dnd_api_t*) api->get_interface(g_app.s, NEUI_API_DND); g_app.behavior = (neui_behavior_api_t*) api->get_interface(g_app.s, NEUI_API_BEHAVIOR); g_app.assets = (neui_asset_api_t*) api->get_interface(g_app.s, NEUI_API_ASSETS); + g_app.ios = (neui_ios_api_t*) api->get_interface(g_app.s, NEUI_API_IOS); + if (g_app.ios) { + // Everything an iOS-only client can ask that needs no window. On any other + // host this pointer is NULL and none of it runs - which is the whole + // feature-detect contract in d/ios.h. + int maj = 0, min = 0, pat = 0; + g_app.ios->os_version(g_app.s, &maj, &min, &pat); + std::printf("[neui-ios] NEUI_API_IOS present: %s iOS %d.%d.%d idiom=%d\n", + g_app.ios->device_model(g_app.s), maj, min, pat, + (int)g_app.ios->idiom(g_app.s)); + std::printf("[neui-ios] battery=%.2f state=%d lowpower=%d thermal=%d\n", + (double)g_app.ios->battery_level(g_app.s), + (int)g_app.ios->battery_state(g_app.s), + g_app.ios->low_power_mode(g_app.s), + (int)g_app.ios->thermal_state(g_app.s)); + std::printf("[neui-ios] content_size=%d a11y_flags=0x%02x brightness=%.2f\n", + (int)g_app.ios->content_size_category(g_app.s), + (unsigned)g_app.ios->accessibility_flags(g_app.s), + (double)g_app.ios->screen_brightness(g_app.s)); + } else { + std::printf("[neui-ios] NEUI_API_IOS absent (not an iOS host)\n"); + } if (g_app.metrics) std::printf("[neui-ios] metrics: ui_scale=%.3f control_h=%d margin=%d body_font=%d\n", (double)g_app.metrics->ui_scale(g_app.s), @@ -680,6 +729,31 @@ void build_ui() g_app.w->show(g_app.s, g_app.win); + // Frame-scoped NEUI_API_IOS settings. These need the frame realized, so they + // come after show(): the view controller is built there, and each setter asks + // it to re-query. Between them they are what a full-screen control surface + // wants - the screen stays lit, a swipe near the bottom bezel takes two tries + // instead of backgrounding the app mid-gesture, and the home indicator fades. + if (g_app.ios) { + g_app.ios->set_idle_timer_disabled(g_app.s, 1); + g_app.ios->set_deferring_system_gestures(g_app.s, g_app.win, NEUI_IOS_EDGE_BOTTOM); + g_app.ios->set_home_indicator_auto_hidden(g_app.s, g_app.win, 1); + g_app.ios->set_status_bar(g_app.s, g_app.win, NEUI_IOS_STATUS_BAR_DEFAULT, 0); + std::printf("[neui-ios] stage settings applied: idle_hold=%d orientation=%d\n", + g_app.ios->idle_timer_disabled(g_app.s), + (int)g_app.ios->orientation(g_app.s, g_app.win)); + } + + // Safe-area insets are PORTABLE - they live in NEUI_API_METRICS, not in the + // iOS interface. Printed here because this example links both iOS hosts and + // drives the native one, which is exactly the case where an assigned (rather + // than added) metrics seam used to resolve no frame and report zeros. + if (g_app.metrics && g_app.metrics->safe_area_insets) { + int l = 0, t = 0, r = 0, b = 0; + g_app.metrics->safe_area_insets(g_app.s, g_app.win, &l, &t, &r, &b); + std::printf("[neui-ios] safe_area_insets: l=%d t=%d r=%d b=%d\n", l, t, r, b); + } + // Smoke the clipboard seam now that the session is live. clipboard_smoke(&g_app); diff --git a/hosts/crossplatform/CMakeLists.txt b/hosts/crossplatform/CMakeLists.txt index dc464de..6f72d1e 100644 --- a/hosts/crossplatform/CMakeLists.txt +++ b/hosts/crossplatform/CMakeLists.txt @@ -36,6 +36,11 @@ elseif(NEUI_IOS) # platform layer instead of the AppKit one. This branch MUST come before the # generic APPLE branch since iOS also matches APPLE. target_sources(neui-xplhost PRIVATE platform_ios.mm) + # host.cpp is compiled on every platform, so the NEUI_API_IOS lines in its + # get_interface / ~Session need a gate. NEUI_IOS is a CMake variable only - + # there is no NEUI_IOS preprocessor define - so mint one here, mirroring + # NEUI_PLATFORM_LVGL above. + target_compile_definitions(neui-xplhost PRIVATE NEUI_PLATFORM_IOS=1) set_source_files_properties(platform_ios.mm PROPERTIES COMPILE_FLAGS "-fobjc-arc" ) diff --git a/hosts/crossplatform/host.cpp b/hosts/crossplatform/host.cpp index c240723..a607d2b 100644 --- a/hosts/crossplatform/host.cpp +++ b/hosts/crossplatform/host.cpp @@ -177,6 +177,12 @@ namespace xpl_host if (!strcmp(iface, NEUI_API_NOTIFY)) return ¬ify_api; if (!strcmp(iface, NEUI_API_METRICS)) return &neui_detail::k_metrics_api; if (!strcmp(iface, NEUI_API_EMBED)) return &embed_api; +#if defined(NEUI_PLATFORM_IOS) + // iOS host specialities. Exposed only when this host is built on its UIKit + // platform layer; every other build returns NULL, which is the contract + // d/ios.h asks clients to feature-detect on. + if (!strcmp(iface, NEUI_API_IOS)) return platform_ios_api(); +#endif return nullptr; } @@ -291,6 +297,11 @@ namespace xpl_host auto* cur = neui_detail::active_palette_override_ptr(); if (cur == &_effective_palette || cur == &_frozen_palette) neui_detail::set_active_palette_override(nullptr); +#if defined(NEUI_PLATFORM_IOS) + // Drops this session's idle-timer hold and restores any brightness it + // changed - the promises d/ios.h makes about both are kept here. + platform_ios_session_shutdown(_session_id); +#endif } void Session::recompute_effective_palette() diff --git a/hosts/crossplatform/platform.h b/hosts/crossplatform/platform.h index b46bca7..938df6c 100644 --- a/hosts/crossplatform/platform.h +++ b/hosts/crossplatform/platform.h @@ -416,4 +416,21 @@ namespace xpl_host void platform_retained_tree_changed(Session* session, uint32_t widget_index); #endif +#if defined(NEUI_PLATFORM_IOS) + // ------------------------------------------------------------------------- + // NEUI_API_IOS seams (include/neui/d/ios.h). Implemented in platform_ios.mm. + // + // host.cpp is compiled for every platform and is plain C++, so it can neither + // include the ObjC++ implementation header nor name its types. These three + // entry points are the whole surface it needs, and they exist only on iOS. + + // The NEUI_API_IOS vtable, as an opaque pointer for get_interface to return. + // Installs the environment observers on the first call. + void* platform_ios_api(); + + // Drop a session's idle-timer hold and restore any brightness it changed. + // Called from ~Session. + void platform_ios_session_shutdown(uint32_t session_id); +#endif + } // namespace xpl_host diff --git a/hosts/crossplatform/platform_ios.mm b/hosts/crossplatform/platform_ios.mm index 99acb6a..b4664fd 100644 --- a/hosts/crossplatform/platform_ios.mm +++ b/hosts/crossplatform/platform_ios.mm @@ -84,8 +84,9 @@ // unchanged - hover only fires for a pointing device, never for a finger, // so the "no hover on touch" divergence holds (a finger never sets hovered). // -// DnD stays a null no-op stub for a later phase; the native iOS host is still a -// stub (see plan step 7). +// DnD and the native iOS host were both stubs while this layer was being +// built; both are IMPLEMENTED now (hosts/ios/, hosts/shared/ios/dnd_ios.h). +// See docs/host-ios.md. #import #import // CACurrentMediaTime for platform_now_ms @@ -116,6 +117,7 @@ #include "../shared/ios/dnd_ios.h" #include "../shared/dnd_modifier_suggest.h" #include "../shared/metrics.h" +#include "../shared/ios/ios_api.h" // NEUI_API_IOS: shared vtable + per-host seams // --------------------------------------------------------------------------- // Forward declarations. @@ -1782,6 +1784,48 @@ - (NEUIView*)neuiView return [self.view isKindOfClass:[NEUIView class]] ? (NEUIView*)self.view : nil; } +// ---- NEUI_API_IOS chrome --------------------------------------------------- +// Twin of the native host's overrides (hosts/ios/window.mm). The client's +// settings live in the shared iOS state table (hosts/shared/ios/ios_api.h) +// keyed by this frame's widget id, so both iOS hosts read the same state +// through the same accessor. No entry means the client never asked for +// anything, and each override falls through to super. +- (const neui_detail::IosFrameState*)neuiIosState +{ + if (!session || !session->_widgets.exists(widget_index)) return nullptr; + return neui_detail::ios_frame_state_if_present( + neui_widget_t{ session->_widgets[widget_index].widget_id }); +} +- (UIStatusBarStyle)preferredStatusBarStyle +{ + const neui_detail::IosFrameState* st = [self neuiIosState]; + return st ? neui_detail::ios_uikit_status_bar_style(st->status_style) + : [super preferredStatusBarStyle]; +} +- (BOOL)prefersStatusBarHidden +{ + const neui_detail::IosFrameState* st = [self neuiIosState]; + return st ? (st->status_hidden ? YES : NO) : [super prefersStatusBarHidden]; +} +- (BOOL)prefersHomeIndicatorAutoHidden +{ + const neui_detail::IosFrameState* st = [self neuiIosState]; + return st ? (st->home_indicator_auto_hidden ? YES : NO) + : [super prefersHomeIndicatorAutoHidden]; +} +- (UIRectEdge)preferredScreenEdgesDeferringSystemGestures +{ + const neui_detail::IosFrameState* st = [self neuiIosState]; + return st ? neui_detail::ios_uikit_rect_edge(st->deferring_edges) + : [super preferredScreenEdgesDeferringSystemGestures]; +} +- (UIInterfaceOrientationMask)supportedInterfaceOrientations +{ + const neui_detail::IosFrameState* st = [self neuiIosState]; + return st ? neui_detail::ios_uikit_orientation_mask(st->supported_orientations) + : [super supportedInterfaceOrientations]; +} + - (void)reportResizeIfChanged { if (!session || !session->_widgets.exists(widget_index)) return; @@ -1812,6 +1856,11 @@ - (void)reportResizeIfChanged // rotation + when the notch/status-bar inset first resolves, so notify the // client that the metrics changed too (alongside RESIZE). dispatch_metrics_changed_xpl_ios(session, widget_index); + // A rotation settles here too. Told from the layout pass rather than from + // UIDevice orientation notifications: no accelerometer, and this is the + // INTERFACE orientation, which is what NEUI_API_IOS reports. Only broadcasts + // on a real change. + neui_detail::ios_note_orientation_changed(self); // A bounds change can be a full-screen <-> windowed transition (entering/ // leaving Stage Manager / Split View), which flips the hamburger-visibility // rule on iPad 26+. Re-evaluate so the hamburger appears when going full-screen @@ -1982,21 +2031,24 @@ int metrics_measure_text_xpl_ios(neui_session_t /*session*/, const char* text, return (int)(sz.width + 0.5f); } -void metrics_safe_area_xpl_ios(neui_session_t session, neui_widget_t frame, +// Returns true only when this host owns `frame` - the shared seam tries each +// installed host in turn, so "not mine" must be distinguishable from "mine, and +// the insets are zero". +bool metrics_safe_area_xpl_ios(neui_session_t session, neui_widget_t frame, int* left, int* top, int* right, int* bottom) { - if (left) *left = 0; - if (top) *top = 0; - if (right) *right = 0; - if (bottom) *bottom = 0; xpl_host::Session* s = xpl_host::session_by_id(session.session); - if (!s) return; + if (!s) return false; uint32_t idx = frame.id & 0xffff; - if (!s->_widgets.exists(idx)) return; + if (!s->_widgets.exists(idx)) return false; auto& fw = s->_widgets[idx]; - if (!fw.is_frame()) return; + if (!fw.is_frame()) return false; NEUIView* view = neui_view_for_window(fw.native_handle); - if (!view) return; + if (!view) return false; + if (left) *left = 0; + if (top) *top = 0; + if (right) *right = 0; + if (bottom) *bottom = 0; if (@available(iOS 11.0, *)) { UIEdgeInsets ins = view.safeAreaInsets; if (left) *left = (int)(ins.left + 0.5); @@ -2006,6 +2058,7 @@ void metrics_safe_area_xpl_ios(neui_session_t session, neui_widget_t frame, // matches the content rect get_client_rect / frame_top_inset report. if (top) *top = s->frame_top_inset(idx); } + return true; } // Build the UIWindow + NEUIViewController + NEUIView triad, wire the render @@ -2075,6 +2128,68 @@ void install_window_and_context(xpl_host::Session* session, // ------------------------------------------------------------------------- // Lifecycle / window management. + // ---- NEUI_API_IOS seams -------------------------------------------------- + // Twins of the native host's (hosts/ios/window.mm). Only the frame lookup and + // the registry walk differ; everything the interface actually does is shared. + + // frame widget -> its root UIViewController. The shared implementation's + // default finds the key window's root, which is right only while one frame is + // up; this resolves the exact frame. + static UIViewController* ios_frame_controller_xpl(neui_session_t session, + neui_widget_t frame) + { + Session* s = session_by_id(session.session & 0xffff); + if (!s) return nil; + const uint32_t idx = frame.id & 0xffff; + if (!s->_widgets.exists(idx)) return nil; + WidgetData& fw = s->_widgets[idx]; + if (!fw.isroot || !fw.native_handle) return nil; + UIWindow* w = (__bridge UIWindow*)fw.native_handle; + return w.rootViewController; + } + + // Deliver NEUI_EVENT_IOS_ENVIRONMENT_CHANGED to every live frame of every live + // session. Only a host knows its own registries, which is why this is a seam + // rather than shared code. + static void ios_broadcast_env_xpl(uint32_t changed) + { + const uint32_t n = session_count(); + for (uint32_t i = 1; i <= n; ++i) { + Session* s = session_by_id(i); + if (!s) continue; + for (uint32_t w : s->_widgets.release_order()) { + if (w == 0 || !s->_widgets.exists(w)) continue; + WidgetData& wd = s->_widgets[w]; + if (!wd.isroot) continue; + neui_event_t ev = {}; + ev.type = NEUI_EVENT_IOS_ENVIRONMENT_CHANGED; + ev.data.ios_env.widget = { wd.widget_id }; + ev.data.ios_env.changed = changed; + s->dispatch_event(&ev); + } + } + } + + // Repaint after a forced appearance change, so painted widgets follow the + // native controls instead of keeping the old palette. Every widget here is + // painted into the frame's single view, so invalidating the frames is enough. + static void ios_theme_refresh_xpl() + { + neui_detail::refresh_theme_palette_ios(); + const uint32_t n = session_count(); + for (uint32_t i = 1; i <= n; ++i) { + Session* s = session_by_id(i); + if (!s) continue; + for (uint32_t w : s->_widgets.release_order()) { + if (w == 0 || !s->_widgets.exists(w)) continue; + WidgetData& wd = s->_widgets[w]; + if (!wd.isroot || !wd.native_handle) continue; + if (NEUIView* v = neui_view_for_window(wd.native_handle)) + [v setNeedsDisplay]; + } + } + } + void platform_init() { // UIApplicationMain owns app bootstrap on iOS; nothing to register here. @@ -2097,8 +2212,35 @@ void platform_init() // Install the iOS-real NEUI_API_METRICS seams (UIFont measurement + the // frame view's safeAreaInsets) into the shared vtable. Desktop platforms // leave the shared desktop defaults in place. - neui_detail::metrics_measure_seam() = &metrics_measure_text_xpl_ios; - neui_detail::metrics_safe_area_seam() = &metrics_safe_area_xpl_ios; + // ADD, not assign - see the seam note in hosts/shared/metrics.h. + neui_detail::metrics_add_measure_seam(&metrics_measure_text_xpl_ios); + neui_detail::metrics_add_safe_area_seam(&metrics_safe_area_xpl_ios); + + // NEUI_API_IOS: the frame -> UIViewController lookup, the environment-event + // broadcast, and the repaint used after a forced appearance change. The + // interface itself is not built here - platform_ios_api() installs its + // observers on the first get_interface hit, so a client that never asks for + // it pays nothing. + // ADD, not assign: the native iOS host may be linked into the same binary + // and registers BEFORE this one, so assigning would silently discard its + // implementations and leave a native-host client with a dead interface. + neui_detail::ios_add_frame_controller_seam(&ios_frame_controller_xpl); + neui_detail::ios_add_broadcast_env_seam(&ios_broadcast_env_xpl); + neui_detail::ios_add_theme_refresh_seam(&ios_theme_refresh_xpl); + } + + // ---- NEUI_API_IOS entry points ------------------------------------------ + // Declared in platform.h behind NEUI_PLATFORM_IOS; host.cpp is plain C++ and + // reaches the ObjC++ implementation only through these. + + void* platform_ios_api() + { + return neui_detail::ios_api(); + } + + void platform_ios_session_shutdown(uint32_t session_id) + { + neui_detail::ios_session_shutdown(neui_session_t{ session_id }); } neui_render_backend_t* platform_get_backend() @@ -2145,6 +2287,9 @@ void platform_create_dialog(Session* session, uint32_t widget_index, void platform_destroy_window(WidgetData& wd) { + // Drop any NEUI_API_IOS chrome recorded for this frame first: widget ids are + // recycled, and a new frame must not inherit the old one's status bar. + neui_detail::ios_frame_forget(neui_widget_t{ wd.widget_id }); if (!wd.native_handle) return; // Tear down the render context before the view goes away. diff --git a/hosts/ios/host.h b/hosts/ios/host.h index d2d04ea..64737e0 100644 --- a/hosts/ios/host.h +++ b/hosts/ios/host.h @@ -2,11 +2,11 @@ // Native iOS host (neui.host.ios) - UIKit. // -// MILESTONE 7. The native counterpart to hosts/macos/host.h, trimmed to the -// agreed v1 core subset (LABEL / BUTTON / INPUTBOX / MULTILINE / CHECKBOX / -// CHECKBOX3 / SLIDER / IMAGE / SECTION / CUSTOMDRAW + APPWINDOW / DIALOG / -// MENUBAR). GRID / TREEVIEW / COMBOBOX / LISTBOX / TABVIEW / DnD are phase-2 -// stubs (see the TODO(ios phase 2) markers in widgets.mm / window.mm). +// The native counterpart to hosts/macos/host.h. GRID / TREEVIEW / COMBOBOX / +// LISTBOX / TABVIEW / DnD were phase-2 stubs while this host was being built; +// they are all IMPLEMENTED now and exercised by examples/ios. Reader-facing +// overview of this host, the xpl one, and what each already handles: +// docs/host-ios.md. // // Structure mirrors the macOS native host: Session + WidgetData + a slot-reused // session registry. Native-handle fields are void* so this header is includable diff --git a/hosts/ios/host.mm b/hosts/ios/host.mm index bd20fd1..0f87bd5 100644 --- a/hosts/ios/host.mm +++ b/hosts/ios/host.mm @@ -15,6 +15,7 @@ // static lives inside ensure_theme_provider_ios (which only window.mm calls), so // pulling this header in here just to set the scale is harmless. #include "../shared/ios/theme_provider_ios.h" +#include "../shared/ios/ios_api.h" // NEUI_API_IOS: shared vtable + per-host seams #include "../shared/metrics.h" #include "../shared/dnd_dispatch.h" // dnd_formats_match + dnd_dispatch_* templates @@ -68,7 +69,12 @@ } } - Session::~Session() = default; + Session::~Session() + { + // Drops this session's idle-timer hold and restores any brightness it + // changed - the promises d/ios.h makes about both are kept here. + neui_detail::ios_session_shutdown(neui_session_t{ _session_id }); + } // UIApplicationMain owns the run loop on iOS; neui never owns / stops it, so // run() returns immediately and the client builds its UI from the scene @@ -91,6 +97,11 @@ // safe_area_insets seams into the shared NEUI_API_METRICS vtable. void install_metrics_seams_ios(); + // Defined in window.mm (UIKit): installs this host's NEUI_API_IOS seams - + // frame -> UIViewController lookup, the environment-event broadcast, and the + // theme repaint used after an appearance override. + void install_ios_seams_ios(); + void Session::invalidate_widgets_with_compound(uint32_t asset_id) { auto order = _widgets.release_order(); @@ -369,6 +380,7 @@ static neui_session_t create_session(neui_client_t* client, void* token) if (!strcmp(iface, NEUI_API_TABS)) return &tabs_api; if (!strcmp(iface, NEUI_API_NOTIFY)) return ¬ify_api; if (!strcmp(iface, NEUI_API_METRICS)) return &neui_detail::k_metrics_api; + if (!strcmp(iface, NEUI_API_IOS)) return neui_detail::ios_api(); return nullptr; } @@ -403,6 +415,12 @@ void register_host() // the shared desktop defaults in place. install_metrics_seams_ios(); + // NEUI_API_IOS: the frame -> UIViewController lookup, the environment + // broadcast and the theme repaint. The interface itself is not built here - + // ios_api() installs its observers on the first get_interface hit, so a + // client that never asks for it pays nothing. + install_ios_seams_ios(); + static neui_api_t base_api = { NEUI_VERSION, create_session, diff --git a/hosts/ios/window.mm b/hosts/ios/window.mm index 863898b..dbccb58 100644 --- a/hosts/ios/window.mm +++ b/hosts/ios/window.mm @@ -34,6 +34,7 @@ // data source/delegate alive for the table's lifetime #include "host.h" +#include "../shared/ios/ios_api.h" // NEUI_API_IOS shared state + seams #include "../shared/compound.h" #include "../shared/widget_paint_section.h" #include "../shared/widget_paint_knob.h" @@ -1579,6 +1580,47 @@ - (NEUINativeIOSContentView*)contentView { return [self.view isKindOfClass:[NEUINativeIOSContentView class]] ? (NEUINativeIOSContentView*)self.view : nil; } + +// ---- NEUI_API_IOS chrome --------------------------------------------------- +// The client's settings live in the shared iOS state table (hosts/shared/ios/ +// ios_api.h) keyed by this frame's widget id, so both iOS hosts read the same +// state through the same accessor. No entry means the client never asked for +// anything, and each override falls through to super. +- (const neui_detail::IosFrameState*)neuiIosState +{ + if (!session || !session->_widgets.exists(widget_index)) return nullptr; + return neui_detail::ios_frame_state_if_present( + neui_widget_t{ session->_widgets[widget_index].widget_id }); +} +- (UIStatusBarStyle)preferredStatusBarStyle +{ + const neui_detail::IosFrameState* st = [self neuiIosState]; + return st ? neui_detail::ios_uikit_status_bar_style(st->status_style) + : [super preferredStatusBarStyle]; +} +- (BOOL)prefersStatusBarHidden +{ + const neui_detail::IosFrameState* st = [self neuiIosState]; + return st ? (st->status_hidden ? YES : NO) : [super prefersStatusBarHidden]; +} +- (BOOL)prefersHomeIndicatorAutoHidden +{ + const neui_detail::IosFrameState* st = [self neuiIosState]; + return st ? (st->home_indicator_auto_hidden ? YES : NO) + : [super prefersHomeIndicatorAutoHidden]; +} +- (UIRectEdge)preferredScreenEdgesDeferringSystemGestures +{ + const neui_detail::IosFrameState* st = [self neuiIosState]; + return st ? neui_detail::ios_uikit_rect_edge(st->deferring_edges) + : [super preferredScreenEdgesDeferringSystemGestures]; +} +- (UIInterfaceOrientationMask)supportedInterfaceOrientations +{ + const neui_detail::IosFrameState* st = [self neuiIosState]; + return st ? neui_detail::ios_uikit_orientation_mask(st->supported_orientations) + : [super supportedInterfaceOrientations]; +} - (void)reportResizeIfChanged { if (!session || !session->_widgets.exists(widget_index)) return; @@ -1602,6 +1644,11 @@ - (void)reportResizeIfChanged // rotation + when the notch/status-bar inset first resolves, so notify the // client that the metrics changed too (alongside RESIZE). ios_host::dispatch_metrics_changed_ios(session, widget_index); + // A rotation settles here too. Told from the layout pass rather than from + // UIDevice orientation notifications: no accelerometer, and this is the + // INTERFACE orientation, which is what NEUI_API_IOS reports. Only broadcasts + // on a real change. + neui_detail::ios_note_orientation_changed(self); // A bounds change can be a full-screen <-> windowed transition (entering/ // leaving Stage Manager / Split View), which flips the hamburger-visibility // rule on iPad 26+. Re-evaluate so the hamburger appears when going full-screen @@ -1758,18 +1805,21 @@ static int metrics_measure_text_ios(neui_session_t /*session*/, const char* text return (int)(sz.width + 0.5f); } - static void metrics_safe_area_ios(neui_session_t /*session*/, neui_widget_t frame, + // Returns true only when this host owns `frame` - the shared seam tries each + // installed host in turn, so "not mine" must be distinguishable from "mine, + // and the insets are zero". + static bool metrics_safe_area_ios(neui_session_t /*session*/, neui_widget_t frame, int* left, int* top, int* right, int* bottom) { + Session* s = nullptr; + WidgetData* fw = widget_for_id(frame.id, &s); + if (!fw || !s || !fw->isroot) return false; + NEUINativeIOSContentView* cv = content_view_for_frame(*fw); + if (!cv) return false; if (left) *left = 0; if (top) *top = 0; if (right) *right = 0; if (bottom) *bottom = 0; - Session* s = nullptr; - WidgetData* fw = widget_for_id(frame.id, &s); - if (!fw || !s || !fw->isroot) return; - NEUINativeIOSContentView* cv = content_view_for_frame(*fw); - if (!cv) return; if (@available(iOS 11.0, *)) { UIEdgeInsets ins = cv.safeAreaInsets; if (left) *left = (int)(ins.left + 0.5); @@ -1782,14 +1832,71 @@ static void metrics_safe_area_ios(neui_session_t /*session*/, neui_widget_t fram *top = frame_top_inset_ios(s, fidx); } } + return true; } // Install the iOS-real measure_text + safe_area seams. Called once from each // iOS host's register_host(); idempotent (overwrites with the same pointers). void install_metrics_seams_ios() { - neui_detail::metrics_measure_seam() = &metrics_measure_text_ios; - neui_detail::metrics_safe_area_seam() = &metrics_safe_area_ios; + // ADD, not assign - see the seam note in hosts/shared/metrics.h. + neui_detail::metrics_add_measure_seam(&metrics_measure_text_ios); + neui_detail::metrics_add_safe_area_seam(&metrics_safe_area_ios); + } + + // ---- NEUI_API_IOS seams -------------------------------------------------- + + // frame widget -> its root UIViewController. The shared implementation's + // default finds the key window's root, which is right only while one frame is + // up; this resolves the exact frame. + static UIViewController* ios_frame_controller_native(neui_session_t /*session*/, + neui_widget_t frame) + { + Session* s = nullptr; + WidgetData* fw = widget_for_id(frame.id, &s); + if (!fw || !s || !fw->isroot || !fw->native_window) return nil; + UIWindow* w = (__bridge UIWindow*)fw->native_window; + return w.rootViewController; + } + + // Deliver NEUI_EVENT_IOS_ENVIRONMENT_CHANGED to every live frame of every + // live session. Only a host knows its own registries, which is why this is a + // seam rather than shared code. + static void ios_broadcast_env_native(uint32_t changed) + { + for (auto& sp : sessions) { + Session* s = sp.get(); + if (!s) continue; + for (uint32_t i : s->_widgets.release_order()) { + if (i == 0 || !s->_widgets.exists(i)) continue; + WidgetData& wd = s->_widgets[i]; + if (!wd.isroot) continue; + neui_event_t ev = {}; + ev.type = NEUI_EVENT_IOS_ENVIRONMENT_CHANGED; + ev.data.ios_env.widget = { wd.widget_id }; + ev.data.ios_env.changed = changed; + s->dispatch_event(&ev); + } + } + } + + // Repaint after a forced appearance change, so painted widgets follow the + // native controls instead of keeping the old palette. + static void ios_theme_refresh_native() + { + neui_detail::refresh_theme_palette_ios(); + for (auto& sp : sessions) + if (Session* s = sp.get()) s->invalidate_all_for_theme_change(); + } + + // Called once from register_host(); idempotent. + void install_ios_seams_ios() + { + // ADD, not assign: both iOS hosts can be linked into one binary and xpl + // registers last, so a single-slot seam would always end up holding xpl's. + neui_detail::ios_add_frame_controller_seam(&ios_frame_controller_native); + neui_detail::ios_add_broadcast_env_seam(&ios_broadcast_env_native); + neui_detail::ios_add_theme_refresh_seam(&ios_theme_refresh_native); } // Dispatch NEUI_EVENT_METRICS_CHANGED to a frame's client (same path as @@ -4256,6 +4363,9 @@ void realize_widget_ios(Session* s, uint32_t idx) void release_native_window_ios(WidgetData& wd) { + // Drop any NEUI_API_IOS chrome recorded for this frame first: widget ids are + // recycled, and a new frame must not inherit the old one's status bar. + neui_detail::ios_frame_forget(neui_widget_t{ wd.widget_id }); if (!wd.native_window) return; auto* backend = neui_cg_backend::get_backend(); if (backend && wd.render_ctx) { diff --git a/hosts/shared/ios/ios_api.h b/hosts/shared/ios/ios_api.h new file mode 100644 index 0000000..53efd2b --- /dev/null +++ b/hosts/shared/ios/ios_api.h @@ -0,0 +1,707 @@ +#pragma once + +#if defined(__APPLE__) +#import +#if TARGET_OS_IPHONE + +#import +#import + +#include +#include +#include + +#include // NEUI_VERSION +#include +#include + +// Shared, host-neutral implementation of NEUI_API_IOS (include/neui/d/ios.h). +// +// UIApplication, UIDevice, UIScreen, NSProcessInfo and UIAccessibility are +// process-global and behave the same whether the frame is a native-control tree +// (hosts/ios) or one painted view (platform_ios.mm). The hosts differ in two +// things only - resolving a frame to its UIViewController, and reaching every +// live frame to deliver an event - so those are seams, the same shape +// hosts/shared/metrics.h uses. +// +// Safe to include from more than one TU: every definition is inline, so all TUs +// share one instance of the state and one address for k_ios_api. That matters - +// a client may link both iOS hosts into one binary, and a non-inline definition +// would be a duplicate symbol. + +namespace neui_detail +{ + // --------------------------------------------------------------------------- + // Per-host seams. + + // Seams are REGISTRIES, not single slots. Both iOS hosts can be linked into + // one binary and neui_init() registers ios BEFORE xpl, so an assigned slot + // would always end up holding xpl's - the frame lookup would resolve nothing + // and the broadcast would walk an empty registry, both silently. Each host + // ADDS instead: the frame lookup takes the first that resolves (a frame + // belongs to exactly one host) and the broadcast runs every one. add() + // ignores a pointer it already holds, so a repeated register_host() is safe. + // Same rule in hosts/shared/metrics.h. + + template + inline void ios_seam_add(std::vector& list, Fn fn) + { + if (!fn) return; + for (Fn existing : list) + if (existing == fn) return; + list.push_back(fn); + } + + // A frame widget -> the UIViewController that owns its view. + using ios_frame_controller_fn = UIViewController* (*)(neui_session_t, neui_widget_t); + + inline std::vector& ios_frame_controller_seams() + { + static std::vector v; + return v; + } + + inline void ios_add_frame_controller_seam(ios_frame_controller_fn fn) + { + ios_seam_add(ios_frame_controller_seams(), fn); + } + + // The fallback when no host resolved the frame: the key window's root + // controller. Right for a single-window app, and the only thing available + // before a host has installed anything. + inline UIViewController* ios_frame_controller_default(neui_session_t, neui_widget_t) + { + if (@available(iOS 13.0, *)) { + for (UIScene* scene in UIApplication.sharedApplication.connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) continue; + for (UIWindow* w in ((UIWindowScene*)scene).windows) + if (w.isKeyWindow && w.rootViewController) return w.rootViewController; + } + } + return nil; + } + + inline UIViewController* ios_controller_for(neui_session_t s, neui_widget_t frame) + { + for (ios_frame_controller_fn fn : ios_frame_controller_seams()) + if (UIViewController* vc = fn(s, frame)) return vc; + return ios_frame_controller_default(s, frame); + } + + // Deliver NEUI_EVENT_IOS_ENVIRONMENT_CHANGED to every live frame. Only a host + // knows its own session and widget registries, so each contributes its own + // walk and all of them run. + using ios_broadcast_env_fn = void (*)(uint32_t changed); + + inline std::vector& ios_broadcast_env_seams() + { + static std::vector v; + return v; + } + + inline void ios_add_broadcast_env_seam(ios_broadcast_env_fn fn) + { + ios_seam_add(ios_broadcast_env_seams(), fn); + } + + inline void ios_broadcast_env(uint32_t changed) + { + for (ios_broadcast_env_fn fn : ios_broadcast_env_seams()) fn(changed); + } + + // --------------------------------------------------------------------------- + // State. + // + // Frame chrome lives here rather than in each host's WidgetData: there are + // two different WidgetData structs and only one set of settings, and the + // view-controller overrides in both hosts read it back through + // ios_frame_state_if_present(). + + struct IosFrameState + { + neui_ios_status_bar_t status_style = NEUI_IOS_STATUS_BAR_DEFAULT; + bool status_hidden = false; + bool home_indicator_auto_hidden = false; + uint32_t deferring_edges = NEUI_IOS_EDGE_NONE; + uint32_t supported_orientations = NEUI_IOS_ORIENTATION_ALL; + }; + + inline std::map& ios_frame_states() + { + static std::map m; + return m; + } + + inline IosFrameState& ios_frame_state(neui_widget_t frame) + { + return ios_frame_states()[frame.id]; + } + + // Read-only lookup for the view-controller overrides: nullptr when the client + // never set anything, so the override can fall through to super. + inline const IosFrameState* ios_frame_state_if_present(neui_widget_t frame) + { + auto& m = ios_frame_states(); + auto it = m.find(frame.id); + return it == m.end() ? nullptr : &it->second; + } + + // Called by each host when a frame is destroyed. Without it a recycled widget + // id would inherit the previous frame's status-bar style. + inline void ios_frame_forget(neui_widget_t frame) + { + ios_frame_states().erase(frame.id); + } + + struct IosSessionState + { + int idle_hold = 0; // refcount, per the header's contract + bool brightness_saved = false; + float saved_brightness = 0.0f; + }; + + // Keyed on the raw session handle. Two hosts in one process each mint their + // own ids and could in principle collide here; in practice a client drives + // one host, and the cost of a collision is a shared idle-timer refcount. + inline std::map& ios_session_states() + { + static std::map m; + return m; + } + + inline IosSessionState& ios_session_state(neui_session_t s) + { + return ios_session_states()[s.session]; + } + + // The keyboard's frame in SCREEN coordinates, or CGRectZero when it is down. + // Kept globally and intersected per frame on demand, so no frame has to be + // told about it in advance. + inline CGRect& ios_keyboard_frame() + { + static CGRect r = CGRectZero; + return r; + } + + // --------------------------------------------------------------------------- + // The idle timer, applied process-wide from the sum of the per-session holds. + + inline void ios_apply_idle_timer() + { + bool any = false; + for (const auto& kv : ios_session_states()) + if (kv.second.idle_hold > 0) { any = true; break; } + UIApplication.sharedApplication.idleTimerDisabled = any ? YES : NO; + } + + // --------------------------------------------------------------------------- + // Environment observation. Installed once, on the first get_interface hit, so + // a client that never asks for this interface pays nothing. + + inline uint32_t ios_accessibility_flags_now() + { + uint32_t f = 0; + if (UIAccessibilityIsReduceMotionEnabled()) f |= NEUI_IOS_A11Y_REDUCE_MOTION; + if (UIAccessibilityIsReduceTransparencyEnabled()) f |= NEUI_IOS_A11Y_REDUCE_TRANSPARENCY; + if (UIAccessibilityIsBoldTextEnabled()) f |= NEUI_IOS_A11Y_BOLD_TEXT; + if (UIAccessibilityDarkerSystemColorsEnabled()) f |= NEUI_IOS_A11Y_DARKER_COLORS; + if (UIAccessibilityIsVoiceOverRunning()) f |= NEUI_IOS_A11Y_VOICE_OVER; + if (UIAccessibilityIsSwitchControlRunning()) f |= NEUI_IOS_A11Y_SWITCH_CONTROL; + return f; + } + + inline neui_ios_orientation_t ios_orientation_of(UIViewController* vc) + { + UIInterfaceOrientation o = UIInterfaceOrientationUnknown; + if (@available(iOS 13.0, *)) { + UIWindowScene* scene = vc.view.window.windowScene; + if (scene) o = scene.interfaceOrientation; + } + switch (o) { + case UIInterfaceOrientationPortrait: return NEUI_IOS_ORIENTATION_PORTRAIT; + case UIInterfaceOrientationPortraitUpsideDown: return NEUI_IOS_ORIENTATION_PORTRAIT_UPSIDE_DOWN; + case UIInterfaceOrientationLandscapeLeft: return NEUI_IOS_ORIENTATION_LANDSCAPE_LEFT; + case UIInterfaceOrientationLandscapeRight: return NEUI_IOS_ORIENTATION_LANDSCAPE_RIGHT; + default: return NEUI_IOS_ORIENTATION_UNKNOWN; + } + } + + // Last seen values, so an observer only broadcasts on a real change. + inline uint32_t& ios_last_a11y() { static uint32_t v = 0; return v; } + inline int& ios_last_low_power() { static int v = -1; return v; } + inline int& ios_last_thermal() { static int v = -1; return v; } + inline int& ios_last_orientation() { static int v = -1; return v; } + + // Called by each host from its view controller once a layout / rotation has + // settled. Cheaper and more accurate than UIDevice orientation notifications, + // which need the accelerometer running and report DEVICE, not INTERFACE, + // orientation. + inline void ios_note_orientation_changed(UIViewController* vc) + { + const int now = (int)ios_orientation_of(vc); + if (now == ios_last_orientation()) return; + ios_last_orientation() = now; + ios_broadcast_env(NEUI_IOS_ENV_ORIENTATION); + } + + inline void ios_install_observers() + { + static bool installed = false; + if (installed) return; + installed = true; + + NSNotificationCenter* nc = NSNotificationCenter.defaultCenter; + NSOperationQueue* main = NSOperationQueue.mainQueue; + + ios_last_a11y() = ios_accessibility_flags_now(); + ios_last_low_power() = NSProcessInfo.processInfo.isLowPowerModeEnabled ? 1 : 0; + ios_last_thermal() = (int)NSProcessInfo.processInfo.thermalState; + + auto a11y = ^(NSNotification*) { + const uint32_t now = ios_accessibility_flags_now(); + if (now == ios_last_a11y()) return; + ios_last_a11y() = now; + ios_broadcast_env(NEUI_IOS_ENV_ACCESSIBILITY); + }; + for (NSNotificationName n in @[ UIAccessibilityReduceMotionStatusDidChangeNotification, + UIAccessibilityReduceTransparencyStatusDidChangeNotification, + UIAccessibilityBoldTextStatusDidChangeNotification, + UIAccessibilityDarkerSystemColorsStatusDidChangeNotification, + UIAccessibilityVoiceOverStatusDidChangeNotification, + UIAccessibilitySwitchControlStatusDidChangeNotification ]) + [nc addObserverForName:n object:nil queue:main usingBlock:a11y]; + + [nc addObserverForName:NSProcessInfoPowerStateDidChangeNotification + object:nil queue:main usingBlock:^(NSNotification*) { + const int now = NSProcessInfo.processInfo.isLowPowerModeEnabled ? 1 : 0; + if (now == ios_last_low_power()) return; + ios_last_low_power() = now; + ios_broadcast_env(NEUI_IOS_ENV_LOW_POWER); + }]; + + [nc addObserverForName:NSProcessInfoThermalStateDidChangeNotification + object:nil queue:main usingBlock:^(NSNotification*) { + const int now = (int)NSProcessInfo.processInfo.thermalState; + if (now == ios_last_thermal()) return; + ios_last_thermal() = now; + ios_broadcast_env(NEUI_IOS_ENV_THERMAL); + }]; + + auto battery = ^(NSNotification*) { + ios_broadcast_env(NEUI_IOS_ENV_BATTERY); + }; + [nc addObserverForName:UIDeviceBatteryLevelDidChangeNotification + object:nil queue:main usingBlock:battery]; + [nc addObserverForName:UIDeviceBatteryStateDidChangeNotification + object:nil queue:main usingBlock:battery]; + + [nc addObserverForName:UIKeyboardWillChangeFrameNotification + object:nil queue:main usingBlock:^(NSNotification* note) { + NSValue* v = note.userInfo[UIKeyboardFrameEndUserInfoKey]; + ios_keyboard_frame() = v ? v.CGRectValue : CGRectZero; + ios_broadcast_env(NEUI_IOS_ENV_KEYBOARD); + }]; + [nc addObserverForName:UIKeyboardWillHideNotification + object:nil queue:main usingBlock:^(NSNotification*) { + ios_keyboard_frame() = CGRectZero; + ios_broadcast_env(NEUI_IOS_ENV_KEYBOARD); + }]; + } + + // --------------------------------------------------------------------------- + // The vtable methods. + + inline void ios_set_idle_timer_disabled(neui_session_t session, int disabled) + { + IosSessionState& st = ios_session_state(session); + if (disabled) { + ++st.idle_hold; + } else if (st.idle_hold > 0) { + --st.idle_hold; + } + ios_apply_idle_timer(); + } + + inline int ios_idle_timer_disabled(neui_session_t session) + { + return ios_session_state(session).idle_hold > 0 ? 1 : 0; + } + + inline void ios_set_deferring_system_gestures(neui_session_t session, + neui_widget_t frame, uint32_t edges) + { + ios_frame_state(frame).deferring_edges = edges; + UIViewController* vc = ios_controller_for(session, frame); + if (!vc) return; + if (@available(iOS 11.0, *)) + [vc setNeedsUpdateOfScreenEdgesDeferringSystemGestures]; + } + + inline void ios_set_home_indicator_auto_hidden(neui_session_t session, + neui_widget_t frame, int hidden) + { + ios_frame_state(frame).home_indicator_auto_hidden = hidden != 0; + UIViewController* vc = ios_controller_for(session, frame); + if (!vc) return; + if (@available(iOS 11.0, *)) + [vc setNeedsUpdateOfHomeIndicatorAutoHidden]; + } + + inline float ios_screen_brightness(neui_session_t) + { + UIScreen* s = UIScreen.mainScreen; + return s ? (float)s.brightness : -1.0f; + } + + inline void ios_set_screen_brightness(neui_session_t session, float value) + { + UIScreen* s = UIScreen.mainScreen; + if (!s) return; + IosSessionState& st = ios_session_state(session); + if (value < 0.0f) { + // Explicit restore. + if (st.brightness_saved) { + s.brightness = st.saved_brightness; + st.brightness_saved = false; + } + return; + } + // Remember what the user had, once, so session teardown can put it back. + if (!st.brightness_saved) { + st.saved_brightness = (float)s.brightness; + st.brightness_saved = true; + } + if (value > 1.0f) value = 1.0f; + s.brightness = value; + } + + inline void ios_set_status_bar(neui_session_t session, neui_widget_t frame, + neui_ios_status_bar_t style, int hidden) + { + IosFrameState& fs = ios_frame_state(frame); + fs.status_style = style; + fs.status_hidden = hidden != 0; + UIViewController* vc = ios_controller_for(session, frame); + if (vc) [vc setNeedsStatusBarAppearanceUpdate]; + } + + inline neui_ios_orientation_t ios_orientation(neui_session_t session, + neui_widget_t frame) + { + return ios_orientation_of(ios_controller_for(session, frame)); + } + + inline void ios_set_supported_orientations(neui_session_t session, + neui_widget_t frame, uint32_t mask) + { + ios_frame_state(frame).supported_orientations = mask; + UIViewController* vc = ios_controller_for(session, frame); + if (!vc) return; + if (@available(iOS 16.0, *)) { + [vc setNeedsUpdateOfSupportedInterfaceOrientations]; + } else { + [UIViewController attemptRotationToDeviceOrientation]; + } + } + + inline uint32_t ios_supported_orientations(neui_session_t, neui_widget_t frame) + { + const IosFrameState* fs = ios_frame_state_if_present(frame); + return fs ? fs->supported_orientations : (uint32_t)NEUI_IOS_ORIENTATION_ALL; + } + + // Contributed by each host: repaint after an appearance override, so painted + // widgets follow the native controls instead of keeping the old palette. A + // registry for the same reason as the two above. + using ios_theme_refresh_fn = void (*)(); + + inline std::vector& ios_theme_refresh_seams() + { + static std::vector v; + return v; + } + + inline void ios_add_theme_refresh_seam(ios_theme_refresh_fn fn) + { + ios_seam_add(ios_theme_refresh_seams(), fn); + } + + inline void ios_theme_refresh() + { + for (ios_theme_refresh_fn fn : ios_theme_refresh_seams()) fn(); + } + + inline void ios_set_user_interface_style(neui_session_t session, neui_widget_t frame, + neui_ios_style_t style) + { + UIViewController* vc = ios_controller_for(session, frame); + if (!vc) return; + if (@available(iOS 13.0, *)) { + UIUserInterfaceStyle s = UIUserInterfaceStyleUnspecified; + if (style == NEUI_IOS_STYLE_LIGHT) s = UIUserInterfaceStyleLight; + else if (style == NEUI_IOS_STYLE_DARK) s = UIUserInterfaceStyleDark; + vc.overrideUserInterfaceStyle = s; + // The trait change reaches the views asynchronously; repaint through the + // host's own theme path so painted widgets and native controls agree. + ios_theme_refresh(); + } + } + + inline neui_ios_content_size_t ios_content_size_category(neui_session_t) + { + UIContentSizeCategory c = UIApplication.sharedApplication.preferredContentSizeCategory; + if (!c) return NEUI_IOS_CONTENT_SIZE_UNKNOWN; + if ([c isEqualToString:UIContentSizeCategoryExtraSmall]) return NEUI_IOS_CONTENT_SIZE_XS; + if ([c isEqualToString:UIContentSizeCategorySmall]) return NEUI_IOS_CONTENT_SIZE_S; + if ([c isEqualToString:UIContentSizeCategoryMedium]) return NEUI_IOS_CONTENT_SIZE_M; + if ([c isEqualToString:UIContentSizeCategoryLarge]) return NEUI_IOS_CONTENT_SIZE_L; + if ([c isEqualToString:UIContentSizeCategoryExtraLarge]) return NEUI_IOS_CONTENT_SIZE_XL; + if ([c isEqualToString:UIContentSizeCategoryExtraExtraLarge]) + return NEUI_IOS_CONTENT_SIZE_XXL; + if ([c isEqualToString:UIContentSizeCategoryExtraExtraExtraLarge]) + return NEUI_IOS_CONTENT_SIZE_XXXL; + if ([c isEqualToString:UIContentSizeCategoryAccessibilityMedium]) + return NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_M; + if ([c isEqualToString:UIContentSizeCategoryAccessibilityLarge]) + return NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_L; + if ([c isEqualToString:UIContentSizeCategoryAccessibilityExtraLarge]) + return NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_XL; + if ([c isEqualToString:UIContentSizeCategoryAccessibilityExtraExtraLarge]) + return NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_XXL; + if ([c isEqualToString:UIContentSizeCategoryAccessibilityExtraExtraExtraLarge]) + return NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_XXXL; + return NEUI_IOS_CONTENT_SIZE_UNKNOWN; + } + + inline uint32_t ios_accessibility_flags(neui_session_t) + { + return ios_accessibility_flags_now(); + } + + inline neui_ios_idiom_t ios_idiom(neui_session_t) + { + switch (UIDevice.currentDevice.userInterfaceIdiom) { + case UIUserInterfaceIdiomPhone: return NEUI_IOS_IDIOM_PHONE; + case UIUserInterfaceIdiomPad: return NEUI_IOS_IDIOM_PAD; + case UIUserInterfaceIdiomTV: return NEUI_IOS_IDIOM_TV; + case UIUserInterfaceIdiomMac: return NEUI_IOS_IDIOM_MAC; + default: return NEUI_IOS_IDIOM_UNKNOWN; + } + } + + inline void ios_os_version(neui_session_t, int* major, int* minor, int* patch) + { + NSOperatingSystemVersion v = NSProcessInfo.processInfo.operatingSystemVersion; + if (major) *major = (int)v.majorVersion; + if (minor) *minor = (int)v.minorVersion; + if (patch) *patch = (int)v.patchVersion; + } + + inline const char* ios_device_model(neui_session_t) + { + // Cached: sysctl is cheap but the header promises a pointer that stays + // valid for the process, so the storage has to outlive the call. + static std::string model = [] { + size_t len = 0; + if (sysctlbyname("hw.machine", nullptr, &len, nullptr, 0) != 0 || len == 0) + return std::string(); + std::string s(len, '\0'); + if (sysctlbyname("hw.machine", &s[0], &len, nullptr, 0) != 0) + return std::string(); + if (!s.empty() && s.back() == '\0') s.pop_back(); + return s; + }(); + return model.c_str(); + } + + // Battery monitoring is off by default and costs power, so it is turned on + // by the first query and left on - see the header's COST note. + inline void ios_ensure_battery_monitoring() + { + static bool on = false; + if (on) return; + on = true; + UIDevice.currentDevice.batteryMonitoringEnabled = YES; + } + + inline float ios_battery_level(neui_session_t) + { + ios_ensure_battery_monitoring(); + const float v = UIDevice.currentDevice.batteryLevel; + return v < 0.0f ? -1.0f : v; // UIKit reports -1 when it does not know + } + + inline neui_ios_battery_t ios_battery_state(neui_session_t) + { + ios_ensure_battery_monitoring(); + switch (UIDevice.currentDevice.batteryState) { + case UIDeviceBatteryStateUnplugged: return NEUI_IOS_BATTERY_UNPLUGGED; + case UIDeviceBatteryStateCharging: return NEUI_IOS_BATTERY_CHARGING; + case UIDeviceBatteryStateFull: return NEUI_IOS_BATTERY_FULL; + default: return NEUI_IOS_BATTERY_UNKNOWN; + } + } + + inline int ios_low_power_mode(neui_session_t) + { + return NSProcessInfo.processInfo.isLowPowerModeEnabled ? 1 : 0; + } + + inline neui_ios_thermal_t ios_thermal_state(neui_session_t) + { + switch (NSProcessInfo.processInfo.thermalState) { + case NSProcessInfoThermalStateFair: return NEUI_IOS_THERMAL_FAIR; + case NSProcessInfoThermalStateSerious: return NEUI_IOS_THERMAL_SERIOUS; + case NSProcessInfoThermalStateCritical: return NEUI_IOS_THERMAL_CRITICAL; + default: return NEUI_IOS_THERMAL_NOMINAL; + } + } + + inline void ios_haptic(neui_session_t, neui_ios_haptic_t kind) + { + if (@available(iOS 10.0, *)) { + switch (kind) { + case NEUI_IOS_HAPTIC_SELECTION: { + UISelectionFeedbackGenerator* g = [[UISelectionFeedbackGenerator alloc] init]; + [g prepare]; + [g selectionChanged]; + break; + } + case NEUI_IOS_HAPTIC_SUCCESS: + case NEUI_IOS_HAPTIC_WARNING: + case NEUI_IOS_HAPTIC_ERROR: { + UINotificationFeedbackType t = UINotificationFeedbackTypeSuccess; + if (kind == NEUI_IOS_HAPTIC_WARNING) t = UINotificationFeedbackTypeWarning; + else if (kind == NEUI_IOS_HAPTIC_ERROR) t = UINotificationFeedbackTypeError; + UINotificationFeedbackGenerator* g = + [[UINotificationFeedbackGenerator alloc] init]; + [g prepare]; + [g notificationOccurred:t]; + break; + } + default: { + UIImpactFeedbackStyle st = UIImpactFeedbackStyleMedium; + if (kind == NEUI_IOS_HAPTIC_LIGHT) st = UIImpactFeedbackStyleLight; + else if (kind == NEUI_IOS_HAPTIC_HEAVY) st = UIImpactFeedbackStyleHeavy; + UIImpactFeedbackGenerator* g = + [[UIImpactFeedbackGenerator alloc] initWithStyle:st]; + [g prepare]; + [g impactOccurred]; + break; + } + } + } + } + + inline int ios_keyboard_inset(neui_session_t session, neui_widget_t frame) + { + const CGRect kb = ios_keyboard_frame(); + if (CGRectIsEmpty(kb)) return 0; + UIViewController* vc = ios_controller_for(session, frame); + UIView* view = vc.view; + if (!view || !view.window) return 0; + // The notification's rect is in screen space; bring the view into the same + // space rather than the other way round, so a frame that is not full-screen + // (an iPad form-sheet DIALOG) gets the overlap it actually has. + const CGRect self_in_screen = [view convertRect:view.bounds toView:nil]; + const CGRect overlap = CGRectIntersection(self_in_screen, kb); + if (CGRectIsNull(overlap) || CGRectIsEmpty(overlap)) return 0; + return (int)(overlap.size.height + 0.5); + } + + // --------------------------------------------------------------------------- + // Teardown. Each host calls this from ~Session, which is what makes the + // idle-timer refcount and the brightness restore promises in d/ios.h true. + + inline void ios_session_shutdown(neui_session_t session) + { + auto& m = ios_session_states(); + auto it = m.find(session.session); + if (it == m.end()) return; + if (it->second.brightness_saved) { + if (UIScreen* s = UIScreen.mainScreen) s.brightness = it->second.saved_brightness; + } + m.erase(it); + ios_apply_idle_timer(); + } + + // --------------------------------------------------------------------------- + // The shared vtable. One definition shared by both iOS hosts (inline). + + inline neui_ios_api_t k_ios_api = { + NEUI_VERSION, + ios_set_idle_timer_disabled, + ios_idle_timer_disabled, + ios_set_deferring_system_gestures, + ios_set_home_indicator_auto_hidden, + ios_screen_brightness, + ios_set_screen_brightness, + ios_set_status_bar, + ios_orientation, + ios_set_supported_orientations, + ios_supported_orientations, + ios_set_user_interface_style, + ios_content_size_category, + ios_accessibility_flags, + ios_idiom, + ios_os_version, + ios_device_model, + ios_battery_level, + ios_battery_state, + ios_low_power_mode, + ios_thermal_state, + ios_haptic, + ios_keyboard_inset, + }; + + // What each host's get_interface returns for NEUI_API_IOS. Installs the + // environment observers on first use, so a client that never fetches the + // interface pays for none of them. + inline neui_ios_api_t* ios_api() + { + ios_install_observers(); + return &k_ios_api; + } + + // --------------------------------------------------------------------------- + // UIKit translations for the view-controller overrides in both hosts. + + inline UIStatusBarStyle ios_uikit_status_bar_style(neui_ios_status_bar_t s) + { + if (@available(iOS 13.0, *)) { + if (s == NEUI_IOS_STATUS_BAR_LIGHT) return UIStatusBarStyleLightContent; + if (s == NEUI_IOS_STATUS_BAR_DARK) return UIStatusBarStyleDarkContent; + } else if (s == NEUI_IOS_STATUS_BAR_LIGHT) { + return UIStatusBarStyleLightContent; + } + return UIStatusBarStyleDefault; + } + + inline UIRectEdge ios_uikit_rect_edge(uint32_t edges) + { + UIRectEdge e = UIRectEdgeNone; + if (edges & NEUI_IOS_EDGE_TOP) e |= UIRectEdgeTop; + if (edges & NEUI_IOS_EDGE_LEFT) e |= UIRectEdgeLeft; + if (edges & NEUI_IOS_EDGE_BOTTOM) e |= UIRectEdgeBottom; + if (edges & NEUI_IOS_EDGE_RIGHT) e |= UIRectEdgeRight; + return e; + } + + inline UIInterfaceOrientationMask ios_uikit_orientation_mask(uint32_t mask) + { + UIInterfaceOrientationMask m = (UIInterfaceOrientationMask)0; + if (mask & NEUI_IOS_ORIENTATION_PORTRAIT) + m |= UIInterfaceOrientationMaskPortrait; + if (mask & NEUI_IOS_ORIENTATION_PORTRAIT_UPSIDE_DOWN) + m |= UIInterfaceOrientationMaskPortraitUpsideDown; + if (mask & NEUI_IOS_ORIENTATION_LANDSCAPE_LEFT) + m |= UIInterfaceOrientationMaskLandscapeLeft; + if (mask & NEUI_IOS_ORIENTATION_LANDSCAPE_RIGHT) + m |= UIInterfaceOrientationMaskLandscapeRight; + return m ? m : UIInterfaceOrientationMaskAll; + } + +} // namespace neui_detail + +#endif // TARGET_OS_IPHONE +#endif // __APPLE__ diff --git a/hosts/shared/metrics.h b/hosts/shared/metrics.h index 884938d..a20e417 100644 --- a/hosts/shared/metrics.h +++ b/hosts/shared/metrics.h @@ -2,6 +2,7 @@ #include #include +#include #include "widget_font.h" // painted_ui_scale() / scaled_painted_metric() #include "scrollbar.h" // SCROLLBAR_W @@ -19,9 +20,9 @@ // and the frame's safe-area insets (the view's safeAreaInsets on iOS). The // desktop hosts have no such surface, so the shared vtable carries a desktop // DEFAULT for each (a font-metric-based width estimate, and zero insets) and -// exposes a function-pointer seam the iOS hosts overwrite at registration with -// the real implementation. Desktop hosts touch nothing - they just return the -// shared vtable. +// exposes a seam each iOS host ADDS its real implementation to at +// registration. Desktop hosts touch nothing - they just return the shared +// vtable. The seams are lists, not single slots; see the note on them below. namespace neui_detail { @@ -64,10 +65,8 @@ namespace neui_detail } // Desktop default safe-area: no insets (desktop chrome lives outside the - // client area). The iOS override returns the frame view's safeAreaInsets. - inline void metrics_safe_area_default(neui_session_t /*session*/, - neui_widget_t /*frame*/, - int* left, int* top, int* right, int* bottom) + // client area). + inline void metrics_safe_area_default(int* left, int* top, int* right, int* bottom) { if (left) *left = 0; if (top) *top = 0; @@ -75,18 +74,46 @@ namespace neui_detail if (bottom) *bottom = 0; } - using metrics_measure_fn = int (*)(neui_session_t, const char*, const char*, float, int); - using metrics_safe_area_fn = void (*)(neui_session_t, neui_widget_t, int*, int*, int*, int*); + // The seams are REGISTRIES, not single slots. Two hosts for the same platform + // can be linked into one binary (both iOS hosts are, in examples/ios), and + // neui_init() registers the native one BEFORE xpl, so an assigned slot would + // always end up holding xpl's - and for a native-host client the frame would + // resolve to nothing and safe_area_insets would silently report zeros. + // Each host ADDS instead: measure_text takes the first installed (the iOS + // implementations are equivalent), and safe_area tries each until one claims + // the frame. add() ignores a pointer it already holds, so a repeated + // register_host() stays idempotent. + using metrics_measure_fn = int (*)(neui_session_t, const char*, const char*, float, int); + // Returns true when this host owns `frame` and filled the outputs. + using metrics_safe_area_fn = bool (*)(neui_session_t, neui_widget_t, int*, int*, int*, int*); + + template + inline void metrics_seam_add(std::vector& list, Fn fn) + { + if (!fn) return; + for (Fn existing : list) + if (existing == fn) return; + list.push_back(fn); + } + + inline std::vector& metrics_measure_seams() + { + static std::vector v; + return v; + } + inline std::vector& metrics_safe_area_seams() + { + static std::vector v; + return v; + } - inline metrics_measure_fn& metrics_measure_seam() + inline void metrics_add_measure_seam(metrics_measure_fn fn) { - static metrics_measure_fn fn = &metrics_measure_text_default; - return fn; + metrics_seam_add(metrics_measure_seams(), fn); } - inline metrics_safe_area_fn& metrics_safe_area_seam() + inline void metrics_add_safe_area_seam(metrics_safe_area_fn fn) { - static metrics_safe_area_fn fn = &metrics_safe_area_default; - return fn; + metrics_seam_add(metrics_safe_area_seams(), fn); } // --------------------------------------------------------------------------- @@ -129,13 +156,18 @@ namespace neui_detail inline int metrics_measure_text(neui_session_t session, const char* text, const char* family, float size_px, int weight) { - return metrics_measure_seam()(session, text, family, size_px, weight); + auto& seams = metrics_measure_seams(); + if (!seams.empty()) + return seams.front()(session, text, family, size_px, weight); + return metrics_measure_text_default(session, text, family, size_px, weight); } inline void metrics_safe_area(neui_session_t session, neui_widget_t frame, int* left, int* top, int* right, int* bottom) { - metrics_safe_area_seam()(session, frame, left, top, right, bottom); + for (metrics_safe_area_fn fn : metrics_safe_area_seams()) + if (fn(session, frame, left, top, right, bottom)) return; + metrics_safe_area_default(left, top, right, bottom); } // The shared vtable. One definition shared by every host (header-only inline). diff --git a/include/neui/d/attrs.h b/include/neui/d/attrs.h index be517e7..9319625 100644 --- a/include/neui/d/attrs.h +++ b/include/neui/d/attrs.h @@ -27,6 +27,10 @@ extern "C" { // neui.win32.* - reserved, host-specific (Win32) // neui.macos.* - reserved, host-specific (macOS) // neui.linux.* - reserved, host-specific (Linux) +// neui.ios.* - reserved, host-specific (iOS) - SESSION keys, set via +// set_session_int; see NEUI_IOS_CHECKBOX_STYLE below. For +// anything beyond a creation-time rendering choice, iOS has +// a proper interface instead: NEUI_API_IOS (d/ios.h). #define NEUI_API_ATTRS "com.defiantnerd.neui.extension.attrs/0" @@ -422,6 +426,12 @@ enum { // so it needs no k_well_known_attrs row). Only the native iOS host // (neui.host.ios) reads it; every other host stores it inertly. // +// NOTE: this is the only knob of its kind. The rest of the iOS-specific +// surface - idle timer, screen-edge gestures, status bar, orientation, +// accessibility, battery / thermal, haptics, keyboard inset - is a vtable, +// NEUI_API_IOS in , because a session int cannot carry a float, +// an action, or a per-frame argument. +// // int: rendering style for a 2-state NEUI_W_CHECKBOX on the native iOS host. // Read ONCE at checkbox creation - changing it afterwards only affects // checkboxes created later. NEUI_W_CHECKBOX3 (tri-state) ALWAYS uses the diff --git a/include/neui/d/events.h b/include/neui/d/events.h index a152325..944aba6 100644 --- a/include/neui/d/events.h +++ b/include/neui/d/events.h @@ -32,6 +32,7 @@ extern "C" { #define DEF_GRID_EVENT(x) (((x)<<16) | 0x0006) #define DEF_DND_EVENT(x) (((x)<<16) | 0x0007) #define DEF_TAB_EVENT(x) (((x)<<16) | 0x0008) +#define DEF_IOS_EVENT(x) (((x)<<16) | 0x000B) typedef enum neui_event_type { @@ -88,6 +89,8 @@ extern "C" { NEUI_EVENT_TAB_DESELECTED = DEF_TAB_EVENT(1), // tabview: outgoing tab, fired before the page swap / repaint NEUI_EVENT_TAB_SELECTED = DEF_TAB_EVENT(2), // tabview: incoming tab, fired before the page swap / repaint + NEUI_EVENT_IOS_ENVIRONMENT_CHANGED = DEF_IOS_EVENT(1), // iOS only: orientation / power / thermal / battery / accessibility / keyboard moved (see neui_event_ios_env_t) + NEUI_EVENT_CUSTOM = 0x1ffff, } neui_event_type_t; @@ -354,6 +357,22 @@ extern "C" { float ui_scale; // new painted / text UI scale } neui_event_metrics_t; + // iOS environment change (NEUI_EVENT_IOS_ENVIRONMENT_CHANGED). Fired by the + // two iOS hosts only, when something the NEUI_API_IOS interface reports has + // moved: device orientation, Low Power Mode, thermal state, battery, one of + // the accessibility switches, or the software keyboard. `changed` is an OR of + // NEUI_IOS_ENV_* (see d/ios.h) saying which - one event with a bitmask rather + // than six event types, because these change independently and rarely and a + // client re-reads only what it cares about. + // + // Dynamic Type is NOT here: a content-size change is a metrics change and + // arrives as NEUI_EVENT_METRICS_CHANGED, where it always has. + typedef struct neui_event_ios_env + { + neui_widget_t widget; // the frame + uint32_t changed; // NEUI_IOS_ENV_* bitmask + } neui_event_ios_env_t; + // Grid sort-changed event - fires after a user-driven header click that // mutates the sort stack (or removes a level). Carries the column that // was clicked and its new direction in the stack; clients that need the @@ -436,6 +455,7 @@ extern "C" { neui_event_scroll_t scroll; neui_event_tab_t tab; neui_event_metrics_t metrics; + neui_event_ios_env_t ios_env; } data; // more event data can be added here diff --git a/include/neui/d/ios.h b/include/neui/d/ios.h new file mode 100644 index 0000000..583e83b --- /dev/null +++ b/include/neui/d/ios.h @@ -0,0 +1,320 @@ +#pragma once + +#include +#include +#include "api.h" +#include "events.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// iOS / iPadOS host specialities: the parts of UIKit with no portable +// equivalent, and therefore no home elsewhere in the API. Keeping the screen +// awake through a set, stopping a thumb near the bezel from swiping the app +// away mid-song, asking whether the device dropped into Low Power Mode, firing +// a haptic tick. +// +// Exposed ONLY by the two iOS hosts - neui.host.ios (native UIKit) and +// neui.host.crossplatform on platform_ios.mm. Every other host returns NULL +// from get_interface, which is how a client feature-detects it: +// +// neui_ios_api_t* ios = (neui_ios_api_t*)api->get_interface(sess, NEUI_API_IOS); +// if (ios) ios->set_idle_timer_disabled(sess, 1); // NULL everywhere else +// +// Never handed out inert: a host that returns it implements every method, and a +// call it cannot answer says so in its RETURN VALUE (documented per method). +// +// NOT here, deliberately: safe-area insets and the Dynamic Type SCALE are +// portable and already exist in NEUI_API_METRICS (d/metrics.h) - and +// get_client_rect already excludes the top inset. content_size_category() below +// adds the named CATEGORY, which a float cannot express; it does not replace +// ui_scale. NEUI_IOS_CHECKBOX_STYLE (d/attrs.h) stays a session key: it is a +// creation-time rendering choice, not a live setting. +// +// ANDROID: entries below are marked "Android: " for a future +// hosts/android - annotation only, nothing Android exists yet. "Android: none" +// means an iOS peculiarity that should NOT be promoted to a portable API. +// +// Threading: main thread only, like the rest of neui. UIKit requires it. +#define NEUI_API_IOS "com.defiantnerd.neui.extension.ios/0" + +// --------------------------------------------------------------------------- +// Enumerations and bit flags. +// +// Explicitly numbered because they cross an ABI boundary. Reserved for future +// values: do NOT renumber; bind a new value to the next unused integer. + +// Status-bar foreground style. +// Android: WindowInsetsController APPEARANCE_LIGHT_STATUS_BARS. +typedef enum neui_ios_status_bar { + NEUI_IOS_STATUS_BAR_DEFAULT = 0, // follow the system (dark content on light) + NEUI_IOS_STATUS_BAR_LIGHT = 1, // light content, for a dark UI + NEUI_IOS_STATUS_BAR_DARK = 2, // dark content, for a light UI +} neui_ios_status_bar_t; + +// Interface orientation. The values are bit positions, so one enum serves both +// orientation() - which returns exactly one of them - and +// set_supported_orientations(), which takes an OR of them. +// Android: Display.getRotation() / Activity.setRequestedOrientation. +typedef enum neui_ios_orientation { + NEUI_IOS_ORIENTATION_UNKNOWN = 0, + NEUI_IOS_ORIENTATION_PORTRAIT = 1 << 0, + NEUI_IOS_ORIENTATION_PORTRAIT_UPSIDE_DOWN = 1 << 1, + NEUI_IOS_ORIENTATION_LANDSCAPE_LEFT = 1 << 2, + NEUI_IOS_ORIENTATION_LANDSCAPE_RIGHT = 1 << 3, +} neui_ios_orientation_t; + +// Convenience masks for set_supported_orientations. +enum { + NEUI_IOS_ORIENTATION_ALL_PORTRAIT = NEUI_IOS_ORIENTATION_PORTRAIT | + NEUI_IOS_ORIENTATION_PORTRAIT_UPSIDE_DOWN, + NEUI_IOS_ORIENTATION_ALL_LANDSCAPE = NEUI_IOS_ORIENTATION_LANDSCAPE_LEFT | + NEUI_IOS_ORIENTATION_LANDSCAPE_RIGHT, + NEUI_IOS_ORIENTATION_ALL = NEUI_IOS_ORIENTATION_ALL_PORTRAIT | + NEUI_IOS_ORIENTATION_ALL_LANDSCAPE, +}; + +// Screen edges, for set_deferring_system_gestures. +// Android: View.setSystemGestureExclusionRects - same intent, rects not edges. +enum { + NEUI_IOS_EDGE_NONE = 0, + NEUI_IOS_EDGE_TOP = 1 << 0, + NEUI_IOS_EDGE_LEFT = 1 << 1, + NEUI_IOS_EDGE_BOTTOM = 1 << 2, + NEUI_IOS_EDGE_RIGHT = 1 << 3, + NEUI_IOS_EDGE_ALL = 0x0F, +}; + +// Forced appearance, overriding the device's Light/Dark setting. +// Android: AppCompatDelegate.setDefaultNightMode. +typedef enum neui_ios_style { + NEUI_IOS_STYLE_SYSTEM = 0, // follow the device setting (the default) + NEUI_IOS_STYLE_LIGHT = 1, + NEUI_IOS_STYLE_DARK = 2, +} neui_ios_style_t; + +// Device idiom. Android: screen-size class / smallestScreenWidthDp. +typedef enum neui_ios_idiom { + NEUI_IOS_IDIOM_UNKNOWN = 0, + NEUI_IOS_IDIOM_PHONE = 1, + NEUI_IOS_IDIOM_PAD = 2, + NEUI_IOS_IDIOM_TV = 3, + NEUI_IOS_IDIOM_MAC = 4, // Catalyst, or "Designed for iPad" on Apple silicon + NEUI_IOS_IDIOM_VISION = 5, +} neui_ios_idiom_t; + +// Haptic feedback kinds. The three families UIKit offers, flattened. +// Android: HapticFeedbackConstants / Vibrator (no notification family there). +typedef enum neui_ios_haptic { + NEUI_IOS_HAPTIC_SELECTION = 0, // UISelectionFeedbackGenerator - value ticked + NEUI_IOS_HAPTIC_LIGHT = 1, // UIImpactFeedbackGenerator, .light + NEUI_IOS_HAPTIC_MEDIUM = 2, // ... .medium + NEUI_IOS_HAPTIC_HEAVY = 3, // ... .heavy + NEUI_IOS_HAPTIC_SUCCESS = 4, // UINotificationFeedbackGenerator, .success + NEUI_IOS_HAPTIC_WARNING = 5, // ... .warning + NEUI_IOS_HAPTIC_ERROR = 6, // ... .error +} neui_ios_haptic_t; + +// Thermal pressure. Android: PowerManager.getThermalStatus (API 29+). +typedef enum neui_ios_thermal { + NEUI_IOS_THERMAL_NOMINAL = 0, + NEUI_IOS_THERMAL_FAIR = 1, + NEUI_IOS_THERMAL_SERIOUS = 2, // shed work: stop animating, drop frame rate + NEUI_IOS_THERMAL_CRITICAL = 3, +} neui_ios_thermal_t; + +// Battery charging state. Android: BatteryManager BATTERY_STATUS_*. +typedef enum neui_ios_battery { + NEUI_IOS_BATTERY_UNKNOWN = 0, // also: monitoring unavailable + NEUI_IOS_BATTERY_UNPLUGGED = 1, + NEUI_IOS_BATTERY_CHARGING = 2, + NEUI_IOS_BATTERY_FULL = 3, +} neui_ios_battery_t; + +// Dynamic Type content-size category, ordered smallest to largest so a client +// can COMPARE: `cat >= NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_M` is the usual +// "this user wants the accessibility sizes, switch to a taller layout" test. +// That decision cannot be made from a scale factor, which is why this exists +// alongside metrics->ui_scale rather than instead of it. +// Android: Configuration.fontScale - a float, with no named steps. +typedef enum neui_ios_content_size { + NEUI_IOS_CONTENT_SIZE_UNKNOWN = 0, + NEUI_IOS_CONTENT_SIZE_XS = 1, + NEUI_IOS_CONTENT_SIZE_S = 2, + NEUI_IOS_CONTENT_SIZE_M = 3, + NEUI_IOS_CONTENT_SIZE_L = 4, // the system default + NEUI_IOS_CONTENT_SIZE_XL = 5, + NEUI_IOS_CONTENT_SIZE_XXL = 6, + NEUI_IOS_CONTENT_SIZE_XXXL = 7, + NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_M = 8, + NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_L = 9, + NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_XL = 10, + NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_XXL = 11, + NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_XXXL = 12, +} neui_ios_content_size_t; + +// Accessibility switches, as a bitmask returned by accessibility_flags(). +// One call rather than six, because a client that cares usually reads several +// and they all change through the same notification. +enum { + NEUI_IOS_A11Y_REDUCE_MOTION = 1 << 0, // Android: ANIMATOR_DURATION_SCALE == 0 + NEUI_IOS_A11Y_REDUCE_TRANSPARENCY = 1 << 1, // Android: none + NEUI_IOS_A11Y_BOLD_TEXT = 1 << 2, // Android: none + NEUI_IOS_A11Y_DARKER_COLORS = 1 << 3, // Android: none + NEUI_IOS_A11Y_VOICE_OVER = 1 << 4, // Android: TalkBack, via AccessibilityManager + NEUI_IOS_A11Y_SWITCH_CONTROL = 1 << 5, // Android: Switch Access +}; + +// What moved, in neui_event_ios_env_t::changed. A bitmask rather than one +// event type per observable: these change independently and rarely, and a +// client re-reads only the ones it cares about. +enum { + NEUI_IOS_ENV_ORIENTATION = 1 << 0, + NEUI_IOS_ENV_LOW_POWER = 1 << 1, + NEUI_IOS_ENV_THERMAL = 1 << 2, + NEUI_IOS_ENV_BATTERY = 1 << 3, + NEUI_IOS_ENV_ACCESSIBILITY = 1 << 4, + NEUI_IOS_ENV_KEYBOARD = 1 << 5, +}; + +// --------------------------------------------------------------------------- + +// Frame-scoped calls take an APPWINDOW / PLUGWINDOW / DIALOG widget and are +// no-ops for anything else. Session-scoped calls take the session only; the +// underlying UIKit state is process-wide, and where that matters it is said +// so per method. +typedef struct neui_ios_api { + uint32_t neui_version; + + // ---- Stage & session ---------------------------------------------------- + + // Hold the screen awake (UIApplication.idleTimerDisabled). REFCOUNTED per + // session: two components can hold it without one's release cancelling the + // other's, and the hold is dropped when the session is destroyed - a session + // that goes away never leaves the device awake. Nesting is per session, not + // per process, so two sessions each hold their own. + // Android: FLAG_KEEP_SCREEN_ON. + void (NEUI_ABI *set_idle_timer_disabled)(neui_session_t session, int disabled); + // This session's current hold state (not the process-wide flag). + int (NEUI_ABI *idle_timer_disabled)(neui_session_t session); + + // Which screen edges swallow the first swipe, so the system gesture (swipe-up + // to home, pull-down for notifications) needs a second one. An OR of + // NEUI_IOS_EDGE_*, or NEUI_IOS_EDGE_NONE to stop deferring. This does not + // DISABLE the system gesture - iOS gives no way to - it only makes it + // deliberate, which is what a full-screen control surface needs. + // Android: View.setSystemGestureExclusionRects. + void (NEUI_ABI *set_deferring_system_gestures)(neui_session_t session, + neui_widget_t frame, + uint32_t edges); + + // Let the home indicator fade out when the user stops interacting + // (prefersHomeIndicatorAutoHidden). iOS decides when; this only permits it. + // Android: immersive mode / WindowInsetsController.hide(navigationBars()). + void (NEUI_ABI *set_home_indicator_auto_hidden)(neui_session_t session, + neui_widget_t frame, + int hidden); + + // Screen brightness, 0.0 to 1.0. Returns -1.0 if it cannot be read. + // SYSTEM-WIDE and it outlives the process, so the host records the value it + // found on the first set and restores it when the session is destroyed. Pass + // a negative value to restore it explicitly, earlier. + // Android: WindowManager.LayoutParams.screenBrightness (per window there). + float (NEUI_ABI *screen_brightness)(neui_session_t session); + void (NEUI_ABI *set_screen_brightness)(neui_session_t session, float value); + + // ---- Chrome & orientation ----------------------------------------------- + + // Status-bar style and visibility for a frame. Takes effect on the next + // -setNeedsStatusBarAppearanceUpdate, which this issues. + void (NEUI_ABI *set_status_bar)(neui_session_t session, neui_widget_t frame, + neui_ios_status_bar_t style, int hidden); + + // The frame's current interface orientation: exactly one + // NEUI_IOS_ORIENTATION_* value, or _UNKNOWN before the frame is realized. + neui_ios_orientation_t (NEUI_ABI *orientation)(neui_session_t session, + neui_widget_t frame); + + // Restrict which orientations the frame will rotate to (an OR of + // NEUI_IOS_ORIENTATION_*). This can only NARROW what the app's Info.plist + // UISupportedInterfaceOrientations already allows - iOS will not rotate to an + // orientation the plist omits, whatever is passed here. Pass + // NEUI_IOS_ORIENTATION_ALL to lift a restriction back to the plist's set. + // Android: Activity.setRequestedOrientation. + void (NEUI_ABI *set_supported_orientations)(neui_session_t session, + neui_widget_t frame, + uint32_t mask); + uint32_t (NEUI_ABI *supported_orientations)(neui_session_t session, + neui_widget_t frame); + + // Force Light or Dark for this frame's view tree, overriding the device + // setting (UIView.overrideUserInterfaceStyle). Repaints painted widgets + // through the same path a system appearance change takes, so native controls + // and painted widgets stay in agreement. NEUI_IOS_STYLE_SYSTEM restores the + // default. Independent of NEUI_ATTR_FOLLOW_SYSTEM_THEME, which decides + // whether the PALETTE tracks the appearance; this decides what the appearance + // IS. With a forced style, FOLLOW_SYSTEM_THEME follows the forced one. + void (NEUI_ABI *set_user_interface_style)(neui_session_t session, + neui_widget_t frame, + neui_ios_style_t style); + + // ---- Accessibility & environment ---------------------------------------- + + // The live Dynamic Type category. See the enum's note on why this exists + // next to metrics->ui_scale. Process-wide; `session` is not consulted. + neui_ios_content_size_t (NEUI_ABI *content_size_category)(neui_session_t session); + + // The accessibility switches, as an OR of NEUI_IOS_A11Y_*. Process-wide; + // `session` is not consulted. + uint32_t (NEUI_ABI *accessibility_flags)(neui_session_t session); + + // ---- Device, power & feedback ------------------------------------------- + + neui_ios_idiom_t (NEUI_ABI *idiom)(neui_session_t session); + + // Running OS version. Any out pointer may be NULL to skip that component. + void (NEUI_ABI *os_version)(neui_session_t session, + int* major, int* minor, int* patch); + + // Hardware identifier, e.g. "iPhone16,1" - NOT a marketing name. Points at + // host-owned storage that stays valid for the process's lifetime. Never NULL; + // "" if it cannot be read. Android: Build.MODEL. + const char* (NEUI_ABI *device_model)(neui_session_t session); + + // Battery charge, 0.0 to 1.0, or -1.0 when unknown (the simulator, or the + // moment before monitoring warms up). + // + // COST: the first call to either battery method turns on + // UIDevice.batteryMonitoringEnabled and leaves it on for the process. + // Monitoring is not free, so it is not switched on for a client that never + // asks. A client that polls these is holding it on. + float (NEUI_ABI *battery_level)(neui_session_t session); + neui_ios_battery_t (NEUI_ABI *battery_state)(neui_session_t session); + + // Low Power Mode. Android: PowerManager.isPowerSaveMode. + int (NEUI_ABI *low_power_mode)(neui_session_t session); + + // Thermal pressure. Android: PowerManager.getThermalStatus (API 29+). + neui_ios_thermal_t (NEUI_ABI *thermal_state)(neui_session_t session); + + // Fire a haptic. Silently does nothing on hardware without a Taptic Engine, + // in the simulator, and while the device is in Low Power Mode - that is + // UIKit's behaviour, not a neui policy. Returns nothing because iOS reports + // no outcome either. + void (NEUI_ABI *haptic)(neui_session_t session, neui_ios_haptic_t kind); + + // How much of the frame's bottom edge the software keyboard is covering, in + // logical pixels at 96 DPI (the same units as every other geometry call), or + // 0 when no keyboard is up. Nothing in neui moves content out of the way - + // this is the number a client needs to do it, and NEUI_IOS_ENV_KEYBOARD is + // the notification that it changed. + // Android: WindowInsets.Type.ime() bottom inset. + int (NEUI_ABI *keyboard_inset)(neui_session_t session, neui_widget_t frame); + + // Append new methods at the end (vtable-append evolution rule). +} neui_ios_api_t; + +#ifdef __cplusplus +} +#endif diff --git a/include/neui/neui.h b/include/neui/neui.h index 7ed64ae..04192e3 100644 --- a/include/neui/neui.h +++ b/include/neui/neui.h @@ -31,6 +31,7 @@ #include "d/notify.h" #include "d/metrics.h" #include "d/embed.h" +#include "d/ios.h" #ifdef __cplusplus extern "C" { @@ -48,7 +49,7 @@ extern "C" { // Single startup entry point - clients call this once before // neui_get_api() and don't need to know which host static libs the // current platform produced. Registration order is native-first - // (win32 / macos) then xpl, so neui_get_api(NULL) returns the native + // (win32 / macos / ios) then xpl, so neui_get_api(NULL) returns the native // host where one exists. See also: neui_register / neui_get_api. void neui_init(void); diff --git a/plans/how-to-port.md b/plans/how-to-port.md index ade36bc..a3af2d2 100644 --- a/plans/how-to-port.md +++ b/plans/how-to-port.md @@ -20,8 +20,8 @@ neui has three composable layers. Decide which you're adding before opening any - **Linux / X11**: new backend (Cairo or Skia) + new xpl platform layer (`platform_linux.cpp`). No native host — Linux has no single "native" toolkit. - **Linux / Wayland**: new backend (likely Skia or Cairo) + new xpl platform layer. May share backend with X11. -- **iOS**: reuse `backends/cg/` (CoreGraphics works on iOS) + new xpl platform layer (`platform_ios.mm`) backed by UIKit. macOS-native-host port is not transferable to iOS. -- **Android**: new backend (Skia is a natural fit; the AOSP Skia headers are public) + new xpl platform layer with a JNI bridge. +- **iOS**: **DONE** - see `docs/host-ios.md`. It went further than this playbook predicted: `backends/cg/` was reused and `platform_ios.mm` written as expected, but a full native UIKit host (`hosts/ios/`) was built as well, so iOS ships two. Worth reading as the most recent worked example of everything below, including the two things this playbook does not cover: a platform-only public interface (`NEUI_API_IOS`, `include/neui/d/ios.h`) and what happens when two hosts for the same platform are linked into one binary (seams must be additive, not assigned). +- **Android**: new backend (Skia is a natural fit; the AOSP Skia headers are public) + new xpl platform layer with a JNI bridge. Several of the iOS specialities in `NEUI_API_IOS` have direct Android counterparts and are marked as such per entry in `include/neui/d/ios.h` - safe-area insets (`WindowInsets`) should go through the existing portable `NEUI_API_METRICS` seam rather than a new interface. - **Embedded / framebuffer**: software backend (RGBA buffer writes) + tiny platform layer driving a kernel framebuffer or RTOS GUI service. If you're only adding a **new backend** on an already-supported platform (e.g. a Skia backend for Windows that swaps out d2d), you don't need to touch the platform layer — only the backend and the per-platform CMake selection. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 58f8758..5b4d1a8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -27,6 +27,8 @@ add_executable(neui_tests test_tabview.cpp test_mujson.cpp test_component_loader.cpp + test_metrics.cpp + test_ios_api.cpp # mujson lives in the core lib, but the suite links no library, so its TU is # compiled directly into the test executable (same pattern as the *.cpp above). ${PROJECT_SOURCE_DIR}/src/mujson.cpp) @@ -46,6 +48,19 @@ if(NEUI_IOS) MACOSX_BUNDLE TRUE MACOSX_BUNDLE_GUI_IDENTIFIER "org.neui.tests" XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "org.neui.tests") + + # NEUI_API_IOS (include/neui/d/ios.h) is UIKit, so its implementation can only + # be exercised on iOS. The suite has a plain main(), not UIApplicationMain, so + # these cover the process-global queries and the session/frame bookkeeping - + # everything that needs a window is exercised by examples/ios instead. This + # is the one Tier-1 TU that links a framework; the rest still link nothing. + target_sources(neui_tests PRIVATE test_ios_api_device.mm) + set_source_files_properties(test_ios_api_device.mm PROPERTIES + COMPILE_FLAGS "-fobjc-arc") + target_link_libraries(neui_tests PRIVATE + "-framework UIKit" + "-framework Foundation" + "-framework CoreGraphics") endif() add_test(NAME neui_tests COMMAND neui_tests) diff --git a/tests/test_ios_api.cpp b/tests/test_ios_api.cpp new file mode 100644 index 0000000..e3654e6 --- /dev/null +++ b/tests/test_ios_api.cpp @@ -0,0 +1,129 @@ +#include "neui_test.h" + +#include // NEUI_VERSION, and d/ios.h via the umbrella +#include +#include + +#include + +// Tier 1 checks for the NEUI_API_IOS public contract (include/neui/d/ios.h). +// +// The interface itself is UIKit and can only run on iOS - those checks live in +// test_ios_api_device.mm, which is compiled into this suite only on an iOS +// build. What IS portable is the shape of the contract: the enum values and bit +// flags cross an ABI boundary, so a renumbering is a silent break for every +// already-compiled client. These tests run on all four CI jobs and pin them. + +TEST_CASE("ios: the interface id follows the extension naming convention") +{ + // Reverse-DNS with a revision suffix, as every interface but METRICS uses. + CHECK(std::strcmp(NEUI_API_IOS, "com.defiantnerd.neui.extension.ios/0") == 0); +} + +TEST_CASE("ios: the event category is distinct and the id decodes to it") +{ + // Category lives in the low 16 bits, id in the high bits (d/events.h). + CHECK_EQ((int)(NEUI_EVENT_IOS_ENVIRONMENT_CHANGED & 0xffff), 0x000B); + CHECK_EQ((int)(NEUI_EVENT_IOS_ENVIRONMENT_CHANGED >> 16), 1); + + // Must not collide with any existing category. + CHECK((NEUI_EVENT_IOS_ENVIRONMENT_CHANGED & 0xffff) != (NEUI_EVENT_APP_QUIT & 0xffff)); + CHECK((NEUI_EVENT_IOS_ENVIRONMENT_CHANGED & 0xffff) != (NEUI_EVENT_RESIZE & 0xffff)); + CHECK((NEUI_EVENT_IOS_ENVIRONMENT_CHANGED & 0xffff) != (NEUI_EVENT_TAB_SELECTED & 0xffff)); + CHECK(NEUI_EVENT_IOS_ENVIRONMENT_CHANGED != NEUI_EVENT_METRICS_CHANGED); +} + +TEST_CASE("ios: content-size categories are ordered smallest to largest") +{ + // d/ios.h tells clients to COMPARE against ACCESSIBILITY_M to decide whether + // to switch layout. That only works while the order holds. + CHECK(NEUI_IOS_CONTENT_SIZE_XS < NEUI_IOS_CONTENT_SIZE_S); + CHECK(NEUI_IOS_CONTENT_SIZE_S < NEUI_IOS_CONTENT_SIZE_M); + CHECK(NEUI_IOS_CONTENT_SIZE_M < NEUI_IOS_CONTENT_SIZE_L); + CHECK(NEUI_IOS_CONTENT_SIZE_L < NEUI_IOS_CONTENT_SIZE_XL); + CHECK(NEUI_IOS_CONTENT_SIZE_XL < NEUI_IOS_CONTENT_SIZE_XXL); + CHECK(NEUI_IOS_CONTENT_SIZE_XXL < NEUI_IOS_CONTENT_SIZE_XXXL); + CHECK(NEUI_IOS_CONTENT_SIZE_XXXL < NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_M); + CHECK(NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_M < NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_L); + CHECK(NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_L < NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_XL); + CHECK(NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_XL < NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_XXL); + CHECK(NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_XXL < NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_XXXL); + + // UNKNOWN sorts below every real category, so a comparison against a missing + // value never reads as "the user wants the accessibility sizes". + CHECK(NEUI_IOS_CONTENT_SIZE_UNKNOWN < NEUI_IOS_CONTENT_SIZE_XS); +} + +TEST_CASE("ios: orientation values are single, distinct bits") +{ + // The same enum serves orientation() (one value) and + // set_supported_orientations() (an OR), which only works if each is one bit. + auto one_bit = [](uint32_t v) { return v != 0 && (v & (v - 1)) == 0; }; + CHECK(one_bit(NEUI_IOS_ORIENTATION_PORTRAIT)); + CHECK(one_bit(NEUI_IOS_ORIENTATION_PORTRAIT_UPSIDE_DOWN)); + CHECK(one_bit(NEUI_IOS_ORIENTATION_LANDSCAPE_LEFT)); + CHECK(one_bit(NEUI_IOS_ORIENTATION_LANDSCAPE_RIGHT)); + CHECK_EQ((int)NEUI_IOS_ORIENTATION_UNKNOWN, 0); + + const uint32_t all = NEUI_IOS_ORIENTATION_PORTRAIT | + NEUI_IOS_ORIENTATION_PORTRAIT_UPSIDE_DOWN | + NEUI_IOS_ORIENTATION_LANDSCAPE_LEFT | + NEUI_IOS_ORIENTATION_LANDSCAPE_RIGHT; + CHECK_EQ((int)NEUI_IOS_ORIENTATION_ALL, (int)all); + CHECK_EQ((int)(NEUI_IOS_ORIENTATION_ALL_PORTRAIT & NEUI_IOS_ORIENTATION_ALL_LANDSCAPE), 0); +} + +TEST_CASE("ios: edge and flag bits do not overlap") +{ + auto disjoint_bits = [](const uint32_t* v, int n) { + uint32_t seen = 0; + for (int i = 0; i < n; ++i) { + if (seen & v[i]) return false; + seen |= v[i]; + } + return true; + }; + + const uint32_t edges[] = { NEUI_IOS_EDGE_TOP, NEUI_IOS_EDGE_LEFT, + NEUI_IOS_EDGE_BOTTOM, NEUI_IOS_EDGE_RIGHT }; + CHECK(disjoint_bits(edges, 4)); + CHECK_EQ((int)NEUI_IOS_EDGE_NONE, 0); + CHECK_EQ((int)NEUI_IOS_EDGE_ALL, + (int)(NEUI_IOS_EDGE_TOP | NEUI_IOS_EDGE_LEFT | + NEUI_IOS_EDGE_BOTTOM | NEUI_IOS_EDGE_RIGHT)); + + const uint32_t a11y[] = { NEUI_IOS_A11Y_REDUCE_MOTION, + NEUI_IOS_A11Y_REDUCE_TRANSPARENCY, + NEUI_IOS_A11Y_BOLD_TEXT, + NEUI_IOS_A11Y_DARKER_COLORS, + NEUI_IOS_A11Y_VOICE_OVER, + NEUI_IOS_A11Y_SWITCH_CONTROL }; + CHECK(disjoint_bits(a11y, 6)); + + const uint32_t env[] = { NEUI_IOS_ENV_ORIENTATION, NEUI_IOS_ENV_LOW_POWER, + NEUI_IOS_ENV_THERMAL, NEUI_IOS_ENV_BATTERY, + NEUI_IOS_ENV_ACCESSIBILITY, NEUI_IOS_ENV_KEYBOARD }; + CHECK(disjoint_bits(env, 6)); +} + +TEST_CASE("ios: enum zero values are the safe defaults") +{ + // A zero-initialised struct must mean "nothing asked for / not known", + // never an active setting. + CHECK_EQ((int)NEUI_IOS_STATUS_BAR_DEFAULT, 0); + CHECK_EQ((int)NEUI_IOS_STYLE_SYSTEM, 0); + CHECK_EQ((int)NEUI_IOS_IDIOM_UNKNOWN, 0); + CHECK_EQ((int)NEUI_IOS_THERMAL_NOMINAL, 0); + CHECK_EQ((int)NEUI_IOS_BATTERY_UNKNOWN, 0); + CHECK_EQ((int)NEUI_IOS_CONTENT_SIZE_UNKNOWN, 0); + CHECK_EQ((int)NEUI_IOS_HAPTIC_SELECTION, 0); +} + +TEST_CASE("ios: the vtable's first slot is the version, as every interface has") +{ + neui_ios_api_t api = {}; + api.neui_version = NEUI_VERSION; + CHECK_EQ((int)api.neui_version, (int)NEUI_VERSION); + // Nothing else may be assumed non-null by a client that only version-checks. + CHECK(api.set_idle_timer_disabled == nullptr); +} diff --git a/tests/test_ios_api_device.mm b/tests/test_ios_api_device.mm new file mode 100644 index 0000000..96a6c1d --- /dev/null +++ b/tests/test_ios_api_device.mm @@ -0,0 +1,202 @@ +#include "neui_test.h" + +#include "ios/ios_api.h" + +// iOS-only Tier 1 checks. Compiled into neui_tests only on an iOS build +// (tests/CMakeLists.txt), where CI installs the suite on a simulator, launches +// it and scrapes the summary - so these run on every push. +// +// SCOPE: this bundle has a main() from test_main.cpp, not UIApplicationMain, so +// there is no UIApplication and no window. That rules out anything frame-scoped +// (status bar, home indicator, orientation, keyboard) - those are exercised by +// examples/ios. What IS reachable is every process-global query, which is most +// of the "device, power & environment" half of the interface, plus the +// session-state bookkeeping, which is pure C++ behind the UIKit calls. + +using namespace neui_detail; + +TEST_CASE("ios device: the idle-timer hold is refcounted per session") +{ + // The contract d/ios.h states: two components can hold the screen awake + // without one's release cancelling the other's. + const neui_session_t a = { 4001 }; + const neui_session_t b = { 4002 }; + + CHECK_EQ(ios_idle_timer_disabled(a), 0); + + ios_set_idle_timer_disabled(a, 1); + ios_set_idle_timer_disabled(a, 1); // a second, independent hold + CHECK_EQ(ios_idle_timer_disabled(a), 1); + + ios_set_idle_timer_disabled(a, 0); // one release is not enough + CHECK_EQ(ios_idle_timer_disabled(a), 1); + + ios_set_idle_timer_disabled(a, 0); + CHECK_EQ(ios_idle_timer_disabled(a), 0); + + // Releasing past zero must not go negative, or the next hold would not take. + ios_set_idle_timer_disabled(a, 0); + ios_set_idle_timer_disabled(a, 1); + CHECK_EQ(ios_idle_timer_disabled(a), 1); + + // Sessions are independent. + CHECK_EQ(ios_idle_timer_disabled(b), 0); + + ios_session_shutdown(a); + CHECK_EQ(ios_idle_timer_disabled(a), 0); // teardown drops the hold +} + +TEST_CASE("ios device: frame chrome is remembered, and forgotten on destroy") +{ + const neui_widget_t frame = { 0x00010002 }; + + // Nothing set: the view-controller overrides must see no state and fall + // through to super rather than forcing a default. + ios_frame_forget(frame); + CHECK(ios_frame_state_if_present(frame) == nullptr); + + // Defaults before anything is set, so a frame that only sets the status bar + // does not accidentally restrict its orientations. + CHECK_EQ((int)ios_supported_orientations(neui_session_t{ 0 }, frame), + (int)NEUI_IOS_ORIENTATION_ALL); + + ios_frame_state(frame).deferring_edges = NEUI_IOS_EDGE_BOTTOM; + ios_frame_state(frame).supported_orientations = NEUI_IOS_ORIENTATION_ALL_LANDSCAPE; + const IosFrameState* st = ios_frame_state_if_present(frame); + CHECK(st != nullptr); + CHECK_EQ((int)st->deferring_edges, (int)NEUI_IOS_EDGE_BOTTOM); + CHECK_EQ((int)ios_supported_orientations(neui_session_t{ 0 }, frame), + (int)NEUI_IOS_ORIENTATION_ALL_LANDSCAPE); + + // Widget ids are recycled: a destroyed frame must not leave its chrome behind + // for whoever gets the id next. + ios_frame_forget(frame); + CHECK(ios_frame_state_if_present(frame) == nullptr); + CHECK_EQ((int)ios_supported_orientations(neui_session_t{ 0 }, frame), + (int)NEUI_IOS_ORIENTATION_ALL); +} + +TEST_CASE("ios device: UIKit translations map every value") +{ + CHECK_EQ((int)ios_uikit_rect_edge(NEUI_IOS_EDGE_NONE), (int)UIRectEdgeNone); + CHECK_EQ((int)ios_uikit_rect_edge(NEUI_IOS_EDGE_ALL), (int)UIRectEdgeAll); + CHECK_EQ((int)ios_uikit_rect_edge(NEUI_IOS_EDGE_BOTTOM), (int)UIRectEdgeBottom); + + // An empty mask must not lock the app to nothing - it falls back to "all". + CHECK_EQ((int)ios_uikit_orientation_mask(0), (int)UIInterfaceOrientationMaskAll); + CHECK_EQ((int)ios_uikit_orientation_mask(NEUI_IOS_ORIENTATION_PORTRAIT), + (int)UIInterfaceOrientationMaskPortrait); + CHECK_EQ((int)ios_uikit_orientation_mask(NEUI_IOS_ORIENTATION_ALL_LANDSCAPE), + (int)UIInterfaceOrientationMaskLandscape); + + CHECK_EQ((int)ios_uikit_status_bar_style(NEUI_IOS_STATUS_BAR_DEFAULT), + (int)UIStatusBarStyleDefault); + CHECK_EQ((int)ios_uikit_status_bar_style(NEUI_IOS_STATUS_BAR_LIGHT), + (int)UIStatusBarStyleLightContent); +} + +TEST_CASE("ios device: the process-global queries answer sanely") +{ + const neui_session_t s = { 0 }; + + // Idiom: the simulator is a phone or a pad, never unknown. + const neui_ios_idiom_t idiom = ios_idiom(s); + CHECK(idiom == NEUI_IOS_IDIOM_PHONE || idiom == NEUI_IOS_IDIOM_PAD || + idiom == NEUI_IOS_IDIOM_MAC); + + int major = 0, minor = -1, patch = -1; + ios_os_version(s, &major, &minor, &patch); + CHECK(major >= 11); // the oldest version anything here guards for + CHECK(minor >= 0); + CHECK(patch >= 0); + + // Out pointers may be NULL - the header says so, so it must be true. + ios_os_version(s, nullptr, nullptr, nullptr); + + const char* model = ios_device_model(s); + CHECK(model != nullptr); + CHECK(model[0] != '\0'); + // Cached: the header promises a pointer valid for the process lifetime. + CHECK(model == ios_device_model(s)); + + CHECK(ios_low_power_mode(s) == 0 || ios_low_power_mode(s) == 1); + + const neui_ios_thermal_t t = ios_thermal_state(s); + CHECK(t >= NEUI_IOS_THERMAL_NOMINAL && t <= NEUI_IOS_THERMAL_CRITICAL); + + // Every raised bit must be one we define. + const uint32_t known = NEUI_IOS_A11Y_REDUCE_MOTION | NEUI_IOS_A11Y_REDUCE_TRANSPARENCY | + NEUI_IOS_A11Y_BOLD_TEXT | NEUI_IOS_A11Y_DARKER_COLORS | + NEUI_IOS_A11Y_VOICE_OVER | NEUI_IOS_A11Y_SWITCH_CONTROL; + CHECK_EQ((int)(ios_accessibility_flags(s) & ~known), 0); + + // The category is whatever the device is set to, but it must be in range. + const neui_ios_content_size_t c = ios_content_size_category(s); + CHECK(c >= NEUI_IOS_CONTENT_SIZE_UNKNOWN && + c <= NEUI_IOS_CONTENT_SIZE_ACCESSIBILITY_XXXL); +} + +TEST_CASE("ios device: battery and brightness report or admit they cannot") +{ + const neui_session_t s = { 0 }; + + // The simulator has no battery. Either a real 0..1 reading or the documented + // -1 is correct; anything else is not. + const float level = ios_battery_level(s); + CHECK(level == -1.0f || (level >= 0.0f && level <= 1.0f)); + + const neui_ios_battery_t bs = ios_battery_state(s); + CHECK(bs >= NEUI_IOS_BATTERY_UNKNOWN && bs <= NEUI_IOS_BATTERY_FULL); + + const float b = ios_screen_brightness(s); + CHECK(b == -1.0f || (b >= 0.0f && b <= 1.0f)); +} + +TEST_CASE("ios device: a haptic on hardware without one is a silent no-op") +{ + // The header says UIKit reports no outcome and neui adds no policy. The only + // thing to assert is that asking does not crash where there is no engine. + const neui_session_t s = { 0 }; + ios_haptic(s, NEUI_IOS_HAPTIC_SELECTION); + ios_haptic(s, NEUI_IOS_HAPTIC_LIGHT); + ios_haptic(s, NEUI_IOS_HAPTIC_SUCCESS); + CHECK(true); +} + +TEST_CASE("ios device: the vtable is fully populated") +{ + // The interface is never handed out inert - a host that returns it implements + // every method. A null slot would be a caller crash, not a graceful absence. + const neui_ios_api_t* a = &k_ios_api; + CHECK_EQ((int)a->neui_version, (int)NEUI_VERSION); + CHECK(a->set_idle_timer_disabled != nullptr); + CHECK(a->idle_timer_disabled != nullptr); + CHECK(a->set_deferring_system_gestures != nullptr); + CHECK(a->set_home_indicator_auto_hidden != nullptr); + CHECK(a->screen_brightness != nullptr); + CHECK(a->set_screen_brightness != nullptr); + CHECK(a->set_status_bar != nullptr); + CHECK(a->orientation != nullptr); + CHECK(a->set_supported_orientations != nullptr); + CHECK(a->supported_orientations != nullptr); + CHECK(a->set_user_interface_style != nullptr); + CHECK(a->content_size_category != nullptr); + CHECK(a->accessibility_flags != nullptr); + CHECK(a->idiom != nullptr); + CHECK(a->os_version != nullptr); + CHECK(a->device_model != nullptr); + CHECK(a->battery_level != nullptr); + CHECK(a->battery_state != nullptr); + CHECK(a->low_power_mode != nullptr); + CHECK(a->thermal_state != nullptr); + CHECK(a->haptic != nullptr); + CHECK(a->keyboard_inset != nullptr); +} + +TEST_CASE("ios device: with no keyboard up, the inset is zero") +{ + // No window in this bundle, so the lookup finds no view - which must read as + // "nothing covered", not as a crash or a garbage inset. + ios_keyboard_frame() = CGRectZero; + CHECK_EQ(ios_keyboard_inset(neui_session_t{ 0 }, neui_widget_t{ 0 }), 0); +} diff --git a/tests/test_metrics.cpp b/tests/test_metrics.cpp new file mode 100644 index 0000000..d5be655 --- /dev/null +++ b/tests/test_metrics.cpp @@ -0,0 +1,94 @@ +#include "neui_test.h" + +#include "metrics.h" + +// The NEUI_API_METRICS seams. Two hosts for one platform can be linked into the +// same binary (both iOS hosts are), so the seams are registries: each host adds +// its implementation, and safe_area asks each until one claims the frame. +// Assigning a single slot instead made the last-registered host win and report +// zeros for every frame the other one owned. + +using namespace neui_detail; + +namespace { + // Two fake hosts, each owning one frame id. + bool fake_host_a(neui_session_t, neui_widget_t frame, int* l, int* t, int* r, int* b) + { + if (frame.id != 0xA) return false; + if (l) *l = 1; + if (t) *t = 2; + if (r) *r = 3; + if (b) *b = 4; + return true; + } + bool fake_host_b(neui_session_t, neui_widget_t frame, int* l, int* t, int* r, int* b) + { + if (frame.id != 0xB) return false; + if (l) *l = 5; + if (t) *t = 6; + if (r) *r = 7; + if (b) *b = 8; + return true; + } +} + +TEST_CASE("metrics: safe_area asks every host, not just the last registered") +{ + metrics_add_safe_area_seam(&fake_host_a); + metrics_add_safe_area_seam(&fake_host_b); + + int l = -1, t = -1, r = -1, b = -1; + + // The FIRST-registered host still answers for its own frame. This is the + // regression: with an assigned slot, host B would have been asked instead and + // reported zeros. + metrics_safe_area(neui_session_t{ 0 }, neui_widget_t{ 0xA }, &l, &t, &r, &b); + CHECK_EQ(l, 1); CHECK_EQ(t, 2); CHECK_EQ(r, 3); CHECK_EQ(b, 4); + + metrics_safe_area(neui_session_t{ 0 }, neui_widget_t{ 0xB }, &l, &t, &r, &b); + CHECK_EQ(l, 5); CHECK_EQ(t, 6); CHECK_EQ(r, 7); CHECK_EQ(b, 8); + + // A frame no host claims falls back to the desktop default: zero insets. + metrics_safe_area(neui_session_t{ 0 }, neui_widget_t{ 0xC }, &l, &t, &r, &b); + CHECK_EQ(l, 0); CHECK_EQ(t, 0); CHECK_EQ(r, 0); CHECK_EQ(b, 0); + + // Out pointers may be NULL - the public header says so. + metrics_safe_area(neui_session_t{ 0 }, neui_widget_t{ 0xA }, nullptr, &t, nullptr, nullptr); + CHECK_EQ(t, 2); +} + +TEST_CASE("metrics: adding the same seam twice is a no-op") +{ + // register_host() is documented idempotent, so a repeat must not grow the + // list (and must not make a claim-check run twice). + const size_t before = metrics_safe_area_seams().size(); + metrics_add_safe_area_seam(&fake_host_a); + metrics_add_safe_area_seam(&fake_host_a); + CHECK_EQ((int)metrics_safe_area_seams().size(), (int)before); + + metrics_add_safe_area_seam(nullptr); // and null is ignored, not stored + CHECK_EQ((int)metrics_safe_area_seams().size(), (int)before); +} + +TEST_CASE("metrics: the desktop defaults are the framework's own sizes at scale 1") +{ + // painted_ui_scale() is 1.0 on every desktop host, so these are exact. + CHECK_APPROX(metrics_ui_scale(neui_session_t{ 0 }), 1.0); + CHECK_EQ(metrics_metric(neui_session_t{ 0 }, NEUI_METRIC_CONTROL_HEIGHT), + METRIC_BASE_CONTROL_HEIGHT); + CHECK_EQ(metrics_metric(neui_session_t{ 0 }, NEUI_METRIC_MARGIN), METRIC_BASE_MARGIN); + CHECK_EQ(metrics_metric(neui_session_t{ 0 }, NEUI_METRIC_SPACING), METRIC_BASE_SPACING); + CHECK_EQ(metrics_metric(neui_session_t{ 0 }, NEUI_METRIC_BODY_FONT_SIZE), 12); + CHECK_EQ(metrics_metric(neui_session_t{ 0 }, (neui_metric_t)9999), 0); // unknown => 0 +} + +TEST_CASE("metrics: the default text measure counts codepoints, not bytes") +{ + const neui_session_t s = { 0 }; + CHECK_EQ(metrics_measure_text_default(s, nullptr, nullptr, 10.0f, 0), 0); + CHECK_EQ(metrics_measure_text_default(s, "", nullptr, 10.0f, 0), 0); + // 4 glyphs * 10px * 0.5 = 20. + CHECK_EQ(metrics_measure_text_default(s, "abcd", nullptr, 10.0f, 0), 20); + // "ä" is two bytes but one glyph, so it must not measure as two. + CHECK_EQ(metrics_measure_text_default(s, "\xc3\xa4", nullptr, 10.0f, 0), 5); +}