T02 typography: text styles, keys, formatters, fit, links, math, motion text

Headings to overlines, Code, Kbd and KbdCombo on gpui keystrokes,
Highlight, MiddleEllipsis and EllipsisTooltip, links, eight formatted
values on jiff with a typographic minus, CopyableText, SelectableText,
Emoji, a TeX subset, rolling AnimatedNumber, Typewriter, tonal
GradientText, ShimmerText. Gallery probes now hug their targets.
Codex gpt-6-astra (max): PASS in round 2.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 01:12:52 -04:00
1 parent a13343c2a5
commit b35530c80d
30 files changed
+3125 -58

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
use std::time::Duration;
use gpui::{
Animation, AnimationExt, App, ClipboardItem, ElementId, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Task, Window, div,
prelude::*,
};
use crate::{
buttons::IconButton,
motion,
primitives::{Icon, IconName, Tooltip},
theme::{ActiveTheme, ControlSize, IconSize},
};
const CONFIRM: Duration = Duration::from_millis(1400);
#[derive(Default)]
struct Copied {
count: u32,
_reset: Option<Task<()>>,
}
/// Text with a copy button that confirms with a check.
#[derive(IntoElement)]
pub struct CopyableText {
id: ElementId,
text: SharedString,
mono: bool,
}
impl CopyableText {
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
text: text.into(),
mono: false,
}
}
pub fn mono(mut self) -> Self {
self.mono = true;
self
}
}
impl RenderOnce for CopyableText {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Copied::default());
let count = state.read(cx).count;
let copied = state.read(cx)._reset.is_some();
let theme = cx.theme();
let (success, mono) = (theme.colors.success, theme.mono_family.clone());
let text = self.text.clone();
let button = IconButton::new("copy", IconName::Copy)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(text.to_string()));
let weak = state.downgrade();
let reset = window.spawn(cx, async move |cx| {
cx.background_executor().timer(CONFIRM).await;
if let Err(error) = weak.update(cx, |copied, cx| {
copied._reset = None;
cx.notify();
}) {
log::error!("copyable text: reset lost its state: {error:#}");
}
});
state.update(cx, |copied, cx| {
copied.count += 1;
copied._reset = Some(reset);
cx.notify();
});
});
let check = Icon::new(IconName::Check)
.size(IconSize::Sm)
.color(success)
.with_animation(
("copied", count),
Animation::new(motion::duration(motion::FAST, cx))
.with_easing(motion::ease_out_cubic),
|icon, t| icon.rotate(gpui::radians((1.0 - t) * -0.6)),
);
div()
.id(self.id)
.flex()
.items_center()
.gap_1()
.when(self.mono, |row| row.font_family(mono))
.child(self.text)
.child(
div()
.id("copy-slot")
.flex_none()
.size(theme.control_height(ControlSize::Sm))
.flex()
.items_center()
.justify_center()
.tooltip(Tooltip::text(if copied { "Copied" } else { "Copy" }))
.map(|slot| {
if copied {
slot.child(check)
} else {
slot.child(button)
}
}),
)
}
}
+186
View File
@@ -0,0 +1,186 @@
use std::{
hash::{DefaultHasher, Hash, Hasher},
ops::Range,
time::Duration,
};
use gpui::{
Animation, AnimationExt, App, ElementId, HighlightStyle, Hsla, IntoElement, ParentElement,
RenderOnce, SharedString, Styled, StyledText, Window, div, prelude::*, pulsating_between,
};
use unicode_segmentation::UnicodeSegmentation;
use crate::{
motion,
theme::{ActiveTheme, Mix, TextSize},
};
const SHIMMER: Duration = Duration::from_millis(1800);
const BLINK: Duration = Duration::from_millis(1100);
fn graphemes(text: &str) -> Vec<Range<usize>> {
text.grapheme_indices(true)
.map(|(start, grapheme)| start..start + grapheme.len())
.collect()
}
fn tinted(text: &SharedString, color: impl Fn(usize, usize) -> Hsla) -> StyledText {
let spans = graphemes(text);
let count = spans.len();
let runs: Vec<_> = spans
.into_iter()
.enumerate()
.map(|(ix, span)| {
let style = HighlightStyle {
color: Some(color(ix, count)),
..Default::default()
};
(span, style)
})
.collect();
StyledText::new(text.clone()).with_highlights(runs)
}
/// Reveals text one grapheme at a time, with a blinking caret.
#[derive(IntoElement)]
pub struct Typewriter {
id: ElementId,
text: SharedString,
per_second: f32,
}
impl Typewriter {
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
text: text.into(),
per_second: 32.0,
}
}
/// Graphemes per second.
pub fn speed(mut self, per_second: f32) -> Self {
assert!(per_second > 0.0, "typewriter speed must be positive");
self.per_second = per_second;
self
}
}
impl RenderOnce for Typewriter {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let text = self.text;
let spans = graphemes(&text);
let count = spans.len();
let full = Duration::from_secs_f32(count as f32 / self.per_second);
let reveal = motion::duration(full, cx);
let mut hasher = DefaultHasher::new();
text.hash(&mut hasher);
let theme = cx.theme();
let body = theme.text_size(TextSize::Base);
let caret = div().w(body * 0.15).h(body * 1.3).bg(theme.colors.fg);
let caret = if theme.reduced_motion {
caret.into_any_element()
} else {
caret
.with_animation(
"caret",
Animation::new(BLINK)
.repeat()
.with_easing(pulsating_between(0.15, 1.0)),
|caret, t| caret.opacity(t),
)
.into_any_element()
};
div()
.id(self.id)
.flex()
.items_center()
.gap_0p5()
.child(div().with_animation(
("typing", hasher.finish()),
Animation::new(reveal),
move |line, t| {
let shown = ((count as f32 * t).ceil() as usize).min(count);
let end = if shown == 0 { 0 } else { spans[shown - 1].end };
line.child(SharedString::from(text[..end].to_string()))
},
))
.child(caret)
}
}
/// Tonal ramp across the glyphs, ink to muted by default.
#[derive(IntoElement)]
pub struct GradientText {
text: SharedString,
ends: Option<(Hsla, Hsla)>,
}
impl GradientText {
pub fn new(text: impl Into<SharedString>) -> Self {
Self {
text: text.into(),
ends: None,
}
}
pub fn colors(mut self, from: impl Into<Hsla>, to: impl Into<Hsla>) -> Self {
self.ends = Some((from.into(), to.into()));
self
}
}
impl RenderOnce for GradientText {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let colors = &cx.theme().colors;
let (from, to) = self.ends.unwrap_or((colors.fg, colors.fg_subtle));
tinted(&self.text, |ix, count| {
from.mix(&to, ix as f32 / count.saturating_sub(1).max(1) as f32)
})
}
}
/// A band of light that sweeps the text while it waits.
#[derive(IntoElement)]
pub struct ShimmerText {
id: ElementId,
text: SharedString,
}
impl ShimmerText {
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
text: text.into(),
}
}
}
impl RenderOnce for ShimmerText {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (dim, lit) = (theme.colors.fg_subtle, theme.colors.fg);
let text = self.text;
if theme.reduced_motion {
return div()
.id(self.id)
.text_color(dim)
.child(text)
.into_any_element();
}
div()
.id(self.id)
.with_animation(
"shimmer",
Animation::new(SHIMMER).repeat(),
move |line, t| {
let band = motion::lerp(-0.3, 1.3, t);
line.child(tinted(&text, |ix, count| {
let x = ix as f32 / count.saturating_sub(1).max(1) as f32;
dim.mix(&lit, (1.0 - (x - band).abs() / 0.25).max(0.0))
}))
},
)
.into_any_element()
}
}
+49
View File
@@ -0,0 +1,49 @@
use gpui::{App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div};
use crate::theme::{ActiveTheme, TextSize};
/// One emoji in the system color font.
#[derive(IntoElement)]
pub struct Emoji {
glyph: SharedString,
size: TextSize,
}
impl Emoji {
pub fn new(glyph: impl Into<SharedString>) -> Self {
Self {
glyph: glyph.into(),
size: TextSize::Lg,
}
}
/// GitHub shortcode without colons; `None` if unknown.
pub fn shortcode(code: &str) -> Option<Self> {
emojis::get_by_shortcode(code).map(|emoji| Self::new(emoji.as_str()))
}
pub fn size(mut self, size: TextSize) -> Self {
self.size = size;
self
}
}
impl RenderOnce for Emoji {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
div()
.text_size(cx.theme().text_size(self.size))
.child(self.glyph)
}
}
#[cfg(test)]
mod tests {
use super::Emoji;
#[test]
fn shortcodes_resolve_or_refuse() {
let glyph = Emoji::shortcode("sparkles").map(|emoji| emoji.glyph.to_string());
assert_eq!(glyph.as_deref(), Some("✨"));
assert!(Emoji::shortcode("not_an_emoji_name").is_none());
}
}
+177
View File
@@ -0,0 +1,177 @@
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, ShapedLine,
SharedString, StatefulInteractiveElement, Styled, TextRun, Window, canvas, div, prelude::*,
relative,
};
use crate::{
primitives::Tooltip,
theme::{ActiveTheme, TextSize},
};
const LEADING: f32 = 1.4;
/// Keeps `kept` chars: half from the front, half from the back.
fn elide(chars: &[char], kept: usize) -> String {
let head = kept.div_ceil(2);
let tail = kept / 2;
let mut out: String = chars[..head].iter().collect();
out.push('…');
out.extend(&chars[chars.len() - tail..]);
out
}
fn shape(text: &str, window: &mut Window) -> ShapedLine {
let style = window.text_style();
let run = TextRun {
len: text.len(),
font: style.font(),
color: style.color,
background_color: None,
underline: None,
strikethrough: None,
};
let size = style.font_size.to_pixels(window.rem_size());
window
.text_system()
.shape_line(SharedString::from(text.to_string()), size, &[run], None)
}
fn width_of(text: &str, window: &mut Window) -> Pixels {
shape(text, window).width
}
/// Longest middle cut that fits `width`.
fn fit_middle(text: &str, width: Pixels, window: &mut Window) -> String {
if width_of(text, window) <= width {
return text.to_string();
}
if width_of("…", window) > width {
return String::new();
}
let chars: Vec<char> = text.chars().collect();
let (mut low, mut high) = (0, chars.len());
while low < high {
let mid = (low + high).div_ceil(2);
if width_of(&elide(&chars, mid), window) <= width {
low = mid;
} else {
high = mid - 1;
}
}
elide(&chars, low)
}
/// Shortens in the middle, keeping both ends. Paths, hashes.
#[derive(IntoElement)]
pub struct MiddleEllipsis {
text: SharedString,
}
impl MiddleEllipsis {
pub fn new(text: impl Into<SharedString>) -> Self {
Self { text: text.into() }
}
}
impl RenderOnce for MiddleEllipsis {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let size = theme.text_size(TextSize::Base);
let line = size.to_pixels(window.rem_size()) * LEADING;
let text = self.text;
div()
.w_full()
.h(line)
.overflow_hidden()
.text_size(size)
.text_color(theme.colors.fg)
.child(
canvas(
move |bounds, window, _| {
let shown = fit_middle(&text, bounds.size.width, window);
shape(&shown, window)
},
move |bounds, shaped, window, cx| {
shaped
.paint(bounds.origin, line, window, cx)
.expect("middle ellipsis failed to paint");
},
)
.size_full(),
)
}
}
/// Truncates at the end; the full text shows on hover only when cut.
#[derive(IntoElement)]
pub struct EllipsisTooltip {
id: ElementId,
text: SharedString,
}
impl EllipsisTooltip {
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
text: text.into(),
}
}
}
impl RenderOnce for EllipsisTooltip {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let cut = window.use_keyed_state(self.id.clone(), cx, |_, _| false);
let is_cut = *cut.read(cx);
let theme = cx.theme();
let measured = self.text.clone();
div()
.id(self.id)
.relative()
.w_full()
.truncate()
.text_size(theme.text_size(TextSize::Base))
.line_height(relative(LEADING))
.text_color(theme.colors.fg)
.child(self.text.clone())
.child(
canvas(
move |bounds, window, cx| {
let natural = width_of(&measured, window);
let overflow = natural > bounds.size.width;
if *cut.read(cx) != overflow {
log::debug!(
"ellipsis tooltip: {natural:?} in {:?}, cut {overflow}",
bounds.size.width
);
cut.update(cx, |cut, cx| {
*cut = overflow;
cx.notify();
});
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.when(is_cut, |el| el.tooltip(Tooltip::text(self.text)))
}
}
#[cfg(test)]
mod tests {
use super::elide;
#[test]
fn elide_keeps_both_ends() {
let chars: Vec<char> = "abcdefghij".chars().collect();
assert_eq!(elide(&chars, 4), "ab…ij");
assert_eq!(elide(&chars, 5), "abc…ij");
assert_eq!(elide(&chars, 0), "…");
let wide: Vec<char> = "路径/很长/文件.rs".chars().collect();
assert_eq!(elide(&wide, 4), "路径…rs");
}
}
+258
View File
@@ -0,0 +1,258 @@
use jiff::{Timestamp, fmt::strtime, tz::TimeZone};
/// Typographic minus; aligns with plus in tabular figures.
pub const MINUS: char = '\u{2212}';
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Separators {
pub group: char,
pub decimal: char,
}
impl Separators {
pub const EN: Self = Self {
group: ',',
decimal: '.',
};
}
/// Groups thousands. Non-finite values print as Rust prints them.
pub fn number(value: f64, decimals: usize, separators: Separators) -> String {
if !value.is_finite() {
return value.to_string();
}
let fixed = format!("{:.*}", decimals, value.abs());
let (int, fraction) = match fixed.split_once('.') {
Some((int, fraction)) => (int, Some(fraction)),
None => (fixed.as_str(), None),
};
let mut out = String::new();
if value < 0.0 && fixed.chars().any(|ch| ch.is_ascii_digit() && ch != '0') {
out.push(MINUS);
}
out.push_str(&group_digits(int, separators.group));
if let Some(fraction) = fraction {
out.push(separators.decimal);
out.push_str(fraction);
}
out
}
/// Inserts `group` every three digits from the right.
pub fn group_digits(digits: &str, group: char) -> String {
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (ix, digit) in digits.chars().enumerate() {
if ix > 0 && (digits.len() - ix).is_multiple_of(3) {
out.push(group);
}
out.push(digit);
}
out
}
/// ISO 4217 code to symbol and minor units. Unknown codes print as a prefix.
pub fn currency(amount: f64, code: &str) -> String {
let (symbol, decimals, spaced) = match code {
"USD" => ("$", 2, false),
"EUR" => ("€", 2, false),
"GBP" => ("£", 2, false),
"JPY" => ("¥", 0, false),
"CNY" => ("¥", 2, false),
"KRW" => ("₩", 0, false),
"INR" => ("₹", 2, false),
_ => (code, 2, true),
};
let body = number(amount.abs(), decimals, Separators::EN);
let negative = amount < 0.0 && body.chars().any(|ch| ch.is_ascii_digit() && ch != '0');
let sign = if negative {
MINUS.to_string()
} else {
String::new()
};
let gap = if spaced { " " } else { "" };
format!("{sign}{symbol}{gap}{body}")
}
/// `ratio` 0.1234 reads "12.34%". `signed` adds a plus to gains.
pub fn percent(ratio: f64, decimals: usize, signed: bool) -> String {
let body = number(ratio * 100.0, decimals, Separators::EN);
let gain = signed && ratio > 0.0 && body.chars().any(|ch| ch.is_ascii_digit() && ch != '0');
format!("{}{body}%", if gain { "+" } else { "" })
}
/// Decimal units (KB = 1000) or binary (KiB = 1024).
pub fn file_size(bytes: u64, binary: bool) -> String {
let (base, units) = if binary {
(1024.0, ["B", "KiB", "MiB", "GiB", "TiB", "PiB"])
} else {
(1000.0, ["B", "KB", "MB", "GB", "TB", "PB"])
};
let mut value = bytes as f64;
let mut unit = 0;
while value >= base && unit < units.len() - 1 {
value /= base;
unit += 1;
}
if unit == 0 {
return format!("{bytes} B");
}
let decimals = if value < 10.0 { 1 } else { 0 };
let shown = format!("{value:.decimals$}");
if shown.parse::<f64>().is_ok_and(|rounded| rounded >= base) && unit < units.len() - 1 {
return format!("1.0 {}", units[unit + 1]);
}
format!("{shown} {}", units[unit])
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DurationStyle {
/// `01:23:45`, or `23:45` under an hour.
Clock,
/// `1h 23m`, `4m 5s`.
Compact,
}
pub fn duration(seconds: u64, style: DurationStyle) -> String {
let (days, hours, minutes, secs) = (
seconds / 86_400,
seconds / 3_600,
seconds / 60 % 60,
seconds % 60,
);
match style {
DurationStyle::Clock if hours > 0 => format!("{hours:02}:{minutes:02}:{secs:02}"),
DurationStyle::Clock => format!("{minutes:02}:{secs:02}"),
DurationStyle::Compact if days > 0 => format!("{days}d {}h", hours % 24),
DurationStyle::Compact if hours > 0 => format!("{hours}h {minutes}m"),
DurationStyle::Compact if minutes > 0 => format!("{minutes}m {secs}s"),
DurationStyle::Compact => format!("{secs}s"),
}
}
/// "1 file", "12,345 files".
pub fn plural(count: u64, one: &str, other: &str) -> String {
let word = if count == 1 { one } else { other };
format!(
"{} {word}",
group_digits(&count.to_string(), Separators::EN.group)
)
}
/// "just now", "3 minutes ago", "in 2 days".
pub fn relative(then: Timestamp, now: Timestamp) -> String {
let delta = now.as_second() - then.as_second();
let seconds = delta.unsigned_abs();
if seconds < 45 {
return "just now".to_string();
}
let round = |unit: u64| (seconds + unit / 2) / unit;
let (count, unit) = match seconds {
s if s < 90 => (1, "minute"),
s if s < 45 * 60 => (round(60), "minute"),
s if s < 90 * 60 => (1, "hour"),
s if s < 22 * 3_600 => (round(3_600), "hour"),
s if s < 36 * 3_600 => (1, "day"),
s if s < 26 * 86_400 => (round(86_400), "day"),
s if s < 45 * 86_400 => (1, "month"),
s if s < 320 * 86_400 => (round(30 * 86_400), "month"),
s if s < 548 * 86_400 => (1, "year"),
_ => (round(365 * 86_400), "year"),
};
let label = plural(count, unit, &format!("{unit}s"));
if delta < 0 {
format!("in {label}")
} else {
format!("{label} ago")
}
}
/// strftime in `zone`; a bad pattern is an error.
pub fn datetime(at: Timestamp, zone: &TimeZone, pattern: &str) -> Result<String, jiff::Error> {
strtime::format(pattern, &at.to_zoned(zone.clone()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn number_groups_and_rounds() {
assert_eq!(number(1234567.891, 2, Separators::EN), "1,234,567.89");
assert_eq!(number(999.5, 0, Separators::EN), "1,000");
assert_eq!(number(12.0, 0, Separators::EN), "12");
assert_eq!(number(-1234.5, 1, Separators::EN), "−1,234.5");
assert_eq!(number(-0.001, 2, Separators::EN), "0.00");
let de = Separators {
group: '.',
decimal: ',',
};
assert_eq!(number(1234.5, 2, de), "1.234,50");
assert_eq!(number(f64::NAN, 2, Separators::EN), "NaN");
}
#[test]
fn currency_uses_symbols_and_minor_units() {
assert_eq!(currency(1234.5, "USD"), "$1,234.50");
assert_eq!(currency(-42.0, "EUR"), "−€42.00");
assert_eq!(currency(1500.4, "JPY"), "¥1,500");
assert_eq!(currency(9.99, "CHF"), "CHF 9.99");
}
#[test]
fn percent_signs_gains_only_when_asked() {
assert_eq!(percent(0.1234, 2, false), "12.34%");
assert_eq!(percent(0.05, 1, true), "+5.0%");
assert_eq!(percent(-0.031, 1, true), "−3.1%");
assert_eq!(percent(0.0, 1, true), "0.0%");
}
#[test]
fn file_size_picks_units_and_carries() {
assert_eq!(file_size(512, false), "512 B");
assert_eq!(file_size(1536, false), "1.5 KB");
assert_eq!(file_size(15_360, false), "15 KB");
assert_eq!(file_size(999_999, false), "1.0 MB");
assert_eq!(file_size(1536, true), "1.5 KiB");
assert_eq!(file_size(3 * 1024 * 1024 * 1024, true), "3.0 GiB");
}
#[test]
fn duration_in_both_styles() {
assert_eq!(duration(5_025, DurationStyle::Clock), "01:23:45");
assert_eq!(duration(1_425, DurationStyle::Clock), "23:45");
assert_eq!(duration(5_025, DurationStyle::Compact), "1h 23m");
assert_eq!(duration(245, DurationStyle::Compact), "4m 5s");
assert_eq!(duration(183_600, DurationStyle::Compact), "2d 3h");
assert_eq!(duration(12, DurationStyle::Compact), "12s");
}
#[test]
fn plural_counts() {
assert_eq!(plural(1, "file", "files"), "1 file");
assert_eq!(plural(0, "file", "files"), "0 files");
assert_eq!(plural(12_345, "file", "files"), "12,345 files");
let huge = plural(9_007_199_254_740_993, "file", "files");
assert_eq!(huge, "9,007,199,254,740,993 files");
}
#[test]
fn relative_reads_both_directions() {
let now = Timestamp::from_second(1_000_000_000).unwrap();
let ago = |s: i64| relative(Timestamp::from_second(1_000_000_000 - s).unwrap(), now);
assert_eq!(ago(10), "just now");
assert_eq!(ago(60), "1 minute ago");
assert_eq!(ago(180), "3 minutes ago");
assert_eq!(ago(7_200), "2 hours ago");
assert_eq!(ago(3 * 86_400), "3 days ago");
assert_eq!(ago(3 * 365 * 86_400), "3 years ago");
assert_eq!(ago(-120), "in 2 minutes");
}
#[test]
fn datetime_formats_in_zone() {
let at = Timestamp::from_second(0).unwrap();
let formatted = datetime(at, &TimeZone::UTC, "%Y-%m-%d %H:%M").unwrap();
assert_eq!(formatted, "1970-01-01 00:00");
assert!(datetime(at, &TimeZone::UTC, "%Y %").is_err());
}
}
+240
View File
@@ -0,0 +1,240 @@
use std::time::Duration;
use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Task, Window, div};
use jiff::{Timestamp, tz::TimeZone};
use super::{
format::{self, DurationStyle, Separators},
text::tabular,
};
use crate::theme::ActiveTheme;
const TICK: Duration = Duration::from_secs(30);
fn figure(text: String) -> impl IntoElement {
tabular(div()).child(text)
}
/// Grouped digits at fixed precision.
#[derive(IntoElement)]
pub struct NumberText {
value: f64,
decimals: usize,
}
impl NumberText {
pub fn new(value: f64) -> Self {
Self { value, decimals: 0 }
}
pub fn decimals(mut self, decimals: usize) -> Self {
self.decimals = decimals;
self
}
}
impl RenderOnce for NumberText {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
figure(format::number(self.value, self.decimals, Separators::EN))
}
}
/// Amount in an ISO 4217 currency.
#[derive(IntoElement)]
pub struct CurrencyText {
amount: f64,
code: &'static str,
}
impl CurrencyText {
pub fn new(amount: f64, code: &'static str) -> Self {
Self { amount, code }
}
}
impl RenderOnce for CurrencyText {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
figure(format::currency(self.amount, self.code))
}
}
/// Ratio as percent. `signed` shows direction in word and color.
#[derive(IntoElement)]
pub struct PercentText {
ratio: f64,
decimals: usize,
signed: bool,
}
impl PercentText {
pub fn new(ratio: f64) -> Self {
Self {
ratio,
decimals: 1,
signed: false,
}
}
pub fn decimals(mut self, decimals: usize) -> Self {
self.decimals = decimals;
self
}
pub fn signed(mut self) -> Self {
self.signed = true;
self
}
}
impl RenderOnce for PercentText {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let colors = &cx.theme().colors;
let tone = match (self.signed, self.ratio) {
(true, ratio) if ratio > 0.0 => colors.success,
(true, ratio) if ratio < 0.0 => colors.danger,
_ => colors.fg,
};
tabular(div()).text_color(tone).child(format::percent(
self.ratio,
self.decimals,
self.signed,
))
}
}
/// Bytes in KB, MB, GB; or KiB with `binary`.
#[derive(IntoElement)]
pub struct FileSizeText {
bytes: u64,
binary: bool,
}
impl FileSizeText {
pub fn new(bytes: u64) -> Self {
Self {
bytes,
binary: false,
}
}
pub fn binary(mut self) -> Self {
self.binary = true;
self
}
}
impl RenderOnce for FileSizeText {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
figure(format::file_size(self.bytes, self.binary))
}
}
/// Seconds as a clock or a compact span.
#[derive(IntoElement)]
pub struct DurationText {
seconds: u64,
style: DurationStyle,
}
impl DurationText {
pub fn new(seconds: u64, style: DurationStyle) -> Self {
Self { seconds, style }
}
}
impl RenderOnce for DurationText {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
figure(format::duration(self.seconds, self.style))
}
}
/// Count with the right noun.
#[derive(IntoElement)]
pub struct PluralText {
count: u64,
one: &'static str,
other: &'static str,
}
impl PluralText {
pub fn new(count: u64, one: &'static str, other: &'static str) -> Self {
Self { count, one, other }
}
}
impl RenderOnce for PluralText {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
figure(format::plural(self.count, self.one, self.other))
}
}
/// "3 minutes ago", kept fresh while on screen.
#[derive(IntoElement)]
pub struct RelativeTime {
id: ElementId,
at: Timestamp,
}
impl RelativeTime {
pub fn new(id: impl Into<ElementId>, at: Timestamp) -> Self {
Self { id: id.into(), at }
}
}
impl RenderOnce for RelativeTime {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
window.use_keyed_state(self.id, cx, |window, cx| -> Task<()> {
cx.spawn_in(window, async move |ticker, cx| {
loop {
cx.background_executor().timer(TICK).await;
if ticker.update(cx, |_, cx| cx.notify()).is_err() {
return;
}
}
})
});
figure(format::relative(self.at, Timestamp::now()))
}
}
/// A moment, strftime pattern, in the system zone unless told.
#[derive(IntoElement)]
pub struct DateTimeText {
at: Timestamp,
pattern: &'static str,
zone: Option<TimeZone>,
}
impl DateTimeText {
pub fn new(at: Timestamp) -> Self {
Self {
at,
pattern: "%b %-d, %Y · %H:%M",
zone: None,
}
}
pub fn pattern(mut self, pattern: &'static str) -> Self {
self.pattern = pattern;
self
}
pub fn zone(mut self, zone: TimeZone) -> Self {
self.zone = Some(zone);
self
}
}
impl RenderOnce for DateTimeText {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let zone = match self.zone {
Some(zone) => zone,
None => TimeZone::try_system().unwrap_or_else(|error| {
panic!("DateTimeText: system time zone unknown ({error}); pass .zone()")
}),
};
let text = format::datetime(self.at, &zone, self.pattern)
.unwrap_or_else(|error| panic!("DateTimeText pattern {:?}: {error}", self.pattern));
figure(text)
}
}
+226
View File
@@ -0,0 +1,226 @@
use std::ops::Range;
use gpui::{
AnyElement, App, FontWeight, HighlightStyle, IntoElement, Keystroke, ParentElement, RenderOnce,
SharedString, Styled, StyledText, Window, div,
};
use regex::RegexBuilder;
use smallvec::SmallVec;
use super::keys::{Platform, keystroke_labels};
use crate::theme::{ActiveTheme, Radius, TextSize};
/// Monospace fragment on a quiet fill.
#[derive(IntoElement)]
pub struct Code {
text: SharedString,
}
impl Code {
pub fn new(text: impl Into<SharedString>) -> Self {
Self { text: text.into() }
}
}
impl RenderOnce for Code {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
div()
.px_1()
.rounded(theme.radius(Radius::Sm))
.bg(theme.colors.sunken)
.border_1()
.border_color(theme.colors.border)
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg)
.child(self.text)
}
}
fn cap(label: String, cx: &App) -> impl IntoElement + use<> {
let theme = cx.theme();
div()
.min_w(theme.text_size(TextSize::Xs) * 1.8)
.px_1()
.flex()
.justify_center()
.rounded(theme.radius(Radius::Sm))
.border_1()
.border_b_2()
.border_color(theme.colors.border_strong)
.bg(theme.colors.surface)
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(theme.colors.fg_muted)
.child(label)
}
fn parse(source: &str) -> Keystroke {
Keystroke::parse(source).unwrap_or_else(|error| panic!("bad keystroke {source:?}: {error}"))
}
/// One key, labeled for this platform. Takes gpui key syntax.
#[derive(IntoElement)]
pub struct Kbd {
stroke: Keystroke,
}
impl Kbd {
/// `"secondary-s"` reads ⌘S on macOS and Ctrl S elsewhere.
pub fn new(source: &str) -> Self {
Self {
stroke: parse(source),
}
}
}
impl RenderOnce for Kbd {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let labels = keystroke_labels(&self.stroke, Platform::current());
div()
.flex()
.gap_0p5()
.children(labels.into_iter().map(|label| cap(label, cx)))
}
}
/// A chord sequence such as `"cmd-k cmd-s"`.
#[derive(IntoElement)]
pub struct KbdCombo {
strokes: SmallVec<[Keystroke; 2]>,
}
impl KbdCombo {
pub fn new(source: &str) -> Self {
Self {
strokes: source.split_whitespace().map(parse).collect(),
}
}
}
impl RenderOnce for KbdCombo {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let platform = Platform::current();
let subtle = cx.theme().colors.fg_subtle;
let spelled = platform != Platform::Mac;
div()
.flex()
.items_center()
.gap_2()
.children(self.strokes.iter().map(|stroke| {
let labels = keystroke_labels(stroke, platform);
let last = labels.len() - 1;
div().flex().items_center().gap_0p5().children(
labels.into_iter().enumerate().flat_map(|(ix, label)| {
let mut parts = vec![cap(label, cx).into_any_element()];
if spelled && ix < last {
parts.push(div().text_color(subtle).child("+").into_any_element());
}
parts
}),
)
}))
}
}
/// Quoted passage behind a rule.
#[derive(IntoElement)]
pub struct Blockquote {
children: SmallVec<[AnyElement; 2]>,
}
impl Blockquote {
pub fn new() -> Self {
Self {
children: SmallVec::new(),
}
}
}
impl Default for Blockquote {
fn default() -> Self {
Self::new()
}
}
impl ParentElement for Blockquote {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for Blockquote {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
div()
.pl_4()
.border_l_2()
.border_color(theme.colors.border_strong)
.text_color(theme.colors.fg_muted)
.text_size(theme.text_size(TextSize::Md))
.children(self.children)
}
}
/// Text with marked ranges, as for search matches.
#[derive(IntoElement)]
pub struct Highlight {
text: SharedString,
ranges: Vec<Range<usize>>,
}
impl Highlight {
/// Byte ranges on char boundaries.
pub fn new(text: impl Into<SharedString>, ranges: Vec<Range<usize>>) -> Self {
Self {
text: text.into(),
ranges,
}
}
/// Marks every case-insensitive occurrence of `query`.
pub fn matching(text: impl Into<SharedString>, query: &str) -> Self {
let text = text.into();
let ranges = if query.is_empty() {
Vec::new()
} else {
RegexBuilder::new(&regex::escape(query))
.case_insensitive(true)
.build()
.expect("an escaped literal is a valid regex")
.find_iter(&text)
.map(|found| found.range())
.collect()
};
Self { text, ranges }
}
}
impl RenderOnce for Highlight {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let colors = &cx.theme().colors;
let mark = HighlightStyle {
background_color: Some(colors.warning_subtle),
color: Some(colors.fg),
..Default::default()
};
StyledText::new(self.text)
.with_highlights(self.ranges.into_iter().map(|range| (range, mark)))
}
}
#[cfg(test)]
mod tests {
use super::{Highlight, Range};
#[test]
fn matching_finds_every_case_insensitive_hit() {
let found = Highlight::matching("Ely, ely, ELY and élan", "ely");
assert_eq!(found.ranges, [0..3, 5..8, 10..13]);
let none = Highlight::matching("abc", "");
assert!(none.ranges.is_empty());
let literal = Highlight::matching("a.b ab", ".");
assert_eq!(literal.ranges, vec![Range { start: 1, end: 2 }]);
}
}
+103
View File
@@ -0,0 +1,103 @@
use gpui::Keystroke;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Platform {
Mac,
Windows,
Linux,
}
impl Platform {
pub fn current() -> Self {
if cfg!(target_os = "macos") {
Platform::Mac
} else if cfg!(target_os = "windows") {
Platform::Windows
} else {
Platform::Linux
}
}
}
/// Caps for one keystroke, modifiers first, in platform order.
pub fn keystroke_labels(stroke: &Keystroke, platform: Platform) -> Vec<String> {
let m = &stroke.modifiers;
let mac = platform == Platform::Mac;
let super_key = if platform == Platform::Windows {
"Win"
} else {
"Super"
};
let order: [(bool, &str, &str); 5] = [
(m.control, "⌃", "Ctrl"),
(m.alt, "⌥", "Alt"),
(m.shift, "⇧", "Shift"),
(m.platform, "⌘", super_key),
(m.function, "fn", "Fn"),
];
let mut labels: Vec<String> = order
.iter()
.filter(|(held, _, _)| *held)
.map(|(_, mac_label, label)| if mac { *mac_label } else { *label }.to_string())
.collect();
labels.push(key_label(&stroke.key, platform));
labels
}
/// Cap text for a gpui key name.
pub fn key_label(key: &str, platform: Platform) -> String {
let mac = platform == Platform::Mac;
let named = match key {
"enter" => Some(if mac { "↩" } else { "Enter" }),
"escape" => Some(if mac { "⎋" } else { "Esc" }),
"tab" => Some(if mac { "⇥" } else { "Tab" }),
"backspace" => Some(if mac { "⌫" } else { "Backspace" }),
"delete" => Some(if mac { "⌦" } else { "Del" }),
"space" => Some("Space"),
"up" => Some("↑"),
"down" => Some("↓"),
"left" => Some("←"),
"right" => Some("→"),
"home" => Some("Home"),
"end" => Some("End"),
"pageup" => Some("PgUp"),
"pagedown" => Some("PgDn"),
_ => None,
};
match named {
Some(label) => label.to_string(),
None => key.to_uppercase(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(source: &str) -> Keystroke {
Keystroke::parse(source).expect("valid keystroke")
}
#[test]
fn mac_uses_symbols_in_apple_order() {
let labels = keystroke_labels(&parse("cmd-shift-p"), Platform::Mac);
assert_eq!(labels, ["⇧", "⌘", "P"]);
let labels = keystroke_labels(&parse("ctrl-alt-enter"), Platform::Mac);
assert_eq!(labels, ["⌃", "⌥", "↩"]);
}
#[test]
fn other_platforms_spell_modifiers() {
let labels = keystroke_labels(&parse("ctrl-shift-p"), Platform::Windows);
assert_eq!(labels, ["Ctrl", "Shift", "P"]);
let labels = keystroke_labels(&parse("cmd-escape"), Platform::Linux);
assert_eq!(labels, ["Super", "Esc"]);
}
#[test]
fn keys_keep_their_names_or_upper_case() {
assert_eq!(key_label("f12", Platform::Mac), "F12");
assert_eq!(key_label("space", Platform::Windows), "Space");
assert_eq!(key_label("left", Platform::Linux), "←");
}
}
+106
View File
@@ -0,0 +1,106 @@
use gpui::{
App, ClickEvent, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
transparent_black,
};
use crate::{
primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, IconSize, Radius},
};
type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
/// Inline link. Give it an `href` or an `on_click`.
#[derive(IntoElement)]
pub struct Link {
id: ElementId,
label: SharedString,
href: Option<SharedString>,
on_click: Option<ClickHandler>,
external: bool,
}
impl Link {
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
href: None,
on_click: None,
external: false,
}
}
/// Opens in the system browser.
pub fn href(mut self, url: impl Into<SharedString>) -> Self {
self.href = Some(url.into());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
}
impl RenderOnce for Link {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let color = theme.colors.link;
let action: ClickHandler = match (self.on_click, self.href) {
(Some(handler), _) => handler,
(None, Some(url)) => Box::new(move |_, _, cx| cx.open_url(&url)),
(None, None) => panic!("link {:?} has neither href nor on_click", self.id),
};
div()
.id(self.id)
.flex()
.items_center()
.gap_0p5()
.px_0p5()
.rounded(theme.radius(Radius::Sm))
.border_1()
.border_color(transparent_black())
.text_color(color)
.cursor_pointer()
.tab_index(0)
.focus_ring(cx)
.hover(|style| style.underline())
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(action)
.child(self.label)
.when(self.external, |link| {
link.child(
Icon::new(IconName::ArrowUpRight)
.size(IconSize::Xs)
.color(color),
)
})
}
}
/// Link that leaves the app, marked with an arrow.
#[derive(IntoElement)]
pub struct ExternalLink(Link);
impl ExternalLink {
pub fn new(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
url: impl Into<SharedString>,
) -> Self {
let mut link = Link::new(id, label).href(url);
link.external = true;
Self(link)
}
}
impl RenderOnce for ExternalLink {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
self.0
}
}
+354
View File
@@ -0,0 +1,354 @@
use std::fmt;
use gpui::{
AnyElement, App, IntoElement, ParentElement, Rems, RenderOnce, Styled, Window, div, prelude::*,
};
use crate::theme::{ActiveTheme, TextSize};
#[derive(Clone, Debug, PartialEq)]
enum Node {
Var(char),
Sym(String),
Op(String),
Word(String),
Row(Vec<Node>),
Script {
base: Box<Node>,
sup: Option<Box<Node>>,
sub: Option<Box<Node>>,
},
Frac(Box<Node>, Box<Node>),
Sqrt(Box<Node>),
}
#[derive(Debug, PartialEq, Eq)]
pub enum MathError {
Unknown(String),
Unbalanced,
MissingArgument(&'static str),
}
impl fmt::Display for MathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MathError::Unknown(name) => write!(f, "unknown command \\{name}"),
MathError::Unbalanced => write!(f, "unbalanced braces"),
MathError::MissingArgument(name) => write!(f, "\\{name} is missing an argument"),
}
}
}
impl std::error::Error for MathError {}
fn symbol(name: &str) -> Option<Node> {
let greek = "alpha α beta β gamma γ delta δ epsilon ε zeta ζ eta η theta θ iota ι kappa κ \
lambda λ mu μ nu ν xi ξ pi π rho ρ sigma σ tau τ upsilon υ phi φ chi χ psi ψ omega ω";
let upper = "Gamma Γ Delta Δ Theta Θ Lambda Λ Xi Ξ Pi Π Sigma Σ Phi Φ Psi Ψ Omega Ω";
let ops = "pm ± mp ∓ times × cdot · div ÷ leq ≤ geq ≥ neq ≠ approx ≈ equiv ≡ to → \
rightarrow → leftarrow ← in ∈ subset ⊂ cup ∪ cap ∩";
let plain = "infty ∞ partial ∂ nabla ∇ sum ∑ prod ∏ int ∫ forall ∀ exists ∃ ldots … cdots ⋯";
let find = |table: &str| {
let words: Vec<&str> = table.split_whitespace().collect();
words
.chunks(2)
.find(|pair| pair[0] == name)
.map(|pair| pair[1].to_string())
};
if let Some(glyph) = find(greek) {
return glyph.chars().next().map(Node::Var);
}
if let Some(glyph) = find(ops) {
return Some(Node::Op(glyph));
}
find(upper).or_else(|| find(plain)).map(Node::Sym)
}
const FUNCTIONS: [&str; 11] = [
"sin", "cos", "tan", "log", "ln", "exp", "lim", "max", "min", "det", "gcd",
];
struct Parser {
chars: Vec<char>,
at: usize,
}
impl Parser {
fn peek(&self) -> Option<char> {
self.chars.get(self.at).copied()
}
fn bump(&mut self) -> Option<char> {
let next = self.peek();
self.at += 1;
next
}
fn row(&mut self, closed: bool) -> Result<Vec<Node>, MathError> {
let mut row = Vec::new();
loop {
match self.peek() {
None if closed => return Err(MathError::Unbalanced),
None => return Ok(row),
Some('}') if closed => {
self.bump();
return Ok(row);
}
Some('}') => return Err(MathError::Unbalanced),
Some(ch) if ch.is_whitespace() => {
self.bump();
}
Some(mark @ ('^' | '_')) => {
self.bump();
let script = Box::new(self.argument(if mark == '^' { "^" } else { "_" })?);
let base = row.pop().unwrap_or(Node::Sym(String::new()));
row.push(attach(base, mark == '^', script));
}
Some(_) => {
let atom = self.atom()?;
row.push(atom);
}
}
}
}
fn atom(&mut self) -> Result<Node, MathError> {
match self.bump() {
Some('{') => Ok(Node::Row(self.row(true)?)),
Some('\\') => self.command(),
Some('-') => Ok(Node::Op("−".into())),
Some(ch @ ('+' | '=' | '<' | '>')) => Ok(Node::Op(ch.to_string())),
Some(ch) if ch.is_alphabetic() => Ok(Node::Var(ch)),
Some(ch) => Ok(Node::Sym(ch.to_string())),
None => Err(MathError::Unbalanced),
}
}
fn argument(&mut self, name: &'static str) -> Result<Node, MathError> {
while self.peek().is_some_and(char::is_whitespace) {
self.bump();
}
match self.peek() {
None | Some('}') => Err(MathError::MissingArgument(name)),
Some(_) => self.atom(),
}
}
/// `{...}` read as plain text, spaces kept.
fn raw_group(&mut self) -> Result<String, MathError> {
while self.peek().is_some_and(char::is_whitespace) {
self.bump();
}
if self.bump() != Some('{') {
return Err(MathError::MissingArgument("text"));
}
let mut depth = 1;
let mut text = String::new();
loop {
match self.bump().ok_or(MathError::Unbalanced)? {
'{' => depth += 1,
'}' if depth == 1 => return Ok(text),
'}' => depth -= 1,
_ => {}
}
text.push(self.chars[self.at - 1]);
}
}
fn command(&mut self) -> Result<Node, MathError> {
let start = self.at;
while self.peek().is_some_and(|ch| ch.is_ascii_alphabetic()) {
self.bump();
}
let name: String = self.chars[start..self.at].iter().collect();
if name.is_empty() {
let escaped = self.bump().ok_or(MathError::Unbalanced)?;
return Ok(Node::Sym(if escaped == ',' {
" ".into()
} else {
escaped.to_string()
}));
}
match name.as_str() {
"frac" => {
let top = self.argument("frac")?;
let bottom = self.argument("frac")?;
Ok(Node::Frac(Box::new(top), Box::new(bottom)))
}
"sqrt" => Ok(Node::Sqrt(Box::new(self.argument("sqrt")?))),
"text" => Ok(Node::Word(self.raw_group()?)),
"left" | "right" => self.argument("left"),
name if FUNCTIONS.contains(&name) => Ok(Node::Word(name.to_string())),
name => symbol(name).ok_or_else(|| MathError::Unknown(name.to_string())),
}
}
}
fn attach(base: Node, upper: bool, script: Box<Node>) -> Node {
match base {
Node::Script { base, sup, sub } if upper && sup.is_none() => Node::Script {
base,
sup: Some(script),
sub,
},
Node::Script { base, sup, sub } if !upper && sub.is_none() => Node::Script {
base,
sup,
sub: Some(script),
},
base => {
let (sup, sub) = if upper {
(Some(script), None)
} else {
(None, Some(script))
};
Node::Script {
base: Box::new(base),
sup,
sub,
}
}
}
}
fn draw(node: &Node, size: Rems, cx: &App) -> AnyElement {
let rule = cx.theme().colors.fg;
let script = size * 0.72;
let text = |content: String| div().text_size(size).child(content);
match node {
Node::Var(ch) => text(ch.to_string()).italic().into_any_element(),
Node::Sym(content) | Node::Word(content) => text(content.clone()).into_any_element(),
Node::Op(content) => text(content.clone()).px(size * 0.22).into_any_element(),
Node::Row(nodes) => div()
.flex()
.items_center()
.children(nodes.iter().map(|node| draw(node, size, cx)))
.into_any_element(),
Node::Script { base, sup, sub } => div()
.flex()
.items_center()
.child(draw(base, size, cx))
.child(
div()
.flex()
.flex_col()
.when(sub.is_none(), |col| col.mb(size * 0.55))
.when(sup.is_none(), |col| col.mt(size * 0.45))
.children(sup.iter().map(|node| draw(node, script, cx)))
.children(sub.iter().map(|node| draw(node, script, cx))),
)
.into_any_element(),
Node::Frac(top, bottom) => div()
.flex()
.flex_col()
.items_center()
.px(size * 0.12)
.child(draw(top, size * 0.85, cx))
.child(div().w_full().border_t_1().border_color(rule))
.child(draw(bottom, size * 0.85, cx))
.into_any_element(),
Node::Sqrt(body) => div()
.flex()
.items_center()
.child(text("√".into()))
.child(
div()
.border_t_1()
.border_color(rule)
.child(draw(body, size, cx)),
)
.into_any_element(),
}
}
/// Inline math from a TeX subset: scripts, `\frac`, `\sqrt`, Greek, operators.
#[derive(IntoElement)]
pub struct Latex {
root: Node,
size: TextSize,
}
impl Latex {
pub fn parse(source: &str) -> Result<Self, MathError> {
let mut parser = Parser {
chars: source.chars().collect(),
at: 0,
};
Ok(Self {
root: Node::Row(parser.row(false)?),
size: TextSize::Lg,
})
}
pub fn size(mut self, size: TextSize) -> Self {
self.size = size;
self
}
}
impl RenderOnce for Latex {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let size = theme.text_size(self.size);
div()
.text_color(theme.colors.fg)
.child(draw(&self.root, size, cx))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(source: &str) -> Result<Node, MathError> {
Latex::parse(source).map(|latex| latex.root)
}
#[test]
fn scripts_attach_to_the_previous_atom() {
let Node::Row(nodes) = parse("x_i^2 + y").unwrap() else {
panic!("row")
};
assert_eq!(nodes.len(), 3);
let Node::Script { base, sup, sub } = &nodes[0] else {
panic!("script")
};
assert_eq!(**base, Node::Var('x'));
assert_eq!(sup.as_deref(), Some(&Node::Sym("2".into())));
assert_eq!(sub.as_deref(), Some(&Node::Var('i')));
assert_eq!(nodes[1], Node::Op("+".into()));
}
#[test]
fn commands_build_structure() {
let Node::Row(nodes) = parse(r"\frac{\alpha}{2} \sqrt{x} \sin").unwrap() else {
panic!("row")
};
assert!(
matches!(&nodes[0], Node::Frac(top, _) if **top == Node::Row(vec![Node::Var('α')]))
);
assert!(matches!(&nodes[1], Node::Sqrt(_)));
assert_eq!(nodes[2], Node::Word("sin".into()));
}
#[test]
fn text_keeps_its_spaces() {
let Node::Row(nodes) = parse(r"\text{hello world} x").unwrap() else {
panic!("row")
};
assert_eq!(nodes[0], Node::Word("hello world".into()));
}
#[test]
fn errors_name_the_fault() {
assert_eq!(parse(r"\foo"), Err(MathError::Unknown("foo".into())));
assert_eq!(parse("{x"), Err(MathError::Unbalanced));
assert_eq!(parse("x}"), Err(MathError::Unbalanced));
assert_eq!(parse(r"\frac{a}"), Err(MathError::MissingArgument("frac")));
assert_eq!(
parse(r"\mathrm{x}"),
Err(MathError::Unknown("mathrm".into()))
);
assert_eq!(parse(r"\text{a"), Err(MathError::Unbalanced));
}
}
+29
View File
@@ -0,0 +1,29 @@
mod copy;
mod effects;
mod emoji;
mod fit;
pub mod format;
mod formatted;
mod inline;
pub mod keys;
mod link;
mod math;
mod rolling;
mod select;
mod text;
pub use copy::CopyableText;
pub use effects::{GradientText, ShimmerText, Typewriter};
pub use emoji::Emoji;
pub use fit::{EllipsisTooltip, MiddleEllipsis};
pub use format::DurationStyle;
pub use formatted::{
CurrencyText, DateTimeText, DurationText, FileSizeText, NumberText, PercentText, PluralText,
RelativeTime,
};
pub use inline::{Blockquote, Code, Highlight, Kbd, KbdCombo};
pub use link::{ExternalLink, Link};
pub use math::{Latex, MathError};
pub use rolling::AnimatedNumber;
pub use select::SelectableText;
pub use text::{Caption, Heading, Label, Overline, Paragraph, Subtitle, Title, tabular};
+170
View File
@@ -0,0 +1,170 @@
use gpui::{
Animation, AnimationExt, App, ElementId, 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,
tween: Tween,
size: TextSize,
}
impl AnimatedNumber {
pub fn new(id: impl Into<ElementId>, value: f64) -> Self {
Self {
id: id.into(),
value,
decimals: 0,
tween: Tween::Roll,
size: TextSize::Xxl,
}
}
pub fn decimals(mut self, decimals: usize) -> Self {
self.decimals = decimals;
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
}
}
/// 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 = self.decimals;
let text = move |number: f64| format::number(number, decimals, Separators::EN);
let duration = motion::duration(motion::SLOW * 2, cx);
let theme = cx.theme();
let size = theme.text_size(self.size);
let line = size.to_pixels(window.rem_size()) * LEADING;
let row = tabular(div())
.id(self.id)
.flex()
.h(line)
.overflow_hidden()
.text_size(size)
.line_height(line)
.text_color(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);
let stack =
div().flex().flex_col().children((0..10).map(|digit| {
div().h(line).child(SharedString::from(digit.to_string()))
}));
div()
.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;
#[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");
}
}
+133
View File
@@ -0,0 +1,133 @@
use std::ops::Range;
use gpui::{
App, ClipboardItem, CursorStyle, ElementId, FocusHandle, HighlightStyle, InteractiveElement,
IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, Styled, StyledText,
TextLayout, Window, div,
};
use crate::theme::ActiveTheme;
struct Selection {
text: SharedString,
focus: FocusHandle,
anchor: usize,
head: usize,
dragging: bool,
}
impl Selection {
fn range(&self) -> Range<usize> {
self.anchor.min(self.head)..self.anchor.max(self.head)
}
}
fn index_at(layout: &TextLayout, position: gpui::Point<gpui::Pixels>) -> usize {
match layout.index_for_position(position) {
Ok(ix) | Err(ix) => ix,
}
}
/// Read-only text the pointer can select and copy.
#[derive(IntoElement)]
pub struct SelectableText {
id: ElementId,
text: SharedString,
}
impl SelectableText {
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
text: text.into(),
}
}
}
impl RenderOnce for SelectableText {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| Selection {
text: self.text.clone(),
focus: cx.focus_handle(),
anchor: 0,
head: 0,
dragging: false,
});
if state.read(cx).text != self.text {
state.update(cx, |selection, _| {
selection.text = self.text.clone();
selection.anchor = 0;
selection.head = 0;
});
}
let (focus, range) = {
let selection = state.read(cx);
(selection.focus.clone(), selection.range())
};
let highlight = HighlightStyle {
background_color: Some(cx.theme().colors.selection),
..Default::default()
};
let styled = StyledText::new(self.text.clone())
.with_highlights((!range.is_empty()).then_some((range, highlight)));
let layout = styled.layout().clone();
let (down, drag, up, keys) = (state.clone(), state.clone(), state.clone(), state);
let (down_layout, drag_layout) = (layout.clone(), layout);
let text = self.text;
div()
.id(self.id)
.track_focus(&focus)
.cursor(CursorStyle::IBeam)
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
let ix = index_at(&down_layout, event.position);
down.update(cx, |selection, cx| {
window.focus(&selection.focus);
if !event.modifiers.shift {
selection.anchor = ix;
}
selection.head = ix;
selection.dragging = true;
cx.notify();
});
})
.on_mouse_move(move |event, _, cx| {
drag.update(cx, |selection, cx| {
if selection.dragging && event.dragging() {
selection.head = index_at(&drag_layout, event.position);
cx.notify();
}
})
})
.on_mouse_up(MouseButton::Left, {
let up = up.clone();
move |_, _, cx| up.update(cx, |selection, _| selection.dragging = false)
})
.on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
up.update(cx, |selection, _| selection.dragging = false)
})
.on_key_down(move |event, _, cx| {
let stroke = &event.keystroke;
if !stroke.modifiers.secondary() {
return;
}
match stroke.key.as_str() {
"c" => {
let range = keys.read(cx).range();
if !range.is_empty() {
cx.write_to_clipboard(ClipboardItem::new_string(
text[range].to_string(),
));
}
}
"a" => keys.update(cx, |selection, cx| {
selection.anchor = 0;
selection.head = text.len();
cx.notify();
}),
_ => {}
}
})
.child(styled)
}
}
+187
View File
@@ -0,0 +1,187 @@
use std::sync::Arc;
use gpui::{
App, Div, FontFeatures, FontWeight, Hsla, IntoElement, ParentElement, Refineable, RenderOnce,
SharedString, StyleRefinement, Styled, Window, div, relative,
};
use crate::theme::{ActiveTheme, Palette, TextSize};
/// Tabular numerals, so digits line up in columns.
pub fn tabular<E: Styled>(mut element: E) -> E {
let features = FontFeatures(Arc::new(vec![("tnum".into(), 1)]));
element
.text_style()
.get_or_insert_with(Default::default)
.font_features = Some(features);
element
}
struct Spec {
size: TextSize,
weight: FontWeight,
color: fn(&Palette) -> Hsla,
leading: f32,
}
fn styled(text: SharedString, spec: Spec, style: &StyleRefinement, cx: &App) -> Div {
let theme = cx.theme();
let mut base = div()
.text_size(theme.text_size(spec.size))
.font_weight(spec.weight)
.text_color((spec.color)(&theme.colors))
.line_height(relative(spec.leading));
base.style().refine(style);
base.child(text)
}
macro_rules! text_kind {
($(#[$doc:meta])* $name:ident, $size:ident, $weight:ident, $color:ident, $leading:expr) => {
$(#[$doc])*
#[derive(IntoElement)]
pub struct $name {
text: SharedString,
style: StyleRefinement,
}
impl $name {
pub fn new(text: impl Into<SharedString>) -> Self {
Self { text: text.into(), style: StyleRefinement::default() }
}
}
impl Styled for $name {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for $name {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let spec = Spec {
size: TextSize::$size,
weight: FontWeight::$weight,
color: |colors| colors.$color,
leading: $leading,
};
styled(self.text, spec, &self.style, cx)
}
}
};
}
text_kind!(Title, Xl, SEMIBOLD, fg, 1.3);
text_kind!(Subtitle, Lg, NORMAL, fg_muted, 1.45);
text_kind!(
/// Reading text. Loose leading.
Paragraph, Md, NORMAL, fg, 1.6
);
text_kind!(Label, Base, MEDIUM, fg, 1.4);
text_kind!(Caption, Sm, NORMAL, fg_muted, 1.45);
/// Small upper-case eyebrow above a heading.
#[derive(IntoElement)]
pub struct Overline {
text: SharedString,
style: StyleRefinement,
}
impl Overline {
pub fn new(text: impl Into<SharedString>) -> Self {
Self {
text: text.into(),
style: StyleRefinement::default(),
}
}
}
impl Styled for Overline {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Overline {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let spec = Spec {
size: TextSize::Xs,
weight: FontWeight::SEMIBOLD,
color: |colors| colors.fg_subtle,
leading: 1.4,
};
styled(self.text.to_uppercase().into(), spec, &self.style, cx)
}
}
/// H1 to H6.
#[derive(IntoElement)]
pub struct Heading {
level: u8,
text: SharedString,
style: StyleRefinement,
}
impl Heading {
/// Levels run 1 to 6; others panic.
pub fn new(level: u8, text: impl Into<SharedString>) -> Self {
assert!(
(1..=6).contains(&level),
"heading level {level} is not 1 to 6"
);
Self {
level,
text: text.into(),
style: StyleRefinement::default(),
}
}
pub fn h1(text: impl Into<SharedString>) -> Self {
Self::new(1, text)
}
pub fn h2(text: impl Into<SharedString>) -> Self {
Self::new(2, text)
}
pub fn h3(text: impl Into<SharedString>) -> Self {
Self::new(3, text)
}
pub fn h4(text: impl Into<SharedString>) -> Self {
Self::new(4, text)
}
pub fn h5(text: impl Into<SharedString>) -> Self {
Self::new(5, text)
}
pub fn h6(text: impl Into<SharedString>) -> Self {
Self::new(6, text)
}
}
impl Styled for Heading {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Heading {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let size = match self.level {
1 => TextSize::Display,
2 => TextSize::Xxl,
3 => TextSize::Xl,
4 => TextSize::Lg,
5 => TextSize::Md,
_ => TextSize::Base,
};
let spec = Spec {
size,
weight: FontWeight::SEMIBOLD,
color: |colors| colors.fg,
leading: 1.25,
};
styled(self.text, spec, &self.style, cx)
}
}