From b35530c80d4fe9ae068f222fc37941b1ce2edae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 01:12:52 -0400 Subject: [PATCH] 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. --- AGENTS.md | 3 +- Cargo.lock | 48 +++ Cargo.toml | 4 + TASKS.md | 4 +- examples/gallery/capture.rs | 19 +- examples/gallery/main.rs | 10 +- examples/gallery/pages/mod.rs | 8 +- examples/gallery/pages/typography/mod.rs | 69 ++++ examples/gallery/pages/typography/motion.rs | 120 +++++++ examples/gallery/pages/typography/text.rs | 220 ++++++++++++ examples/gallery/pages/typography/values.rs | 226 +++++++++++++ examples/gallery/probe.rs | 16 +- examples/gallery/script.rs | 25 +- scripts/check.sh | 17 + src/lib.rs | 1 + src/typography/copy.rs | 109 ++++++ src/typography/effects.rs | 186 ++++++++++ src/typography/emoji.rs | 49 +++ src/typography/fit.rs | 177 ++++++++++ src/typography/format.rs | 258 ++++++++++++++ src/typography/formatted.rs | 240 +++++++++++++ src/typography/inline.rs | 226 +++++++++++++ src/typography/keys.rs | 103 ++++++ src/typography/link.rs | 106 ++++++ src/typography/math.rs | 354 ++++++++++++++++++++ src/typography/mod.rs | 29 ++ src/typography/rolling.rs | 170 ++++++++++ src/typography/select.rs | 133 ++++++++ src/typography/text.rs | 187 +++++++++++ tasks/ch01-10.md | 66 ++-- 30 files changed, 3125 insertions(+), 58 deletions(-) create mode 100644 examples/gallery/pages/typography/mod.rs create mode 100644 examples/gallery/pages/typography/motion.rs create mode 100644 examples/gallery/pages/typography/text.rs create mode 100644 examples/gallery/pages/typography/values.rs create mode 100755 scripts/check.sh create mode 100644 src/typography/copy.rs create mode 100644 src/typography/effects.rs create mode 100644 src/typography/emoji.rs create mode 100644 src/typography/fit.rs create mode 100644 src/typography/format.rs create mode 100644 src/typography/formatted.rs create mode 100644 src/typography/inline.rs create mode 100644 src/typography/keys.rs create mode 100644 src/typography/link.rs create mode 100644 src/typography/math.rs create mode 100644 src/typography/mod.rs create mode 100644 src/typography/rolling.rs create mode 100644 src/typography/select.rs create mode 100644 src/typography/text.rs diff --git a/AGENTS.md b/AGENTS.md index a034a11..041fc74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 2e9de5f..9fda5e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index a557c76..fccd519 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/TASKS.md b/TASKS.md index 5c43882..0567533 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | | diff --git a/examples/gallery/capture.rs b/examples/gallery/capture.rs index 4f27eba..9d85da1 100644 --- a/examples/gallery/capture.rs +++ b/examples/gallery/capture.rs @@ -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, dir: PathBuf, cx: &mut App) { +/// Shoots one page, or all, in both modes, then quits. Exits 1 on failure. +pub fn run(window: WindowHandle, dir: PathBuf, only: Option, 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, dir: PathBuf, cx: &mut App) { .detach(); } -async fn shoot_all(window: WindowHandle, dir: &Path, cx: &mut AsyncApp) -> Result<()> { +async fn shoot_all( + window: WindowHandle, + dir: &Path, + only: Option, + 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); diff --git a/examples/gallery/main.rs b/examples/gallery/main.rs index 8949a15..89d0a3f 100644 --- a/examples/gallery/main.rs +++ b/examples/gallery/main.rs @@ -17,13 +17,13 @@ use gpui::{ actions!(gallery, [Quit]); struct Args { - page: usize, + page: Option, capture: Option, } fn parse_args() -> Result { 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 { .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(()) diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index f1155fb..4c1cf59 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -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 { ALL.iter().position(|page| page.slug == slug) diff --git a/examples/gallery/pages/typography/mod.rs b/examples/gallery/pages/typography/mod.rs new file mode 100644 index 0000000..8952f42 --- /dev/null +++ b/examples/gallery/pages/typography/mod.rs @@ -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() +} diff --git a/examples/gallery/pages/typography/motion.rs b/examples/gallery/pages/typography/motion.rs new file mode 100644 index 0000000..c9c79f7 --- /dev/null +++ b/examples/gallery/pages/typography/motion.rs @@ -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…")), + ) +} diff --git a/examples/gallery/pages/typography/text.rs b/examples/gallery/pages/typography/text.rs new file mode 100644 index 0000000..251b98c --- /dev/null +++ b/examples/gallery/pages/typography/text.rs @@ -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", + )) +} diff --git a/examples/gallery/pages/typography/values.rs b/examples/gallery/pages/typography/values.rs new file mode 100644 index 0000000..3d1f2bf --- /dev/null +++ b/examples/gallery/pages/typography/values.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)) +} diff --git a/examples/gallery/probe.rs b/examples/gallery/probe.rs index c429044..dadeb7c 100644 --- a/examples/gallery/probe.rs +++ b/examples/gallery/probe.rs @@ -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::().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::().0.insert(key, bounds); + }) + .child(element), + ) } diff --git a/examples/gallery/script.rs b/examples/gallery/script.rs index 2a90b95..a2186b6 100644 --- a/examples/gallery/script.rs +++ b/examples/gallery/script.rs @@ -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> { + Ok(target_bounds(window, key, cx).await?.center()) +} + +async fn target_bounds( + window: WindowHandle, + key: &str, + cx: &mut AsyncApp, +) -> Result> { 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")) } diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..bf0e444 --- /dev/null +++ b/scripts/check.sh @@ -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 diff --git a/src/lib.rs b/src/lib.rs index f905863..72fd868 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod buttons; pub mod motion; pub mod primitives; pub mod theme; +pub mod typography; pub use assets::Assets; diff --git a/src/typography/copy.rs b/src/typography/copy.rs new file mode 100644 index 0000000..50d13ce --- /dev/null +++ b/src/typography/copy.rs @@ -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>, +} + +/// 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, text: impl Into) -> 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) + } + }), + ) + } +} diff --git a/src/typography/effects.rs b/src/typography/effects.rs new file mode 100644 index 0000000..4046cf5 --- /dev/null +++ b/src/typography/effects.rs @@ -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> { + 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, text: impl Into) -> 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) -> Self { + Self { + text: text.into(), + ends: None, + } + } + + pub fn colors(mut self, from: impl Into, to: impl Into) -> 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, text: impl Into) -> 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() + } +} diff --git a/src/typography/emoji.rs b/src/typography/emoji.rs new file mode 100644 index 0000000..15a959c --- /dev/null +++ b/src/typography/emoji.rs @@ -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) -> Self { + Self { + glyph: glyph.into(), + size: TextSize::Lg, + } + } + + /// GitHub shortcode without colons; `None` if unknown. + pub fn shortcode(code: &str) -> Option { + 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()); + } +} diff --git a/src/typography/fit.rs b/src/typography/fit.rs new file mode 100644 index 0000000..66308bd --- /dev/null +++ b/src/typography/fit.rs @@ -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 = 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) -> 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, text: impl Into) -> 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 = "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 = "路径/很长/文件.rs".chars().collect(); + assert_eq!(elide(&wide, 4), "路径…rs"); + } +} diff --git a/src/typography/format.rs b/src/typography/format.rs new file mode 100644 index 0000000..4b06299 --- /dev/null +++ b/src/typography/format.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::().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 { + 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()); + } +} diff --git a/src/typography/formatted.rs b/src/typography/formatted.rs new file mode 100644 index 0000000..ffb66c2 --- /dev/null +++ b/src/typography/formatted.rs @@ -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, 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, +} + +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) + } +} diff --git a/src/typography/inline.rs b/src/typography/inline.rs new file mode 100644 index 0000000..4a7e331 --- /dev/null +++ b/src/typography/inline.rs @@ -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) -> 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) { + 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>, +} + +impl Highlight { + /// Byte ranges on char boundaries. + pub fn new(text: impl Into, ranges: Vec>) -> Self { + Self { + text: text.into(), + ranges, + } + } + + /// Marks every case-insensitive occurrence of `query`. + pub fn matching(text: impl Into, 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 }]); + } +} diff --git a/src/typography/keys.rs b/src/typography/keys.rs new file mode 100644 index 0000000..c6b7851 --- /dev/null +++ b/src/typography/keys.rs @@ -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 { + 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 = 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), "←"); + } +} diff --git a/src/typography/link.rs b/src/typography/link.rs new file mode 100644 index 0000000..06c4de2 --- /dev/null +++ b/src/typography/link.rs @@ -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; + +/// Inline link. Give it an `href` or an `on_click`. +#[derive(IntoElement)] +pub struct Link { + id: ElementId, + label: SharedString, + href: Option, + on_click: Option, + external: bool, +} + +impl Link { + pub fn new(id: impl Into, label: impl Into) -> 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) -> 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, + label: impl Into, + url: impl Into, + ) -> 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 + } +} diff --git a/src/typography/math.rs b/src/typography/math.rs new file mode 100644 index 0000000..4c86d34 --- /dev/null +++ b/src/typography/math.rs @@ -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), + Script { + base: Box, + sup: Option>, + sub: Option>, + }, + Frac(Box, Box), + Sqrt(Box), +} + +#[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 { + 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, + at: usize, +} + +impl Parser { + fn peek(&self) -> Option { + self.chars.get(self.at).copied() + } + + fn bump(&mut self) -> Option { + let next = self.peek(); + self.at += 1; + next + } + + fn row(&mut self, closed: bool) -> Result, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)); + } +} diff --git a/src/typography/mod.rs b/src/typography/mod.rs new file mode 100644 index 0000000..707592b --- /dev/null +++ b/src/typography/mod.rs @@ -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}; diff --git a/src/typography/rolling.rs b/src/typography/rolling.rs new file mode 100644 index 0000000..1bb1e1c --- /dev/null +++ b/src/typography/rolling.rs @@ -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, 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, Vec) { + 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::(), " 999"); + assert_eq!(to.iter().collect::(), "1,000"); + } +} diff --git a/src/typography/select.rs b/src/typography/select.rs new file mode 100644 index 0000000..8f6897b --- /dev/null +++ b/src/typography/select.rs @@ -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 { + self.anchor.min(self.head)..self.anchor.max(self.head) + } +} + +fn index_at(layout: &TextLayout, position: gpui::Point) -> 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, text: impl Into) -> 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) + } +} diff --git a/src/typography/text.rs b/src/typography/text.rs new file mode 100644 index 0000000..e1ecc5c --- /dev/null +++ b/src/typography/text.rs @@ -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(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) -> 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) -> 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) -> 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) -> Self { + Self::new(1, text) + } + + pub fn h2(text: impl Into) -> Self { + Self::new(2, text) + } + + pub fn h3(text: impl Into) -> Self { + Self::new(3, text) + } + + pub fn h4(text: impl Into) -> Self { + Self::new(4, text) + } + + pub fn h5(text: impl Into) -> Self { + Self::new(5, text) + } + + pub fn h6(text: impl Into) -> 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) + } +} diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index 7d372d5..09f687f 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -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`