Every page and scripted state was shot with gpui 0.2.2 and with the port, light and dark, full width and at 280px, judged pair by pair by agents with skeptics, and every page's log diffed. What differed is fixed, each with a test that fails without it where the test platform can show it: - The gallery's harness takes posted mouse moves (gpui_macos listens to a tracking area), posts each drag move twice (it no longer replays a held drag), and waits three frames before each shot. - Rolling digits: a block column, so taffy 0.13 lends the cell no baseline, and a line of whole device pixels, since gpui snaps each length on its own. DescriptionList's row aligns ends. - Margins that taffy 0.13 collapses through a plain block: the drawer's pull and snap, the stagger and transition rise, the dialog and palette rises, the fullscreen dialog's slide. - An Input keeps room to type in a squeezed row; the field rows drop w_full, whose reason was taffy 0.9's. AGENTS.md's rules that named gpui 0.2.2 or taffy 0.9 were re-tested against both sources and rewritten, and new ones say what the port changed. TASKS.md's tags follow: what the new gpui makes possible is prove. Captions, doc comments and README say what holds now.
210 lines
6.4 KiB
Rust
210 lines
6.4 KiB
Rust
use gpui::{
|
|
Animation, AnimationExt, App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement,
|
|
RenderOnce, SharedString, Styled, Window, div,
|
|
};
|
|
|
|
use super::{
|
|
format::{self, Separators},
|
|
text::tabular,
|
|
};
|
|
use crate::{
|
|
motion,
|
|
theme::{ActiveTheme, TextSize},
|
|
};
|
|
|
|
const LEADING: f32 = 1.2;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum Tween {
|
|
Roll,
|
|
Count,
|
|
}
|
|
|
|
struct Change {
|
|
from: f64,
|
|
to: f64,
|
|
generation: u64,
|
|
}
|
|
|
|
/// Number that rolls its digits, or counts, to each new value.
|
|
#[derive(IntoElement)]
|
|
pub struct AnimatedNumber {
|
|
id: ElementId,
|
|
value: f64,
|
|
decimals: usize,
|
|
pad: usize,
|
|
color: Option<Hsla>,
|
|
tween: Tween,
|
|
size: TextSize,
|
|
}
|
|
|
|
impl AnimatedNumber {
|
|
pub fn new(id: impl Into<ElementId>, value: f64) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
value,
|
|
decimals: 0,
|
|
pad: 0,
|
|
color: None,
|
|
tween: Tween::Roll,
|
|
size: TextSize::Xxl,
|
|
}
|
|
}
|
|
|
|
pub fn decimals(mut self, decimals: usize) -> Self {
|
|
self.decimals = decimals;
|
|
self
|
|
}
|
|
|
|
/// Zero-pads to `width` characters, as a clock shows minutes.
|
|
pub fn pad(mut self, width: usize) -> Self {
|
|
self.pad = width;
|
|
self
|
|
}
|
|
|
|
/// Defaults to the text color.
|
|
pub fn color(mut self, color: Hsla) -> Self {
|
|
self.color = Some(color);
|
|
self
|
|
}
|
|
|
|
/// Counts through the values instead of rolling digits.
|
|
pub fn count_up(mut self) -> Self {
|
|
self.tween = Tween::Count;
|
|
self
|
|
}
|
|
|
|
pub fn size(mut self, size: TextSize) -> Self {
|
|
self.size = size;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Zero-pads `text` to `width` characters, after its minus sign.
|
|
fn padded(text: &str, width: usize) -> String {
|
|
match text.strip_prefix(format::MINUS) {
|
|
Some(digits) => format!("{}{digits:0>1$}", format::MINUS, width.saturating_sub(1)),
|
|
None => format!("{text:0>width$}"),
|
|
}
|
|
}
|
|
|
|
/// Pads the shorter string on the left so digits align by place.
|
|
fn align(from: &str, to: &str) -> (Vec<char>, Vec<char>) {
|
|
let width = from.chars().count().max(to.chars().count());
|
|
let pad = |text: &str| {
|
|
let mut chars = vec![' '; width - text.chars().count()];
|
|
chars.extend(text.chars());
|
|
chars
|
|
};
|
|
(pad(from), pad(to))
|
|
}
|
|
|
|
impl RenderOnce for AnimatedNumber {
|
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let value = self.value;
|
|
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Change {
|
|
from: value,
|
|
to: value,
|
|
generation: 0,
|
|
});
|
|
if state.read(cx).to.to_bits() != value.to_bits() {
|
|
state.update(cx, |change, _| {
|
|
change.from = change.to;
|
|
change.to = value;
|
|
change.generation += 1;
|
|
});
|
|
}
|
|
let (from, to, generation) = {
|
|
let change = state.read(cx);
|
|
(change.from, change.to, change.generation)
|
|
};
|
|
let (decimals, pad) = (self.decimals, self.pad);
|
|
let text =
|
|
move |number: f64| padded(&format::number(number, decimals, Separators::EN), pad);
|
|
let duration = motion::duration(motion::SLOW * 2, cx);
|
|
let theme = cx.theme();
|
|
let size = theme.text_size(self.size);
|
|
let scale = window.scale_factor();
|
|
// gpui snaps rows and margin apart; keep whole device pixels.
|
|
let line = (size.to_pixels(window.rem_size()) * LEADING * scale).round() / scale;
|
|
let row = tabular(div())
|
|
.debug_selector(|| format!("animated-number {}", self.id))
|
|
.id(self.id)
|
|
.flex()
|
|
.h(line)
|
|
.overflow_hidden()
|
|
.text_size(size)
|
|
.line_height(line)
|
|
.text_color(self.color.unwrap_or(theme.colors.fg));
|
|
|
|
if self.tween == Tween::Count {
|
|
return row.child(div().with_animation(
|
|
("count", generation),
|
|
Animation::new(duration),
|
|
move |label, t| {
|
|
let eased = f64::from(motion::ease_out_cubic(t));
|
|
label.child(text(from + (to - from) * eased))
|
|
},
|
|
));
|
|
}
|
|
let (before, after) = align(&text(from), &text(to));
|
|
row.children(
|
|
before
|
|
.into_iter()
|
|
.zip(after)
|
|
.enumerate()
|
|
.map(move |(ix, (old, new))| {
|
|
let Some(end) = new.to_digit(10) else {
|
|
return div()
|
|
.child(SharedString::from(new.to_string()))
|
|
.into_any_element();
|
|
};
|
|
let start = old.to_digit(10).unwrap_or(0);
|
|
// A block column lends the cell no baseline.
|
|
let stack = div().children((0..10).map(|digit| {
|
|
div()
|
|
.debug_selector(move || format!("rolling-{digit}"))
|
|
.h(line)
|
|
.child(SharedString::from(digit.to_string()))
|
|
}));
|
|
div()
|
|
.debug_selector(|| "rolling-cell".into())
|
|
.h(line)
|
|
.overflow_hidden()
|
|
.child(stack.with_animation(
|
|
("roll", generation * 1_000 + ix as u64),
|
|
Animation::new(duration),
|
|
move |stack, t| {
|
|
let place = motion::lerp(
|
|
start as f32,
|
|
end as f32,
|
|
motion::ease_out_cubic(t),
|
|
);
|
|
stack.mt(-(line * place))
|
|
},
|
|
))
|
|
.into_any_element()
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{align, padded};
|
|
|
|
#[test]
|
|
fn padding_keeps_the_minus_first() {
|
|
assert_eq!(padded("7", 2), "07");
|
|
assert_eq!(padded("\u{2212}12", 5), "\u{2212}0012");
|
|
assert_eq!(padded("123", 2), "123");
|
|
}
|
|
|
|
#[test]
|
|
fn align_pads_by_place() {
|
|
let (from, to) = align("999", "1,000");
|
|
assert_eq!(from.iter().collect::<String>(), " 999");
|
|
assert_eq!(to.iter().collect::<String>(), "1,000");
|
|
}
|
|
}
|