T06a forms text: text input core, fields, numbers, masks, pin, inline, groups, tags, pairs, path, regex, expression, hotkey, mention

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 05:38:24 -04:00
1 parent 817da41c22
commit ec5713c10b
41 files changed
+6233 -45

No files matched your search

+9 -1
View File
@@ -14,7 +14,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- `cargo run --example gallery` opens the gallery. `-- --page <slug>` starts on a page.
- `cargo run --example gallery -- --capture <dir>` 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 <lucide-name>...` 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.
Generated
+93 -1
View File
@@ -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"
+1
View File
@@ -11,6 +11,7 @@ publish = false
[features]
default = ["runtime_shaders"]
runtime_shaders = ["gpui/runtime_shaders"]
test-support = ["gpui/test-support"]
[dependencies]
anyhow = "1"
+4 -1
View File
@@ -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 |
+125
View File
@@ -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();
})
})),
))
}
+137
View File
@@ -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()
}
+133
View File
@@ -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<f64> {
window.use_keyed_state(key, cx, move |_, _| initial)
}
fn store(state: Entity<f64>) -> 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,
))
}
+174
View File
@@ -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.",
))
}
+166
View File
@@ -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<TextInput> {
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(&notes)),
))
.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,
)),
)
}
+2
View File
@@ -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,
];
+53 -6
View File
@@ -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<Gallery>,
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<()> {
+2 -2
View File
@@ -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)
+1 -2
View File
@@ -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);
+148
View File
@@ -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<Regex> =
LazyLock::new(|| Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").expect("email pattern compiles"));
static URL: LazyLock<Regex> = 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<TextInput>,
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<TextInput>,
size: ControlSize,
}
impl EmailInput {
pub fn new(state: &Entity<TextInput>) -> 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<TextInput>,
size: ControlSize,
}
impl UrlInput {
pub fn new(state: &Entity<TextInput>) -> 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"));
}
}
+359
View File
@@ -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<Vec<(Range<usize>, 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::<f64>()
.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<f64, String> {
if value.is_finite() {
Ok(value)
} else {
Err(format!("{step} has no finite value"))
}
}
struct Parser<'a> {
tokens: Vec<Token>,
at: usize,
variables: &'a [(SharedString, f64)],
}
impl Parser<'_> {
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.at)
}
fn next(&mut self) -> Option<Token> {
let token = self.tokens.get(self.at).cloned();
self.at += 1;
token
}
fn sum(&mut self) -> Result<f64, String> {
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<f64, String> {
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<f64, String> {
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<f64, String> {
if self.peek() == Some(&Token::Op('-')) {
self.next();
return Ok(-self.unary()?);
}
self.primary()
}
fn primary(&mut self) -> Result<f64, String> {
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<f64, String> {
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<f64, String> {
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<usize>, 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<TextInput>,
variables: Vec<(SharedString, f64)>,
}
impl ExpressionInput {
/// Give the state `expression_highlights` as its highlighter.
pub fn new(state: &Entity<TextInput>) -> Self {
Self {
state: state.clone(),
variables: Vec::new(),
}
}
pub fn variable(mut self, name: impl Into<SharedString>, 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())
);
}
}
+118
View File
@@ -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<SharedString>) -> 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<TextInput>,
before: Vec<InputAddon>,
after: Vec<InputAddon>,
size: ControlSize,
}
impl InputGroup {
pub fn new(state: &Entity<TextInput>) -> 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)))
}
}
+111
View File
@@ -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<dyn Fn(Option<Keystroke>, &mut Window, &mut App)>;
/// Records one shortcut, then lets go. Backspace clears; Escape leaves.
#[derive(IntoElement)]
pub struct HotkeyInput {
id: ElementId,
value: Option<Keystroke>,
on_change: Option<OnChange>,
}
impl HotkeyInput {
pub fn new(id: impl Into<ElementId>, value: Option<Keystroke>) -> Self {
Self {
id: id.into(),
value,
on_change: None,
}
}
pub fn on_change(
mut self,
handler: impl Fn(Option<Keystroke>, &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),
)
}
}
+153
View File
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
#[derive(Default)]
struct Editing {
field: Option<(Entity<TextInput>, Subscription)>,
on_commit: Option<OnCommit>,
}
impl Editing {
/// Ends editing; keeps the text unless `cancel`.
fn finish(&mut self, cancel: bool, window: &mut Window, cx: &mut Context<Self>) {
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<OnCommit>,
}
impl InlineEdit {
pub fn new(id: impl Into<ElementId>, value: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
value: value.into(),
placeholder: "Empty".into(),
on_commit: None,
}
}
pub fn placeholder(mut self, text: impl Into<SharedString>) -> 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()
}
}
+192
View File
@@ -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<TextInput>,
size: ControlSize,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
clearable: bool,
invalid: bool,
}
impl Input {
pub fn new(state: &Entity<TextInput>) -> 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<TextInput>,
size: ControlSize,
}
impl PasswordInput {
/// Build the state with `TextInput::masked`.
pub fn new(state: &Entity<TextInput>) -> 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);
})
}),
)
}
}
+360
View File
@@ -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<usize>, 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<TextInput>,
mask: &SharedString,
window: &mut Window,
cx: &mut App,
) {
let applied = window.use_keyed_state(id.clone(), cx, |_, _| None::<SharedString>);
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<TextInput>,
mask: SharedString,
size: ControlSize,
prefix: Option<AnyElement>,
}
impl MaskedInput {
pub fn new(
id: impl Into<ElementId>,
state: &Entity<TextInput>,
mask: impl Into<SharedString>,
) -> 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<dyn Fn(&'static str, &mut Window, &mut App)>;
/// A phone number: a country picker for the code, then its number pattern.
#[derive(IntoElement)]
pub struct PhoneInput {
id: ElementId,
state: Entity<TextInput>,
country: &'static str,
on_country: Option<OnCountry>,
}
impl PhoneInput {
/// `country` is a code from `COUNTRIES`.
pub fn new(id: impl Into<ElementId>, state: &Entity<TextInput>, 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<Pixels>,
}
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<Suggestion> = 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)
);
}
}
+279
View File
@@ -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<usize>, active: Option<usize>) -> Option<usize> {
dismissed.filter(|at| active == Some(*at))
}
/// Spans of `@name` and `#tag` for a highlighter.
pub(crate) fn mention_spans(text: &str) -> Vec<Range<usize>> {
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<usize>, 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<usize>,
}
/// A field where `@` or `#` opens suggestions at the caret. Arrows choose; Enter picks.
#[derive(IntoElement)]
pub struct MentionInput {
id: ElementId,
state: Entity<TextInput>,
triggers: Vec<(char, Vec<SharedString>)>,
}
impl MentionInput {
/// Give the state `mention_highlights` as its highlighter.
pub fn new(id: impl Into<ElementId>, state: &Entity<TextInput>) -> 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<Item = impl Into<SharedString>>,
) -> Self {
let handles: Vec<SharedString> = 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<char> = 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<SharedString> = 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<Suggestion> = 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::<std::ops::Range<usize>>::new()
);
}
}
+39
View File
@@ -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;
+482
View File
@@ -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<dyn Fn(f64, &mut Window, &mut App)>;
type Nudge = Box<dyn Fn(&mut Window, &mut App)>;
/// Reads a typed number: groups and the typographic minus are allowed.
pub(crate) fn parse(text: &str) -> Option<f64> {
let plain: String = text
.chars()
.filter(|ch| *ch != ',')
.map(|ch| if ch == format::MINUS { '-' } else { ch })
.collect();
plain
.trim()
.parse::<f64>()
.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<TextInput>,
on_change: Option<OnChange>,
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<TextInput>,
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<Numeric>, 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<Numeric> {
window.use_keyed_state(id.clone(), cx, |window, cx: &mut Context<Numeric>| {
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<Cell<Option<(Pixels, f64)>>>,
}
/// Wraps `face` so dragging it sideways moves the number, one step per notch.
fn scrub_handle(
id: ElementId,
face: AnyElement,
state: Entity<Numeric>,
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<Scrub>, 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<SharedString>,
suffix: Option<AnyElement>,
size: ControlSize,
on_change: Option<OnChange>,
scrub_label: Option<SharedString>,
}
impl NumberInput {
pub fn new(id: impl Into<ElementId>, 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<SharedString>) -> 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<ElementId>, label: impl Into<SharedString>, 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);
}
}
+247
View File
@@ -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<dyn Fn(Vec<Vec<String>>, &mut Window, &mut App)>;
/// Rows of fields: `columns` wide, each row removable, more rows on demand.
struct Rows {
columns: usize,
rows: Vec<Vec<(Entity<TextInput>, Subscription)>>,
placeholders: Vec<SharedString>,
on_change: Option<OnRows>,
}
impl Rows {
fn values(&self, cx: &App) -> Vec<Vec<String>> {
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<Self>) {
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<Self>) {
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<String>],
placeholders: &[SharedString],
window: &mut Window,
cx: &mut App,
) -> Entity<Rows> {
let (seed, placeholders) = (seed.to_vec(), placeholders.to_vec());
window.use_keyed_state(id.clone(), cx, move |window, cx: &mut Context<Rows>| {
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<Rows>,
add: &'static str,
cx: &App,
) -> impl IntoElement + use<> {
let rows = state.read(cx).rows.len();
let lines = (0..rows).map(|ix| {
let fields: Vec<Entity<TextInput>> = 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<Vec<String>>,
on_change: Option<OnRows>,
}
impl KeyValueInput {
/// `seed` fills the rows on first render.
pub fn new(id: impl Into<ElementId>, seed: impl IntoIterator<Item = (String, String)>) -> 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<Vec<String>>, 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<Vec<String>>,
placeholder: SharedString,
on_change: Option<OnRows>,
}
impl ListInput {
pub fn new(id: impl Into<ElementId>, seed: impl IntoIterator<Item = String>) -> 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<SharedString>) -> Self {
self.placeholder = text.into();
self
}
pub fn on_change(
mut self,
handler: impl Fn(Vec<String>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(
move |rows: Vec<Vec<String>>, 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)
}
}
+93
View File
@@ -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<TextInput>,
directories: bool,
}
impl PathInput {
pub fn new(id: impl Into<ElementId>, state: &Entity<TextInput>) -> 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();
}),
)
}
}
+191
View File
@@ -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<usize>, 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<usize>, 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<TextInput>,
size: ControlSize,
}
impl RegexInput {
/// Give the state `regex_highlights` as its highlighter.
pub fn new(state: &Entity<TextInput>) -> 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<Part> = 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);
}
}
+150
View File
@@ -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<dyn Fn(&str, &mut Window, &mut App)>;
struct Pin {
input: Entity<TextInput>,
length: usize,
on_complete: Option<OnCode>,
_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<OnCode>,
}
impl PinInput {
pub fn new(id: impl Into<ElementId>, 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<Pin>| {
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<char> = 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))
}
}
+120
View File
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A search field: a magnifier, a clear button, recent searches while empty.
#[derive(IntoElement)]
pub struct SearchInput {
id: ElementId,
state: Entity<TextInput>,
history: Vec<SharedString>,
size: ControlSize,
on_pick: Option<OnPick>,
}
impl SearchInput {
pub fn new(id: impl Into<ElementId>, state: &Entity<TextInput>) -> 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<Item = impl Into<SharedString>>) -> 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::<Pixels>::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<Suggestion> = 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,
))
})
}
}
+79
View File
@@ -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<SharedString>,
}
type Pick = Rc<dyn Fn(usize, &mut gpui::Window, &mut App)>;
pub(crate) type Dismiss = Rc<dyn Fn(&mut gpui::Window, &mut App)>;
/// A list floating under `anchor`. Rows keep focus where it was.
pub(crate) fn suggestion_list(
id: impl Into<ElementId>,
anchor: Bounds<Pixels>,
rows: &[Suggestion],
highlighted: usize,
pick: Pick,
dismiss: Option<Dismiss>,
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()
}
+192
View File
@@ -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<dyn Fn(Vec<SharedString>, &mut Window, &mut App)>;
struct Tags {
input: Entity<TextInput>,
tags: Vec<SharedString>,
on_change: Option<OnChange>,
_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<Self>) {
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<SharedString>,
placeholder: SharedString,
on_change: Option<OnChange>,
}
impl TagInput {
pub fn new(
id: impl Into<ElementId>,
tags: impl IntoIterator<Item = impl Into<SharedString>>,
) -> 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<SharedString>) -> Self {
self.placeholder = text.into();
self
}
pub fn on_change(
mut self,
handler: impl Fn(Vec<SharedString>, &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<Tags>| {
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),
)
}
}
+254
View File
@@ -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<Entity<TextInput>>,
clearable: bool,
}
impl Render for Fields {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> 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<Entity<TextInput>>, &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<Entity<TextInput>>, &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<TextInput>, cx: &mut VisualTestContext) -> String {
field.read_with(cx, |input, _| input.text().to_string())
}
fn focus(field: &Entity<TextInput>, 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<Self>) -> 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<Self>) -> 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<TextInput>,
mask: &'static str,
}
impl Render for Masked {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
MaskedInput::new("masked", &self.state, self.mask)
}
}
fn masked<'a>(
mask: &'static str,
cx: &'a mut TestAppContext,
) -> (Entity<TextInput>, &'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");
}
+391
View File
@@ -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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
let to = self.vertical(false);
self.move_to(to, cx);
}
fn down(&mut self, _: &Down, _: &mut Window, cx: &mut Context<Self>) {
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>) {
self.select_to(prev_grapheme(&self.text, self.cursor()), cx);
}
fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(next_grapheme(&self.text, self.cursor()), cx);
}
fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
let to = self.vertical(false);
self.select_to(to, cx);
}
fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
let to = self.vertical(true);
self.select_to(to, cx);
}
fn word_left(&mut self, _: &WordLeft, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(edit::prev_word(&self.text, self.cursor()), cx);
}
fn word_right(&mut self, _: &WordRight, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(edit::next_word(&self.text, self.cursor()), cx);
}
fn select_word_left(&mut self, _: &SelectWordLeft, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(edit::prev_word(&self.text, self.cursor()), cx);
}
fn select_word_right(&mut self, _: &SelectWordRight, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(edit::next_word(&self.text, self.cursor()), cx);
}
fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(edit::line_start(&self.text, self.cursor()), cx);
}
fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(edit::line_end(&self.text, self.cursor()), cx);
}
fn select_home(&mut self, _: &SelectHome, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(edit::line_start(&self.text, self.cursor()), cx);
}
fn select_end(&mut self, _: &SelectEnd, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(edit::line_end(&self.text, self.cursor()), cx);
}
fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
self.selection = 0..self.text.len();
self.reversed = false;
cx.notify();
}
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
log::info!("text input: submitted");
cx.emit(InputEvent::Submit);
}
fn show_character_palette(
&mut self,
_: &ShowCharacterPalette,
window: &mut Window,
_: &mut Context<Self>,
) {
window.show_character_palette();
}
fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
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>) {
self.selecting = false;
}
}
impl Render for TextInput {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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()))
}
}
+189
View File
@@ -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<Item = (usize, &str)> {
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<usize> {
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<usize>,
}
/// 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<Snapshot>,
redo: Vec<Snapshot>,
typing_since: Option<Instant>,
}
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<Snapshot> {
let previous = self.undo.pop()?;
self.redo.push(current);
self.typing_since = None;
Some(previous)
}
pub fn redo(&mut self, current: Snapshot) -> Option<Snapshot> {
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());
}
}
+410
View File
@@ -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<TextInput>,
}
impl TextElement {
pub fn new(input: Entity<TextInput>) -> 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<Bounds<Pixels>>,
caret: Option<Bounds<Pixels>>,
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<usize>, Hsla, Option<Hsla>, bool)>,
thickness: Pixels,
) -> Vec<TextRun> {
let mut cuts: Vec<usize> = 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<usize> {
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<ElementId> {
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<Pixels>,
_: &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<Pixels>,
_: &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<Pixels>,
caret_width: Pixels,
bounds: Bounds<Pixels>,
line_height: Pixels,
) -> Point<Pixels> {
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<Bounds<Pixels>> {
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]);
}
}
+139
View File
@@ -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<usize>) -> Range<usize> {
from_utf16(&self.text, range.start)..from_utf16(&self.text, range.end)
}
fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
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<usize>,
actual_range: &mut Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<String> {
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<Self>,
) -> Option<UTF16Selection> {
Some(UTF16Selection {
range: self.range_to_utf16(&self.selection),
reversed: self.reversed,
})
}
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
self.marked.as_ref().map(|range| self.range_to_utf16(range))
}
fn unmark_text(&mut self, _: &mut Window, cx: &mut Context<Self>) {
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<Range<usize>>,
text: &str,
_: &mut Window,
cx: &mut Context<Self>,
) {
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<Range<usize>>,
text: &str,
selected_utf16: Option<Range<usize>>,
_: &mut Window,
cx: &mut Context<Self>,
) {
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<usize>,
bounds: Bounds<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
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<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<usize> {
self.layout.as_ref()?;
Some(to_utf16(&self.text, self.offset_for_point(point)))
}
}
+496
View File
@@ -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<Hsla>,
}
type Highlighter = Rc<dyn Fn(&str, &App) -> Vec<(Range<usize>, Highlight)>>;
type Filter = Rc<dyn Fn(char) -> bool>;
pub(crate) type Fit = Rc<dyn Fn(&str, Range<usize>, &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<Pixels>,
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<usize>,
reversed: bool,
marked: Option<Range<usize>>,
composing: Option<Snapshot>,
rows: Option<(usize, usize)>,
masked: bool,
filter: Option<Filter>,
fit: Option<Fit>,
max_len: Option<usize>,
highlighter: Option<Highlighter>,
disabled: bool,
history: History,
pub(crate) layout: Option<Layout>,
pub(crate) scroll: Point<Pixels>,
selecting: bool,
caret_on: bool,
blink_epoch: u64,
_blink: Option<Task<()>>,
_subscriptions: Vec<Subscription>,
}
impl EventEmitter<InputEvent> 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>) -> 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<SharedString>) -> 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<usize>, 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<usize> {
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<SharedString>, cx: &mut Context<Self>) {
self.placeholder = text.into();
cx.notify();
}
pub fn set_masked(&mut self, masked: bool, cx: &mut Context<Self>) {
self.masked = masked;
cx.notify();
}
pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
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<String>, cx: &mut Context<Self>) {
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>) {
self.replace(self.selection.clone(), text, false, cx);
}
pub fn select(&mut self, range: Range<usize>, cx: &mut Context<Self>) {
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<Bounds<Pixels>> {
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<Bounds<Pixels>> {
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<usize>,
incoming: &str,
typing: bool,
cx: &mut Context<Self>,
) {
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<Self>) {
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>) {
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>) {
self.selection = offset..offset;
self.reversed = false;
self.restart_blink(cx);
}
pub(crate) fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
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>) {
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<Point<Pixels>> {
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<Pixels>) -> 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<usize>, Highlight)> {
match (&self.highlighter, self.masked) {
(Some(highlight), false) => highlight(&self.text, cx),
_ => Vec::new(),
}
}
pub(crate) fn marked(&self) -> Option<Range<usize>> {
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
}
}
+93
View File
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A number with a unit you click through, such as px, % and em.
#[derive(IntoElement)]
pub struct UnitInput {
field: NumberInput,
units: Vec<SharedString>,
unit: SharedString,
on_unit: Option<OnUnit>,
}
impl UnitInput {
pub fn new(
id: impl Into<ElementId>,
value: f64,
units: impl IntoIterator<Item = impl Into<SharedString>>,
unit: impl Into<SharedString>,
) -> Self {
let units: Vec<SharedString> = 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)
}
}
+3 -1
View File
@@ -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);
}
+9
View File
@@ -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)
+9 -4
View File
@@ -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 {
+27 -27
View File
@@ -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