Iris's phone: typing newlines into the composer with the keyboard up dropped the caret flush against the bar's bottom edge, eating the 12dp padding, and closing the keyboard fixed it. `Scroll::draw` offers its child last frame's content length on purpose, so an ordinary scroll tick is an O(1) move rather than a redraw. The comment claimed the lag self-corrects on the next frame; nothing asked for that frame. A keystroke dirties the field, that frame draws it in a box one line short of its text, and the tree is clean afterwards -- so the stale placement is the last one drawn. The composer's text is centred in its box, so one line short hung half a line past each end and put the caret's line box a whole padding low. Closing the keyboard rewrote the bar's inset, dirtied it, and forced the missing redraw. `Scroll::draw` now calls `Painter::draw_again` when what it measured differs from what it offered, and a frame that leaves anything dirty asks for another frame on both backends -- `draw_again` sets its mark during the update, after the input path's own check has run, so nothing asked before this (which applied to `List::clamp_to_content` too). Verified at layer 1 (the new test fails on the old code with the caret exactly on the bar's edge) and on the emulator: the caret's bottom moved from 1535 -- the bar's own bottom edge -- to 1509, 26px inside a 31px padding, the remainder being parley's line box overhanging its line height. `phone.rs` grew `--typed TEXT`, which enters text over frames rather than preloading it; only that reproduces this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
152 lines
6.0 KiB
Rust
152 lines
6.0 KiB
Rust
//! Layer 2 of docs/RUST.md's "Three test layers": the fixture-backed
|
|
//! transcript screen in a phone-shaped window, for looking at.
|
|
//!
|
|
//! iris/run-headless.sh phone --phone --shot /tmp/phone.png -- -p transcript-fixture
|
|
//!
|
|
//! `--phone` sets the headless sway output to the phone's own 1080x2424
|
|
//! and exports `IRIS_SCALE=2.55`, so this draws at the density Iris's
|
|
//! phone reports (`transcript_fixture::PHONE_SCALE`) rather than the
|
|
//! desktop's 1.0 -- same screen, same fixture and the same folding as
|
|
//! the Android bench and the headless tests, so what differs between a
|
|
//! screenshot here and one from the phone is the renderer, never the
|
|
//! data.
|
|
//!
|
|
//! `--message TEXT` (through `RUN_HEADLESS_ARGS`) starts with that text
|
|
//! already in the composer, `\n` for a newline -- the composer's grown
|
|
//! and overflowing states are otherwise unreachable here, since this
|
|
//! window has no keyboard to type into (UI_RULES.md's "check the states
|
|
//! you can't see by default"). `--typed TEXT` *enters* the same text
|
|
//! instead, one character per 100ms: laying the composer out from
|
|
//! scratch and growing one already on screen are different cases, and
|
|
//! only the second reproduced the caret landing in the bar's padding
|
|
//! (IRIS.md, 2026-09-08).
|
|
//!
|
|
//! No server: `transcript-fixture` embeds the transcript. Colour,
|
|
//! spacing, type and anything a person has to *see* is answered here;
|
|
//! anything with an assertion behind it belongs in `tests/
|
|
//! phone_screen.rs` one layer down.
|
|
|
|
use iris::prelude::*;
|
|
use winit::{dpi::PhysicalSize, window::WindowAttributes};
|
|
|
|
/// The `--ime PX` argument: the bottom inset a keyboard would report,
|
|
/// applied after the first frame the way Android's `on_insets_changed`
|
|
/// does. The composer's keyboard-open layout is otherwise unreachable
|
|
/// here, and it is where its mask went wrong before (see
|
|
/// `ActiveData::own_mask`).
|
|
fn ime_argv() -> Option<f32> {
|
|
let mut args = std::env::args().skip(1);
|
|
while let Some(arg) = args.next() {
|
|
if arg == "--ime" {
|
|
return args.next()?.parse().ok();
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// The `--message TEXT` argument, with `\n` taken as a newline so a
|
|
/// multi-line message survives one shell word.
|
|
fn message_argv() -> Option<String> {
|
|
let mut args = std::env::args().skip(1);
|
|
while let Some(arg) = args.next() {
|
|
if arg == "--message" {
|
|
return Some(args.next()?.replace("\\n", "\n"));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// The `--typed TEXT` argument: the same text as `--message`, but
|
|
/// *entered* rather than preloaded -- one insertion per 100ms, into a
|
|
/// focused field, the way a person types. The two are different cases
|
|
/// for layout: `--message` is laid out from scratch on the first frame,
|
|
/// while this grows an already-drawn composer, which is the path
|
|
/// Iris's 2026-09-08 phone report is about.
|
|
fn typed_argv() -> Option<String> {
|
|
let mut args = std::env::args().skip(1);
|
|
while let Some(arg) = args.next() {
|
|
if arg == "--typed" {
|
|
return Some(args.next()?.replace("\\n", "\n"));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn main() {
|
|
DefaultApp::<Client>::run();
|
|
}
|
|
|
|
#[derive(DefaultUiState)]
|
|
pub struct Client {
|
|
ui_state: DefaultUiState,
|
|
#[allow(dead_code)]
|
|
screen: Option<transcript_ui::TranscriptScreen>,
|
|
}
|
|
|
|
impl DefaultAppState for Client {
|
|
fn window_attributes() -> WindowAttributes {
|
|
WindowAttributes::default()
|
|
.with_title("iris transcript (bench fixture)")
|
|
.with_inner_size(PhysicalSize::new(
|
|
transcript_fixture::PHONE_WIDTH,
|
|
transcript_fixture::PHONE_HEIGHT,
|
|
))
|
|
}
|
|
|
|
fn new(
|
|
mut ui_state: DefaultUiState,
|
|
rsc: &mut DefaultRsc<Self>,
|
|
_: Proxy<Self::Event>,
|
|
) -> Self {
|
|
let screen = match transcript_fixture::open(rsc, &mut ui_state) {
|
|
Ok(opened) => {
|
|
if let Some(message) = message_argv() {
|
|
opened.screen.composer.field.edit(rsc).set(&message);
|
|
}
|
|
if let Some(text) = typed_argv() {
|
|
let field = opened.screen.composer.field;
|
|
let redraw = rsc.tasks.redraw_handle();
|
|
rsc.spawn_task(async move |mut ctx| {
|
|
for ch in text.chars() {
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
ctx.update(move |state: &mut Client, rsc| {
|
|
state.set_focus(Some(field));
|
|
let end = rsc[field].text().len();
|
|
let mut edit = field.edit(rsc);
|
|
if edit.text.caret().is_none() {
|
|
edit.set_cursor_byte(end);
|
|
}
|
|
edit.insert(&ch.to_string());
|
|
});
|
|
redraw.request_redraw();
|
|
}
|
|
});
|
|
}
|
|
if let Some(inset) = ime_argv() {
|
|
opened.screen.composer.set_bottom_inset(rsc, inset);
|
|
}
|
|
// A fling coasts only while something asks for the next
|
|
// frame; on the desktop that is the window's own redraw
|
|
// request (`List::fling`'s doc).
|
|
let handle = rsc.tasks.redraw_handle();
|
|
(opened.screen.list)(rsc).set_redraw_handle(handle);
|
|
Some(opened.screen)
|
|
}
|
|
// On screen rather than a panic: this window exists to be
|
|
// looked at, and "the fixture stopped folding" is something
|
|
// to read, not a process that vanished (UI_RULES.md).
|
|
Err(message) => {
|
|
let text = wtext(format!("Couldn't fold the bench fixture: {message}"))
|
|
.color(Color::WHITE)
|
|
.wrap(true)
|
|
.pad(dp(16))
|
|
.add_strong(rsc)
|
|
.any();
|
|
ui_state.set_root(text);
|
|
None
|
|
}
|
|
};
|
|
Self { ui_state, screen }
|
|
}
|
|
}
|