Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions CLAUDE.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Attribute API

`NEUI_API_ATTRS`. String-keyed bag per widget (`std::unique_ptr<AttrBag>` 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<AttrBag>` 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.<name>`; macros `NEUI_ATTR_*`):

Expand Down Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions docs/design-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
103 changes: 103 additions & 0 deletions docs/host-ios.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<!-- neui reference. Extracted from CLAUDE.md - read when working on these topics. -->

## 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.
74 changes: 74 additions & 0 deletions examples/ios/SceneDelegate.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);

Expand Down
5 changes: 5 additions & 0 deletions hosts/crossplatform/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
Loading
Loading