From 85d59cebf76504443a9dda5cca45f520471cc38d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 15:44:09 +0100 Subject: [PATCH] fix(native): keep the composer focused when the iOS keyboard shows The view wrapped the page in a keyboard-padding column only while the keyboard was up. Rune derives unkeyed element ids from their position, so the wrapper renamed every element, the focused composer included: focus dropped, the keyboard hid, the wrapper went away, and it looped. The wrapper is now always built, padded by 0 with the keyboard down, and the page-level paste handler moves to it as the new root. Co-Authored-By: Claude Opus 5.5 --- apps/native/host/src/render/mod.rs | 18 +++++++++++------- apps/native/host/src/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/apps/native/host/src/render/mod.rs b/apps/native/host/src/render/mod.rs index d30d4fc8e..d20739c37 100644 --- a/apps/native/host/src/render/mod.rs +++ b/apps/native/host/src/render/mod.rs @@ -421,12 +421,10 @@ pub fn view(s: &State, cx: &mut Cx) -> El { let [top, right, bottom, left] = cx.safe_area(); let keyboard = s.keyboard; let inset = [top, right, if keyboard > 0.0 { 0.0 } else { bottom }, left]; - // A paste with no field focused goes to the page, which hands it to the composer. let mut page = col() .w_full() .h_full() - .on_layout(move |_: &mut State, rect| report_env(rect, inset)) - .on_paste_content(|_: &mut State, content, _| send_paste(content)); + .on_layout(move |_: &mut State, rect| report_env(rect, inset)); let bg = color(&s.page.bg); if let Some(bg) = bg { page = page.fill(bg) @@ -476,10 +474,16 @@ pub fn view(s: &State, cx: &mut Cx) -> El { page = page.child(l); } } - if keyboard <= 0.0 { - return page; - } - let mut window = col().w_full().h_full().pb(keyboard); + // Always wrapped, the keyboard down or not: the tree keeps its shape, so the ids of the + // elements under it (the focused composer's among them) stay put as the keyboard moves. + // Wrapping only while the keyboard is up renamed every id, which dropped the composer's + // focus, which lowered the keyboard, which renamed them back: a loop. + // A paste with no field focused goes to the window's root, which hands it to the composer. + let mut window = col() + .w_full() + .h_full() + .pb(keyboard.max(0.0)) + .on_paste_content(|_: &mut State, content, _| send_paste(content)); if let Some(bg) = bg { window = window.fill(bg) } diff --git a/apps/native/host/src/tests.rs b/apps/native/host/src/tests.rs index cb54887c7..fbdc564ce 100644 --- a/apps/native/host/src/tests.rs +++ b/apps/native/host/src/tests.rs @@ -3100,6 +3100,30 @@ fn the_page_ends_above_the_on_screen_keyboard() { assert_eq!(rect(&rt, "r/1"), [0.0, 760.0, 400.0, 40.0]); } +/// Focusing the composer raises the keyboard, and the keyboard must leave the composer focused: +/// a page whose element ids change under it would drop focus, which lowers the keyboard, which +/// changes them back, over and over. +#[test] +fn the_on_screen_keyboard_leaves_the_composer_focused() { + let mut rt = composer_page(); + rt.update(|_, tx| tx.focus("r/0/field".to_string())); + rt.frame(0.1); + let focused = rt.focused_path().map(|p| p.to_string()); + assert!(focused.is_some(), "the composer took focus"); + for covered in [300.0, 0.0, 300.0] { + assert!(rt.state.set_keyboard(covered)); + rt.invalidate(); + rt.frame(0.1); + rt.settle_all(); + rt.frame(0.1); + assert_eq!( + rt.focused_path().map(|p| p.to_string()), + focused, + "keyboard covering {covered}" + ); + } +} + #[test] fn a_lone_inline_span_in_a_block_wraps_its_text_to_the_block() { // A message's markdown: `p > span > span > span > text` in the content column of a row.