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:
30 files changed
+3125
-58
No files matched your search
@@ -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)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(®ex::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 }]);
|
||||
}
|
||||
}
|
||||
@@ -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), "←");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user