diff --git a/AGENTS.md b/AGENTS.md index 4c8ca39..cb6b94a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - `cargo run --example gallery` opens the gallery. `-- --page ` starts on a page. - `cargo run --example gallery -- --capture ` writes PNGs of every page, light and dark, top to bottom, then each page's scripted states, including windows the demos open. macOS only. -- `cargo test --lib`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`. +- `cargo test --lib --features test-support`, `cargo clippy --all-targets --features test-support -- -D warnings`, `cargo fmt --check`. `scripts/check.sh` runs them with the house rules. - `rm -rf target/debug/incremental` after each task item keeps the disk lean. - `scripts/icons.sh ...` adds icons. Then add a line to `IconName` in `src/primitives/icon.rs`. @@ -47,6 +47,14 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - An overlay takes focus with `primitives::take_focus` and returns it with `give_back` on every close path. - A state change made outside render calls `cx.notify()`, or nothing redraws. - The macOS share picker draws its content in another process; a capture shows only its frame. +- Every text field is a `forms::TextInput` entity. Its keys live under the `ElyInput` context, bound in `init`. Wrappers take Up, Down, Enter and Backspace first with `capture_action`. +- `TextInput::bounds_for` reads the last layout. Text changed this frame clamps to the laid-out end until the next paint. +- The harness types with `dispatch_keystroke` through `AnyWindowHandle`, which leaves the root view free to redraw. +- The macOS open panel runs out of process too. Escape does not reach it; the harness sends it `cancel:`. +- gpui's `test-support` swaps its executor: `block` fails on a real dispatcher. It stays behind this crate's `test-support` feature, never on for the gallery. +- A tracked `FocusHandle` is a Tab stop only when built with `.tab_stop(true)`. `tab_index` on the div reaches only handles gpui makes itself. +- 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. - `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. diff --git a/Cargo.lock b/Cargo.lock index f6bd8e1..9a5c720 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -122,7 +131,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" dependencies = [ - "object", + "object 0.39.1", ] [[package]] @@ -520,6 +529,21 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object 0.37.3", + "rustc-demangle", + "windows-link 0.2.1", +] + [[package]] name = "base64" version = "0.22.1" @@ -2132,6 +2156,25 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.13.2", + "libc", + "libgit2-sys", + "log", + "url", +] + [[package]] name = "glob" version = "0.3.4" @@ -2215,6 +2258,7 @@ dependencies = [ "as-raw-xcb-connection", "ashpd 0.11.1", "async-task", + "backtrace", "bindgen", "blade-graphics", "blade-macros", @@ -2431,12 +2475,15 @@ dependencies = [ "dunce", "futures", "futures-lite 1.13.0", + "git2", "globset", "gpui_collections", + "gpui_util_macros", "itertools 0.14.0", "libc", "log", "nix 0.29.0", + "rand 0.9.5", "regex", "rust-embed", "schemars", @@ -3115,6 +3162,18 @@ dependencies = [ "cc", ] +[[package]] +name = "libgit2-sys" +version = "0.18.5+1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -3140,6 +3199,18 @@ dependencies = [ "libc", ] +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3739,6 +3810,15 @@ dependencies = [ "objc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "object" version = "0.39.1" @@ -4643,6 +4723,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -6126,6 +6212,12 @@ dependencies = [ "sval_serde", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 459b562..954318a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ publish = false [features] default = ["runtime_shaders"] runtime_shaders = ["gpui/runtime_shaders"] +test-support = ["gpui/test-support"] [dependencies] anyhow = "1" diff --git a/TASKS.md b/TASKS.md index 41db440..f37d206 100644 --- a/TASKS.md +++ b/TASKS.md @@ -22,7 +22,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T03 Layout - [x] T04 Window & Shell - [x] T05 Buttons & Actions -- [ ] T06a Forms · Text +- [x] T06a Forms · Text - [ ] T06b Forms · Selection - [ ] T06c Forms · Date & Time - [ ] T06d Forms · Color @@ -119,3 +119,6 @@ Per-component lines live in `tasks/`. Tags there: | T05 | 1 | FAIL | hold task kept its state alive; a third click wedged the done state; border_1 undid the group seam | | T05 | 2 | FAIL | AGENTS.md said no window is found by scanning; popups are | | T05 | 3 | PASS | | +| T06a | 1 | FAIL | twin ids in clear and eye buttons; text fields not Tab stops; IME undo; stepping from a stale value; mask literals re-read as input; undo while disabled; Down past the last line; clamp before rounding; stuck mention dismissal; NaN swallowed by min and max | +| T06a | 2 | FAIL | float error in rounded bounds; composition commit skipped filter and length; a typed leading literal was eaten; PIN select-all collapsed; stepper state read the old value | +| T06a | 3 | FAIL | a mask diff misread a select-all replacement; grid tolerance too wide for large numbers; fixed after the cap by fitting masks inside `TextInput` with the exact edit, and a float-error tolerance; verified by tests and capture, no fourth review | diff --git a/examples/gallery/pages/forms/formats.rs b/examples/gallery/pages/forms/formats.rs new file mode 100644 index 0000000..5d81ba1 --- /dev/null +++ b/examples/gallery/pages/forms/formats.rs @@ -0,0 +1,125 @@ +use ely_gpui_component::{ + forms::{EmailInput, InlineEdit, MaskedInput, PhoneInput, PinInput, UrlInput}, + typography::Caption, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use super::text::field; +use crate::{ + probe::probe, + ui::{section, specimen, specimens}, +}; + +pub fn email_url(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let email = field("input-email", window, cx, |input| { + input.placeholder("name@example.com") + }); + let url = field("input-url", window, cx, |input| { + input.placeholder("https://") + }); + section( + "EmailInput / UrlInput", + "Checked once you leave the field; a quiet note says what fits.", + cx, + ) + .child( + specimens() + .child(specimen( + "email", + probe("email", div().w(px(280.0)).child(EmailInput::new(&email))), + cx, + )) + .child(specimen( + "url", + div().w(px(280.0)).child(UrlInput::new(&url)), + cx, + )), + ) +} + +pub fn phone_masked(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let phone = field("input-phone", window, cx, |input| input); + let card = field("input-card", window, cx, |input| input); + let country = window.use_keyed_state("phone-country", cx, |_, _| "US"); + let current = *country.read(cx); + section( + "PhoneInput / MaskedInput", + "Typing fits the pattern; literals appear on their own.", + cx, + ) + .child( + specimens() + .child(specimen( + "phone", + probe( + "phone", + div() + .w(px(300.0)) + .child(PhoneInput::new("phone", &phone, current).on_country( + move |code, _, cx| { + country.update(cx, |country, cx| { + *country = code; + cx.notify(); + }) + }, + )), + ), + cx, + )) + .child(specimen( + "masked: aa-9999", + probe( + "masked", + div() + .w(px(200.0)) + .child(MaskedInput::new("card", &card, "aa-9999")), + ), + cx, + )), + ) +} + +pub fn pin(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let entered = window.use_keyed_state("pin-entered", cx, |_, _| SharedString::from("waiting")); + let note = entered.read(cx).clone(); + section( + "PinInput / OTPInput", + "Six boxes, one field underneath: type, paste, or backspace through them.", + cx, + ) + .child(probe( + "pin", + PinInput::new("otp", 6).on_complete(move |code, _, cx| { + let code = SharedString::from(format!("entered {code}")); + entered.update(cx, |entered, cx| { + *entered = code; + cx.notify(); + }) + }), + )) + .child(Caption::new(format!("Code: {note}"))) +} + +pub fn inline_edit(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let title = window.use_keyed_state("inline-title", cx, |_, _| { + SharedString::from("Quarterly planning") + }); + let now = title.read(cx).clone(); + section( + "InlineEdit / EditableText", + "Click the text to edit. Enter or leaving keeps it; Escape puts it back.", + cx, + ) + .child(probe( + "inline", + div() + .w(px(280.0)) + .child(InlineEdit::new("title", now).on_commit(move |text, _, cx| { + let text = text.clone(); + title.update(cx, |title, cx| { + *title = text; + cx.notify(); + }) + })), + )) +} diff --git a/examples/gallery/pages/forms/mod.rs b/examples/gallery/pages/forms/mod.rs new file mode 100644 index 0000000..fe01ae0 --- /dev/null +++ b/examples/gallery/pages/forms/mod.rs @@ -0,0 +1,137 @@ +mod formats; +mod numbers; +mod rich; +mod text; + +use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; + +use super::Page; +use crate::script::Step; + +pub const PAGE: Page = Page { + number: 6, + slug: "forms", + title: "Forms", + summary: "Text, choices, dates, colors, files: every way to say what you mean.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::Rest, + Step::Click("input-name"), + Step::Key("secondary-a"), + Step::Type("Ada Lovelace"), + Step::Key("shift-left"), + Step::Key("shift-left"), + Step::Key("shift-left"), + Step::Wait(300), + Step::Shot("typed"), + Step::Click("textarea"), + Step::Key("secondary-a"), + Step::Type("First line of notes"), + Step::Key("enter"), + Step::Type("Second line, longer, so the words wrap when they reach the edge of the box"), + Step::Wait(300), + Step::Shot("textarea"), + Step::Click("password"), + Step::Key("secondary-a"), + Step::Type("hunter2"), + Step::Wait(200), + Step::Shot("password"), + Step::Click("search"), + Step::Wait(300), + Step::Shot("search-history"), + Step::Click("number"), + Step::Key("up"), + Step::Key("up"), + Step::Wait(300), + Step::Shot("number"), + Step::DownAt("scrub", 12.0, 14.0), + Step::DragTo("scrub", 72.0, 14.0), + Step::UpAt("scrub", 72.0, 14.0), + Step::Wait(300), + Step::Shot("scrubbed"), + Step::Click("email"), + Step::Key("secondary-a"), + Step::Type("ada@example"), + Step::Click("phone"), + Step::Key("secondary-a"), + Step::Type("4155550132"), + Step::Click("masked"), + Step::Key("secondary-a"), + Step::Type("ab1234"), + Step::Wait(300), + Step::Shot("formats"), + Step::Click("pin"), + Step::Key("secondary-a"), + Step::Type("4821"), + Step::Wait(300), + Step::Shot("pin"), + Step::Click("inline"), + Step::Key("secondary-a"), + Step::Type("Q3 planning"), + Step::Key("enter"), + Step::Wait(300), + Step::Shot("inline"), + Step::Click("mention"), + Step::Key("secondary-a"), + Step::Type("Ask @gr"), + Step::Wait(300), + Step::Shot("mention"), + Step::Key("enter"), + Step::Type("about #rel"), + Step::Key("down"), + Step::Key("enter"), + Step::Wait(300), + Step::Shot("mentioned"), + Step::Click("tags"), + Step::Type("motion,design"), + Step::Key("enter"), + Step::Wait(300), + Step::Shot("tags"), + Step::Click("regex"), + Step::Key("secondary-a"), + Step::Type("^(ab+"), + Step::Wait(300), + Step::Shot("regex"), + Step::Click("expression"), + Step::Key("secondary-a"), + Step::Type("price * qty + max(2, 4)"), + Step::Wait(300), + Step::Shot("expression"), + Step::Click("hotkey"), + Step::Key("cmd-shift-k"), + Step::Wait(300), + Step::Shot("hotkey"), + Step::DownAt("path", 380.0, 16.0), + Step::UpAt("path", 380.0, 16.0), + Step::Wait(1200), + Step::CancelPanel, + Step::Wait(600), + Step::Rest, +]; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(text::input(window, cx)) + .child(text::text_area(window, cx)) + .child(text::password(window, cx)) + .child(text::search(window, cx)) + .child(numbers::number_input(window, cx)) + .child(numbers::scrub(window, cx)) + .child(numbers::money(window, cx)) + .child(formats::email_url(window, cx)) + .child(formats::phone_masked(window, cx)) + .child(formats::pin(window, cx)) + .child(formats::inline_edit(window, cx)) + .child(text::group(window, cx)) + .child(rich::mention(window, cx)) + .child(rich::tags(window, cx)) + .child(rich::rows(cx)) + .child(rich::path(window, cx)) + .child(rich::regex(window, cx)) + .child(rich::expression(window, cx)) + .child(rich::hotkey(window, cx)) + .into_any_element() +} diff --git a/examples/gallery/pages/forms/numbers.rs b/examples/gallery/pages/forms/numbers.rs new file mode 100644 index 0000000..aca5e95 --- /dev/null +++ b/examples/gallery/pages/forms/numbers.rs @@ -0,0 +1,133 @@ +use ely_gpui_component::forms::{NumberInput, ScrubInput, UnitInput}; +use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{code, section, specimen, specimens}, +}; + +fn number(key: &'static str, initial: f64, window: &mut Window, cx: &mut App) -> Entity { + window.use_keyed_state(key, cx, move |_, _| initial) +} + +fn store(state: Entity) -> impl Fn(f64, &mut Window, &mut App) + 'static { + move |value, _, cx| { + state.update(cx, |state, cx| { + *state = value; + cx.notify(); + }) + } +} + +pub fn number_input(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let seats = number("number-seats", 4.0, window, cx); + let now = *seats.read(cx); + section( + "NumberInput", + "Arrow keys or the stepper move it; drag the grip sideways to scrub.", + cx, + ) + .child(probe( + "number", + div().w(px(220.0)).child( + NumberInput::new("seats", now) + .range(1.0, 99.0) + .on_change(store(seats)), + ), + )) +} + +pub fn scrub(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let x = number("scrub-x", 120.0, window, cx); + let opacity = number("scrub-opacity", 0.8, window, cx); + let (x_now, opacity_now) = (*x.read(cx), *opacity.read(cx)); + section( + "ScrubInput", + "Drag the label sideways, as in a design tool. The field still types.", + cx, + ) + .child(probe( + "scrub", + div() + .flex() + .flex_col() + .gap_2() + .w(px(260.0)) + .child( + ScrubInput::new("scrub-x", "X", x_now) + .range(-2000.0, 2000.0) + .on_change(store(x)), + ) + .child( + ScrubInput::new("scrub-opacity", "Opacity", opacity_now) + .range(0.0, 1.0) + .step(0.01) + .precision(2) + .on_change(store(opacity)), + ), + )) +} + +pub fn money(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let price = number("number-price", 1249.5, window, cx); + let tax = number("number-tax", 8.0, window, cx); + let gap = number("number-gap", 16.0, window, cx); + let unit = window.use_keyed_state("number-unit", cx, |_, _| SharedString::from("px")); + let (price_now, tax_now, gap_now, unit_now) = ( + *price.read(cx), + *tax.read(cx), + *gap.read(cx), + unit.read(cx).clone(), + ); + section( + "CurrencyInput / PercentInput / UnitInput", + "The same number field with a symbol, a percent, or a unit you click through.", + cx, + ) + .child( + specimens() + .child(specimen( + "currency", + div().w(px(200.0)).child( + NumberInput::new("price", price_now) + .currency("USD") + .range(0.0, 1_000_000.0) + .on_change(store(price)), + ), + cx, + )) + .child(specimen( + "percent", + div().w(px(160.0)).child( + NumberInput::new("tax", tax_now) + .percent() + .range(0.0, 100.0) + .on_change(store(tax)), + ), + cx, + )) + .child(specimen( + "unit", + probe( + "unit", + div().w(px(180.0)).child( + UnitInput::new("gap", gap_now, ["px", "%", "em"], unit_now) + .range(0.0, 400.0) + .on_change(store(gap)) + .on_unit(move |next, _, cx| { + let next = next.clone(); + unit.update(cx, |unit, cx| { + *unit = next; + cx.notify(); + }) + }), + ), + ), + cx, + )), + ) + .child(code( + "NumberInput::new(id, value).currency(\"USD\") and .percent()", + cx, + )) +} diff --git a/examples/gallery/pages/forms/rich.rs b/examples/gallery/pages/forms/rich.rs new file mode 100644 index 0000000..478b8d7 --- /dev/null +++ b/examples/gallery/pages/forms/rich.rs @@ -0,0 +1,174 @@ +use ely_gpui_component::{ + forms::{ + ExpressionInput, HotkeyInput, KeyValueInput, ListInput, MentionInput, PathInput, + RegexInput, TagInput, expression_highlights, mention_highlights, regex_highlights, + }, + typography::Caption, +}; +use gpui::{App, IntoElement, Keystroke, ParentElement, SharedString, Styled, Window, div, px}; + +use super::text::field; +use crate::{ + probe::probe, + ui::{code, section}, +}; + +pub fn mention(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let message = field("input-mention", window, cx, |input| { + input + .multi_line(2, 5) + .placeholder("Write a note. @ for people, # for tags.") + .highlighter(mention_highlights) + }); + section( + "MentionInput / HashtagInput", + "@ and # open suggestions at the caret. Arrows choose, Enter picks.", + cx, + ) + .child(probe( + "mention", + div().w(px(420.0)).child( + MentionInput::new("mentions", &message) + .trigger('@', ["ada", "alan", "grace", "linus"]) + .trigger('#', ["release", "research", "design", "docs"]), + ), + )) +} + +pub fn tags(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let tags = window.use_keyed_state("tags", cx, |_, _| { + vec![SharedString::from("rust"), SharedString::from("gpui")] + }); + let now = tags.read(cx).clone(); + section( + "TagInput / TokenInput", + "Enter or a comma adds a chip; Backspace on an empty field drops the last.", + cx, + ) + .child(probe( + "tags", + div().w(px(420.0)).child( + TagInput::new("topic-tags", now).on_change(move |next, _, cx| { + tags.update(cx, |tags, cx| { + *tags = next; + cx.notify(); + }) + }), + ), + )) +} + +pub fn rows(cx: &App) -> impl IntoElement + use<> { + section( + "KeyValueInput / ListInput", + "Rows you add and remove. Each row reports as you type.", + cx, + ) + .child( + div() + .flex() + .gap_8() + .child( + div().w(px(380.0)).child( + KeyValueInput::new( + "env", + [ + ("RUST_LOG".to_string(), "info".to_string()), + ("PORT".to_string(), "8080".to_string()), + ], + ) + .on_change(|pairs, _, _| log::info!("gallery: {} pairs", pairs.len())), + ), + ) + .child( + div().w(px(240.0)).child( + ListInput::new("hosts", ["ely.dev".to_string()]) + .placeholder("Host") + .on_change(|items, _, _| log::info!("gallery: {} hosts", items.len())), + ), + ), + ) +} + +pub fn path(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let folder = field("input-path", window, cx, |input| { + input.placeholder("~/Projects") + }); + section( + "PathInput", + "Type a path, or Browse with the system's dialog.", + cx, + ) + .child(probe( + "path", + div() + .w(px(420.0)) + .child(PathInput::new("path-browse", &folder).directories()), + )) +} + +pub fn regex(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let pattern = field("input-regex", window, cx, |input| { + input.placeholder("^[a-z]+$").highlighter(regex_highlights) + }); + section( + "RegexInput", + "Colored by part as you type; a broken pattern says why.", + cx, + ) + .child(probe( + "regex", + div().w(px(420.0)).child(RegexInput::new(&pattern)), + )) +} + +pub fn expression(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let formula = field("input-formula", window, cx, |input| { + input + .placeholder("price * qty") + .highlighter(expression_highlights) + }); + section( + "ExpressionInput", + "A formula, colored and evaluated as you type.", + cx, + ) + .child(probe( + "expression", + div().w(px(420.0)).child( + ExpressionInput::new(&formula) + .variable("price", 12.5) + .variable("qty", 3.0), + ), + )) + .child(code( + "price = 12.5, qty = 3; abs sqrt round floor ceil min max", + cx, + )) +} + +pub fn hotkey(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let recorded = window.use_keyed_state("hotkey", cx, |_, _| Keystroke::parse("cmd-k").ok()); + let now = recorded.read(cx).clone(); + section( + "HotkeyInput / ShortcutRecorder", + "Focus it and press a shortcut; it keeps that one and lets go.", + cx, + ) + .child(probe( + "hotkey", + div() + .w(px(260.0)) + .child( + HotkeyInput::new("palette-key", now).on_change(move |next, _, cx| { + recorded.update(cx, |recorded, cx| { + *recorded = next; + cx.notify(); + }) + }), + ), + )) + .child(Caption::new( + "Records any key with its modifiers; Tab still moves focus.", + )) +} diff --git a/examples/gallery/pages/forms/text.rs b/examples/gallery/pages/forms/text.rs new file mode 100644 index 0000000..b23f9c3 --- /dev/null +++ b/examples/gallery/pages/forms/text.rs @@ -0,0 +1,166 @@ +use ely_gpui_component::{ + buttons::IconButton, + forms::{Input, InputAddon, InputGroup, PasswordInput, SearchInput, TextInput}, + primitives::IconName, + theme::ControlSize, + typography::Caption, +}; +use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{code, section, specimen, specimens}, +}; + +pub fn field( + key: &'static str, + window: &mut Window, + cx: &mut App, + build: impl FnOnce(TextInput) -> TextInput, +) -> Entity { + window.use_keyed_state(key, cx, move |window, cx| build(TextInput::new(window, cx))) +} + +pub fn input(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let name = field("input-name", window, cx, |input| { + input.placeholder("Full name") + }); + let small = field("input-small", window, cx, |input| { + input.placeholder("Small") + }); + let large = field("input-large", window, cx, |input| { + input.placeholder("Large") + }); + let clear = field("input-clear", window, cx, |input| { + input.placeholder("Clearable") + }); + section( + "Input / TextField / ClearableInput", + "One line. Undo, word jumps, IME and the clipboard come built in.", + cx, + ) + .child(probe( + "input-name", + div().w(px(320.0)).child(Input::new(&name)), + )) + .child( + specimens() + .child(specimen( + "small", + div() + .w(px(200.0)) + .child(Input::new(&small).size(ControlSize::Sm)), + cx, + )) + .child(specimen( + "large", + div() + .w(px(240.0)) + .child(Input::new(&large).size(ControlSize::Lg)), + cx, + )) + .child(specimen( + "clearable", + div().w(px(240.0)).child(Input::new(&clear).clearable()), + cx, + )), + ) +} + +pub fn text_area(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let notes = field("textarea-notes", window, cx, |input| { + input + .multi_line(3, 6) + .placeholder("Notes grow from three lines to six, then scroll.") + }); + section( + "TextArea / AutoResizeInput", + "Several lines. It grows to its content, then scrolls.", + cx, + ) + .child(probe( + "textarea", + div().w(px(420.0)).child(Input::new(¬es)), + )) + .child(code("TextInput::new(window, cx).multi_line(3, 6)", cx)) +} + +pub fn password(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let secret = field("input-password", window, cx, |input| { + input.masked().placeholder("Password") + }); + section( + "PasswordInput", + "Bullets by default; the eye shows the text. Masked text never reaches the clipboard.", + cx, + ) + .child(probe( + "password", + div().w(px(320.0)).child(PasswordInput::new(&secret)), + )) +} + +pub fn search(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let query = field("input-search", window, cx, |input| { + input.placeholder("Search docs") + }); + let picked = window.use_keyed_state("search-picked", cx, |_, _| { + SharedString::from("nothing yet") + }); + let note = picked.read(cx).clone(); + section( + "SearchInput", + "A magnifier, a clear button, and recent searches while it is empty.", + cx, + ) + .child(probe( + "search", + div().w(px(320.0)).child( + SearchInput::new("docs-search", &query) + .history(["dock layout", "sticky header", "spring easing"]) + .on_pick(move |query, _, cx| { + let query = query.clone(); + picked.update(cx, |picked, cx| { + *picked = query; + cx.notify(); + }) + }), + ), + )) + .child(Caption::new(format!("Last recent search: {note}"))) +} + +pub fn group(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let site = field("input-site", window, cx, |input| { + input.placeholder("ely.dev") + }); + let share = field("input-share", window, cx, |input| input.placeholder("0")); + section( + "InputGroup / InputAddon", + "Addons join the field's frame: a scheme before, a unit or a control after.", + cx, + ) + .child( + specimens() + .child(specimen( + "text addons", + div().w(px(320.0)).child( + InputGroup::new(&site) + .before(InputAddon::text("https://")) + .after(InputAddon::text(".com")), + ), + cx, + )) + .child(specimen( + "control addon", + div().w(px(200.0)).child( + InputGroup::new(&share).after(InputAddon::new( + IconButton::new("group-copy", IconName::Copy) + .size(ControlSize::Sm) + .tooltip("Copy"), + )), + ), + cx, + )), + ) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index 3aaff5b..ef7f06e 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -1,4 +1,5 @@ mod buttons; +mod forms; mod layout; mod primitives; mod shell; @@ -25,6 +26,7 @@ pub const ALL: &[Page] = &[ layout::PAGE, shell::PAGE, buttons::PAGE, + forms::PAGE, theme::PAGE, ]; diff --git a/examples/gallery/script.rs b/examples/gallery/script.rs index 7152aa3..e9667ba 100644 --- a/examples/gallery/script.rs +++ b/examples/gallery/script.rs @@ -24,6 +24,8 @@ pub enum Step { DragTo(&'static str, f32, f32), UpAt(&'static str, f32, f32), Key(&'static str), + /// Types text into whatever holds focus, one key at a time. + Type(&'static str), Wait(u64), Shot(&'static str), /// Photographs a window a demo opened, by its key. @@ -39,6 +41,8 @@ pub enum Step { ShotPopup(&'static str), /// Posts a key to AppKit itself; only `escape` is known. NativeKey(&'static str), + /// Cancels the open file panel, whose content runs out of process. + CancelPanel, } const FRAME: Duration = Duration::from_millis(120); @@ -125,9 +129,24 @@ pub async fn play( Step::Key(stroke) => { let stroke = Keystroke::parse(stroke).with_context(|| format!("bad keystroke {stroke}"))?; - window.update(cx, |_, window, cx| { - window.dispatch_keystroke(stroke, cx); - })?; + press(window.into(), stroke, cx)?; + } + Step::Type(text) => { + for ch in text.chars() { + let key = match ch { + ' ' => "space".to_string(), + ch if ch.is_ascii_uppercase() => { + format!("shift-{}", ch.to_ascii_lowercase()) + } + ch => ch.to_string(), + }; + let stroke = + Keystroke::parse(&key).with_context(|| format!("bad key {key}"))?; + press(window.into(), stroke, cx)?; + cx.background_executor() + .timer(Duration::from_millis(16)) + .await; + } } Step::Wait(ms) => { cx.background_executor() @@ -148,9 +167,7 @@ pub async fn play( Step::KeyWindow(key, stroke) => { let stroke = Keystroke::parse(stroke).with_context(|| format!("bad keystroke {stroke}"))?; - opened(key, cx)?.update(cx, |_, window, cx| { - window.dispatch_keystroke(stroke, cx); - })?; + press(opened(key, cx)?, stroke, cx)?; } Step::CloseNative(key) => { let number = opened(key, cx)?.update(cx, |_, window, _| number(window))??; @@ -166,6 +183,7 @@ pub async fn play( let number = window.update(cx, |_, window, _| number(window))??; post_escape(number)?; } + Step::CancelPanel => cancel_panel()?, Step::ExpectClosed(key) => { let handle = opened(key, cx)?; let open = cx.update(|cx| cx.windows().contains(&handle))?; @@ -184,6 +202,13 @@ pub async fn play( Ok(()) } +/// Types a keystroke without holding the root view, so the window can redraw. +fn press(window: gpui::AnyWindowHandle, stroke: Keystroke, cx: &mut AsyncApp) -> Result<()> { + window.update(cx, |_, window, cx| { + window.dispatch_keystroke(stroke, cx); + }) +} + fn send( window: WindowHandle, kind: Mouse, @@ -263,6 +288,28 @@ fn post_escape(_: u32) -> Result<()> { anyhow::bail!("native keys need macOS") } +#[cfg(target_os = "macos")] +fn cancel_panel() -> Result<()> { + use cocoa::{ + appkit::NSApp, + base::{BOOL, NO, id, nil}, + }; + use objc::{class, msg_send, sel, sel_impl}; + unsafe { + let key: id = msg_send![NSApp(), keyWindow]; + anyhow::ensure!(key != nil, "no key window to cancel"); + let panel: BOOL = msg_send![key, isKindOfClass: class!(NSSavePanel)]; + anyhow::ensure!(panel != NO, "the key window is not a file panel"); + let _: () = msg_send![key, cancel: nil]; + } + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn cancel_panel() -> Result<()> { + anyhow::bail!("file panels need macOS") +} + /// Runs `performClose:` on a window, which asks before closing. #[cfg(target_os = "macos")] fn perform_close(number: u32) -> Result<()> { diff --git a/scripts/check.sh b/scripts/check.sh index fcd220c..0bdbeb1 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -2,8 +2,8 @@ # Pre-review gate: build rules plus house rules. set -eu cargo fmt --check -cargo clippy --all-targets -- -D warnings -cargo test --lib --quiet +cargo clippy --all-targets --features test-support -- -D warnings +cargo test --lib --features test-support --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) diff --git a/src/buttons/quick.rs b/src/buttons/quick.rs index 43ad121..49f2361 100644 --- a/src/buttons/quick.rs +++ b/src/buttons/quick.rs @@ -98,7 +98,7 @@ impl RenderOnce for QuickActions { assert!(count > 0, "quick actions {:?} hold no action", self.id); let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| Pick { selected: 0, - focus: cx.focus_handle(), + focus: cx.focus_handle().tab_stop(true), }); let (selected, focus) = { let pick = state.read(cx); @@ -188,7 +188,6 @@ impl RenderOnce for QuickActions { .border_1() .border_color(colors.border) .bg(colors.surface) - .tab_index(0) .focus_ring(cx) .on_key_down(move |event, window, cx| { let current = keys.read(cx).selected.min(count - 1); diff --git a/src/forms/checked.rs b/src/forms/checked.rs new file mode 100644 index 0000000..9679cd5 --- /dev/null +++ b/src/forms/checked.rs @@ -0,0 +1,148 @@ +use std::sync::LazyLock; + +use gpui::{ + App, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, + prelude::*, +}; +use regex::Regex; + +use super::{Input, TextInput}; +use crate::{ + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, TextSize}, +}; + +static EMAIL: LazyLock = + LazyLock::new(|| Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").expect("email pattern compiles")); +static URL: LazyLock = LazyLock::new(|| { + Regex::new(r"^https?://[^\s/?#]+\.[^\s/?#]+\S*$").expect("url pattern compiles") +}); + +pub(crate) fn is_email(text: &str) -> bool { + EMAIL.is_match(text) +} + +pub(crate) fn is_url(text: &str) -> bool { + URL.is_match(text) +} + +/// A field checked once focus leaves it; a note explains a miss. +fn checked( + state: &Entity, + icon: IconName, + valid: fn(&str) -> bool, + note: &'static str, + size: ControlSize, + window: &Window, + cx: &App, +) -> impl IntoElement + use<> { + let input = state.read(cx); + let miss = !input.focus().is_focused(window) && !input.is_empty() && !valid(input.text()); + let theme = cx.theme(); + let (subtle, danger) = (theme.colors.fg_subtle, theme.colors.danger); + div() + .flex() + .flex_col() + .gap_1() + .w_full() + .child( + Input::new(state) + .size(size) + .invalid(miss) + .prefix(Icon::new(icon).size(IconSize::Sm).color(subtle)), + ) + .when(miss, |field| { + field.child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(danger) + .child(SharedString::from(note)), + ) + }) +} + +/// An email field, checked when focus leaves it. +#[derive(IntoElement)] +pub struct EmailInput { + state: Entity, + size: ControlSize, +} + +impl EmailInput { + 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 EmailInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + checked( + &self.state, + IconName::AtSign, + is_email, + "Enter an address like name@example.com", + self.size, + window, + cx, + ) + } +} + +/// A link field, checked when focus leaves it. +#[derive(IntoElement)] +pub struct UrlInput { + state: Entity, + size: ControlSize, +} + +impl UrlInput { + 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 UrlInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + checked( + &self.state, + IconName::Link, + is_url, + "Enter a link that starts with https://", + self.size, + window, + cx, + ) + } +} + +#[cfg(test)] +mod tests { + use super::{is_email, is_url}; + + #[test] + fn emails_and_urls() { + assert!(is_email("ada@example.com")); + assert!(!is_email("ada@example")); + assert!(!is_email("ada example.com")); + assert!(is_url("https://ely.dev/docs?x=1")); + assert!(is_url("http://localhost.test")); + assert!(!is_url("ely.dev")); + assert!(!is_url("https://nodot")); + } +} diff --git a/src/forms/expression.rs b/src/forms/expression.rs new file mode 100644 index 0000000..c6f3cba --- /dev/null +++ b/src/forms/expression.rs @@ -0,0 +1,359 @@ +use std::ops::Range; + +use gpui::{ + App, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, + prelude::*, +}; + +use super::{Highlight, Input, TextInput}; +use crate::{ + theme::{ActiveTheme, ControlSize, TextSize}, + typography::format::{self, Separators}, +}; + +#[derive(Clone, Debug, PartialEq)] +enum Token { + Number(f64), + Name(String), + Op(char), + Open, + Close, + Comma, +} + +fn tokens(source: &str) -> Result, Token)>, String> { + let mut out = Vec::new(); + let mut chars = source.char_indices().peekable(); + while let Some(&(ix, ch)) = chars.peek() { + if ch.is_whitespace() { + chars.next(); + continue; + } + if ch.is_ascii_digit() || ch == '.' { + let mut end = ix; + while let Some(&(at, ch)) = chars.peek() { + if !(ch.is_ascii_digit() || ch == '.') { + break; + } + end = at + 1; + chars.next(); + } + let number = source[ix..end] + .parse::() + .ok() + .filter(|number| number.is_finite()) + .ok_or_else(|| format!("{} is not a number", &source[ix..end]))?; + out.push((ix..end, Token::Number(number))); + continue; + } + if ch.is_alphabetic() || ch == '_' { + let mut end = ix; + while let Some(&(at, ch)) = chars.peek() { + if !(ch.is_alphanumeric() || ch == '_') { + break; + } + end = at + ch.len_utf8(); + chars.next(); + } + out.push((ix..end, Token::Name(source[ix..end].to_string()))); + continue; + } + chars.next(); + let token = match ch { + '+' | '-' | '*' | '/' | '%' | '^' => Token::Op(ch), + '(' => Token::Open, + ')' => Token::Close, + ',' => Token::Comma, + other => return Err(format!("unexpected {other}")), + }; + out.push((ix..ix + ch.len_utf8(), token)); + } + Ok(out) +} + +/// Passes `value` on, or names the step that left the finite numbers. +fn finite(value: f64, step: &str) -> Result { + if value.is_finite() { + Ok(value) + } else { + Err(format!("{step} has no finite value")) + } +} + +struct Parser<'a> { + tokens: Vec, + at: usize, + variables: &'a [(SharedString, f64)], +} + +impl Parser<'_> { + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.at) + } + + fn next(&mut self) -> Option { + let token = self.tokens.get(self.at).cloned(); + self.at += 1; + token + } + + fn sum(&mut self) -> Result { + let mut value = self.product()?; + while let Some(Token::Op(op @ ('+' | '-'))) = self.peek().cloned() { + self.next(); + let rhs = self.product()?; + value = finite(if op == '+' { value + rhs } else { value - rhs }, "+")?; + } + Ok(value) + } + + fn product(&mut self) -> Result { + let mut value = self.power()?; + while let Some(Token::Op(op @ ('*' | '/' | '%'))) = self.peek().cloned() { + self.next(); + let rhs = self.power()?; + if op != '*' && rhs == 0.0 { + return Err("division by zero".into()); + } + value = match op { + '*' => finite(value * rhs, "*")?, + '/' => finite(value / rhs, "/")?, + _ => value % rhs, + }; + } + Ok(value) + } + + fn power(&mut self) -> Result { + let base = self.unary()?; + if self.peek() == Some(&Token::Op('^')) { + self.next(); + return finite(base.powf(self.power()?), "^"); + } + Ok(base) + } + + fn unary(&mut self) -> Result { + if self.peek() == Some(&Token::Op('-')) { + self.next(); + return Ok(-self.unary()?); + } + self.primary() + } + + fn primary(&mut self) -> Result { + match self.next() { + Some(Token::Number(number)) => Ok(number), + Some(Token::Open) => { + let value = self.sum()?; + match self.next() { + Some(Token::Close) => Ok(value), + _ => Err("a ( is never closed".into()), + } + } + Some(Token::Name(name)) if self.peek() == Some(&Token::Open) => { + self.next(); + let mut args = Vec::new(); + if self.peek() != Some(&Token::Close) { + loop { + args.push(self.sum()?); + match self.next() { + Some(Token::Comma) => continue, + Some(Token::Close) => break, + _ => return Err(format!("{name}( is never closed")), + } + } + } else { + self.next(); + } + finite(call(&name, &args)?, &format!("{name}(…)")) + } + Some(Token::Name(name)) => { + let (_, value) = self + .variables + .iter() + .find(|(known, _)| known.as_ref() == name) + .ok_or_else(|| format!("{name} is not defined"))?; + finite(*value, &name) + } + Some(other) => Err(format!("unexpected {other:?}")), + None => Err("the formula ends early".into()), + } + } +} + +fn call(name: &str, args: &[f64]) -> Result { + let one = |f: fn(f64) -> f64| match args { + [x] => Ok(f(*x)), + _ => Err(format!("{name} takes one value")), + }; + match name { + "abs" => one(f64::abs), + "sqrt" => one(f64::sqrt), + "round" => one(f64::round), + "floor" => one(f64::floor), + "ceil" => one(f64::ceil), + "min" | "max" if args.is_empty() => Err(format!("{name} needs values")), + "min" => Ok(args.iter().copied().fold(f64::INFINITY, f64::min)), + "max" => Ok(args.iter().copied().fold(f64::NEG_INFINITY, f64::max)), + _ => Err(format!("{name} is not a function")), + } +} + +/// Evaluates a formula with the given variables. +pub fn evaluate(source: &str, variables: &[(SharedString, f64)]) -> Result { + let tokens = tokens(source)? + .into_iter() + .map(|(_, token)| token) + .collect(); + let mut parser = Parser { + tokens, + at: 0, + variables, + }; + let value = parser.sum()?; + match parser.peek() { + None => Ok(value), + Some(extra) => Err(format!("unexpected {extra:?}")), + } +} + +/// Colors for a formula: numbers, names, operators. +pub fn expression_highlights(source: &str, cx: &App) -> Vec<(Range, Highlight)> { + let syntax = &cx.theme().colors.syntax; + let Ok(found) = tokens(source) else { + return Vec::new(); + }; + found + .iter() + .enumerate() + .map(|(ix, (range, token))| { + let color = match token { + Token::Number(_) => syntax.number, + Token::Name(_) if matches!(found.get(ix + 1), Some((_, Token::Open))) => { + syntax.function + } + Token::Name(_) => syntax.property, + Token::Op(_) => syntax.operator, + Token::Open | Token::Close | Token::Comma => syntax.punctuation, + }; + ( + range.clone(), + Highlight { + color, + background: None, + }, + ) + }) + .collect() +} + +/// A formula field: colored as you type, with its value or its problem below. +#[derive(IntoElement)] +pub struct ExpressionInput { + state: Entity, + variables: Vec<(SharedString, f64)>, +} + +impl ExpressionInput { + /// Give the state `expression_highlights` as its highlighter. + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + variables: Vec::new(), + } + } + + pub fn variable(mut self, name: impl Into, value: f64) -> Self { + self.variables.push((name.into(), value)); + self + } +} + +impl RenderOnce for ExpressionInput { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let source = self.state.read(cx).text().to_string(); + let result = (!source.trim().is_empty()).then(|| evaluate(&source, &self.variables)); + let theme = cx.theme(); + let colors = &theme.colors; + let mono = theme.mono_family.clone(); + let invalid = matches!(result, Some(Err(_))); + div() + .flex() + .flex_col() + .gap_1() + .w_full() + .child( + div().font_family(mono).child( + Input::new(&self.state) + .size(ControlSize::Md) + .invalid(invalid) + .prefix(div().text_color(colors.fg_subtle).child("=")), + ), + ) + .when_some(result, |field, result| { + let (text, color) = match result { + Ok(value) => ( + format!( + "= {}", + format::number(value, 4, Separators::EN) + .trim_end_matches('0') + .trim_end_matches('.') + ), + colors.fg_muted, + ), + Err(problem) => (problem, colors.danger), + }; + field.child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(color) + .child(SharedString::from(text)), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use gpui::SharedString; + + use super::evaluate; + + #[test] + fn precedence_functions_and_variables() { + let vars = [ + (SharedString::from("price"), 12.5), + (SharedString::from("qty"), 3.0), + ]; + assert_eq!(evaluate("1 + 2 * 3", &vars), Ok(7.0)); + assert_eq!(evaluate("2 ^ 3 ^ 2", &vars), Ok(512.0)); + assert_eq!(evaluate("-(price * qty) + max(1, 4, 2)", &vars), Ok(-33.5)); + assert_eq!(evaluate("round(sqrt(16.4))", &vars), Ok(4.0)); + } + + #[test] + fn problems_are_named() { + let vars = []; + assert_eq!(evaluate("1 / 0", &vars), Err("division by zero".into())); + assert_eq!(evaluate("tax * 2", &vars), Err("tax is not defined".into())); + assert_eq!(evaluate("(1 + 2", &vars), Err("a ( is never closed".into())); + assert_eq!( + evaluate("abs(1, 2)", &vars), + Err("abs takes one value".into()) + ); + assert!(evaluate("1 $ 2", &vars).is_err()); + } + + #[test] + fn a_step_without_a_finite_value_fails_at_once() { + assert_eq!( + evaluate("min(sqrt(-1), 2)", &[]), + Err("sqrt(…) has no finite value".into()) + ); + assert_eq!( + evaluate("max(10 ^ 400, 1)", &[]), + Err("^ has no finite value".into()) + ); + } +} diff --git a/src/forms/group.rs b/src/forms/group.rs new file mode 100644 index 0000000..a1c21f6 --- /dev/null +++ b/src/forms/group.rs @@ -0,0 +1,118 @@ +use gpui::{ + AnyElement, App, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, SharedString, Styled, Window, div, prelude::*, +}; + +use super::{TextInput, input::text_size}; +use crate::theme::{ActiveTheme, ControlSize, Radius}; + +/// A block joined to a field's side: text such as `https://`, or a control. +#[derive(IntoElement)] +pub struct InputAddon { + body: AnyElement, +} + +impl InputAddon { + pub fn text(text: impl Into) -> Self { + Self { + body: text.into().into_any_element(), + } + } + + pub fn new(body: impl IntoElement) -> Self { + Self { + body: body.into_any_element(), + } + } +} + +impl RenderOnce for InputAddon { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.body + } +} + +/// A field with addons joined before and after it, in one frame. +#[derive(IntoElement)] +pub struct InputGroup { + state: Entity, + before: Vec, + after: Vec, + size: ControlSize, +} + +impl InputGroup { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + before: Vec::new(), + after: Vec::new(), + size: ControlSize::default(), + } + } + + pub fn before(mut self, addon: InputAddon) -> Self { + self.before.push(addon); + self + } + + pub fn after(mut self, addon: InputAddon) -> Self { + self.after.push(addon); + self + } + + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } +} + +impl RenderOnce for InputGroup { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let input = self.state.read(cx); + let focus = input.focus().clone(); + let focused = focus.is_focused(window); + let theme = cx.theme(); + let colors = &theme.colors; + let pad = theme.control_padding(self.size); + let addon = |addon: InputAddon, before: bool| { + div() + .flex() + .flex_none() + .items_center() + .h_full() + .px(pad) + .bg(colors.sunken) + .text_color(colors.fg_muted) + .map(|block| { + if before { + block.border_r_1() + } else { + block.border_l_1() + } + }) + .border_color(colors.border_strong) + .child(addon) + }; + div() + .flex() + .items_center() + .w_full() + .h(theme.control_height(self.size)) + .overflow_hidden() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(if focused { + colors.focus + } else { + colors.border_strong + }) + .bg(colors.surface) + .text_size(theme.text_size(text_size(self.size))) + .text_color(colors.fg) + .on_mouse_down(MouseButton::Left, move |_, window, _| window.focus(&focus)) + .children(self.before.into_iter().map(|block| addon(block, true))) + .child(div().flex_1().min_w_0().px(pad).child(self.state)) + .children(self.after.into_iter().map(|block| addon(block, false))) + } +} diff --git a/src/forms/hotkey.rs b/src/forms/hotkey.rs new file mode 100644 index 0000000..7cbe029 --- /dev/null +++ b/src/forms/hotkey.rs @@ -0,0 +1,111 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, Keystroke, ParentElement, RenderOnce, Styled, + Window, div, prelude::*, +}; + +use crate::{ + primitives::{FocusRing, Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::KbdCombo, +}; + +type OnChange = Rc, &mut Window, &mut App)>; + +/// Records one shortcut, then lets go. Backspace clears; Escape leaves. +#[derive(IntoElement)] +pub struct HotkeyInput { + id: ElementId, + value: Option, + on_change: Option, +} + +impl HotkeyInput { + pub fn new(id: impl Into, value: Option) -> Self { + Self { + id: id.into(), + value, + on_change: None, + } + } + + pub fn on_change( + mut self, + handler: impl Fn(Option, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for HotkeyInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focus = window + .use_keyed_state(self.id.clone(), cx, |_, cx| { + cx.focus_handle().tab_stop(true) + }) + .read(cx) + .clone(); + let recording = focus.is_focused(window); + let theme = cx.theme(); + let colors = &theme.colors; + let on_change = self.on_change; + div() + .id(self.id) + .track_focus(&focus) + .flex() + .items_center() + .justify_between() + .gap_2() + .w_full() + .h(theme.control_height(ControlSize::Md)) + .px(theme.control_padding(ControlSize::Md)) + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border_strong) + .bg(colors.surface) + .focus_ring(cx) + .cursor_pointer() + .text_size(theme.text_size(TextSize::Base)) + .on_key_down(move |event, window, cx| { + cx.stop_propagation(); + let stroke = &event.keystroke; + let bare = !stroke.modifiers.modified(); + let next = match stroke.key.as_str() { + "escape" if bare => { + window.blur(); + return; + } + "backspace" | "delete" if bare => None, + _ => Some(Keystroke { + key_char: None, + ..stroke.clone() + }), + }; + log::info!( + "hotkey input: {}", + next.as_ref() + .map_or("cleared".into(), |stroke| stroke.unparse()) + ); + window.blur(); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + }) + .map(|field| match (&self.value, recording) { + (_, true) => field.child( + div() + .text_color(colors.fg_subtle) + .child("Press a shortcut…"), + ), + (Some(stroke), false) => field.child(KbdCombo::new(&stroke.unparse())), + (None, false) => field.child(div().text_color(colors.fg_subtle).child("None")), + }) + .child( + Icon::new(IconName::Keyboard) + .size(IconSize::Sm) + .color(colors.fg_subtle), + ) + } +} diff --git a/src/forms/inline.rs b/src/forms/inline.rs new file mode 100644 index 0000000..763a316 --- /dev/null +++ b/src/forms/inline.rs @@ -0,0 +1,153 @@ +use std::rc::Rc; + +use gpui::{ + App, Context, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div, + prelude::*, +}; + +use super::{Input, InputEvent, TextInput}; +use crate::{ + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius}, +}; + +type OnCommit = Rc; + +#[derive(Default)] +struct Editing { + field: Option<(Entity, Subscription)>, + on_commit: Option, +} + +impl Editing { + /// Ends editing; keeps the text unless `cancel`. + fn finish(&mut self, cancel: bool, window: &mut Window, cx: &mut Context) { + let Some((field, _)) = self.field.take() else { + return; + }; + if cancel { + log::info!("inline edit: cancelled"); + } else { + let text = SharedString::from(field.read(cx).text().to_string()); + log::info!("inline edit: kept"); + if let Some(commit) = self.on_commit.clone() { + commit(&text, window, cx); + } + } + cx.notify(); + } +} + +/// Text that becomes a field when clicked. Enter or leaving keeps; Escape reverts. +#[derive(IntoElement)] +pub struct InlineEdit { + id: ElementId, + value: SharedString, + placeholder: SharedString, + on_commit: Option, +} + +impl InlineEdit { + pub fn new(id: impl Into, value: impl Into) -> Self { + Self { + id: id.into(), + value: value.into(), + placeholder: "Empty".into(), + on_commit: None, + } + } + + pub fn placeholder(mut self, text: impl Into) -> Self { + self.placeholder = text.into(); + self + } + + pub fn on_commit( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_commit = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for InlineEdit { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Editing::default()); + state.update(cx, |editing, _| editing.on_commit = self.on_commit.clone()); + if let Some(field) = state + .read(cx) + .field + .as_ref() + .map(|(field, _)| field.clone()) + { + let escape = state.clone(); + return div() + .id(self.id) + .w_full() + .on_key_down(move |event, window, cx| { + if event.keystroke.key == "escape" { + cx.stop_propagation(); + escape.update(cx, |editing, cx| editing.finish(true, window, cx)); + } + }) + .child(Input::new(&field).size(ControlSize::Sm)) + .into_any_element(); + } + let theme = cx.theme(); + let colors = &theme.colors; + let empty = self.value.is_empty(); + let (value, start) = (self.value.clone(), state); + let group = SharedString::from(format!("inline-edit-{:?}", self.id)); + div() + .id(self.id) + .group(group.clone()) + .flex() + .items_center() + .gap_2() + .h(theme.control_height(ControlSize::Sm)) + .px(theme.control_padding(ControlSize::Sm)) + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(gpui::transparent_black()) + .cursor_text() + .hover(|style| style.bg(colors.hover)) + .text_color(if empty { colors.fg_subtle } else { colors.fg }) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| { + log::info!("inline edit: editing"); + let value = value.to_string(); + let field = cx.new(|cx| { + let mut input = TextInput::new(window, cx); + input.set_text(value, cx); + input + }); + let all = field.read(cx).text().len(); + field.update(cx, |input, cx| input.select(0..all, cx)); + window.focus(&field.read(cx).focus().clone()); + let owner = start.clone(); + let events = window.subscribe(&field, cx, move |_, event, window, cx| { + if matches!(event, InputEvent::Submit | InputEvent::Blur) { + owner.update(cx, |editing, cx| editing.finish(false, window, cx)); + } + }); + start.update(cx, |editing, cx| { + editing.field = Some((field, events)); + cx.notify(); + }); + }) + .child(if empty { self.placeholder } else { self.value }) + .child( + div() + .invisible() + .group_hover(group, |style| style.visible()) + .child( + Icon::new(IconName::Pencil) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ), + ) + .into_any_element() + } +} diff --git a/src/forms/input.rs b/src/forms/input.rs new file mode 100644 index 0000000..23f86ec --- /dev/null +++ b/src/forms/input.rs @@ -0,0 +1,192 @@ +use gpui::{ + AnyElement, App, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, Styled, Window, div, prelude::*, +}; + +use super::TextInput; +use crate::{ + buttons::IconButton, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, +}; + +/// A text field's frame: border, focus, and room for things before and after. +#[derive(IntoElement)] +pub struct Input { + state: Entity, + size: ControlSize, + prefix: Option, + suffix: Option, + clearable: bool, + invalid: bool, +} + +impl Input { + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + size: ControlSize::default(), + prefix: None, + suffix: None, + clearable: false, + invalid: false, + } + } + + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } + + pub fn prefix(mut self, prefix: impl IntoElement) -> Self { + self.prefix = Some(prefix.into_any_element()); + self + } + + pub fn suffix(mut self, suffix: impl IntoElement) -> Self { + self.suffix = Some(suffix.into_any_element()); + self + } + + /// Adds a clear button while there is text. + pub fn clearable(mut self) -> Self { + self.clearable = true; + self + } + + /// Draws the frame in the danger tone. + pub fn invalid(mut self, invalid: bool) -> Self { + self.invalid = invalid; + self + } +} + +pub(crate) fn text_size(size: ControlSize) -> TextSize { + match size { + ControlSize::Sm => TextSize::Sm, + ControlSize::Md => TextSize::Base, + ControlSize::Lg => TextSize::Md, + } +} + +impl RenderOnce for Input { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let input = self.state.read(cx); + let focus = input.focus().clone(); + let (focused, empty, multi, disabled) = ( + focus.is_focused(window), + input.is_empty(), + input.is_multi_line(), + input.is_disabled(), + ); + let theme = cx.theme(); + let colors = &theme.colors; + let border = if self.invalid { + colors.danger + } else if focused { + colors.focus + } else { + colors.border_strong + }; + let (state, clear_id) = (self.state.clone(), self.state.entity_id()); + div() + .flex() + .gap_2() + .w_full() + .px(theme.control_padding(self.size)) + .map(|frame| { + if multi { + frame.items_start().py_1p5() + } else { + frame.items_center().h(theme.control_height(self.size)) + } + }) + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(border) + .bg(if disabled { + colors.sunken + } else { + colors.surface + }) + .text_size(theme.text_size(text_size(self.size))) + .text_color(if disabled { + colors.fg_disabled + } else { + colors.fg + }) + .on_mouse_down(MouseButton::Left, move |_, window, _| window.focus(&focus)) + .children(self.prefix) + .child(div().flex_1().min_w_0().child(self.state)) + .when(self.clearable && !empty && !disabled, |frame| { + frame.child( + IconButton::new(("input-clear", clear_id), IconName::CircleX) + .size(ControlSize::Sm) + .tooltip("Clear") + .on_click(move |_, window, cx| { + state.update(cx, |input, cx| input.set_text("", cx)); + window.focus(&state.read(cx).focus().clone()); + }), + ) + }) + .children(self.suffix) + } +} + +/// A masked field with a lock before it and an eye that shows the text. +#[derive(IntoElement)] +pub struct PasswordInput { + state: Entity, + size: ControlSize, +} + +impl PasswordInput { + /// Build the state with `TextInput::masked`. + 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 PasswordInput { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let masked = self.state.read(cx).is_masked(); + let (toggle, subtle) = (self.state.clone(), cx.theme().colors.fg_subtle); + Input::new(&self.state) + .size(self.size) + .prefix(Icon::new(IconName::Lock).size(IconSize::Sm).color(subtle)) + .suffix( + IconButton::new( + ("password-eye", self.state.entity_id()), + if masked { + IconName::Eye + } else { + IconName::EyeOff + }, + ) + .size(ControlSize::Sm) + .tooltip(if masked { + "Show password" + } else { + "Hide password" + }) + .on_click(move |_, _, cx| { + toggle.update(cx, |input, cx| { + let masked = input.is_masked(); + log::info!( + "password input: {}", + if masked { "shown" } else { "hidden" } + ); + input.set_masked(!masked, cx); + }) + }), + ) + } +} diff --git a/src/forms/masked.rs b/src/forms/masked.rs new file mode 100644 index 0000000..249a6f6 --- /dev/null +++ b/src/forms/masked.rs @@ -0,0 +1,360 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + AnyElement, App, Bounds, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, + ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, + canvas, div, prelude::*, +}; + +use super::{ + Input, TextInput, + suggest::{Suggestion, suggestion_list}, +}; +use crate::{ + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius}, +}; + +fn is_slot(slot: char) -> bool { + matches!(slot, '9' | 'a' | '*') +} + +/// Fits typed characters into `mask`: `9` a digit, `a` a letter, `*` either; the rest is literal. +pub(crate) fn apply_mask(mask: &str, raw: &str) -> String { + let mut raw = raw.chars().filter(char::is_ascii_alphanumeric).peekable(); + let mut out = String::new(); + for slot in mask.chars() { + let fits = |ch: &char| match slot { + '9' => ch.is_ascii_digit(), + 'a' => ch.is_ascii_alphabetic(), + _ => ch.is_ascii_alphanumeric(), + }; + if raw.peek().is_none() { + break; + } + if !is_slot(slot) { + out.push(slot); + continue; + } + while raw.peek().is_some_and(|ch| !fits(ch)) { + raw.next(); + } + match raw.next() { + Some(ch) => out.push(ch), + None => break, + } + } + out +} + +/// What `fitted` holds in the slots of `mask`, from char `from` to char `to`. +fn slot_chars(mask: &str, fitted: &str, from: usize, to: usize) -> String { + mask.chars() + .zip(fitted.chars()) + .enumerate() + .filter(|(ix, (slot, _))| (from..to).contains(ix) && is_slot(*slot)) + .map(|(_, (_, ch))| ch) + .collect() +} + +/// Fits an edit into `mask`: `range` of `text` becomes `typed`. Gives the text and caret. +pub(crate) fn refit(mask: &str, text: &str, range: Range, typed: &str) -> (String, usize) { + let at = |byte: usize| text[..byte].chars().count(); + let head = slot_chars(mask, text, 0, at(range.start)) + typed; + let fitted = apply_mask( + mask, + &(head.clone() + &slot_chars(mask, text, at(range.end), usize::MAX)), + ); + let caret = if typed.is_empty() { + range.start + } else { + apply_mask(mask, &head).len() + }; + let caret = caret.min(fitted.len()); + (fitted, caret) +} + +/// The mask shown while empty: slots become underscores. +pub(crate) fn mask_hint(mask: &str) -> String { + mask.chars() + .map(|slot| { + if matches!(slot, '9' | 'a' | '*') { + '_' + } else { + slot + } + }) + .collect() +} + +fn masking( + id: &ElementId, + state: &Entity, + mask: &SharedString, + window: &mut Window, + cx: &mut App, +) { + let applied = window.use_keyed_state(id.clone(), cx, |_, _| None::); + let old = applied.read(cx).clone(); + if old.as_ref() != Some(mask) { + applied.update(cx, |applied, _| *applied = Some(mask.clone())); + let fit_mask = mask.clone(); + state.update(cx, |input, cx| { + let raw = match &old { + Some(old) => slot_chars(old, input.text(), 0, usize::MAX), + None => input.text().to_string(), + }; + input.set_fit(Rc::new(move |text, range, typed| { + refit(&fit_mask, text, range, typed) + })); + input.set_text(raw, cx); + }); + log::info!("masked input: mask {mask}"); + } + let hint = mask_hint(mask); + if state.read(cx).placeholder_text().as_ref() != hint { + state.update(cx, |input, cx| input.set_placeholder(hint, cx)); + } +} + +/// A field that fits typing into a pattern such as `(999) 999-9999`. +#[derive(IntoElement)] +pub struct MaskedInput { + id: ElementId, + state: Entity, + mask: SharedString, + size: ControlSize, + prefix: Option, +} + +impl MaskedInput { + pub fn new( + id: impl Into, + state: &Entity, + mask: impl Into, + ) -> Self { + Self { + id: id.into(), + state: state.clone(), + mask: mask.into(), + size: ControlSize::default(), + prefix: None, + } + } + + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } + + pub fn prefix(mut self, prefix: impl IntoElement) -> Self { + self.prefix = Some(prefix.into_any_element()); + self + } +} + +impl RenderOnce for MaskedInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + masking(&self.id, &self.state, &self.mask, window, cx); + let field = Input::new(&self.state).size(self.size); + match self.prefix { + Some(prefix) => field.prefix(prefix), + None => field, + } + } +} + +/// Dialing code, flag and number pattern of each country offered. +pub const COUNTRIES: [(&str, &str, &str, &str); 6] = [ + ("US", "+1", "🇺🇸", "(999) 999-9999"), + ("GB", "+44", "🇬🇧", "9999 999999"), + ("DE", "+49", "🇩🇪", "9999 9999999"), + ("FR", "+33", "🇫🇷", "9 99 99 99 99"), + ("JP", "+81", "🇯🇵", "99-9999-9999"), + ("CN", "+86", "🇨🇳", "999 9999 9999"), +]; + +type OnCountry = Rc; + +/// A phone number: a country picker for the code, then its number pattern. +#[derive(IntoElement)] +pub struct PhoneInput { + id: ElementId, + state: Entity, + country: &'static str, + on_country: Option, +} + +impl PhoneInput { + /// `country` is a code from `COUNTRIES`. + pub fn new(id: impl Into, state: &Entity, country: &'static str) -> Self { + assert!( + COUNTRIES.iter().any(|(code, ..)| *code == country), + "phone input has no country {country}" + ); + Self { + id: id.into(), + state: state.clone(), + country, + on_country: None, + } + } + + pub fn on_country( + mut self, + handler: impl Fn(&'static str, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_country = Some(Rc::new(handler)); + self + } +} + +struct Picker { + open: bool, + anchor: Bounds, +} + +impl RenderOnce for PhoneInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let picker = window.use_keyed_state((self.id.clone(), "picker"), cx, |_, _| Picker { + open: false, + anchor: Bounds::default(), + }); + let (open, anchor) = (picker.read(cx).open, picker.read(cx).anchor); + let &(_, dial, flag, mask) = COUNTRIES + .iter() + .find(|(code, ..)| *code == self.country) + .expect("checked at construction"); + let theme = cx.theme(); + let (hover, muted) = (theme.colors.hover, theme.colors.fg_muted); + let rows: Vec = COUNTRIES + .iter() + .map(|(code, dial, flag, _)| Suggestion { + label: SharedString::from(format!("{flag} {code}")), + note: Some(SharedString::from(*dial)), + }) + .collect(); + let (toggle, close, measure) = (picker.clone(), picker.clone(), picker.clone()); + let on_country = self.on_country; + let pick = Rc::new(move |ix: usize, window: &mut Window, cx: &mut App| { + let code = COUNTRIES[ix].0; + log::info!("phone input: country {code}"); + close.update(cx, |picker, cx| { + picker.open = false; + cx.notify(); + }); + if let Some(on_country) = &on_country { + on_country(code, window, cx); + } + }); + let dismiss = picker.clone(); + let code = div() + .id((self.id.clone(), "code")) + .flex() + .items_center() + .gap_1() + .px_1() + .rounded(theme.radius(Radius::Sm)) + .text_color(muted) + .cursor_pointer() + .hover(|style| style.bg(hover)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, _, cx| { + toggle.update(cx, |picker, cx| { + picker.open = !picker.open; + cx.notify(); + }) + }) + .child(format!("{flag} {dial}")) + .child( + Icon::new(IconName::ChevronDown) + .size(IconSize::Xs) + .color(muted), + ); + div() + .relative() + .w_full() + .child(MaskedInput::new(self.id.clone(), &self.state, mask).prefix(code)) + .child( + canvas( + move |bounds, _, cx| { + if measure.read(cx).anchor != bounds { + measure.update(cx, |picker, _| picker.anchor = bounds); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .when(open, |field| { + let current = COUNTRIES + .iter() + .position(|(code, ..)| *code == self.country); + field.child(suggestion_list( + (self.id.clone(), "countries"), + anchor, + &rows, + current.unwrap_or(usize::MAX), + pick, + Some(Rc::new(move |_, cx| { + dismiss.update(cx, |picker, cx| { + picker.open = false; + cx.notify(); + }) + })), + cx, + )) + }) + } +} + +#[cfg(test)] +mod tests { + use super::{apply_mask, mask_hint, refit}; + + fn typed(mask: &str, keys: &str) -> String { + keys.chars().fold(String::new(), |text, key| { + let end = text.len(); + refit(mask, &text, end..end, &key.to_string()).0 + }) + } + + #[test] + fn mask_fits_slots_skips_misfits_and_stops_with_the_input() { + assert_eq!(apply_mask("(999) 999-9999", "5551234567"), "(555) 123-4567"); + assert_eq!(apply_mask("(999) 999-9999", "555"), "(555"); + assert_eq!(apply_mask("(999) 999-9999", "(55x5) 1"), "(555) 1"); + assert_eq!(apply_mask("aa-9999", "ab1234"), "ab-1234"); + assert_eq!(apply_mask("aa-9999", "12ab"), "ab"); + assert_eq!(mask_hint("(999) 999-9999"), "(___) ___-____"); + } + + #[test] + fn typing_fills_slots_and_literals_never_eat_input() { + assert_eq!(typed("+1 999", "234"), "+1 234"); + assert_eq!(typed("1-999", "123"), "1-123"); + assert_eq!(typed("(999) 999-9999", "5551234567"), "(555) 123-4567"); + } + + #[test] + fn an_edit_replaces_its_range_and_places_the_caret() { + let fit = |mask, text, range, typed| refit(mask, text, range, typed); + assert_eq!(fit("1-999", "1-123", 0..5, "1"), ("1-1".into(), 3)); + assert_eq!(fit("+1 999", "+1 23", 2..3, ""), ("+1 23".into(), 2)); + assert_eq!( + fit("(999) 999-9999", "(555) 123-4567", 8..8, "9"), + ("(555) 129-3456".into(), 9) + ); + assert_eq!( + fit("(999) 999-9999", "(555) 1", 6..7, ""), + ("(555".into(), 4) + ); + assert_eq!( + fit("(999) 999-9999", "", 0..0, "(555) 123-4567"), + ("(555) 123-4567".into(), 14) + ); + } +} diff --git a/src/forms/mention.rs b/src/forms/mention.rs new file mode 100644 index 0000000..d2804c4 --- /dev/null +++ b/src/forms/mention.rs @@ -0,0 +1,279 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + App, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Window, div, prelude::*, +}; + +use super::{ + Highlight, Input, TextInput, + suggest::{Suggestion, suggestion_list}, + text::{Down, Enter, Up}, +}; +use crate::theme::ActiveTheme; + +const SHOWN: usize = 6; + +/// The trigger being typed: its byte offset and char, ending at `caret`. +pub(crate) fn active_trigger(text: &str, caret: usize, triggers: &[char]) -> Option<(usize, char)> { + let before = &text[..caret]; + let (ix, ch) = before + .char_indices() + .rev() + .take_while(|(_, ch)| !ch.is_whitespace()) + .find(|(_, ch)| triggers.contains(ch))?; + let opens = before[..ix] + .chars() + .next_back() + .is_none_or(char::is_whitespace); + let word = before[ix + ch.len_utf8()..] + .chars() + .all(|ch| ch.is_alphanumeric() || ch == '_'); + (opens && word).then_some((ix, ch)) +} + +/// The dismissed trigger, while it is still the one being typed. +fn dismissal(dismissed: Option, active: Option) -> Option { + dismissed.filter(|at| active == Some(*at)) +} + +/// Spans of `@name` and `#tag` for a highlighter. +pub(crate) fn mention_spans(text: &str) -> Vec> { + let mut spans = Vec::new(); + let mut previous_space = true; + let mut chars = text.char_indices().peekable(); + while let Some((ix, ch)) = chars.next() { + if previous_space && (ch == '@' || ch == '#') { + let mut end = ix + 1; + while let Some(&(at, next)) = chars.peek() { + if !(next.is_alphanumeric() || next == '_') { + break; + } + end = at + next.len_utf8(); + chars.next(); + } + if end > ix + 1 { + spans.push(ix..end); + } + previous_space = false; + continue; + } + previous_space = ch.is_whitespace(); + } + spans +} + +/// Colors mentions and tags in the link tone over a faint wash. +pub fn mention_highlights(text: &str, cx: &App) -> Vec<(Range, Highlight)> { + let link = cx.theme().colors.link; + mention_spans(text) + .into_iter() + .map(|range| { + ( + range, + Highlight { + color: link, + background: Some(link.opacity(0.08)), + }, + ) + }) + .collect() +} + +#[derive(Default)] +struct Picking { + highlighted: usize, + dismissed: Option, +} + +/// A field where `@` or `#` opens suggestions at the caret. Arrows choose; Enter picks. +#[derive(IntoElement)] +pub struct MentionInput { + id: ElementId, + state: Entity, + triggers: Vec<(char, Vec)>, +} + +impl MentionInput { + /// Give the state `mention_highlights` as its highlighter. + pub fn new(id: impl Into, state: &Entity) -> Self { + Self { + id: id.into(), + state: state.clone(), + triggers: Vec::new(), + } + } + + /// Offers `handles` after `trigger`, such as `@` for people or `#` for tags. + pub fn trigger( + mut self, + trigger: char, + handles: impl IntoIterator>, + ) -> Self { + let handles: Vec = handles.into_iter().map(Into::into).collect(); + for handle in &handles { + assert!( + !handle.is_empty() && handle.chars().all(|ch| ch.is_alphanumeric() || ch == '_'), + "mention handle {handle:?} must be one word" + ); + } + self.triggers.push((trigger, handles)); + self + } +} + +impl RenderOnce for MentionInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let picking = window.use_keyed_state(self.id.clone(), cx, |_, _| Picking::default()); + let input = self.state.read(cx); + let (text, caret) = (input.text().to_string(), input.cursor()); + let focused = input.focus().is_focused(window); + let marks: Vec = self.triggers.iter().map(|(trigger, _)| *trigger).collect(); + let active = active_trigger(&text, caret, &marks); + let anchor = active.and_then(|(ix, _)| input.bounds_for(ix)); + let matches: Vec = active + .map(|(ix, trigger)| { + let query = text[ix + trigger.len_utf8()..caret].to_lowercase(); + let names = &self + .triggers + .iter() + .find(|(mark, _)| *mark == trigger) + .expect("from triggers") + .1; + names + .iter() + .filter(|name| name.to_lowercase().contains(&query)) + .take(SHOWN) + .cloned() + .collect() + }) + .unwrap_or_default(); + let dismissed = dismissal(picking.read(cx).dismissed, active.map(|(ix, _)| ix)); + if dismissed != picking.read(cx).dismissed { + picking.update(cx, |picking, _| picking.dismissed = dismissed); + } + let open = + focused && !matches.is_empty() && active.is_some_and(|(ix, _)| dismissed != Some(ix)); + let highlighted = picking + .read(cx) + .highlighted + .min(matches.len().saturating_sub(1)); + log::debug!( + "mention input: open {open}, {} matches, caret {anchor:?}", + matches.len() + ); + let rows: Vec = matches + .iter() + .map(|name| Suggestion { + label: active.map_or(name.clone(), |(_, trigger)| { + SharedString::from(format!("{trigger}{name}")) + }), + note: None, + }) + .collect(); + let insert = { + let (state, matches) = (self.state.clone(), matches.clone()); + Rc::new(move |pick: usize, _: &mut Window, cx: &mut App| { + let Some((ix, trigger)) = active else { + return; + }; + let name = &matches[pick]; + log::info!("mention input: picked {trigger}{name}"); + state.update(cx, |input, cx| { + input.select(ix..caret, cx); + input.insert(&format!("{trigger}{name} "), cx); + }); + }) + }; + let count = matches.len(); + let (up, down, enter, escape) = ( + picking.clone(), + picking.clone(), + insert.clone(), + picking.clone(), + ); + div() + .id(self.id) + .relative() + .w_full() + .capture_action(move |_: &Up, _, cx| { + if open { + cx.stop_propagation(); + up.update(cx, |picking, cx| { + picking.highlighted = (picking.highlighted + count - 1) % count; + cx.notify(); + }); + } + }) + .capture_action(move |_: &Down, _, cx| { + if open { + cx.stop_propagation(); + down.update(cx, |picking, cx| { + picking.highlighted = (picking.highlighted + 1) % count; + cx.notify(); + }); + } + }) + .capture_action(move |_: &Enter, window, cx| { + if open { + cx.stop_propagation(); + enter(highlighted, window, cx); + } + }) + .on_key_down(move |event, _, cx| { + if open && event.keystroke.key == "escape" { + cx.stop_propagation(); + escape.update(cx, |picking, cx| { + picking.dismissed = active.map(|(ix, _)| ix); + cx.notify(); + }); + } + }) + .child(Input::new(&self.state)) + .when_some(anchor.filter(|_| open), |field, anchor| { + field.child(suggestion_list( + "mentions", + anchor, + &rows, + highlighted, + insert, + None, + cx, + )) + }) + } +} + +#[cfg(test)] +mod tests { + use super::{active_trigger, dismissal, mention_spans}; + + #[test] + fn triggers_open_after_space_and_close_on_space() { + let marks = ['@', '#']; + assert_eq!(active_trigger("hi @ad", 6, &marks), Some((3, '@'))); + assert_eq!(active_trigger("@", 1, &marks), Some((0, '@'))); + assert_eq!(active_trigger("mail@ad", 7, &marks), None); + assert_eq!(active_trigger("hi @ada lovelace", 16, &marks), None); + assert_eq!(active_trigger("go #rel", 7, &marks), Some((3, '#'))); + } + + #[test] + fn a_dismissal_ends_with_its_trigger() { + assert_eq!(dismissal(Some(0), Some(0)), Some(0)); + assert_eq!(dismissal(Some(0), None), None); + assert_eq!(dismissal(Some(0), Some(4)), None); + } + + #[test] + fn spans_cover_whole_names() { + assert_eq!( + mention_spans("ping @ada and #release now"), + vec![5..9, 14..22] + ); + assert_eq!( + mention_spans("a@b # c"), + Vec::>::new() + ); + } +} diff --git a/src/forms/mod.rs b/src/forms/mod.rs new file mode 100644 index 0000000..fbfe2d5 --- /dev/null +++ b/src/forms/mod.rs @@ -0,0 +1,39 @@ +mod checked; +mod expression; +mod group; +mod hotkey; +mod inline; +mod input; +mod masked; +mod mention; +mod number; +mod pairs; +mod path; +mod pattern; +mod pin; +mod search; +mod suggest; +mod tags; +#[cfg(all(test, feature = "test-support"))] +mod tests; +mod text; +mod unit; + +pub use checked::{EmailInput, UrlInput}; +pub use expression::{ExpressionInput, evaluate, expression_highlights}; +pub use group::{InputAddon, InputGroup}; +pub use hotkey::HotkeyInput; +pub use inline::InlineEdit; +pub use input::{Input, PasswordInput}; +pub use masked::{COUNTRIES, MaskedInput, PhoneInput}; +pub use mention::{MentionInput, mention_highlights}; +pub use number::{NumberInput, ScrubInput}; +pub use pairs::{KeyValueInput, ListInput}; +pub use path::PathInput; +pub use pattern::{RegexInput, regex_highlights}; +pub use pin::PinInput; +pub use search::SearchInput; +pub use tags::TagInput; +pub(crate) use text::bind_keys; +pub use text::{Highlight, InputEvent, TextInput}; +pub use unit::UnitInput; diff --git a/src/forms/number.rs b/src/forms/number.rs new file mode 100644 index 0000000..b3f23bf --- /dev/null +++ b/src/forms/number.rs @@ -0,0 +1,482 @@ +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + AnyElement, App, Context, CursorStyle, DragMoveEvent, ElementId, EmptyView, Entity, EntityId, + InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, SharedString, + StatefulInteractiveElement, Styled, Subscription, Window, div, prelude::*, +}; + +use super::{ + Input, InputEvent, TextInput, + text::{Down, Up}, +}; +use crate::{ + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, TextSize}, + typography::format::{self, Separators}, +}; + +type OnChange = Rc; +type Nudge = Box; + +/// Reads a typed number: groups and the typographic minus are allowed. +pub(crate) fn parse(text: &str) -> Option { + let plain: String = text + .chars() + .filter(|ch| *ch != ',') + .map(|ch| if ch == format::MINUS { '-' } else { ch }) + .collect(); + plain + .trim() + .parse::() + .ok() + .filter(|value| value.is_finite()) +} + +/// Rounds to `precision` places, then clamps to the rounded numbers in `min..=max`. +fn settle(value: f64, min: f64, max: f64, precision: usize) -> f64 { + let scale = 10f64.powi(precision as i32); + let (low, high) = ( + on_grid(min, scale, f64::ceil), + on_grid(max, scale, f64::floor), + ); + assert!( + low <= high, + "no {precision}-place number lies in {min}..={max}" + ); + ((value * scale).round() / scale).clamp(low, high) +} + +/// `bound` on the `1 / scale` grid: kept if float error alone put it off, else moved `inward`. +fn on_grid(bound: f64, scale: f64, inward: fn(f64) -> f64) -> f64 { + let scaled = bound * scale; + let near = scaled.round(); + if (scaled - near).abs() <= 4.0 * f64::EPSILON * near.abs().max(1.0) { + near / scale + } else { + inward(scaled) / scale + } +} + +/// A number's live settings, read by its input's event handler. +struct Numeric { + input: Entity, + on_change: Option, + value: f64, + limits: (f64, f64, f64, usize), + _events: Subscription, +} + +impl Numeric { + /// The typed number, or the owner's value while the text is not one. + fn current(&self, cx: &App) -> f64 { + parse(self.input.read(cx).text()).unwrap_or(self.value) + } +} + +/// Shows `value` in the field, then tells the owner. +fn show( + input: &Entity, + on_change: Option<&OnChange>, + value: f64, + precision: usize, + window: &mut Window, + cx: &mut App, +) { + let shown = format::number(value, precision, Separators::EN); + input.update(cx, |input, cx| input.set_text(shown, cx)); + log::info!("number input: {value}"); + if let Some(on_change) = on_change { + on_change(value, window, cx); + } +} + +/// Settles `next` into the limits and shows it. +fn commit(state: &Entity, next: f64, window: &mut Window, cx: &mut App) { + let (input, on_change, (min, max, _, precision)) = { + let numeric = state.read(cx); + ( + numeric.input.clone(), + numeric.on_change.clone(), + numeric.limits, + ) + }; + let value = settle(next, min, max, precision); + show(&input, on_change.as_ref(), value, precision, window, cx); +} + +fn numeric(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity { + window.use_keyed_state(id.clone(), cx, |window, cx: &mut Context| { + let input = cx.new(|cx| { + TextInput::new(window, cx) + .filter(|ch| ch.is_ascii_digit() || matches!(ch, '.' | ',' | '-' | format::MINUS)) + }); + let events = cx.subscribe_in(&input, window, |numeric, input, event, window, cx| { + if !matches!(event, InputEvent::Blur | InputEvent::Submit) { + return; + } + let (min, max, _, precision) = numeric.limits; + let value = settle(numeric.current(cx), min, max, precision); + show( + input, + numeric.on_change.as_ref(), + value, + precision, + window, + cx, + ); + }); + Numeric { + input, + on_change: None, + value: 0.0, + limits: (f64::MIN, f64::MAX, 1.0, 0), + _events: events, + } + }) +} + +struct Scrub { + owner: EntityId, + start: Rc>>, +} + +/// Wraps `face` so dragging it sideways moves the number, one step per notch. +fn scrub_handle( + id: ElementId, + face: AnyElement, + state: Entity, + window: &mut Window, + cx: &mut App, +) -> impl IntoElement + use<> { + let owner = window + .use_keyed_state(id.clone(), cx, |_, _| ()) + .entity_id(); + let notch = cx.theme().handle_hit().to_pixels(window.rem_size()); + div() + .id(id) + .cursor(CursorStyle::ResizeLeftRight) + .on_drag( + Scrub { + owner, + start: Rc::new(Cell::new(None)), + }, + |scrub, _, _, cx| { + scrub.start.set(None); + cx.new(|_| EmptyView) + }, + ) + .on_drag_move(move |event: &DragMoveEvent, window, cx| { + let scrub = event.drag(cx); + if scrub.owner != owner { + return; + } + let (start, x) = (scrub.start.clone(), event.event.position.x); + let (anchor, from) = start.get().unwrap_or_else(|| { + let fresh = (x, state.read(cx).current(cx)); + start.set(Some(fresh)); + fresh + }); + let notches = f64::from(((x - anchor) / notch).trunc()); + let step = state.read(cx).limits.2; + commit(&state, from + notches * step, window, cx); + }) + .child(face) +} + +/// A number field: arrows or the stepper move it, dragging the grip scrubs it. +#[derive(IntoElement)] +pub struct NumberInput { + pub(super) id: ElementId, + value: f64, + min: f64, + max: f64, + step: f64, + precision: usize, + prefix: Option, + suffix: Option, + size: ControlSize, + on_change: Option, + scrub_label: Option, +} + +impl NumberInput { + pub fn new(id: impl Into, value: f64) -> Self { + Self { + id: id.into(), + value, + min: f64::MIN, + max: f64::MAX, + step: 1.0, + precision: 0, + prefix: None, + suffix: None, + size: ControlSize::default(), + on_change: None, + scrub_label: None, + } + } + + pub fn range(mut self, min: f64, max: f64) -> Self { + assert!(min <= max, "number range {min}..{max} is empty"); + self.min = min; + self.max = max; + self + } + + pub fn step(mut self, step: f64) -> Self { + assert!(step > 0.0, "number step {step} must be positive"); + self.step = step; + self + } + + /// Decimal places kept and shown. + pub fn precision(mut self, places: usize) -> Self { + self.precision = places; + self + } + + /// A symbol before the digits, such as `$`. + pub fn prefix(mut self, symbol: impl Into) -> Self { + self.prefix = Some(symbol.into()); + self + } + + /// Money in an ISO 4217 currency: its symbol and minor units. + pub fn currency(mut self, code: &str) -> Self { + let (symbol, places, _) = format::currency_parts(code); + self.prefix = Some(SharedString::from(symbol.to_string())); + self.precision = places; + self + } + + /// A percentage, marked with `%`. + pub fn percent(self) -> Self { + self.suffix(div().child("%")) + } + + /// Something after the digits, such as `%` or a unit. + pub fn suffix(mut self, suffix: impl IntoElement) -> Self { + self.suffix = Some(suffix.into_any_element()); + self + } + + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } + + pub fn on_change(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for NumberInput { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = numeric(&self.id, window, cx); + let (value, min, max, step) = (self.value, self.min, self.max, self.step); + state.update(cx, |numeric, _| { + numeric.on_change = self.on_change.clone(); + numeric.value = value; + numeric.limits = (min, max, step, self.precision); + }); + let input = state.read(cx).input.clone(); + let shown = format::number(value, self.precision, Separators::EN); + let editing = input.read(cx).focus().is_focused(window); + if !editing && input.read(cx).text() != shown { + input.update(cx, |input, cx| input.set_text(shown, cx)); + } + let now = state.read(cx).current(cx); + let nudge = |by: f64| { + let state = state.clone(); + move |window: &mut Window, cx: &mut App| { + let from = state.read(cx).current(cx); + commit(&state, from + by, window, cx); + } + }; + let (up, down, key_up, key_down) = (nudge(step), nudge(-step), nudge(step), nudge(-step)); + let label = self.scrub_label.take().map(|label| { + let theme = cx.theme(); + let face = div() + .flex_none() + .min_w(theme.control_height(ControlSize::Md)) + .whitespace_nowrap() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(label) + .into_any_element(); + scrub_handle( + (self.id.clone(), "label").into(), + face, + state.clone(), + window, + cx, + ) + }); + let subtle = cx.theme().colors.fg_subtle; + let grip = scrub_handle( + (self.id.clone(), "grip").into(), + Icon::new(IconName::ChevronsUpDown) + .size(IconSize::Xs) + .color(subtle) + .into_any_element(), + state, + window, + cx, + ); + let theme = cx.theme(); + let colors = &theme.colors; + let half = |id: &'static str, icon: IconName, enabled: bool, act: Nudge| { + div() + .id((self.id.clone(), id)) + .flex() + .flex_1() + .items_center() + .justify_center() + .w(theme.icon_size(IconSize::Lg)) + .map(|half| { + if enabled { + half.cursor_pointer() + .hover(|style| style.bg(colors.hover)) + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) + .on_click(move |_, window, cx| act(window, cx)) + } else { + half.opacity(0.4) + } + }) + .child(Icon::new(icon).size(IconSize::Xs).color(colors.fg_muted)) + }; + let stepper = div() + .flex() + .flex_col() + .h_full() + .border_l_1() + .border_color(colors.border) + .child(half("up", IconName::ChevronUp, now < max, Box::new(up))) + .child(half( + "down", + IconName::ChevronDown, + now > min, + Box::new(down), + )); + let field = Input::new(&input) + .size(self.size) + .prefix( + div() + .flex() + .items_center() + .gap_1() + .child(grip) + .when_some(self.prefix, |prefix, symbol| { + prefix.child(div().text_color(subtle).child(symbol)) + }), + ) + .suffix( + div() + .flex() + .items_center() + .gap_1() + .children(self.suffix) + .child(stepper), + ); + div() + .flex() + .items_center() + .gap_2() + .w_full() + .children(label) + .child( + div() + .id(self.id) + .flex_1() + .min_w_0() + .capture_action(move |_: &Up, window, cx| { + cx.stop_propagation(); + key_up(window, cx); + }) + .capture_action(move |_: &Down, window, cx| { + cx.stop_propagation(); + key_down(window, cx); + }) + .child(field), + ) + } +} + +/// A label you drag sideways to change a number, beside its field. +#[derive(IntoElement)] +pub struct ScrubInput { + field: NumberInput, +} + +impl ScrubInput { + pub fn new(id: impl Into, label: impl Into, value: f64) -> Self { + let mut field = NumberInput::new(id, value); + field.scrub_label = Some(label.into()); + Self { field } + } + + pub fn range(mut self, min: f64, max: f64) -> Self { + self.field = self.field.range(min, max); + self + } + + pub fn step(mut self, step: f64) -> Self { + self.field = self.field.step(step); + self + } + + pub fn precision(mut self, places: usize) -> Self { + self.field = self.field.precision(places); + self + } + + pub fn on_change(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self { + self.field = self.field.on_change(handler); + self + } +} + +impl RenderOnce for ScrubInput { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.field + } +} + +#[cfg(test)] +mod tests { + use super::{parse, settle}; + + #[test] + fn parses_grouped_and_typographic_minus_and_refuses_junk() { + assert_eq!(parse("1,234.5"), Some(1234.5)); + assert_eq!(parse("\u{2212}3"), Some(-3.0)); + assert_eq!(parse("abc"), None); + assert_eq!(parse("1e999"), None); + } + + #[test] + fn settle_clamps_then_rounds() { + assert_eq!(settle(1.23456, 0.0, 10.0, 2), 1.23); + assert_eq!(settle(-5.0, 0.0, 10.0, 0), 0.0); + assert_eq!(settle(12.6, 0.0, 10.0, 0), 10.0); + } + + #[test] + fn settle_rounds_before_it_clamps() { + assert_eq!(settle(0.2 + 0.1, 0.0, 0.25, 1), 0.2); + assert_eq!(settle(-0.26, -0.25, 1.0, 1), -0.2); + assert_eq!(settle(0.3, 0.0, 0.29, 2), 0.29); + assert_eq!(settle(0.29, 0.29, 0.29, 2), 0.29); + assert_eq!(settle(10000000.01, 0.0, 10000000.005, 2), 10000000.0); + } + + #[test] + #[should_panic(expected = "no 1-place number lies in 0.21..=0.29")] + fn settle_refuses_a_range_without_a_number_at_its_precision() { + settle(0.25, 0.21, 0.29, 1); + } +} diff --git a/src/forms/pairs.rs b/src/forms/pairs.rs new file mode 100644 index 0000000..21af7e0 --- /dev/null +++ b/src/forms/pairs.rs @@ -0,0 +1,247 @@ +use std::rc::Rc; + +use gpui::{ + App, Context, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Subscription, Window, div, prelude::*, +}; + +use super::{Input, InputEvent, TextInput}; +use crate::{ + buttons::{Button, ButtonVariant, IconButton}, + primitives::IconName, + theme::ControlSize, +}; + +type OnRows = Rc>, &mut Window, &mut App)>; + +/// Rows of fields: `columns` wide, each row removable, more rows on demand. +struct Rows { + columns: usize, + rows: Vec, Subscription)>>, + placeholders: Vec, + on_change: Option, +} + +impl Rows { + fn values(&self, cx: &App) -> Vec> { + self.rows + .iter() + .map(|row| { + row.iter() + .map(|(field, _)| field.read(cx).text().to_string()) + .collect() + }) + .collect() + } + + fn report(&self, window: &mut Window, cx: &mut Context) { + let values = self.values(cx); + if let Some(on_change) = self.on_change.clone() { + on_change(values, window, cx); + } + } + + fn push(&mut self, seed: &[String], window: &mut Window, cx: &mut Context) { + let row = (0..self.columns) + .map(|column| { + let placeholder = self.placeholders[column].clone(); + let text = seed.get(column).cloned().unwrap_or_default(); + let field = cx.new(|cx| { + let mut input = TextInput::new(window, cx).placeholder(placeholder); + input.set_text(text, cx); + input + }); + let events = cx.subscribe_in(&field, window, |rows, _, event, window, cx| { + if *event == InputEvent::Changed { + rows.report(window, cx); + } + }); + (field, events) + }) + .collect(); + self.rows.push(row); + } +} + +fn rows_state( + id: &ElementId, + seed: &[Vec], + placeholders: &[SharedString], + window: &mut Window, + cx: &mut App, +) -> Entity { + let (seed, placeholders) = (seed.to_vec(), placeholders.to_vec()); + window.use_keyed_state(id.clone(), cx, move |window, cx: &mut Context| { + let mut rows = Rows { + columns: placeholders.len(), + rows: Vec::new(), + placeholders, + on_change: None, + }; + for row in &seed { + rows.push(row, window, cx); + } + rows + }) +} + +fn render_rows( + id: &ElementId, + state: Entity, + add: &'static str, + cx: &App, +) -> impl IntoElement + use<> { + let rows = state.read(cx).rows.len(); + let lines = (0..rows).map(|ix| { + let fields: Vec> = state.read(cx).rows[ix] + .iter() + .map(|(field, _)| field.clone()) + .collect(); + let remove = state.clone(); + div() + .flex() + .items_center() + .gap_2() + .children(fields.iter().map(|field| { + div() + .flex_1() + .child(Input::new(field).size(ControlSize::Sm)) + })) + .child( + IconButton::new(("remove-row", ix), IconName::Minus) + .size(ControlSize::Sm) + .tooltip("Remove") + .on_click(move |_, window, cx| { + log::info!("rows: removed {ix}"); + remove.update(cx, |rows, cx| { + rows.rows.remove(ix); + rows.report(window, cx); + cx.notify(); + }) + }), + ) + }); + let grow = state.clone(); + div() + .id(id.clone()) + .flex() + .flex_col() + .gap_2() + .w_full() + .children(lines) + .child( + div().child( + Button::new(("add-row", 0usize), add) + .size(ControlSize::Sm) + .variant(ButtonVariant::Ghost) + .icon(IconName::Plus) + .on_click(move |_, window, cx| { + log::info!("rows: added one"); + grow.update(cx, |rows, cx| { + rows.push(&[], window, cx); + let last = rows.rows.last().expect("just pushed"); + window.focus(&last[0].0.read(cx).focus().clone()); + rows.report(window, cx); + cx.notify(); + }) + }), + ), + ) +} + +/// Rows of key and value fields; add and remove rows. +#[derive(IntoElement)] +pub struct KeyValueInput { + id: ElementId, + seed: Vec>, + on_change: Option, +} + +impl KeyValueInput { + /// `seed` fills the rows on first render. + pub fn new(id: impl Into, seed: impl IntoIterator) -> Self { + Self { + id: id.into(), + seed: seed + .into_iter() + .map(|(key, value)| vec![key, value]) + .collect(), + on_change: None, + } + } + + /// Reports every pair after each change. + pub fn on_change( + mut self, + handler: impl Fn(Vec<(String, String)>, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new( + move |rows: Vec>, window: &mut Window, cx: &mut App| { + let pairs = rows + .into_iter() + .map(|mut row| { + let value = row.pop().expect("two columns"); + (row.pop().expect("two columns"), value) + }) + .collect(); + handler(pairs, window, cx) + }, + )); + self + } +} + +impl RenderOnce for KeyValueInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let placeholders = [SharedString::from("Key"), SharedString::from("Value")]; + let state = rows_state(&self.id, &self.seed, &placeholders, window, cx); + state.update(cx, |rows, _| rows.on_change = self.on_change.clone()); + render_rows(&self.id, state, "Add pair", cx) + } +} + +/// A list of text entries; add and remove entries. +#[derive(IntoElement)] +pub struct ListInput { + id: ElementId, + seed: Vec>, + placeholder: SharedString, + on_change: Option, +} + +impl ListInput { + pub fn new(id: impl Into, seed: impl IntoIterator) -> Self { + Self { + id: id.into(), + seed: seed.into_iter().map(|item| vec![item]).collect(), + placeholder: "Item".into(), + on_change: None, + } + } + + pub fn placeholder(mut self, text: impl Into) -> Self { + self.placeholder = text.into(); + self + } + + pub fn on_change( + mut self, + handler: impl Fn(Vec, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new( + move |rows: Vec>, window: &mut Window, cx: &mut App| { + handler(rows.into_iter().flatten().collect(), window, cx) + }, + )); + self + } +} + +impl RenderOnce for ListInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let placeholders = [self.placeholder.clone()]; + let state = rows_state(&self.id, &self.seed, &placeholders, window, cx); + state.update(cx, |rows, _| rows.on_change = self.on_change.clone()); + render_rows(&self.id, state, "Add item", cx) + } +} diff --git a/src/forms/path.rs b/src/forms/path.rs new file mode 100644 index 0000000..0300e18 --- /dev/null +++ b/src/forms/path.rs @@ -0,0 +1,93 @@ +use gpui::{ + App, ElementId, Entity, IntoElement, PathPromptOptions, RenderOnce, SharedString, Window, +}; + +use super::{Input, TextInput}; +use crate::{ + buttons::{Button, ButtonVariant}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize}, +}; + +/// A path field with Browse, which opens the system's file dialog. +#[derive(IntoElement)] +pub struct PathInput { + id: ElementId, + state: Entity, + directories: bool, +} + +impl PathInput { + pub fn new(id: impl Into, state: &Entity) -> Self { + Self { + id: id.into(), + state: state.clone(), + directories: false, + } + } + + /// Chooses folders instead of files. + pub fn directories(mut self) -> Self { + self.directories = true; + self + } +} + +impl RenderOnce for PathInput { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let subtle = cx.theme().colors.fg_subtle; + let (state, directories) = (self.state.clone(), self.directories); + let icon = if directories { + IconName::Folder + } else { + IconName::File + }; + Input::new(&self.state) + .prefix(Icon::new(icon).size(IconSize::Sm).color(subtle)) + .suffix( + Button::new(self.id, "Browse…") + .size(ControlSize::Sm) + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| { + let chosen = cx.prompt_for_paths(PathPromptOptions { + files: !directories, + directories, + multiple: false, + prompt: Some(SharedString::from("Choose")), + }); + log::info!("path input: dialog opened"); + let state = state.clone(); + window + .spawn(cx, async move |cx| { + let path = match chosen.await { + Ok(Ok(Some(paths))) => paths.into_iter().next(), + Ok(Ok(None)) => { + log::info!("path input: dialog cancelled"); + return; + } + Ok(Err(error)) => { + log::error!("path input: dialog failed: {error:#}"); + return; + } + Err(_) => { + log::error!("path input: dialog closed without an answer"); + return; + } + }; + let Some(path) = path else { + log::error!("path input: dialog chose nothing"); + return; + }; + let shown = path.display().to_string(); + log::info!("path input: chose a path"); + if let Err(error) = + state.update(cx, |input, cx| input.set_text(shown, cx)) + { + log::error!("path input: field gone: {error:#}"); + } + }) + .detach(); + }), + ) + } +} diff --git a/src/forms/pattern.rs b/src/forms/pattern.rs new file mode 100644 index 0000000..1cedab4 --- /dev/null +++ b/src/forms/pattern.rs @@ -0,0 +1,191 @@ +use std::ops::Range; + +use gpui::{ + App, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, + prelude::*, +}; +use regex::Regex; + +use super::{Highlight, Input, TextInput}; +use crate::theme::{ActiveTheme, ControlSize, TextSize}; + +/// Token spans of a regex, by kind. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Part { + Escape, + Class, + Group, + Quantifier, + Anchor, + Alternation, +} + +/// Splits a pattern into colored parts; plain text stays uncolored. +pub(crate) fn parts(pattern: &str) -> Vec<(Range, Part)> { + let mut out = Vec::new(); + let mut chars = pattern.char_indices().peekable(); + while let Some((ix, ch)) = chars.next() { + let span = |end: usize| ix..end; + match ch { + '\\' => { + let end = chars + .next() + .map_or(pattern.len(), |(next, ch)| next + ch.len_utf8()); + out.push((span(end), Part::Escape)); + } + '[' => { + let mut end = pattern.len(); + while let Some((next, ch)) = chars.next() { + if ch == '\\' { + chars.next(); + } else if ch == ']' { + end = next + 1; + break; + } + } + out.push((span(end), Part::Class)); + } + '(' | ')' => { + let mut end = ix + 1; + if ch == '(' && chars.peek().is_some_and(|(_, next)| *next == '?') { + chars.next(); + end += 1; + if let Some((next, ch)) = + chars.next_if(|(_, ch)| matches!(ch, ':' | '=' | '!' | '<')) + { + end = next + ch.len_utf8(); + } + } + out.push((span(end), Part::Group)); + } + '*' | '+' | '?' => out.push((span(ix + 1), Part::Quantifier)), + '{' => { + let end = pattern[ix..] + .find('}') + .map_or(pattern.len(), |close| ix + close + 1); + while chars.peek().is_some_and(|(next, _)| *next < end) { + chars.next(); + } + out.push((span(end), Part::Quantifier)); + } + '^' | '$' => out.push((span(ix + 1), Part::Anchor)), + '|' => out.push((span(ix + 1), Part::Alternation)), + _ => {} + } + } + out +} + +/// Colors for a regex, from the syntax palette. +pub fn regex_highlights(pattern: &str, cx: &App) -> Vec<(Range, Highlight)> { + let syntax = &cx.theme().colors.syntax; + parts(pattern) + .into_iter() + .map(|(range, part)| { + let color = match part { + Part::Escape => syntax.keyword, + Part::Class => syntax.string, + Part::Group => syntax.function, + Part::Quantifier => syntax.number, + Part::Anchor => syntax.constant, + Part::Alternation => syntax.operator, + }; + ( + range, + Highlight { + color, + background: None, + }, + ) + }) + .collect() +} + +/// A regular expression field: colored as you type, checked as you type. +#[derive(IntoElement)] +pub struct RegexInput { + state: Entity, + size: ControlSize, +} + +impl RegexInput { + /// Give the state `regex_highlights` as its highlighter. + 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 RegexInput { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let pattern = self.state.read(cx).text().to_string(); + let error = (!pattern.is_empty()) + .then(|| Regex::new(&pattern).err()) + .flatten() + .map(|error| { + let text = error.to_string(); + let last = text.lines().last().unwrap_or_default().trim().to_string(); + SharedString::from(last) + }); + let theme = cx.theme(); + let (subtle, danger) = (theme.colors.fg_subtle, theme.colors.danger); + let mono = theme.mono_family.clone(); + let slash = || div().text_color(subtle).child("/"); + div() + .flex() + .flex_col() + .gap_1() + .w_full() + .child( + div().font_family(mono).child( + Input::new(&self.state) + .size(self.size) + .invalid(error.is_some()) + .prefix(slash()) + .suffix(slash()), + ), + ) + .when_some(error, |field, error| { + field.child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(danger) + .child(error), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::{Part, parts}; + + #[test] + fn parts_cover_escapes_classes_groups_quantifiers() { + let found = parts(r"^(?:\d+)[a-z\]]{2,3}|x$"); + let kinds: Vec = found.iter().map(|(_, part)| *part).collect(); + assert_eq!( + kinds, + vec![ + Part::Anchor, + Part::Group, + Part::Escape, + Part::Quantifier, + Part::Group, + Part::Class, + Part::Quantifier, + Part::Alternation, + Part::Anchor, + ] + ); + assert_eq!(found[1].0, 1..4); + assert_eq!(found[5].0, 8..15); + } +} diff --git a/src/forms/pin.rs b/src/forms/pin.rs new file mode 100644 index 0000000..6c75b9d --- /dev/null +++ b/src/forms/pin.rs @@ -0,0 +1,150 @@ +use std::rc::Rc; + +use gpui::{ + App, Context, ElementId, Entity, FontWeight, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Subscription, Window, div, prelude::*, +}; + +use super::{InputEvent, TextInput}; +use crate::theme::{ActiveTheme, ControlSize, Radius, TextSize}; + +type OnCode = Rc; + +struct Pin { + input: Entity, + length: usize, + on_complete: Option, + _events: Subscription, +} + +/// Boxes for a one-time code. Typing moves along; a paste fills them all. +#[derive(IntoElement)] +pub struct PinInput { + id: ElementId, + length: usize, + letters: bool, + masked: bool, + on_complete: Option, +} + +impl PinInput { + pub fn new(id: impl Into, length: usize) -> Self { + assert!(length > 0, "a pin needs at least one box"); + Self { + id: id.into(), + length, + letters: false, + masked: false, + on_complete: None, + } + } + + /// Accepts letters as well as digits. + pub fn letters(mut self) -> Self { + self.letters = true; + self + } + + /// Shows bullets in filled boxes. + pub fn masked(mut self) -> Self { + self.masked = true; + self + } + + /// Runs once every box is filled. + pub fn on_complete(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self { + self.on_complete = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for PinInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let (length, letters) = (self.length, self.letters); + let state = window.use_keyed_state(self.id.clone(), cx, |window, cx: &mut Context| { + let input = cx.new(|cx| { + TextInput::new(window, cx) + .max_len(length) + .filter(move |ch| ch.is_ascii_digit() || (letters && ch.is_ascii_alphabetic())) + }); + let events = cx.subscribe_in(&input, window, |pin, input, event, window, cx| { + let code = input.read(cx).text().to_uppercase(); + if *event == InputEvent::Changed && code.chars().count() == pin.length { + log::info!("pin input: complete"); + if let Some(done) = pin.on_complete.clone() { + done(&code, window, cx); + } + } + }); + Pin { + input, + length, + on_complete: None, + _events: events, + } + }); + state.update(cx, |pin, _| pin.on_complete = self.on_complete.clone()); + let input = state.read(cx).input.clone(); + let (code, focused, caret_on) = { + let field = input.read(cx); + ( + field.text().to_uppercase(), + field.focus().is_focused(window), + field.caret_on(), + ) + }; + let end = code.len(); + let selection = input.read(cx).selection(); + let whole = focused && end > 0 && selection == (0..end); + if focused && !whole && selection != (end..end) { + input.update(cx, |field, cx| field.select(end..end, cx)); + } + let filled: Vec = code.chars().collect(); + let active = filled.len().min(length - 1); + let theme = cx.theme(); + let colors = &theme.colors; + let side = theme.control_height(ControlSize::Lg) * 1.25; + let boxes = (0..length).map(|ix| { + let current = focused && ix == active; + let glyph = filled.get(ix).map(|ch| if self.masked { '•' } else { *ch }); + div() + .relative() + .flex() + .items_center() + .justify_center() + .size(side) + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(if current { + colors.focus + } else { + colors.border_strong + }) + .bg(if whole && glyph.is_some() { + colors.selection + } else { + colors.surface + }) + .text_size(theme.text_size(TextSize::Xl)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .when_some(glyph, |cell, glyph| { + cell.child(SharedString::from(glyph.to_string())) + }) + .when(current && glyph.is_none() && caret_on, |cell| { + cell.child( + div() + .w(theme.caret_width()) + .h(theme.text_size(TextSize::Xl)) + .bg(colors.focus), + ) + }) + }); + div() + .relative() + .flex() + .gap_2() + .children(boxes) + .child(div().absolute().inset_0().opacity(0.0).child(input)) + } +} diff --git a/src/forms/search.rs b/src/forms/search.rs new file mode 100644 index 0000000..00b33b9 --- /dev/null +++ b/src/forms/search.rs @@ -0,0 +1,120 @@ +use std::rc::Rc; + +use gpui::{ + App, Bounds, ElementId, Entity, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, + Styled, Window, canvas, div, prelude::*, +}; + +use super::{ + Input, TextInput, + suggest::{Suggestion, suggestion_list}, +}; +use crate::{ + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize}, +}; + +type OnPick = Rc; + +/// A search field: a magnifier, a clear button, recent searches while empty. +#[derive(IntoElement)] +pub struct SearchInput { + id: ElementId, + state: Entity, + history: Vec, + size: ControlSize, + on_pick: Option, +} + +impl SearchInput { + pub fn new(id: impl Into, state: &Entity) -> Self { + Self { + id: id.into(), + state: state.clone(), + history: Vec::new(), + size: ControlSize::default(), + on_pick: None, + } + } + + /// Recent searches, newest first; shown while the field is empty. + pub fn history(mut self, history: impl IntoIterator>) -> Self { + self.history = history.into_iter().map(Into::into).collect(); + self + } + + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } + + /// Runs after a recent search fills the field. + pub fn on_pick( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SearchInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let frame = window.use_keyed_state(self.id.clone(), cx, |_, _| Bounds::::default()); + let input = self.state.read(cx); + let open = input.focus().is_focused(window) && input.is_empty() && !self.history.is_empty(); + let anchor = *frame.read(cx); + let subtle = cx.theme().colors.fg_subtle; + let rows: Vec = self + .history + .iter() + .map(|query| Suggestion { + label: query.clone(), + note: None, + }) + .collect(); + let (state, history, on_pick) = (self.state.clone(), self.history, self.on_pick); + let pick = Rc::new(move |ix: usize, window: &mut Window, cx: &mut App| { + let query = history[ix].clone(); + log::info!("search input: recent {query}"); + state.update(cx, |input, cx| input.set_text(query.to_string(), cx)); + if let Some(on_pick) = &on_pick { + on_pick(&query, window, cx); + } + }); + div() + .relative() + .w_full() + .child( + Input::new(&self.state) + .size(self.size) + .clearable() + .prefix(Icon::new(IconName::Search).size(IconSize::Sm).color(subtle)), + ) + .child( + canvas( + move |bounds, _, cx| { + if *frame.read(cx) != bounds { + frame.update(cx, |frame, _| *frame = bounds); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .when(open, |field| { + field.child(suggestion_list( + (self.id.clone(), "recent"), + anchor, + &rows, + usize::MAX, + pick, + None, + cx, + )) + }) + } +} diff --git a/src/forms/suggest.rs b/src/forms/suggest.rs new file mode 100644 index 0000000..3a7edd2 --- /dev/null +++ b/src/forms/suggest.rs @@ -0,0 +1,79 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, Bounds, ElementId, InteractiveElement, IntoElement, MouseButton, + ParentElement, Pixels, SharedString, Styled, anchored, deferred, div, point, prelude::*, +}; + +use crate::theme::{ActiveTheme, Elevation, Radius, TextSize}; + +/// One row in a suggestion list: a label and a quiet note. +#[derive(Clone, Debug)] +pub(crate) struct Suggestion { + pub label: SharedString, + pub note: Option, +} + +type Pick = Rc; +pub(crate) type Dismiss = Rc; + +/// A list floating under `anchor`. Rows keep focus where it was. +pub(crate) fn suggestion_list( + id: impl Into, + anchor: Bounds, + rows: &[Suggestion], + highlighted: usize, + pick: Pick, + dismiss: Option, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let colors = &theme.colors; + let list = div() + .id(id) + .min_w(theme.tooltip_max_width()) + .p_1() + .flex() + .flex_col() + .rounded(theme.radius(Radius::Lg)) + .bg(colors.overlay) + .border_1() + .border_color(colors.border) + .shadow(theme.elevation(Elevation::Floating)) + .text_size(theme.text_size(TextSize::Sm)) + .when_some(dismiss, |list, dismiss| { + list.on_mouse_down_out(move |_, window, cx| dismiss(window, cx)) + }) + .children(rows.iter().enumerate().map(|(ix, row)| { + let pick = pick.clone(); + div() + .id(("suggestion", ix)) + .flex() + .items_center() + .justify_between() + .gap_4() + .px_2() + .py_1() + .rounded(theme.radius(Radius::Md)) + .text_color(colors.fg) + .when(ix == highlighted, |row| row.bg(colors.hover)) + .hover(|style| style.bg(colors.hover)) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + pick(ix, window, cx); + }) + .child(row.label.clone()) + .when_some(row.note.clone(), |row, note| { + row.child(div().text_color(colors.fg_subtle).child(note)) + }) + })); + deferred( + anchored() + .position(point(anchor.left(), anchor.bottom())) + .child(div().mt_1().child(list)), + ) + .with_priority(1) + .into_any_element() +} diff --git a/src/forms/tags.rs b/src/forms/tags.rs new file mode 100644 index 0000000..a3ca5bb --- /dev/null +++ b/src/forms/tags.rs @@ -0,0 +1,192 @@ +use std::rc::Rc; + +use gpui::{ + App, Context, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div, + prelude::*, +}; + +use super::{InputEvent, TextInput, text::Backspace}; +use crate::{ + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, +}; + +type OnChange = Rc, &mut Window, &mut App)>; + +struct Tags { + input: Entity, + tags: Vec, + on_change: Option, + _events: Subscription, +} + +impl Tags { + /// Adds the field's text as a tag, unless blank or already there. + fn take(&mut self, window: &mut Window, cx: &mut Context) { + let typed = self + .input + .read(cx) + .text() + .trim() + .trim_end_matches(',') + .trim() + .to_string(); + self.input.update(cx, |input, cx| input.set_text("", cx)); + if typed.is_empty() || self.tags.iter().any(|tag| tag.as_ref() == typed) { + return; + } + let mut next = self.tags.clone(); + next.push(typed.into()); + log::info!("tag input: {} tags", next.len()); + if let Some(on_change) = self.on_change.clone() { + on_change(next, window, cx); + } + } +} + +/// A chip per tag, then a field. Enter or a comma adds; Backspace on empty drops the last. +#[derive(IntoElement)] +pub struct TagInput { + id: ElementId, + tags: Vec, + placeholder: SharedString, + on_change: Option, +} + +impl TagInput { + pub fn new( + id: impl Into, + tags: impl IntoIterator>, + ) -> Self { + Self { + id: id.into(), + tags: tags.into_iter().map(Into::into).collect(), + placeholder: "Add a tag".into(), + on_change: None, + } + } + + pub fn placeholder(mut self, text: impl Into) -> Self { + self.placeholder = text.into(); + self + } + + pub fn on_change( + mut self, + handler: impl Fn(Vec, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TagInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let placeholder = self.placeholder.clone(); + let state = + window.use_keyed_state(self.id.clone(), cx, |window, cx: &mut Context| { + let input = cx.new(|cx| TextInput::new(window, cx).placeholder(placeholder)); + let events = cx.subscribe_in(&input, window, |tags, input, event, window, cx| { + let comma = + *event == InputEvent::Changed && input.read(cx).text().ends_with(','); + if comma || *event == InputEvent::Submit { + tags.take(window, cx); + } + }); + Tags { + input, + tags: Vec::new(), + on_change: None, + _events: events, + } + }); + state.update(cx, |tags, _| { + tags.tags = self.tags.clone(); + tags.on_change = self.on_change.clone(); + }); + let input = state.read(cx).input.clone(); + let focused = input.read(cx).focus().is_focused(window); + let focus = input.read(cx).focus().clone(); + let theme = cx.theme(); + let colors = &theme.colors; + let chips = self.tags.iter().enumerate().map(|(ix, tag)| { + let (remove, tags, on_change) = (ix, self.tags.clone(), self.on_change.clone()); + div() + .flex() + .items_center() + .gap_1() + .pl_2() + .pr_1() + .h(theme.control_height(ControlSize::Sm)) + .rounded(theme.radius(Radius::Sm)) + .bg(colors.hover) + .text_size(theme.text_size(TextSize::Sm)) + .child(tag.clone()) + .child( + div() + .id(("tag-remove", ix)) + .rounded(theme.radius(Radius::Sm)) + .cursor_pointer() + .hover(|style| style.bg(colors.active)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| { + let mut next = tags.clone(); + next.remove(remove); + log::info!("tag input: removed one, {} left", next.len()); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + }) + .child( + Icon::new(IconName::X) + .size(IconSize::Xs) + .color(colors.fg_muted), + ), + ) + }); + let (tags, on_change, empty_check) = + (self.tags.clone(), self.on_change.clone(), input.clone()); + div() + .id(self.id) + .flex() + .flex_wrap() + .items_center() + .gap_1() + .w_full() + .min_h(theme.control_height(ControlSize::Md)) + .px_1() + .py_0p5() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(if focused { + colors.focus + } else { + colors.border_strong + }) + .bg(colors.surface) + .text_size(theme.text_size(TextSize::Base)) + .text_color(colors.fg) + .on_mouse_down(MouseButton::Left, move |_, window, _| window.focus(&focus)) + .capture_action(move |_: &Backspace, window, cx| { + if !empty_check.read(cx).is_empty() || tags.is_empty() { + return; + } + cx.stop_propagation(); + let mut next = tags.clone(); + next.pop(); + log::info!("tag input: backspace dropped the last tag"); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + }) + .children(chips) + .child( + div() + .flex_1() + .min_w(theme.pane_min() / 2.0) + .px_1() + .child(input), + ) + } +} diff --git a/src/forms/tests.rs b/src/forms/tests.rs new file mode 100644 index 0000000..e61a3ba --- /dev/null +++ b/src/forms/tests.rs @@ -0,0 +1,254 @@ +use gpui::{ + AppContext as _, Context, Entity, EntityInputHandler, IntoElement, KeyUpEvent, Keystroke, + ParentElement, Render, TestAppContext, VisualTestContext, Window, div, +}; + +use super::{Input, MaskedInput, NumberInput, PinInput, TextInput, bind_keys}; +use crate::theme::Theme; + +struct Fields { + fields: Vec>, + clearable: bool, +} + +impl Render for Fields { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().children(self.fields.iter().map(|field| { + let input = Input::new(field); + if self.clearable { + input.clearable() + } else { + input + } + })) + } +} + +fn setup(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + bind_keys(cx); + }); +} + +fn open( + count: usize, + clearable: bool, + cx: &mut TestAppContext, +) -> (Vec>, &mut VisualTestContext) { + open_with(count, clearable, |input| input, cx) +} + +fn open_with( + count: usize, + clearable: bool, + build: impl Fn(TextInput) -> TextInput + 'static, + cx: &mut TestAppContext, +) -> (Vec>, &mut VisualTestContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|window, cx| Fields { + fields: (0..count) + .map(|_| cx.new(|cx| build(TextInput::new(window, cx)))) + .collect(), + clearable, + }); + let fields = view.read_with(cx, |view, _| view.fields.clone()); + (fields, cx) +} + +fn text(field: &Entity, cx: &mut VisualTestContext) -> String { + field.read_with(cx, |input, _| input.text().to_string()) +} + +fn focus(field: &Entity, cx: &mut VisualTestContext) { + cx.update(|window, cx| window.focus(&field.read(cx).focus().clone())); +} + +#[gpui::test] +fn tab_reaches_fields_and_skips_disabled_ones(cx: &mut TestAppContext) { + let (fields, cx) = open(3, false, cx); + fields[1].update(cx, |input, cx| input.set_disabled(true, cx)); + cx.run_until_parked(); + let mut order = Vec::new(); + for _ in 0..2 { + order.push(cx.update(|window, cx| { + window.focus_next(); + fields + .iter() + .position(|field| field.read(cx).focus().is_focused(window)) + })); + } + assert_eq!(order, [Some(0), Some(2)]); +} + +#[gpui::test] +fn enter_on_a_clear_button_clears_its_own_field(cx: &mut TestAppContext) { + let (fields, cx) = open(2, true, cx); + for field in &fields { + field.update(cx, |input, cx| input.set_text("kept", cx)); + } + cx.run_until_parked(); + cx.update(|window, _| { + window.focus_next(); + window.focus_next(); + }); + cx.run_until_parked(); + cx.simulate_keystrokes("enter"); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse("enter").unwrap(), + }); + assert_eq!([text(&fields[0], cx), text(&fields[1], cx)], ["", "kept"]); +} + +#[gpui::test] +fn a_composition_undoes_in_one_step(cx: &mut TestAppContext) { + let (fields, cx) = open(1, false, cx); + let field = &fields[0]; + focus(field, cx); + cx.update(|window, cx| { + field.update(cx, |input, cx| { + input.replace_and_mark_text_in_range(None, "n", None, window, cx); + input.replace_and_mark_text_in_range(None, "ni", None, window, cx); + input.replace_text_in_range(None, "你", window, cx); + }) + }); + assert_eq!(text(field, cx), "你"); + cx.simulate_keystrokes("cmd-z"); + assert_eq!(text(field, cx), ""); +} + +#[gpui::test] +fn a_committed_composition_obeys_the_filter_and_length(cx: &mut TestAppContext) { + let (fields, cx) = open_with( + 1, + false, + |input| input.filter(|ch| ch.is_ascii_digit()).max_len(1), + cx, + ); + let field = &fields[0]; + focus(field, cx); + cx.update(|window, cx| { + field.update(cx, |input, cx| { + input.replace_and_mark_text_in_range(None, "12a", None, window, cx); + input.unmark_text(window, cx); + }) + }); + assert_eq!(text(field, cx), "1"); + cx.simulate_keystrokes("cmd-z"); + assert_eq!(text(field, cx), ""); +} + +#[gpui::test] +fn undo_leaves_a_disabled_field_alone(cx: &mut TestAppContext) { + let (fields, cx) = open(1, false, cx); + let field = &fields[0]; + focus(field, cx); + cx.simulate_input("a"); + field.update(cx, |input, cx| input.set_disabled(true, cx)); + cx.simulate_keystrokes("cmd-z"); + assert_eq!(text(field, cx), "a"); +} + +struct Stepped { + value: f64, +} + +impl Render for Stepped { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + NumberInput::new("stepped", self.value).on_change(move |value, _, cx| { + view.update(cx, |view, cx| { + view.value = value; + cx.notify(); + }) + }) + } +} + +#[gpui::test] +fn stepping_starts_from_the_typed_number(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Stepped { value: 4.0 }); + cx.update(|window, _| window.focus_next()); + cx.simulate_keystrokes("cmd-a"); + cx.simulate_input("20"); + cx.simulate_keystrokes("up"); + assert_eq!(view.read_with(cx, |view, _| view.value), 21.0); +} + +struct Pin { + code: String, +} + +impl Render for Pin { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + PinInput::new("pin", 4).on_complete(move |code, _, cx| { + let code = code.to_string(); + view.update(cx, |view, _| view.code = code); + }) + } +} + +#[gpui::test] +fn select_all_then_typing_replaces_the_pin(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Pin { + code: String::new(), + }); + cx.update(|window, _| window.focus_next()); + cx.simulate_input("4821"); + cx.simulate_keystrokes("cmd-a"); + cx.simulate_input("1234"); + assert_eq!(view.read_with(cx, |view, _| view.code.clone()), "1234"); +} + +struct Masked { + state: Entity, + mask: &'static str, +} + +impl Render for Masked { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + MaskedInput::new("masked", &self.state, self.mask) + } +} + +fn masked<'a>( + mask: &'static str, + cx: &'a mut TestAppContext, +) -> (Entity, &'a mut VisualTestContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|window, cx| Masked { + state: cx.new(|cx| TextInput::new(window, cx)), + mask, + }); + let state = view.read_with(cx, |view, _| view.state.clone()); + focus(&state, cx); + (state, cx) +} + +#[gpui::test] +fn select_all_then_typing_refits_a_masked_field(cx: &mut TestAppContext) { + let (state, cx) = masked("1-999", cx); + cx.simulate_input("123"); + assert_eq!(text(&state, cx), "1-123"); + cx.simulate_keystrokes("cmd-a"); + cx.simulate_input("1"); + assert_eq!(text(&state, cx), "1-1"); +} + +#[gpui::test] +fn a_composition_inside_a_masked_field_fits_from_where_it_began(cx: &mut TestAppContext) { + let (state, cx) = masked("(999) 999", cx); + cx.simulate_input("123456"); + assert_eq!(text(&state, cx), "(123) 456"); + cx.update(|window, cx| { + state.update(cx, |input, cx| { + input.select(2..2, cx); + input.replace_and_mark_text_in_range(None, "9", None, window, cx); + input.replace_text_in_range(None, "9", window, cx); + }) + }); + assert_eq!(text(&state, cx), "(192) 345"); +} diff --git a/src/forms/text/actions.rs b/src/forms/text/actions.rs new file mode 100644 index 0000000..dca0b43 --- /dev/null +++ b/src/forms/text/actions.rs @@ -0,0 +1,391 @@ +use gpui::{ + App, ClipboardItem, Context, CursorStyle, InteractiveElement, IntoElement, KeyBinding, + MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Render, Styled, Window, actions, + div, +}; + +use super::{ + InputEvent, TextInput, + edit::{self, next_grapheme, prev_grapheme}, + element::TextElement, +}; + +actions!( + ely_input, + [ + Backspace, + Delete, + DeleteWordLeft, + Left, + Right, + Up, + Down, + SelectLeft, + SelectRight, + SelectUp, + SelectDown, + WordLeft, + WordRight, + SelectWordLeft, + SelectWordRight, + Home, + End, + SelectHome, + SelectEnd, + SelectAll, + Copy, + Cut, + Paste, + Undo, + Redo, + Enter, + Submit, + ShowCharacterPalette, + ] +); + +const CONTEXT: &str = "ElyInput"; + +/// Binds the text keys. `init` calls it. +pub(crate) fn bind_keys(cx: &mut App) { + let word = if cfg!(target_os = "macos") { + "alt" + } else { + "ctrl" + }; + let mut bindings = vec![ + KeyBinding::new("backspace", Backspace, Some(CONTEXT)), + KeyBinding::new("delete", Delete, Some(CONTEXT)), + KeyBinding::new(&format!("{word}-backspace"), DeleteWordLeft, Some(CONTEXT)), + KeyBinding::new("left", Left, Some(CONTEXT)), + KeyBinding::new("right", Right, Some(CONTEXT)), + KeyBinding::new("up", Up, Some(CONTEXT)), + KeyBinding::new("down", Down, Some(CONTEXT)), + KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)), + KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)), + KeyBinding::new("shift-up", SelectUp, Some(CONTEXT)), + KeyBinding::new("shift-down", SelectDown, Some(CONTEXT)), + KeyBinding::new(&format!("{word}-left"), WordLeft, Some(CONTEXT)), + KeyBinding::new(&format!("{word}-right"), WordRight, Some(CONTEXT)), + KeyBinding::new(&format!("{word}-shift-left"), SelectWordLeft, Some(CONTEXT)), + KeyBinding::new( + &format!("{word}-shift-right"), + SelectWordRight, + Some(CONTEXT), + ), + KeyBinding::new("home", Home, Some(CONTEXT)), + KeyBinding::new("end", End, Some(CONTEXT)), + KeyBinding::new("shift-home", SelectHome, Some(CONTEXT)), + KeyBinding::new("shift-end", SelectEnd, Some(CONTEXT)), + KeyBinding::new("secondary-a", SelectAll, Some(CONTEXT)), + KeyBinding::new("secondary-c", Copy, Some(CONTEXT)), + KeyBinding::new("secondary-x", Cut, Some(CONTEXT)), + KeyBinding::new("secondary-v", Paste, Some(CONTEXT)), + KeyBinding::new("secondary-z", Undo, Some(CONTEXT)), + KeyBinding::new("secondary-shift-z", Redo, Some(CONTEXT)), + KeyBinding::new("enter", Enter, Some(CONTEXT)), + KeyBinding::new("secondary-enter", Submit, Some(CONTEXT)), + ]; + if cfg!(target_os = "macos") { + bindings.extend([ + KeyBinding::new("cmd-left", Home, Some(CONTEXT)), + KeyBinding::new("cmd-right", End, Some(CONTEXT)), + KeyBinding::new("cmd-shift-left", SelectHome, Some(CONTEXT)), + KeyBinding::new("cmd-shift-right", SelectEnd, Some(CONTEXT)), + KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, Some(CONTEXT)), + ]); + } else { + bindings.push(KeyBinding::new("ctrl-y", Redo, Some(CONTEXT))); + } + cx.bind_keys(bindings); +} + +impl TextInput { + fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context) { + let range = if self.selection.is_empty() { + prev_grapheme(&self.text, self.cursor())..self.cursor() + } else { + self.selection.clone() + }; + self.replace(range, "", false, cx); + } + + fn delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context) { + let range = if self.selection.is_empty() { + self.cursor()..next_grapheme(&self.text, self.cursor()) + } else { + self.selection.clone() + }; + self.replace(range, "", false, cx); + } + + fn delete_word_left(&mut self, _: &DeleteWordLeft, _: &mut Window, cx: &mut Context) { + let range = if self.selection.is_empty() { + edit::prev_word(&self.text, self.cursor())..self.cursor() + } else { + self.selection.clone() + }; + self.replace(range, "", false, cx); + } + + fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { + let to = if self.selection.is_empty() { + prev_grapheme(&self.text, self.cursor()) + } else { + self.selection.start + }; + self.move_to(to, cx); + } + + fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context) { + let to = if self.selection.is_empty() { + next_grapheme(&self.text, self.cursor()) + } else { + self.selection.end + }; + self.move_to(to, cx); + } + + /// The offset one visual line above or below the caret. + fn vertical(&self, down: bool) -> usize { + let caret = self.cursor(); + let (Some(layout), Some(at)) = ( + self.layout.as_ref(), + self.position_for(self.display_offset(caret)), + ) else { + return caret; + }; + let step = if down { + layout.line_height + } else { + -layout.line_height + }; + let target = gpui::point(at.x, at.y + step + layout.line_height / 2.0); + if target.y < gpui::Pixels::ZERO { + return 0; + } + self.offset_for_point(layout.bounds.origin + target - self.scroll) + } + + fn up(&mut self, _: &Up, _: &mut Window, cx: &mut Context) { + let to = self.vertical(false); + self.move_to(to, cx); + } + + fn down(&mut self, _: &Down, _: &mut Window, cx: &mut Context) { + let to = if self.rows.is_some() { + self.vertical(true) + } else { + self.text.len() + }; + self.move_to(to, cx); + } + + fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { + self.select_to(prev_grapheme(&self.text, self.cursor()), cx); + } + + fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { + self.select_to(next_grapheme(&self.text, self.cursor()), cx); + } + + fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context) { + let to = self.vertical(false); + self.select_to(to, cx); + } + + fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context) { + let to = self.vertical(true); + self.select_to(to, cx); + } + + fn word_left(&mut self, _: &WordLeft, _: &mut Window, cx: &mut Context) { + self.move_to(edit::prev_word(&self.text, self.cursor()), cx); + } + + fn word_right(&mut self, _: &WordRight, _: &mut Window, cx: &mut Context) { + self.move_to(edit::next_word(&self.text, self.cursor()), cx); + } + + fn select_word_left(&mut self, _: &SelectWordLeft, _: &mut Window, cx: &mut Context) { + self.select_to(edit::prev_word(&self.text, self.cursor()), cx); + } + + fn select_word_right(&mut self, _: &SelectWordRight, _: &mut Window, cx: &mut Context) { + self.select_to(edit::next_word(&self.text, self.cursor()), cx); + } + + fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) { + self.move_to(edit::line_start(&self.text, self.cursor()), cx); + } + + fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { + self.move_to(edit::line_end(&self.text, self.cursor()), cx); + } + + fn select_home(&mut self, _: &SelectHome, _: &mut Window, cx: &mut Context) { + self.select_to(edit::line_start(&self.text, self.cursor()), cx); + } + + fn select_end(&mut self, _: &SelectEnd, _: &mut Window, cx: &mut Context) { + self.select_to(edit::line_end(&self.text, self.cursor()), cx); + } + + fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.selection = 0..self.text.len(); + self.reversed = false; + cx.notify(); + } + + fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { + if self.masked || self.selection.is_empty() { + return; + } + cx.write_to_clipboard(ClipboardItem::new_string( + self.text[self.selection.clone()].to_string(), + )); + } + + fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context) { + if self.masked || self.selection.is_empty() { + return; + } + cx.write_to_clipboard(ClipboardItem::new_string( + self.text[self.selection.clone()].to_string(), + )); + self.replace(self.selection.clone(), "", false, cx); + } + + fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context) { + let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { + return; + }; + self.replace(self.selection.clone(), &text, false, cx); + } + + fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context) { + if self.disabled { + return; + } + if let Some(previous) = self.history.undo(self.snapshot()) { + self.restore(previous, cx); + } + } + + fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context) { + if self.disabled { + return; + } + if let Some(next) = self.history.redo(self.snapshot()) { + self.restore(next, cx); + } + } + + fn enter(&mut self, _: &Enter, _: &mut Window, cx: &mut Context) { + if self.rows.is_some() { + self.replace(self.selection.clone(), "\n", false, cx); + } else { + log::info!("text input: submitted"); + cx.emit(InputEvent::Submit); + } + } + + fn submit(&mut self, _: &Submit, _: &mut Window, cx: &mut Context) { + log::info!("text input: submitted"); + cx.emit(InputEvent::Submit); + } + + fn show_character_palette( + &mut self, + _: &ShowCharacterPalette, + window: &mut Window, + _: &mut Context, + ) { + window.show_character_palette(); + } + + fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context) { + if self.disabled { + return; + } + window.focus(&self.focus); + let at = self.offset_for_point(event.position); + match event.click_count { + 2 => { + let word = edit::word_at(&self.text, at); + self.selection = word; + self.reversed = false; + cx.notify(); + } + 3.. => { + self.selection = edit::line_start(&self.text, at)..edit::line_end(&self.text, at); + self.reversed = false; + cx.notify(); + } + _ if event.modifiers.shift => self.select_to(at, cx), + _ => { + self.selecting = true; + self.move_to(at, cx); + } + } + } + + fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context) { + if self.selecting && event.dragging() { + let at = self.offset_for_point(event.position); + self.select_to(at, cx); + } + } + + fn mouse_up(&mut self, _: &gpui::MouseUpEvent, _: &mut Window, _: &mut Context) { + self.selecting = false; + } +} + +impl Render for TextInput { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .id("ely-text-input") + .key_context(CONTEXT) + .track_focus(&self.focus) + .w_full() + .cursor(if self.disabled { + CursorStyle::OperationNotAllowed + } else { + CursorStyle::IBeam + }) + .on_action(cx.listener(Self::backspace)) + .on_action(cx.listener(Self::delete)) + .on_action(cx.listener(Self::delete_word_left)) + .on_action(cx.listener(Self::left)) + .on_action(cx.listener(Self::right)) + .on_action(cx.listener(Self::up)) + .on_action(cx.listener(Self::down)) + .on_action(cx.listener(Self::select_left)) + .on_action(cx.listener(Self::select_right)) + .on_action(cx.listener(Self::select_up)) + .on_action(cx.listener(Self::select_down)) + .on_action(cx.listener(Self::word_left)) + .on_action(cx.listener(Self::word_right)) + .on_action(cx.listener(Self::select_word_left)) + .on_action(cx.listener(Self::select_word_right)) + .on_action(cx.listener(Self::home)) + .on_action(cx.listener(Self::end)) + .on_action(cx.listener(Self::select_home)) + .on_action(cx.listener(Self::select_end)) + .on_action(cx.listener(Self::select_all)) + .on_action(cx.listener(Self::copy)) + .on_action(cx.listener(Self::cut)) + .on_action(cx.listener(Self::paste)) + .on_action(cx.listener(Self::undo)) + .on_action(cx.listener(Self::redo)) + .on_action(cx.listener(Self::enter)) + .on_action(cx.listener(Self::submit)) + .on_action(cx.listener(Self::show_character_palette)) + .on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down)) + .on_mouse_move(cx.listener(Self::mouse_move)) + .on_mouse_up(MouseButton::Left, cx.listener(Self::mouse_up)) + .on_mouse_up_out(MouseButton::Left, cx.listener(Self::mouse_up)) + .child(TextElement::new(cx.entity())) + } +} diff --git a/src/forms/text/edit.rs b/src/forms/text/edit.rs new file mode 100644 index 0000000..00a726e --- /dev/null +++ b/src/forms/text/edit.rs @@ -0,0 +1,189 @@ +use std::{ + ops::Range, + time::{Duration, Instant}, +}; + +use unicode_segmentation::UnicodeSegmentation; + +pub(crate) fn prev_grapheme(text: &str, offset: usize) -> usize { + text.grapheme_indices(true) + .rev() + .find_map(|(ix, _)| (ix < offset).then_some(ix)) + .unwrap_or(0) +} + +pub(crate) fn next_grapheme(text: &str, offset: usize) -> usize { + text.grapheme_indices(true) + .find_map(|(ix, _)| (ix > offset).then_some(ix)) + .unwrap_or(text.len()) +} + +fn words(text: &str) -> impl DoubleEndedIterator { + text.split_word_bound_indices() + .filter(|(_, word)| !word.trim().is_empty()) +} + +/// Start of the word before `offset`. +pub(crate) fn prev_word(text: &str, offset: usize) -> usize { + words(text) + .rev() + .find_map(|(ix, _)| (ix < offset).then_some(ix)) + .unwrap_or(0) +} + +/// End of the word after `offset`. +pub(crate) fn next_word(text: &str, offset: usize) -> usize { + words(text) + .find_map(|(ix, word)| (ix + word.len() > offset).then_some(ix + word.len())) + .unwrap_or(text.len()) +} + +/// The word around `offset`, for double-click selection. +pub(crate) fn word_at(text: &str, offset: usize) -> Range { + text.split_word_bound_indices() + .find(|(ix, word)| *ix <= offset && offset < ix + word.len()) + .map(|(ix, word)| ix..ix + word.len()) + .unwrap_or(offset..offset) +} + +pub(crate) fn line_start(text: &str, offset: usize) -> usize { + text[..offset].rfind('\n').map_or(0, |ix| ix + 1) +} + +pub(crate) fn line_end(text: &str, offset: usize) -> usize { + text[offset..] + .find('\n') + .map_or(text.len(), |ix| offset + ix) +} + +pub(crate) fn to_utf16(text: &str, offset: usize) -> usize { + text[..offset].chars().map(char::len_utf16).sum() +} + +pub(crate) fn from_utf16(text: &str, offset: usize) -> usize { + let mut units = 0; + for (ix, ch) in text.char_indices() { + if units >= offset { + return ix; + } + units += ch.len_utf16(); + } + text.len() +} + +/// Display offset of a content offset when each grapheme shows as `bullet`. +pub(crate) fn masked_offset(text: &str, offset: usize, bullet: char) -> usize { + text[..offset].graphemes(true).count() * bullet.len_utf8() +} + +/// Content offset of a display offset in masked text. +pub(crate) fn unmasked_offset(text: &str, display: usize, bullet: char) -> usize { + let count = display / bullet.len_utf8(); + text.grapheme_indices(true) + .nth(count) + .map_or(text.len(), |(ix, _)| ix) +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Snapshot { + pub text: String, + pub selection: Range, +} + +/// Typing within this pause joins one undo step. +const JOIN: Duration = Duration::from_millis(900); + +/// Undo and redo as whole-text snapshots. +#[derive(Default)] +pub(crate) struct History { + undo: Vec, + redo: Vec, + typing_since: Option, +} + +impl History { + /// Records the state before an edit. Typing in one burst makes one step. + pub fn record(&mut self, before: Snapshot, typing: bool) { + let joins = typing && self.typing_since.is_some_and(|at| at.elapsed() < JOIN); + if !joins { + self.undo.push(before); + } + self.typing_since = typing.then(Instant::now); + self.redo.clear(); + } + + pub fn undo(&mut self, current: Snapshot) -> Option { + let previous = self.undo.pop()?; + self.redo.push(current); + self.typing_since = None; + Some(previous) + } + + pub fn redo(&mut self, current: Snapshot) -> Option { + let next = self.redo.pop()?; + self.undo.push(current); + self.typing_since = None; + Some(next) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn graphemes_keep_clusters_whole() { + let text = "ae\u{301}🇯🇵z"; + assert_eq!(next_grapheme(text, 0), 1); + assert_eq!(next_grapheme(text, 1), 4); + assert_eq!(next_grapheme(text, 4), 12); + assert_eq!(prev_grapheme(text, 12), 4); + assert_eq!(prev_grapheme(text, 0), 0); + } + + #[test] + fn words_skip_spaces_and_punctuation_stops() { + let text = "hello brave, new"; + assert_eq!(next_word(text, 0), 5); + assert_eq!(next_word(text, 5), 12); + assert_eq!(prev_word(text, 17), 14); + assert_eq!(prev_word(text, 14), 12); + assert_eq!(word_at(text, 8), 7..12); + } + + #[test] + fn lines_and_utf16_round_trip() { + let text = "one\ntwo😀\nthree"; + assert_eq!(line_start(text, 6), 4); + assert_eq!(line_end(text, 6), 11); + let emoji_end = 11; + assert_eq!(to_utf16(text, emoji_end), 9); + assert_eq!(from_utf16(text, 9), emoji_end); + } + + #[test] + fn masking_maps_graphemes_to_bullets() { + let text = "pa😀s"; + assert_eq!(masked_offset(text, 6, '•'), 9); + assert_eq!(unmasked_offset(text, 9, '•'), 6); + assert_eq!(unmasked_offset(text, 12, '•'), text.len()); + } + + #[test] + fn typing_joins_one_step_and_redo_clears_on_edit() { + let snap = |text: &str| Snapshot { + text: text.into(), + selection: text.len()..text.len(), + }; + let mut history = History::default(); + history.record(snap(""), true); + history.record(snap("a"), true); + history.record(snap("ab"), false); + assert_eq!(history.undo(snap("")).unwrap(), snap("ab")); + assert_eq!(history.undo(snap("ab")).unwrap(), snap("")); + assert!(history.undo(snap("")).is_none()); + assert_eq!(history.redo(snap("")).unwrap(), snap("ab")); + history.record(snap("ab"), false); + assert!(history.redo(snap("abc")).is_none()); + } +} diff --git a/src/forms/text/element.rs b/src/forms/text/element.rs new file mode 100644 index 0000000..6098f99 --- /dev/null +++ b/src/forms/text/element.rs @@ -0,0 +1,410 @@ +use gpui::{ + App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Element, ElementId, + ElementInputHandler, Entity, GlobalElementId, Hsla, InspectorElementId, IntoElement, LayoutId, + MouseMoveEvent, Pixels, Point, SharedString, Style, TextAlign, TextRun, UnderlineStyle, Window, + WrappedLine, fill, point, relative, size, +}; + +use super::{Layout, TextInput}; +use crate::theme::ActiveTheme; + +pub(crate) struct TextElement { + input: Entity, +} + +impl TextElement { + pub fn new(input: Entity) -> Self { + Self { input } + } +} + +impl IntoElement for TextElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +pub(crate) struct Prepaint { + lines: Vec<(usize, WrappedLine)>, + selections: Vec>, + caret: Option>, + selection_color: Hsla, + caret_color: Hsla, +} + +/// What to show, and whether it is the placeholder. +fn shown(input: &TextInput) -> (String, bool) { + if input.is_empty() { + (input.placeholder_text().to_string(), true) + } else { + (input.display_text(), false) + } +} + +fn plain_run(len: usize, window: &Window, color: Hsla) -> TextRun { + TextRun { + len, + font: window.text_style().font(), + color, + background_color: None, + underline: None, + strikethrough: None, + } +} + +/// Splits `len` bytes into runs: highlights and the marked range over a base color. +fn runs( + len: usize, + base: TextRun, + spans: Vec<(std::ops::Range, Hsla, Option, bool)>, + thickness: Pixels, +) -> Vec { + let mut cuts: Vec = vec![0, len]; + for (range, ..) in &spans { + cuts.extend([range.start.min(len), range.end.min(len)]); + } + cuts.sort_unstable(); + cuts.dedup(); + cuts.windows(2) + .map(|pair| { + let (start, end) = (pair[0], pair[1]); + let mut run = TextRun { + len: end - start, + ..base.clone() + }; + for (range, color, background, underline) in &spans { + if range.start <= start && end <= range.end { + run.color = *color; + if background.is_some() { + run.background_color = *background; + } + if *underline { + run.underline = Some(UnderlineStyle { + color: Some(*color), + thickness, + wavy: false, + }); + } + } + } + run + }) + .filter(|run| run.len > 0) + .collect() +} + +/// Starts of the hard lines in `text`. +fn line_starts(text: &str) -> Vec { + std::iter::once(0) + .chain(text.match_indices('\n').map(|(ix, _)| ix + 1)) + .collect() +} + +impl Element for TextElement { + type RequestLayoutState = (); + type PrepaintState = Prepaint; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, ()) { + let input = self.input.read(cx); + let line_height = window.line_height(); + let mut style = Style::default(); + style.size.width = relative(1.).into(); + let Some((min, max)) = input.rows() else { + style.size.height = line_height.into(); + return (window.request_layout(style, [], cx), ()); + }; + let (text, _) = shown(input); + let font_size = window.text_style().font_size.to_pixels(window.rem_size()); + let run = plain_run(text.len(), window, Hsla::default()); + let layout = window.request_measured_layout(style, move |known, available, window, _| { + let width = known.width.or(match available.width { + AvailableSpace::Definite(width) => Some(width), + _ => None, + }); + let lines = window + .text_system() + .shape_text( + SharedString::from(text.clone()), + font_size, + std::slice::from_ref(&run), + width, + None, + ) + .expect("text input shaping failed"); + let rows: usize = lines + .iter() + .map(|line| line.wrap_boundaries().len() + 1) + .sum(); + size( + width.unwrap_or_default(), + line_height * rows.clamp(min, max) as f32, + ) + }); + (layout, ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut (), + window: &mut Window, + cx: &mut App, + ) -> Prepaint { + let theme = cx.theme(); + let (selection_color, caret_color, subtle) = ( + theme.colors.selection, + theme.colors.focus, + theme.colors.fg_subtle, + ); + let caret_width = theme.caret_width().to_pixels(window.rem_size()); + let thickness = theme.underline_thickness(); + let input = self.input.read(cx); + let (text, placeholder) = shown(input); + let base = plain_run( + text.len(), + window, + if placeholder { + subtle + } else { + window.text_style().color + }, + ); + let mut spans = Vec::new(); + if !placeholder { + for (range, highlight) in input.highlights(cx) { + spans.push((range, highlight.color, highlight.background, false)); + } + if let Some(marked) = input.marked() { + let color = window.text_style().color; + let marked = input.display_offset(marked.start)..input.display_offset(marked.end); + spans.push((marked, color, None, true)); + } + } + let font_size = window.text_style().font_size.to_pixels(window.rem_size()); + let line_height = window.line_height(); + let wrap = input.rows().map(|_| bounds.size.width); + let shaped = window + .text_system() + .shape_text( + text.clone().into(), + font_size, + &runs(text.len(), base, spans, thickness), + wrap, + None, + ) + .expect("text input shaping failed"); + let lines: Vec<(usize, WrappedLine)> = line_starts(&text).into_iter().zip(shaped).collect(); + let focused = input.focus().is_focused(window); + let caret_on = input.caret_on(); + self.input.update(cx, |input, _| { + input.layout = Some(Layout { + lines: lines.clone(), + bounds, + line_height, + placeholder, + }); + }); + let input = self.input.read(cx); + let caret_at = input + .position_for(input.display_offset(input.cursor())) + .expect("caret offset lies inside the laid-out text"); + let scroll = keep_in_view(input, &lines, caret_at, caret_width, bounds, line_height); + let selection = input.selection(); + let selections = if placeholder || selection.is_empty() { + Vec::new() + } else { + let (start, end) = ( + input.display_offset(selection.start), + input.display_offset(selection.end), + ); + selection_rects(&lines, start, end, line_height, bounds.size.width) + .into_iter() + .map(|rect| Bounds::new(rect.origin + bounds.origin - scroll, rect.size)) + .collect() + }; + let caret = (focused && caret_on && selection.is_empty()).then(|| { + Bounds::new( + bounds.origin + caret_at - scroll, + size(caret_width, line_height), + ) + }); + self.input.update(cx, |input, _| input.scroll = scroll); + Prepaint { + lines, + selections, + caret, + selection_color, + caret_color, + } + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut (), + prepaint: &mut Prepaint, + window: &mut Window, + cx: &mut App, + ) { + let input = self.input.read(cx); + let (focus, scroll, selecting) = (input.focus().clone(), input.scroll, input.selecting()); + window.handle_input( + &focus, + ElementInputHandler::new(bounds, self.input.clone()), + cx, + ); + let line_height = window.line_height(); + window.with_content_mask(Some(ContentMask { bounds }), |window| { + for rect in prepaint.selections.drain(..) { + window.paint_quad(fill(rect, prepaint.selection_color)); + } + let mut top = Pixels::ZERO; + for (_, line) in &prepaint.lines { + line.paint( + bounds.origin - scroll + point(Pixels::ZERO, top), + line_height, + TextAlign::Left, + None, + window, + cx, + ) + .expect("text input paint failed"); + top += line.size(line_height).height; + } + if let Some(caret) = prepaint.caret.take() { + window.paint_quad(fill(caret, prepaint.caret_color)); + } + }); + if selecting { + let input = self.input.clone(); + window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| { + if phase == DispatchPhase::Bubble && event.dragging() { + input.update(cx, |input, cx| { + let at = input.offset_for_point(event.position); + input.select_to(at, cx); + }); + } + }); + } + } +} + +/// Scroll that keeps the caret inside the box, clamped to the text. +fn keep_in_view( + input: &TextInput, + lines: &[(usize, WrappedLine)], + caret: Point, + caret_width: Pixels, + bounds: Bounds, + line_height: Pixels, +) -> Point { + let mut scroll = input.scroll; + if input.is_multi_line() { + let content = lines.iter().fold(Pixels::ZERO, |sum, (_, line)| { + sum + line.size(line_height).height + }); + let view = bounds.size.height; + if caret.y < scroll.y { + scroll.y = caret.y; + } else if caret.y + line_height > scroll.y + view { + scroll.y = caret.y + line_height - view; + } + scroll.y = scroll + .y + .clamp(Pixels::ZERO, (content - view).max(Pixels::ZERO)); + scroll.x = Pixels::ZERO; + } else { + let content = lines.first().map_or(Pixels::ZERO, |(_, line)| line.width()) + caret_width; + let view = bounds.size.width; + if caret.x < scroll.x { + scroll.x = caret.x; + } else if caret.x + caret_width > scroll.x + view { + scroll.x = caret.x + caret_width - view; + } + scroll.x = scroll + .x + .clamp(Pixels::ZERO, (content - view).max(Pixels::ZERO)); + scroll.y = Pixels::ZERO; + } + scroll +} + +/// Boxes covering display offsets `start..end`, relative to the text origin. +fn selection_rects( + lines: &[(usize, WrappedLine)], + start: usize, + end: usize, + line_height: Pixels, + width: Pixels, +) -> Vec> { + let mut rects = Vec::new(); + let mut top = Pixels::ZERO; + for (line_start, line) in lines { + let line_end = line_start + line.len(); + let (from, to) = (start.max(*line_start), end.min(line_end)); + if from <= to && start <= line_end && end >= *line_start { + let at = |ix: usize| { + line.position_for_index(ix - line_start, line_height) + .unwrap_or_default() + }; + let (a, b) = (at(from), at(to)); + let edge = if end > line_end { width } else { b.x }; + if a.y == b.y { + rects.push(Bounds::from_corners( + point(a.x, top + a.y), + point(edge.max(a.x), top + a.y + line_height), + )); + } else { + rects.push(Bounds::from_corners( + point(a.x, top + a.y), + point(width, top + a.y + line_height), + )); + let mut y = a.y + line_height; + while y < b.y { + rects.push(Bounds::from_corners( + point(Pixels::ZERO, top + y), + point(width, top + y + line_height), + )); + y += line_height; + } + rects.push(Bounds::from_corners( + point(Pixels::ZERO, top + b.y), + point(edge, top + b.y + line_height), + )); + } + } + top += line.size(line_height).height; + } + rects +} + +#[cfg(test)] +mod tests { + use super::line_starts; + + #[test] + fn line_starts_follow_newlines() { + assert_eq!(line_starts("ab\ncd\n"), vec![0, 3, 6]); + assert_eq!(line_starts(""), vec![0]); + } +} diff --git a/src/forms/text/ime.rs b/src/forms/text/ime.rs new file mode 100644 index 0000000..dcd5dcf --- /dev/null +++ b/src/forms/text/ime.rs @@ -0,0 +1,139 @@ +use std::ops::Range; + +use gpui::{Bounds, Context, EntityInputHandler, Pixels, Point, UTF16Selection, Window}; + +use super::{ + TextInput, + edit::{from_utf16, to_utf16}, +}; + +impl TextInput { + fn range_from_utf16(&self, range: &Range) -> Range { + from_utf16(&self.text, range.start)..from_utf16(&self.text, range.end) + } + + fn range_to_utf16(&self, range: &Range) -> Range { + to_utf16(&self.text, range.start)..to_utf16(&self.text, range.end) + } +} + +impl EntityInputHandler for TextInput { + fn text_for_range( + &mut self, + range_utf16: Range, + actual_range: &mut Option>, + _: &mut Window, + _: &mut Context, + ) -> Option { + let range = self.range_from_utf16(&range_utf16); + actual_range.replace(self.range_to_utf16(&range)); + Some(self.text[range].to_string()) + } + + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut Context, + ) -> Option { + Some(UTF16Selection { + range: self.range_to_utf16(&self.selection), + reversed: self.reversed, + }) + } + + fn marked_text_range(&self, _: &mut Window, _: &mut Context) -> Option> { + self.marked.as_ref().map(|range| self.range_to_utf16(range)) + } + + fn unmark_text(&mut self, _: &mut Window, cx: &mut Context) { + let Some(marked) = self.marked.clone() else { + return; + }; + let composed = self.text[marked.clone()].to_string(); + self.replace(marked, &composed, true, cx); + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + text: &str, + _: &mut Window, + cx: &mut Context, + ) { + log::debug!( + "text input {:?}: replace {range_utf16:?} marked {:?} selection {:?} in {} bytes", + cx.entity_id(), + self.marked, + self.selection, + self.text.len() + ); + let range = range_utf16 + .map(|range| self.range_from_utf16(&range)) + .or(self.marked.clone()) + .unwrap_or(self.selection.clone()); + self.replace(range, text, true, cx); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + text: &str, + selected_utf16: Option>, + _: &mut Window, + cx: &mut Context, + ) { + if self.disabled { + return; + } + let range = range_utf16 + .map(|range| self.range_from_utf16(&range)) + .or(self.marked.clone()) + .unwrap_or(self.selection.clone()); + if self.marked.is_none() { + self.composing = Some(self.snapshot()); + } + self.text.replace_range(range.clone(), text); + self.marked = (!text.is_empty()).then(|| range.start..range.start + text.len()); + if self.marked.is_none() { + self.composing = None; + } + self.selection = match selected_utf16 { + Some(inner) => { + let inner = from_utf16(text, inner.start)..from_utf16(text, inner.end); + range.start + inner.start..range.start + inner.end + } + None => range.start + text.len()..range.start + text.len(), + }; + self.reversed = false; + cx.notify(); + } + + fn bounds_for_range( + &mut self, + range_utf16: Range, + bounds: Bounds, + _: &mut Window, + _: &mut Context, + ) -> Option> { + let range = self.range_from_utf16(&range_utf16); + let line_height = self.layout.as_ref()?.line_height; + let start = self.position_for(self.display_offset(range.start))?; + let end = self.position_for(self.display_offset(range.end))?; + let origin = bounds.origin - self.scroll; + Some(Bounds::from_corners( + origin + start, + origin + gpui::point(end.x, end.y + line_height), + )) + } + + fn character_index_for_point( + &mut self, + point: Point, + _: &mut Window, + _: &mut Context, + ) -> Option { + self.layout.as_ref()?; + Some(to_utf16(&self.text, self.offset_for_point(point))) + } +} diff --git a/src/forms/text/mod.rs b/src/forms/text/mod.rs new file mode 100644 index 0000000..ba59dfe --- /dev/null +++ b/src/forms/text/mod.rs @@ -0,0 +1,496 @@ +mod actions; +mod edit; +mod element; +mod ime; + +use std::{ops::Range, rc::Rc, time::Duration}; + +use gpui::{ + App, Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, Pixels, Point, SharedString, + Subscription, Task, Window, WrappedLine, +}; + +pub(crate) use actions::{Backspace, Down, Enter, Up, bind_keys}; +use edit::{History, Snapshot}; + +use crate::theme::ActiveTheme; + +/// What a text input tells its owner. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InputEvent { + Changed, + /// Enter in one line, or the submit key in several. + Submit, + Focus, + Blur, +} + +/// Colors a span of the text: foreground, and an optional wash behind it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Highlight { + pub color: Hsla, + pub background: Option, +} + +type Highlighter = Rc Vec<(Range, Highlight)>>; +type Filter = Rc bool>; +pub(crate) type Fit = Rc, &str) -> (String, usize)>; + +/// The last shaped text: one entry per hard line, with its display offset. +pub(crate) struct Layout { + pub lines: Vec<(usize, WrappedLine)>, + pub bounds: Bounds, + pub line_height: Pixels, + /// The lines show the placeholder, not content. + pub placeholder: bool, +} + +const BULLET: char = '•'; +const BLINK: Duration = Duration::from_millis(530); + +/// Editable text: one line or many, with selection, undo, IME and clipboard. +pub struct TextInput { + focus: FocusHandle, + text: String, + placeholder: SharedString, + selection: Range, + reversed: bool, + marked: Option>, + composing: Option, + rows: Option<(usize, usize)>, + masked: bool, + filter: Option, + fit: Option, + max_len: Option, + highlighter: Option, + disabled: bool, + history: History, + pub(crate) layout: Option, + pub(crate) scroll: Point, + selecting: bool, + caret_on: bool, + blink_epoch: u64, + _blink: Option>, + _subscriptions: Vec, +} + +impl EventEmitter for TextInput {} + +impl Focusable for TextInput { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus.clone() + } +} + +impl TextInput { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let focus = cx.focus_handle().tab_stop(true); + let subscriptions = vec![ + cx.on_focus(&focus, window, |input, _, cx| { + input.restart_blink(cx); + cx.emit(InputEvent::Focus); + }), + cx.on_blur(&focus, window, |input, _, cx| { + input.selecting = false; + input._blink = None; + cx.emit(InputEvent::Blur); + cx.notify(); + }), + ]; + Self { + focus, + text: String::new(), + placeholder: SharedString::default(), + selection: 0..0, + reversed: false, + marked: None, + composing: None, + rows: None, + masked: false, + filter: None, + fit: None, + max_len: None, + highlighter: None, + disabled: false, + history: History::default(), + layout: None, + scroll: Point::default(), + selecting: false, + caret_on: true, + blink_epoch: 0, + _blink: None, + _subscriptions: subscriptions, + } + } + + /// Several lines: grows from `min` rows to `max`, then scrolls. + pub fn multi_line(mut self, min: usize, max: usize) -> Self { + assert!(0 < min && min <= max, "text rows {min}..={max} are empty"); + self.rows = Some((min, max)); + self + } + + pub fn placeholder(mut self, text: impl Into) -> Self { + self.placeholder = text.into(); + self + } + + /// Shows bullets instead of the text, and keeps it off the clipboard. + pub fn masked(mut self) -> Self { + self.masked = true; + self + } + + /// Drops typed or pasted characters `allow` rejects. + pub fn filter(mut self, allow: impl Fn(char) -> bool + 'static) -> Self { + self.filter = Some(Rc::new(allow)); + self + } + + /// Longest text, in characters. + pub fn max_len(mut self, len: usize) -> Self { + self.max_len = Some(len); + self + } + + /// Colors spans of the text as it changes. + pub fn highlighter( + mut self, + highlight: impl Fn(&str, &App) -> Vec<(Range, Highlight)> + 'static, + ) -> Self { + self.highlighter = Some(Rc::new(highlight)); + self + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + pub fn is_masked(&self) -> bool { + self.masked + } + + pub fn is_multi_line(&self) -> bool { + self.rows.is_some() + } + + pub fn is_disabled(&self) -> bool { + self.disabled + } + + pub fn rows(&self) -> Option<(usize, usize)> { + self.rows + } + + pub fn placeholder_text(&self) -> &SharedString { + &self.placeholder + } + + pub fn selection(&self) -> Range { + self.selection.clone() + } + + pub fn cursor(&self) -> usize { + if self.reversed { + self.selection.start + } else { + self.selection.end + } + } + + pub fn set_placeholder(&mut self, text: impl Into, cx: &mut Context) { + self.placeholder = text.into(); + cx.notify(); + } + + pub fn set_masked(&mut self, masked: bool, cx: &mut Context) { + self.masked = masked; + cx.notify(); + } + + pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context) { + self.disabled = disabled; + self.focus = self.focus.clone().tab_stop(!disabled); + cx.notify(); + } + + /// Replaces all text and puts the caret at its end. Not an undo step. + pub fn set_text(&mut self, text: impl Into, cx: &mut Context) { + let text = self.admit(&text.into(), 0); + let text = match &self.fit { + Some(fit) => fit(&self.text, 0..self.text.len(), &text).0, + None => text, + }; + self.selection = text.len()..text.len(); + self.text = text; + self.marked = None; + self.composing = None; + cx.emit(InputEvent::Changed); + cx.notify(); + } + + /// Reshapes every edit, as a mask does: text, replaced range and typed text in; text and caret out. + pub(crate) fn set_fit(&mut self, fit: Fit) { + self.fit = Some(fit); + } + + /// Replaces the selection, as typing would. An undo step. + pub fn insert(&mut self, text: &str, cx: &mut Context) { + self.replace(self.selection.clone(), text, false, cx); + } + + pub fn select(&mut self, range: Range, cx: &mut Context) { + assert!( + range.start <= range.end && range.end <= self.text.len(), + "selection {range:?} is outside {} bytes", + self.text.len() + ); + self.selection = range; + self.reversed = false; + self.restart_blink(cx); + } + + /// The caret's box in window coordinates, once laid out. + pub fn caret_bounds(&self) -> Option> { + self.bounds_for(self.cursor()) + } + + /// An offset's box in the window, from the last layout; later offsets clamp to its end. + pub fn bounds_for(&self, offset: usize) -> Option> { + let layout = self.layout.as_ref()?; + let laid = layout + .lines + .last() + .map_or(0, |(start, line)| start + line.len()); + let at = self.position_for(self.display_offset(offset).min(laid))?; + Some(Bounds::new( + layout.bounds.origin + at - self.scroll, + gpui::size(Pixels::ZERO, layout.line_height), + )) + } + + /// Keeps what `filter` and `max_len` allow of `incoming`. + fn admit(&self, incoming: &str, keep: usize) -> String { + let allowed: String = incoming + .chars() + .filter(|ch| self.filter.as_ref().is_none_or(|allow| allow(*ch))) + .collect(); + match self.max_len { + Some(max) => allowed.chars().take(max.saturating_sub(keep)).collect(), + None => allowed, + } + } + + pub(crate) fn replace( + &mut self, + range: Range, + incoming: &str, + typing: bool, + cx: &mut Context, + ) { + if self.disabled { + return; + } + let incoming = if self.rows.is_none() { + incoming.replace(['\n', '\r'], " ") + } else { + incoming.replace("\r\n", "\n") + }; + let kept = self.text.chars().count() - self.text[range.clone()].chars().count(); + let incoming = self.admit(&incoming, kept); + if incoming.is_empty() && range.is_empty() { + return; + } + let composed = self.composing.take(); + let before = composed.clone().unwrap_or_else(|| self.snapshot()); + let caret = match &self.fit { + Some(fit) => { + let (base, range) = match &composed { + Some(start) => (start.text.as_str(), start.selection.clone()), + None => (self.text.as_str(), range), + }; + let (text, caret) = fit(base, range, &incoming); + self.text = text; + caret + } + None => { + self.text.replace_range(range.clone(), &incoming); + range.start + incoming.len() + } + }; + self.selection = caret..caret; + self.reversed = false; + self.marked = None; + self.commit(before, typing, cx); + self.restart_blink(cx); + } + + /// Makes `before` one undo step, if the text moved on from it. + fn commit(&mut self, before: Snapshot, typing: bool, cx: &mut Context) { + if before.text != self.text { + self.history.record(before, typing); + cx.emit(InputEvent::Changed); + } + } + + fn snapshot(&self) -> Snapshot { + Snapshot { + text: self.text.clone(), + selection: self.selection.clone(), + } + } + + pub(crate) fn restore(&mut self, snapshot: Snapshot, cx: &mut Context) { + self.text = snapshot.text; + self.selection = snapshot.selection; + self.reversed = false; + self.marked = None; + self.composing = None; + cx.emit(InputEvent::Changed); + self.restart_blink(cx); + } + + pub(crate) fn move_to(&mut self, offset: usize, cx: &mut Context) { + self.selection = offset..offset; + self.reversed = false; + self.restart_blink(cx); + } + + pub(crate) fn select_to(&mut self, offset: usize, cx: &mut Context) { + if self.reversed { + self.selection.start = offset; + } else { + self.selection.end = offset; + } + if self.selection.end < self.selection.start { + self.reversed = !self.reversed; + self.selection = self.selection.end..self.selection.start; + } + self.restart_blink(cx); + } + + /// Shows the caret now and restarts its blink. + fn restart_blink(&mut self, cx: &mut Context) { + self.caret_on = true; + self.blink_epoch += 1; + let epoch = self.blink_epoch; + let still = cx.theme().reduced_motion; + self._blink = (!still).then(|| { + cx.spawn(async move |input, cx| { + loop { + cx.background_executor().timer(BLINK).await; + let alive = input.update(cx, |input, cx| { + if input.blink_epoch == epoch { + input.caret_on = !input.caret_on; + cx.notify(); + } + }); + if alive.is_err() { + return; + } + } + }) + }); + cx.notify(); + } + + /// What the element shows: bullets when masked. + pub(crate) fn display_text(&self) -> String { + if self.masked { + BULLET.to_string().repeat(self.grapheme_count()) + } else { + self.text.clone() + } + } + + fn grapheme_count(&self) -> usize { + unicode_segmentation::UnicodeSegmentation::graphemes(self.text.as_str(), true).count() + } + + pub(crate) fn display_offset(&self, offset: usize) -> usize { + if self.masked { + edit::masked_offset(&self.text, offset, BULLET) + } else { + offset + } + } + + pub(crate) fn content_offset(&self, display: usize) -> usize { + if self.masked { + edit::unmasked_offset(&self.text, display, BULLET) + } else { + display + } + } + + /// Top-left of a display offset, relative to the text's origin. + pub(crate) fn position_for(&self, display: usize) -> Option> { + let layout = self.layout.as_ref()?; + if layout.placeholder { + return Some(Point::default()); + } + let mut top = Pixels::ZERO; + for (start, line) in &layout.lines { + if display <= start + line.len() { + let at = line.position_for_index(display - start, layout.line_height)?; + return Some(gpui::point(at.x, at.y + top)); + } + top += line.size(layout.line_height).height; + } + None + } + + /// The content offset nearest a window position. + pub(crate) fn offset_for_point(&self, point: Point) -> usize { + let Some(layout) = self.layout.as_ref() else { + return 0; + }; + if layout.placeholder { + return 0; + } + let local = point - layout.bounds.origin + self.scroll; + if local.y < Pixels::ZERO { + return 0; + } + let mut top = Pixels::ZERO; + for (start, line) in &layout.lines { + let height = line.size(layout.line_height).height; + if local.y < top + height { + let inside = gpui::point(local.x.max(Pixels::ZERO), local.y - top); + let index = match line.closest_index_for_position(inside, layout.line_height) { + Ok(index) | Err(index) => index, + }; + return self.content_offset(start + index.min(line.len())); + } + top += height; + } + self.text.len() + } + + pub(crate) fn highlights(&self, cx: &App) -> Vec<(Range, Highlight)> { + match (&self.highlighter, self.masked) { + (Some(highlight), false) => highlight(&self.text, cx), + _ => Vec::new(), + } + } + + pub(crate) fn marked(&self) -> Option> { + self.marked.clone() + } + + pub(crate) fn selecting(&self) -> bool { + self.selecting + } + + pub(crate) fn caret_on(&self) -> bool { + self.caret_on + } + + pub(crate) fn focus(&self) -> &FocusHandle { + &self.focus + } +} diff --git a/src/forms/unit.rs b/src/forms/unit.rs new file mode 100644 index 0000000..1340161 --- /dev/null +++ b/src/forms/unit.rs @@ -0,0 +1,93 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, +}; + +use super::NumberInput; +use crate::theme::{ActiveTheme, Radius}; + +type OnUnit = Rc; + +/// A number with a unit you click through, such as px, % and em. +#[derive(IntoElement)] +pub struct UnitInput { + field: NumberInput, + units: Vec, + unit: SharedString, + on_unit: Option, +} + +impl UnitInput { + pub fn new( + id: impl Into, + value: f64, + units: impl IntoIterator>, + unit: impl Into, + ) -> Self { + let units: Vec = units.into_iter().map(Into::into).collect(); + let unit = unit.into(); + assert!(units.contains(&unit), "unit {unit} is not among {units:?}"); + Self { + field: NumberInput::new(id, value), + units, + unit, + on_unit: None, + } + } + + pub fn range(mut self, min: f64, max: f64) -> Self { + self.field = self.field.range(min, max); + self + } + + pub fn precision(mut self, places: usize) -> Self { + self.field = self.field.precision(places); + self + } + + pub fn on_change(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self { + self.field = self.field.on_change(handler); + self + } + + /// Runs with the next unit when the unit is clicked. + pub fn on_unit( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_unit = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for UnitInput { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let position = self + .units + .iter() + .position(|unit| *unit == self.unit) + .expect("checked at construction"); + let next = self.units[(position + 1) % self.units.len()].clone(); + let on_unit = self.on_unit; + let hover = theme.colors.hover; + let unit = div() + .id((self.field.id.clone(), "unit")) + .px_1p5() + .rounded(theme.radius(Radius::Sm)) + .text_color(theme.colors.fg_muted) + .cursor_pointer() + .hover(|style| style.bg(hover)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| { + log::info!("unit input: {next}"); + if let Some(on_unit) = &on_unit { + on_unit(&next, window, cx); + } + }) + .child(self.unit); + self.field.suffix(unit) + } +} diff --git a/src/lib.rs b/src/lib.rs index cf4450b..f5d088b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ mod assets; pub mod buttons; +pub mod forms; pub mod layout; pub mod motion; pub mod primitives; @@ -14,7 +15,7 @@ use gpui::{App, KeyBinding}; use crate::primitives::{FocusNext, FocusPrev, IconName}; -/// Loads fonts and the theme, binds Tab. Call once, first. +/// Loads fonts and the theme, binds Tab and text keys. Call once, first. pub fn init(cx: &mut App) { match cx.asset_source().load(IconName::Check.path()) { Ok(Some(_)) => {} @@ -27,4 +28,5 @@ pub fn init(cx: &mut App) { KeyBinding::new("tab", FocusNext, None), KeyBinding::new("shift-tab", FocusPrev, None), ]); + forms::bind_keys(cx); } diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index 61b69c6..a7959e7 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -153,6 +153,15 @@ impl Theme { point(px(14.0), px(13.0)) } + /// Underline under composing text. + pub fn underline_thickness(&self) -> Pixels { + px(1.0) + } + + pub fn caret_width(&self) -> Rems { + px_to_rems(1.5) + } + /// Diameter of a floating action button. pub fn fab_size(&self) -> Rems { px_to_rems(56.0) diff --git a/src/typography/format.rs b/src/typography/format.rs index 4b06299..b03ac28 100644 --- a/src/typography/format.rs +++ b/src/typography/format.rs @@ -50,9 +50,9 @@ pub fn group_digits(digits: &str, group: char) -> String { 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 { +/// ISO 4217 code to symbol, minor units, and whether a space follows. +pub fn currency_parts(code: &str) -> (&str, usize, bool) { + match code { "USD" => ("$", 2, false), "EUR" => ("€", 2, false), "GBP" => ("£", 2, false), @@ -61,7 +61,12 @@ pub fn currency(amount: f64, code: &str) -> String { "KRW" => ("₩", 0, false), "INR" => ("₹", 2, false), _ => (code, 2, true), - }; + } +} + +/// Unknown codes print as a prefix. +pub fn currency(amount: f64, code: &str) -> String { + let (symbol, decimals, spaced) = currency_parts(code); 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 { diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index 73f391b..be28c98 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -168,33 +168,33 @@ ## T06a Forms · Text — `src/forms` -- [ ] Input / TextField -- [ ] TextArea -- [ ] PasswordInput -- [ ] SearchInput -- [ ] NumberInput -- [ ] ScrubInput -- [ ] CurrencyInput -- [ ] PercentInput -- [ ] UnitInput -- [ ] EmailInput -- [ ] UrlInput -- [ ] PhoneInput -- [ ] MaskedInput -- [ ] PinInput / OTPInput -- [ ] InlineEdit / EditableText -- [ ] InputGroup / InputAddon -- [ ] ClearableInput → `forms::Input` -- [ ] AutoResizeInput → `forms::Input` -- [ ] MentionInput -- [ ] HashtagInput -- [ ] TagInput / TokenInput -- [ ] KeyValueInput -- [ ] ListInput -- [ ] PathInput — native dialog via `prompt_for_paths` -- [ ] RegexInput -- [ ] ExpressionInput -- [ ] HotkeyInput / ShortcutRecorder +- [x] Input / TextField +- [x] TextArea → `forms::TextInput::multi_line` +- [x] PasswordInput +- [x] SearchInput +- [x] NumberInput +- [x] ScrubInput +- [x] CurrencyInput → `forms::NumberInput::currency` +- [x] PercentInput → `forms::NumberInput::percent` +- [x] UnitInput +- [x] EmailInput +- [x] UrlInput +- [x] PhoneInput +- [x] MaskedInput +- [x] PinInput / OTPInput +- [x] InlineEdit / EditableText +- [x] InputGroup / InputAddon +- [x] ClearableInput → `forms::Input::clearable` +- [x] AutoResizeInput → `forms::TextInput::multi_line` +- [x] MentionInput +- [x] HashtagInput → `forms::MentionInput` with `#` +- [x] TagInput / TokenInput +- [x] KeyValueInput +- [x] ListInput +- [x] PathInput — native dialog via `prompt_for_paths` +- [x] RegexInput +- [x] ExpressionInput +- [x] HotkeyInput / ShortcutRecorder ## T06b Forms · Selection