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)。