From 5be6dc3ccc565d7a3d72b9495652e8e1abfd0df4 Mon Sep 17 00:00:00 2001 From: defiantnerd <97224712+defiantnerd@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:01:44 +0200 Subject: [PATCH] Tell the client when an inset other than the top moves Both iOS hosts gated reportResizeIfChanged on the bounds and the TOP inset alone: if (w == _last_w && h == _last_h && top_inset == _last_top_inset) return; so a change confined to the left, right or bottom inset raised nothing at all. That was defensible while the top inset was the only one anybody acted on -- it is the one get_client_rect subtracts -- but safe_area_insets reports all four, and docs/host-ios.md tells every iOS client to subtract the other three by hand. A client that does has no way to hear that they moved. Both hosts now track all four edges and split the two notifications. RESIZE fires exactly where it did: the top inset is folded into the rect get_client_rect reports, so a top-only change is a resize to a client even when the bounds held still, which is what the comment in platform_ios.mm already said and is left alone. The other three are not in that rect, so they raise METRICS_CHANGED on its own. The insets are read off the same view each host's safe_area_insets seam reads, so the gate and the answer cannot drift apart. On what this does NOT fix: the case that prompted it was launching straight into landscape, the idea being that the bounds arrive first and the inset resolve is then swallowed. That does not reproduce. Measured on an iPhone 17 Pro / iOS 26.5 with one client binary and only these two files differing, a landscape launch lays out 74 pt clear on each side either way -- UIKit resolves the safe area before the first viewDidLayoutSubviews, so the opening RESIZE already carries the right insets. The hole is real but I have no reproduction of it biting. The concrete trigger is set_home_indicator_auto_hidden, which NEUI_API_IOS ships: it moves safeAreaInsets.bottom and changes no bounds. Verified: fresh iOS configure with NEUI_WERROR=ON builds clean, and the suite on the simulator is 351 cases / 2107 checks, 0 failed. --- docs/host-ios.md | 4 +- hosts/crossplatform/platform_ios.mm | 65 ++++++++++++++++++++++------- hosts/ios/window.mm | 55 ++++++++++++++++++------ 3 files changed, 97 insertions(+), 27 deletions(-) diff --git a/docs/host-ios.md b/docs/host-ios.md index 3f2afbc..3bf5c58 100644 --- a/docs/host-ios.md +++ b/docs/host-ios.md @@ -32,7 +32,9 @@ backend separately as `wd.dpi = 96 * screen.scale`. 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. +- **`NEUI_EVENT_METRICS_CHANGED`** on Dynamic Type, rotation and safe-area change — a change + confined to the left, right or bottom inset moves no bounds and raises no `RESIZE`, so this + is the only notice a client laying out inside the safe area gets. - **Dark mode** via `NEUI_ATTR_FOLLOW_SYSTEM_THEME`; `@2x`/`@3x` asset selection. ### Not available here diff --git a/hosts/crossplatform/platform_ios.mm b/hosts/crossplatform/platform_ios.mm index b4664fd..88b029e 100644 --- a/hosts/crossplatform/platform_ios.mm +++ b/hosts/crossplatform/platform_ios.mm @@ -1750,6 +1750,13 @@ @interface NEUIViewController : UIViewController // still re-fires RESIZE - the usable client rect changed even though the // frame size didn't, so the client must re-lay-out below the new inset. int _last_top_inset; + // The other three safe-area edges. They are not folded into the client rect, + // so they raise METRICS_CHANGED on their own rather than RESIZE - but they do + // move, and a client laying out inside the safe area has no other way to hear + // about it. + int _last_left; + int _last_right; + int _last_bottom; } @end @@ -1836,25 +1843,54 @@ - (void)reportResizeIfChanged // The effective top inset (status-bar/notch safe area + hamburger band when // a menubar exists) feeds widget_client_rect; track it alongside the bounds. int top_inset = session->frame_top_inset(widget_index); - if (w == _last_w && h == _last_h && top_inset == _last_top_inset) return; + // And the other three edges, read off the same view metrics_safe_area_xpl_ios + // reads so the gate and the answer cannot drift apart. Gating on the top alone + // swallowed a side- or bottom-only resolve entirely: the client was never + // told, and went on laying out to insets that had moved. In landscape those + // are the edges that matter - the rounded corners and the camera housing down + // the sides, the home indicator along the bottom. + int left = 0, right = 0, bottom = 0; + if (@available(iOS 11.0, *)) { + NEUIView* view = [self neuiView]; + if (view) { + UIEdgeInsets ins = view.safeAreaInsets; + left = (int)(ins.left + 0.5); + right = (int)(ins.right + 0.5); + bottom = (int)(ins.bottom + 0.5); + } + } + // The top inset is folded into the rect get_client_rect reports, so a change + // to it is a resize as far as a client is concerned even when the bounds held + // still. The other three are not, so they raise METRICS_CHANGED alone. + const bool client_rect_changed = + (w != _last_w || h != _last_h || top_inset != _last_top_inset); + const bool edges_changed = + (left != _last_left || right != _last_right || bottom != _last_bottom); + if (!client_rect_changed && !edges_changed) return; _last_w = w; _last_h = h; _last_top_inset = top_inset; + _last_left = left; + _last_right = right; + _last_bottom = bottom; auto& wd = session->_widgets[widget_index]; - wd.width = w; - wd.height = h; - session->resize_render_ctx(widget_index, (uint32_t)w, (uint32_t)h); + if (client_rect_changed) { + wd.width = w; + wd.height = h; + session->resize_render_ctx(widget_index, (uint32_t)w, (uint32_t)h); - neui_event_t ev = {}; - ev.type = NEUI_EVENT_RESIZE; - ev.data.resize.widget = { wd.widget_id }; - ev.data.resize.width = w; - ev.data.resize.height = h; - session->dispatch_event(&ev); + neui_event_t ev = {}; + ev.type = NEUI_EVENT_RESIZE; + ev.data.resize.widget = { wd.widget_id }; + ev.data.resize.width = w; + ev.data.resize.height = h; + session->dispatch_event(&ev); + } // The safe-area insets (and thus safe_area_insets / get_client_rect) change on - // rotation + when the notch/status-bar inset first resolves, so notify the - // client that the metrics changed too (alongside RESIZE). + // rotation, when the notch/status-bar inset first resolves, and when any of + // the other three edges moves. This fires for all of them - alongside RESIZE + // where the client rect moved too, on its own where only an edge did. 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 @@ -1904,8 +1940,9 @@ - (void)viewDidLayoutSubviews // A safe-area change (status bar appearing, the first inset resolve after the // window binds to its scene, or rotation revealing a notch on a new edge) does // not change the view bounds, so reportResizeIfChanged would otherwise stay -// silent. The inset feeds widget_client_rect + the hamburger band position, so -// re-fire RESIZE (now inset-aware) and re-layout the button. +// silent. The top inset feeds widget_client_rect + the hamburger band position, +// so re-fire RESIZE for that one; a move on any of the other three edges raises +// METRICS_CHANGED instead. Either way the button is re-laid-out. - (void)viewSafeAreaInsetsDidChange { [super viewSafeAreaInsetsDidChange]; diff --git a/hosts/ios/window.mm b/hosts/ios/window.mm index dbccb58..9129ad6 100644 --- a/hosts/ios/window.mm +++ b/hosts/ios/window.mm @@ -1546,6 +1546,11 @@ @interface NEUINativeIOSViewController : UIViewController uint32_t widget_index; @private int _last_w, _last_h, _last_inset; + // The other three safe-area edges. They do not move the client rect the way + // the top inset does, so they drive METRICS_CHANGED on their own rather than + // RESIZE - but they do move, and a client laying out inside the safe area has + // no other way to hear about it. + int _last_left, _last_right, _last_bottom; } @end @@ -1628,21 +1633,47 @@ - (void)reportResizeIfChanged int w = (int)sz.width, h = (int)sz.height; if (w <= 0 || h <= 0) return; int inset = ios_host::frame_top_inset_ios(session, widget_index); - if (w == _last_w && h == _last_h && inset == _last_inset) return; + // And the other three edges, read off the same view metrics_safe_area_ios + // reads so the gate and the answer cannot drift apart. Gating on the top + // alone swallowed a side- or bottom-only resolve entirely: the client was + // never told, and went on laying out to insets that had moved. In landscape + // those are the edges that matter - the rounded corners and the camera + // housing down the sides, the home indicator along the bottom. + int left = 0, right = 0, bottom = 0; + if (@available(iOS 11.0, *)) { + NEUINativeIOSContentView* cv = [self contentView]; + if (cv) { + UIEdgeInsets ins = cv.safeAreaInsets; + left = (int)(ins.left + 0.5); + right = (int)(ins.right + 0.5); + bottom = (int)(ins.bottom + 0.5); + } + } + // The top inset is folded into the rect get_client_rect reports, so a change + // to it is a resize as far as a client is concerned even when the bounds held + // still. The other three are not, so they raise METRICS_CHANGED alone. + const bool client_rect_changed = (w != _last_w || h != _last_h || inset != _last_inset); + const bool edges_changed = + (left != _last_left || right != _last_right || bottom != _last_bottom); + if (!client_rect_changed && !edges_changed) return; _last_w = w; _last_h = h; _last_inset = inset; + _last_left = left; _last_right = right; _last_bottom = bottom; auto& wd = session->_widgets[widget_index]; - wd.width = w; wd.height = h; - auto* backend = neui_cg_backend::get_backend(); - if (backend && backend->resize && wd.render_ctx) - backend->resize(wd.render_ctx, (uint32_t)w, (uint32_t)h); - neui_event_t ev = {}; - ev.type = NEUI_EVENT_RESIZE; - ev.data.resize.widget = { wd.widget_id }; - ev.data.resize.width = w; ev.data.resize.height = h; - session->dispatch_event(&ev); + if (client_rect_changed) { + wd.width = w; wd.height = h; + auto* backend = neui_cg_backend::get_backend(); + if (backend && backend->resize && wd.render_ctx) + backend->resize(wd.render_ctx, (uint32_t)w, (uint32_t)h); + neui_event_t ev = {}; + ev.type = NEUI_EVENT_RESIZE; + ev.data.resize.widget = { wd.widget_id }; + ev.data.resize.width = w; ev.data.resize.height = h; + session->dispatch_event(&ev); + } // The safe-area insets (and thus safe_area_insets / get_client_rect) change on - // rotation + when the notch/status-bar inset first resolves, so notify the - // client that the metrics changed too (alongside RESIZE). + // rotation, when the notch/status-bar inset first resolves, and when any of + // the other three edges moves. This fires for all of them - alongside RESIZE + // where the client rect moved too, on its own where only an edge did. 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