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
@@ -31,7 +31,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- Borders use gpui's fixed-pixel helpers (`border_1` is 1px). Hairlines do not scale.
|
||||
- One component, one home. Duplicates point to it.
|
||||
- Fail fast. No silent fallbacks. Log state changes with `log`.
|
||||
- Files stay under 500 lines. Comments are one short line, and rare.
|
||||
- Files stay at or under 500 lines. Comments are one short line, and rare.
|
||||
- `motion::duration()` honors reduced motion. Springs overshoot: use them inside animators, never as gpui easings, because gpui asserts eased values stay in `0..=1`.
|
||||
- `svg()` paints only with its own `text_color`. `Icon` always sets one.
|
||||
- Buttons stay out of focus on click (`prevent_default` on mouse down). Tab still reaches them.
|
||||
@@ -39,6 +39,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- `ely_gpui_component::init` binds Tab and Shift-Tab to `FocusNext` and `FocusPrev`. Wrap the root in `FocusScope` and focus its handle.
|
||||
- gpui's `Window::dispatch_event` returns a private type, so the gallery scripts mouse input by posting `NSEvent`s to its own queue.
|
||||
- `hover()` needs an element id.
|
||||
- Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders.
|
||||
- `img()` keeps loading state only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping.
|
||||
|
||||
## Decisions
|
||||
|
||||
Generated
+48
@@ -1522,12 +1522,16 @@ dependencies = [
|
||||
"cocoa 0.26.0",
|
||||
"core-foundation 0.10.0",
|
||||
"core-graphics 0.24.0",
|
||||
"emojis",
|
||||
"env_logger",
|
||||
"gpui",
|
||||
"image",
|
||||
"jiff",
|
||||
"log",
|
||||
"regex",
|
||||
"rust-embed",
|
||||
"smallvec",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1544,6 +1548,15 @@ dependencies = [
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "emojis"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b1514ced566c94991ed9563258ecf61e311fd773bf0a3f20606830386bf66e3"
|
||||
dependencies = [
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
@@ -2956,10 +2969,12 @@ dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"jiff-tzdb-platform",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2984,6 +2999,21 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb-platform"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
|
||||
dependencies = [
|
||||
"jiff-tzdb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
@@ -3869,6 +3899,24 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pico-args"
|
||||
version = "0.5.0"
|
||||
|
||||
@@ -14,10 +14,14 @@ runtime_shaders = ["gpui/runtime_shaders"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
emojis = "0.9"
|
||||
gpui = "0.2.2"
|
||||
jiff = "0.2"
|
||||
log = "0.4"
|
||||
regex = "1"
|
||||
rust-embed = "8.12"
|
||||
smallvec = "1"
|
||||
unicode-segmentation = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger = "0.11"
|
||||
|
||||
@@ -18,7 +18,7 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
|
||||
- [x] T00 Foundation: package, theme, motion, assets, Icon, Button, gallery shell, docs
|
||||
- [x] T01 Primitives — `tasks/ch01-10.md`
|
||||
- [ ] T02 Typography
|
||||
- [x] T02 Typography
|
||||
- [ ] T03 Layout
|
||||
- [ ] T04 Window & Shell
|
||||
- [ ] T05 Buttons & Actions
|
||||
@@ -108,3 +108,5 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T01 | 1 | FAIL | img loading needs an id; radii lost; focus loop cap; tooltip geometry literals |
|
||||
| T01 | 2 | FAIL | Cover painted outside the box once the clip was removed |
|
||||
| T01 | 3 | FAIL | two-line doc comment; fixed after the cap, verified by scan, no fourth review |
|
||||
| T02 | 1 | FAIL | stale selection; strftime and zone fallbacks; \\text flattening; probes missed targets; ellipsis overflow; u64 precision; caret sizes; LineClamp copy; 500-line wording |
|
||||
| T02 | 2 | PASS | |
|
||||
@@ -15,11 +15,11 @@ use crate::{
|
||||
const SETTLE: Duration = Duration::from_millis(700);
|
||||
const SCROLL_SETTLE: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Shoots every page in both modes, top to bottom, then quits. Exits 1 on failure.
|
||||
pub fn run(window: WindowHandle<Gallery>, dir: PathBuf, cx: &mut App) {
|
||||
/// Shoots one page, or all, in both modes, then quits. Exits 1 on failure.
|
||||
pub fn run(window: WindowHandle<Gallery>, dir: PathBuf, only: Option<usize>, cx: &mut App) {
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
cx.spawn(async move |cx| {
|
||||
let outcome = shoot_all(window, &dir, cx).await;
|
||||
let outcome = shoot_all(window, &dir, only, cx).await;
|
||||
if let Err(error) = outcome.and_then(|()| cx.update(|cx| cx.quit())) {
|
||||
log::error!("capture failed: {error:#}");
|
||||
std::process::exit(1);
|
||||
@@ -28,9 +28,18 @@ pub fn run(window: WindowHandle<Gallery>, dir: PathBuf, cx: &mut App) {
|
||||
.detach();
|
||||
}
|
||||
|
||||
async fn shoot_all(window: WindowHandle<Gallery>, dir: &Path, cx: &mut AsyncApp) -> Result<()> {
|
||||
async fn shoot_all(
|
||||
window: WindowHandle<Gallery>,
|
||||
dir: &Path,
|
||||
only: Option<usize>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<()> {
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
for (ix, page) in pages::ALL.iter().enumerate() {
|
||||
let chosen = pages::ALL
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(ix, _)| only.is_none_or(|only| only == *ix));
|
||||
for (ix, page) in chosen {
|
||||
for (choice, name) in [(Choice::Light, "light"), (Choice::Dark, "dark")] {
|
||||
window.update(cx, |gallery, window, cx| {
|
||||
gallery.select(ix, cx);
|
||||
|
||||
@@ -17,13 +17,13 @@ use gpui::{
|
||||
actions!(gallery, [Quit]);
|
||||
|
||||
struct Args {
|
||||
page: usize,
|
||||
page: Option<usize>,
|
||||
capture: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args> {
|
||||
let mut args = Args {
|
||||
page: 0,
|
||||
page: None,
|
||||
capture: None,
|
||||
};
|
||||
let mut iter = std::env::args().skip(1);
|
||||
@@ -33,7 +33,7 @@ fn parse_args() -> Result<Args> {
|
||||
.with_context(|| format!("{flag} needs a value"))?;
|
||||
match flag.as_str() {
|
||||
"--page" => {
|
||||
args.page = pages::find(&value).with_context(|| format!("no page {value}"))?
|
||||
args.page = Some(pages::find(&value).with_context(|| format!("no page {value}"))?)
|
||||
}
|
||||
"--capture" => args.capture = Some(PathBuf::from(value)),
|
||||
other => bail!("unknown flag {other}"),
|
||||
@@ -64,12 +64,12 @@ fn main() -> Result<()> {
|
||||
};
|
||||
let window = cx
|
||||
.open_window(options, |window, cx| {
|
||||
cx.new(|cx| shell::Gallery::new(args.page, window, cx))
|
||||
cx.new(|cx| shell::Gallery::new(args.page.unwrap_or(0), window, cx))
|
||||
})
|
||||
.expect("gallery window failed to open");
|
||||
cx.activate(true);
|
||||
if let Some(dir) = args.capture {
|
||||
capture::run(window, dir, cx);
|
||||
capture::run(window, dir, args.page, cx);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod buttons;
|
||||
mod primitives;
|
||||
mod theme;
|
||||
mod typography;
|
||||
|
||||
use gpui::{AnyElement, App, Window};
|
||||
|
||||
@@ -16,7 +17,12 @@ pub struct Page {
|
||||
pub script: &'static [Step],
|
||||
}
|
||||
|
||||
pub const ALL: &[Page] = &[primitives::PAGE, buttons::PAGE, theme::PAGE];
|
||||
pub const ALL: &[Page] = &[
|
||||
primitives::PAGE,
|
||||
typography::PAGE,
|
||||
buttons::PAGE,
|
||||
theme::PAGE,
|
||||
];
|
||||
|
||||
pub fn find(slug: &str) -> Option<usize> {
|
||||
ALL.iter().position(|page| page.slug == slug)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
mod motion;
|
||||
mod text;
|
||||
mod values;
|
||||
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
|
||||
|
||||
use super::Page;
|
||||
use crate::script::Step;
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 2,
|
||||
slug: "typography",
|
||||
title: "Typography",
|
||||
summary: "Inter for words, tabular figures for numbers, one ink for everything.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
|
||||
const SCRIPT: &[Step] = &[
|
||||
Step::Rest,
|
||||
Step::Hover("fit-tooltip"),
|
||||
Step::Wait(700),
|
||||
Step::Shot("ellipsis-tooltip"),
|
||||
Step::Rest,
|
||||
Step::ClickEnd("copy-token"),
|
||||
Step::Shot("copied"),
|
||||
Step::Click("select-text"),
|
||||
Step::Key("cmd-a"),
|
||||
Step::Shot("select-all"),
|
||||
Step::Rest,
|
||||
];
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div()
|
||||
.child(text::headings(cx))
|
||||
.child(text::title(cx))
|
||||
.child(text::paragraph(cx))
|
||||
.child(text::label(cx))
|
||||
.child(text::caption(cx))
|
||||
.child(text::overline(cx))
|
||||
.child(text::inline_code(cx))
|
||||
.child(text::kbd(cx))
|
||||
.child(text::kbd_combo(cx))
|
||||
.child(text::blockquote(cx))
|
||||
.child(text::highlight(cx))
|
||||
.child(text::truncate(cx))
|
||||
.child(text::line_clamp(cx))
|
||||
.child(text::middle(cx))
|
||||
.child(text::ellipsis_tooltip(cx))
|
||||
.child(text::link(cx))
|
||||
.child(text::external_link(cx))
|
||||
.child(values::relative_time(cx))
|
||||
.child(values::date_time(cx))
|
||||
.child(values::numbers(cx))
|
||||
.child(values::currency(cx))
|
||||
.child(values::percent(cx))
|
||||
.child(values::file_size(cx))
|
||||
.child(values::duration(cx))
|
||||
.child(values::plural(cx))
|
||||
.child(values::copyable(cx))
|
||||
.child(values::selectable(cx))
|
||||
.child(values::emoji(cx))
|
||||
.child(values::latex(cx))
|
||||
.child(motion::animated_number(window, cx))
|
||||
.child(motion::typewriter(window, cx))
|
||||
.child(motion::gradient(cx))
|
||||
.child(motion::shimmer(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
typography::{AnimatedNumber, GradientText, ShimmerText, Typewriter},
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, Styled, Task, Window, div};
|
||||
|
||||
use crate::ui::{row, section, specimen, specimens};
|
||||
|
||||
const STEP: Duration = Duration::from_millis(1600);
|
||||
const VALUES: [f64; 5] = [1_284.0, 1_917.0, 12_406.0, 9_998.0, 10_031.0];
|
||||
|
||||
struct Ticker {
|
||||
index: usize,
|
||||
_task: Task<()>,
|
||||
}
|
||||
|
||||
pub fn animated_number(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let ticker = window.use_keyed_state("type-ticker", cx, |window, cx| Ticker {
|
||||
index: 0,
|
||||
_task: cx.spawn_in(window, async move |ticker, cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(STEP).await;
|
||||
let advanced = ticker.update(cx, |ticker: &mut Ticker, cx| {
|
||||
ticker.index = (ticker.index + 1) % VALUES.len();
|
||||
cx.notify();
|
||||
});
|
||||
if advanced.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}),
|
||||
});
|
||||
let value = VALUES[ticker.read(cx).index];
|
||||
section(
|
||||
"AnimatedNumber",
|
||||
"Digits roll by place; or the value counts up.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.gap_12()
|
||||
.child(specimen("roll", AnimatedNumber::new("roll", value), cx))
|
||||
.child(specimen(
|
||||
"count up",
|
||||
AnimatedNumber::new("count", value * 3.7)
|
||||
.decimals(1)
|
||||
.count_up(),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn typewriter(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let take = window.use_keyed_state("type-take", cx, |_, _| 0usize);
|
||||
let lines = [
|
||||
"Writing, one grapheme at a time.",
|
||||
"中文与 emoji ✨ 也按字形逐个出现。",
|
||||
];
|
||||
let line = lines[*take.read(cx) % lines.len()];
|
||||
section(
|
||||
"Typewriter",
|
||||
"Reveals by grapheme, so emoji and CJK stay whole.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_6()
|
||||
.child(div().min_w_80().child(Typewriter::new("typewriter", line)))
|
||||
.child(
|
||||
Button::new("type-again", "Next line")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, _, cx| {
|
||||
take.update(cx, |take, cx| {
|
||||
*take += 1;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn gradient(cx: &App) -> impl IntoElement + use<> {
|
||||
let colors = &cx.theme().colors;
|
||||
let (ink, focus) = (colors.fg, colors.focus);
|
||||
let display = cx.theme().text_size(TextSize::Display);
|
||||
section("GradientText", "A tonal ramp, glyph by glyph. No neon.", cx).child(
|
||||
specimens()
|
||||
.gap_10()
|
||||
.child(specimen(
|
||||
"ink to muted",
|
||||
div()
|
||||
.text_size(display)
|
||||
.child(GradientText::new("Quiet type")),
|
||||
cx,
|
||||
))
|
||||
.child(specimen(
|
||||
"ink to focus",
|
||||
div()
|
||||
.text_size(display)
|
||||
.child(GradientText::new("Quiet type").colors(ink, focus)),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn shimmer(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"ShimmerText",
|
||||
"A slow band of light while something loads.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(cx.theme().text_size(TextSize::Lg))
|
||||
.child(ShimmerText::new("shimmer", "Thinking through the request…")),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
use ely_gpui_component::{
|
||||
theme::{ActiveTheme, Radius},
|
||||
typography::{
|
||||
Blockquote, Caption, Code, EllipsisTooltip, ExternalLink, Heading, Highlight, Kbd,
|
||||
KbdCombo, Label, Link, MiddleEllipsis, Overline, Paragraph, Subtitle, Title,
|
||||
},
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, Styled, div, px};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{code, row, section, specimen, specimens},
|
||||
};
|
||||
|
||||
const PROSE: &str = "Good interfaces are quiet. They leave room around each idea, \
|
||||
use one voice for text, and save color for the moments that need it. The rest is spacing.";
|
||||
|
||||
pub fn headings(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"Heading (H1–H6)",
|
||||
"Six steps, one weight. Size carries the order.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(Heading::h1("Quiet interfaces"))
|
||||
.child(Heading::h2("Quiet interfaces"))
|
||||
.child(Heading::h3("Quiet interfaces"))
|
||||
.child(Heading::h4("Quiet interfaces"))
|
||||
.child(Heading::h5("Quiet interfaces"))
|
||||
.child(Heading::h6("Quiet interfaces")),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn title(cx: &App) -> impl IntoElement + use<> {
|
||||
section("Title / Subtitle", "A pair for page and panel headers.", cx).child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(Title::new("Quarterly review"))
|
||||
.child(Subtitle::new("Numbers, notes and the next three moves.")),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn paragraph(cx: &App) -> impl IntoElement + use<> {
|
||||
section("Paragraph", "Reading text at 14 with loose leading.", cx)
|
||||
.child(Paragraph::new(PROSE).max_w(px(560.0)))
|
||||
}
|
||||
|
||||
pub fn label(cx: &App) -> impl IntoElement + use<> {
|
||||
section("Label", "Names a control or a value.", cx).child(
|
||||
row()
|
||||
.child(Label::new("Display name"))
|
||||
.child(Label::new("Workspace")),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn caption(cx: &App) -> impl IntoElement + use<> {
|
||||
section("Caption", "The quiet line beneath.", cx)
|
||||
.child(Caption::new("Updated a moment ago · visible to your team"))
|
||||
}
|
||||
|
||||
pub fn overline(cx: &App) -> impl IntoElement + use<> {
|
||||
section("Overline", "An eyebrow in small capitals.", cx).child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(Overline::new("Release notes"))
|
||||
.child(Title::new("Version 0.1")),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn inline_code(cx: &App) -> impl IntoElement + use<> {
|
||||
section("Code", "Monospace on a quiet fill.", cx).child(
|
||||
row()
|
||||
.child("Run")
|
||||
.child(Code::new("cargo run --example gallery"))
|
||||
.child("to open this window."),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn kbd(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"Kbd",
|
||||
"gpui key syntax. secondary means ⌘ on macOS and Ctrl elsewhere.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.child(specimen("secondary-s", Kbd::new("secondary-s"), cx))
|
||||
.child(specimen("shift-tab", Kbd::new("shift-tab"), cx))
|
||||
.child(specimen("escape", Kbd::new("escape"), cx))
|
||||
.child(specimen("enter", Kbd::new("enter"), cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn kbd_combo(cx: &App) -> impl IntoElement + use<> {
|
||||
section("KbdCombo", "Sequences read left to right.", cx).child(
|
||||
specimens()
|
||||
.child(specimen(
|
||||
"secondary-shift-p",
|
||||
KbdCombo::new("secondary-shift-p"),
|
||||
cx,
|
||||
))
|
||||
.child(specimen(
|
||||
"secondary-k secondary-s",
|
||||
KbdCombo::new("secondary-k secondary-s"),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn blockquote(cx: &App) -> impl IntoElement + use<> {
|
||||
section("Blockquote", "A rule and a softer ink.", cx).child(div().max_w(px(520.0)).child(
|
||||
Blockquote::new().child(
|
||||
"Simplicity is not the absence of clutter. It is the right thing, in the right place.",
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn highlight(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"Highlight / Mark",
|
||||
"Every match of a query, case-insensitive.",
|
||||
cx,
|
||||
)
|
||||
.child(Highlight::matching(
|
||||
"Quiet interfaces age well. Loud ones AGE fast.",
|
||||
"age",
|
||||
))
|
||||
.child(code("Highlight::matching(text, \"age\")", cx))
|
||||
}
|
||||
|
||||
fn narrow(cx: &App) -> gpui::Div {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.w(px(240.0))
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
}
|
||||
|
||||
pub fn truncate(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"Truncate",
|
||||
"gpui's .truncate(): one line, an ellipsis at the end.",
|
||||
cx,
|
||||
)
|
||||
.child(narrow(cx).child(div().truncate().child(PROSE)))
|
||||
}
|
||||
|
||||
pub fn line_clamp(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"LineClamp",
|
||||
"gpui's .line_clamp(n): n lines, then a hard cut.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
narrow(cx)
|
||||
.w(px(360.0))
|
||||
.child(div().line_clamp(2).child(PROSE)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn middle(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"MiddleEllipsis",
|
||||
"Cuts the middle so both ends stay legible.",
|
||||
cx,
|
||||
)
|
||||
.child(narrow(cx).w(px(280.0)).child(MiddleEllipsis::new(
|
||||
"~/Documents/GitHub/Ely-GPUI-Components/src/typography/fit.rs",
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn ellipsis_tooltip(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"EllipsisTooltip",
|
||||
"Hover shows the whole line, only when it was cut.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"fit-tooltip",
|
||||
narrow(cx).child(EllipsisTooltip::new(
|
||||
"fit-tooltip-text",
|
||||
"Design review · Tuesday, with the full product team and guests",
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn link(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"Link",
|
||||
"Blue marks where a click goes. Underline on hover.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.child("Read the")
|
||||
.child(
|
||||
Link::new("guide", "style guide").on_click(|_, _, _| log::info!("gallery: link")),
|
||||
)
|
||||
.child("before you start."),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn external_link(cx: &App) -> impl IntoElement + use<> {
|
||||
section("ExternalLink", "Leaves the app. The arrow says so.", cx).child(ExternalLink::new(
|
||||
"gpui",
|
||||
"gpui.rs",
|
||||
"https://www.gpui.rs",
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use ely_gpui_component::{
|
||||
theme::TextSize,
|
||||
typography::{
|
||||
CopyableText, CurrencyText, DateTimeText, DurationStyle, DurationText, Emoji, FileSizeText,
|
||||
Latex, NumberText, PercentText, PluralText, RelativeTime, SelectableText,
|
||||
},
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, Styled, div, px};
|
||||
use jiff::{SignedDuration, Timestamp};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{code, section, specimen, specimens},
|
||||
};
|
||||
|
||||
/// Fixed moment, so shots repeat: 24 Sep 2026, 09:41 UTC.
|
||||
fn moment() -> Timestamp {
|
||||
Timestamp::from_second(1_790_242_860).expect("valid timestamp")
|
||||
}
|
||||
|
||||
pub fn relative_time(cx: &App) -> impl IntoElement + use<> {
|
||||
let ago = |seconds: i64| {
|
||||
Timestamp::now()
|
||||
.checked_sub(SignedDuration::from_secs(seconds))
|
||||
.expect("in range")
|
||||
};
|
||||
section(
|
||||
"RelativeTime",
|
||||
"Refreshes every thirty seconds while on screen.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.child(specimen("10 s", RelativeTime::new("rel-a", ago(10)), cx))
|
||||
.child(specimen("3 min", RelativeTime::new("rel-b", ago(180)), cx))
|
||||
.child(specimen("2 h", RelativeTime::new("rel-c", ago(7_200)), cx))
|
||||
.child(specimen(
|
||||
"3 d",
|
||||
RelativeTime::new("rel-d", ago(259_200)),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn date_time(cx: &App) -> impl IntoElement + use<> {
|
||||
section("DateTimeText", "strftime patterns in the system zone.", cx).child(
|
||||
specimens()
|
||||
.child(specimen("default", DateTimeText::new(moment()), cx))
|
||||
.child(specimen(
|
||||
"%Y-%m-%d",
|
||||
DateTimeText::new(moment()).pattern("%Y-%m-%d"),
|
||||
cx,
|
||||
))
|
||||
.child(specimen(
|
||||
"%A %H:%M",
|
||||
DateTimeText::new(moment()).pattern("%A %H:%M"),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn numbers(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"NumberText",
|
||||
"Grouped, fixed precision, tabular figures.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.child(specimen(
|
||||
"2 decimals",
|
||||
NumberText::new(1_234_567.891).decimals(2),
|
||||
cx,
|
||||
))
|
||||
.child(specimen("integer", NumberText::new(42_000.0), cx))
|
||||
.child(specimen(
|
||||
"negative",
|
||||
NumberText::new(-1_234.5).decimals(1),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn currency(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"CurrencyText",
|
||||
"ISO 4217 codes. Yen and won have no minor units.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.child(specimen("USD", CurrencyText::new(1_234.5, "USD"), cx))
|
||||
.child(specimen("EUR", CurrencyText::new(-42.0, "EUR"), cx))
|
||||
.child(specimen("JPY", CurrencyText::new(1_500.4, "JPY"), cx))
|
||||
.child(specimen("CHF", CurrencyText::new(9.99, "CHF"), cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn percent(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"PercentText",
|
||||
"Signed values carry direction in word and color.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.child(specimen("plain", PercentText::new(0.1234).decimals(2), cx))
|
||||
.child(specimen("gain", PercentText::new(0.052).signed(), cx))
|
||||
.child(specimen("loss", PercentText::new(-0.031).signed(), cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn file_size(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"FileSizeText",
|
||||
"Decimal units by default, binary on request.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.child(specimen("1536 B", FileSizeText::new(1_536), cx))
|
||||
.child(specimen("3.2 GB", FileSizeText::new(3_200_000_000), cx))
|
||||
.child(specimen("binary", FileSizeText::new(3 << 30).binary(), cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn duration(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"DurationText",
|
||||
"A clock for media, a compact span for logs.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.child(specimen(
|
||||
"clock",
|
||||
DurationText::new(5_025, DurationStyle::Clock),
|
||||
cx,
|
||||
))
|
||||
.child(specimen(
|
||||
"clock",
|
||||
DurationText::new(1_425, DurationStyle::Clock),
|
||||
cx,
|
||||
))
|
||||
.child(specimen(
|
||||
"compact",
|
||||
DurationText::new(245, DurationStyle::Compact),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn plural(cx: &App) -> impl IntoElement + use<> {
|
||||
section("PluralText", "The noun follows the count.", cx).child(
|
||||
specimens()
|
||||
.child(specimen("1", PluralText::new(1, "file", "files"), cx))
|
||||
.child(specimen(
|
||||
"12345",
|
||||
PluralText::new(12_345, "file", "files"),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn copyable(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"CopyableText",
|
||||
"One click copies. A check confirms, then leaves.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"copy-token",
|
||||
CopyableText::new("token", "ely_live_4f9a2c81d7").mono(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn selectable(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"SelectableText",
|
||||
"Drag to select. ⌘C copies, ⌘A takes it all.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"select-text",
|
||||
div().max_w(px(520.0)).child(SelectableText::new(
|
||||
"selectable",
|
||||
"Selection works on read-only text too: logs, messages, receipts.",
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn emoji(cx: &App) -> impl IntoElement + use<> {
|
||||
let glyph = |code: &str| Emoji::shortcode(code).expect("known shortcode");
|
||||
section("Emoji", "Shortcodes resolve to the system color font.", cx).child(
|
||||
specimens()
|
||||
.child(specimen("sparkles", glyph("sparkles"), cx))
|
||||
.child(specimen("herb", glyph("herb"), cx))
|
||||
.child(specimen("coffee", glyph("coffee"), cx))
|
||||
.child(specimen("xl", glyph("sparkles").size(TextSize::Xxl), cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn latex(cx: &App) -> impl IntoElement + use<> {
|
||||
let math = |source: &str| Latex::parse(source).expect("gallery formula parses");
|
||||
section(
|
||||
"Latex / MathInline",
|
||||
"A TeX subset: scripts, fractions, roots, Greek.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.gap_10()
|
||||
.child(specimen("euler", math(r"e^{i\pi} + 1 = 0"), cx))
|
||||
.child(specimen(
|
||||
"sum",
|
||||
math(r"\sum_{k=1}^{n} k = \frac{n(n+1)}{2}"),
|
||||
cx,
|
||||
))
|
||||
.child(specimen(
|
||||
"root",
|
||||
math(r"x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}"),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.child(code("Latex::parse(r\"e^{i\\pi} + 1 = 0\")?", cx))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ely_gpui_component::primitives::Measure;
|
||||
use gpui::{App, Bounds, Global, IntoElement, ParentElement, Pixels};
|
||||
use gpui::{App, Bounds, Div, Global, IntoElement, ParentElement, Pixels, Styled, div};
|
||||
|
||||
/// Window bounds of named demo elements, for scripted capture.
|
||||
#[derive(Default)]
|
||||
@@ -16,10 +16,12 @@ impl Probes {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps `element` so scripts can find it by `key`.
|
||||
pub fn probe(key: &'static str, element: impl IntoElement) -> Measure {
|
||||
Measure::new(key, move |bounds, _, cx| {
|
||||
cx.default_global::<Probes>().0.insert(key, bounds);
|
||||
})
|
||||
.child(element)
|
||||
/// Wraps `element`, sized to it, so scripts can find it by `key`.
|
||||
pub fn probe(key: &'static str, element: impl IntoElement) -> Div {
|
||||
div().flex().child(
|
||||
Measure::new(key, move |bounds, _, cx| {
|
||||
cx.default_global::<Probes>().0.insert(key, bounds);
|
||||
})
|
||||
.child(element),
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{AsyncApp, Keystroke, Pixels, Point, WindowHandle, point, px};
|
||||
use gpui::{AsyncApp, Bounds, Keystroke, Pixels, Point, WindowHandle, point, px};
|
||||
|
||||
use crate::{capture::snapshot, probe::Probes, shell::Gallery};
|
||||
|
||||
@@ -12,6 +12,8 @@ pub enum Step {
|
||||
Down(&'static str),
|
||||
Up(&'static str),
|
||||
Click(&'static str),
|
||||
/// Clicks just inside the probe's right edge.
|
||||
ClickEnd(&'static str),
|
||||
Key(&'static str),
|
||||
Wait(u64),
|
||||
Shot(&'static str),
|
||||
@@ -59,6 +61,13 @@ pub async fn play(
|
||||
send(window, Mouse::Down, at, cx)?;
|
||||
send(window, Mouse::Up, at, cx)?;
|
||||
}
|
||||
Step::ClickEnd(key) => {
|
||||
let bounds = target_bounds(window, key, cx).await?;
|
||||
let at = point(bounds.right() - px(12.0), bounds.center().y);
|
||||
send(window, Mouse::Move, at, cx)?;
|
||||
send(window, Mouse::Down, at, cx)?;
|
||||
send(window, Mouse::Up, at, cx)?;
|
||||
}
|
||||
Step::Key(stroke) => {
|
||||
let stroke =
|
||||
Keystroke::parse(stroke).with_context(|| format!("bad keystroke {stroke}"))?;
|
||||
@@ -135,14 +144,20 @@ async fn target(
|
||||
key: &str,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Point<Pixels>> {
|
||||
Ok(target_bounds(window, key, cx).await?.center())
|
||||
}
|
||||
|
||||
async fn target_bounds(
|
||||
window: WindowHandle<Gallery>,
|
||||
key: &str,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Bounds<Pixels>> {
|
||||
let bounds = cx
|
||||
.update(|cx| Probes::get(key, cx))?
|
||||
.with_context(|| format!("probe {key} never rendered"))?;
|
||||
if window.update(cx, |gallery, _, cx| gallery.reveal(bounds, cx))? {
|
||||
cx.background_executor().timer(FRAME).await;
|
||||
}
|
||||
let bounds = cx
|
||||
.update(|cx| Probes::get(key, cx))?
|
||||
.with_context(|| format!("probe {key} vanished after scrolling"))?;
|
||||
Ok(bounds.center())
|
||||
cx.update(|cx| Probes::get(key, cx))?
|
||||
.with_context(|| format!("probe {key} vanished after scrolling"))
|
||||
}
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# Pre-review gate: build rules plus house rules.
|
||||
set -eu
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test --lib --quiet
|
||||
fail=0
|
||||
files=$(git ls-files -co --exclude-standard '*.rs')
|
||||
multi=$(awk 'FNR==1{prev=0} /^[[:space:]]*\/\// {if(prev) print FILENAME":"FNR; prev=1; next} {prev=0}' $files)
|
||||
[ -z "$multi" ] || { echo "multi-line comments:"; echo "$multi"; fail=1; }
|
||||
for f in $files AGENTS.md README.md TASKS.md tasks/*.md; do
|
||||
n=$(wc -l < "$f")
|
||||
[ "$n" -le 500 ] || { echo "$f has $n lines"; fail=1; }
|
||||
done
|
||||
raw=$(grep -rnE "(^|[^.[:alnum:]_])px\(" src --include='*.rs' | grep -v "^src/theme/tokens.rs\|^src/motion/" || true)
|
||||
[ -z "$raw" ] || { echo "raw px in components:"; echo "$raw"; fail=1; }
|
||||
exit $fail
|
||||
@@ -4,6 +4,7 @@ pub mod buttons;
|
||||
pub mod motion;
|
||||
pub mod primitives;
|
||||
pub mod theme;
|
||||
pub mod typography;
|
||||
|
||||
pub use assets::Assets;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+33
-33
@@ -32,39 +32,39 @@
|
||||
|
||||
## T02 Typography — `src/typography`
|
||||
|
||||
- [ ] Heading (H1–H6)
|
||||
- [ ] Title / Subtitle
|
||||
- [ ] Paragraph
|
||||
- [ ] Label
|
||||
- [ ] Caption
|
||||
- [ ] Overline
|
||||
- [ ] Code
|
||||
- [ ] Kbd — ⌘ / Ctrl by platform
|
||||
- [ ] KbdCombo
|
||||
- [ ] Blockquote
|
||||
- [ ] Highlight / Mark
|
||||
- [ ] Truncate — native `.truncate()`
|
||||
- [ ] LineClamp — native `.line_clamp(n)`
|
||||
- [ ] MiddleEllipsis
|
||||
- [ ] EllipsisTooltip
|
||||
- [ ] Link
|
||||
- [ ] ExternalLink
|
||||
- [ ] RelativeTime
|
||||
- [ ] DateTimeText
|
||||
- [ ] NumberText
|
||||
- [ ] CurrencyText
|
||||
- [ ] PercentText
|
||||
- [ ] FileSizeText
|
||||
- [ ] DurationText
|
||||
- [ ] PluralText
|
||||
- [ ] CopyableText
|
||||
- [ ] SelectableText — prove: selection over `StyledText` layout
|
||||
- [ ] Emoji — shortcodes
|
||||
- [ ] Latex / MathInline — prove: TeX subset
|
||||
- [ ] AnimatedNumber
|
||||
- [ ] Typewriter
|
||||
- [ ] GradientText — per-glyph tonal ramp
|
||||
- [ ] ShimmerText
|
||||
- [x] Heading (H1–H6)
|
||||
- [x] Title / Subtitle
|
||||
- [x] Paragraph
|
||||
- [x] Label
|
||||
- [x] Caption
|
||||
- [x] Overline
|
||||
- [x] Code
|
||||
- [x] Kbd — ⌘ / Ctrl by platform
|
||||
- [x] KbdCombo
|
||||
- [x] Blockquote
|
||||
- [x] Highlight / Mark
|
||||
- [x] Truncate — native `.truncate()`
|
||||
- [x] LineClamp — native `.line_clamp(n)`
|
||||
- [x] MiddleEllipsis
|
||||
- [x] EllipsisTooltip
|
||||
- [x] Link
|
||||
- [x] ExternalLink
|
||||
- [x] RelativeTime
|
||||
- [x] DateTimeText
|
||||
- [x] NumberText
|
||||
- [x] CurrencyText
|
||||
- [x] PercentText
|
||||
- [x] FileSizeText
|
||||
- [x] DurationText
|
||||
- [x] PluralText
|
||||
- [x] CopyableText
|
||||
- [x] SelectableText — prove: selection over `StyledText` layout
|
||||
- [x] Emoji — shortcodes
|
||||
- [x] Latex / MathInline — prove: TeX subset
|
||||
- [x] AnimatedNumber
|
||||
- [x] Typewriter
|
||||
- [x] GradientText — per-glyph tonal ramp
|
||||
- [x] ShimmerText
|
||||
|
||||
## T03 Layout — `src/layout`
|
||||
|
||||
|
||||
Reference in new issue
Block a user