From 0ca1ff65c79818b83e3560af6b5959cd09f6e406 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 09:41:29 -0400 Subject: [PATCH] T06f forms other: signature pad, code and JSON inputs, font, icon and emoji pickers, language, country and currency comboboxes --- AGENTS.md | 2 + Cargo.lock | 65 +++- Cargo.toml | 3 + TASKS.md | 4 +- examples/gallery/pages/forms/mod.rs | 60 ++++ examples/gallery/pages/forms/other.rs | 206 +++++++++++ src/forms/catalogs.rs | 191 ++++++++++ src/forms/code.rs | 266 ++++++++++++++ src/forms/combobox.rs | 8 +- src/forms/glyphs.rs | 482 ++++++++++++++++++++++++++ src/forms/mod.rs | 8 + src/forms/signature.rs | 281 +++++++++++++++ src/forms/tests/mod.rs | 1 + src/forms/tests/other.rs | 94 +++++ src/theme/tokens.rs | 5 + src/typography/format.rs | 20 +- tasks/ch01-10.md | 18 +- tasks/ch31-43.md | 2 +- 18 files changed, 1692 insertions(+), 24 deletions(-) create mode 100644 examples/gallery/pages/forms/other.rs create mode 100644 src/forms/catalogs.rs create mode 100644 src/forms/code.rs create mode 100644 src/forms/glyphs.rs create mode 100644 src/forms/signature.rs create mode 100644 src/forms/tests/other.rs diff --git a/AGENTS.md b/AGENTS.md index 8099bb3..bc7c5b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Rust 1.95, edition 2024. One crate: `ely-gpui-component`. - gpui 0.2.2 from crates.io. Feature `runtime_shaders` is on by default: machines without Xcode lack the Metal compiler. - Assets, embedded with `rust-embed`: Lucide 1.48.0 icons (ISC), Inter 4.1 and JetBrains Mono 2.304 (OFL). +- Catalog data: `emojis` (Unicode emoji), `isolang` (ISO 639, native names), `isocountry` (ISO 3166), `iso_currency` (ISO 4217, also minor units for money). - macOS extras (tray icon, Dock badge) call AppKit through `cocoa` 0.26 and `objc` 0.2, the crates gpui already links. - Gallery: `examples/gallery`. Website: `frontend/` (Vite 8, pnpm), built after the components. @@ -61,6 +62,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui's `Hsla::opacity` scales alpha; `alpha` sets it. - A rounded box does not clip its children. Each layer inside takes the radius itself; `checker` takes one. - Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them. +- Long grids scroll in a `uniform_list`. gpui has no nearest scroll, so a key move up scrolls with `Top` and down with `Bottom` (`forms::glyphs`). - Masks reshape edits inside `TextInput::set_fit`, which sees the replaced range and the typed text. A diff after the fact cannot tell typed characters from kept ones. - Ids inside a reusable component carry its owner's id or `EntityId`. Twin ids share focus and click state. - 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. diff --git a/Cargo.lock b/Cargo.lock index 50cddf6..39a9c9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1551,6 +1551,9 @@ dependencies = [ "env_logger", "gpui", "image", + "iso_currency", + "isocountry", + "isolang", "jiff", "log", "objc", @@ -1583,7 +1586,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b1514ced566c94991ed9563258ecf61e311fd773bf0a3f20606830386bf66e3" dependencies = [ - "phf", + "phf 0.13.1", ] [[package]] @@ -2988,6 +2991,46 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "iso_country" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20633e788d3948ea7336861fdb09ec247f5dae4267e8f0743fa97de26c28624d" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "iso_currency" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d721074ff687a7ac523217a2bedb8277ac81d83dc10216ffc2a19128ca3dd5" +dependencies = [ + "iso_country", + "proc-macro2", + "quote", + "strum 0.27.2", +] + +[[package]] +name = "isocountry" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ea1dc4bf0fb4904ba83ffdb98af3d9c325274e92e6e295e4151e86c96363e04" +dependencies = [ + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "isolang" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe50d48c77760c55188549098b9a7f6e37ae980c586a24693d6b01c3b2010c3c" +dependencies = [ + "phf 0.11.3", +] + [[package]] name = "itertools" version = "0.13.0" @@ -3984,13 +4027,31 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fbc100f..9a7a664 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,9 @@ test-support = ["gpui/test-support"] anyhow = "1" emojis = "0.9" gpui = "0.2.2" +iso_currency = { version = "0.7", features = ["iterator"] } +isocountry = "0.3" +isolang = { version = "2.4", features = ["local_names", "list_languages"] } jiff = "0.2" log = "0.4" regex = "1" diff --git a/TASKS.md b/TASKS.md index 0543a75..b0912e9 100644 --- a/TASKS.md +++ b/TASKS.md @@ -27,7 +27,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T06c Forms · Date & Time - [x] T06d Forms · Color - [x] T06e Forms · Files -- [ ] T06f Forms · Other +- [x] T06f Forms · Other - [ ] T06g Forms · Structure - [ ] T07 Navigation - [ ] T08 Menus @@ -131,3 +131,5 @@ Per-component lines live in `tasks/`. Tags there: | T06d | 1 | FAIL | a zero-size checker hung the paint loop; a dragged stop lost its grip after passing another; `opacity(1.0)` kept alpha, so opaque pickers stayed see-through; inset thumbs drifted from the drawn gradients; swatch fills and checkers crossed rounded corners; the thumb ring was a literal white | | T06d | 2 | PASS | | | T06e | 1 | PASS | | +| T06f | 1 | FAIL | a stroke lost the move that started the drag and its release point; language codes were not searchable though the gallery said so; emoji search compared a lowercase query with mixed-case names | +| T06f | 2 | PASS | | diff --git a/examples/gallery/pages/forms/mod.rs b/examples/gallery/pages/forms/mod.rs index de0088a..bfb2821 100644 --- a/examples/gallery/pages/forms/mod.rs +++ b/examples/gallery/pages/forms/mod.rs @@ -4,6 +4,7 @@ mod dates; mod files; mod formats; mod numbers; +mod other; mod pickers; mod rich; mod text; @@ -244,6 +245,60 @@ const SCRIPT: &[Step] = &[ Step::CancelPanel, Step::Wait(600), Step::Shot("files"), + Step::DownAt("signature", 40.0, 100.0), + Step::DragTo("signature", 70.0, 50.0), + Step::DragTo("signature", 100.0, 110.0), + Step::DragTo("signature", 130.0, 55.0), + Step::DragTo("signature", 160.0, 105.0), + Step::DragTo("signature", 210.0, 60.0), + Step::DragTo("signature", 250.0, 100.0), + Step::DragTo("signature", 320.0, 80.0), + Step::UpAt("signature", 320.0, 80.0), + Step::Wait(300), + Step::Shot("signature"), + Step::Click("code"), + Step::Key("secondary-a"), + Step::Type("let total = price(3) * 1.2; // tax"), + Step::Click("json"), + Step::Key("secondary-a"), + Step::Type("{\"name\": \"Ely\", \"size\": 3,"), + Step::Wait(300), + Step::Shot("code-json"), + Step::DownAt("font", 100.0, 14.0), + Step::UpAt("font", 100.0, 14.0), + Step::Key("secondary-a"), + Step::Type("mono"), + Step::Wait(300), + Step::Shot("font"), + Step::Key("enter"), + Step::Wait(300), + Step::DownAt("icons", 100.0, 14.0), + Step::UpAt("icons", 100.0, 14.0), + Step::Key("secondary-a"), + Step::Type("arrow"), + Step::Key("down"), + Step::Key("right"), + Step::Key("down"), + Step::Wait(300), + Step::Shot("icons"), + Step::Key("enter"), + Step::DownAt("emoji", 100.0, 14.0), + Step::UpAt("emoji", 100.0, 14.0), + Step::Key("secondary-a"), + Step::Type("heart"), + Step::Key("down"), + Step::Key("right"), + Step::Wait(300), + Step::Shot("emoji"), + Step::Key("enter"), + Step::Wait(300), + Step::Click("country"), + Step::Key("secondary-a"), + Step::Type("jap"), + Step::Wait(300), + Step::Shot("country"), + Step::Key("enter"), + Step::Wait(300), Step::Rest, ]; @@ -291,5 +346,10 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(colors::swatches(window, cx)) .child(colors::gradient(window, cx)) .child(files::files(window, cx)) + .child(other::signature(window, cx)) + .child(other::code(window, cx)) + .child(other::font(window, cx)) + .child(other::glyphs(window, cx)) + .child(other::regional(window, cx)) .into_any_element() } diff --git a/examples/gallery/pages/forms/other.rs b/examples/gallery/pages/forms/other.rs new file mode 100644 index 0000000..0c482f6 --- /dev/null +++ b/examples/gallery/pages/forms/other.rs @@ -0,0 +1,206 @@ +use ely_gpui_component::{ + forms::{ + CodeInput, Combobox, EmojiPicker, FontPicker, IconPicker, JsonInput, NumberInput, + SignaturePad, Stroke, code_highlights, json_highlights, + }, + primitives::IconName, + theme::ActiveTheme, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use super::{ + choices::{keep, set}, + text::field, +}; +use crate::{ + probe::probe, + ui::{section, specimen, specimens}, +}; + +pub fn signature(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let strokes = keep("signature-strokes", Vec::::new, window, cx); + let now = strokes.read(cx).clone(); + section( + "SignaturePad", + "Sign with the pointer. A stroke lands when the pen lifts; Clear starts over.", + cx, + ) + .child(specimen( + match now.len() { + 1 => "1 stroke".to_string(), + count => format!("{count} strokes"), + }, + probe( + "signature", + div().w(px(420.0)).child( + SignaturePad::new("signature", now) + .on_change(move |next, _, cx| set(&strokes, next.to_vec(), cx)), + ), + ), + cx, + )) +} + +pub fn code(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let line = field("input-code", window, cx, |input| { + input + .placeholder("cargo run --example gallery") + .highlighter(code_highlights) + }); + let json = field("input-json", window, cx, |input| { + input + .multi_line(4, 8) + .placeholder("{ \"name\": \"Ely\" }") + .highlighter(json_highlights) + }); + section( + "CodeInput / JsonInput", + "One line of code, colored the way most languages read. JSON is checked as you type.", + cx, + ) + .child( + specimens() + .child(specimen( + "code", + probe("code", div().w(px(420.0)).child(CodeInput::new(&line))), + cx, + )) + .child(specimen( + "json", + probe("json", div().w(px(420.0)).child(JsonInput::new(&json))), + cx, + )), + ) +} + +pub fn font(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let search = field("input-font", window, cx, |input| { + input.placeholder("Search fonts") + }); + let ui = cx.theme().font_family.clone(); + let family = keep("font-family", move || ui, window, cx); + let now = family.read(cx).clone(); + section( + "FontPicker", + "Every family the system knows, and a line set in the chosen one.", + cx, + ) + .child(probe( + "font", + div().w(px(420.0)).child( + FontPicker::new("font-picker", &search) + .selected(now) + .on_change(move |next, _, cx| set(&family, next.clone(), cx)), + ), + )) +} + +pub fn glyphs(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let icon = keep("icon-picked", || IconName::Sparkles, window, cx); + let emoji = keep("emoji-picked", || SharedString::from("✨"), window, cx); + let (icon_now, emoji_now) = (*icon.read(cx), emoji.read(cx).clone()); + section( + "IconPicker / EmojiPicker", + "Type to narrow. Down moves into the grid, arrows walk it, Enter picks.", + cx, + ) + .child( + specimens() + .child(specimen( + icon_now.name(), + probe( + "icons", + IconPicker::new("icon-picker") + .selected(icon_now) + .on_change(move |next, _, cx| set(&icon, next, cx)), + ), + cx, + )) + .child(specimen( + emoji_now.clone(), + probe( + "emoji", + EmojiPicker::new("emoji-picker") + .selected(emoji_now) + .on_change(move |next, _, cx| { + set(&emoji, SharedString::from(next.to_string()), cx) + }), + ), + cx, + )), + ) +} + +pub fn regional(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let language = field("input-language", window, cx, |input| { + input.placeholder("Language") + }); + let country = field("input-country", window, cx, |input| { + input.placeholder("Country") + }); + let currency = field("input-currency", window, cx, |input| { + input.placeholder("Currency") + }); + let spoken = keep("regional-language", || SharedString::from("zh"), window, cx); + let land = keep("regional-country", || SharedString::from("CN"), window, cx); + let money = keep( + "regional-currency", + || SharedString::from("CNY"), + window, + cx, + ); + let amount = keep("regional-amount", || 1280.0, window, cx); + let (spoken_now, land_now, money_now, amount_now) = ( + spoken.read(cx).clone(), + land.read(cx).clone(), + money.read(cx).clone(), + *amount.read(cx), + ); + section( + "LanguageSelect / CountrySelect / CurrencySelect", + "Languages under their own names, countries with flags, currencies by ISO 4217. Type a name or a code.", + cx, + ) + .child( + specimens() + .child(specimen( + spoken_now.clone(), + div().w(px(260.0)).child( + Combobox::languages("language-select", &language) + .selected(spoken_now) + .on_change(move |next, _, cx| set(&spoken, next.clone(), cx)), + ), + cx, + )) + .child(specimen( + land_now.clone(), + probe( + "country", + div().w(px(260.0)).child( + Combobox::countries("country-select", &country) + .selected(land_now) + .on_change(move |next, _, cx| set(&land, next.clone(), cx)), + ), + ), + cx, + )) + .child(specimen( + money_now.clone(), + div().w(px(260.0)).child( + Combobox::currencies("currency-select", ¤cy) + .selected(money_now.clone()) + .on_change(move |next, _, cx| set(&money, next.clone(), cx)), + ), + cx, + )), + ) + .child(specimen( + format!("an amount in {money_now}"), + div().w(px(260.0)).child( + NumberInput::new("regional-amount", amount_now) + .currency(&money_now) + .on_change(move |next, _, cx| set(&amount, next, cx)), + ), + cx, + )) +} diff --git a/src/forms/catalogs.rs b/src/forms/catalogs.rs new file mode 100644 index 0000000..178f6c7 --- /dev/null +++ b/src/forms/catalogs.rs @@ -0,0 +1,191 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, + div, prelude::*, +}; +use iso_currency::{Currency, IntoEnumIterator}; +use isocountry::CountryCode; + +use super::{Choice, Combobox, TextInput, options::OnValue}; +use crate::theme::{ActiveTheme, TextSize}; + +/// The flag emoji of a two-letter region code. +fn flag(region: &str) -> String { + region + .chars() + .map(|letter| { + assert!(letter.is_ascii_uppercase(), "region code {region:?}"); + char::from_u32(0x1F1E6 + (letter as u32 - 'A' as u32)).expect("a regional indicator") + }) + .collect() +} + +/// `text` with its first letter capitalized, as list items read. +fn titled(text: &str) -> String { + let mut chars = text.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_uppercase().chain(chars).collect() + }) +} + +fn by_label(mut choices: Vec) -> Vec { + choices.sort_by_key(|choice| choice.label.to_lowercase()); + choices +} + +/// ISO 639-1 languages under their own names, with the English name and code beside. +fn languages() -> Vec { + let choices = isolang::languages() + .filter_map(|language| { + let (code, english) = (language.to_639_1()?, language.to_name()); + Some(match language.to_autonym() { + Some(own) => Choice::new(code, titled(own)).note(format!("{english} · {code}")), + None => Choice::new(code, english).note(code), + }) + }) + .collect(); + by_label(choices) +} + +impl Combobox { + /// ISO 639-1 languages under their own names; a name in either tongue or the code finds one. + pub fn languages(id: impl Into, state: &Entity) -> Self { + Self::new(id, state, languages()) + } + + /// ISO 3166-1 countries and territories, flagged, with their codes. + pub fn countries(id: impl Into, state: &Entity) -> Self { + let mut named: Vec<(&str, &str)> = CountryCode::iter() + .map(|country| (country.name(), country.alpha2())) + .collect(); + named.sort_by_key(|(name, _)| name.to_lowercase()); + let choices = named + .into_iter() + .map(|(name, code)| Choice::new(code, format!("{} {name}", flag(code))).note(code)); + Self::new(id, state, choices) + } + + /// ISO 4217 currencies in use, without funds, metals or retired codes. + pub fn currencies(id: impl Into, state: &Entity) -> Self { + let choices = Currency::iter() + .filter(|currency| { + !currency.is_fund() && !currency.is_special() && currency.is_superseded().is_none() + }) + .map(|currency| { + Choice::new(currency.code(), currency.name().to_string()).note(currency.code()) + }) + .collect(); + Self::new(id, state, by_label(choices)) + } +} + +/// The system's font families in a combobox, and a line set in the chosen one. +#[derive(IntoElement)] +pub struct FontPicker { + id: ElementId, + state: Entity, + selected: Option, + on_change: Option, +} + +impl FontPicker { + pub fn new(id: impl Into, state: &Entity) -> Self { + Self { + id: id.into(), + state: state.clone(), + selected: None, + on_change: None, + } + } + + pub fn selected(mut self, family: impl Into) -> Self { + self.selected = Some(family.into()); + self + } + + pub fn on_change( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for FontPicker { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let families = window.use_keyed_state((self.id.clone(), "families"), cx, |_, cx| { + let found: Vec = cx + .text_system() + .all_font_names() + .into_iter() + .filter(|family| !family.starts_with('.')) + .map(|family| Choice::new(family.clone(), family)) + .collect(); + log::info!("font picker: {} families", found.len()); + found + }); + let theme = cx.theme(); + let mut combobox = Combobox::new( + self.id.clone(), + &self.state, + families.read(cx).iter().cloned(), + ); + if let Some(family) = &self.selected { + combobox = combobox.selected(family.clone()); + } + if let Some(on_change) = self.on_change { + combobox = combobox.on_change(move |family, window, cx| on_change(family, window, cx)); + } + div() + .flex() + .flex_col() + .gap_2() + .w_full() + .child(combobox) + .when_some(self.selected, |picker, family| { + picker.child( + div() + .font_family(family) + .text_size(theme.text_size(TextSize::Lg)) + .text_color(theme.colors.fg_muted) + .child("The quick brown fox jumps over the lazy dog"), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::{flag, languages, titled}; + use crate::forms::combobox::matching; + + #[test] + fn languages_answer_to_their_code_and_english_name() { + let found = |query| { + matching(&languages(), query) + .into_iter() + .map(|choice| choice.value) + .collect::>() + }; + assert!(found("zh").iter().any(|code| code == "zh")); + assert!(found("chinese").iter().any(|code| code == "zh")); + assert!(found("中文").iter().any(|code| code == "zh")); + assert!(found("aymara").iter().any(|code| code == "ay")); + } + + #[test] + fn flags_come_from_region_letters() { + assert_eq!(flag("CN"), "🇨🇳"); + assert_eq!(flag("US"), "🇺🇸"); + } + + #[test] + fn titles_capitalize_the_first_letter_only() { + assert_eq!(titled("français"), "Français"); + assert_eq!(titled("русский"), "Русский"); + assert_eq!(titled("中文"), "中文"); + assert_eq!(titled(""), ""); + } +} diff --git a/src/forms/code.rs b/src/forms/code.rs new file mode 100644 index 0000000..1bf2eeb --- /dev/null +++ b/src/forms/code.rs @@ -0,0 +1,266 @@ +use std::ops::Range; + +use gpui::{ + App, Entity, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, + prelude::*, +}; + +use super::{Highlight, Input, TextInput}; +use crate::theme::{ActiveTheme, ControlSize, TextSize}; + +/// What a span of source is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Kind { + Comment, + Text, + Number, + Word, + Mark, +} + +const KEYWORDS: &[&str] = &[ + "as", "async", "await", "break", "case", "catch", "class", "const", "continue", "def", "do", + "else", "enum", "export", "fn", "for", "from", "func", "function", "if", "impl", "import", + "in", "let", "match", "mut", "new", "pub", "return", "static", "struct", "switch", "throw", + "try", "type", "use", "var", "while", "yield", +]; +const CONSTANTS: &[&str] = &[ + "false", + "nil", + "None", + "null", + "self", + "this", + "true", + "undefined", +]; + +/// Splits source into spans; spaces fall between them. +pub(crate) fn lex(source: &str) -> Vec<(Range, Kind)> { + let mut out = Vec::new(); + let mut chars = source.char_indices().peekable(); + while let Some((start, ch)) = chars.next() { + let kind = match ch { + '"' | '\'' | '`' => { + let mut escaped = false; + while let Some((_, next)) = chars.next_if(|(_, next)| *next != '\n') { + if escaped { + escaped = false; + } else if next == '\\' { + escaped = true; + } else if next == ch { + break; + } + } + Kind::Text + } + '/' if chars.peek().is_some_and(|(_, next)| *next == '/') => { + while chars.next_if(|(_, next)| *next != '\n').is_some() {} + Kind::Comment + } + '0'..='9' => { + while chars + .next_if(|(_, next)| next.is_ascii_alphanumeric() || matches!(next, '.' | '_')) + .is_some() + {} + Kind::Number + } + ch if ch.is_alphabetic() || ch == '_' || ch == '$' => { + while chars + .next_if(|(_, next)| next.is_alphanumeric() || matches!(next, '_' | '$')) + .is_some() + {} + Kind::Word + } + ch if ch.is_whitespace() => continue, + _ => Kind::Mark, + }; + let end = chars.peek().map_or(source.len(), |(next, _)| *next); + out.push((start..end, kind)); + } + out +} + +fn painted(spans: impl Iterator, Hsla)>) -> Vec<(Range, Highlight)> { + spans + .map(|(range, color)| { + let highlight = Highlight { + color, + background: None, + }; + (range, highlight) + }) + .collect() +} + +/// Colors for one line of code in most languages, from the syntax palette. +pub fn code_highlights(source: &str, cx: &App) -> Vec<(Range, Highlight)> { + let syntax = &cx.theme().colors.syntax; + let spans = lex(source); + let next_is = |ix: usize, mark: &str| { + spans + .get(ix + 1) + .is_some_and(|(range, _)| &source[range.clone()] == mark) + }; + painted(spans.iter().enumerate().map(|(ix, (range, kind))| { + let text = &source[range.clone()]; + let color = match kind { + Kind::Comment => syntax.comment, + Kind::Text => syntax.string, + Kind::Number => syntax.number, + Kind::Word if KEYWORDS.contains(&text) => syntax.keyword, + Kind::Word if CONSTANTS.contains(&text) => syntax.constant, + Kind::Word if next_is(ix, "(") => syntax.function, + Kind::Word if text.starts_with(char::is_uppercase) => syntax.type_name, + Kind::Word => syntax.variable, + Kind::Mark if "()[]{},.;".contains(text) => syntax.punctuation, + Kind::Mark => syntax.operator, + }; + (range.clone(), color) + })) +} + +/// Colors for JSON: keys, strings, numbers and literals. +pub fn json_highlights(source: &str, cx: &App) -> Vec<(Range, Highlight)> { + let syntax = &cx.theme().colors.syntax; + let spans = lex(source); + let keyed = |ix: usize| { + spans + .get(ix + 1) + .is_some_and(|(range, _)| &source[range.clone()] == ":") + }; + painted(spans.iter().enumerate().filter_map(|(ix, (range, kind))| { + let color = match kind { + Kind::Text if keyed(ix) => syntax.property, + Kind::Text => syntax.string, + Kind::Number => syntax.number, + Kind::Word if matches!(&source[range.clone()], "true" | "false" | "null") => { + syntax.constant + } + Kind::Mark => syntax.punctuation, + Kind::Word | Kind::Comment => return None, + }; + Some((range.clone(), color)) + })) +} + +/// A one-line code field in the mono face. Give its state `code_highlights`. +#[derive(IntoElement)] +pub struct CodeInput { + state: Entity, + size: ControlSize, +} + +impl CodeInput { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + size: ControlSize::default(), + } + } + + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } +} + +impl RenderOnce for CodeInput { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + assert!( + !self.state.read(cx).is_multi_line(), + "a code input is one line; JsonInput takes several" + ); + div() + .w_full() + .font_family(cx.theme().mono_family.clone()) + .child(Input::new(&self.state).size(self.size)) + } +} + +/// A JSON field, checked as you type. Give its state `json_highlights`. +#[derive(IntoElement)] +pub struct JsonInput { + state: Entity, +} + +impl JsonInput { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + } + } +} + +impl RenderOnce for JsonInput { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let source = self.state.read(cx).text().to_string(); + let problem = (!source.trim().is_empty()) + .then(|| serde_json::from_str::(&source).err()) + .flatten() + .map(|error| SharedString::from(error.to_string())); + let theme = cx.theme(); + div() + .flex() + .flex_col() + .gap_1() + .w_full() + .child( + div() + .font_family(theme.mono_family.clone()) + .child(Input::new(&self.state).invalid(problem.is_some())), + ) + .when_some(problem, |field, problem| { + field.child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.danger) + .child(problem), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::{Kind, lex}; + + fn kinds(source: &str) -> Vec<(&str, Kind)> { + lex(source) + .into_iter() + .map(|(range, kind)| (&source[range], kind)) + .collect() + } + + #[test] + fn lexing_splits_strings_numbers_words_and_comments() { + assert_eq!( + kinds(r#"let s = "a \"b\""; // done"#), + [ + ("let", Kind::Word), + ("s", Kind::Word), + ("=", Kind::Mark), + (r#""a \"b\"""#, Kind::Text), + (";", Kind::Mark), + ("// done", Kind::Comment), + ] + ); + assert_eq!( + kinds("f(0x1F, 2.5)"), + [ + ("f", Kind::Word), + ("(", Kind::Mark), + ("0x1F", Kind::Number), + (",", Kind::Mark), + ("2.5", Kind::Number), + (")", Kind::Mark), + ] + ); + } + + #[test] + fn an_open_string_stops_at_the_line_end() { + assert_eq!(kinds("'open"), [("'open", Kind::Text)]); + assert_eq!(kinds("\"a\n1"), [("\"a", Kind::Text), ("1", Kind::Number)]); + } +} diff --git a/src/forms/combobox.rs b/src/forms/combobox.rs index b72b410..492432a 100644 --- a/src/forms/combobox.rs +++ b/src/forms/combobox.rs @@ -16,12 +16,13 @@ use crate::{ theme::{ActiveTheme, ControlSize, IconSize}, }; -/// Rows whose label holds `query`, ignoring case. All rows for an empty query. +/// Rows whose label or note holds `query`, ignoring case. All rows for an empty query. pub(crate) fn matching(choices: &[Choice], query: &str) -> Vec { let query = query.trim().to_lowercase(); + let holds = |text: &SharedString| text.to_lowercase().contains(&query); choices .iter() - .filter(|choice| choice.label.to_lowercase().contains(&query)) + .filter(|choice| holds(&choice.label) || choice.note.as_ref().is_some_and(holds)) .cloned() .collect() } @@ -271,7 +272,7 @@ mod tests { let rows = [ Choice::new("ber", "Berlin"), Choice::new("lis", "Lisbon"), - Choice::new("oslo", "Oslo"), + Choice::new("oslo", "Oslo").note("NO"), ]; let labels = |query| { matching(&rows, query) @@ -282,5 +283,6 @@ mod tests { assert_eq!(labels("LI"), ["Berlin", "Lisbon"]); assert_eq!(labels(""), ["Berlin", "Lisbon", "Oslo"]); assert!(labels("xyz").is_empty()); + assert_eq!(labels("no"), ["Oslo"]); } } diff --git a/src/forms/glyphs.rs b/src/forms/glyphs.rs new file mode 100644 index 0000000..425abf0 --- /dev/null +++ b/src/forms/glyphs.rs @@ -0,0 +1,482 @@ +use std::{ops::Range, rc::Rc}; + +use emojis::Group; +use gpui::{ + AnyElement, App, Context, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, + RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement, Styled, + UniformListScrollHandle, Window, div, prelude::*, uniform_list, +}; + +use super::{ + Input, TextInput, + check::tab_stop, + text::{Down, Enter}, +}; +use crate::{ + primitives::{Icon, IconName, Tooltip}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::Emoji, +}; + +const COLUMNS: usize = 8; + +/// A row of a glyph grid: a group's title, or a run of glyphs. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Row { + Title(&'static str), + Glyphs(Range), +} + +/// Rows for glyph groups given as (title, count), laid in order. +pub(crate) fn rows(groups: impl IntoIterator, usize)>) -> Vec { + let (mut out, mut start) = (Vec::new(), 0); + for (title, count) in groups.into_iter().filter(|(_, count)| *count > 0) { + out.extend(title.map(Row::Title)); + let end = start + count; + while start < end { + let stop = (start + COLUMNS).min(end); + out.push(Row::Glyphs(start..stop)); + start = stop; + } + } + out +} + +fn row_of(rows: &[Row], ix: usize) -> usize { + rows.iter() + .position(|row| matches!(row, Row::Glyphs(range) if range.contains(&ix))) + .expect("every glyph sits in a row") +} + +/// The glyph an arrow key moves to from `at`: same column in the next row, or the last one there. +pub(crate) fn stepped(rows: &[Row], at: usize, key: &str) -> Option { + let row = row_of(rows, at); + let Row::Glyphs(here) = &rows[row] else { + unreachable!("row_of finds glyph rows"); + }; + let column = at - here.start; + let land = |row: &Row| match row { + Row::Glyphs(range) => Some((range.start + column).min(range.end - 1)), + Row::Title(_) => None, + }; + let last = rows + .iter() + .rev() + .find_map(|row| match row { + Row::Glyphs(range) => Some(range.end - 1), + Row::Title(_) => None, + }) + .expect("a grid with a cursor has glyphs"); + match key { + "left" => at.checked_sub(1), + "right" => (at < last).then_some(at + 1), + "up" => rows[..row].iter().rev().find_map(land), + "down" => rows[row + 1..].iter().find_map(land), + _ => None, + } +} + +/// A grid's search field, its cursor, and where its list has scrolled. +struct Finder { + search: Entity, + query: String, + cursor: usize, + scroll: UniformListScrollHandle, +} + +fn finder(id: &ElementId, hint: &'static str, window: &mut Window, cx: &mut App) -> Entity { + let finder = window.use_keyed_state( + (id.clone(), "finder"), + cx, + |window, cx: &mut Context| Finder { + search: cx.new(|cx| TextInput::new(window, cx).placeholder(hint)), + query: String::new(), + cursor: 0, + scroll: UniformListScrollHandle::new(), + }, + ); + let query = { + let search = finder.read(cx).search.clone(); + search.read(cx).text().trim().to_lowercase() + }; + if finder.read(cx).query != query { + finder.update(cx, |finder, _| { + finder.query = query; + finder.cursor = 0; + finder.scroll.scroll_to_item(0, ScrollStrategy::Top); + }); + } + finder +} + +type Pick = Rc; +type Draw = Rc AnyElement>; +type OnIcon = Rc; +type OnEmoji = Rc; + +/// What a glyph grid shows: names for tooltips, rows, a painter, and what a pick does. +struct Grid { + id: ElementId, + names: Rc>, + rows: Rc>, + selected: Option, + draw: Draw, + pick: Pick, + none: &'static str, +} + +fn grid(grid: Grid, finder: &Entity, window: &mut Window, cx: &mut App) -> AnyElement { + let focus = tab_stop((grid.id.clone(), "grid").into(), true, window, cx); + let focused = focus.is_focused(window); + let count = grid.names.len(); + let (search, cursor, scroll) = { + let finder = finder.read(cx); + let cursor = finder.cursor.min(count.saturating_sub(1)); + (finder.search.clone(), cursor, finder.scroll.clone()) + }; + let theme = cx.theme(); + let colors = theme.colors.clone(); + let cell = theme.control_height(ControlSize::Lg); + let (into, enter, keys) = (focus.clone(), grid.pick.clone(), grid.pick.clone()); + let field = div() + .capture_action(move |_: &Down, window, cx| { + if count > 0 { + cx.stop_propagation(); + window.focus(&into); + } + }) + .capture_action(move |_: &Enter, window, cx| { + if count > 0 { + cx.stop_propagation(); + enter(cursor, window, cx); + } + }) + .child( + Input::new(&search).prefix( + Icon::new(IconName::Search) + .size(IconSize::Sm) + .color(colors.fg_subtle), + ), + ); + let body = if count == 0 { + div() + .h(theme.list_max_height()) + .pt_6() + .text_center() + .text_color(colors.fg_subtle) + .child(grid.none) + .into_any_element() + } else { + let (rows, names, draw, pick) = (grid.rows.clone(), grid.names, grid.draw, grid.pick); + let selected = grid.selected; + let (moved, key_rows, place) = (finder.clone(), grid.rows, scroll.clone()); + let clicked = finder.clone(); + let list = uniform_list((grid.id, "rows"), rows.len(), move |range, _, cx| { + let theme = cx.theme(); + rows[range] + .iter() + .map(|row| match row { + Row::Title(title) => div() + .h(cell) + .flex() + .items_end() + .pb_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(*title) + .into_any_element(), + Row::Glyphs(glyphs) => div() + .flex() + .children(glyphs.clone().map(|ix| { + let (pick, name, clicked) = + (pick.clone(), names[ix].clone(), clicked.clone()); + let ring = if focused && ix == cursor { + colors.focus + } else if selected == Some(ix) { + colors.accent + } else { + gpui::transparent_black() + }; + div() + .id(("glyph", ix)) + .flex() + .items_center() + .justify_center() + .size(cell) + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(ring) + .when(selected == Some(ix), |cell| cell.bg(colors.active)) + .cursor_pointer() + .hover(|style| style.bg(colors.hover)) + .tooltip(Tooltip::text(name)) + .on_click(move |_, window, cx| { + clicked.update(cx, |finder, cx| { + finder.cursor = ix; + cx.notify(); + }); + pick(ix, window, cx); + }) + .child(draw(ix, cx)) + })) + .into_any_element(), + }) + .collect() + }) + .track_scroll(scroll) + .h(theme.list_max_height()); + div() + .track_focus(&focus) + .on_key_down(move |event, window, cx| { + let key = event.keystroke.key.as_str(); + if matches!(key, "enter" | "space") { + cx.stop_propagation(); + keys(cursor, window, cx); + return; + } + let Some(to) = stepped(&key_rows, cursor, key) else { + return; + }; + cx.stop_propagation(); + let strategy = if to < cursor { + ScrollStrategy::Top + } else { + ScrollStrategy::Bottom + }; + place.scroll_to_item(row_of(&key_rows, to), strategy); + moved.update(cx, |finder, cx| { + finder.cursor = to; + cx.notify(); + }); + }) + .child(list) + .into_any_element() + }; + div() + .flex() + .flex_col() + .gap_2() + .w(cell * COLUMNS as f32) + .child(field) + .child(body) + .into_any_element() +} + +/// A searchable grid of the library's icons. +#[derive(IntoElement)] +pub struct IconPicker { + id: ElementId, + selected: Option, + on_change: Option, +} + +impl IconPicker { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + selected: None, + on_change: None, + } + } + + pub fn selected(mut self, icon: IconName) -> Self { + self.selected = Some(icon); + self + } + + pub fn on_change( + mut self, + handler: impl Fn(IconName, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for IconPicker { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let finder = finder(&self.id, "Search icons", window, cx); + let query = finder.read(cx).query.clone(); + let found: Rc> = Rc::new( + IconName::ALL + .iter() + .copied() + .filter(|icon| icon.name().replace('-', " ").contains(&query)) + .collect(), + ); + let (painted, picked) = (found.clone(), found.clone()); + let (id, on_change) = (self.id.clone(), self.on_change); + let pick: Pick = Rc::new(move |ix, window, cx| { + let icon = picked[ix]; + log::info!("icon picker {id:?}: {}", icon.name()); + if let Some(on_change) = &on_change { + on_change(icon, window, cx); + } + }); + let fg = cx.theme().colors.fg; + grid( + Grid { + id: self.id, + names: Rc::new(found.iter().map(|icon| icon.name().into()).collect()), + rows: Rc::new(rows([(None, found.len())])), + selected: self + .selected + .and_then(|icon| found.iter().position(|f| *f == icon)), + draw: Rc::new(move |ix, _| { + Icon::new(painted[ix]) + .size(IconSize::Md) + .color(fg) + .into_any_element() + }), + pick, + none: "No icons match", + }, + &finder, + window, + cx, + ) + } +} + +/// Emoji whose name or shortcode holds `query`, a lowercase string; all for an empty one. +fn emoji_found(query: &str) -> Vec<&'static emojis::Emoji> { + emojis::iter() + .filter(|emoji| { + query.is_empty() + || emoji.name().to_lowercase().contains(query) + || emoji.shortcodes().any(|code| code.contains(query)) + }) + .collect() +} + +fn title(group: Group) -> &'static str { + match group { + Group::SmileysAndEmotion => "Smileys & Emotion", + Group::PeopleAndBody => "People & Body", + Group::AnimalsAndNature => "Animals & Nature", + Group::FoodAndDrink => "Food & Drink", + Group::TravelAndPlaces => "Travel & Places", + Group::Activities => "Activities", + Group::Objects => "Objects", + Group::Symbols => "Symbols", + Group::Flags => "Flags", + } +} + +/// A searchable grid of emoji, grouped as Unicode groups them. +#[derive(IntoElement)] +pub struct EmojiPicker { + id: ElementId, + selected: Option, + on_change: Option, +} + +impl EmojiPicker { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + selected: None, + on_change: None, + } + } + + pub fn selected(mut self, emoji: impl Into) -> Self { + self.selected = Some(emoji.into()); + self + } + + pub fn on_change(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for EmojiPicker { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let finder = finder(&self.id, "Search emoji", window, cx); + let query = finder.read(cx).query.clone(); + let found = emoji_found(&query); + let layout = if query.is_empty() { + rows(Group::iter().map(|group| (Some(title(group)), group.emojis().count()))) + } else { + rows([(None, found.len())]) + }; + let found = Rc::new(found); + let (painted, picked) = (found.clone(), found.clone()); + let (id, on_change) = (self.id.clone(), self.on_change); + let pick: Pick = Rc::new(move |ix, window, cx| { + let emoji = picked[ix]; + log::info!("emoji picker {id:?}: {}", emoji.name()); + if let Some(on_change) = &on_change { + on_change(emoji.as_str(), window, cx); + } + }); + grid( + Grid { + id: self.id, + names: Rc::new(found.iter().map(|emoji| emoji.name().into()).collect()), + rows: Rc::new(layout), + selected: self.selected.and_then(|glyph| { + found + .iter() + .position(|emoji| emoji.as_str() == glyph.as_ref()) + }), + draw: Rc::new(move |ix, _| { + Emoji::new(painted[ix].as_str()) + .size(TextSize::Lg) + .into_any_element() + }), + pick, + none: "No emoji match", + }, + &finder, + window, + cx, + ) + } +} + +#[cfg(test)] +mod tests { + use super::{Row, emoji_found, rows, stepped}; + + #[test] + fn emoji_are_found_whatever_the_case_of_their_names() { + let glyphs = |query| { + emoji_found(query) + .into_iter() + .map(|emoji| emoji.as_str()) + .collect::>() + }; + assert!(glyphs("united states").contains(&"🇺🇸")); + assert!(glyphs("sparkles").contains(&"✨")); + assert_eq!(glyphs("").len(), emojis::iter().count()); + } + + #[test] + fn groups_break_into_titled_rows_of_eight() { + let laid = rows([(Some("A"), 10), (None, 0), (Some("B"), 3)]); + assert_eq!( + laid, + [ + Row::Title("A"), + Row::Glyphs(0..8), + Row::Glyphs(8..10), + Row::Title("B"), + Row::Glyphs(10..13), + ] + ); + } + + #[test] + fn arrows_keep_the_column_and_cross_titles() { + let laid = rows([(Some("A"), 10), (Some("B"), 3)]); + assert_eq!(stepped(&laid, 6, "down"), Some(9)); + assert_eq!(stepped(&laid, 9, "down"), Some(11)); + assert_eq!(stepped(&laid, 11, "up"), Some(9)); + assert_eq!(stepped(&laid, 1, "up"), None); + assert_eq!(stepped(&laid, 12, "right"), None); + assert_eq!(stepped(&laid, 0, "left"), None); + assert_eq!(stepped(&laid, 8, "left"), Some(7)); + } +} diff --git a/src/forms/mod.rs b/src/forms/mod.rs index cbe043d..2c316f6 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -1,13 +1,16 @@ mod card; mod cascade; +mod catalogs; mod check; mod checked; mod chips; +mod code; mod color; mod combobox; mod date; mod expression; mod files; +mod glyphs; mod group; mod hotkey; mod inline; @@ -27,6 +30,7 @@ mod radio; mod rating; mod search; mod select; +mod signature; mod slider; mod stepper; mod switch; @@ -39,9 +43,11 @@ mod unit; pub use card::{CheckboxCard, RadioCard}; pub use cascade::{Cascade, Cascader}; +pub use catalogs::FontPicker; pub use check::{CheckState, Checkbox, CheckboxGroup}; pub use checked::{EmailInput, UrlInput}; pub use chips::ChoiceChips; +pub use code::{CodeInput, JsonInput, code_highlights, json_highlights}; pub use color::{ColorPalette, ColorPicker, ColorSwatch, EyeDropper, GradientEditor, GradientStop}; pub use combobox::Combobox; pub use date::{ @@ -51,6 +57,7 @@ pub use date::{ }; pub use expression::{ExpressionInput, evaluate, expression_highlights}; pub use files::{DropZone, FileInput}; +pub use glyphs::{EmojiPicker, IconPicker}; pub use group::{InputAddon, InputGroup}; pub use hotkey::HotkeyInput; pub use inline::InlineEdit; @@ -70,6 +77,7 @@ pub use radio::{Radio, RadioGroup}; pub use rating::Rating; pub use search::SearchInput; pub use select::Select; +pub use signature::{SignaturePad, Stroke}; pub use slider::{RangeSlider, Slider}; pub use stepper::Stepper; pub use switch::Switch; diff --git a/src/forms/signature.rs b/src/forms/signature.rs new file mode 100644 index 0000000..6fdc329 --- /dev/null +++ b/src/forms/signature.rs @@ -0,0 +1,281 @@ +use std::rc::Rc; + +use gpui::{ + App, Bounds, Corners, DragMoveEvent, ElementId, EmptyView, EntityId, Hsla, InteractiveElement, + IntoElement, MouseButton, ParentElement, Path, PathBuilder, Pixels, Point, RenderOnce, + StatefulInteractiveElement, Styled, Window, canvas, div, fill, point, prelude::*, size, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + layout::seeded::use_seeded, + theme::{ActiveTheme, ControlSize, Radius, TextSize}, +}; + +/// A pen stroke: points from the pad's top left corner. +pub type Stroke = Vec>; + +type OnStrokes = Rc; +type Lift = Rc, &mut Window, &mut App)>; + +struct Pen { + owner: EntityId, +} + +/// Where the pad sits, and the stroke under the pen. +#[derive(Default)] +struct Drawing { + bounds: Bounds, + stroke: Stroke, +} + +impl Drawing { + /// Adds `at` to the stroke under the pen, unless the pen has not moved. + fn reach(&mut self, at: Point) { + let at = local(self.bounds, at); + if self.stroke.last() != Some(&at) { + self.stroke.push(at); + } + } +} + +/// `at` from the pad's corner, kept inside the pad. +fn local(bounds: Bounds, at: Point) -> Point { + point( + (at.x - bounds.left()) + .max(Pixels::ZERO) + .min(bounds.size.width), + (at.y - bounds.top()) + .max(Pixels::ZERO) + .min(bounds.size.height), + ) +} + +/// A line through `points`, bent through their midpoints so it reads smooth. +fn ink(points: &[Point], origin: Point, width: Pixels) -> Option> { + let [first, .., last] = points else { + return None; + }; + let at = |p: Point| point(origin.x + p.x, origin.y + p.y); + let mut path = PathBuilder::stroke(width); + path.move_to(at(*first)); + for pair in points.windows(2) { + let middle = point((pair[0].x + pair[1].x) / 2.0, (pair[0].y + pair[1].y) / 2.0); + path.curve_to(at(middle), at(pair[0])); + } + path.line_to(at(*last)); + Some(path.build().expect("a stroke is a simple open path")) +} + +fn paint( + strokes: &[Stroke], + bounds: Bounds, + width: Pixels, + color: Hsla, + window: &mut Window, +) { + let dot = |at: Point| { + let center = point(bounds.left() + at.x, bounds.top() + at.y); + fill(Bounds::centered_at(center, size(width, width)), color) + .corner_radii(Corners::all(width / 2.0)) + }; + for stroke in strokes { + if let Some(path) = ink(stroke, bounds.origin, width) { + window.paint_path(path, color); + } + for end in [stroke.first(), stroke.last()].into_iter().flatten() { + window.paint_quad(dot(*end)); + } + } +} + +/// A pad to sign with the pointer. A stroke lands when the pen lifts; Clear wipes them all. +#[derive(IntoElement)] +pub struct SignaturePad { + id: ElementId, + strokes: Vec, + on_change: Option, +} + +impl SignaturePad { + pub fn new(id: impl Into, strokes: impl IntoIterator) -> Self { + Self { + id: id.into(), + strokes: strokes.into_iter().collect(), + on_change: None, + } + } + + pub fn on_change( + mut self, + handler: impl Fn(&[Stroke], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SignaturePad { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let strokes = use_seeded((self.id.clone(), "strokes"), self.strokes, window, cx); + let drawing = + window.use_keyed_state((self.id.clone(), "drawing"), cx, |_, _| Drawing::default()); + let owner = drawing.entity_id(); + let set: OnStrokes = { + let (id, strokes, on_change) = (self.id.clone(), strokes.clone(), self.on_change); + Rc::new(move |next, window, cx| { + log::info!("signature pad {id:?}: {} strokes", next.len()); + strokes.update(cx, |strokes, cx| { + strokes.value = next.to_vec(); + cx.notify(); + }); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + }) + }; + let lift: Lift = { + let (strokes, drawing, set) = (strokes.clone(), drawing.clone(), set.clone()); + Rc::new(move |at, window, cx| { + let stroke = drawing.update(cx, |drawing, _| { + if !drawing.stroke.is_empty() { + drawing.reach(at); + } + std::mem::take(&mut drawing.stroke) + }); + if stroke.is_empty() { + return; + } + let mut next = strokes.read(cx).value.clone(); + next.push(stroke); + set(&next, window, cx); + }) + }; + let theme = cx.theme(); + let colors = &theme.colors; + let (height, width) = theme.signature(); + let width = width.to_pixels(window.rem_size()); + let empty = strokes.read(cx).value.is_empty(); + let mut shown = strokes.read(cx).value.clone(); + shown.push(drawing.read(cx).stroke.clone()); + let (measure, press, start, pen) = + (drawing.clone(), drawing.clone(), drawing.clone(), drawing); + let (up, out) = (lift.clone(), lift); + let color = colors.fg; + let pad = div() + .id(self.id.clone()) + .relative() + .w_full() + .h(height) + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border_strong) + .bg(colors.surface) + .cursor_crosshair() + .child( + div() + .absolute() + .left_6() + .right_6() + .bottom_8() + .flex() + .items_end() + .gap_2() + .pb_1() + .border_b_1() + .border_dashed() + .border_color(colors.border_strong) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_subtle) + .child("×") + .when(empty, |line| line.child("Sign here")), + ) + .child( + canvas( + move |bounds, _, cx| { + if measure.read(cx).bounds != bounds { + measure.update(cx, |drawing, _| drawing.bounds = bounds); + } + }, + move |bounds, _, window, _| paint(&shown, bounds, width, color, window), + ) + .absolute() + .size_full(), + ) + .on_mouse_down(MouseButton::Left, move |event, _, cx| { + press.update(cx, |drawing, cx| { + drawing.stroke = vec![local(drawing.bounds, event.position)]; + cx.notify(); + }) + }) + .on_drag(Pen { owner }, move |_, _, window, cx| { + let at = window.mouse_position(); + start.update(cx, |drawing, cx| { + drawing.reach(at); + cx.notify(); + }); + cx.new(|_| EmptyView) + }) + .on_drag_move(move |event: &DragMoveEvent, _, cx| { + if event.drag(cx).owner != owner { + return; + } + pen.update(cx, |drawing, cx| { + drawing.reach(event.event.position); + cx.notify(); + }) + }) + .on_mouse_up(MouseButton::Left, move |event, window, cx| { + up(event.position, window, cx) + }) + .on_mouse_up_out(MouseButton::Left, move |event, window, cx| { + out(event.position, window, cx) + }); + div() + .flex() + .flex_col() + .items_end() + .gap_1() + .w_full() + .child(pad) + .child( + Button::new((self.id, "clear"), "Clear") + .size(ControlSize::Sm) + .variant(ButtonVariant::Ghost) + .disabled(empty) + .on_click(move |_, window, cx| set(&[], window, cx)), + ) + } +} + +#[cfg(test)] +mod tests { + use gpui::{Bounds, point, px, size}; + + use super::{ink, local}; + + #[test] + fn points_stay_inside_the_pad() { + let bounds = Bounds::new(point(px(10.0), px(20.0)), size(px(100.0), px(50.0))); + assert_eq!( + local(bounds, point(px(30.0), px(40.0))), + point(px(20.0), px(20.0)) + ); + assert_eq!( + local(bounds, point(px(-5.0), px(90.0))), + point(px(0.0), px(50.0)) + ); + } + + #[test] + fn a_stroke_needs_two_points_for_a_line() { + let origin = point(px(0.0), px(0.0)); + assert!(ink(&[point(px(1.0), px(1.0))], origin, px(2.0)).is_none()); + let line = [ + point(px(1.0), px(1.0)), + point(px(9.0), px(4.0)), + point(px(12.0), px(9.0)), + ]; + assert!(ink(&line, origin, px(2.0)).is_some()); + } +} diff --git a/src/forms/tests/mod.rs b/src/forms/tests/mod.rs index 6e418b1..5a1a947 100644 --- a/src/forms/tests/mod.rs +++ b/src/forms/tests/mod.rs @@ -1,6 +1,7 @@ mod choices; mod colors; mod dates; +mod other; use gpui::{ AppContext as _, Context, Entity, EntityInputHandler, IntoElement, KeyUpEvent, Keystroke, diff --git a/src/forms/tests/other.rs b/src/forms/tests/other.rs new file mode 100644 index 0000000..69b1a12 --- /dev/null +++ b/src/forms/tests/other.rs @@ -0,0 +1,94 @@ +use gpui::{ + Context, IntoElement, Modifiers, MouseButton, Render, TestAppContext, Window, point, px, +}; + +use super::setup; +use crate::{ + forms::{IconPicker, SignaturePad, Stroke}, + primitives::IconName, +}; + +struct Icons { + picked: Option, +} + +impl Render for Icons { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + IconPicker::new("icons").on_change(move |icon, _, cx| { + view.update(cx, |view, cx| { + view.picked = Some(icon); + cx.notify(); + }) + }) + } +} + +#[gpui::test] +fn an_icon_is_searched_walked_and_picked_from_the_keyboard(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Icons { picked: None }); + cx.update(|window, _| window.focus_next()); + cx.simulate_input("arrow down"); + cx.simulate_keystrokes("down right enter"); + let wanted: Vec = IconName::ALL + .iter() + .copied() + .filter(|icon| icon.name().replace('-', " ").contains("arrow down")) + .collect(); + assert!(wanted.len() > 1, "{wanted:?}"); + assert_eq!(view.read_with(cx, |view, _| view.picked), Some(wanted[1])); +} + +struct Pad { + strokes: Vec, +} + +impl Render for Pad { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + SignaturePad::new("pad", self.strokes.clone()).on_change(move |next, _, cx| { + view.update(cx, |view, cx| { + view.strokes = next.to_vec(); + cx.notify(); + }) + }) + } +} + +#[gpui::test] +fn a_drag_on_the_pad_lands_as_one_stroke(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Pad { + strokes: Vec::new(), + }); + let none = Modifiers::none(); + cx.simulate_mouse_down(point(px(40.0), px(80.0)), MouseButton::Left, none); + for (x, y) in [(60.0, 70.0), (90.0, 100.0), (120.0, 90.0)] { + cx.simulate_mouse_move(point(px(x), px(y)), MouseButton::Left, none); + } + cx.simulate_mouse_up(point(px(120.0), px(90.0)), MouseButton::Left, none); + let strokes = view.read_with(cx, |view, _| view.strokes.clone()); + assert_eq!(strokes.len(), 1, "{strokes:?}"); + let stroke = &strokes[0]; + assert!(stroke.len() >= 2, "{stroke:?}"); + let moved = stroke[stroke.len() - 1] - stroke[0]; + assert_eq!(moved, point(px(80.0), px(10.0)), "{stroke:?}"); +} + +#[gpui::test] +fn one_move_keeps_both_ends_of_the_stroke(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Pad { + strokes: Vec::new(), + }); + let none = Modifiers::none(); + cx.simulate_mouse_down(point(px(40.0), px(80.0)), MouseButton::Left, none); + cx.simulate_mouse_move(point(px(100.0), px(60.0)), MouseButton::Left, none); + cx.simulate_mouse_up(point(px(100.0), px(60.0)), MouseButton::Left, none); + let strokes = view.read_with(cx, |view, _| view.strokes.clone()); + assert_eq!(strokes.len(), 1, "{strokes:?}"); + let stroke = &strokes[0]; + assert_eq!(stroke.len(), 2, "{stroke:?}"); + assert_eq!(stroke[1] - stroke[0], point(px(60.0), px(-20.0))); +} diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index 60b1d6c..fd00d8b 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -266,6 +266,11 @@ impl Theme { px_to_rems(152.0) } + /// A signature pad's height and its ink's width. + pub fn signature(&self) -> (Rems, Rems) { + (px_to_rems(160.0), px_to_rems(2.0)) + } + /// Tallest a list grows before it scrolls. pub fn list_max_height(&self) -> Rems { px_to_rems(280.0) diff --git a/src/typography/format.rs b/src/typography/format.rs index b03ac28..7ebf0ee 100644 --- a/src/typography/format.rs +++ b/src/typography/format.rs @@ -52,15 +52,17 @@ pub fn group_digits(digits: &str, group: char) -> String { /// ISO 4217 code to symbol, minor units, and whether a space follows. pub fn currency_parts(code: &str) -> (&str, usize, bool) { + let places = iso_currency::Currency::from_code(code) + .and_then(|currency| currency.exponent()) + .map_or(2, usize::from); 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), + "USD" => ("$", places, false), + "EUR" => ("€", places, false), + "GBP" => ("£", places, false), + "JPY" | "CNY" => ("¥", places, false), + "KRW" => ("₩", places, false), + "INR" => ("₹", places, false), + _ => (code, places, true), } } @@ -201,6 +203,8 @@ mod tests { 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"); + assert_eq!(currency(1.5, "BHD"), "BHD 1.500"); + assert_eq!(currency(25000.0, "VND"), "VND 25,000"); } #[test] diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index 838dbb7..9cda409 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -254,15 +254,15 @@ ## T06f Forms · Other -- [ ] SignaturePad -- [ ] CodeInput -- [ ] JsonInput -- [ ] FontPicker -- [ ] IconPicker -- [ ] EmojiPicker -- [ ] LanguageSelect -- [ ] CountrySelect -- [ ] CurrencySelect +- [x] SignaturePad → `forms::SignaturePad` +- [x] CodeInput → `forms::CodeInput`, colored by `code_highlights` +- [x] JsonInput → `forms::JsonInput`, colored by `json_highlights` +- [x] FontPicker → `forms::FontPicker` +- [x] IconPicker → `forms::IconPicker` +- [x] EmojiPicker → `forms::EmojiPicker` +- [x] LanguageSelect → `forms::Combobox::languages` +- [x] CountrySelect → `forms::Combobox::countries` +- [x] CurrencySelect → `forms::Combobox::currencies` ## T06g Forms · Structure diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 408d7c2..f3805b1 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -127,7 +127,7 @@ - [ ] FontSizeControl - [ ] FontFamilySelect → `forms::FontPicker` - [ ] DensitySelector -- [ ] LanguageSelector → `forms::LanguageSelect` +- [ ] LanguageSelector → `forms::Combobox::languages` (T06f) - [ ] KeyboardShortcutsList - [ ] ShortcutEditor → `editor::KeybindingsEditor` - [ ] ProxySettings