From e9fcba7f7b52f77e9b0296afbf710c0f48300a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=B4ng=20Tr=E1=BA=A7n?= Date: Sun, 4 Oct 2026 00:14:22 +0700 Subject: [PATCH] feat(text): pace the stream fade to how fast chunks arrive A fixed fade pulsed chunk by chunk on a fast stream and finished long before the next chunk on a slow one. With `TextViewMotion::with_stream_fade_pacing(min, max)`: - each update fades over three times the running average of the gaps between updates (weight 0.3), kept between `min` and `max`; - `stream_fade` still turns the fade on and times the first update; - a pause counts as the longest gap the bounds follow, so the pace returns within a few updates; - an update parsed with others already waiting fades 1.3 times faster for each (up to eight), so a backlog catches up; - the stagger compresses against the paced fade, and a replaced text starts its pace afresh. The idea follows Waku's veil as described, rewritten without its (GPL) code. Reduced motion still drops the fade. Each segment keeps its own duration and is sampled directly rather than through a `Timing`: `paced_fade_frame_bench` (300 words mid-fade, release, median of 9) goes from 21.2 to 12.1 us per frame, paced or not. Co-Authored-By: Claude Opus 5.5 --- crates/base/src/text/state.rs | 1 + crates/base/src/text/stream_fade.rs | 370 ++++++++++++++++++++++++++-- website/base/text-view.md | 17 ++ website/zh-CN/base/text-view.md | 8 + 4 files changed, 377 insertions(+), 19 deletions(-) diff --git a/crates/base/src/text/state.rs b/crates/base/src/text/state.rs index 304b563513..4f55b22c8b 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -645,6 +645,7 @@ impl TextViewState { &self.parsed_content.document, &content.document, Instant::now(), + self.revision.saturating_sub(revision), ); // This result may cover only part of the queued appends. // Keep the uncommitted tail pending from this document's end, diff --git a/crates/base/src/text/stream_fade.rs b/crates/base/src/text/stream_fade.rs index 20de935dd8..578d3dc949 100644 --- a/crates/base/src/text/stream_fade.rs +++ b/crates/base/src/text/stream_fade.rs @@ -5,6 +5,13 @@ //! large to light up within one fade goes back to fading as one chunk -- see //! [`TextViewMotion::stagger_step`]. //! +//! With [`TextViewMotion::with_stream_fade_pacing`], the fade follows the +//! stream instead of a fixed duration: each update fades over three times the +//! running average of the gaps between updates, kept within the pacing's +//! bounds, so a fast stream fades briskly and a slow one stays veiled until +//! about when the next chunk lands. An update that lands with more already +//! waiting to be parsed behind it fades faster still, so a backlog catches up. +//! //! The tracker compares rendered text, not source, so `**bo` completing into //! bold `bold` fades the changed glyphs rather than mapping source bytes. @@ -20,7 +27,7 @@ use super::{ document::ParsedDocument, node::{BlockNode, InlineNode, Paragraph}, }; -use crate::motion::{Easing, Timing}; +use crate::motion::Easing; /// Motion policy of a text view. Base plays it; every duration defaults to /// zero, so an unstyled view adopts new content at once. @@ -29,6 +36,7 @@ pub struct TextViewMotion { stream_fade: Duration, stream_fade_stagger: Duration, stream_fade_easing: Easing, + stream_fade_pacing: Option<(Duration, Duration)>, } impl Default for TextViewMotion { @@ -37,10 +45,28 @@ impl Default for TextViewMotion { stream_fade: Duration::ZERO, stream_fade_stagger: Duration::ZERO, stream_fade_easing: Easing::default(), + stream_fade_pacing: None, } } } +/// How many of the gaps between updates a paced fade lasts: long enough that +/// the text an update adds is still lifting when the next update lands, so +/// chunks overlap into one motion rather than pulse. +const PACED_GAPS_PER_FADE: u32 = 3; + +/// The weight the latest gap between updates takes in their running average: +/// a few updates in, the fade has caught up with a stream that changes pace. +const PACED_GAP_WEIGHT: f64 = 0.3; + +/// How much faster a paced fade runs for each further update already waiting +/// to be parsed when it lands. +const PACED_BACKLOG_SPEEDUP: f64 = 1.3; + +/// The most waiting updates that speed a paced fade, so a long backlog fades +/// briskly rather than not at all. +const PACED_BACKLOG_MAX: i32 = 8; + impl TextViewMotion { /// How long the text an update appends takes to reach full color. pub fn with_stream_fade(mut self, duration: Duration) -> Self { @@ -62,6 +88,16 @@ impl TextViewMotion { self } + /// Paces the fade to the stream: each update fades over three times the + /// running average of the gaps between updates, kept between `min` and + /// `max`, and faster for each further update already waiting behind it. + /// [`Self::with_stream_fade`] still turns the fade on, and is how long the + /// first update of a text fades, before there is a gap to go by. + pub fn with_stream_fade_pacing(mut self, min: Duration, max: Duration) -> Self { + self.stream_fade_pacing = Some((min.min(max), min.max(max))); + self + } + pub fn stream_fade(&self) -> Duration { self.stream_fade } @@ -74,21 +110,26 @@ impl TextViewMotion { &self.stream_fade_easing } - /// The start offset between consecutive words of an update `words` long: the stagger as - /// asked, or nothing. + /// The bounds a paced fade keeps within, if the fade is paced. + pub fn stream_fade_pacing(&self) -> Option<(Duration, Duration)> { + self.stream_fade_pacing + } + + /// The start offset between consecutive words of an update `words` long that fades over + /// `fade`: the stagger as asked, or nothing. /// /// Staggering only reads as words arriving one after another while the whole update lights - /// up well within one [`Self::stream_fade`]. Once the last word would start later than + /// up well within its fade. Once the last word would start later than /// that, what is left is a sweep drawn across text that appeared at once -- and an update /// that large (`stream_fade / stream_fade_stagger` words and up) was not typed anyway. /// Squeezing the step to fit only makes the sweep faster, so drop it instead and let the /// update fade as one chunk. - fn stagger_step(&self, words: usize) -> Duration { + fn stagger_step(&self, words: usize, fade: Duration) -> Duration { if words < 2 { return Duration::ZERO; } let span = self.stream_fade_stagger.as_nanos() * (words as u128 - 1); - if span > self.stream_fade.as_nanos() { + if span > fade.as_nanos() { return Duration::ZERO; } self.stream_fade_stagger @@ -174,6 +215,7 @@ impl StreamFadeFrame { struct FadeSegment { range: Range, started_at: Instant, + duration: Duration, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -194,6 +236,11 @@ pub(super) struct StreamFadeTracker { motion: TextViewMotion, pending: PendingUpdate, segments: Vec<(TextLeafKey, Vec)>, + /// When the last update was recorded, for the gap to the next one. + last_update: Option, + /// The running average of the gaps between updates, each gap kept within + /// the bounds a paced fade can follow. + gap_average: Option, } impl StreamFadeTracker { @@ -237,11 +284,19 @@ impl StreamFadeTracker { /// Records what `new` renders that `old` did not, for the updates noted /// since the last call, as segments that start fading at `now`. + /// `waiting` is how many further updates were already made when this one + /// was parsed, which speeds a paced fade. /// /// Only blocks the extension reaches are compared, walking both documents /// from the tail, so a chunk landing at the end of a long document costs /// one paragraph, not the document. - pub(super) fn record(&mut self, old: &ParsedDocument, new: &ParsedDocument, now: Instant) { + pub(super) fn record( + &mut self, + old: &ParsedDocument, + new: &ParsedDocument, + now: Instant, + waiting: usize, + ) { let pending = std::mem::take(&mut self.pending); if !self.is_enabled() { self.segments.clear(); @@ -251,10 +306,13 @@ impl StreamFadeTracker { PendingUpdate::None => return, PendingUpdate::Replace => { self.segments.clear(); + self.last_update = None; + self.gap_average = None; return; } PendingUpdate::Extend { origin } => origin, }; + let fade = self.fade_for_update(now, waiting); let mut affected = Vec::new(); for block in new.blocks.iter().rev() { @@ -301,21 +359,54 @@ impl StreamFadeTracker { segments.push(FadeSegment { range: prefix..len, started_at: now, + duration: fade, }); continue; } let words = fade_units(leaf.chunks(), prefix, len); - let step = self.motion.stagger_step(words.len()); + let step = self.motion.stagger_step(words.len(), fade); for (ix, range) in words.into_iter().enumerate() { segments.push(FadeSegment { range, started_at: now + step * ix as u32, + duration: fade, }); } } self.segments.retain(|(_, segments)| !segments.is_empty()); } + /// How long the update recorded at `now` fades, with `waiting` updates + /// behind it, noting the gap since the last one. + fn fade_for_update(&mut self, now: Instant, waiting: usize) -> Duration { + let Some((min, max)) = self.motion.stream_fade_pacing else { + return self.motion.stream_fade; + }; + if let Some(last) = self.last_update.replace(now) { + // A gap past what the bounds can follow, such as a pause while + // the agent thinks, counts as the longest they can, so the + // average comes back within a few updates once the stream resumes. + let gap = now + .saturating_duration_since(last) + .clamp(min / PACED_GAPS_PER_FADE, max / PACED_GAPS_PER_FADE); + self.gap_average = Some(match self.gap_average { + None => gap, + Some(average) => { + average.mul_f64(1.0 - PACED_GAP_WEIGHT) + gap.mul_f64(PACED_GAP_WEIGHT) + } + }); + } + let fade = self + .gap_average + .map_or(self.motion.stream_fade, |average| { + average * PACED_GAPS_PER_FADE + }) + .clamp(min, max); + let backlog = i32::try_from(waiting) + .map_or(PACED_BACKLOG_MAX, |waiting| waiting.min(PACED_BACKLOG_MAX)); + fade.div_f64(PACED_BACKLOG_SPEEDUP.powi(backlog)) + } + /// Samples every unfinished segment at `now`, dropping the finished ones. /// `None` means nothing is fading, so no frame needs to follow. pub(super) fn frame( @@ -330,17 +421,17 @@ impl StreamFadeTracker { self.segments.clear(); return None; } - let timing = - Timing::new(self.motion.stream_fade).ease(self.motion.stream_fade_easing.clone()); + let easing = &self.motion.stream_fade_easing; let mut leaves = Vec::with_capacity(self.segments.len()); self.segments.retain_mut(|(key, segments)| { let mut fades = Vec::with_capacity(segments.len()); segments.retain(|segment| { - let sample = timing.sample(now.saturating_duration_since(segment.started_at)); - if sample.finished { + let elapsed = now.saturating_duration_since(segment.started_at); + if elapsed >= segment.duration { return false; } - let fade_out = (1.0 - sample.directed_progress).clamp(0.0, 1.0); + let progress = elapsed.as_secs_f32() / segment.duration.as_secs_f32(); + let fade_out = (1.0 - easing.sample(progress)).clamp(0.0, 1.0); fades.push((segment.range.clone(), fade_out)); true }); @@ -610,10 +701,16 @@ mod tests { let motion = TextViewMotion::default() .with_stream_fade(Duration::from_millis(600)) .with_stream_fade_stagger(Duration::from_millis(100)); - assert_eq!(motion.stagger_step(1), Duration::ZERO); - assert_eq!(motion.stagger_step(3), Duration::from_millis(100)); + assert_eq!(motion.stagger_step(1, motion.stream_fade), Duration::ZERO); + assert_eq!( + motion.stagger_step(3, motion.stream_fade), + Duration::from_millis(100) + ); // The 7th word starts at 600 ms, exactly one fade in -- still the stagger as asked. - assert_eq!(motion.stagger_step(7), Duration::from_millis(100)); + assert_eq!( + motion.stagger_step(7, motion.stream_fade), + Duration::from_millis(100) + ); } #[test] @@ -622,11 +719,11 @@ mod tests { .with_stream_fade(Duration::from_millis(600)) .with_stream_fade_stagger(Duration::from_millis(100)); // An 8th word would start past the fade: that is a sweep, not typing. - assert_eq!(motion.stagger_step(8), Duration::ZERO); - assert_eq!(motion.stagger_step(200), Duration::ZERO); + assert_eq!(motion.stagger_step(8, motion.stream_fade), Duration::ZERO); + assert_eq!(motion.stagger_step(200, motion.stream_fade), Duration::ZERO); // Without a stagger nothing changes -- every update was already one chunk. let plain = TextViewMotion::default().with_stream_fade(Duration::from_millis(600)); - assert_eq!(plain.stagger_step(200), Duration::ZERO); + assert_eq!(plain.stagger_step(200, plain.stream_fade), Duration::ZERO); } #[test] @@ -639,4 +736,239 @@ mod tests { assert_eq!(floor_char_boundary([new].into_iter(), prefix), 1); assert_eq!(floor_char_boundary(["a", "中"].into_iter(), 4), 4); } + + mod pacing { + use super::*; + use crate::text::{format::markdown, node::NodeContext}; + + const MIN: Duration = Duration::from_millis(120); + const MAX: Duration = Duration::from_millis(400); + const FIRST: Duration = Duration::from_millis(280); + + fn ms(ms: u64) -> Duration { + Duration::from_millis(ms) + } + + fn paced() -> TextViewMotion { + TextViewMotion::default() + .with_stream_fade(FIRST) + .with_stream_fade_easing(Easing::Linear) + .with_stream_fade_pacing(MIN, MAX) + } + + /// A stream of one paragraph, its updates recorded at chosen times. + struct Stream { + tracker: StreamFadeTracker, + text: String, + start: Instant, + } + + impl Stream { + fn new(motion: TextViewMotion) -> Self { + let mut tracker = StreamFadeTracker::default(); + tracker.set_motion(motion); + Self { + tracker, + text: "start".into(), + start: Instant::now(), + } + } + + /// Appends `word` at `at` after the start, with `waiting` updates + /// behind it, and returns how long its fade lasts. + fn append(&mut self, word: &str, at: Duration, waiting: usize) -> Duration { + let old = parse(&self.text); + self.tracker.note_extend(self.text.len()); + let range = self.text.len()..self.text.len() + word.len(); + self.text.push_str(word); + let now = self.start + at; + self.tracker.record(&old, &parse(&self.text), now, waiting); + self.fade_of(range, now) + } + + fn replace(&mut self, text: &str) { + let old = parse(&self.text); + self.tracker.note_replace(); + self.text = text.into(); + self.tracker.record(&old, &parse(&self.text), self.start, 0); + } + + /// How long the segment over `range` that starts at `now` lasts, + /// read off its linear fade. + fn fade_of(&mut self, range: Range, now: Instant) -> Duration { + let probe = ms(10); + let frame = self + .tracker + .frame(now + probe, false) + .expect("the update fades"); + let fade_out = frame + .fades(TextLeafKey::block(0)) + .and_then(|fades| fades.iter().find(|(r, _)| *r == range)) + .map(|(_, fade_out)| *fade_out) + .expect("the update's segment"); + let fade = probe.as_secs_f64() / f64::from(1.0 - fade_out); + // Rounded to the millisecond, as the fade is read back off a + // float. + ms((fade * 1000.0).round() as u64) + } + } + + fn parse(text: &str) -> ParsedDocument { + markdown::parse(text, &mut NodeContext::default()).expect("markdown parses") + } + + #[test] + fn the_first_update_fades_over_the_set_fade() { + let mut stream = Stream::new(paced()); + assert_eq!(stream.append(" one", ms(0), 0), FIRST); + } + + #[test] + fn a_paced_fade_lasts_three_gaps() { + let mut stream = Stream::new(paced()); + stream.append(" one", ms(0), 0); + assert_eq!(stream.append(" two", ms(50), 0), ms(150)); + // The average moves a third of the way to the next gap. + assert_eq!(stream.append(" three", ms(150), 0), ms(195)); + } + + #[test] + fn a_fast_stream_fades_over_the_shortest_and_a_slow_one_the_longest() { + let mut fast = Stream::new(paced()); + for n in 0..10 { + fast.append(" w", ms(n * 10), 0); + } + assert_eq!(fast.append(" w", ms(100), 0), MIN); + + let mut slow = Stream::new(paced()); + for n in 0..10 { + slow.append(" w", ms(n * 1_000), 0); + } + assert_eq!(slow.append(" w", ms(10_000), 0), MAX); + } + + /// A pause counts as the longest gap the bounds follow, so the fade + /// after it lengthens by a step, and the stream's pace returns within + /// a few updates. + #[test] + fn a_pause_moves_the_pace_by_one_step() { + let mut stream = Stream::new(paced()); + for n in 0..10 { + stream.append(" w", ms(n * 40), 0); + } + assert_eq!(stream.append(" w", ms(400), 0), MIN); + // 40 ms average, then a 133 ms gap at a weight of 0.3: 68 ms. + assert_eq!(stream.append(" w", ms(5_400), 0), ms(204)); + let mut fade = Duration::MAX; + for n in 1..=8 { + fade = stream.append(" w", ms(5_400 + n * 40), 0); + } + assert!(fade < ms(130), "back near the stream's pace: {fade:?}"); + } + + #[test] + fn updates_waiting_behind_speed_the_fade() { + let mut stream = Stream::new(paced()); + stream.append(" one", ms(0), 0); + stream.append(" two", ms(100), 0); + // A 300 ms fade, 1.3 times faster for each of two waiting. + assert_eq!(stream.append(" three", ms(200), 2), ms(178)); + let mut backlog = Stream::new(paced()); + // 280 ms over 1.3 to the eighth: as fast as a backlog goes. + assert_eq!(backlog.append(" one", ms(0), 1_000), ms(34)); + } + + #[test] + fn a_replaced_text_starts_its_pace_afresh() { + let mut stream = Stream::new(paced()); + for n in 0..10 { + stream.append(" w", ms(n * 10), 0); + } + stream.replace("other"); + assert_eq!(stream.append(" one", ms(5_000), 0), FIRST); + } + + /// The stagger is dropped for an update too large to light up within + /// the fade it is paced to, not the set one. + #[test] + fn the_stagger_fits_the_paced_fade() { + let motion = paced().with_stream_fade_stagger(ms(10)); + let mut stream = Stream::new(motion); + for n in 0..10 { + stream.append(" w", ms(n * 10), 0); + } + // 15 words would span 140 ms of stagger in a 120 ms fade. + let old = parse(&stream.text); + stream.tracker.note_extend(stream.text.len()); + let start = stream.text.len(); + stream.text.push_str(&" w".repeat(15)); + let now = stream.start + ms(100); + stream.tracker.record(&old, &parse(&stream.text), now, 0); + // Every word starts at once: half way through the fade, all are + // half faded. + let frame = stream.tracker.frame(now + ms(60), false).expect("fading"); + let fades = frame.fades(TextLeafKey::block(0)).expect("paragraph"); + let new: Vec<_> = fades + .iter() + .filter(|(range, _)| range.start >= start) + .map(|(_, fade_out)| *fade_out) + .collect(); + assert!(new.len() > 1); + assert!( + new.iter().all(|fade_out| (fade_out - 0.5).abs() < 1e-3), + "{new:?}" + ); + } + + #[test] + fn reduced_motion_drops_a_paced_fade() { + let mut stream = Stream::new(paced()); + stream.append(" one", ms(0), 0); + assert!(stream.tracker.frame(stream.start, true).is_none()); + assert!(stream.tracker.frame(stream.start, false).is_none()); + } + + /// What sampling a frame costs with 300 words mid-fade, paced and + /// not. Ignored by default; run it with + /// + /// ```text + /// cargo test -p gpui-base --lib --release paced_fade_frame_bench -- --ignored --nocapture + /// ``` + #[test] + #[ignore] + fn paced_fade_frame_bench() { + let fixed = TextViewMotion::default() + .with_stream_fade(MAX) + .with_stream_fade_stagger(ms(1)) + .with_stream_fade_easing(Easing::EaseOut); + let paced = fixed.clone().with_stream_fade_pacing(MAX, MAX); + let measure = |motion: TextViewMotion| { + let mut stream = Stream::new(motion); + for n in 0..30 { + let old = parse(&stream.text); + stream.tracker.note_extend(stream.text.len()); + stream.text.push_str(&" word".repeat(10)); + stream + .tracker + .record(&old, &parse(&stream.text), stream.start + ms(n), 0); + } + let samples = 20_000; + let now = stream.start + ms(40); + let begin = Instant::now(); + for _ in 0..samples { + std::hint::black_box(stream.tracker.frame(now, false)); + } + begin.elapsed().as_secs_f64() * 1e6 / f64::from(samples) + }; + let mut costs = [Vec::new(), Vec::new()]; + for _ in 0..9 { + costs[0].push(measure(fixed.clone())); + costs[1].push(measure(paced.clone())); + } + for (name, mut cost) in ["fixed", "paced"].into_iter().zip(costs) { + cost.sort_by(f64::total_cmp); + println!("{name}: median {:.2} µs per frame", cost[cost.len() / 2]); + } + } + } } diff --git a/website/base/text-view.md b/website/base/text-view.md index 6f65dcd4ed..57f224c5b9 100644 --- a/website/base/text-view.md +++ b/website/base/text-view.md @@ -265,6 +265,23 @@ append, and Markdown that completes as it streams (`**bo` becoming bold blocks the update reaches are compared, and frames are requested only while something is still fading. Reduced motion skips the fade. +A model streams at its own pace, and a fixed fade either pulses chunk by +chunk on a fast stream or finishes long before the next chunk on a slow one. +`with_stream_fade_pacing(min, max)` paces the fade to the stream instead: each +update fades over three times the running average of the gaps between +updates, kept between `min` and `max`. `stream_fade` still turns the fade on +and times the first update, before there is a gap to go by. A pause, such as +a model thinking, counts as the longest gap the bounds follow, so the pace +comes back within a few updates. An update parsed with more already waiting +behind it fades 1.3 times faster for each, so a backlog catches up. The +stagger compresses against the paced fade. + +```rust +TextViewMotion::default() + .with_stream_fade(Duration::from_millis(280)) + .with_stream_fade_pacing(Duration::from_millis(120), Duration::from_millis(400)) +``` + `TextViewState::set_range_highlights` paints backgrounds behind ranges of `rendered_text()`, the text plain copy produces, so an application can show its search results or citations without reparsing or restyling the document. diff --git a/website/zh-CN/base/text-view.md b/website/zh-CN/base/text-view.md index e69eb2387d..736efefe7e 100644 --- a/website/zh-CN/base/text-view.md +++ b/website/zh-CN/base/text-view.md @@ -231,6 +231,14 @@ TextView::new(&document).motion( 不设错位时每次更新整块一起淡入。设了错位时,追加的文字按词拆分(词带上其后的空白),中日韩文字按字拆分;一次追加很长时会压缩错位,保证最后一个词在一个淡入时长内开始。追踪器比较的是渲染后的文字而不是源码字节,因此 `set_text` 传入以当前文本为前缀的更长文本会被视为追加;流式过程中被补齐的 Markdown 标记(`**bo` 变成粗体 `bold`)只让发生变化的字形重新淡入,不会整段闪烁。每次只比较更新触及的块,并且只在还有文字在淡入时才请求下一帧。系统开启减少动态效果时跳过淡入。 +模型按自己的节奏流式输出。固定的淡入时长在快的流里会一块一块地闪,在慢的流里又会在下一块到达之前早早结束。`with_stream_fade_pacing(min, max)` 让淡入跟随流的节奏:每次更新的淡入时长是更新间隔滑动平均值的三倍,并限制在 `min` 与 `max` 之间。`stream_fade` 仍负责开启淡入,并决定第一次更新(还没有间隔可参考时)的时长。停顿(例如模型在思考)按上下限所能跟随的最长间隔计算,流恢复后几次更新内节奏就会回来。解析时后面已有更新在排队的,每多一个就快 1.3 倍,让积压尽快追上。错位按调整后的淡入时长压缩。 + +```rust +TextViewMotion::default() + .with_stream_fade(Duration::from_millis(280)) + .with_stream_fade_pacing(Duration::from_millis(120), Duration::from_millis(400)) +``` + `TextViewState::set_range_highlights` 在 `rendered_text()`(与纯文本复制得到的文字一致)的指定范围后面绘制背景,应用可以借此显示搜索结果或引用位置,无需重新解析或修改文档样式。这些范围只参与绘制、不参与排版,因此不会改变布局。Markdown 源码里的范围(例如 `selected_source_range()` 返回的范围)可以用 `rendered_text().range_for_source(range)` 转换过来。`reveal_range` 通过视图自身的列表、外层 `gpui::list`,或者其他容器上的 `TextView::on_reveal`,把范围起点所在的行滚动到可见区域内;规则详见[高亮文本范围](../component/text-view.md#高亮文本范围)和[滚动到范围](../component/text-view.md#滚动到范围)。 通过 `SelectionFormat` 可以选择复制渲染文本或 Markdown 源码。链接路由、代码块操作、表格操作、图片和 Markdown 插件继续使用与兼容 API 相同的 builder,详见 [gpui-component TextView 文档](../component/text-view.md)。