From 480891baf551f4c6f156c2640789eeb14ae31593 Mon Sep 17 00:00:00 2001 From: defiantnerd <97224712+defiantnerd@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:30:08 +0200 Subject: [PATCH] Add NEUI_API_IOS, an iOS-only host extension UIKit exposes a lot that has no portable equivalent, and a client had no way to reach any of it: keep the screen awake through a set, stop a thumb near the bezel from swiping the app away mid-song, ask whether the device dropped into Low Power Mode, fire a haptic tick. NEUI_API_IOS (include/neui/d/ios.h) is returned only by the two iOS hosts; every other host returns NULL from get_interface, the same feature-detect contract NEUI_API_EMBED and NEUI_API_METRICS already use. Unlike the xpl host's inert NEUI_API_EMBED on iOS, it is never handed out empty: a host that returns it implements every method, and a call it cannot answer says so in its return value. Changes arrive as one NEUI_EVENT_IOS_ENVIRONMENT_CHANGED carrying a bitmask of what moved, in its own event category. Safe-area insets are deliberately NOT here. They are portable, they already exist in NEUI_API_METRICS, and an Android host can implement the same seam from WindowInsets. Entries that do have an Android counterpart are marked inline for whoever writes that host; three are marked "Android: none". Implementation is shared between the hosts (hosts/shared/ios/ios_api.h) because UIKit's globals are identical in both. They differ in two things only, and both are seams: resolving a frame to its UIViewController, and walking the host's own registry to deliver an event. Those seams are registries rather than single slots, and so are the metrics ones now. neui_init() registers the native iOS host and then xpl, so an assigned slot always ended up holding xpl's - and for a native-host client it failed silently. This was already true of NEUI_API_METRICS: safe_area_insets measured zeros on every edge in examples/ios, masked because get_client_rect computes its top inset directly rather than through the seam. Both now ADD, the frame lookups try each until one claims the frame, and the example prints its insets so a regression shows up on the next run. Verified on an iOS 26.5 simulator (351 cases / 2107 checks) and on macOS (343 / 2046), both -Werror clean. Battery, thermal, Low Power, brightness and whether haptics actually fire still need hardware. Docs: docs/host-ios.md is new and entered through the CLAUDE.md subsystem index, which is the path an agent follows. Also corrected what would otherwise mislead - CLAUDE.md's platform list and host table, TODO.md listing iOS as unported, the neui.ios.* namespace missing from attrs.h, and the "phase-2 stubs" claims in hosts/ios/host.h and platform_ios.mm for widgets that are long since implemented. --- CLAUDE.md | 21 +- TODO.md | 5 +- docs/attributes.md | 4 +- docs/design-notes.md | 10 + docs/host-ios.md | 103 ++++ examples/ios/SceneDelegate.mm | 74 +++ hosts/crossplatform/CMakeLists.txt | 5 + hosts/crossplatform/host.cpp | 11 + hosts/crossplatform/platform.h | 17 + hosts/crossplatform/platform_ios.mm | 171 ++++++- hosts/ios/host.h | 10 +- hosts/ios/host.mm | 20 +- hosts/ios/window.mm | 126 ++++- hosts/shared/ios/ios_api.h | 707 ++++++++++++++++++++++++++++ hosts/shared/metrics.h | 66 ++- include/neui/d/attrs.h | 10 + include/neui/d/events.h | 20 + include/neui/d/ios.h | 320 +++++++++++++ include/neui/neui.h | 3 +- plans/how-to-port.md | 4 +- tests/CMakeLists.txt | 15 + tests/test_ios_api.cpp | 129 +++++ tests/test_ios_api_device.mm | 202 ++++++++ tests/test_metrics.cpp | 94 ++++ 24 files changed, 2087 insertions(+), 60 deletions(-) create mode 100644 docs/host-ios.md create mode 100644 hosts/shared/ios/ios_api.h create mode 100644 include/neui/d/ios.h create mode 100644 tests/test_ios_api.cpp create mode 100644 tests/test_ios_api_device.mm create mode 100644 tests/test_metrics.cpp 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); +}