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 <noreply@anthropic.com>
This commit is contained in:
1 parent
20303e0b4c
commit
64f64b54e5
16 files changed
+1141
-161
No files matched your search
@@ -17,6 +17,7 @@ mod attr;
|
||||
mod ime;
|
||||
mod input;
|
||||
mod insets;
|
||||
mod platform;
|
||||
mod render;
|
||||
mod view;
|
||||
|
||||
|
||||
@@ -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<T: HasAndroidUiState> 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;
|
||||
@@ -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<String>,
|
||||
/// 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<State: AndroidAppState> IrisViewPeer<State> {
|
||||
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`
|
||||
|
||||
@@ -15,6 +15,7 @@ mod access;
|
||||
mod app;
|
||||
mod attr;
|
||||
mod input;
|
||||
mod platform;
|
||||
mod render;
|
||||
|
||||
pub use access::*;
|
||||
|
||||
@@ -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<T: HasDefaultUiState> 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+127
-21
@@ -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();
|
||||
|
||||
@@ -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(_) => {}
|
||||
|
||||
+14
-5
@@ -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<N: UiNum> From<N> 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<Len>) -> 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,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -85,10 +85,19 @@ widget_trait! {
|
||||
}
|
||||
|
||||
fn scrollable(self) -> impl WidgetIdFn<Rsc, Scroll> 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<Rsc, Scroll> 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
|
||||
|
||||
@@ -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<Rsc: HasEvents>(&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<Rsc: HasEvents>(&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<Rsc: HasEvents>(
|
||||
rows: Vec<FoldedRow>,
|
||||
) -> 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<Rsc: HasEvents>(
|
||||
rows: Vec<FoldedRow>,
|
||||
) -> (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<WeakWidget<TextEdit>>,
|
||||
}
|
||||
/// 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
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
//! Markdown -> one plain string plus a `Vec<SpanStyle>`, 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<u8>` (`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<usize>,
|
||||
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<SpanStyle>,
|
||||
pub links: Vec<Link>,
|
||||
}
|
||||
|
||||
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<SpanStyle>) {
|
||||
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<usize> = 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<String>)> = 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<Option<u64>> = 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<Language> = 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<SpanStyle>) {
|
||||
| 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<SpanStyle>) {
|
||||
| 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<SpanStyle>) {
|
||||
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<SpanStyle>) {
|
||||
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<SpanStyle>, text: &str, range: Range<usize>, 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<usize> = 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<Vec<Vec<String>>> = rows
|
||||
.iter()
|
||||
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
|
||||
.collect();
|
||||
let widths: Vec<usize> = (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::<usize>() + 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<Vec<String>> {
|
||||
let mut rows: Vec<Vec<String>> = 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<String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
+137
-38
@@ -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<Block>,
|
||||
fields: Vec<WeakWidget<TextEdit>>,
|
||||
/// 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<Rc<RefCell<Vec<Link>>>>,
|
||||
column: WeakWidget<Span>,
|
||||
/// 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<Block> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Rsc: HasEvents>(
|
||||
/// 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: HasEvents>(
|
||||
rsc: &mut Rsc,
|
||||
list: WeakWidget<List>,
|
||||
selection: Rc<RefCell<Selection>>,
|
||||
key: SelKey,
|
||||
source: &str,
|
||||
) -> WeakWidget<TextEdit>
|
||||
block: &Block,
|
||||
) -> (WeakWidget<TextEdit>, StrongWidget, Rc<RefCell<Vec<Link>>>)
|
||||
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}");
|
||||
<Rsc::State as OpenUrl>::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<Rsc: HasEvents>(
|
||||
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<Rsc: HasEvents>(
|
||||
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<Rsc: HasEvents>(
|
||||
calls: Vec<TranscriptItem>,
|
||||
) -> 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<Rsc: HasEvents>(
|
||||
row: &FoldedRow,
|
||||
) -> (RowKey, StrongWidget, Option<RowBlocks>)
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
Rsc::State: FocusHost + OpenUrl,
|
||||
{
|
||||
match row {
|
||||
FoldedRow::Single(item) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user