diff --git a/app-rust/examples/phone.rs b/app-rust/examples/phone.rs index 9985c9e..fd855df 100644 --- a/app-rust/examples/phone.rs +++ b/app-rust/examples/phone.rs @@ -32,17 +32,17 @@ fn typed_argv() -> Option { } fn main() { - DefaultApp::::run(); + DesktopApp::::run(); } -#[derive(DefaultUiState)] +#[derive(DesktopUiState)] pub struct Client { - ui_state: DefaultUiState, + ui_state: DesktopUiState, #[allow(dead_code)] screen: Option, } -impl DefaultAppState for Client { +impl DesktopAppState for Client { fn window_attributes() -> WindowAttributes { WindowAttributes::default() .with_title("iris transcript (bench fixture)") @@ -53,8 +53,8 @@ impl DefaultAppState for Client { } fn new( - mut ui_state: DefaultUiState, - rsc: &mut DefaultRsc, + mut ui_state: DesktopUiState, + rsc: &mut DesktopRsc, _: Proxy, ) -> Self { let screen = match ai_app::ui::fixture::open(rsc, &mut ui_state) { diff --git a/app-rust/examples/transcript.rs b/app-rust/examples/transcript.rs index a33bf8a..17672df 100644 --- a/app-rust/examples/transcript.rs +++ b/app-rust/examples/transcript.rs @@ -3,12 +3,12 @@ use ai_app::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRo use iris::prelude::*; fn main() { - DefaultApp::::run(); + DesktopApp::::run(); } -#[derive(DefaultUiState)] +#[derive(DesktopUiState)] pub struct Client { - ui_state: DefaultUiState, + ui_state: DesktopUiState, #[allow(dead_code)] screen: ai_app::ui::TranscriptScreen, } @@ -157,13 +157,13 @@ fn fold_event(items: Vec, seq: u64) -> Vec { > A quoted line, to show the bar and the indent. "; -impl DefaultAppState for Client { +impl DesktopAppState for Client { fn new( - mut ui_state: DefaultUiState, - rsc: &mut DefaultRsc, + mut ui_state: DesktopUiState, + rsc: &mut DesktopRsc, _: Proxy, ) -> Self { - let screen = ai_app::ui::build(rsc, &mut ui_state, synthetic_rows()); + let mut screen = ai_app::ui::build(rsc, &mut ui_state, synthetic_rows()); screen.push_row( rsc, &FoldedRow::Single(TranscriptItem::CommandRow { diff --git a/app-rust/src/android/app_log.rs b/app-rust/src/android/app_log.rs index 10a82ad..6442583 100644 --- a/app-rust/src/android/app_log.rs +++ b/app-rust/src/android/app_log.rs @@ -3,13 +3,13 @@ //! `android_logger` as the logger to forward to and nothing else. use crate::client::log_ring::{self, LogRing}; +use std::{ + fs, panic, + path::{Path, PathBuf}, + sync::OnceLock, +}; -/// Installs the ring in front of `android_logger`, so `logcat` still sees -/// exactly what it saw before and the ring sees it too. -/// -/// Called once, from `JNI_OnLoad`. A second call is refused by `log` -/// itself; the message says which caller, since two initialisation paths -/// is a programmer error rather than something to recover from. +/// Installs the in-process ring in front of Android's logger. pub fn install(max_level: log::LevelFilter) { let inner = android_logger::AndroidLogger::new( android_logger::Config::default() @@ -23,10 +23,6 @@ pub fn install(max_level: log::LevelFilter) { ) .is_err() { - // Not a panic: a logger already installed means logging works, - // just without the ring, and taking the app down over a - // diagnostic would be worse than the diagnostic being missing. - // The line goes through whatever logger did win. log::warn!("iris app log: a logger was already installed, so there is no ring"); } install_panic_hook(); @@ -40,9 +36,6 @@ pub fn ring() -> &'static LogRing { pub fn diagnostics_line() -> String { let where_to_read = match crate::android::devlog::authority() { Some(authority) => format!("devlog provider: content://{authority}"), - // Not "off": Android creates a provider lazily, so this is what - // "nobody has asked for it yet" looks like, and it is a different - // thing from a build that does not have one. None => "devlog provider: declared, not created yet".to_string(), }; format!("{}\n{where_to_read}", ring().summary()) @@ -53,26 +46,17 @@ pub fn diagnostics_line() -> String { /// start. const CRASH_FILE: &str = "last-panic.txt"; -/// How many of the dying run's own log lines the panic hook saves with -/// the panic, and [`set_crash_dir`] replays. -/// -/// The panic's message and location say *what* broke; these say what the -/// app was doing on the way there, which is the half that is otherwise -/// unrecoverable -- the ring is memory only, so an abort takes every line -/// before the panic with it. Bounded rather than the whole ring because -/// this is written by a hook on a process that is about to die, and -/// because the replay pushes each line into the new run's ring, where an -/// unbounded paste would evict the run that is actually being watched. +/// Enough preceding log lines to explain a crash without evicting the next run. const CRASH_CONTEXT_LINES: usize = 80; const PREVIOUS_RUN_TARGET: &str = "previous_run"; -static CRASH_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); +static CRASH_PATH: OnceLock = OnceLock::new(); /// Copies aborting panics into the device-readable log ring. fn install_panic_hook() { - let previous = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { + let previous = panic::take_hook(); + panic::set_hook(Box::new(move |info| { let where_at = match info.location() { Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()), None => "an unknown location".to_string(), @@ -81,41 +65,23 @@ fn install_panic_hook() { let line = format!("iris panic at {where_at}: {message}"); log::error!("{line}"); if let Some(path) = CRASH_PATH.get() { - // The panic line first, then what the app was doing before - // it: one file, split again on that first newline by - // `set_crash_dir`. let context = ring() .try_tail_text(CRASH_CONTEXT_LINES) .unwrap_or_else(|| { "(the log ring was locked as this run died; no context)".to_string() }); - // Best effort by design: a panic is already the failure, and - // failing to record it must not become a second one. - let _ = std::fs::write(path, format!("{line}\n{context}")); + let _ = fs::write(path, format!("{line}\n{context}")); } previous(info); })); } -/// Tells the panic hook where to leave its report, and replays the report -/// a previous run left there into the ring before deleting it. -/// -/// Called from **both** `MainActivity.nativeSetFilesDir` and -/// `DevLogProvider.nativeReady` -- whichever of the two runs first in -/// this process, since after a crash Dev Updater's query starts the -/// process for the provider alone and no activity ever runs. Safe to call -/// twice: the file is gone after the first, so the second finds nothing -/// and says nothing. The panic itself is replayed at `error` level and -/// says it is from the previous run, so a crash loop shows the reason it -/// is looping in the Runtime tab of the run that is still up. -pub fn set_crash_dir(dir: &std::path::Path) { +/// Configures crash persistence and replays a report left by the previous run. +pub fn set_crash_dir(dir: &Path) { let path = dir.join(CRASH_FILE); - if let Ok(previous) = std::fs::read_to_string(&path) { - // Delete before replaying rather than after: a replay that itself - // panicked would otherwise leave the file to be replayed again on - // every start, and a crash loop nothing can get out of is worse - // than one report lost. - let _ = std::fs::remove_file(&path); + if let Ok(previous) = fs::read_to_string(&path) { + // Delete first so a panic during replay cannot create a replay loop. + let _ = fs::remove_file(&path); replay_crash(&previous); } let _ = CRASH_PATH.set(path); diff --git a/app-rust/src/android/bench_client.rs b/app-rust/src/android/bench_client.rs index 6d9f467..15ca029 100644 --- a/app-rust/src/android/bench_client.rs +++ b/app-rust/src/android/bench_client.rs @@ -1,12 +1,19 @@ use crate::android::bench_jni::PlatformHandle; use crate::client::transcript_fold::{TranscriptItem, fold_event}; +use crate::ui::{self, TranscriptScreen}; use android_view::jni::{JavaVM, objects::GlobalRef}; use event_model::SeqEvent; use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState}; use iris::prelude::*; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; +use std::{ + fs, mem, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + time::{Duration, Instant}, +}; const STREAM_EVENTS_PER_SEC: u64 = 20; const STREAM_SECONDS: u64 = 20; @@ -40,7 +47,7 @@ pub struct BenchClient { content: WeakWidget, report_display: WeakWidget, top_bar: WeakWidget, - screen: Option, + screen: Option, items: Vec, stream_tail: Vec, platform: Option>, @@ -80,7 +87,7 @@ fn process_cpu_ms() -> Option { // SAFETY: `rusage` is a plain-old-data struct `getrusage` fully // initialises on success; on failure it is never read. unsafe { - let mut usage: libc::rusage = std::mem::zeroed(); + let mut usage: libc::rusage = mem::zeroed(); if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 { return None; } @@ -91,7 +98,7 @@ fn process_cpu_ms() -> Option { } fn peak_rss_kb() -> Option { - std::fs::read_to_string("/proc/self/status") + fs::read_to_string("/proc/self/status") .ok()? .lines() .find_map(|line| line.strip_prefix("VmHWM:")) @@ -174,7 +181,7 @@ impl AndroidAppState for BenchClient { last_top_pad: 0.0, }; - match crate::ui::fixture::build_screen(rsc) { + match ui::fixture::build_screen(rsc) { Ok((opened, tree)) => { client.items = opened.items; client.stream_tail = opened.stream_tail; @@ -392,7 +399,7 @@ impl BenchClient { } fn rebuild_transcript(&mut self, rsc: &mut Rsc) { - let (screen, tree) = crate::ui::build_tree(rsc, crate::ui::fixture::rows(&self.items)); + let (screen, tree) = ui::build_tree(rsc, ui::fixture::rows(&self.items)); (self.content)(rsc).set(tree); self.screen = Some(screen); } @@ -653,7 +660,7 @@ where T: Send + 'static, F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static, { - let (tx, rx) = std::sync::mpsc::channel(); + let (tx, rx) = mpsc::channel(); ctx.update(move |state: &mut BenchClient, rsc| { let _ = tx.send(f(state, rsc)); }); @@ -769,7 +776,7 @@ async fn run_stream_phase( ctx.update(move |state: &mut BenchClient, rsc| { let old_items = state.items.clone(); state.items = fold_event(&state.items, &event); - match &state.screen { + match state.screen.as_mut() { Some(screen) => screen.apply(rsc, &old_items, &state.items), None => state.rebuild_transcript(rsc), } diff --git a/app-rust/src/android/devlog.rs b/app-rust/src/android/devlog.rs index cc54620..6e71631 100644 --- a/app-rust/src/android/devlog.rs +++ b/app-rust/src/android/devlog.rs @@ -4,7 +4,7 @@ use android_view::jni::JNIEnv; use android_view::jni::objects::{JClass, JObject, JString}; use android_view::jni::sys::{jlong, jobjectArray}; -use std::sync::OnceLock; +use std::{path::Path, ptr, sync::OnceLock}; /// Gated with its one reader: the tabs demo links no `client-core` and so /// has no ring to lay out, and an ungated constant is a warning in that @@ -43,7 +43,7 @@ pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady( ) { #[cfg(feature = "transcript-screen")] if let Some(dir) = string_arg(&mut env, &files_dir) { - crate::android::app_log::set_crash_dir(std::path::Path::new(&dir)); + crate::android::app_log::set_crash_dir(Path::new(&dir)); } #[cfg(not(feature = "transcript-screen"))] let _ = &files_dir; @@ -138,7 +138,7 @@ fn line_fields(_since: u64) -> Vec { /// down to report that its diagnostic is unavailable would be worse than /// the diagnostic being unavailable. fn string_array(env: &mut JNIEnv, fields: &[String]) -> jobjectArray { - let null = std::ptr::null_mut(); + let null = ptr::null_mut(); let Ok(class) = env.find_class("java/lang/String") else { return null; }; diff --git a/app-rust/src/android/mod.rs b/app-rust/src/android/mod.rs index 42d3059..af29da9 100644 --- a/app-rust/src/android/mod.rs +++ b/app-rust/src/android/mod.rs @@ -10,7 +10,10 @@ use android_view::{ #[cfg(not(feature = "transcript-screen"))] use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState}; use log::LevelFilter; -use std::ffi::c_void; +use std::{ + ffi::c_void, + path::{Path, PathBuf}, +}; /// The app's own log ring and its upload -- only where `client-core` is /// linked, which is every build that has a server to send to. The plain @@ -50,7 +53,7 @@ impl HasAndroidUiState for Client { impl AndroidAppState for Client { fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc) -> Self { // `widgets.info` is the winit example's frame-debug readout, kept - // current from `DefaultAppState::window_event` -- android-view has + // current from `DesktopAppState::window_event` -- android-view has // no per-frame hook to drive the equivalent from here yet, so it // is left at its built "" text rather than wired to nothing. let _ = tabs_ui::build(rsc, &mut ui_state); @@ -123,8 +126,8 @@ pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir }; #[cfg(feature = "transcript-screen")] { - app_log::set_crash_dir(std::path::Path::new(&dir)); - enrollment::set_files_dir(std::path::PathBuf::from(&dir)); + app_log::set_crash_dir(Path::new(&dir)); + enrollment::set_files_dir(PathBuf::from(&dir)); } log::debug!("iris app: files directory is {dir}"); } diff --git a/app-rust/src/android/transcript_client.rs b/app-rust/src/android/transcript_client.rs index f08ed15..b866545 100644 --- a/app-rust/src/android/transcript_client.rs +++ b/app-rust/src/android/transcript_client.rs @@ -1,11 +1,19 @@ use crate::client::api::{ApiClient, UreqTransport}; use crate::client::event_stream::{StreamItem, follow_session_events}; -use crate::client::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs}; +use crate::client::transcript_fold::{ + TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq, +}; +use crate::ui::{self, TranscriptScreen}; use event_model::SeqEvent; use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState}; use iris::prelude::*; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::{ + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + thread, +}; pub struct TranscriptClient { ui_state: AndroidUiState, @@ -15,7 +23,7 @@ pub struct TranscriptClient { /// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched /// by rebuilding the session list beside it. content: WeakWidget, - screen: Option, + screen: Option, items: Vec, session_id: Option, generation: Arc, @@ -189,7 +197,7 @@ impl TranscriptClient { .as_ref() .ok() .and_then(|values| values.last()) - .and_then(crate::client::transcript_fold::raw_seq) + .and_then(raw_seq) .unwrap_or(0); let result = page.and_then(|values| fold_page(&values)); @@ -251,7 +259,7 @@ impl TranscriptClient { .filter(|t| !t.is_empty()); let rows = group_tool_runs(&self.items); - let (screen, tree) = crate::ui::build_tree(rsc, rows); + let (screen, tree) = ui::build_tree(rsc, rows); if let Some(text) = in_progress { screen.composer.field.edit(rsc).set(&text); @@ -274,14 +282,14 @@ impl TranscriptClient { fn apply_event(&mut self, rsc: &mut AndroidRsc, event: &SeqEvent) { let old_items = self.items.clone(); self.items = fold_event(&self.items, event); - match &self.screen { + match self.screen.as_mut() { Some(screen) => screen.apply(rsc, &old_items, &self.items), None => self.rebuild_transcript(rsc), } } fn send_message(&mut self, session_id: String, text: String) { - std::thread::spawn(move || { + thread::spawn(move || { if let Ok(transport) = build_transport() { let api = ApiClient::new(transport); let _ = api.send_message(&session_id, &text, &[]); diff --git a/app-rust/src/client/transcript_cache.rs b/app-rust/src/client/transcript_cache.rs index ed0ee06..d876301 100644 --- a/app-rust/src/client/transcript_cache.rs +++ b/app-rust/src/client/transcript_cache.rs @@ -1,8 +1,9 @@ -use std::collections::VecDeque; +use std::collections::{HashSet, VecDeque}; use std::fs; use std::io; use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; pub const CACHE_BUDGET_BYTES: u64 = 256_000_000; @@ -14,7 +15,7 @@ pub struct CachedTail { pub struct TranscriptCache { root: PathBuf, - warn: std::sync::Arc, + warn: Arc, } impl TranscriptCache { @@ -28,7 +29,7 @@ impl TranscriptCache { ) -> Self { Self { root: root.into(), - warn: std::sync::Arc::new(warn), + warn: Arc::new(warn), } } @@ -40,7 +41,7 @@ impl TranscriptCache { /// successful list fetch. The path out for a session deleted on /// another device: nothing here would otherwise hear about it, and /// unlike a draft's few bytes what it leaves behind is megabytes. - pub fn retain_only(&self, ids: &std::collections::HashSet) { + pub fn retain_only(&self, ids: &HashSet) { guard_io((), self.warn.as_ref(), || { for dir in session_dirs(&self.root)? { if let Some(name) = dir.file_name().and_then(|n| n.to_str()) @@ -61,12 +62,12 @@ impl TranscriptCache { /// for. pub fn evict_to_budget(&self, keep: &str, budget: u64) { guard_io((), self.warn.as_ref(), || { - let mut dirs: Vec<(PathBuf, std::time::SystemTime)> = session_dirs(&self.root)? + let mut dirs: Vec<(PathBuf, SystemTime)> = session_dirs(&self.root)? .into_iter() .map(|d| { let modified = fs::metadata(&d) .and_then(|m| m.modified()) - .unwrap_or(std::time::UNIX_EPOCH); + .unwrap_or(UNIX_EPOCH); (d, modified) }) .collect(); @@ -132,7 +133,7 @@ fn dir_size(path: &Path) -> u64 { /// closed by paging back through it, but nothing is served across one. pub struct SessionCache { dir: PathBuf, - warn: std::sync::Arc, + warn: Arc, state: Mutex, } @@ -145,7 +146,7 @@ struct WriterState { } impl SessionCache { - fn new(dir: PathBuf, warn: std::sync::Arc) -> Self { + fn new(dir: PathBuf, warn: Arc) -> Self { Self { dir, warn, @@ -333,7 +334,7 @@ impl SessionCache { pub fn touch(&self) { self.guard((), |this, _state| { if this.dir.is_dir() { - let now = std::time::SystemTime::now(); + let now = SystemTime::now(); filetime_set_modified(&this.dir, now)?; } Ok(()) @@ -708,7 +709,7 @@ fn guard_io( } } -fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Result<()> { +fn filetime_set_modified(path: &Path, _when: SystemTime) -> io::Result<()> { use std::io::Write; // Rewriting a marker file's contents (rather than the directory itself, // which `std` has no portable "touch" for) bumps the directory's own @@ -728,18 +729,17 @@ fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Resul #[cfg(test)] mod tests { use super::*; - use std::collections::HashSet; fn cache(temp: &Path) -> TranscriptCache { - let said: std::sync::Arc>> = Default::default(); + let said: Arc>> = Default::default(); let said2 = said.clone(); TranscriptCache::with_warn(temp.join("v1/host_8443"), move |msg| { said2.lock().unwrap().push(msg.to_string()); }) } - fn cache_with_log(temp: &Path) -> (TranscriptCache, std::sync::Arc>>) { - let said: std::sync::Arc>> = Default::default(); + fn cache_with_log(temp: &Path) -> (TranscriptCache, Arc>>) { + let said: Arc>> = Default::default(); let said2 = said.clone(); ( TranscriptCache::with_warn(temp.join("v1/host_8443"), move |msg| { diff --git a/app-rust/src/client/transcript_fold.rs b/app-rust/src/client/transcript_fold.rs index dce5beb..36139b7 100644 --- a/app-rust/src/client/transcript_fold.rs +++ b/app-rust/src/client/transcript_fold.rs @@ -1,4 +1,5 @@ use event_model::{Event, QuestionOption, SeqEvent, SessionStatus}; +use std::collections::{HashMap, HashSet}; #[derive(Debug, Clone, PartialEq)] pub struct QuestionCard { @@ -260,11 +261,11 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec Vec { let (older, newer) = heal_split_message(earlier, later); - let started_earlier: std::collections::HashSet<&str> = older + let started_earlier: HashSet<&str> = older .iter() .filter_map(TranscriptItem::as_tool_run) .collect(); - let ended_later: std::collections::HashMap = newer + let ended_later: HashMap = newer .iter() .filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone()))) .filter(|(id, _)| started_earlier.contains(id.as_str())) diff --git a/app-rust/src/desktop/app.rs b/app-rust/src/desktop/app.rs index dff267e..018df05 100644 --- a/app-rust/src/desktop/app.rs +++ b/app-rust/src/desktop/app.rs @@ -5,8 +5,14 @@ use crate::client::transcript_fold::{ }; use event_model::SeqEvent; use iris::prelude::*; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::{ + process, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + thread, +}; const LIST_WIDTH: f32 = 260.0; @@ -31,12 +37,12 @@ enum AppEvent { } pub fn run() { - DefaultApp::::run(); + DesktopApp::::run(); } -#[derive(DefaultUiState)] +#[derive(DesktopUiState)] struct Client { - ui_state: DefaultUiState, + ui_state: DesktopUiState, api: Arc>, stream_transport: Arc, proxy: Proxy, @@ -49,17 +55,17 @@ struct Client { generation: Arc, } -impl DefaultAppState for Client { +impl DesktopAppState for Client { type Event = AppEvent; fn new( - mut ui_state: DefaultUiState, - rsc: &mut DefaultRsc, + mut ui_state: DesktopUiState, + rsc: &mut DesktopRsc, proxy: Proxy, ) -> Self { let (server, ca_pem) = super::startup::load_startup_config().unwrap_or_else(|e| { eprintln!("desktop-app: {e}"); - std::process::exit(2); + process::exit(2); }); let build_transport = || UreqTransport::new(server.base_url(), server.token.clone(), &ca_pem); @@ -70,7 +76,7 @@ impl DefaultAppState for Client { "desktop-app: couldn't set up TLS to {}: {e}", server.base_url() ); - std::process::exit(1); + process::exit(1); }); let api = Arc::new(ApiClient::new(rest_transport)); let stream_transport = Arc::new(stream_transport); @@ -101,7 +107,7 @@ impl DefaultAppState for Client { client } - fn event(&mut self, event: AppEvent, rsc: &mut DefaultRsc) { + fn event(&mut self, event: AppEvent, rsc: &mut DesktopRsc) { match event { AppEvent::Sessions(Ok(sessions)) => { self.sessions = sessions; @@ -141,7 +147,7 @@ impl DefaultAppState for Client { if self.current(&session_id, generation) { let old_items = self.items.clone(); self.items = fold_event(&self.items, &event); - match &self.screen { + match self.screen.as_mut() { Some(screen) => screen.apply(rsc, &old_items, &self.items), None => self.rebuild_transcript(rsc), } @@ -171,7 +177,7 @@ impl Client { && self.generation.load(Ordering::SeqCst) == generation } - fn show_message(&mut self, rsc: &mut DefaultRsc, message: &str) { + fn show_message(&mut self, rsc: &mut DesktopRsc, message: &str) { let widget = placeholder(rsc, message); (self.transcript_ptr)(rsc).set(widget); } @@ -179,13 +185,13 @@ impl Client { fn spawn_fetch_sessions(&self) { let api = self.api.clone(); let proxy = self.proxy.clone(); - std::thread::spawn(move || { + thread::spawn(move || { let result = api.fetch_sessions().map_err(|e| e.to_string()); let _ = proxy.send_event(AppEvent::Sessions(result)); }); } - fn rebuild_list(&mut self, rsc: &mut DefaultRsc) { + fn rebuild_list(&mut self, rsc: &mut DesktopRsc) { let list = Span::empty(Dir::DOWN).gap(2).add(rsc); for session in &self.sessions { let selected = self.selected.as_deref() == Some(session.id.as_str()); @@ -199,7 +205,7 @@ impl Client { (self.list_ptr)(rsc).set(tree); } - fn select_session(&mut self, rsc: &mut DefaultRsc, session_id: String) { + fn select_session(&mut self, rsc: &mut DesktopRsc, session_id: String) { let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; self.selected = Some(session_id.clone()); self.items.clear(); @@ -211,7 +217,7 @@ impl Client { let stream_transport = self.stream_transport.clone(); let proxy = self.proxy.clone(); let live_generation = self.generation.clone(); - std::thread::spawn(move || { + thread::spawn(move || { let page: Result, String> = api .fetch_transcript_page(&session_id, None, 200, true) .map_err(|e| e.to_string()); @@ -257,14 +263,14 @@ impl Client { fn send_message(&mut self, session_id: String, text: String) { let api = self.api.clone(); let proxy = self.proxy.clone(); - std::thread::spawn(move || { + thread::spawn(move || { if let Err(e) = api.send_message(&session_id, &text, &[]) { let _ = proxy.send_event(AppEvent::SendFailed(e.to_string())); } }); } - fn rebuild_transcript(&mut self, rsc: &mut DefaultRsc) { + fn rebuild_transcript(&mut self, rsc: &mut DesktopRsc) { let in_progress = self .screen .as_ref() @@ -294,7 +300,7 @@ impl Client { } fn session_row( - rsc: &mut DefaultRsc, + rsc: &mut DesktopRsc, session: &SessionSummary, selected: bool, ) -> StrongWidget { @@ -313,7 +319,7 @@ fn session_row( .background(rect(bg)) .on( CursorSense::click(), - move |ctx, rsc: &mut DefaultRsc| { + move |ctx, rsc: &mut DesktopRsc| { ctx.state.select_session(rsc, id.clone()); }, ) @@ -321,7 +327,7 @@ fn session_row( .any() } -fn placeholder(rsc: &mut DefaultRsc, message: &str) -> StrongWidget { +fn placeholder(rsc: &mut DesktopRsc, message: &str) -> StrongWidget { wtext(message.to_string()) .color(PaintId::WHITE) .wrap(true) diff --git a/app-rust/src/desktop/startup.rs b/app-rust/src/desktop/startup.rs index 4aae33c..e679f21 100644 --- a/app-rust/src/desktop/startup.rs +++ b/app-rust/src/desktop/startup.rs @@ -4,25 +4,22 @@ //! callers are in the library; the binary is only `fn main`. use crate::client::config::EnrolledServer; +use std::{env, fs, path::PathBuf}; use super::config; struct Args { - ca_path: Option, + ca_path: Option, link: Option, } fn parse_args() -> Result { let mut ca_path = None; let mut link = None; - let mut args = std::env::args().skip(1); + let mut args = env::args().skip(1); while let Some(arg) = args.next() { match arg.as_str() { - "--ca" => { - ca_path = Some(std::path::PathBuf::from( - args.next().ok_or("--ca needs a path")?, - )) - } + "--ca" => ca_path = Some(PathBuf::from(args.next().ok_or("--ca needs a path")?)), "--link" => link = Some(args.next().ok_or("--link needs a value")?), other => return Err(format!("unrecognised argument '{other}'")), } @@ -56,7 +53,7 @@ pub fn load_startup_config() -> Result<(EnrolledServer, Vec), String> { // server at a certificate it did not carry -- and so the flag still // means what it did before the link could carry one. let ca_pem = match (&args.ca_path, &server.ca_pem) { - (Some(path), _) => std::fs::read(path) + (Some(path), _) => fs::read(path) .map_err(|e| format!("couldn't read the CA at {}: {e}", path.display()))?, (None, Some(pem)) => pem.clone().into_bytes(), (None, None) => { diff --git a/app-rust/src/ui/mod.rs b/app-rust/src/ui/mod.rs index 3584a8f..c5c1700 100644 --- a/app-rust/src/ui/mod.rs +++ b/app-rust/src/ui/mod.rs @@ -1,8 +1,5 @@ pub mod composer; -// The checked-in bench fixture opened as a real screen -- 1.9 MB of -// `include_str!`, so it is a feature rather than always present: a build -// meant for a phone must not carry it. `bench` turns it on; so does the -// default, which is what makes `cargo test` here run the harness tests. +// Keep the 1.9 MB fixture out of ordinary APKs. #[cfg(feature = "fixture")] pub mod fixture; pub mod markdown; @@ -11,9 +8,9 @@ pub(crate) mod tap; pub mod theme; pub mod tool; -use crate::client::transcript_fold::TranscriptRow as FoldedRow; +use crate::client::transcript_fold::{TranscriptItem, TranscriptRow as FoldedRow, group_tool_runs}; use iris::prelude::*; -use std::{cell::RefCell, rc::Rc}; +use std::{mem, rc::Rc}; use theme::Theme; pub struct TranscriptScreen { @@ -25,9 +22,9 @@ pub struct TranscriptScreen { /// `.jump_to_end()` directly. pub list: WeakWidget, pub composer: composer::Composer, - rebuilds: std::cell::Cell, - tail: RefCell>, - session_working: std::cell::Cell, + rebuilds: usize, + tail: Option<(RowKey, row::TailRow)>, + session_working: bool, theme: Rc, } @@ -36,7 +33,7 @@ impl TranscriptScreen { /// what a caller's SSE loop or a sent message calls as new events /// arrive. `LazySpan::push_back` is O(1) and keeps the view pinned to the /// newest content when it already was (I3). - pub fn push_row(&self, rsc: &mut Rsc, row: &FoldedRow) + pub fn push_row(&mut self, rsc: &mut Rsc, row: &FoldedRow) where Rsc::State: FocusHost + OpenUrl, { @@ -45,27 +42,27 @@ impl TranscriptScreen { // grows through `RowBlocks::apply_delta`, which appends to what is // already drawn -- so the cap only ever catches a row that arrived // long, which is the one nobody is watching arrive. - let (key, widget, tail) = row::build_row( + let row::BuiltRow { key, widget, tail } = row::build_row( rsc, self.list, row, - self.session_working.get(), + self.session_working, true, self.theme.clone(), ); (self.list)(rsc).push_back(LazyItem::new(key, widget)); - *self.tail.borrow_mut() = tail.map(|t| (key, t)); + self.tail = tail.map(|t| (key, t)); } - pub fn set_session_working(&self, rsc: &mut Rsc, working: bool) + pub fn set_session_working(&mut self, rsc: &mut Rsc, working: bool) where Rsc::State: FocusHost + OpenUrl, { - if self.session_working.replace(working) == working { + if self.session_working == working { return; } - let mut tail = self.tail.borrow_mut(); - if let Some((_, row::TailRow::Tools(tools))) = tail.as_mut() { + self.session_working = working; + if let Some((_, row::TailRow::Tools(tools))) = self.tail.as_mut() { let calls = tools.calls(); tools.apply_calls(rsc, &calls, working); } @@ -73,7 +70,7 @@ impl TranscriptScreen { #[cfg(test)] fn tail_card_count(&self) -> usize { - match self.tail.borrow().as_ref() { + match self.tail.as_ref() { Some((_, row::TailRow::Tools(tools))) => tools.card_count(), _ => 0, } @@ -84,12 +81,11 @@ impl TranscriptScreen { /// displayless machine, and the tests below). Answers whether there /// was such a row to act on, so a caller that expected one can say so /// rather than silently producing the collapsed picture. - pub fn expand_tail_tools(&self, rsc: &mut Rsc, expanded: bool) -> bool + pub fn expand_tail_tools(&mut self, rsc: &mut Rsc, expanded: bool) -> bool where Rsc::State: FocusHost + OpenUrl, { - let tail = self.tail.borrow(); - let Some((_, row::TailRow::Tools(tools))) = tail.as_ref() else { + let Some((_, row::TailRow::Tools(tools))) = self.tail.as_ref() else { return false; }; tools.set_group_expanded(rsc, expanded); @@ -99,12 +95,16 @@ impl TranscriptScreen { /// The `ReplaceLast` fast path: update the tail row in place if this /// really is a change to the same row, and say whether that worked. /// `false` for anything the caller must rebuild instead. - fn apply_tail_delta(&self, rsc: &mut Rsc, key: RowKey, row: &FoldedRow) -> bool + fn apply_tail_delta( + &mut self, + rsc: &mut Rsc, + key: RowKey, + row: &FoldedRow, + ) -> bool where Rsc::State: FocusHost + OpenUrl, { - let mut tail = self.tail.borrow_mut(); - let Some((tail_key, kept)) = tail.as_mut() else { + let Some((tail_key, kept)) = self.tail.as_mut() else { return false; }; if *tail_key != key { @@ -116,19 +116,16 @@ impl TranscriptScreen { // A tool call is drawn as a card, never as markdown, so a // row that kept blocks and now holds one is a different // row -- rebuild it. - if matches!( - item, - crate::client::transcript_fold::TranscriptItem::ToolRun { .. } - ) { + if matches!(item, TranscriptItem::ToolRun { .. }) { return false; } blocks.apply_delta(rsc, sender, &markdown_src) } (row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => { - tools.apply_calls(rsc, calls, self.session_working.get()) + tools.apply_calls(rsc, calls, self.session_working) } (row::TailRow::Tools(tools), FoldedRow::Single(item)) => { - tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working.get()) + tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working) } (row::TailRow::Blocks(_), FoldedRow::Tools(_)) => false, } @@ -143,15 +140,13 @@ impl TranscriptScreen { /// often the fallback actually fires rather than assuming it never /// does. pub fn apply( - &self, + &mut self, rsc: &mut Rsc, - old: &[crate::client::transcript_fold::TranscriptItem], - new: &[crate::client::transcript_fold::TranscriptItem], + old: &[TranscriptItem], + new: &[TranscriptItem], ) where Rsc::State: FocusHost + OpenUrl, { - use crate::client::transcript_fold::group_tool_runs; - let old_rows = group_tool_runs(old); let new_rows = group_tool_runs(new); @@ -172,25 +167,29 @@ impl TranscriptScreen { return; } - let (new_key, widget, kept) = row::build_row( + let row::BuiltRow { + key: new_key, + widget, + tail: kept, + } = row::build_row( rsc, self.list, &new_rows[common], - self.session_working.get(), + self.session_working, false, self.theme.clone(), ); let evicted = (self.list)(rsc).replace_back(LazyItem::new(new_key, widget)); drop(evicted); // frees the old row's widget, same as a pop would - *self.tail.borrow_mut() = kept.map(|t| (new_key, t)); + self.tail = kept.map(|t| (new_key, t)); for row in &new_rows[common + 1..] { self.push_row(rsc, row); } } RowDiff::Rebuild => { - self.rebuilds.set(self.rebuilds.get() + 1); + self.rebuilds += 1; (self.list)(rsc).clear(); - *self.tail.borrow_mut() = None; + self.tail = None; for row in &new_rows { self.push_row(rsc, row); } @@ -198,8 +197,8 @@ impl TranscriptScreen { } } - pub fn take_rebuilds(&self) -> usize { - self.rebuilds.replace(0) + pub fn take_rebuilds(&mut self) -> usize { + mem::take(&mut self.rebuilds) } /// The semantic paint IDs used by this screen. A caller can replace @@ -268,7 +267,11 @@ where // be a reply already streaming when this screen opened, and a // capped row cannot take a delta (`RowBlocks::capped`). let cap = i + 1 < rows.len(); - let (key, widget, kept) = row::build_row(rsc, list, row, false, cap, theme.clone()); + let row::BuiltRow { + key, + widget, + tail: kept, + } = row::build_row(rsc, list, row, false, cap, theme.clone()); list(rsc).push_back(LazyItem::new(key, widget)); tail = kept.map(|t| (key, t)); } @@ -303,11 +306,11 @@ where ( TranscriptScreen { - tail: RefCell::new(tail), - session_working: std::cell::Cell::new(false), + tail, + session_working: false, list, composer, - rebuilds: std::cell::Cell::new(0), + rebuilds: 0, theme, }, tree, @@ -348,7 +351,6 @@ fn diff_rows(old: &[FoldedRow], new: &[FoldedRow]) -> RowDiff { #[cfg(test)] mod diff_tests { use super::*; - use crate::client::transcript_fold::TranscriptItem; fn user(seq: u64, text: &str) -> FoldedRow { FoldedRow::Single(TranscriptItem::UserMsg { @@ -433,7 +435,8 @@ mod diff_tests { #[cfg(test)] mod apply_tests { use super::*; - use crate::client::transcript_fold::TranscriptItem; + use crate::client::text_cap::MESSAGE_LINES; + use std::iter; struct TestFocus { focus: Option>, @@ -523,10 +526,7 @@ mod apply_tests { 1, &reply(paragraphs, "and the last one is still going."), )]; - let (screen, tree) = build_tree( - &mut rsc, - crate::client::transcript_fold::group_tool_runs(&old_items), - ); + let (mut screen, tree) = build_tree(&mut rsc, group_tool_runs(&old_items)); let mut render = UiRenderState::new(); render.resize((1080.0, 20000.0)); render.update(&tree, &mut rsc); @@ -535,7 +535,7 @@ mod apply_tests { screen.apply(&mut rsc, &old_items, &new_items); render.update(&tree, &mut rsc); assert_eq!(screen.take_rebuilds(), 0, "the delta path must be taken"); - let (draws, _, _, shapes) = render.take_counters(); + let RenderCounters { draws, shapes, .. } = render.take_counters(); (draws, shapes) } @@ -585,8 +585,7 @@ mod apply_tests { rsc: &mut TestRsc, items: &[TranscriptItem], ) -> (TranscriptScreen, StrongWidget, UiRenderState) { - let (screen, tree) = - build_tree(rsc, crate::client::transcript_fold::group_tool_runs(items)); + let (mut screen, tree) = build_tree(rsc, group_tool_runs(items)); let mut render = UiRenderState::new(); render.resize((1080.0, 20000.0)); render.update(&tree, rsc); @@ -605,10 +604,7 @@ mod apply_tests { events: EventManager::default(), }; let items = run_of(3, output, true); - let (screen, tree) = build_tree( - &mut rsc, - crate::client::transcript_fold::group_tool_runs(&items), - ); + let (mut screen, tree) = build_tree(&mut rsc, group_tool_runs(&items)); let mut render = UiRenderState::new(); render.resize((1080.0, 20000.0)); render.update(&tree, &mut rsc); @@ -619,13 +615,12 @@ mod apply_tests { "the fixture's only row must be the tool run" ); render.update(&tree, &mut rsc); - let (_, _, _, shapes) = render.take_counters(); - shapes + render.take_counters().shapes } #[test] fn collapsed_cards_shape_only_their_summary_lines() { - let long: String = std::iter::repeat_n("a line of tool output\n", 4_000).collect(); + let long: String = iter::repeat_n("a line of tool output\n", 4_000).collect(); assert!(long.len() > 80_000, "the long case must actually be long"); let short_shapes = shapes_to_open("ok\n"); @@ -659,22 +654,18 @@ mod apply_tests { settled: true, }, ]; - let (_screen, tree) = build_tree( - &mut rsc, - crate::client::transcript_fold::group_tool_runs(&items), - ); + let (_screen, tree) = build_tree(&mut rsc, group_tool_runs(&items)); let mut render = UiRenderState::new(); render.resize((1080.0, 20000.0)); render.update(&tree, &mut rsc); - let (_, _, _, shapes) = render.take_counters(); - shapes + render.take_counters().shapes } #[test] fn a_long_message_is_drawn_only_as_far_as_the_cap() { let paragraphs = |n: usize| "a paragraph of a reply\n\n".repeat(n); - let capped = shapes_for_message(¶graphs(crate::client::text_cap::MESSAGE_LINES * 4)); - let bigger = shapes_for_message(¶graphs(crate::client::text_cap::MESSAGE_LINES * 40)); + let capped = shapes_for_message(¶graphs(MESSAGE_LINES * 4)); + let bigger = shapes_for_message(¶graphs(MESSAGE_LINES * 40)); assert!( capped > 0, "the screen shaped nothing, so this compares zeroes" @@ -695,7 +686,7 @@ mod apply_tests { let mut after = before.clone(); after[0] = call("t0", "the result", true); - let (screen, tree, mut render) = open_run(&mut rsc, &before); + let (mut screen, tree, mut render) = open_run(&mut rsc, &before); screen.apply(&mut rsc, &before, &after); render.update(&tree, &mut rsc); assert_eq!( @@ -703,8 +694,7 @@ mod apply_tests { 0, "a result arriving must not rebuild the whole screen" ); - let (draws, _, _, _) = render.take_counters(); - draws + render.take_counters().draws } #[test] @@ -732,10 +722,7 @@ mod apply_tests { let mut after = before.clone(); after[1] = call("t1", "done", true); - let (screen, tree) = build_tree( - &mut rsc, - crate::client::transcript_fold::group_tool_runs(&before), - ); + let (mut screen, tree) = build_tree(&mut rsc, group_tool_runs(&before)); let mut render = UiRenderState::new(); render.resize((1080.0, 20000.0)); render.update(&tree, &mut rsc); @@ -767,7 +754,7 @@ mod apply_tests { let mut after = before.clone(); after.push(call("t2", "", false)); - let (screen, tree, mut render) = open_run(&mut rsc, &before); + let (mut screen, tree, mut render) = open_run(&mut rsc, &before); assert_eq!(screen.tail_card_count(), 2); screen.apply(&mut rsc, &before, &after); render.update(&tree, &mut rsc); @@ -787,10 +774,7 @@ mod apply_tests { }; let before = vec![user(1, "stable"), call("t0", "", false)]; let after = vec![user(1, "stable"), user(2, "not a tool call at all")]; - let (screen, _tree) = build_tree( - &mut rsc, - crate::client::transcript_fold::group_tool_runs(&before), - ); + let (mut screen, _tree) = build_tree(&mut rsc, group_tool_runs(&before)); screen.apply(&mut rsc, &before, &after); assert_eq!( screen.take_rebuilds(), diff --git a/app-rust/src/ui/row.rs b/app-rust/src/ui/row.rs index 67906a8..4d8aa8f 100644 --- a/app-rust/src/ui/row.rs +++ b/app-rust/src/ui/row.rs @@ -1,32 +1,31 @@ use crate::client::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks}; use crate::client::text_cap::{MESSAGE_BYTES, MESSAGE_LINES, cut, show_all_label}; -use crate::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow}; +use crate::client::transcript_fold::{ + ItemKey, QuestionCard, TranscriptItem, TranscriptRow as FoldedRow, +}; use crate::ui::markdown::{BlockFrame, Link, frame_of, render_block}; use crate::ui::tap::{hold_edge, on_tap}; use crate::ui::theme::Theme; -use crate::ui::tool::ToolRow; +use crate::ui::tool::{ToolRow, build_tool_row}; use iris::prelude::*; -use std::{cell::RefCell, rc::Rc}; +use std::{ + cell::RefCell, + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + rc::Rc, + slice, +}; const BLOCK_GAP_DP: f32 = 8.0; pub const BASE_SIZE: f32 = 16.0; -/// `ItemKey::Seq` already is the `RowKey` (`u64`) this crate's `LazySpan` wants. -/// `ItemKey::RunId` is a string (a tool call's own id), so it is hashed into -/// one -- collisions are not a correctness risk worth guarding against here -/// (a `DefaultHasher` collision across the run ids one session produces is -/// astronomically unlikely, and the consequence of one would only be two -/// tool-call rows sharing a list slot, not data loss), and the high bit is -/// forced on so a hashed key can never collide with a real sequence number -/// (this build never produces 2^63 events). -pub fn row_key(key: &crate::client::transcript_fold::ItemKey) -> RowKey { - use crate::client::transcript_fold::ItemKey; - use std::hash::{Hash, Hasher}; +/// Maps string run IDs above the sequence-number range used by transcripts. +pub fn row_key(key: &ItemKey) -> RowKey { match key { ItemKey::Seq(seq) => *seq, ItemKey::RunId(id) => { - let mut h = std::collections::hash_map::DefaultHasher::new(); + let mut h = DefaultHasher::new(); id.hash(&mut h); h.finish() | (1 << 63) } @@ -94,19 +93,10 @@ fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String { pub struct RowBlocks { blocks: Vec, fields: Vec>, - links: Vec>>>, + links: Vec, column: WeakWidget, sender: Option, - /// Whether this row draws less than the whole message - /// ([`cap_message`]). A delta cannot be appended to a capped row -- - /// the new text would go on *below* the "Show all" that says it is - /// hidden -- so [`RowBlocks::apply_delta`] refuses one and the caller - /// rebuilds instead. - /// - /// Never `true` for the row a reply is actually streaming into: the - /// live tail is built uncapped ([`build_row`]'s `cap`), which is what - /// keeps the refusal from costing anything in practice. This field is - /// the belt to that braces. + /// A capped row must be rebuilt before accepting a delta. capped: bool, theme: Rc, } @@ -126,17 +116,7 @@ fn display_blocks(markdown_src: &str) -> Vec { } } -/// `blocks` cut to what a row draws, with the line count of the **whole** -/// message; `None` when all of it fits. -/// -/// The cut prefers a **block boundary**, because a message is markdown and -/// a whole paragraph is a smaller version of a message in a way that half -/// a paragraph is not. Where one block is over the bound by itself -- the -/// reply that is one enormous fence -- that block is truncated instead of -/// being dropped or drawn whole: dropping it would leave a row saying -/// nothing, and a truncated fence still renders as a fence, since the -/// renderer already knows the block's kind and pulldown-cmark closes an -/// unterminated one at the end of its input. +/// Caps at a block boundary when possible, or within the first oversized block. fn cap_message(blocks: Vec, cap: bool) -> (Vec, Option) { let total = || blocks.iter().map(|b| b.source.lines().count()).sum(); if !cap { @@ -167,30 +147,50 @@ fn cap_message(blocks: Vec, cap: bool) -> (Vec, Option) { (kept, None) } -/// A message's own text, kept so that asking for the whole of a capped row -/// can rebuild it. `Rc` rather than a copy per closure: the source of a -/// long message is the largest string in the row, and the tap handler -/// would otherwise hold a second one for the lifetime of the row. +/// Shared with the "Show all" callback to avoid copying a long message. struct RowSource { sender: Option, markdown: String, } +#[derive(Clone)] +struct LinkTargets(Rc>>); + +impl LinkTargets { + fn new(links: Vec) -> Self { + Self(Rc::new(RefCell::new(links))) + } + + fn replace(&self, links: Vec) { + *self.0.borrow_mut() = links; + } + + fn url_at(&self, byte: usize) -> Option { + self.0 + .borrow() + .iter() + .find(|link| link.range.contains(&byte)) + .map(|link| link.url.clone()) + } +} + +struct BuiltBlock { + field: WeakWidget, + widget: StrongWidget, + links: LinkTargets, +} + const FRAME_PAD_DP: f32 = 10.0; const QUOTE_BAR_DP: f32 = 3.0; const FRAME_RADIUS_DP: f32 = 8.0; -fn build_block( - rsc: &mut Rsc, - block: &Block, - theme: &Theme, -) -> (WeakWidget, StrongWidget, Rc>>) +fn build_block(rsc: &mut Rsc, block: &Block, theme: &Theme) -> BuiltBlock where Rsc::State: FocusHost + OpenUrl, { let frame = frame_of(block.kind, theme); let rendered = render_block(block, BASE_SIZE, theme); - let links = Rc::new(RefCell::new(rendered.links)); + let links = LinkTargets::new(rendered.links); let verbatim = matches!(frame, BlockFrame::Verbatim { .. }); let field = wtext(rendered.text) .spans(rendered.spans) @@ -218,17 +218,10 @@ where selection.drag(id, rsc, input) }) .unwrap_or(SelectionInput::Tapped); - // A *tap*, decided by the same `DragArbiter` the pan and - // the selection are: a gesture that panned the list past - // this link, or held long enough to select, must not also - // follow it (`GestureOutcome::Tapped`'s doc). + // Panning or selecting across a link must not open it. if outcome == SelectionInput::Tapped { let byte = field.selection(rsc).byte_at(pos, size); - let url = tap_links - .borrow() - .iter() - .find(|l| l.range.contains(&byte)) - .map(|l| l.url.clone()); + let url = tap_links.url_at(byte); if let Some(url) = url { log::info!("iris link: opening {url}"); ::open_url(ctx.state, &url); @@ -257,7 +250,11 @@ where .add_strong(rsc) .any(), }; - (field, framed, links) + BuiltBlock { + field, + widget: framed, + links, + } } #[allow(clippy::too_many_arguments)] @@ -305,10 +302,10 @@ where let mut fields = Vec::with_capacity(blocks.len()); let mut links = Vec::with_capacity(blocks.len()); for block in &blocks { - let (field, framed, block_links) = build_block(rsc, block, &theme); - fields.push(field); - links.push(block_links); - column.push(framed); + let built = build_block(rsc, block, &theme); + fields.push(built.field); + links.push(built.links); + column.push(built.widget); } if let Some(lines) = hidden { column.push(show_all( @@ -323,14 +320,7 @@ where } let column = column.add(rsc); - // `.add` (weak), not `.add_strong` -- `header` is about to be embedded - // as a child of the `.span(Dir::DOWN)` below, whose own composition is - // what performs the *one* real strong registration each child gets. - // Calling `.add_strong`/`.upgrade` here too, then feeding a `.weak()` - // copy into that composition, tried to strong-register the same id - // twice and panicked with "was already added" - // (`core/src/widget/like.rs:12`) -- found running this crate's own - // `run-headless.sh` example, the first real render of a row. + // The parent composition performs the header's single strong registration. let header: WeakWidget = match &source.sender { Some(name) => wtext(name.clone()) .size(13.0) @@ -359,9 +349,7 @@ where ) } -/// The `RowBlocks` the rebuild produces is **discarded**, because a capped -/// row is never the row a reply is streaming into (`build_row`'s `cap`) -- -/// so nothing is holding one for it, and there is nothing to keep in step. +/// Rebuilds a capped row uncapped; its incremental state is intentionally discarded. #[allow(clippy::too_many_arguments)] fn show_all( rsc: &mut Rsc, @@ -395,12 +383,7 @@ where } impl RowBlocks { - /// Bring this row up to date with `markdown_src` **without** re-laying - /// out the blocks that did not change, and say whether that was - /// possible. `false` means the caller must rebuild the row the - /// ordinary way: an earlier block was rewritten (markdown allows it -- - /// a trailing `---` turns the paragraph above into a heading), the - /// sender changed, or the message got shorter. + /// Updates only the changed tail blocks, or returns `false` when a rebuild is required. pub fn apply_delta( &mut self, rsc: &mut Rsc, @@ -413,20 +396,12 @@ impl RowBlocks { if self.sender.as_deref() != sender { return false; } - // A capped row draws less than the message it was built from, so - // appending to it would put the new text *below* the "Show all" - // saying the rest is hidden. The caller rebuilds instead, and - // rebuilds uncapped (`TranscriptScreen::apply`), so this refusal - // costs one rebuild per message rather than one per delta. if self.capped { return false; } let new_blocks = display_blocks(markdown_src); let common = common_prefix(&self.blocks, &new_blocks); - // Everything already drawn must either be kept whole (`common == - // len`, a pure append) or be kept except for the last block, which - // is the one a delta lands in. Anything else means an already - // laid-out block is no longer what it was. + // A delta may append or rewrite only the current final block. if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() { return false; } @@ -449,14 +424,14 @@ impl RowBlocks { (Some(field), Some(links)) => { let rendered = render_block(block, BASE_SIZE, &self.theme); field(rsc).set_with_spans(rendered.text, rendered.spans); - *links.borrow_mut() = rendered.links; + links.replace(rendered.links); } _ => { - let (field, framed, links) = build_block(rsc, block, &self.theme); - self.fields.push(field); - self.links.push(links); + let built = build_block(rsc, block, &self.theme); + self.fields.push(built.field); + self.links.push(built.links); if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) { - column.push(framed); + column.push(built.widget); } } } @@ -481,20 +456,19 @@ where build_text_row(rsc, list, key, sender, &markdown_src, cap, theme) } -/// Two mechanisms would have been two answers to the same question ("what -/// can this row do cheaply?"), so the caller holds one of these for its -/// tail row and asks it, rather than holding a `RowBlocks` and a -/// `ToolRow` and choosing between them at each call site. +/// Incremental state retained for whichever kind of row is currently last. pub enum TailRow { Blocks(RowBlocks), Tools(ToolRow), } -/// `cap` draws a long message as [`cap_message`]'s worth of it behind a -/// "Show all"; the caller passes `false` for the **live tail**, the row a -/// reply is streaming into, because a row that grows while it is capped -/// would appear to stop growing (`RowBlocks::capped`). Every other row is -/// capped. +pub struct BuiltRow { + pub key: RowKey, + pub widget: StrongWidget, + pub tail: Option, +} + +/// `cap` limits historical rows; a live tail must remain uncapped. pub fn build_row( rsc: &mut Rsc, list: WeakWidget, @@ -502,33 +476,35 @@ pub fn build_row( working: bool, cap: bool, theme: Rc, -) -> (RowKey, StrongWidget, Option) +) -> BuiltRow where Rsc::State: FocusHost + OpenUrl, { - // A lone tool call is a card too, not a message with markdown in it: - // `group_tool_runs` leaves one call as a `Single` because "Called 1 - // tool" hides a card to say the same thing in more words, and the - // *card* is what both cases draw (`ToolRows.kt`). + // A single tool call still draws as a card, without a redundant group wrapper. let calls = match row { - FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => { - Some(std::slice::from_ref(item)) - } + FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => Some(slice::from_ref(item)), FoldedRow::Tools(calls) => Some(calls.as_slice()), FoldedRow::Single(_) => None, }; if let Some(calls) = calls { let key = row_key(&calls[0].key()); - let (widget, tools) = - crate::ui::tool::build_tool_row(rsc, list, key, calls.to_vec(), working, theme); - return (key, widget, Some(TailRow::Tools(tools))); + let (widget, tools) = build_tool_row(rsc, list, key, calls.to_vec(), working, theme); + return BuiltRow { + key, + widget, + tail: Some(TailRow::Tools(tools)), + }; } let FoldedRow::Single(item) = row else { unreachable!("every Tools row took the branch above"); }; let key = row_key(&item.key()); let (widget, blocks) = build_single(rsc, list, key, item, cap, theme); - (key, widget, Some(TailRow::Blocks(blocks))) + BuiltRow { + key, + widget, + tail: Some(TailRow::Blocks(blocks)), + } } #[cfg(test)] diff --git a/app-rust/src/ui/tool.rs b/app-rust/src/ui/tool.rs index e0535ef..4e236a7 100644 --- a/app-rust/src/ui/tool.rs +++ b/app-rust/src/ui/tool.rs @@ -5,7 +5,11 @@ use crate::ui::markdown::highlight_into; use crate::ui::tap::{hold_edge, on_tap}; use crate::ui::theme::Theme; use iris::prelude::*; -use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc}; +use std::{ + cell::{Cell, RefCell}, + collections::{HashMap, HashSet}, + rc::Rc, +}; const NAME_SIZE: f32 = 14.0; const BODY_SIZE: f32 = 12.0; @@ -591,7 +595,7 @@ impl ToolRow { let changed: Vec = (0..old.len()).filter(|&i| old[i] != calls[i]).collect(); self.shared.working.set(working); *self.shared.calls.borrow_mut() = calls.to_vec(); - let ids: std::collections::HashSet = calls + let ids: HashSet = calls .iter() .filter_map(|c| match c { TranscriptItem::ToolRun { id, .. } => Some(id.clone()), diff --git a/app-rust/tests/fence_fling.rs b/app-rust/tests/fence_fling.rs index 41f7e75..650664b 100644 --- a/app-rust/tests/fence_fling.rs +++ b/app-rust/tests/fence_fling.rs @@ -49,7 +49,7 @@ fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() { let mut h = Harness::new(phone_size(), PHONE_SCALE); let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds"); - let screen = opened.screen; + let mut screen = opened.screen; h.frame(0); h.frame(PHONE_FRAME_MS); @@ -119,7 +119,7 @@ fn a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting() { let mut h = Harness::new(phone_size(), PHONE_SCALE); let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds"); - let screen = opened.screen; + let mut screen = opened.screen; h.frame(0); h.frame(PHONE_FRAME_MS); diff --git a/app-rust/tests/gesture_cancel.rs b/app-rust/tests/gesture_cancel.rs index e896978..92e42cf 100644 --- a/app-rust/tests/gesture_cancel.rs +++ b/app-rust/tests/gesture_cancel.rs @@ -87,7 +87,7 @@ fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() { fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() { use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow}; - let (mut h, screen) = opened(); + let (mut h, mut screen) = opened(); let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg { seq: 9_000_000, text: "```\none two three four five six seven eight nine ten eleven twelve\n\ diff --git a/iris/benches/message_list.rs b/iris/benches/message_list.rs index b3cff62..5898519 100644 --- a/iris/benches/message_list.rs +++ b/iris/benches/message_list.rs @@ -68,7 +68,12 @@ fn bench_first_frame(n: usize) { let start = Instant::now(); render.update(&root, &mut rsc); let elapsed = start.elapsed(); - let (draws, rewrites, moves, _shapes) = render.take_counters(); + let RenderCounters { + draws, + region_rewrites: rewrites, + moves, + .. + } = render.take_counters(); report( &format!("(a) first frame, N={n}"), elapsed, @@ -97,7 +102,12 @@ fn bench_scroll(n: usize, ticks: usize) { let start = Instant::now(); render.update(&root, &mut rsc); total += start.elapsed(); - let (draws, rewrites, moves, _shapes) = render.take_counters(); + let RenderCounters { + draws, + region_rewrites: rewrites, + moves, + .. + } = render.take_counters(); total_draws += draws; total_rewrites += rewrites; total_moves += moves; @@ -155,7 +165,12 @@ fn bench_input_grows(n: usize, lines: usize) { let start = Instant::now(); render.update(&root, &mut rsc); total += start.elapsed(); - let (draws, rewrites, moves, _shapes) = render.take_counters(); + let RenderCounters { + draws, + region_rewrites: rewrites, + moves, + .. + } = render.take_counters(); total_draws += draws; total_rewrites += rewrites; total_moves += moves; @@ -200,7 +215,12 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) { let start = Instant::now(); render.update(&root, &mut rsc); total += start.elapsed(); - let (draws, rewrites, moves, _shapes) = render.take_counters(); + let RenderCounters { + draws, + region_rewrites: rewrites, + moves, + .. + } = render.take_counters(); total_draws += draws; total_rewrites += rewrites; total_moves += moves; @@ -270,7 +290,12 @@ fn bench_expand_holds_edge(n: usize, growths: usize) { let start = Instant::now(); render.update(&root, &mut rsc); total += start.elapsed(); - let (draws, rewrites, moves, _shapes) = render.take_counters(); + let RenderCounters { + draws, + region_rewrites: rewrites, + moves, + .. + } = render.take_counters(); total_draws += draws; total_rewrites += rewrites; total_moves += moves; @@ -314,7 +339,12 @@ fn bench_redraw_big_text(chars: usize, redraws: usize) { render.update(&root, &mut rsc); total += start.elapsed(); } - let (draws, rewrites, moves, _shapes) = render.take_counters(); + let RenderCounters { + draws, + region_rewrites: rewrites, + moves, + .. + } = render.take_counters(); report( &format!("(g) redraw one {chars}-glyph text, {redraws}x (totals)"), total, diff --git a/iris/core/src/ui/access.rs b/iris/core/src/ui/access.rs index c742d0e..d8c8306 100644 --- a/iris/core/src/ui/access.rs +++ b/iris/core/src/ui/access.rs @@ -32,7 +32,7 @@ fn entry_node(entry: &Entry) -> Node { /// Owns the last tree pushed out, so `update` can tell "nothing /// accessibility-relevant changed" from "something did" without asking /// the platform adapter to diff two `Node`s itself. One of these per -/// window/view -- `default::DefaultUiState` and `android::AndroidUiState` +/// window/view -- `desktop::DesktopUiState` and `android::AndroidUiState` /// each keep one. #[derive(Default)] pub struct AccessTree { diff --git a/iris/core/src/ui/render_state.rs b/iris/core/src/ui/render_state.rs index 6bb0a53..928b21e 100644 --- a/iris/core/src/ui/render_state.rs +++ b/iris/core/src/ui/render_state.rs @@ -18,6 +18,14 @@ pub enum RedrawKind { Updates, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RenderCounters { + pub draws: u64, + pub region_rewrites: u64, + pub moves: u64, + pub shapes: u64, +} + pub struct UiRenderState { pub active: HashMap, pub primitives: Primitives, @@ -115,13 +123,13 @@ impl UiRenderState { } } - pub fn take_counters(&mut self) -> (u64, u64, u64, u64) { - ( - std::mem::take(&mut self.draw_count), - std::mem::take(&mut self.region_mut_count), - std::mem::take(&mut self.mov_count), - std::mem::take(&mut self.shape_count), - ) + pub fn take_counters(&mut self) -> RenderCounters { + RenderCounters { + draws: std::mem::take(&mut self.draw_count), + region_rewrites: std::mem::take(&mut self.region_mut_count), + moves: std::mem::take(&mut self.mov_count), + shapes: std::mem::take(&mut self.shape_count), + } } pub(super) fn note_move(&mut self) { diff --git a/iris/examples/bench_images.rs b/iris/examples/bench_images.rs index 71d63a0..db4b0b5 100644 --- a/iris/examples/bench_images.rs +++ b/iris/examples/bench_images.rs @@ -4,24 +4,24 @@ const ROWS: usize = 1000; const SETTLE_FRAMES: usize = 4; const FRAMES: usize = 6; -#[derive(DefaultUiState)] +#[derive(DesktopUiState)] struct State { - ui_state: DefaultUiState, + ui_state: DesktopUiState, span: WeakWidget, frame: usize, appended: bool, } -impl DefaultAppState for State { +impl DesktopAppState for State { fn new( - mut ui_state: DefaultUiState, - rsc: &mut DefaultRsc, + mut ui_state: DesktopUiState, + rsc: &mut DesktopRsc, _: Proxy, ) -> Self { let mut span = Span::empty(Dir::DOWN); for _ in 0..ROWS { let img = image::DynamicImage::new_rgba8(32, 32); - let widget = image::>(img)(rsc); + let widget = image::>(img)(rsc); let widget = rsc.ui.widgets.add_strong(widget); span.push(widget.any()); } @@ -40,7 +40,7 @@ impl DefaultAppState for State { } } - fn window_event(&mut self, event: winit::event::WindowEvent, rsc: &mut DefaultRsc) { + fn window_event(&mut self, event: winit::event::WindowEvent, rsc: &mut DesktopRsc) { if !matches!(event, winit::event::WindowEvent::RedrawRequested) { return; } @@ -53,7 +53,7 @@ impl DefaultAppState for State { if self.frame == SETTLE_FRAMES && !self.appended { self.appended = true; let img = image::DynamicImage::new_rgba8(32, 32); - let widget = image::>(img)(rsc); + let widget = image::>(img)(rsc); let widget = rsc.ui.widgets.add_strong(widget); rsc.ui .widgets @@ -71,5 +71,5 @@ impl DefaultAppState for State { } fn main() { - DefaultApp::::run(); + DesktopApp::::run(); } diff --git a/iris/examples/message_list.rs b/iris/examples/message_list.rs index c8da116..73712c1 100644 --- a/iris/examples/message_list.rs +++ b/iris/examples/message_list.rs @@ -2,12 +2,12 @@ use iris::prelude::*; use winit::{dpi::LogicalSize, window::WindowAttributes}; fn main() { - DefaultApp::::run(); + DesktopApp::::run(); } -#[derive(DefaultUiState)] +#[derive(DesktopUiState)] struct State { - ui_state: DefaultUiState, + ui_state: DesktopUiState, } const ROWS: usize = 800; @@ -58,14 +58,14 @@ fn build_row(rsc: &mut Rsc, i: usize) -> StrongWidget { } } -impl DefaultAppState for State { +impl DesktopAppState for State { fn window_attributes() -> WindowAttributes { WindowAttributes::default().with_inner_size(LogicalSize::new(420.0, 900.0)) } fn new( - mut ui_state: DefaultUiState, - rsc: &mut DefaultRsc, + mut ui_state: DesktopUiState, + rsc: &mut DesktopRsc, _: Proxy, ) -> Self { let mut list = LazySpan::new(Dir::DOWN, Pin::End); diff --git a/iris/examples/minimal.rs b/iris/examples/minimal.rs index 4484baf..50b7807 100644 --- a/iris/examples/minimal.rs +++ b/iris/examples/minimal.rs @@ -1,18 +1,18 @@ use iris::prelude::*; fn main() { - DefaultApp::::run(); + DesktopApp::::run(); } -#[derive(DefaultUiState)] +#[derive(DesktopUiState)] struct State { - ui_state: DefaultUiState, + ui_state: DesktopUiState, } -impl DefaultAppState for State { +impl DesktopAppState for State { fn new( - mut ui_state: DefaultUiState, - rsc: &mut DefaultRsc, + mut ui_state: DesktopUiState, + rsc: &mut DesktopRsc, _: Proxy, ) -> Self { rect(PaintId::RED).set_root(rsc, &mut ui_state); diff --git a/iris/examples/tabs/main.rs b/iris/examples/tabs/main.rs index d4d4ec9..0732297 100644 --- a/iris/examples/tabs/main.rs +++ b/iris/examples/tabs/main.rs @@ -2,19 +2,19 @@ use iris::prelude::*; use winit::event::WindowEvent; fn main() { - DefaultApp::::run(); + DesktopApp::::run(); } -#[derive(DefaultUiState)] +#[derive(DesktopUiState)] pub struct Client { - ui_state: DefaultUiState, + ui_state: DesktopUiState, info: WeakWidget, } -impl DefaultAppState for Client { +impl DesktopAppState for Client { fn new( - mut ui_state: DefaultUiState, - rsc: &mut DefaultRsc, + mut ui_state: DesktopUiState, + rsc: &mut DesktopRsc, _: Proxy, ) -> Self { let widgets = tabs_ui::build(rsc, &mut ui_state); @@ -24,7 +24,7 @@ impl DefaultAppState for Client { } } - fn window_event(&mut self, _: WindowEvent, rsc: &mut DefaultRsc) { + fn window_event(&mut self, _: WindowEvent, rsc: &mut DesktopRsc) { let render_state = rsc.ui.render_state(); let new = format!( "widgets: {}\nactive: {}\nviews: {}", diff --git a/iris/examples/task.rs b/iris/examples/task.rs index b3aca61..7947832 100644 --- a/iris/examples/task.rs +++ b/iris/examples/task.rs @@ -2,18 +2,18 @@ use iris::prelude::*; use std::time::Duration; fn main() { - DefaultApp::::run(); + DesktopApp::::run(); } -#[derive(DefaultUiState)] +#[derive(DesktopUiState)] struct State { - ui_state: DefaultUiState, + ui_state: DesktopUiState, } -impl DefaultAppState for State { +impl DesktopAppState for State { fn new( - mut ui_state: DefaultUiState, - rsc: &mut DefaultRsc, + mut ui_state: DesktopUiState, + rsc: &mut DesktopRsc, _: Proxy, ) -> Self { let rect = rect(PaintId::RED).add(rsc); diff --git a/iris/examples/view.rs b/iris/examples/view.rs index cbe5ea4..7a4fbc5 100644 --- a/iris/examples/view.rs +++ b/iris/examples/view.rs @@ -1,15 +1,15 @@ use iris::prelude::*; fn main() { - DefaultApp::::run(); + DesktopApp::::run(); } -#[derive(DefaultUiState)] +#[derive(DesktopUiState)] struct State { - ui_state: DefaultUiState, + ui_state: DesktopUiState, } -type Rsc = DefaultRsc; +type Rsc = DesktopRsc; #[derive(Clone, Copy, WidgetView)] struct Test { @@ -35,10 +35,10 @@ impl Test { } } -impl DefaultAppState for State { +impl DesktopAppState for State { fn new( - mut ui_state: DefaultUiState, - rsc: &mut DefaultRsc, + mut ui_state: DesktopUiState, + rsc: &mut DesktopRsc, _: Proxy, ) -> Self { let test = Test::new(rsc); diff --git a/iris/macro/src/lib.rs b/iris/macro/src/lib.rs index d48908c..bdc335e 100644 --- a/iris/macro/src/lib.rs +++ b/iris/macro/src/lib.rs @@ -101,8 +101,8 @@ pub fn widget_trait(input: TokenStream) -> TokenStream { .into() } -#[proc_macro_derive(DefaultUiState, attributes(default_ui_state))] -pub fn derive_default_ui_state(input: TokenStream) -> TokenStream { +#[proc_macro_derive(DesktopUiState, attributes(desktop_ui_state))] +pub fn derive_desktop_ui_state(input: TokenStream) -> TokenStream { let mut output = proc_macro2::TokenStream::new(); let state: ItemStruct = parse_macro_input!(input); @@ -112,14 +112,14 @@ pub fn derive_default_ui_state(input: TokenStream) -> TokenStream { for field in &state.fields { if !found_attr && let Type::Path(path) = &field.ty - && path.path.is_ident("DefaultUiState") + && path.path.is_ident("DesktopUiState") { state_field = Some(field); } let Some(attr) = field .attrs .iter() - .find(|a| a.path().is_ident("default_ui_state")) + .find(|a| a.path().is_ident("desktop_ui_state")) else { continue; }; @@ -127,7 +127,7 @@ pub fn derive_default_ui_state(input: TokenStream) -> TokenStream { output.extend( Error::new( attr.span(), - "cannot have more than one default_ui_state attribute", + "cannot have more than one desktop_ui_state attribute", ) .into_compile_error(), ); @@ -138,18 +138,18 @@ pub fn derive_default_ui_state(input: TokenStream) -> TokenStream { } let Some(field) = state_field else { output.extend( - Error::new(state.ident.span(), "no DefaultUiState field found").into_compile_error(), + Error::new(state.ident.span(), "no DesktopUiState field found").into_compile_error(), ); return output.into(); }; let sname = &state.ident; let fname = field.ident.as_ref().unwrap(); output.extend(quote! { - impl iris::default::HasDefaultUiState for #sname { - fn default_state(&self) -> &iris::default::DefaultUiState { + impl iris::desktop::HasDesktopUiState for #sname { + fn desktop_state(&self) -> &iris::desktop::DesktopUiState { &self.#fname } - fn default_state_mut(&mut self) -> &mut iris::default::DefaultUiState { + fn desktop_state_mut(&mut self) -> &mut iris::desktop::DesktopUiState { &mut self.#fname } } diff --git a/iris/run-headless.sh b/iris/run-headless.sh index 59d8e99..750c19e 100755 --- a/iris/run-headless.sh +++ b/iris/run-headless.sh @@ -18,7 +18,7 @@ # `content_scale` 2.55, from docs/bench/iris-phone-v2-2026-09-06.md, # carried in `ai_app::ui::fixture::PHONE_*`), and `IRIS_SCALE` hands that # density to iris the way `DisplayMetrics.density` does on Android -# (`iris::default::content_scale`). So a screenshot from here and one +# (`iris::desktop::content_scale`). So a screenshot from here and one # from the phone are the same layout at the same density, and what # differs is only the renderer. Without it the output stays desktop- # shaped, which is what every other example wants. diff --git a/iris/src/android/render.rs b/iris/src/android/render.rs index f1c4e62..f6c20a9 100644 --- a/iris/src/android/render.rs +++ b/iris/src/android/render.rs @@ -176,7 +176,7 @@ impl AndroidRenderer { .filter(|part| !part.is_empty()) .collect::>() .join(" "); - // Say which adapter won, in the same words `default::render` uses, + // Say which adapter won, in the same words `desktop::render` uses, // and at startup rather than only on the Diagnostics page: the // backend alone (logged by `view.rs` when a renderer is built) does // not separate the cases that matter. In this checkout's emulator diff --git a/iris/src/android/view.rs b/iris/src/android/view.rs index 9373576..efdbfe3 100644 --- a/iris/src/android/view.rs +++ b/iris/src/android/view.rs @@ -11,13 +11,7 @@ use android_view::{ }, ndk::event::{Axis, Keycode, MotionAction}, }; -use std::{ - cell::RefCell, - marker::{PhantomData, Sized}, - rc::Rc, - sync::Arc, - time::Instant, -}; +use std::{cell::RefCell, marker::Sized, rc::Rc, sync::Arc, time::Instant}; use super::{ access::{AndroidAccessSource, NullActionHandler, raise_if_enabled}, @@ -25,10 +19,7 @@ use super::{ render::{AndroidRedrawHandle, AndroidRenderer}, }; -/// The android-view analogue of `default::DefaultUiState`. `renderer` is an -/// `Option` because a `SurfaceView`'s surface does not outlive backgrounding -/// the way a winit `Window` does -- `surfaceDestroyed`/`surfaceCreated` can -/// happen any number of times over the life of one `IrisViewPeer`. +/// Android host state. The renderer follows the `SurfaceView` lifecycle. /// How many frames after each `surface_changed` `render()` logs a full /// diagnostic line for -- see the log site's own comment. const DIAGNOSTIC_FRAMES: u64 = 10; @@ -39,24 +30,13 @@ pub struct AndroidUiState { pub focus: Option>, pub cursor: CursorState, pub last_click: Instant, - /// The IME preedit's previous length, in `char`s -- the same - /// re-send-the-whole-composition bookkeeping `default::DefaultUiState` - /// keeps for winit's `Ime::Preedit`, since android-view's - /// `setComposingText` has the identical shape (see `android/ime.rs`). + /// Previous IME preedit length, in characters. pub compose_len: usize, - /// Set by `attr::FocusHost::focus_gained` when a `TextEdit` is focused; - /// consumed by the touch handler after the sensor pass finishes, since - /// showing the keyboard is a JNI call and `focus_gained` runs deep - /// inside the platform-agnostic sensor dispatch with no `CallbackCtx` - /// in reach. + /// Deferred until the input callback regains access to JNI. pub pending_show_keyboard: bool, - /// A URL a tapped link asked the platform to open, for the same - /// reason `pending_show_keyboard` is a flag rather than a call -- - /// see `android/platform.rs`. + /// Also deferred until a JNI callback is available. pub pending_open_url: Option, - /// Window insets, filled in from outside the normal `ViewPeer` callback - /// path -- see `android/insets.rs` for why they need a registry of - /// their own. + /// Filled by the native insets callback registered in `android/insets.rs`. shared: Rc>, pub access_adapter: AccessAdapter, pub access: AccessTree, @@ -132,13 +112,7 @@ pub trait AndroidAppState: HasAndroidUiState { fn on_insets_changed(&mut self, rsc: &mut AndroidRsc, insets: WindowInsets) {} } -/// `insets::Insets` as `f32`, for the widget-facing callback above -- a -/// distinct type from `insets::Insets` so a caller of `on_insets_changed` -/// is not coupled to that module's own (`i32`, JNI-shaped) representation. -/// Both are physical pixels; this used to divide by `content_scale` into a -/// separate *logical* unit (hence the old name, `LogicalInsets`), back when -/// the rest of layout was logical too -- see `AndroidUiState::content_scale`'s -/// field comment for why that stopgap is gone. +/// Widget-facing insets in physical pixels, decoupled from JNI's integer shape. #[derive(Clone, Copy, Default, Debug, PartialEq)] pub struct WindowInsets { pub left: f32, @@ -166,66 +140,7 @@ impl WindowInsets { } } -pub struct AndroidRsc { - pub ui: Ui, - pub events: EventManager, - pub tasks: Tasks, - pub state: WidgetState, - _state: PhantomData, -} - -impl AndroidRsc { - pub fn create_state(&mut self, id: impl IdLike, data: T) -> WeakState { - self.state.add(id.id(), data) - } -} - -impl UiRsc for AndroidRsc { - fn ui(&self) -> &Ui { - &self.ui - } - fn ui_mut(&mut self) -> &mut Ui { - &mut self.ui - } - fn on_draw(&mut self, active: &ActiveData) { - self.events.draw(active); - } - fn on_undraw(&mut self, active: &ActiveData) { - self.events.undraw(active); - } - fn on_remove(&mut self, id: WidgetId) { - self.events.remove(id); - self.state.remove(id); - } -} - -impl HasState for AndroidRsc { - type State = State; -} - -impl HasEvents for AndroidRsc { - fn events(&self) -> &EventManager { - &self.events - } - fn events_mut(&mut self) -> &mut EventManager { - &mut self.events - } -} - -impl HasTasks for AndroidRsc { - fn tasks_mut(&mut self) -> &mut Tasks { - &mut self.tasks - } -} - -impl HasWidgetState for AndroidRsc { - fn widget_state(&self) -> &WidgetState { - &self.state - } - fn widget_state_mut(&mut self) -> &mut WidgetState { - &mut self.state - } -} +pub type AndroidRsc = AppRsc; /// The `ViewPeer` android-view dispatches every callback to. One per /// `RustView` instance; `new_peer` (below) builds it and hands the id to @@ -234,30 +149,10 @@ pub struct IrisViewPeer { pub(super) rsc: AndroidRsc, pub(super) state: State, task_recv: TaskMsgReceiver>, - /// The one ruler this view dates everything on: touch samples in - /// `on_touch_event` and the `Choreographer` frame time in `do_frame`. - /// Anchored by whichever of the two arrives first and never - /// re-anchored after, which is what lets a fling be advanced on the - /// same clock the gesture that launched it was measured on. Its path - /// out is the peer's own drop: it holds nothing but three numbers and - /// is meaningless to any other view. + /// Converts input and Choreographer timestamps onto one monotonic clock. device_clock: Option, } -impl>> std::ops::Index for AndroidRsc { - type Output = I::Output; - - fn index(&self, index: I) -> &Self::Output { - index.get(self) - } -} - -impl>> std::ops::IndexMut for AndroidRsc { - fn index_mut(&mut self, index: I) -> &mut Self::Output { - index.get_mut(self) - } -} - impl IrisViewPeer { fn drain_tasks(&mut self) { while let Ok(update) = self.task_recv.try_recv() { @@ -298,7 +193,7 @@ impl IrisViewPeer { /// Common tail for every callback that might have changed the cursor, /// the text focus, or the widget tree: run the sensors that touch /// input feeds, then ask for a frame if the result needs drawing. - /// Mirrors `default::DefaultApp::window_event`'s tail, split across + /// Mirrors `desktop::DesktopApp::window_event`'s tail, split across /// android-view's several entry points instead of winit's one. pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) { self.run_input_frame(ctx); @@ -322,6 +217,42 @@ impl IrisViewPeer { .get_or_insert_with(|| DeviceClock::anchored(Instant::now(), event_time, oldest)) } + fn generic_motion(&mut self, ctx: &mut CallbackCtx, event: &MotionEvent<'_>) -> bool { + let action = event.action_masked(&mut ctx.env); + let event_time = event.event_time_nanos(&mut ctx.env); + let mut clock = self.device_clock(event_time, event_time); + let at = clock.sample(event_time); + self.device_clock = Some(clock); + + let ui = self.state.android_state_mut(); + ui.cursor.time = at; + ui.cursor.pos = vec2(event.x(&mut ctx.env), event.y(&mut ctx.env)); + ui.cursor.exists = !matches!(action, MotionAction::HoverExit); + + let buttons = event.button_state(&mut ctx.env); + ui.cursor.buttons.left.update(buttons.primary()); + ui.cursor.buttons.right.update(buttons.secondary()); + ui.cursor.buttons.middle.update(buttons.teriary()); + + match action { + MotionAction::HoverEnter + | MotionAction::HoverMove + | MotionAction::HoverExit + | MotionAction::ButtonPress + | MotionAction::ButtonRelease => {} + MotionAction::Scroll => { + ui.cursor.scroll_delta = vec2( + event.axis(&mut ctx.env, Axis::Hscroll, 0), + event.axis(&mut ctx.env, Axis::Vscroll, 0), + ); + } + _ => return false, + } + + self.after_input(ctx); + true + } + fn window_size(&self) -> Vec2 { let ui_state = self.state.android_state(); match &ui_state.renderer { @@ -514,27 +445,14 @@ impl ViewPeer for IrisViewPeer { ) -> bool { self.drain_tasks(); let action = event.action_masked(&mut ctx.env); - // Device (physical) pixels, same space layout now uses throughout - // -- see `AndroidUiState::content_scale`'s field comment. + // MotionEvent and layout both use physical pixels. let x = event.x(&mut ctx.env); let y = event.y(&mut ctx.env); - // The event's own clock, converted through the view's one anchor - // -- taken on whichever of a touch or a frame callback came first. - // Android reports sample times in the `SystemClock.uptimeMillis()` - // base, which is the same `CLOCK_MONOTONIC` an `Instant` reads, so - // a single `(Instant, nanos)` pair converts every later sample - // exactly. Anchoring **once** rather than per event is what keeps - // the times ordered, and anchoring on the first event's *oldest* - // sample rather than on its own time is what keeps that event's - // batch from collapsing onto one instant -- `sense::DeviceClock`'s - // doc has both, and owns the arithmetic so it can be unit-tested - // off a device (`sense_tests.rs`). See `CursorState::time`. + // Use the event clock so batched movement keeps its real timing. let event_time = event.event_time_nanos(&mut ctx.env); let history = event.history_size(&mut ctx.env); let mut clock = match self.device_clock { Some(clock) => clock, - // Only the call that anchors needs the batch's oldest sample, - // so the JNI read for it stays off the per-event path. None => { let oldest = if history > 0 { event.historical_event_time_nanos(&mut ctx.env, 0) @@ -544,24 +462,12 @@ impl ViewPeer for IrisViewPeer { self.device_clock(event_time, oldest) } }; - // `iris::input`'s own doc (`sense::log_input_event`): collected - // only when tracing is on, since this is otherwise a `Vec` per - // `MotionEvent` for a line nobody is reading -- the JNI reads - // themselves (`historical_axis`/`historical_event_time_nanos` - // below) already happen unconditionally, for the replay this - // function does regardless of tracing. + // Avoid allocating trace history when input tracing is disabled. let trace_input = crate::diagnostics::trace_enabled(); let mut historical_ms: Vec<(u64, f32, f32)> = Vec::new(); if matches!(action, MotionAction::Move) { - // Android documents the historical samples as oldest first and - // the event's own sample as the newest of the batch; everything - // downstream (`VelocityTracker`, `DragArbiter`'s long-press - // clock) assumes it, so say so here rather than at each reader. - // `DeviceClock::sample` is what asserts it, and it carries the - // last sample seen *across* events, so the first sample of - // every event is checked against the previous event's last one - // rather than against the anchor. + // Android orders history oldest-first; `sample` checks monotonicity. for pos in 0..history { let hx = event.historical_axis(&mut ctx.env, Axis::X, 0, pos); let hy = event.historical_axis(&mut ctx.env, Axis::Y, 0, pos); @@ -616,6 +522,38 @@ impl ViewPeer for IrisViewPeer { true } + fn on_generic_motion_event<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + event: &MotionEvent<'local>, + ) -> bool { + self.drain_tasks(); + self.generic_motion(ctx, event) + } + + fn on_hover_event<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + event: &MotionEvent<'local>, + ) -> bool { + let action = event.action(&mut ctx.env); + let x = event.x(&mut ctx.env); + let y = event.y(&mut ctx.env); + let ui = self.state.android_state_mut(); + if let Some(events) = ui + .access_adapter + .on_hover_event(&mut NullActionHandler, action, x, y) + { + ctx.push_dynamic_deferred_callback(move |env, view| { + raise_if_enabled(env, view, events); + }); + true + } else { + self.drain_tasks(); + self.generic_motion(ctx, event) + } + } + fn on_focus_changed<'local>( &mut self, ctx: &mut CallbackCtx<'local>, @@ -646,32 +584,10 @@ impl ViewPeer for IrisViewPeer { height: i32, ) { self.drain_tasks(); - // The layout engine's own notion of the canvas size is separate - // from the wgpu surface's -- winit's backend sets it from - // `WindowEvent::Resized`, and there is no equivalent automatic - // trigger here, so this is the one place android-view's surface - // size has to be told to `UiRenderState` too. Missing this drew - // nothing but the clear colour: the widget tree laid out against - // whatever size `UiRenderState::new` starts at instead of the - // surface's real one. - // - // **Physical pixels, matching `AndroidRenderer`'s own - // `size()`/`resize()`/`new()`** -- `AndroidUiState::content_scale`'s - // field comment. This call sets `UiRenderState::output_size`, which - // every `rel`/`rest` length resolves against and every `abs` - // pixel-region compares to directly; a `dp(56)` height now folds - // in the density at `Len::apply_rest` time instead of this call - // dividing the whole window into a separate logical space, which - // is what used to make every `abs`-unit size (a fixed `.height(56)` - // in particular) mean something different from a `rest`-based one. + // The layout canvas and wgpu surface are separate and both use physical pixels. self.rsc.ui.resize((width as f32, height as f32)); - // `AndroidRenderer::resize` only reconfigures the wgpu surface and - // rewrites the window uniform -- device, atlas, buffers and bind - // groups are untouched, so the glyph cache's coordinates stay - // valid. A genuinely new surface (after `surface_destroyed`, e.g. - // backgrounding) still goes through `AndroidRenderer::new` below, - // since `renderer` is `None` in that case. + // Resizing preserves GPU resources; recreating a destroyed surface does not. let already_live = self.state.android_state().renderer.is_some(); log::info!( "iris surface: surface_changed {width}x{height} already_live={already_live} \ @@ -883,14 +799,7 @@ pub fn new_peer<'local, State: AndroidAppState>( let vm = env.get_java_vm().unwrap(); let global_view = env.new_global_ref(&view.0).unwrap(); let redraw: Arc = Arc::new(AndroidRedrawHandle::new(vm, global_view)); - let (tasks, task_recv) = Tasks::init(redraw); - let mut rsc = AndroidRsc { - ui: Default::default(), - events: Default::default(), - tasks, - state: Default::default(), - _state: PhantomData, - }; + let (mut rsc, task_recv) = AppRsc::new(redraw); rsc.ui.set_density(content_scale); let shared = Rc::new(RefCell::new(Shared::default())); let ui_state = AndroidUiState::new(shared.clone(), content_scale); diff --git a/iris/src/default/access.rs b/iris/src/desktop/access.rs similarity index 100% rename from iris/src/default/access.rs rename to iris/src/desktop/access.rs diff --git a/iris/src/default/app.rs b/iris/src/desktop/app.rs similarity index 100% rename from iris/src/default/app.rs rename to iris/src/desktop/app.rs diff --git a/iris/src/default/attr.rs b/iris/src/desktop/attr.rs similarity index 71% rename from iris/src/default/attr.rs rename to iris/src/desktop/attr.rs index 5ecfa1c..74b33c0 100644 --- a/iris/src/default/attr.rs +++ b/iris/src/desktop/attr.rs @@ -1,21 +1,21 @@ use crate::prelude::*; use winit::dpi::{PhysicalPosition, PhysicalSize}; -impl FocusHost for T { +impl FocusHost for T { fn recent_click(&mut self) -> bool { - crate::attr::recent_click(&mut self.default_state_mut().last_click) + crate::attr::recent_click(&mut self.desktop_state_mut().last_click) } fn set_focus(&mut self, id: Option>) { - self.default_state_mut().focus = id; + self.desktop_state_mut().focus = id; } fn is_focused(&self, id: WeakWidget) -> bool { - self.default_state().focus == Some(id) + self.desktop_state().focus == Some(id) } fn focus_gained(&mut self, region: Option) { - let state = self.default_state_mut(); + let state = self.desktop_state_mut(); let Some(region) = region else { return }; state.window.set_ime_allowed(true); state.window.set_ime_cursor_area( diff --git a/iris/src/default/input.rs b/iris/src/desktop/input.rs similarity index 96% rename from iris/src/default/input.rs rename to iris/src/desktop/input.rs index 45d2297..6dbbc66 100644 --- a/iris/src/default/input.rs +++ b/iris/src/desktop/input.rs @@ -19,7 +19,7 @@ pub struct Input { impl Input { /// winit's pointer coordinates are physical pixels, which is the /// space the whole tree is laid out and hit-tested in -- see - /// `default::content_scale`. Nothing is converted here; `dp(...)` + /// `desktop::content_scale`. Nothing is converted here; `dp(...)` /// resolves against the density at layout time instead. pub fn event(&mut self, event: &WindowEvent) -> bool { match event { @@ -79,10 +79,10 @@ impl Input { } } -impl DefaultUiState { +impl DesktopUiState { /// Physical pixels, matching `WindowEvent::Resized` (what /// `UiRenderState::resize` is given) and the swapchain -- see - /// `default::content_scale`. + /// `desktop::content_scale`. pub fn window_size(&self) -> Vec2 { let size = self.renderer.window().inner_size(); Vec2::new(size.width as f32, size.height as f32) diff --git a/iris/src/default/logging.rs b/iris/src/desktop/logging.rs similarity index 100% rename from iris/src/default/logging.rs rename to iris/src/desktop/logging.rs diff --git a/iris/src/default/mod.rs b/iris/src/desktop/mod.rs similarity index 63% rename from iris/src/default/mod.rs rename to iris/src/desktop/mod.rs index 765b10e..62b2808 100644 --- a/iris/src/default/mod.rs +++ b/iris/src/desktop/mod.rs @@ -1,10 +1,6 @@ use crate::prelude::*; use arboard::Clipboard; -use std::{ - marker::{PhantomData, Sized}, - sync::Arc, - time::Instant, -}; +use std::{marker::Sized, sync::Arc, time::Instant}; use winit::{ event::{Ime, WindowEvent}, event_loop::{ActiveEventLoop, EventLoopProxy}, @@ -26,19 +22,8 @@ pub use render::*; pub type Proxy = EventLoopProxy; -/// The desktop's `content_scale`: physical pixels per dp, the same -/// quantity Android reads from `DisplayMetrics.density` and feeds to -/// `UiRenderState::set_density` (`android::view::AndroidUiState:: -/// content_scale`'s field comment). Everything in this backend is -/// physical pixels -- the window size, the pointer, the widget tree -- -/// and `dp(...)` is what resolves against this at layout time, exactly -/// as on the phone. That is a correction from an earlier version that -/// divided winit's coordinates into a separate "logical" space instead: -/// it left `UiRenderState::resize` (physical, from `WindowEvent:: -/// Resized`) and the window uniform (logical) disagreeing on any -/// display whose scale factor is not 1.0, and it rasterised glyphs at -/// one resolution to display them at another -- the blur the phone's own -/// stopgap produced before `dp` existed. +/// Physical pixels per dp. Layout and input stay in physical pixels; only +/// `dp(...)` resolves through this scale. pub fn content_scale(window: &Window) -> f32 { match std::env::var("IRIS_SCALE") { Err(_) => window.scale_factor() as f32, @@ -52,7 +37,7 @@ pub fn content_scale(window: &Window) -> f32 { } } -pub struct DefaultUiState { +pub struct DesktopUiState { pub root: Option, pub renderer: UiRenderer, pub input: Input, @@ -65,13 +50,13 @@ pub struct DefaultUiState { pub access: AccessTree, } -impl HasRoot> for DefaultUiState { - fn set_root(&mut self, rsc: &mut DefaultRsc, root: StrongWidget) { +impl HasRoot> for DesktopUiState { + fn set_root(&mut self, rsc: &mut DesktopRsc, root: StrongWidget) { self.root = Some(crate::overlay::default_overlay_root(rsc, root)); } } -impl DefaultUiState { +impl DesktopUiState { pub fn new(window: impl Into>, access_adapter: accesskit_winit::Adapter) -> Self { let window = window.into(); Self { @@ -89,114 +74,35 @@ impl DefaultUiState { } } -pub trait HasDefaultUiState: Sized + 'static { - fn default_state(&self) -> &DefaultUiState; - fn default_state_mut(&mut self) -> &mut DefaultUiState; +pub trait HasDesktopUiState: Sized + 'static { + fn desktop_state(&self) -> &DesktopUiState; + fn desktop_state_mut(&mut self) -> &mut DesktopUiState; } -pub trait DefaultAppState: HasDefaultUiState { +pub trait DesktopAppState: HasDesktopUiState { type Event = (); - fn new(ui_state: DefaultUiState, rsc: &mut DefaultRsc, proxy: Proxy) + fn new(ui_state: DesktopUiState, rsc: &mut DesktopRsc, proxy: Proxy) -> Self; #[allow(unused_variables)] - fn event(&mut self, event: Self::Event, rsc: &mut DefaultRsc) {} + fn event(&mut self, event: Self::Event, rsc: &mut DesktopRsc) {} #[allow(unused_variables)] - fn exit(&mut self, rsc: &mut DefaultRsc) {} + fn exit(&mut self, rsc: &mut DesktopRsc) {} #[allow(unused_variables)] - fn window_event(&mut self, event: WindowEvent, rsc: &mut DefaultRsc) {} + fn window_event(&mut self, event: WindowEvent, rsc: &mut DesktopRsc) {} fn window_attributes() -> WindowAttributes { Default::default() } } -pub struct DefaultRsc { - pub ui: Ui, - pub events: EventManager, - pub tasks: Tasks, - pub state: WidgetState, - _state: PhantomData, -} +pub type DesktopRsc = AppRsc; -impl DefaultRsc { - fn init(window: Arc) -> (Self, TaskMsgReceiver) { - let (tasks, recv) = Tasks::init(window); - ( - Self { - ui: Default::default(), - events: Default::default(), - tasks, - state: Default::default(), - _state: Default::default(), - }, - recv, - ) - } - - pub fn create_state(&mut self, id: impl IdLike, data: T) -> WeakState { - self.state.add(id.id(), data) - } -} - -impl UiRsc for DefaultRsc { - fn ui(&self) -> &Ui { - &self.ui - } - - fn ui_mut(&mut self) -> &mut Ui { - &mut self.ui - } - - fn on_draw(&mut self, active: &ActiveData) { - self.events.draw(active); - } - - fn on_undraw(&mut self, active: &ActiveData) { - self.events.undraw(active); - } - - fn on_remove(&mut self, id: WidgetId) { - self.events.remove(id); - self.state.remove(id); - } -} - -impl HasState for DefaultRsc { - type State = State; -} - -impl HasEvents for DefaultRsc { - fn events(&self) -> &EventManager { - &self.events - } - - fn events_mut(&mut self) -> &mut EventManager { - &mut self.events - } -} - -impl HasTasks for DefaultRsc { - fn tasks_mut(&mut self) -> &mut Tasks { - &mut self.tasks - } -} - -impl HasWidgetState for DefaultRsc { - fn widget_state(&self) -> &WidgetState { - &self.state - } - - fn widget_state_mut(&mut self) -> &mut WidgetState { - &mut self.state - } -} - -pub struct DefaultApp { - rsc: DefaultRsc, +pub struct DesktopApp { + rsc: DesktopRsc, state: State, - task_recv: TaskMsgReceiver>, + task_recv: TaskMsgReceiver>, } -impl AppState for DefaultApp { +impl AppState for DesktopApp { type Event = State::Event; fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy) -> Self { @@ -211,16 +117,12 @@ impl AppState for DefaultApp { NullDeactivationHandler, ); window.set_visible(true); - let default_state = DefaultUiState::new(window, access_adapter); - let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone()); - // Both copies of the density, set before the first widget is - // built so text shapes at the right size on the opening frame -- - // the same pair `android::view::new_peer` sets from - // `content_scale`. See `iris_core::TextData::density` for why the - // shaper keeps its own. - let scale = content_scale(default_state.window.as_ref()); + let desktop_state = DesktopUiState::new(window, access_adapter); + let (mut rsc, task_recv) = AppRsc::new(desktop_state.window.clone()); + // Set before building widgets so the first text shape uses the right density. + let scale = content_scale(desktop_state.window.as_ref()); rsc.ui.set_density(scale); - let state = State::new(default_state, &mut rsc, proxy); + let state = State::new(desktop_state, &mut rsc, proxy); Self { rsc, state, @@ -243,7 +145,7 @@ impl AppState for DefaultApp { update(state, rsc); } - let ui_state = state.default_state_mut(); + let ui_state = state.desktop_state_mut(); // Required by `accesskit_winit` on every window event, not just the // ones this backend otherwise cares about -- some platform adapters // rely on it to notice activation (a screen reader turning on). @@ -257,14 +159,7 @@ impl AppState for DefaultApp { ui_state.focus = None; } if input_changed { - // The winit half of `iris::input` (`sense::log_input_event`'s - // own doc): no batching here, so `historical` is always empty - // -- winit hands one `WindowEvent` per pointer sample, unlike - // Android's `MotionEvent`. The action is read back off the - // buttons `Input::event` just updated, the same test - // `GestureOutcome`'s callers already use to tell a press from a - // release. Computed only when tracing is on, same reasoning as - // `log_input_event` itself gating on it. + // Winit delivers one sample at a time, so there is no history batch. if crate::diagnostics::trace_enabled() { let action = if cursor_state.buttons.left.is_start() { "down" @@ -292,7 +187,7 @@ impl AppState for DefaultApp { .get() .run_sensors(rsc, state, cursor_state, window_size); } - let ui_state = state.default_state_mut(); + let ui_state = state.desktop_state_mut(); if old != ui_state.focus && let Some(old) = old { @@ -301,16 +196,10 @@ impl AppState for DefaultApp { match &event { WindowEvent::CloseRequested => event_loop.exit(), WindowEvent::RedrawRequested => { - // Before the draw, so this frame shows this instant's - // position (`UiData::tick_animations`' own doc), and the - // window is asked for another frame while anything is - // still moving -- the winit half of what - // `IrisViewPeer::render`'s `post_frame_callback` does on - // Android. Nothing else in iris moves without an input - // event. + // Advance animations before drawing and keep requesting frames while active. let frame_start = std::time::Instant::now(); let animating = rsc.ui_mut().tick_animations(frame_start); - let ui_state = state.default_state_mut(); + let ui_state = state.desktop_state_mut(); if animating { ui_state.window.request_redraw(); } @@ -415,7 +304,7 @@ impl AppState for DefaultApp { _ => (), } state.window_event(event, rsc); - let ui_state = self.state.default_state_mut(); + let ui_state = self.state.desktop_state_mut(); let render_state = rsc.ui.render_state(); if render_state .get() @@ -430,17 +319,3 @@ impl AppState for DefaultApp { self.state.exit(&mut self.rsc); } } - -impl>> std::ops::Index for DefaultRsc { - type Output = I::Output; - - fn index(&self, index: I) -> &Self::Output { - index.get(self) - } -} - -impl>> std::ops::IndexMut for DefaultRsc { - fn index_mut(&mut self, index: I) -> &mut Self::Output { - index.get_mut(self) - } -} diff --git a/iris/src/default/platform.rs b/iris/src/desktop/platform.rs similarity index 87% rename from iris/src/default/platform.rs rename to iris/src/desktop/platform.rs index 3ed1281..993f349 100644 --- a/iris/src/default/platform.rs +++ b/iris/src/desktop/platform.rs @@ -1,7 +1,7 @@ use crate::platform::OpenUrl; -use crate::prelude::HasDefaultUiState; +use crate::prelude::HasDesktopUiState; -impl OpenUrl for T { +impl OpenUrl for T { fn open_url(&mut self, url: &str) { let (program, first): (&str, &[&str]) = if cfg!(target_os = "macos") { ("open", &[]) diff --git a/iris/src/default/render.rs b/iris/src/desktop/render.rs similarity index 99% rename from iris/src/default/render.rs rename to iris/src/desktop/render.rs index 4fa3103..ad83302 100644 --- a/iris/src/default/render.rs +++ b/iris/src/desktop/render.rs @@ -213,7 +213,7 @@ impl UiRenderer { // that function's doc comment). // Physical size, the same units the swapchain, `WindowEvent:: // Resized`, the pointer and the widget tree all use -- see - // `default::content_scale` for why this backend stopped dividing + // `desktop::content_scale` for why this backend stopped dividing // into a separate logical space, and what disagreed while it did. let physical_size = Vec2::new(size.width as f32, size.height as f32); let ui = UiRenderNode::new(&device, &queue, formats.view, physical_size) diff --git a/iris/src/diagnostics.rs b/iris/src/diagnostics.rs index 44fd8e7..ea57a2c 100644 --- a/iris/src/diagnostics.rs +++ b/iris/src/diagnostics.rs @@ -15,7 +15,7 @@ pub fn trace_enabled() -> bool { /// One `iris::frame` line, called once per frame from each backend's own /// frame function -- `android::view::IrisViewPeer::render`, -/// `default::DefaultApp::window_event`'s `RedrawRequested` arm, and +/// `desktop::DesktopApp::window_event`'s `RedrawRequested` arm, and /// `harness::Harness::frame` -- after the draw (or, on the harness, where a /// draw would be; `draw` is `Duration::ZERO` there since nothing is /// actually submitted to a GPU). diff --git a/iris/src/harness.rs b/iris/src/harness.rs index 56f4ab1..a044c80 100644 --- a/iris/src/harness.rs +++ b/iris/src/harness.rs @@ -1,5 +1,4 @@ use crate::prelude::*; -use std::marker::PhantomData; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; @@ -164,76 +163,7 @@ impl OpenUrl for HarnessState { } } -/// The harness's `Rsc` -- identical in substance to `DefaultRsc`/ -/// `AndroidRsc` minus the windowing, for the same reason those two are -/// separate types (`AndroidRsc`'s own doc). -pub struct HarnessRsc { - pub ui: Ui, - pub events: EventManager, - pub tasks: Tasks, - pub state: WidgetState, - _state: PhantomData, -} - -impl UiRsc for HarnessRsc { - fn ui(&self) -> &Ui { - &self.ui - } - fn ui_mut(&mut self) -> &mut Ui { - &mut self.ui - } - fn on_draw(&mut self, active: &ActiveData) { - self.events.draw(active); - } - fn on_undraw(&mut self, active: &ActiveData) { - self.events.undraw(active); - } - fn on_remove(&mut self, id: WidgetId) { - self.events.remove(id); - self.state.remove(id); - } -} - -impl HasState for HarnessRsc { - type State = HarnessState; -} - -impl HasEvents for HarnessRsc { - fn events(&self) -> &EventManager { - &self.events - } - fn events_mut(&mut self) -> &mut EventManager { - &mut self.events - } -} - -impl HasTasks for HarnessRsc { - fn tasks_mut(&mut self) -> &mut Tasks { - &mut self.tasks - } -} - -impl HasWidgetState for HarnessRsc { - fn widget_state(&self) -> &WidgetState { - &self.state - } - fn widget_state_mut(&mut self) -> &mut WidgetState { - &mut self.state - } -} - -impl> std::ops::Index for HarnessRsc { - type Output = I::Output; - fn index(&self, index: I) -> &Self::Output { - index.get(self) - } -} - -impl> std::ops::IndexMut for HarnessRsc { - fn index_mut(&mut self, index: I) -> &mut Self::Output { - index.get_mut(self) - } -} +pub type HarnessRsc = AppRsc; /// A screen running with no window: the widget tree, the frame loop and /// the pointer, all advanced by the caller. See the module doc. @@ -255,14 +185,7 @@ impl Harness { /// `PHONE_SCALE`. pub fn new(size: Vec2, density: f32) -> Self { let redraws = Arc::new(RedrawCounter::default()); - let (tasks, task_recv) = Tasks::init(redraws.clone()); - let mut rsc = HarnessRsc { - ui: Ui::default(), - events: EventManager::default(), - tasks, - state: WidgetState::default(), - _state: PhantomData, - }; + let (mut rsc, task_recv) = AppRsc::new(redraws.clone()); rsc.ui.set_density(density); rsc.ui.resize(size); Self { diff --git a/iris/src/layout_tests.rs b/iris/src/layout_tests.rs index 04e02d7..7427494 100644 --- a/iris/src/layout_tests.rs +++ b/iris/src/layout_tests.rs @@ -115,7 +115,7 @@ fn a_widget_retains_its_entry_layer_not_its_child_cursor() { rsc.ui.widgets.get_mut(&outer_weak).unwrap().x = None; render.update(&root, &mut rsc); assert_eq!( - render.take_counters().0, + render.take_counters().draws, 1, "redrawing the parent should retain the unchanged Stack subtree" ); @@ -250,7 +250,12 @@ fn a_child_coordinate_offset_moves_only_the_child_subtree() { rsc.ui.widgets.get_mut(&parent_weak).unwrap().offset.y = 35.0; rsc.ui.widgets.get_mut(&outer_weak).unwrap().x = None; render.update(&root, &mut rsc); - let (draws, rewrites, moves, _shapes) = render.take_counters(); + let RenderCounters { + draws, + region_rewrites: rewrites, + moves, + .. + } = render.take_counters(); let parent_after = render.window_region(&parent_weak, &rsc).unwrap(); let child_after = render.window_region(&child_weak, &rsc).unwrap(); @@ -283,7 +288,7 @@ fn a_hinted_rest_draws_once_and_only_moves_the_fixed_child_after_it() { let mut render = UiRenderState::new(); render.resize((200.0, 300.0)); render.update(&root, &mut rsc); - let (draws, _rewrites, moves, _shapes) = render.take_counters(); + let RenderCounters { draws, moves, .. } = render.take_counters(); assert_eq!(draws, 5); assert_eq!(moves, 1); @@ -328,7 +333,12 @@ fn an_unchanged_frame_draws_and_rewrites_nothing() { render.take_counters(); // discard the first, real draws render.update(&root, &mut rsc); - let (draws, rewrites, moves, _shapes) = render.take_counters(); + let RenderCounters { + draws, + region_rewrites: rewrites, + moves, + .. + } = render.take_counters(); assert_eq!((draws, rewrites, moves), (0, 0, 0)); } @@ -346,7 +356,7 @@ fn scrolling_moves_in_o1_without_a_redraw() { rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0); render.update(&root, &mut rsc); - let (draws, _rewrites, moves, _shapes) = render.take_counters(); + let RenderCounters { draws, moves, .. } = render.take_counters(); assert_eq!(draws, 1, "only Scroll itself should redraw"); assert_eq!(moves, 1, "the scrolled subtree should move in one write"); diff --git a/iris/src/lib.rs b/iris/src/lib.rs index b7dd1fd..5144d22 100644 --- a/iris/src/lib.rs +++ b/iris/src/lib.rs @@ -1,6 +1,6 @@ #![feature(unboxed_closures)] #![feature(fn_traits)] -// Only `default::DefaultAppState::Event`'s default uses this; unused (and +// Only `desktop::DesktopAppState::Event`'s default uses this; unused (and // warned about) on the android target, which has no such default. #![cfg_attr(not(target_os = "android"), feature(associated_type_defaults))] #![feature(unsize)] @@ -10,7 +10,7 @@ #[cfg(target_os = "android")] pub mod android; #[cfg(not(target_os = "android"))] -pub mod default; +pub mod desktop; pub mod attr; pub mod diagnostics; @@ -18,6 +18,7 @@ pub mod event; pub mod harness; pub mod overlay; pub mod platform; +pub mod runtime; pub mod sense; pub mod state; pub mod task; @@ -38,7 +39,7 @@ pub mod prelude { #[cfg(target_os = "android")] pub use android::*; #[cfg(not(target_os = "android"))] - pub use default::*; + pub use desktop::*; pub use attr::*; pub use event::*; @@ -46,6 +47,7 @@ pub mod prelude { pub use iris_macro::*; pub use overlay::*; pub use platform::*; + pub use runtime::*; pub use sense::*; pub use state::*; pub use task::*; diff --git a/iris/src/runtime.rs b/iris/src/runtime.rs new file mode 100644 index 0000000..b3a2c79 --- /dev/null +++ b/iris/src/runtime.rs @@ -0,0 +1,98 @@ +use crate::prelude::*; +use std::sync::Arc; + +/// Resources shared by every Iris host. +pub struct AppRsc { + pub ui: Ui, + pub events: EventManager, + pub tasks: Tasks, + pub state: WidgetState, + _state: std::marker::PhantomData, +} + +impl AppRsc { + pub(crate) fn new(redraw: Arc) -> (Self, TaskMsgReceiver) { + let (tasks, receiver) = Tasks::init(redraw); + ( + Self { + ui: Ui::default(), + events: EventManager::default(), + tasks, + state: WidgetState::default(), + _state: std::marker::PhantomData, + }, + receiver, + ) + } + + pub fn create_state(&mut self, id: impl IdLike, data: T) -> WeakState { + self.state.add(id.id(), data) + } +} + +impl UiRsc for AppRsc { + fn ui(&self) -> &Ui { + &self.ui + } + + fn ui_mut(&mut self) -> &mut Ui { + &mut self.ui + } + + fn on_draw(&mut self, active: &ActiveData) { + self.events.draw(active); + } + + fn on_undraw(&mut self, active: &ActiveData) { + self.events.undraw(active); + } + + fn on_remove(&mut self, id: WidgetId) { + self.events.remove(id); + self.state.remove(id); + } +} + +impl HasState for AppRsc { + type State = State; +} + +impl HasEvents for AppRsc { + fn events(&self) -> &EventManager { + &self.events + } + + fn events_mut(&mut self) -> &mut EventManager { + &mut self.events + } +} + +impl HasTasks for AppRsc { + fn tasks_mut(&mut self) -> &mut Tasks { + &mut self.tasks + } +} + +impl HasWidgetState for AppRsc { + fn widget_state(&self) -> &WidgetState { + &self.state + } + + fn widget_state_mut(&mut self) -> &mut WidgetState { + &mut self.state + } +} + +impl>> std::ops::Index for AppRsc { + type Output = I::Output; + + fn index(&self, index: I) -> &Self::Output { + index.get(self) + } +} + +impl>> std::ops::IndexMut for AppRsc { + fn index_mut(&mut self, index: I) -> &mut Self::Output { + index.get_mut(self) + } +} diff --git a/iris/src/widget/position/lazy_span.rs b/iris/src/widget/position/lazy_span.rs index bedaed8..7897edb 100644 --- a/iris/src/widget/position/lazy_span.rs +++ b/iris/src/widget/position/lazy_span.rs @@ -1155,7 +1155,7 @@ mod tests { .push_front(LazyItem::new(key, w)); } render.update(&root, &mut rsc); - let (draws, _rewrites, _moves, _shapes) = render.take_counters(); + let draws = render.take_counters().draws; let extents_after = rsc.ui.widgets.get(&list_weak).unwrap().extents.clone(); for key in [11u64, 12] { @@ -1269,7 +1269,8 @@ mod tests { rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(1.0); render.update(&root, &mut rsc); - let (draws, _rewrites, moves, _shapes) = render.take_counters(); + let counters = render.take_counters(); + let (draws, moves) = (counters.draws, counters.moves); assert_eq!(draws, 1, "n={n}: only the list should really draw"); assert_eq!(moves, 1, "n={n}: the whole retained run should move once");