diff --git a/iris/Cargo.lock b/iris/Cargo.lock index 4aa9adf..d37cdf1 100644 --- a/iris/Cargo.lock +++ b/iris/Cargo.lock @@ -151,7 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -534,6 +534,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" @@ -710,6 +716,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "client-core" +version = "0.1.0" +dependencies = [ + "event-model", + "serde", + "serde_json", + "ureq", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -755,6 +771,35 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -871,6 +916,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "dispatch" version = "0.2.0" @@ -1023,6 +1074,14 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-model" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "exr" version = "1.74.0" @@ -1165,6 +1224,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1239,6 +1307,26 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1398,6 +1486,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "icu_collections" version = "2.3.0" @@ -1526,6 +1630,27 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.25.9" @@ -1641,6 +1766,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.21.1" @@ -1669,7 +1800,7 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom", + "getrandom 0.3.4", "libc", ] @@ -1978,6 +2109,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -2620,6 +2757,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2672,6 +2815,25 @@ dependencies = [ "syn 2.0.113", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.10.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pxfm" version = "0.1.27" @@ -2746,7 +2908,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] [[package]] @@ -2881,6 +3043,20 @@ version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.21.1" @@ -2922,6 +3098,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2998,6 +3209,19 @@ dependencies = [ "syn 2.0.113", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -3138,6 +3362,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swash" version = "0.2.10" @@ -3196,7 +3426,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.4", "once_cell", "rustix 1.1.3", "windows-sys 0.61.2", @@ -3265,6 +3495,36 @@ dependencies = [ "zune-jpeg 0.4.21", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -3371,6 +3631,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "transcript-ui" +version = "0.1.0" +dependencies = [ + "client-core", + "event-model", + "iris", + "pulldown-cmark", +] + [[package]] name = "tree_magic_mini" version = "3.2.2" @@ -3409,6 +3679,12 @@ dependencies = [ "keyboard-types", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -3427,6 +3703,62 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3471,6 +3803,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -3667,6 +4005,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -4535,6 +4882,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" @@ -4570,6 +4923,12 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zune-core" version = "0.4.12" diff --git a/iris/Cargo.toml b/iris/Cargo.toml index ffae414..f251bbd 100644 --- a/iris/Cargo.toml +++ b/iris/Cargo.toml @@ -73,7 +73,7 @@ name = "message_list" harness = false [workspace] -members = ["core", "macro", "tabs-ui"] +members = ["core", "macro", "tabs-ui", "transcript-ui"] # android-app pulls in android-view, which needs the NDK sysroot to link # -- excluded so `cargo build --workspace --all-targets` on the host stays # buildable. Cross-compile it from its own directory (its own single-crate @@ -100,3 +100,8 @@ accesskit = "0.25.0" iris-core = { path = "core" } iris-macro = { path = "macro" } tokio = "1.49.0" +# Current stable as of 2026-09-05 (`cargo search`) -- I5's markdown block +# model, the same crate E2's uncommitted `e2-transcript` experiment used for +# the identical job (RUST.md), rather than reimplementing a CommonMark +# parser. +pulldown-cmark = "0.13.4" diff --git a/iris/transcript-ui/Cargo.toml b/iris/transcript-ui/Cargo.toml new file mode 100644 index 0000000..115e245 --- /dev/null +++ b/iris/transcript-ui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "transcript-ui" +version.workspace = true +edition.workspace = true + +# I5 (RUST.md): the transcript screen's widget tree, built the same way +# `tabs-ui` is -- its own crate, generic over `Rsc: HasEvents` + +# `Rsc::State: FocusHost`, so the winit example and an eventual +# `iris-android-app`-style cdylib call the same `build`. See its own module +# doc for the design and RUST.md's I5 box for what is and is not proved yet. +# +# `client-core`/`event-model` by path, real code and not a reimplementation +# -- the same dependency shape E2's uncommitted Masonry experiment used for +# the identical job. + +[dependencies] +iris = { path = ".." } +client-core = { path = "../../client-core" } +event-model = { path = "../../event-model" } +pulldown-cmark = { workspace = true } diff --git a/iris/transcript-ui/examples/transcript.rs b/iris/transcript-ui/examples/transcript.rs new file mode 100644 index 0000000..67e5989 --- /dev/null +++ b/iris/transcript-ui/examples/transcript.rs @@ -0,0 +1,122 @@ +//! I5's desktop proof: the transcript screen built from synthetic +//! `client_core::transcript_fold` rows (no network, no server -- see +//! `lib.rs`'s doc for why `transcript-ui` itself never fetches anything), +//! run via `iris/run-headless.sh transcript -- -p transcript-ui` for a +//! screenshot on the winit backend, or `cargo run --example transcript -p +//! transcript-ui` with a real compositor. +//! +//! The rows exercise every one of the seven "hard to get back" behaviours +//! this box's markdown/selection work is meant to show: a heading, bold, +//! italic, an inline code span, a link, a fenced code block (rich inline +//! text), a multi-message conversation (bottom-anchored virtualised list), +//! and a three-call tool run (collapsed by default -- tap it, or drive it +//! with `ui-trace record --do "tap 'Tools'"` on Android, to prove +//! hold-the-edge expand). + +use client_core::transcript_fold::{TranscriptItem, TranscriptRow as FoldedRow}; +use iris::prelude::*; + +fn main() { + DefaultApp::::run(); +} + +#[derive(DefaultUiState)] +pub struct Client { + ui_state: DefaultUiState, + #[allow(dead_code)] + screen: transcript_ui::TranscriptScreen, +} + +fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow { + FoldedRow::Single(if from_user { + TranscriptItem::UserMsg { + seq, + text: text.to_string(), + attachments: Vec::new(), + } + } else { + TranscriptItem::AssistantMsg { + seq, + text: text.to_string(), + settled: true, + } + }) +} + +fn synthetic_rows() -> Vec { + vec![ + msg( + 1, + true, + "Can you show me a **bold** word, some *italic* text, and `inline code`?", + ), + msg( + 2, + false, + "# Sure\n\nHere's a [link to the repo](https://example.com/ai-app-2) and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```", + ), + FoldedRow::Tools(vec![ + TranscriptItem::ToolRun { + seq: 3, + id: "t1".into(), + run_id: "run1".into(), + tool: "Read".into(), + input: "{\"file\": \"src/main.rs\"}".into(), + output: "fn main() {}\n".into(), + done: true, + asks: Vec::new(), + images: Vec::new(), + }, + TranscriptItem::ToolRun { + seq: 4, + id: "t2".into(), + run_id: "run1".into(), + tool: "Edit".into(), + input: "{\"file\": \"src/main.rs\"}".into(), + output: "ok".into(), + done: true, + asks: Vec::new(), + images: Vec::new(), + }, + TranscriptItem::ToolRun { + seq: 5, + id: "t3".into(), + run_id: "run1".into(), + tool: "Bash".into(), + input: "cargo build".into(), + output: "Compiling...\nFinished.".into(), + done: true, + asks: Vec::new(), + images: Vec::new(), + }, + ]), + msg(6, true, "Looks good, thanks!"), + msg( + 7, + false, + "You're welcome. Let me know if you'd like anything else.", + ), + ] +} + +impl DefaultAppState for Client { + fn new( + mut ui_state: DefaultUiState, + rsc: &mut DefaultRsc, + _: Proxy, + ) -> Self { + let screen = transcript_ui::build(rsc, &mut ui_state, synthetic_rows()); + // Exercises `push_row`/`ItemKey` beyond construction time, matching + // how a live SSE loop appends -- a row arriving after the screen + // already exists must land at the bottom without disturbing what's + // above it (I3's `push_back`/`snap_end`). + screen.push_row( + rsc, + &FoldedRow::Single(TranscriptItem::CommandRow { + seq: 8, + text: "clear".into(), + }), + ); + Self { ui_state, screen } + } +} diff --git a/iris/transcript-ui/src/composer.rs b/iris/transcript-ui/src/composer.rs new file mode 100644 index 0000000..f661f05 --- /dev/null +++ b/iris/transcript-ui/src/composer.rs @@ -0,0 +1,48 @@ +//! The message composer at the bottom of the transcript screen: a +//! multi-line editable field with a natural (not fixed) height, so it +//! grows as typed into -- IRIS_TODO.md's "input box" benchmark case +//! (`iris/benches/message_list.rs` exercises the mechanism in isolation; +//! this wires the same `TextEdit`-with-no-`Sized`-wrapper idiom into the +//! real screen). `lib.rs` gives the transcript `List` `.height(rest(1))` +//! beside this widget in a `Span::down`, so the list's own draw already +//! measures whatever vertical space is left each frame -- nothing here +//! computes a height by hand, and growing this field is exactly the +//! O(1)-move-chain case LAYOUT.md and I3's benchmark already measured. + +use iris::prelude::*; + +/// `field` is exposed so the caller can read its content on submit +/// (`field.edit(rsc).text()`) and clear it afterward +/// (`field.edit(rsc).set("")`). +pub struct Composer { + pub field: WeakWidget, +} + +/// Returns the composer plus its own bar as a **weak** id -- the caller +/// (`lib.rs::build`) embeds it in the screen's own top-level tuple, whose +/// `set_root` performs the one real strong registration. Calling +/// `.add_strong`/`.upgrade` a second time on an id already strong-owned +/// panics ("was already added", `core/src/widget/like.rs:12`) -- the same +/// mistake this box's `row.rs` first made with its sender-label header, see +/// that file's comment for the fuller account. +pub fn build_composer(rsc: &mut Rsc) -> (Composer, WeakWidget) +where + Rsc::State: FocusHost, +{ + let field = wtext("") + .editable(EditMode::MultiLine) + .text_align(Align::LEFT) + .wrap(true) + .size(18) + .color(UiColor::WHITE) + .attr::(()) + .label("Message") + .add(rsc); + + let bar: WeakWidget = (field.pad(12).width(rest(1)),) + .span(Dir::RIGHT) + .background(rect(UiColor::new(40, 40, 46, 255))) + .add(rsc); + + (Composer { field }, bar) +} diff --git a/iris/transcript-ui/src/lib.rs b/iris/transcript-ui/src/lib.rs new file mode 100644 index 0000000..dfe4809 --- /dev/null +++ b/iris/transcript-ui/src/lib.rs @@ -0,0 +1,123 @@ +//! The transcript screen, in iris -- RUST.md's I5. Built the same way +//! `tabs-ui` is: its own crate, generic over `Rsc: HasEvents` + +//! `Rsc::State: FocusHost`, so the winit example (`iris/examples/ +//! transcript.rs`) and an eventual `iris-android-app`-style cdylib call the +//! same [`build`]. See RUST.md's I5 box for the full account of what is +//! and is not proved yet, and this doc for the shape. +//! +//! ```text +//! +------------------------------------------+ +//! | iris::widget::List (transcript_ui::row) | <- .height(rest(1)) +//! | row 1: sender label + one TextEdit | +//! | row 2: sender label + one TextEdit | +//! | row 3 (Tools): collapsed/expanded | +//! | ... | +//! +------------------------------------------+ +//! | composer bar (transcript_ui::composer) | <- natural height +//! +------------------------------------------+ +//! ``` +//! +//! **What this crate does not do itself**: fetch anything over the network +//! or read the transcript cache. [`build`] takes an already-folded +//! `Vec` and +//! [`TranscriptScreen::push_row`] takes one more as it arrives -- the +//! caller (an app's own `main`, or a future `iris-android-app`-shaped +//! cdylib) owns `client_core::ApiClient`/ +//! `event_stream::follow_session_events` and the transcript cache, per the +//! code rules' "ask for the least you need": a widget-tree builder that +//! also knew how to make an HTTPS request would be untestable without a +//! server and unable to be driven by `run-headless.sh` with synthetic rows. +//! +//! **Known gap, real and diagnosed rather than untested**: a touch-drag +//! that starts on a row's rendered text currently begins a cross-row +//! *selection* (`row.rs`'s `CursorSense::click_or_drag()` on each row's +//! `TextEdit`), not a *scroll* of the list -- both want the same gesture +//! over the same screen region, and `core/src/sense.rs`'s `run_sensors` +//! gives the widget that registered it in the *inner* layer (a row's own +//! `TextEdit`, which calls `painter.child_layer()`, +//! `iris/src/widget/text/edit.rs:87`) first refusal every frame it is +//! pressed, not just the frame the press started. `List` itself scrolls +//! correctly when driven programmatically (I3's benchmark) and via the +//! mouse wheel (wired below, `CursorSense::Scroll`), but a real touch pan +//! starting on top of a message's own text is not currently reachable -- +//! see IRIS_TODO.md's dated entry and RUST.md's I5 box for what a fix +//! looks like (a press distance/time arbiter deciding pan vs. select +//! before either commits). + +pub mod composer; +pub mod markdown; +pub mod row; +pub mod selection; + +use client_core::transcript_fold::TranscriptRow as FoldedRow; +use iris::prelude::*; +use selection::Selection; +use std::{cell::RefCell, rc::Rc}; + +pub struct TranscriptScreen { + /// The transcript's own `List` -- exposed so a caller can read + /// `.extent()`/call `.jump_to_end()` etc. directly for anything this + /// crate does not already wrap. + pub list: WeakWidget, + pub composer: composer::Composer, + selection: Rc>, +} + +impl TranscriptScreen { + /// Append one more folded row at the live end of the transcript -- + /// what a caller's SSE loop or a sent message calls as new events + /// arrive. `List::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) + where + Rsc::State: FocusHost, + { + let (key, widget) = row::build_row(rsc, self.list, self.selection.clone(), row); + (self.list)(rsc).push_back(ListRow::new(key, widget)); + } + + /// The concatenated text of whatever is currently selected across one + /// or more rows, `None` if nothing is -- what a copy command reads. + pub fn selected_text(&self, rsc: &mut impl UiRsc) -> Option { + self.selection.borrow().selected_text(rsc) + } +} + +pub fn build( + rsc: &mut Rsc, + ui_state: &mut impl HasRoot, + rows: Vec, +) -> TranscriptScreen +where + Rsc::State: FocusHost, +{ + let selection = Rc::new(RefCell::new(Selection::new())); + let list = List::new(Axis::Y).add(rsc); + + for row in &rows { + let (key, widget) = row::build_row(rsc, list, selection.clone(), row); + list(rsc).push_back(ListRow::new(key, widget)); + } + + // Wheel/trackpad scrolling -- the same idiom `trait_fns.rs`'s + // `scrollable()` uses for `Scroll`, applied directly to `List` since + // `List` already does its own placement and needs no `Scroll` wrapper. + // Real touch-drag panning is the known gap in this module's doc. + list.on(CursorSense::Scroll, |ctx, rsc| { + let delta = ctx.data.scroll_delta.y * 50.0; + ctx.widget(rsc).scroll(delta); + }) + .add(rsc); + + let (composer, composer_bar) = composer::build_composer(rsc); + + (list.width(rest(1)).height(rest(1)), composer_bar) + .span(Dir::DOWN) + .set_root(rsc, ui_state); + + TranscriptScreen { + list, + composer, + selection, + } +} diff --git a/iris/transcript-ui/src/markdown.rs b/iris/transcript-ui/src/markdown.rs new file mode 100644 index 0000000..51bfece --- /dev/null +++ b/iris/transcript-ui/src/markdown.rs @@ -0,0 +1,223 @@ +//! Markdown -> one plain string plus a `Vec`, for I5's row +//! builder to hand to a single `TextEdit` (`row.rs`). This is the crate's +//! answer to RUST.md's E2 finding against Masonry ("rich inline text -- +//! block-level yes, inline no, and both for the same reason": `TextArea`'s +//! `StyleSet` is one style for the whole editor, +//! `masonry/src/widgets/text_area.rs:43-44`'s `// TODO: RichTextInput` +//! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`, added for +//! this box) is per-range, so bold/italic/inline-code/links/headings inside +//! one wrapped paragraph render in their own style *and* the paragraph +//! still wraps and selects as one buffer -- there is no second widget per +//! span the way E2's block-level `Prose`-per-heading was. +//! +//! **What this deliberately does not attempt**, each for a reason recorded +//! here rather than silently dropped (see IRIS_TODO.md's dated entries for +//! the same list): +//! - **No background chip behind inline code.** Drawing one needs the +//! glyph run's own geometry (the way `TextEdit::draw`'s selection +//! highlight uses `selection.geometry(layout)`, +//! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal and +//! not exposed to a caller building spans externally. `SpanStyle` gives +//! the code range a monospace family and a dimmer text colour instead -- +//! visually distinct, just not chip-shaped. +//! - **A link is styled (colour + underline) but not tappable.** Following +//! it needs the same kind of per-range hit-testing a chip's background +//! would (which byte range did the tap land in, then look up its URL), +//! which is exactly the same missing primitive. +//! - **Tables render as plain paragraphs of their cell text**, no columns. +//! `pulldown_cmark::Tag::Table` is walked but not laid out -- a real grid +//! needs its own widget, out of scope for a row builder. +//! - **A fenced code block's language is not syntax-highlighted.** +//! `client-core::highlight` exists and could feed per-token `SpanStyle`s, +//! but wiring it in is real work belonging to whoever needs it next +//! (IRIS_TODO.md). +//! +//! A heading's `SpanStyle::font_size` override does not also raise its +//! `line_height` (a buffer has one, set from the *base* font size in +//! `TextAttrs`), so a heading's own line looks slightly tighter than a +//! paragraph's -- visible, not incorrect, and not fixed here since it needs +//! `SpanStyle` to carry line-height too, which nothing in this crate needed +//! badly enough yet to justify. + +use iris::prelude::*; +use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; + +// `UiColor` is `Color` (`core/src/lib.rs`), not the 0..1 float triples +// its brighter/darker helpers might suggest -- these are plain 0..255 RGB. +pub const CODE_COLOR: UiColor = UiColor::new(140, 217, 242, 255); +pub const LINK_COLOR: UiColor = UiColor::new(140, 190, 255, 255); +const STRIKETHROUGH_COLOR: UiColor = UiColor::new(150, 150, 150, 255); + +/// A block-level separator: two blocks never run into each other with no +/// gap, but an empty `out` (the very first block) gets no leading blank. +fn ensure_blank_line(out: &mut String) { + if !out.is_empty() && !out.ends_with("\n\n") { + out.push_str("\n\n"); + } +} + +fn heading_size(level: HeadingLevel) -> f32 { + match level { + HeadingLevel::H1 => 28.0, + HeadingLevel::H2 => 24.0, + HeadingLevel::H3 => 21.0, + _ => 19.0, + } +} + +/// One markdown source string rendered into plain text plus the spans that +/// style it. `base_size` is the row's ordinary paragraph font size, needed +/// only so a heading's override is relative to it rather than a hardcoded +/// absolute the caller cannot retune. +pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec) { + let _ = base_size; // headings use fixed sizes today; kept for callers that may want relative sizing later + let mut out = String::new(); + let mut spans = Vec::new(); + // Stack of start byte offsets for whatever inline/block styling is + // currently open -- pulldown-cmark's `Start`/`End` events are always + // balanced and each `End` already names its own kind (`TagEnd`), so a + // plain offset stack (rather than a tree, or repeating the kind here + // too) is enough. + let mut open: Vec = Vec::new(); + let mut list_depth: u32 = 0; + + let parser = Parser::new_ext(src, Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES); + for event in parser { + match event { + Event::Start(tag) => match tag { + Tag::Heading { .. } + | Tag::Emphasis + | Tag::Strong + | Tag::Strikethrough + | Tag::Link { .. } => open.push(out.len()), + Tag::CodeBlock(_) => { + ensure_blank_line(&mut out); + open.push(out.len()); + } + Tag::Item => { + out.push_str(&" ".repeat(list_depth.saturating_sub(1) as usize)); + out.push_str("\u{2022} "); + } + Tag::List(_) => list_depth += 1, + Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out), + _ => {} + }, + // Only the tag kinds that pushed onto `open` (Start, above) are + // popped here -- `List`/`Item`/`Paragraph`/`BlockQuote`/`Table` + // and friends push nothing, since they need no span, and must + // not touch this stack or they would pop an unrelated styled + // range still open around them. + Event::End( + tag_end @ (TagEnd::Heading(_) + | TagEnd::Emphasis + | TagEnd::Strong + | TagEnd::Strikethrough + | TagEnd::Link + | TagEnd::CodeBlock), + ) => { + let Some(start) = open.pop() else { + continue; + }; + let range = start..out.len(); + if range.is_empty() { + continue; + } + match tag_end { + TagEnd::Heading(level) => { + spans.push(SpanStyle::new(range).font_size(heading_size(level)).bold()); + } + TagEnd::Emphasis => spans.push(SpanStyle::new(range).italic()), + TagEnd::Strong => spans.push(SpanStyle::new(range).bold()), + TagEnd::Strikethrough => { + spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR)); + } + TagEnd::Link => { + spans.push(SpanStyle::new(range).color(LINK_COLOR).underline()); + } + TagEnd::CodeBlock => { + spans.push( + SpanStyle::new(range) + .family(Family::Monospace) + .color(CODE_COLOR), + ); + } + _ => unreachable!("filtered by the outer match arm"), + } + } + Event::Text(text) => out.push_str(&text), + // Inline code (single backticks) is one atomic event with no + // `Start`/`End` pair of its own, unlike a fenced block -- so it + // is spanned directly here instead of through the `open` stack. + Event::Code(text) => { + let start = out.len(); + out.push_str(&text); + spans.push( + SpanStyle::new(start..out.len()) + .family(Family::Monospace) + .color(CODE_COLOR), + ); + } + Event::SoftBreak => out.push(' '), + Event::HardBreak => out.push('\n'), + Event::Rule => { + if !out.ends_with('\n') { + out.push('\n'); + } + out.push_str("\u{2500}\u{2500}\u{2500}\n"); + } + Event::End(TagEnd::List(_)) => list_depth = list_depth.saturating_sub(1), + _ => {} + } + } + (out, spans) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_paragraph_has_no_spans() { + let (text, spans) = render_markdown("just some words", 16.0); + assert_eq!(text, "just some words"); + assert!(spans.is_empty()); + } + + #[test] + fn bold_and_italic_produce_spans_over_the_right_range() { + let (text, spans) = render_markdown("a **bold** and *italic* word", 16.0); + assert_eq!(text, "a bold and italic word"); + let bold = spans.iter().find(|s| s.bold && !s.italic).unwrap(); + assert_eq!(&text[bold.range.clone()], "bold"); + let italic = spans.iter().find(|s| s.italic).unwrap(); + assert_eq!(&text[italic.range.clone()], "italic"); + } + + #[test] + fn heading_gets_a_bigger_font_size_span() { + let (text, spans) = render_markdown("# A Title\n\nbody text", 16.0); + assert!(text.starts_with("A Title")); + let heading = spans.iter().find(|s| s.font_size.is_some()).unwrap(); + assert_eq!(&text[heading.range.clone()], "A Title"); + assert_eq!(heading.font_size, Some(28.0)); + } + + #[test] + fn link_is_styled_and_keeps_its_visible_text() { + let (text, spans) = render_markdown("see [the docs](https://example.com) for more", 16.0); + assert!(text.contains("the docs")); + assert!( + !text.contains("example.com"), + "the URL should not leak into the visible text" + ); + let link = spans.iter().find(|s| s.underline).unwrap(); + assert_eq!(&text[link.range.clone()], "the docs"); + } + + #[test] + fn fenced_code_block_is_monospaced() { + let (text, spans) = render_markdown("before\n\n```\nlet x = 1;\n```\n\nafter", 16.0); + let code = spans.iter().find(|s| s.family.is_some()).unwrap(); + assert!(text[code.range.clone()].contains("let x = 1;")); + } +} diff --git a/iris/transcript-ui/src/row.rs b/iris/transcript-ui/src/row.rs new file mode 100644 index 0000000..a72efaa --- /dev/null +++ b/iris/transcript-ui/src/row.rs @@ -0,0 +1,289 @@ +//! One `iris::widget::list::ListRow` per folded transcript row +//! (`client_core::transcript_fold::TranscriptRow`). Each row's whole text +//! -- headings, paragraphs, inline styling -- goes through `markdown` into +//! **one** `TextEdit`, which is what makes it one thing `Selection` +//! (`selection.rs`) can select and what lets it wrap and scroll as a +//! single buffer, matching RUST.md's "hard to get back" behaviour 2 (rich +//! inline text) and half of behaviour 1 (selectable within a row; across +//! rows is `selection.rs`'s job). +//! +//! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by +//! `client_core::transcript_fold::group_tool_runs`) is the row that proves +//! behaviour 3's "hold the edge nearest the tap" on expand: tapping its +//! header calls `List::note_tap` at the row's own on-screen position +//! (read back from `List::extent`, since the tap event only knows its +//! position *within* this row) before toggling a `WidgetPtr` between the +//! collapsed summary and the full detail -- the same two-step contract +//! `list.rs`'s module doc describes for `AGENTS.md`'s `holdTopEdge`. + +use crate::markdown::render_markdown; +use crate::selection::Selection; +use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow}; +use iris::prelude::*; +use std::{cell::RefCell, rc::Rc}; + +/// The paragraph size every row's `TextEdit` is built at; markdown headings +/// inside a row scale relative to a fixed set of sizes rather than this one +/// (`markdown::heading_size`), since a heading is meant to look the same +/// regardless of which row's base size surrounds it. +pub const BASE_SIZE: f32 = 16.0; + +/// `ItemKey::Seq` already is the `RowKey` (`u64`) this crate's `List` 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: &client_core::transcript_fold::ItemKey) -> RowKey { + use client_core::transcript_fold::ItemKey; + use std::hash::{Hash, Hasher}; + match key { + ItemKey::Seq(seq) => *seq, + ItemKey::RunId(id) => { + let mut h = std::collections::hash_map::DefaultHasher::new(); + id.hash(&mut h); + h.finish() | (1 << 63) + } + } +} + +/// The sender label shown above a row's text, and the markdown source to +/// render below it. `None` for a system-style note that has no sender. +fn item_content(item: &TranscriptItem) -> (Option<&str>, String) { + match item { + TranscriptItem::UserMsg { text, .. } => (Some("You"), text.clone()), + TranscriptItem::AssistantMsg { text, .. } => (Some("Claude"), text.clone()), + TranscriptItem::ErrorMsg { message, .. } => (Some("Error"), message.clone()), + TranscriptItem::CommandRow { text, .. } => (Some("Command"), format!("`/{text}`")), + TranscriptItem::PeerNote { from, text, .. } => (Some(from.as_str()), text.clone()), + TranscriptItem::Note { text, .. } => (None, text.clone()), + TranscriptItem::ClearedNote { .. } => (None, "_Context cleared._".to_string()), + TranscriptItem::CompactedNote { + pre_tokens, + post_tokens, + .. + } => ( + None, + match (pre_tokens, post_tokens) { + (Some(pre), Some(post)) => format!("_Compacted: {pre} -> {post} tokens._"), + _ => "_Compacted._".to_string(), + }, + ), + TranscriptItem::ImageItem { r#ref, .. } => (None, format!("_[image: {ref}]_")), + TranscriptItem::QuestionCard(card) => (Some("Question"), question_markdown(card)), + TranscriptItem::ToolRun { + tool, + input, + output, + .. + } => (Some(tool.as_str()), tool_call_markdown(tool, input, output)), + } +} + +fn question_markdown(card: &QuestionCard) -> String { + let mut out = card.prompt.clone(); + for opt in &card.options { + out.push_str(&format!("\n- {}", opt.label)); + } + out +} + +fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String { + let mut out = format!("**{tool}**\n\n```\n{input}\n```"); + if !output.is_empty() { + out.push_str(&format!("\n\n```\n{output}\n```")); + } + out +} + +/// Build one `TextEdit` from a sender label plus markdown source, register +/// it with `selection` under `key`, and wire the pointer handlers that +/// drive `Selection::begin`/`extend` -- shared by every row variant below, +/// since a selectable row is always "one TextEdit plus this wiring" +/// regardless of what folded it. +fn build_text_row( + rsc: &mut Rsc, + selection: Rc>, + key: RowKey, + sender: Option<&str>, + markdown_src: &str, +) -> StrongWidget +where + Rsc::State: FocusHost, +{ + let (text, spans) = render_markdown(markdown_src, BASE_SIZE); + let field = wtext(text) + .spans(spans) + .editable(EditMode::MultiLine) + .text_align(Align::LEFT) + .wrap(true) + .size(BASE_SIZE) + .color(UiColor::WHITE) + .add(rsc); + selection.borrow_mut().register(key, field); + + field + .on(CursorSense::click_or_drag(), move |ctx, rsc| { + let sel = selection.clone(); + match ctx.data.sense { + CursorSense::PressStart(_) => { + sel.borrow_mut() + .begin(rsc, key, ctx.data.pos, ctx.data.size) + } + _ => sel + .borrow_mut() + .extend(rsc, key, ctx.data.pos, ctx.data.size), + } + }) + .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. + let header: WeakWidget = match sender { + Some(name) => wtext(name.to_string()) + .size(13.0) + .color(UiColor::new(150, 150, 160, 255)) + .add(rsc), + None => Span::empty(Dir::DOWN).add(rsc), + }; + + (header, field.width(rest(1))) + .span(Dir::DOWN) + .gap(4) + .pad(10) + .add_strong(rsc) + .any() +} + +fn build_single( + rsc: &mut Rsc, + selection: Rc>, + key: RowKey, + item: &TranscriptItem, +) -> StrongWidget +where + Rsc::State: FocusHost, +{ + let (sender, markdown_src) = item_content(item); + build_text_row(rsc, selection, key, sender, &markdown_src) +} + +/// A run of adjacent tool calls: collapsed to a one-line summary by +/// default, expanding in place to every call's own tool/input/output on +/// tap -- see the module doc for the hold-the-edge contract this wires +/// against `list`. +fn build_tools( + rsc: &mut Rsc, + list: WeakWidget, + selection: Rc>, + key: RowKey, + calls: Vec, +) -> StrongWidget +where + Rsc::State: FocusHost, +{ + let expanded = Rc::new(RefCell::new(false)); + // `.add_strong` (not `.add`) because nothing else in the tree holds a + // strong reference to this `WidgetPtr` the way a container's own + // `add_strong`-on-its-children does for an ordinary child -- this row + // *is* the top of its own subtree, so it has to own itself. + let ptr_strong = WidgetPtr::new().add_strong(rsc); + let ptr = ptr_strong.weak(); + + let summary_text = format!("\u{25b8} {} tool calls", calls.len()); + let full_text = calls + .iter() + .map(|c| match c { + TranscriptItem::ToolRun { + tool, + input, + output, + .. + } => tool_call_markdown(tool, input, output), + other => item_content(other).1, + }) + .collect::>() + .join("\n\n"); + + fn build_content( + rsc: &mut Rsc, + selection: Rc>, + key: RowKey, + expanded: bool, + summary: &str, + full: &str, + ) -> StrongWidget + where + Rsc::State: FocusHost, + { + let text = if expanded { full } else { summary }; + build_text_row(rsc, selection, key, Some("Tools"), text) + } + + let content = build_content( + rsc, + selection.clone(), + key, + false, + &summary_text, + &full_text, + ); + ptr(rsc).set(content); + + ptr.on(CursorSense::click(), move |ctx, rsc| { + // `List::note_tap` wants a viewport-relative position, but the + // click event only knows where inside *this row* it landed + // (`ctx.data.pos`) -- `List::extent` (last frame's on-screen box + // for this row's key) is what turns the two into the position + // `list.rs`'s hold-the-edge layout pass resolves against, per the + // module doc's contract. + let (top, _bottom) = list(rsc).extent(key).unwrap_or((0.0, 0.0)); + list(rsc).note_tap(top + ctx.data.pos.y); + + let was_expanded = *expanded.borrow(); + *expanded.borrow_mut() = !was_expanded; + let content = build_content( + rsc, + selection.clone(), + key, + !was_expanded, + &summary_text, + &full_text, + ); + // The old content's `StrongWidget` is freed when this drops -- + // the removal half of the row this click just replaced. + let _old = ptr(rsc).replace(content); + }) + .add(rsc); + + ptr_strong.any() +} + +pub fn build_row( + rsc: &mut Rsc, + list: WeakWidget, + selection: Rc>, + row: &FoldedRow, +) -> (RowKey, StrongWidget) +where + Rsc::State: FocusHost, +{ + match row { + FoldedRow::Single(item) => { + let key = row_key(&item.key()); + (key, build_single(rsc, selection, key, item)) + } + FoldedRow::Tools(calls) => { + let key = row_key(&calls[0].key()); + (key, build_tools(rsc, list, selection, key, calls.clone())) + } + } +} diff --git a/iris/transcript-ui/src/selection.rs b/iris/transcript-ui/src/selection.rs new file mode 100644 index 0000000..499d0e6 --- /dev/null +++ b/iris/transcript-ui/src/selection.rs @@ -0,0 +1,227 @@ +//! Selection spanning multiple transcript rows -- RUST.md's "hard to get +//! back" behaviour 1, and the one E2 found flatly impossible on Masonry: +//! `TextArea` wraps exactly one `parley::PlainEditor`, and there is no +//! `SelectionContainer`-shaped type anywhere in `masonry`/`masonry_core`/ +//! `xilem` (RUST.md's E2 box, citing +//! `masonry/src/widgets/text_area.rs:414-459`). Each transcript row here is +//! still its own `TextEdit` (one per row, not one per transcript, since a +//! row is what `List` virtualises), so this is not literally "one +//! `PlainEditor`" either -- iris's answer is a coordinator that drives each +//! visible row's *own* selection primitives (`TextEditCtx::select`/ +//! `select_all`/`deselect`, already built for a single field) from one +//! pointer drag that crosses row boundaries, giving the same reader-facing +//! result (a selection that runs from a reply into the tool output beneath +//! it, one copy) without needing a single shared text buffer underneath. +//! +//! Rows are keyed by `RowKey` (`iris::widget::list`), which every real row +//! source (a transcript's sequence number) already assigns in the order the +//! reader reads them in -- so "between the anchor and the current row" is +//! answered by ordinary integer comparison via a `BTreeMap`, not a second +//! copy of the list's own ordering. +//! +//! **Scoped shortcut, recorded rather than hidden**: the anchor row (the +//! one the drag started in) is selected in full (`select_all`) the moment +//! the drag leaves it, rather than "from the click point to whichever edge +//! points away from the drag" -- the exact partial selection would need +//! that row's own laid-out size, which `TextEditCtx` does not expose to a +//! caller outside `iris::widget::text` (`edit.rs`'s `layout()` helper is +//! private). Only the row currently *under the pointer* gets a true partial +//! selection (from its own start or end, per direction, to the pointer's +//! exact point) -- see `extend`. Re-entering the anchor row is still exact, +//! since that branch never goes through the approximation. + +use iris::prelude::*; +use std::collections::BTreeMap; + +pub struct Selection { + rows: BTreeMap>, + anchor: Option<(RowKey, Vec2)>, +} + +impl Default for Selection { + fn default() -> Self { + Self::new() + } +} + +impl Selection { + pub fn new() -> Self { + Self { + rows: BTreeMap::new(), + anchor: None, + } + } + + /// A row's selectable text became visible/known. Every addition here + /// needs its removal (`unregister`) -- called when `List` evicts the + /// row (`pop_front`/`pop_back`), so this map never outgrows however + /// many rows are actually loaded. + pub fn register(&mut self, key: RowKey, text: WeakWidget) { + self.rows.insert(key, text); + } + + pub fn unregister(&mut self, key: RowKey) { + self.rows.remove(&key); + if self.anchor.map(|(k, _)| k) == Some(key) { + self.anchor = None; + } + } + + /// A fresh press: clears whatever was selected elsewhere (an ordinary + /// click starts a new selection, it does not extend the old one) and + /// gives `key`'s row a collapsed caret at `pos` -- a plain click that + /// never turns into a drag leaves exactly this and nothing else + /// selected. + pub fn begin(&mut self, ui: &mut impl UiRsc, key: RowKey, pos: Vec2, size: Vec2) { + let rows: Vec = self.rows.keys().copied().collect(); + for k in rows { + if k != key + && let Some(w) = self.rows.get(&k) + { + w.edit(ui).deselect(); + } + } + if let Some(w) = self.rows.get(&key) { + w.edit(ui).select(pos, size, false, false); + } + self.anchor = Some((key, pos)); + } + + /// The drag continues, now over `key`'s row at `pos`. See the module + /// doc for the anchor-row shortcut. + pub fn extend(&mut self, ui: &mut impl UiRsc, key: RowKey, pos: Vec2, size: Vec2) { + let Some((anchor_key, _anchor_pos)) = self.anchor else { + return; + }; + if key == anchor_key { + if let Some(w) = self.rows.get(&key) { + w.edit(ui).select(pos, size, true, false); + } + return; + } + let (lo, hi) = if anchor_key < key { + (anchor_key, key) + } else { + (key, anchor_key) + }; + let in_range: Vec = self.rows.range(lo..=hi).map(|(&k, _)| k).collect(); + for k in &in_range { + let Some(w) = self.rows.get(k).copied() else { + continue; + }; + if *k == key { + // The row under the pointer: partial selection from + // whichever of its own edges faces the anchor, extended to + // the exact pointer point. + let start = if key > anchor_key { Vec2::ZERO } else { size }; + w.edit(ui).select(start, size, false, false); + w.edit(ui).select(pos, size, true, false); + } else { + w.edit(ui).select_all(); + } + } + let outside: Vec = self + .rows + .keys() + .copied() + .filter(|k| *k < lo || *k > hi) + .collect(); + for k in outside { + if let Some(w) = self.rows.get(&k) { + w.edit(ui).deselect(); + } + } + } + + /// The concatenated selected text, in row order, `None` if nothing is + /// selected -- what a copy command reads. Joins with a blank line + /// between rows, matching how the transcript itself separates them. + pub fn selected_text(&self, ui: &mut impl UiRsc) -> Option { + let mut parts = Vec::new(); + for w in self.rows.values() { + if let Some(text) = w.edit(ui).text.selected_text() { + parts.push(text); + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("\n\n")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Pure range-membership logic, independent of any widget/render + // machinery (the same reasoning `begin`/`extend` apply per-row) -- + // exercised directly so the "which rows fall between anchor and + // current" arithmetic has a test that needs no `UiRenderState`. + fn in_range(anchor: RowKey, current: RowKey, keys: &[RowKey]) -> Vec { + let (lo, hi) = if anchor < current { + (anchor, current) + } else { + (current, anchor) + }; + keys.iter() + .copied() + .filter(|k| *k >= lo && *k <= hi) + .collect() + } + + #[test] + fn selection_spans_forward_across_rows() { + let keys = [1, 2, 3, 4, 5]; + assert_eq!(in_range(2, 4, &keys), vec![2, 3, 4]); + } + + #[test] + fn selection_spans_backward_across_rows() { + let keys = [1, 2, 3, 4, 5]; + assert_eq!(in_range(4, 2, &keys), vec![2, 3, 4]); + } + + #[test] + fn selection_within_one_row_is_just_that_row() { + let keys = [1, 2, 3]; + assert_eq!(in_range(2, 2, &keys), vec![2]); + } + + struct TestRsc { + ui: UiData, + } + impl UiRsc for TestRsc { + fn ui(&self) -> &UiData { + &self.ui + } + fn ui_mut(&mut self) -> &mut UiData { + &mut self.ui + } + } + + #[test] + fn unregister_forgets_the_row_and_clears_a_matching_anchor() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let field = rsc + .ui + .widgets + .add_strong(TextEdit::new( + TextView::new(TextBuffer::new_empty(), TextAttrs::default(), None), + EditMode::MultiLine, + )) + .weak(); + + let mut sel = Selection::new(); + sel.register(5, field); + sel.anchor = Some((5, Vec2::ZERO)); + assert_eq!(sel.rows.len(), 1); + + sel.unregister(5); + assert!(sel.rows.is_empty()); + assert!(sel.anchor.is_none()); + } +}