From 64f64b54e55bcb0fb304be4c8d9db7c466e3afcc Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sun, 6 Sep 2026 18:55:46 -0400 Subject: [PATCH] iris: per-block markdown appearance, syntax-highlighted fences, tappable links P1a (docs/RUST.md). A transcript row's blocks are drawn the way Markdown.kt draws them rather than as one flat span list: - transcript-ui/src/markdown.rs is a *block* renderer now. `BlockFrame` is the whole widget vocabulary -- Plain, Verbatim (a dark rounded panel that pans sideways) and Quote (a bar and an indent) -- so a new markdown feature costs spans, not widgets. `frame_of` is the one place the BlockKind -> appearance mapping is written. - Fences take `client_core::highlight`'s spans by language, in the same Catppuccin palette Theme.kt's `catppuccinSyntax()` uses, with the char->byte offset conversion the two index spaces need. - Lists get the bullet ladder and coloured markers MarkdownPieces.kt draws, ordered lists count from the number they were written with, headings take Material's own ladder (24/22/16/14/12/11). - Tables are padded monospace columns measured from the cells, with the header bold and a rule under it -- see docs/DECISIONS.md for what that trades against a real grid. - Links carry their URL through to a tap. `GestureOutcome::Tapped` is new: a press that never committed to a pan or a selection, so a finger that flung the list past a link does not also open it. `iris::platform::OpenUrl` is the capability, implemented by each backend (xdg-open/open/start on the desktop, an ACTION_VIEW intent deferred to `after_input` on Android, the same shape `pending_show_keyboard` uses). - `DragArbiter`/`DragGesture` take an axis, so a code fence pans across its own long lines through the same machine a list pans down its rows -- and a vertical drag starting on a fence still reaches the list. - `TextEditCtx::byte_at` answers which byte a tap landed on without exposing the parley layout; `Rect::radius` takes a `Len`, so a corner can be written in dp. Tests: 31 in transcript-ui (11 new, covering the frame mapping, highlighting including a multibyte fence and an unknown language, list markers, table padding and wrapping, link hit-testing), 85 in iris (4 new on the tap-vs-drag rule and the two axes). cargo fmt clean, clippy warning-free. Co-Authored-By: Claude Fable 5.1 --- iris/src/android/mod.rs | 1 + iris/src/android/platform.rs | 89 ++++ iris/src/android/view.rs | 8 + iris/src/default/mod.rs | 1 + iris/src/default/platform.rs | 33 ++ iris/src/lib.rs | 2 + iris/src/platform.rs | 22 + iris/src/sense.rs | 148 +++++- iris/src/widget/position/scroll.rs | 3 +- iris/src/widget/rect.rs | 19 +- iris/src/widget/text/edit.rs | 23 + iris/src/widget/trait_fns.rs | 15 +- iris/transcript-ui/src/lib.rs | 22 +- iris/transcript-ui/src/markdown.rs | 729 ++++++++++++++++++++++++---- iris/transcript-ui/src/row.rs | 175 +++++-- iris/transcript-ui/src/selection.rs | 12 +- 16 files changed, 1141 insertions(+), 161 deletions(-) create mode 100644 iris/src/android/platform.rs create mode 100644 iris/src/default/platform.rs create mode 100644 iris/src/platform.rs diff --git a/iris/src/android/mod.rs b/iris/src/android/mod.rs index a347426..2bc7392 100644 --- a/iris/src/android/mod.rs +++ b/iris/src/android/mod.rs @@ -17,6 +17,7 @@ mod attr; mod ime; mod input; mod insets; +mod platform; mod render; mod view; diff --git a/iris/src/android/platform.rs b/iris/src/android/platform.rs new file mode 100644 index 0000000..8f72fba --- /dev/null +++ b/iris/src/android/platform.rs @@ -0,0 +1,89 @@ +use crate::platform::OpenUrl; +use android_view::{ + View, + jni::{ + JNIEnv, + objects::{JObject, JValue}, + }, +}; + +use super::view::HasAndroidUiState; + +/// Android's URL opener. Like `FocusHost::focus_gained`'s keyboard, the +/// real work is a JNI call and this runs deep inside the sensor dispatch +/// with no `CallbackCtx` in reach -- so it raises a flag that +/// `IrisViewPeer::after_input` consumes, exactly as +/// `pending_show_keyboard` does. +/// +/// Last request wins: two links cannot be tapped in one frame, and a URL +/// left queued from a frame that somehow never reached `after_input` +/// would open at some unrelated later tap, which is worse than dropping +/// it. +impl OpenUrl for T { + fn open_url(&mut self, url: &str) { + self.android_state_mut().pending_open_url = Some(url.to_string()); + } +} + +/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the view's +/// own context. +/// +/// `FLAG_ACTIVITY_NEW_TASK` because the context here is the view's, which +/// may be an application context rather than the activity's -- Android +/// throws `AndroidRuntimeException` for a non-activity context without it, +/// and it is harmless when the context *is* an activity's. +/// +/// Every failure is logged with the URL and returns; there is nothing to +/// fall back to, and the reader will see that nothing happened. +pub(super) fn open_url<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, url: &str) { + match try_open_url(env, view, url) { + Ok(()) => {} + Err(e) => { + // A pending Java exception makes every later JNI call fail in + // ways nowhere near here, so it is cleared at the boundary. + let _ = env.exception_clear(); + log::warn!("could not open {url}: {e}"); + } + } +} + +fn try_open_url<'local>( + env: &mut JNIEnv<'local>, + view: &View<'local>, + url: &str, +) -> Result<(), android_view::jni::errors::Error> { + let context = env + .call_method(&view.0, "getContext", "()Landroid/content/Context;", &[])? + .l()?; + let jurl = env.new_string(url)?; + let uri = env.call_static_method( + "android/net/Uri", + "parse", + "(Ljava/lang/String;)Landroid/net/Uri;", + &[JValue::Object(jurl.as_ref())], + )?; + let action = env.new_string("android.intent.action.VIEW")?; + let intent = env.new_object( + "android/content/Intent", + "(Ljava/lang/String;Landroid/net/Uri;)V", + &[JValue::Object(action.as_ref()), JValue::Object(&uri.l()?)], + )?; + env.call_method( + &intent, + "addFlags", + "(I)Landroid/content/Intent;", + &[JValue::Int(FLAG_ACTIVITY_NEW_TASK)], + )?; + env.call_method( + &context, + "startActivity", + "(Landroid/content/Intent;)V", + &[JValue::Object(&JObject::from(intent))], + )?; + Ok(()) +} + +/// `android.content.Intent.FLAG_ACTIVITY_NEW_TASK`. A constant rather than +/// a static-field read: it is part of the platform's stable ABI and +/// reading it costs two more JNI calls that can each fail. +const FLAG_ACTIVITY_NEW_TASK: i32 = 0x1000_0000; diff --git a/iris/src/android/view.rs b/iris/src/android/view.rs index c743b9a..57059bd 100644 --- a/iris/src/android/view.rs +++ b/iris/src/android/view.rs @@ -54,6 +54,10 @@ pub struct AndroidUiState { /// inside the platform-agnostic sensor dispatch with no `CallbackCtx` /// in reach. 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`. + 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. @@ -113,6 +117,7 @@ impl AndroidUiState { last_click: Instant::now(), compose_len: 0, pending_show_keyboard: false, + pending_open_url: None, shared, access_adapter: Default::default(), access: AccessTree::new(), @@ -324,6 +329,9 @@ impl IrisViewPeer { if std::mem::take(&mut ui_state.pending_show_keyboard) { show_soft_input(&mut ctx.env, &ctx.view); } + if let Some(url) = ui_state.pending_open_url.take() { + super::platform::open_url(&mut ctx.env, &ctx.view, &url); + } // RUST.md's P0 box, "doesn't enter it until I hit space, and also // doesn't move cursor forward": Gboard needs `updateSelection` diff --git a/iris/src/default/mod.rs b/iris/src/default/mod.rs index d50a1b6..cff1fd5 100644 --- a/iris/src/default/mod.rs +++ b/iris/src/default/mod.rs @@ -15,6 +15,7 @@ mod access; mod app; mod attr; mod input; +mod platform; mod render; pub use access::*; diff --git a/iris/src/default/platform.rs b/iris/src/default/platform.rs new file mode 100644 index 0000000..75c2e7d --- /dev/null +++ b/iris/src/default/platform.rs @@ -0,0 +1,33 @@ +use crate::platform::OpenUrl; +use crate::prelude::HasDefaultUiState; + +/// The desktop's URL opener: the platform's own "open this with whatever +/// is registered for it" command, detached so a browser starting slowly +/// cannot stall the event loop. +/// +/// A command rather than a crate: `xdg-open`/`open`/`start` is what every +/// such crate shells out to anyway, and this is one call site. +impl OpenUrl for T { + fn open_url(&mut self, url: &str) { + let (program, first): (&str, &[&str]) = if cfg!(target_os = "macos") { + ("open", &[]) + } else if cfg!(target_os = "windows") { + // `start` is a shell builtin, and its first argument is the + // window title -- an empty one, or a URL containing `&` ends + // up split. + ("cmd", &["/C", "start", ""]) + } else { + ("xdg-open", &[]) + }; + match std::process::Command::new(program) + .args(first) + .arg(url) + .spawn() + { + Ok(_) => {} + // Named with the command that failed and the link it was for, + // since neither is recoverable from the OS error alone. + Err(e) => log::warn!("could not open {url} with {program}: {e}"), + } + } +} diff --git a/iris/src/lib.rs b/iris/src/lib.rs index 7858194..1dda0fe 100644 --- a/iris/src/lib.rs +++ b/iris/src/lib.rs @@ -21,6 +21,7 @@ pub mod default; pub mod attr; pub mod event; +pub mod platform; pub mod sense; pub mod state; pub mod task; @@ -47,6 +48,7 @@ pub mod prelude { pub use event::*; pub use iris_core::*; pub use iris_macro::*; + pub use platform::*; pub use sense::*; pub use state::*; pub use task::*; diff --git a/iris/src/platform.rs b/iris/src/platform.rs new file mode 100644 index 0000000..86ec5ad --- /dev/null +++ b/iris/src/platform.rs @@ -0,0 +1,22 @@ +//! Capabilities a widget tree needs from whatever is hosting it, that +//! neither iris nor the app can perform itself. +//! +//! Same shape as [`crate::attr::FocusHost`], and for the same reason: the +//! interface is declared here, below, and implemented by each backend +//! above (`default/platform.rs`, `android/platform.rs`), so a widget can +//! ask for the capability by trait bound instead of a caller threading a +//! callback down through every builder. + +/// Hand a URL to whatever the platform opens URLs with. +/// +/// One method rather than a general "run an intent"/"exec" surface: the +/// only thing a transcript needs is to follow a link a reader tapped, and +/// a narrower capability is a narrower thing to get wrong. +/// +/// **Nothing is reported back.** There is no answer worth branching on -- +/// the platform either shows a browser or does not, and both are outside +/// this process -- so failures are logged where they happen (each impl) +/// rather than turned into a `Result` every call site would discard. +pub trait OpenUrl { + fn open_url(&mut self, url: &str); +} diff --git a/iris/src/sense.rs b/iris/src/sense.rs index 1c71c5b..04f424b 100644 --- a/iris/src/sense.rs +++ b/iris/src/sense.rs @@ -487,6 +487,12 @@ enum ArbiterState { /// caller-supplied `Instant` rather than a real clock. pub struct DragArbiter { state: ArbiterState, + /// Which way a pan runs. A transcript pans down its list and a code + /// fence pans across its own long lines, and the two decisions are + /// the same one with the axes swapped -- so the axis is a field + /// rather than a second copy of this state machine, and everything + /// below reads `along`/`across` instead of `dy`/`dx`. + axis: Axis, origin: Vec2, origin_at: Instant, last: Vec2, @@ -494,20 +500,28 @@ pub struct DragArbiter { impl Default for DragArbiter { fn default() -> Self { - Self { - state: ArbiterState::Idle, - origin: Vec2::ZERO, - origin_at: Instant::now(), - last: Vec2::ZERO, - } + Self::on(Axis::Y) } } impl DragArbiter { + /// A vertical arbiter -- what a list, and every caller before the + /// axis became a field, wants. pub fn new() -> Self { Self::default() } + /// An arbiter whose pan runs along `axis`. + pub fn on(axis: Axis) -> Self { + Self { + state: ArbiterState::Idle, + axis, + origin: Vec2::ZERO, + origin_at: Instant::now(), + last: Vec2::ZERO, + } + } + /// A fresh press-down at `pos`. `already_selected` is whatever the /// caller's selection state was *before* this press -- it decides /// whether an early horizontal move extends that selection instead of @@ -548,25 +562,25 @@ impl DragArbiter { match self.state { ArbiterState::Idle => DragOutcome::Undecided, ArbiterState::Panning => { - let dy = pos.y - self.last.y; + let along = pos.axis(self.axis) - self.last.axis(self.axis); self.last = pos; - DragOutcome::Pan(dy) + DragOutcome::Pan(along) } ArbiterState::Selecting => { self.last = pos; DragOutcome::SelectExtend } ArbiterState::Undecided { already_selected } => { - let dx = pos.x - self.origin.x; - let dy = pos.y - self.origin.y; - if already_selected && dx.abs() > DRAG_SLOP && dx.abs() > dy.abs() { + let along = pos.axis(self.axis) - self.origin.axis(self.axis); + let across = pos.axis(!self.axis) - self.origin.axis(!self.axis); + if already_selected && across.abs() > DRAG_SLOP && across.abs() > along.abs() { self.state = ArbiterState::Selecting; self.last = pos; DragOutcome::SelectExtend - } else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() { + } else if along.abs() > DRAG_SLOP && along.abs() >= across.abs() { self.state = ArbiterState::Panning; self.last = pos; - // `dy` here is the *whole* drag since `press_start`, + // `along` here is the *whole* drag since `press_start`, // not since the last frame -- nothing panned while // `Undecided` was withholding the slop, so applying it // in full on this one frame is a visible jump the @@ -584,10 +598,10 @@ impl DragArbiter { // `ViewConfiguration.getScaledTouchSlop()` once from // the first scroll past it rather than replaying the // whole pre-threshold drag in one step. - DragOutcome::Pan(dy - DRAG_SLOP.copysign(dy)) + DragOutcome::Pan(along - DRAG_SLOP.copysign(along)) } else if now.duration_since(self.origin_at) >= LONG_PRESS - && dx.abs() <= DRAG_SLOP - && dy.abs() <= DRAG_SLOP + && across.abs() <= DRAG_SLOP + && along.abs() <= DRAG_SLOP { self.state = ArbiterState::Selecting; self.last = pos; @@ -613,6 +627,15 @@ impl DragArbiter { pub fn is_panning(&self) -> bool { matches!(self.state, ArbiterState::Panning) } + + /// Whether a press is in flight that has committed to neither a pan + /// nor a selection -- what a release checks to tell a **tap** from + /// the end of a drag. A tap is exactly "pressed and let go without + /// ever deciding", so it is read here rather than timed separately: + /// one gesture machine, one answer. + pub fn is_undecided(&self) -> bool { + matches!(self.state, ArbiterState::Undecided { .. }) + } } /// What a [`DragGesture`] decided this frame -- [`DragOutcome`] plus the @@ -626,6 +649,14 @@ pub enum GestureOutcome { Pan(f32), SelectStart, SelectExtend, + /// The press ended without ever committing to a pan or a selection -- + /// a tap. Distinct from `Released(None)`, which is the end of a + /// gesture that *did* commit (a selection, or a pan too slow to + /// fling): a caller acting on a tap -- following a markdown link -- + /// must not also act when the finger was panning the list past that + /// link, which is the tap-vs-drag rule this enum exists to state + /// once for every caller rather than per widget. + Tapped, /// The drag ended -- `PressEnd` or the capture's own terminal `Drop`. /// `Some(velocity)` only if the gesture had committed to panning /// (never a tap, a long-press selection, or one still `Undecided`); @@ -661,8 +692,13 @@ impl Default for DragGesture { impl DragGesture { pub fn new() -> Self { + Self::on(Axis::Y) + } + + /// A gesture whose pan runs along `axis` -- see [`DragArbiter::on`]. + pub fn on(axis: Axis) -> Self { Self { - arbiter: DragArbiter::new(), + arbiter: DragArbiter::on(axis), velocity: VelocityTracker::new(), } } @@ -700,14 +736,16 @@ impl DragGesture { self.dispatch(render, id, pos_window, now) } CursorSense::Drop | CursorSense::PressEnd(_) => { - let released = if self.arbiter.is_panning() { - Some(self.velocity.velocity()) + let outcome = if self.arbiter.is_panning() { + GestureOutcome::Released(Some(self.velocity.velocity())) + } else if self.arbiter.is_undecided() { + GestureOutcome::Tapped } else { - None + GestureOutcome::Released(None) }; self.arbiter.release(); render.release_pointer(); - GestureOutcome::Released(released) + outcome } // See `DragArbiter::update`'s own doc: a `Pressing` frame can // arrive with no matching `PressStart` if the touch-down @@ -1292,6 +1330,74 @@ mod drag_arbiter_tests { assert!(!a.is_idle()); } + /// The tap-vs-drag rule a markdown link is followed by + /// (`transcript-ui`'s `row.rs`): a press that never committed is a + /// tap, and a press that panned or selected is not -- read from this + /// one machine rather than timed a second time beside it. + #[test] + fn a_press_that_never_moved_is_still_undecided_at_release() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), false); + a.update(Vec2::new(1.0, 1.0), t(10)); + assert!(a.is_undecided()); + assert!(!a.is_panning()); + } + + /// The half the tap rule had no reason to touch: a gesture that + /// panned must not also read as a tap when the finger comes up over + /// the link it started on. + #[test] + fn a_press_that_panned_is_not_undecided_at_release() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), false); + a.update(Vec2::new(0.0, 40.0), t(10)); + assert!(a.is_panning()); + assert!(!a.is_undecided()); + } + + /// A long press that grew a selection is not a tap either. + #[test] + fn a_long_press_that_selected_is_not_undecided() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), false); + assert_eq!( + a.update(Vec2::new(0.0, 1.0), t(LONG_PRESS.as_millis() as u64 + 10)), + DragOutcome::SelectStart + ); + assert!(!a.is_undecided()); + } + + /// Both axes are one machine with the axis passed in: a horizontal + /// arbiter (a code fence panning across its own long lines) pans on + /// exactly the drag a vertical one ignores, and ignores the one it + /// pans on. + #[test] + fn a_horizontal_arbiter_pans_on_the_drag_a_vertical_one_ignores() { + let mut across = DragArbiter::on(Axis::X); + across.press_start(Vec2::new(0.0, 0.0), t(0), false); + assert_eq!( + across.update(Vec2::new(20.0, 0.0), t(10)), + DragOutcome::Pan(12.0) + ); + + let mut down = DragArbiter::new(); + down.press_start(Vec2::new(0.0, 0.0), t(0), false); + assert_eq!( + down.update(Vec2::new(20.0, 0.0), t(10)), + DragOutcome::Undecided + ); + + // ...and a vertical drag over the horizontal one stays undecided, + // which is what lets the list behind a code fence still be + // panned by a finger that started on the fence. + let mut across = DragArbiter::on(Axis::X); + across.press_start(Vec2::new(0.0, 0.0), t(0), false); + assert_eq!( + across.update(Vec2::new(0.0, 20.0), t(10)), + DragOutcome::Undecided + ); + } + #[test] fn release_resets_to_idle() { let mut a = DragArbiter::new(); diff --git a/iris/src/widget/position/scroll.rs b/iris/src/widget/position/scroll.rs index 2b93486..725efd3 100644 --- a/iris/src/widget/position/scroll.rs +++ b/iris/src/widget/position/scroll.rs @@ -88,7 +88,7 @@ impl Scroll { snap_end: true, container_len: 0.0, content_len: 0.0, - gesture: DragGesture::new(), + gesture: DragGesture::on(axis), } } @@ -138,6 +138,7 @@ impl Scroll { // the content follows the finger. GestureOutcome::Pan(dy) => self.scroll(dy), GestureOutcome::Undecided + | GestureOutcome::Tapped | GestureOutcome::SelectStart | GestureOutcome::SelectExtend | GestureOutcome::Released(_) => {} diff --git a/iris/src/widget/rect.rs b/iris/src/widget/rect.rs index 5a0482b..77f93b7 100644 --- a/iris/src/widget/rect.rs +++ b/iris/src/widget/rect.rs @@ -3,7 +3,13 @@ use crate::prelude::*; #[derive(Clone, Copy)] pub struct Rect { pub color: UiColor, - pub radius: f32, + /// A `Len` rather than a raw `f32` so a corner can be written in `dp` + /// and come out the same physical size on every display -- resolved + /// against `Painter::density` in [`Rect::draw`], the same place every + /// other `dp` is resolved. A plain number still works and still means + /// physical pixels (`impl From for Len`), which is what + /// a hairline wants. + pub radius: Len, pub thickness: f32, pub inner_radius: f32, } @@ -12,7 +18,7 @@ impl Rect { pub fn new(color: UiColor) -> Self { Self { color, - radius: 0.0, + radius: Len::ZERO, inner_radius: 0.0, thickness: 0.0, } @@ -21,8 +27,8 @@ impl Rect { self.color = color; self } - pub fn radius(mut self, radius: impl UiNum) -> Self { - self.radius = radius.to_f32(); + pub fn radius(mut self, radius: impl Into) -> Self { + self.radius = radius.into(); self } } @@ -31,7 +37,10 @@ impl Widget for Rect { fn draw(&mut self, painter: &mut Painter) -> Size { painter.primitive(RectPrimitive { color: self.color, - radius: self.radius, + // `rel` has no meaning for a corner (a rect that fills its + // parent has no length of its own to take a fraction of), so + // only the `abs`/`dp` halves are folded. + radius: self.radius.fold_dp(painter.density()).abs, thickness: self.thickness, inner_radius: self.inner_radius, }); diff --git a/iris/src/widget/text/edit.rs b/iris/src/widget/text/edit.rs index e516272..2e30746 100644 --- a/iris/src/widget/text/edit.rs +++ b/iris/src/widget/text/edit.rs @@ -364,6 +364,29 @@ impl<'a> TextEditCtx<'a> { self.set_caret(index); } + /// The byte offset in the text that `pos` (in the same window-space + /// coordinates a `CursorSense` reports, with `size` the region the + /// event was measured against) lands on. + /// + /// The one thing a caller outside this module needs to turn a tap into + /// a *range* of the text -- which markdown link is under the finger, + /// which inline-code chip was pressed. `layout()` is private because a + /// caller holding a parley `Layout` could shape it against stale text; + /// this hands back the answer rather than the layout, and does the + /// same region-relative transform [`select`](Self::select) does, so + /// the two cannot disagree about where a point is. + /// + /// Parley clamps a point outside the laid-out text to the nearest + /// cursor position, so a tap in the field's padding answers with the + /// nearest offset rather than failing -- a caller wanting "was this + /// actually *on* something" checks its own ranges, which is what + /// makes a tap in the padding hit no link. + pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize { + let pos = pos - self.text.region().top_left().to_abs(size); + let layout = self.layout(); + Selection::from_point(layout, pos.x, pos.y).focus().index() + } + pub fn select_all(&mut self) { let len = self.text.view.buf.text().len(); if len == 0 { diff --git a/iris/src/widget/trait_fns.rs b/iris/src/widget/trait_fns.rs index 941f273..4e98630 100644 --- a/iris/src/widget/trait_fns.rs +++ b/iris/src/widget/trait_fns.rs @@ -85,10 +85,19 @@ widget_trait! { } fn scrollable(self) -> impl WidgetIdFn where Rsc: HasEvents { + self.scrollable_on(Axis::Y) + } + + // `scrollable` along `axis`. A code fence pans across its own long + // lines exactly the way a transcript pans down its rows, so the two + // are one function with the axis passed in rather than a second copy + // -- `DragArbiter::on` is the other half. (A `///` doc comment here + // is not accepted by `widget_trait!`, which parses its body itself.) + fn scrollable_on(self, axis: Axis) -> impl WidgetIdFn where Rsc: HasEvents { move |state| { - Scroll::new(self.add_strong(state), Axis::Y) - .on(CursorSense::Scroll, |ctx, rsc| { - let delta = ctx.data.scroll_delta.y * 50.0; + Scroll::new(self.add_strong(state), axis) + .on(CursorSense::Scroll, move |ctx, rsc| { + let delta = ctx.data.scroll_delta.axis(axis) * 50.0; ctx.widget(rsc).scroll(delta); }) // A finger drag, through the same `DragGesture` the diff --git a/iris/transcript-ui/src/lib.rs b/iris/transcript-ui/src/lib.rs index a80e282..7ca5fa9 100644 --- a/iris/transcript-ui/src/lib.rs +++ b/iris/transcript-ui/src/lib.rs @@ -1,6 +1,6 @@ //! 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/ +//! `Rsc::State: FocusHost + OpenUrl`, 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. @@ -83,7 +83,7 @@ impl TranscriptScreen { /// newest content when it already was (I3). pub fn push_row(&self, rsc: &mut Rsc, row: &FoldedRow) where - Rsc::State: FocusHost, + Rsc::State: FocusHost + OpenUrl, { let (key, widget, blocks) = row::build_row(rsc, self.list, self.selection.clone(), row); (self.list)(rsc).push_back(ListRow::new(key, widget)); @@ -97,7 +97,7 @@ impl TranscriptScreen { /// `Single`, or a change `RowBlocks::apply_delta` will not take. fn apply_tail_delta(&self, rsc: &mut Rsc, key: RowKey, row: &FoldedRow) -> bool where - Rsc::State: FocusHost, + Rsc::State: FocusHost + OpenUrl, { let FoldedRow::Single(item) = row else { return false; @@ -156,7 +156,7 @@ impl TranscriptScreen { old: &[client_core::transcript_fold::TranscriptItem], new: &[client_core::transcript_fold::TranscriptItem], ) where - Rsc::State: FocusHost, + Rsc::State: FocusHost + OpenUrl, { use client_core::transcript_fold::group_tool_runs; @@ -246,7 +246,7 @@ pub fn build( rows: Vec, ) -> TranscriptScreen where - Rsc::State: FocusHost, + Rsc::State: FocusHost + OpenUrl, { let (screen, tree) = build_tree(rsc, rows); ui_state.set_root(tree); @@ -265,7 +265,7 @@ pub fn build_tree( rows: Vec, ) -> (TranscriptScreen, StrongWidget) where - Rsc::State: FocusHost, + Rsc::State: FocusHost + OpenUrl, { let selection = Rc::new(RefCell::new(Selection::new())); let list = List::new(Axis::Y).add(rsc); @@ -504,6 +504,16 @@ mod apply_tests { struct TestFocus { focus: Option>, } + /// The headless stand-in for `iris::platform::OpenUrl`'s real + /// backends. Nothing in these tests taps a link -- the tap-vs-drag + /// rule that decides whether one is followed is `iris`'s own + /// (`sense_tests.rs`'s `a_press_released_without_moving_is_a_tap`), + /// and which link is under a byte offset is `markdown.rs`'s -- so + /// this only exists to satisfy the bound. + impl OpenUrl for TestFocus { + fn open_url(&mut self, _url: &str) {} + } + impl FocusHost for TestFocus { fn recent_click(&mut self) -> bool { false diff --git a/iris/transcript-ui/src/markdown.rs b/iris/transcript-ui/src/markdown.rs index 51bfece..3cc9c94 100644 --- a/iris/transcript-ui/src/markdown.rs +++ b/iris/transcript-ui/src/markdown.rs @@ -1,14 +1,25 @@ -//! 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, +//! One markdown **block** (`client_core::markdown_blocks::Block`) rendered +//! for display: the plain text to draw, the [`SpanStyle`]s that style it, +//! the links inside it, and the [`BlockFrame`] the row builder puts around +//! it. +//! +//! 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. +//! beside it). iris's `SpanStyle` (`core/src/primitive/text.rs`) is +//! per-range, so bold/italic/inline-code/links inside one wrapped +//! paragraph render in their own style *and* the paragraph still wraps and +//! selects as one buffer. +//! +//! **Three widget shapes, not one per markdown feature** ([`BlockFrame`]). +//! A heading, a paragraph and a list are all *text with spans*; a fence +//! and a table are *verbatim text on a dark surface that pans sideways*; +//! a quote is *text behind a coloured bar*. Everything else markdown can +//! say is expressed in the spans, which cost no widgets and no layout +//! nodes. `app/.../Markdown.kt`'s component table is the reference for the +//! sizes and colours; docs/DECISIONS.md's 2026-09-06 entry records where +//! this deliberately differs. //! //! **What this deliberately does not attempt**, each for a reason recorded //! here rather than silently dropped (see IRIS_TODO.md's dated entries for @@ -16,52 +27,190 @@ //! - **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). +//! `iris/src/widget/text/edit.rs:99`), which is `TextEdit`-internal. +//! `SpanStyle` gives the code range a monospace family and the +//! palette's code colour instead -- visually distinct, just not +//! chip-shaped. +//! - **A list's indent is written in spaces**, not measured. Compose lays +//! an item out as a marker column beside a text column, which keeps a +//! wrapped second line aligned under the first; here the marker is part +//! of the same buffer, so a wrapped line returns to the left margin. +//! Doing better needs per-line indent in `TextAttrs`, which nothing else +//! wants yet. //! //! 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. +//! paragraph's -- visible, not incorrect, and not fixed here since it +//! needs `SpanStyle` to carry line-height too. +use client_core::highlight::{self, Kind, Language}; +use client_core::markdown_blocks::{Block, BlockKind}; use iris::prelude::*; -use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use std::ops::Range; // `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); +// Catppuccin Mocha, the same values `app/.../Theme.kt` maps onto +// Material's roles, so a block drawn here and the same block drawn by the +// Compose app are the same colour rather than nearly. +const fn mocha(hex: u32) -> UiColor { + UiColor::new( + ((hex >> 16) & 0xff) as u8, + ((hex >> 8) & 0xff) as u8, + (hex & 0xff) as u8, + 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. +/// Body text: Mocha Text, the Compose app's `onSurface`. +pub const TEXT_COLOR: UiColor = mocha(0xCDD6F4); +/// Inline code, and a fence with no language to highlight it by. +pub const CODE_COLOR: UiColor = mocha(0xCDD6F4); +/// A link. "Blue is what a link is on every Catppuccin surface, and the +/// one colour to leave alone" (`Theme.kt`'s `linkColor`). +pub const LINK_COLOR: UiColor = mocha(0x89B4FA); +/// A list's bullets and numbers: structure rather than words, so the +/// items of a list can be counted without reading them (`listMarkerColor`). +pub const MARKER_COLOR: UiColor = mocha(0xB4BEFE); +/// What every verbatim thing in this app sits on -- Mocha Crust, one step +/// *below* the page rather than above it (`Theme.kt`'s `rawSurface`). +pub const VERBATIM_BACKGROUND: UiColor = mocha(0x11111B); +/// A table's fill: Surface 0, the Compose app's `surfaceVariant`. +pub const TABLE_BACKGROUND: UiColor = mocha(0x313244); +/// A quote's bar and its text: the bar carries the structure, and the +/// words step back one shade from body text so a quote reads as quoted +/// without being hard to read. +pub const QUOTE_BAR_COLOR: UiColor = mocha(0x585B70); +pub const QUOTE_TEXT_COLOR: UiColor = mocha(0xA6ADC8); +const STRIKETHROUGH_COLOR: UiColor = mocha(0x6C7086); + +/// Catppuccin Mocha as the highlighter's palette -- the same mapping +/// `Theme.kt`'s `catppuccinSyntax()` uses, so a `kotlin` fence is the same +/// colours in both apps. +fn syntax_color(kind: Kind) -> UiColor { + match kind { + Kind::Keyword => mocha(0xCBA6F7), + Kind::String => mocha(0xA6E3A1), + Kind::Literal => mocha(0xFAB387), + Kind::Comment => mocha(0x6C7086), + Kind::Metadata => mocha(0xF9E2AF), + Kind::Punctuation => mocha(0xA6ADC8), + Kind::Mark => mocha(0x89DCEB), + } +} + +/// What a row builder puts *around* a block's text widget. Three, not one +/// per markdown feature -- see the module doc. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlockFrame { + /// Text and nothing else: a paragraph, a heading, a list, a rule. + Plain, + /// A dark rounded panel whose text does not wrap -- long lines pan + /// sideways, the way `CodeFence.kt`'s `horizontalScroll` does. Carries + /// its own fill, since a fence and a table are drawn on different + /// ones. + Verbatim { fill: UiColor }, + /// A coloured bar down the left edge and an indent past it. + Quote, +} + +/// The frame a block kind is drawn in. Pure, and the *only* place the +/// mapping is written: a new `BlockKind` shows up here as a compile error +/// rather than silently taking prose's appearance. +pub fn frame_of(kind: BlockKind) -> BlockFrame { + match kind { + BlockKind::Code => BlockFrame::Verbatim { + fill: VERBATIM_BACKGROUND, + }, + BlockKind::Table => BlockFrame::Verbatim { + fill: TABLE_BACKGROUND, + }, + BlockKind::Quote => BlockFrame::Quote, + BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => { + BlockFrame::Plain + } + } +} + +/// A tappable range of a block's text and where it points. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Link { + /// Byte range into [`Rendered::text`]. + pub range: Range, + pub url: String, +} + +/// One block, ready to draw. Not `Debug`: `SpanStyle` is not, and adding +/// it there for this would be a change to iris for a test's benefit. +#[derive(Clone, Default)] +pub struct Rendered { + pub text: String, + pub spans: Vec, + pub links: Vec, +} + +impl Rendered { + /// The link `byte` falls inside, if any -- what a tap resolves + /// through. Half-open, so the offset one past a link's last character + /// (where a tap just after it lands) is *not* in it. + pub fn link_at(&self, byte: usize) -> Option<&Link> { + self.links.iter().find(|l| l.range.contains(&byte)) + } +} + +/// The heading ladder, in points at a 16pt body: it starts near the body +/// text and descends, because these are headings inside a chat message +/// rather than the top of a document. The numbers are Material's +/// `headlineSmall`/`titleLarge`/`titleMedium`/`titleSmall`/`labelMedium`/ +/// `labelSmall`, which is what `Markdown.kt`'s `markdownTypography` picks +/// -- kept as literals rather than derived from `base_size` so the two +/// apps agree exactly. +fn heading_size(level: HeadingLevel) -> f32 { + match level { + HeadingLevel::H1 => 24.0, + HeadingLevel::H2 => 22.0, + HeadingLevel::H3 => 16.0, + HeadingLevel::H4 => 14.0, + HeadingLevel::H5 => 12.0, + HeadingLevel::H6 => 11.0, + } +} + +/// The bullet at each depth, cycling past the third: a disc, a ring, a +/// square -- the ladder a browser draws, so a nested list is told from its +/// parent by the glyph as well as by the indent. Same three +/// `MarkdownPieces.kt` uses. +const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "]; + +/// A block-level separator inside one block's own text (a list item's +/// paragraphs, a quote's): two never run into each other with no gap, but +/// an empty `out` gets no leading blank. fn ensure_blank_line(out: &mut String) { if !out.is_empty() && !out.ends_with("\n\n") { + while out.ends_with('\n') { + out.pop(); + } 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, +fn ensure_line(out: &mut String) { + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } +} + +/// One top-level block, rendered. `base_size` is the row's ordinary +/// paragraph font size; a heading overrides it per span. +pub fn render_block(block: &Block, base_size: f32) -> Rendered { + match block.kind { + // A table is the one block markdown states as a grid and iris has + // no grid widget for. Rendered as padded monospace instead -- + // see [`table_text`]. + BlockKind::Table => table_text(&block.source), + _ => render_markdown(&block.source, base_size), } } @@ -69,19 +218,27 @@ fn heading_size(level: HeadingLevel) -> f32 { /// 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 +pub fn render_markdown(src: &str, base_size: f32) -> Rendered { + let _ = base_size; // headings use the fixed Material ladder; see `heading_size` let mut out = String::new(); let mut spans = Vec::new(); + let mut links = 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; + // too) is enough. A link's destination rides along beside its offset, + // since `TagEnd::Link` does not carry it. + let mut open: Vec<(usize, Option)> = Vec::new(); + // One entry per open list: `Some(next number)` for an ordered list, + // `None` for a bulleted one. Depth is this vector's length, which is + // what picks the bullet glyph. + let mut lists: Vec> = Vec::new(); + // The language of the fence currently open, so `TagEnd::CodeBlock` can + // highlight what was collected between the two. + let mut fence_language: Option = None; - let parser = Parser::new_ext(src, Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES); + let parser = Parser::new_ext(src, options()); for event in parser { match event { Event::Start(tag) => match tag { @@ -89,16 +246,35 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec) { | Tag::Emphasis | Tag::Strong | Tag::Strikethrough - | Tag::Link { .. } => open.push(out.len()), - Tag::CodeBlock(_) => { + | Tag::Image { .. } => open.push((out.len(), None)), + Tag::Link { dest_url, .. } => open.push((out.len(), Some(dest_url.to_string()))), + Tag::CodeBlock(kind) => { + fence_language = match &kind { + CodeBlockKind::Fenced(info) => { + // Only the first word: "rust,ignore" and + // "console session" are both written. + highlight::fence_language(info.split_whitespace().next()) + } + CodeBlockKind::Indented => None, + }; ensure_blank_line(&mut out); - open.push(out.len()); + open.push((out.len(), None)); } Tag::Item => { - out.push_str(&" ".repeat(list_depth.saturating_sub(1) as usize)); - out.push_str("\u{2022} "); + ensure_line(&mut out); + let depth = lists.len().max(1); + out.push_str(&" ".repeat(depth - 1)); + let start = out.len(); + match lists.last_mut() { + Some(Some(n)) => { + out.push_str(&format!("{n}. ")); + *n += 1; + } + _ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]), + } + spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR)); } - Tag::List(_) => list_depth += 1, + Tag::List(first) => lists.push(first), Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out), _ => {} }, @@ -113,11 +289,19 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec) { | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link + | TagEnd::Image | TagEnd::CodeBlock), ) => { - let Some(start) = open.pop() else { + let Some((start, dest)) = open.pop() else { continue; }; + if matches!(tag_end, TagEnd::CodeBlock) { + // A fence's trailing newline is the fence marker's, not + // the code's -- kept and it draws an empty last line. + while out.ends_with('\n') { + out.pop(); + } + } let range = start..out.len(); if range.is_empty() { continue; @@ -131,15 +315,27 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec) { TagEnd::Strikethrough => { spans.push(SpanStyle::new(range).color(STRIKETHROUGH_COLOR)); } - TagEnd::Link => { - spans.push(SpanStyle::new(range).color(LINK_COLOR).underline()); + // An image draws as its alt text until the port has a + // transcript image widget (IRIS_TODO's "scaled + // thumbnail"); marked as a link so it is at least + // followable rather than silently inert. + TagEnd::Link | TagEnd::Image => { + spans.push(SpanStyle::new(range.clone()).color(LINK_COLOR).underline()); + if let Some(url) = dest { + links.push(Link { range, url }); + } } TagEnd::CodeBlock => { spans.push( - SpanStyle::new(range) + SpanStyle::new(range.clone()) .family(Family::Monospace) .color(CODE_COLOR), ); + // After the monospace span, so the per-token + // colours win where they overlap it. + if let Some(language) = fence_language.take() { + highlight_into(&mut spans, &out, range, language); + } } _ => unreachable!("filtered by the outer match arm"), } @@ -160,64 +356,427 @@ pub fn render_markdown(src: &str, base_size: f32) -> (String, Vec) { Event::SoftBreak => out.push(' '), Event::HardBreak => out.push('\n'), Event::Rule => { - if !out.ends_with('\n') { - out.push('\n'); - } + ensure_line(&mut out); out.push_str("\u{2500}\u{2500}\u{2500}\n"); } - Event::End(TagEnd::List(_)) => list_depth = list_depth.saturating_sub(1), + Event::TaskListMarker(done) => { + let start = out.len(); + out.push_str(if done { "[x] " } else { "[ ] " }); + spans.push(SpanStyle::new(start..out.len()).color(MARKER_COLOR)); + } + Event::End(TagEnd::List(_)) => { + lists.pop(); + } _ => {} } } - (out, spans) + while out.ends_with('\n') { + out.pop(); + } + // A span left pointing past the text a later trim shortened would draw + // against nothing; markdown that ends inside an open emphasis is + // ordinary mid-stream input, not a defect. + spans.retain(|s| s.range.end <= out.len()); + links.retain(|l| l.range.end <= out.len()); + Rendered { + text: out, + spans, + links, + } +} + +/// The same option set `client_core::markdown_blocks` splits with, so a +/// block boundary there and the styling here cannot disagree about what +/// the source means. +fn options() -> Options { + Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS +} + +/// `client_core::highlight`'s spans for the code at `range` inside `text`, +/// appended to `spans`. +/// +/// The highlighter indexes **chars** and `SpanStyle` indexes **bytes** +/// (`highlight`'s module doc), so the offsets are walked once rather than +/// converted per span -- a fence is scanned on every delta that lands in +/// it, and it is the only block a delta re-renders. +fn highlight_into(spans: &mut Vec, text: &str, range: Range, language: Language) { + let code = &text[range.clone()]; + // char index -> byte offset within `code`, plus the end, so a span's + // `end` is always in range. + let bytes: Vec = code + .char_indices() + .map(|(i, _)| i) + .chain(std::iter::once(code.len())) + .collect(); + for span in highlight::spans_of(code, language) { + let (Some(&start), Some(&end)) = (bytes.get(span.start), bytes.get(span.end)) else { + debug_assert!( + false, + "highlight span {}..{} outside {} chars of code", + span.start, + span.end, + bytes.len() - 1 + ); + continue; + }; + spans.push( + SpanStyle::new(range.start + start..range.start + end) + .family(Family::Monospace) + .color(syntax_color(span.kind)), + ); + } +} + +/// The widest a table column is allowed to get before its cells wrap +/// inside it, in characters. Chosen the way `Markdown.kt`'s 136dp +/// `tableCellWidth` was -- what fits three columns across a phone -- but +/// counted in monospace characters, which is the unit a padded table has: +/// three 28-character columns plus separators is about 90 characters, +/// which is what a 16pt mono face gives on a 1080px phone before the +/// sideways pan starts. +const TABLE_MAX_COL: usize = 28; + +/// A GFM table as **padded monospace columns**, with the header bold and a +/// rule under it. +/// +/// iris has no grid widget, and building one for the one block kind that +/// needs it would be a widget per markdown feature -- what this crate's +/// module doc says it will not do. A monospace face makes character counts +/// and pixel widths the same thing, so padding each cell to its column's +/// width *is* alignment, the column widths are measured from the cells, +/// and the block reuses `BlockFrame::Verbatim`'s sideways pan for a table +/// too wide to fit. docs/DECISIONS.md, 2026-09-06, has what this trades. +pub fn table_text(src: &str) -> Rendered { + let rows = table_cells(src); + if rows.is_empty() { + return Rendered::default(); + } + let columns = rows.iter().map(Vec::len).max().unwrap_or(0); + // Each cell wrapped to the cap first, so a column's width is the + // widest *line* it will actually draw rather than the longest cell. + let wrapped: Vec>> = rows + .iter() + .map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect()) + .collect(); + let widths: Vec = (0..columns) + .map(|c| { + wrapped + .iter() + .filter_map(|row| row.get(c)) + .flat_map(|lines| lines.iter()) + .map(|l| l.chars().count()) + .max() + .unwrap_or(0) + }) + .collect(); + + let mut out = String::new(); + let mut spans = Vec::new(); + for (r, row) in wrapped.iter().enumerate() { + let height = row.iter().map(Vec::len).max().unwrap_or(1); + let start = out.len(); + for line in 0..height { + if !out.is_empty() { + out.push('\n'); + } + for (c, width) in widths.iter().enumerate() { + if c > 0 { + out.push_str(" "); + } + let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str); + let text = text.unwrap_or(""); + out.push_str(text); + // The last column is not padded: trailing spaces widen + // the block's measured width for nothing. + if c + 1 < widths.len() { + for _ in text.chars().count()..*width { + out.push(' '); + } + } + } + } + if r == 0 { + spans.push(SpanStyle::new(start..out.len()).bold()); + out.push('\n'); + let rule: usize = widths.iter().sum::() + 2 * widths.len().saturating_sub(1); + let rule_start = out.len(); + out.extend(std::iter::repeat_n('\u{2500}', rule)); + spans.push(SpanStyle::new(rule_start..out.len()).color(QUOTE_BAR_COLOR)); + } + } + Rendered { + text: out, + spans, + links: Vec::new(), + } +} + +/// The cells of a GFM table, row by row, as their plain text. +fn table_cells(src: &str) -> Vec> { + let mut rows: Vec> = Vec::new(); + let mut cell = String::new(); + let mut in_cell = false; + for event in Parser::new_ext(src, options()) { + match event { + Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => rows.push(Vec::new()), + Event::Start(Tag::TableCell) => { + cell.clear(); + in_cell = true; + } + Event::End(TagEnd::TableCell) => { + in_cell = false; + if let Some(row) = rows.last_mut() { + row.push(cell.trim().to_string()); + } + } + Event::Text(text) | Event::Code(text) if in_cell => cell.push_str(&text), + Event::SoftBreak | Event::HardBreak if in_cell => cell.push(' '), + _ => {} + } + } + rows.retain(|r| !r.is_empty()); + rows +} + +/// `text` broken onto lines of at most `width` characters, at spaces where +/// there are any. A word longer than the column is left over-long rather +/// than cut mid-word: the column then widens for it, which is visible and +/// correct, where cutting would silently lose characters. +fn wrap_cell(text: &str, width: usize) -> Vec { + let mut lines = Vec::new(); + let mut line = String::new(); + for word in text.split_whitespace() { + let extra = if line.is_empty() { 0 } else { 1 }; + if !line.is_empty() && line.chars().count() + extra + word.chars().count() > width { + lines.push(std::mem::take(&mut line)); + } + if !line.is_empty() { + line.push(' '); + } + line.push_str(word); + } + lines.push(line); + lines } #[cfg(test)] mod tests { use super::*; + use client_core::markdown_blocks::split_blocks; + + fn block(src: &str) -> Rendered { + let blocks = split_blocks(src); + assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}"); + render_block(&blocks[0], 16.0) + } #[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()); + let r = render_markdown("just some words", 16.0); + assert_eq!(r.text, "just some words"); + assert!(r.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"); + let r = render_markdown("a **bold** and *italic* word", 16.0); + assert_eq!(r.text, "a bold and italic word"); + let bold = r.spans.iter().find(|s| s.bold && !s.italic).unwrap(); + assert_eq!(&r.text[bold.range.clone()], "bold"); + let italic = r.spans.iter().find(|s| s.italic).unwrap(); + assert_eq!(&r.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)); + let r = render_markdown("# A Title", 16.0); + assert!(r.text.starts_with("A Title")); + let heading = r.spans.iter().find(|s| s.font_size.is_some()).unwrap(); + assert_eq!(&r.text[heading.range.clone()], "A Title"); + assert_eq!(heading.font_size, Some(24.0)); + } + + /// Every level draws at its own size, so two levels of nesting are + /// never the same -- `Markdown.kt`'s reason for the ladder. + #[test] + fn every_heading_level_is_a_different_size() { + let mut sizes = Vec::new(); + for level in 1..=6 { + let src = format!("{} h", "#".repeat(level)); + let r = render_markdown(&src, 16.0); + sizes.push(r.spans.iter().find_map(|s| s.font_size).unwrap()); + } + let mut sorted = sizes.clone(); + sorted.sort_by(|a, b| b.partial_cmp(a).unwrap()); + sorted.dedup(); + assert_eq!(sizes, sorted, "the ladder must descend with no repeats"); } #[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")); + fn a_link_keeps_its_text_and_its_url_and_can_be_hit() { + let r = render_markdown("see [the docs](https://example.com) for more", 16.0); + assert!(r.text.contains("the docs")); assert!( - !text.contains("example.com"), + !r.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"); + let link = r.spans.iter().find(|s| s.underline).unwrap(); + assert_eq!(&r.text[link.range.clone()], "the docs"); + let at = r.text.find("docs").unwrap(); + assert_eq!(r.link_at(at).unwrap().url, "https://example.com"); + assert!(r.link_at(0).is_none(), "the word 'see' is not the link"); + let past = r.text.find("for").unwrap(); + assert!(r.link_at(past).is_none()); } #[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;")); + fn fenced_code_block_is_monospaced_and_highlighted_by_its_language() { + let r = block("```rust\nlet x = 1; // note\n```"); + assert_eq!(r.text, "let x = 1; // note"); + let keyword = r + .spans + .iter() + .find(|s| s.color == Some(syntax_color(Kind::Keyword))) + .expect("a rust fence colours its keywords"); + assert_eq!(&r.text[keyword.range.clone()], "let"); + let comment = r + .spans + .iter() + .find(|s| s.color == Some(syntax_color(Kind::Comment))) + .unwrap(); + assert_eq!(&r.text[comment.range.clone()], "// note"); + assert!(r.spans.iter().all(|s| s.range.end <= r.text.len())); + } + + /// The half the change had no reason to touch: a fence in a language + /// the highlighter has no rules for must be plain rather than + /// coloured by the nearest language's (`CodeFence.kt`'s + /// `fenceLanguage` doc). + #[test] + fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() { + let r = block("```brainfuck\nlet x = 1;\n```"); + assert_eq!(r.text, "let x = 1;"); + assert_eq!(r.spans.len(), 1); + // `Family` is not `Debug`, so this is `assert!` rather than + // `assert_eq!`. + assert!(r.spans[0].family == Some(Family::Monospace)); + assert_eq!(r.spans[0].color, Some(CODE_COLOR)); + } + + /// Multi-byte characters are where a char-indexed highlighter and a + /// byte-indexed span list disagree if the conversion is missing. + #[test] + fn highlight_spans_are_byte_offsets_even_with_multibyte_code() { + let r = block("```rust\nlet s = \"café ☕\"; // é\n```"); + for span in &r.spans { + assert!( + r.text.is_char_boundary(span.range.start) + && r.text.is_char_boundary(span.range.end), + "span {:?} is not on a char boundary of {:?}", + span.range, + r.text + ); + } + let string = r + .spans + .iter() + .find(|s| s.color == Some(syntax_color(Kind::String))) + .unwrap(); + assert_eq!(&r.text[string.range.clone()], "\"café ☕\""); + } + + #[test] + fn an_unterminated_fence_still_renders_what_arrived() { + let r = block("```rust\nlet x = 1;"); + assert_eq!(r.text, "let x = 1;"); + assert!( + r.spans + .iter() + .any(|s| s.color == Some(syntax_color(Kind::Keyword))) + ); + } + + #[test] + fn a_bulleted_list_gets_a_marker_per_item_and_indents_nesting() { + let r = block("- one\n- two\n - deep"); + assert_eq!(r.text, "\u{2022} one\n\u{2022} two\n \u{25e6} deep"); + let markers: Vec<_> = r + .spans + .iter() + .filter(|s| s.color == Some(MARKER_COLOR)) + .map(|s| r.text[s.range.clone()].to_string()) + .collect(); + assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]); + } + + #[test] + fn a_numbered_list_counts_from_the_number_it_was_written_with() { + let r = block("3. three\n4. four"); + assert_eq!(r.text, "3. three\n4. four"); + let markers: Vec<_> = r + .spans + .iter() + .filter(|s| s.color == Some(MARKER_COLOR)) + .map(|s| r.text[s.range.clone()].to_string()) + .collect(); + assert_eq!(markers, ["3. ", "4. "]); + } + + #[test] + fn a_quote_is_its_text_and_takes_the_quote_frame() { + let blocks = split_blocks("> quoted words\n> still quoted"); + assert_eq!(frame_of(blocks[0].kind), BlockFrame::Quote); + let r = render_block(&blocks[0], 16.0); + assert_eq!(r.text, "quoted words still quoted"); + } + + #[test] + fn each_block_kind_maps_to_the_frame_it_is_drawn_in() { + use BlockKind::*; + assert_eq!(frame_of(Paragraph), BlockFrame::Plain); + assert_eq!(frame_of(Heading), BlockFrame::Plain); + assert_eq!(frame_of(List), BlockFrame::Plain); + assert_eq!(frame_of(Other), BlockFrame::Plain); + assert_eq!(frame_of(Quote), BlockFrame::Quote); + assert!(matches!(frame_of(Code), BlockFrame::Verbatim { .. })); + assert!(matches!(frame_of(Table), BlockFrame::Verbatim { .. })); + assert_ne!( + frame_of(Code), + frame_of(Table), + "a fence and a table sit on different fills" + ); + } + + #[test] + fn a_table_pads_its_columns_to_the_widest_cell() { + let r = block("| a | bb |\n|---|---|\n| cccc | d |"); + let lines: Vec<&str> = r.text.lines().collect(); + assert_eq!(lines[0], "a bb"); + assert_eq!(lines[1], "\u{2500}".repeat(8)); + assert_eq!(lines[2], "cccc d"); + let bold = r.spans.iter().find(|s| s.bold).unwrap(); + assert_eq!(&r.text[bold.range.clone()], "a bb"); + } + + /// The fixture's own table shape: a long cell wraps inside its column + /// instead of making the row one enormous line. + #[test] + fn a_long_table_cell_wraps_inside_its_column() { + let long = "one two three four five six seven eight nine ten eleven twelve"; + let r = block(&format!("| k | v |\n|---|---|\n| a | {long} |")); + for line in r.text.lines() { + assert!( + line.chars().count() <= TABLE_MAX_COL + 1 + 2 + 1, + "line too wide: {line:?}" + ); + } + assert!(r.text.contains("twelve")); + } + + #[test] + fn a_task_list_marks_its_boxes() { + let r = block("- [x] done\n- [ ] not"); + assert!(r.text.contains("[x] done")); + assert!(r.text.contains("[ ] not")); } } diff --git a/iris/transcript-ui/src/row.rs b/iris/transcript-ui/src/row.rs index 4240dea..0862e0c 100644 --- a/iris/transcript-ui/src/row.rs +++ b/iris/transcript-ui/src/row.rs @@ -23,7 +23,7 @@ //! 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::markdown::{BlockFrame, Link, frame_of, render_block}; use crate::selection::{SelKey, Selection}; use client_core::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks}; use client_core::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow}; @@ -133,6 +133,10 @@ pub struct RowBlocks { /// comparison and not an assumption. blocks: Vec, fields: Vec>, + /// Each block's links, shared with its own tap handler so a delta + /// replaces what the handler reads instead of re-registering it. + /// One entry per field, which `apply_delta` asserts. + links: Vec>>>, column: WeakWidget, /// The sender label the row was built with. A delta that changes it is /// not a delta into the same message, so it falls back to a rebuild. @@ -154,30 +158,63 @@ fn display_blocks(markdown_src: &str) -> Vec { } } +/// The room a fence or a table's text gets inside its panel, and the +/// gap between a quote's bar and its words. `CodeFence.kt` charges the +/// renderer's `codeBlock` padding inside the tinted box and 8dp above and +/// below it; the vertical half is `BLOCK_GAP_DP`'s job here, since the +/// column already separates blocks. +const FRAME_PAD_DP: f32 = 10.0; +/// The bar down a quote's left edge. +const QUOTE_BAR_DP: f32 = 3.0; +/// A verbatim panel's corner, matching the renderer's own rounded fence. +const FRAME_RADIUS_DP: f32 = 8.0; + /// One block's own `TextEdit`, registered with `selection` under /// `(row, block)` and wired to `Selection::drag` -- the block is the -/// selection unit (`selection::SelKey`). -fn build_block_field( +/// selection unit (`selection::SelKey`) -- plus whatever +/// [`BlockFrame`] its kind is drawn in. +/// +/// Returns the field (which `apply_delta` writes into), the widget the +/// column actually holds (the field, or the field inside its frame), and +/// the block's links, shared with the tap handler so a delta can replace +/// them without rebuilding the handler. +fn build_block( rsc: &mut Rsc, list: WeakWidget, selection: Rc>, key: SelKey, - source: &str, -) -> WeakWidget + block: &Block, +) -> (WeakWidget, StrongWidget, Rc>>) where - Rsc::State: FocusHost, + Rsc::State: FocusHost + OpenUrl, { - let (text, spans) = render_markdown(source, BASE_SIZE); - let field = wtext(text) - .spans(spans) + let frame = frame_of(block.kind); + let rendered = render_block(block, BASE_SIZE); + let links = Rc::new(RefCell::new(rendered.links)); + let verbatim = matches!(frame, BlockFrame::Verbatim { .. }); + let field = wtext(rendered.text) + .spans(rendered.spans) .editable(EditMode::MultiLine) .text_align(Align::LEFT) - .wrap(true) + // A fence and a table say what they mean by where their + // characters sit, so they pan sideways rather than wrap + // (`CodeFence.kt`'s `horizontalScroll`) -- and a table is padded + // in *characters*, which only lines up in a monospace face. + .wrap(!verbatim) + .family(if verbatim { + Family::Monospace + } else { + Family::SansSerif + }) .size(BASE_SIZE) - .color(UiColor::WHITE) + .color(match frame { + BlockFrame::Quote => crate::markdown::QUOTE_TEXT_COLOR, + _ => crate::markdown::TEXT_COLOR, + }) .add(rsc); selection.borrow_mut().register(key, field); + let tap_links = links.clone(); field // `| CursorSense::unclick()` on top of the usual click-or-drag set // -- this block's own registration only ever needs to see a @@ -192,19 +229,61 @@ where .on( CursorSense::click_or_drag() | CursorSense::unclick(), move |ctx, rsc| { - selection.borrow_mut().drag( + let (pos, size, cursor) = (ctx.data.pos, ctx.data.size, ctx.data.cursor.pos); + let outcome = selection.borrow_mut().drag( rsc, list, - Some((key, ctx.data.pos, ctx.data.size)), - ctx.data.cursor.pos, + Some((key, pos, size)), + cursor, ctx.data.sense, Instant::now(), ctx.data.render, ); + // 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). + if outcome == GestureOutcome::Tapped { + let byte = field.edit(rsc).byte_at(cursor, size); + let url = tap_links + .borrow() + .iter() + .find(|l| l.range.contains(&byte)) + .map(|l| l.url.clone()); + if let Some(url) = url { + log::info!("iris link: opening {url}"); + ::open_url(ctx.state, &url); + } + } }, ) .add(rsc); - field + + // The column holds the *framed* widget; the field is what + // `apply_delta` writes into and what `Selection` resolves. Keeping + // the two apart is what lets a fence gain a background without the + // delta path knowing anything about frames. + let framed = match frame { + BlockFrame::Plain => field.width(rest(1)).add_strong(rsc).any(), + BlockFrame::Verbatim { fill } => field + .scrollable_on(Axis::X) + .masked() + .pad(dp(FRAME_PAD_DP)) + .background(rect(fill).radius(dp(FRAME_RADIUS_DP))) + .width(rest(1)) + .add_strong(rsc) + .any(), + BlockFrame::Quote => ( + rect(crate::markdown::QUOTE_BAR_COLOR).width(dp(QUOTE_BAR_DP)), + field.width(rest(1)), + ) + .span(Dir::RIGHT) + .gap(dp(FRAME_PAD_DP)) + .width(rest(1)) + .add_strong(rsc) + .any(), + }; + (field, framed, links) } /// Build a row from a sender label plus markdown source: a column of one @@ -225,15 +304,18 @@ fn build_text_row( markdown_src: &str, ) -> (StrongWidget, RowBlocks) where - Rsc::State: FocusHost, + Rsc::State: FocusHost + OpenUrl, { let blocks = display_blocks(markdown_src); let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP)); let mut fields = Vec::with_capacity(blocks.len()); + let mut links = Vec::with_capacity(blocks.len()); for (i, block) in blocks.iter().enumerate() { - let field = build_block_field(rsc, list, selection.clone(), (key, i as u32), &block.source); + let (field, framed, block_links) = + build_block(rsc, list, selection.clone(), (key, i as u32), block); fields.push(field); - column.push(field.width(rest(1)).add_strong(rsc).any()); + links.push(block_links); + column.push(framed); } let column = column.add(rsc); @@ -264,6 +346,7 @@ where RowBlocks { blocks, fields, + links, column, sender: sender.map(str::to_string), }, @@ -291,7 +374,7 @@ impl RowBlocks { markdown_src: &str, ) -> bool where - Rsc::State: FocusHost, + Rsc::State: FocusHost + OpenUrl, { if self.sender.as_deref() != sender { return false; @@ -305,32 +388,48 @@ impl RowBlocks { if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() { return false; } + // A block's *frame* is built around its widget once and never + // rewritten, so a block whose kind changed under the delta (the + // paragraph that a `|---|` line turns into a table) cannot take + // this path -- it would keep prose's appearance with a table's + // text in it. Only the last block can differ at all, by the check + // above. + if new_blocks.len() == self.blocks.len() + && common < self.blocks.len() + && new_blocks[common].kind != self.blocks[common].kind + { + return false; + } debug_assert!( - self.fields.len() == self.blocks.len(), - "one field per block: {} fields, {} blocks", + self.fields.len() == self.blocks.len() && self.links.len() == self.blocks.len(), + "one field and one link list per block: {} fields, {} links, {} blocks", self.fields.len(), + self.links.len(), self.blocks.len() ); for (i, block) in new_blocks.iter().enumerate().skip(common) { - let (text, spans) = render_markdown(&block.source, BASE_SIZE); - match self.fields.get(i) { - Some(field) => field.edit(rsc).set_with_spans(&text, spans), - None => { - let field = build_block_field( - rsc, - list, - selection.clone(), - (key, i as u32), - &block.source, - ); + match (self.fields.get(i), self.links.get(i)) { + (Some(field), Some(links)) => { + let rendered = render_block(block, BASE_SIZE); + field + .edit(rsc) + .set_with_spans(&rendered.text, rendered.spans); + // Replaced together with the text: a link range left + // over from the previous delta points into a string + // that no longer exists. + *links.borrow_mut() = rendered.links; + } + _ => { + let (field, framed, links) = + build_block(rsc, list, selection.clone(), (key, i as u32), block); self.fields.push(field); - let child = field.width(rest(1)).add_strong(rsc).any(); + self.links.push(links); // `get_mut` marks the column dirty, which is what gets // the new block drawn; its removal half is the row's // own, since the column owns the child strongly. if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) { - column.push(child); + column.push(framed); } } } @@ -348,7 +447,7 @@ fn build_single( item: &TranscriptItem, ) -> (StrongWidget, RowBlocks) where - Rsc::State: FocusHost, + Rsc::State: FocusHost + OpenUrl, { let (sender, markdown_src) = item_content(item); build_text_row(rsc, list, selection, key, sender, &markdown_src) @@ -366,7 +465,7 @@ fn build_tools( calls: Vec, ) -> StrongWidget where - Rsc::State: FocusHost, + Rsc::State: FocusHost + OpenUrl, { let expanded = Rc::new(RefCell::new(false)); // `.add_strong` (not `.add`) because nothing else in the tree holds a @@ -401,7 +500,7 @@ where full: &str, ) -> StrongWidget where - Rsc::State: FocusHost, + Rsc::State: FocusHost + OpenUrl, { // Every block of the previous content goes first: collapsing a // five-block expansion back to a one-line summary registers only @@ -462,7 +561,7 @@ pub fn build_row( row: &FoldedRow, ) -> (RowKey, StrongWidget, Option) where - Rsc::State: FocusHost, + Rsc::State: FocusHost + OpenUrl, { match row { FoldedRow::Single(item) => { diff --git a/iris/transcript-ui/src/selection.rs b/iris/transcript-ui/src/selection.rs index 1fc9636..d349323 100644 --- a/iris/transcript-ui/src/selection.rs +++ b/iris/transcript-ui/src/selection.rs @@ -245,6 +245,10 @@ impl Selection { /// than the last one. `render` is `CursorData`'s own field -- what /// `DragGesture` needs to take pointer capture. #[allow(clippy::too_many_arguments)] + /// Returns what the gesture decided this frame, so a caller with its + /// own meaning for a *tap* -- a row's link handler -- reads it from + /// the one arbiter that already knows, rather than timing a second + /// one beside it (which would disagree the moment either changed). pub fn drag( &mut self, ui: &mut impl UiRsc, @@ -254,7 +258,7 @@ impl Selection { sense: CursorSense, now: Instant, render: &UiRenderState, - ) { + ) -> GestureOutcome { if matches!(sense, CursorSense::PressStart(_)) { // A fresh touch-down cancels any fling still coasting from // the previous gesture -- `List::fling`'s own doc, and @@ -291,8 +295,12 @@ impl Selection { // tap/long-press that never left `Undecided` -- exactly what // `DragGesture`'s `Some(v)` already encodes. GestureOutcome::Released(Some(v)) => list(ui).fling(-v), - GestureOutcome::Released(None) => {} + // A tap is nobody's business here -- `row.rs` reads it from + // the returned outcome and follows a link if one was under + // the finger. + GestureOutcome::Released(None) | GestureOutcome::Tapped => {} } + outcome } /// The concatenated selected text, in row order, `None` if nothing is