From e39d623b5825471ba80de8fb0d0b8df8def895a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 06:41:35 -0400 Subject: [PATCH] T06b forms selection: checkbox, radio, cards, switch, select, combobox, multi select, cascader, transfer list, list box, chips, rating, sliders, knob, stepper --- AGENTS.md | 2 + TASKS.md | 5 +- examples/gallery/pages/forms/choices.rs | 214 ++++++++++++ examples/gallery/pages/forms/mod.rs | 85 +++++ examples/gallery/pages/forms/pickers.rs | 235 +++++++++++++ examples/gallery/pages/forms/values.rs | 176 ++++++++++ src/forms/card.rs | 243 +++++++++++++ src/forms/cascade.rs | 430 +++++++++++++++++++++++ src/forms/check.rs | 303 ++++++++++++++++ src/forms/chips.rs | 121 +++++++ src/forms/combobox.rs | 286 ++++++++++++++++ src/forms/knob.rs | 196 +++++++++++ src/forms/listbox.rs | 193 +++++++++++ src/forms/masked.rs | 45 ++- src/forms/mention.rs | 34 +- src/forms/mod.rs | 33 +- src/forms/multi.rs | 232 +++++++++++++ src/forms/options.rs | 255 ++++++++++++++ src/forms/radio.rs | 258 ++++++++++++++ src/forms/rating.rs | 189 ++++++++++ src/forms/search.rs | 31 +- src/forms/select.rs | 283 +++++++++++++++ src/forms/slider.rs | 438 ++++++++++++++++++++++++ src/forms/stepper.rs | 109 ++++++ src/forms/suggest.rs | 79 ----- src/forms/switch.rs | 144 ++++++++ src/forms/tags.rs | 90 +++-- src/forms/tests/choices.rs | 313 +++++++++++++++++ src/forms/{tests.rs => tests/mod.rs} | 4 +- src/forms/transfer.rs | 218 ++++++++++++ src/motion/changes.rs | 26 ++ src/motion/mod.rs | 2 + src/primitives/icon.rs | 17 +- src/theme/tokens.rs | 33 ++ tasks/ch01-10.md | 44 +-- tasks/ch11-20.md | 2 +- 36 files changed, 5170 insertions(+), 198 deletions(-) create mode 100644 examples/gallery/pages/forms/choices.rs create mode 100644 examples/gallery/pages/forms/pickers.rs create mode 100644 examples/gallery/pages/forms/values.rs create mode 100644 src/forms/card.rs create mode 100644 src/forms/cascade.rs create mode 100644 src/forms/check.rs create mode 100644 src/forms/chips.rs create mode 100644 src/forms/combobox.rs create mode 100644 src/forms/knob.rs create mode 100644 src/forms/listbox.rs create mode 100644 src/forms/multi.rs create mode 100644 src/forms/options.rs create mode 100644 src/forms/radio.rs create mode 100644 src/forms/rating.rs create mode 100644 src/forms/select.rs create mode 100644 src/forms/slider.rs create mode 100644 src/forms/stepper.rs delete mode 100644 src/forms/suggest.rs create mode 100644 src/forms/switch.rs create mode 100644 src/forms/tests/choices.rs rename src/forms/{tests.rs => tests/mod.rs} (99%) create mode 100644 src/forms/transfer.rs create mode 100644 src/motion/changes.rs diff --git a/AGENTS.md b/AGENTS.md index cb6b94a..16067e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - 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. +- Floating lists go below their anchor, or above when only above has room (`forms::options::float`). gpui's own switch keeps the anchor point, so a flipped list would cover its trigger. +- Motion for a value change keys its animation on `motion::changes`, so it replays per change and stays still on first paint. - Masks reshape edits inside `TextInput::set_fit`, which sees the replaced range and the typed text. A diff after the fact cannot tell typed characters from kept ones. - Ids inside a reusable component carry its owner's id or `EntityId`. Twin ids share focus and click state. - Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders. diff --git a/TASKS.md b/TASKS.md index f37d206..b782ce9 100644 --- a/TASKS.md +++ b/TASKS.md @@ -23,7 +23,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T04 Window & Shell - [x] T05 Buttons & Actions - [x] T06a Forms · Text -- [ ] T06b Forms · Selection +- [x] T06b Forms · Selection - [ ] T06c Forms · Date & Time - [ ] T06d Forms · Color - [ ] T06e Forms · Files @@ -122,3 +122,6 @@ Per-component lines live in `tasks/`. Tags there: | 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 | +| T06b | 1 | FAIL | kept highlight and cascade trail went out of bounds; keyboard click reclosed MultiSelect and Cascader; disabled rows committed by keyboard; Combobox hid its owner's value; a disabled chosen radio left no Tab stop; a disabled Rating took arrows | +| T06b | 2 | FAIL | a free Combobox cleared the text it mounted with | +| T06b | 3 | PASS | | diff --git a/examples/gallery/pages/forms/choices.rs b/examples/gallery/pages/forms/choices.rs new file mode 100644 index 0000000..89d7bb1 --- /dev/null +++ b/examples/gallery/pages/forms/choices.rs @@ -0,0 +1,214 @@ +use ely_gpui_component::{ + forms::{ + CheckState, Checkbox, CheckboxCard, CheckboxGroup, Choice, RadioCard, RadioGroup, Switch, + }, + primitives::IconName, +}; +use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{code, section, specimen}, +}; + +pub(super) fn keep( + key: &'static str, + init: impl FnOnce() -> T, + window: &mut Window, + cx: &mut App, +) -> Entity { + window.use_keyed_state(key, cx, move |_, _| init()) +} + +pub(super) fn set(state: &Entity, value: T, cx: &mut App) { + state.update(cx, |state, cx| { + *state = value; + cx.notify(); + }); +} + +fn values(list: &[&'static str]) -> Vec { + list.iter() + .map(|value| SharedString::from(*value)) + .collect() +} + +pub fn checkboxes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let channels = [ + Choice::new("email", "Email"), + Choice::new("push", "Push"), + Choice::new("sms", "Text message").disabled(), + ]; + let open: Vec = channels + .iter() + .filter(|choice| !choice.disabled) + .map(|choice| choice.value.clone()) + .collect(); + let on = keep("check-channels", || values(&["email"]), window, cx); + let now = on.read(cx).clone(); + let parent = match now.len() { + 0 => CheckState::Off, + n if n == open.len() => CheckState::On, + _ => CheckState::Mixed, + }; + let (all, some) = (on.clone(), on.clone()); + section( + "Checkbox / CheckboxGroup", + "On, off, or mixed when a parent's children disagree. The check pops in.", + cx, + ) + .child(probe( + "checks", + div() + .flex() + .flex_col() + .gap_2() + .w(px(280.0)) + .child( + Checkbox::new("check-all", parent) + .label("All notifications") + .on_change(move |on, _, cx| { + set(&all, if on { open.clone() } else { Vec::new() }, cx) + }), + ) + .child( + div().pl_6().child( + CheckboxGroup::new("check-group", channels) + .selected(now) + .on_change(move |next, _, cx| set(&some, next.to_vec(), cx)), + ), + ), + )) + .child(code("Checkbox::new(id, CheckState::Mixed)", cx)) +} + +pub fn radios(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let plan = keep("radio-plan", || SharedString::from("pro"), window, cx); + let density = keep( + "radio-density", + || SharedString::from("standard"), + window, + cx, + ); + let (plan_now, density_now) = (plan.read(cx).clone(), density.read(cx).clone()); + section( + "Radio / RadioGroup", + "One Tab stop per group; the arrow keys move the choice.", + cx, + ) + .child( + div() + .flex() + .flex_col() + .gap_6() + .child(specimen( + "plan", + probe( + "radios", + RadioGroup::new( + "radio-plan", + [ + Choice::new("free", "Free"), + Choice::new("pro", "Pro"), + Choice::new("team", "Team"), + Choice::new("enterprise", "Enterprise").disabled(), + ], + ) + .selected(plan_now) + .on_change(move |value, _, cx| set(&plan, value.clone(), cx)), + ), + cx, + )) + .child(specimen( + "density, in a row", + RadioGroup::new( + "radio-density", + [ + Choice::new("compact", "Compact"), + Choice::new("standard", "Standard"), + Choice::new("comfortable", "Comfortable"), + ], + ) + .horizontal() + .selected(density_now) + .on_change(move |value, _, cx| set(&density, value.clone(), cx)), + cx, + )), + ) +} + +pub fn cards(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let space = keep("card-space", || SharedString::from("personal"), window, cx); + let backup = keep("card-backup", || true, window, cx); + let (space_now, backup_now) = (space.read(cx).clone(), *backup.read(cx)); + let (personal, team) = (space.clone(), space); + section( + "CheckboxCard / RadioCard", + "Larger targets that explain a choice. The chosen border turns to ink.", + cx, + ) + .child(probe( + "cards", + div() + .flex() + .gap_3() + .w(px(640.0)) + .child( + div().flex_1().child( + RadioCard::new("card-personal", space_now == "personal", "Personal") + .description("Just you. Notes stay on this Mac.") + .icon(IconName::User) + .on_select(move |_, cx| set(&personal, "personal".into(), cx)), + ), + ) + .child( + div().flex_1().child( + RadioCard::new("card-team", space_now == "team", "Team") + .description("Shared with everyone you invite.") + .icon(IconName::Users) + .on_select(move |_, cx| set(&team, "team".into(), cx)), + ), + ), + )) + .child( + div().w(px(640.0)).child( + CheckboxCard::new("card-backup", backup_now, "Back up nightly") + .description("Copies change sets to the cloud at 2 AM.") + .icon(IconName::Cloud) + .on_change(move |on, _, cx| set(&backup, on, cx)), + ), + ) +} + +pub fn switches(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let wifi = keep("switch-wifi", || true, window, cx); + let sync = keep("switch-sync", || false, window, cx); + let (wifi_now, sync_now) = (*wifi.read(cx), *sync.read(cx)); + section( + "Switch / Toggle", + "Takes effect at once. The thumb slides with a little overshoot.", + cx, + ) + .child(probe( + "switches", + div() + .flex() + .flex_col() + .gap_3() + .child( + Switch::new("switch-wifi", wifi_now) + .label("Wi-Fi") + .on_change(move |on, _, cx| set(&wifi, on, cx)), + ) + .child( + Switch::new("switch-sync", sync_now) + .label("Sync over cellular") + .on_change(move |on, _, cx| set(&sync, on, cx)), + ) + .child( + Switch::new("switch-locked", true) + .label("Managed by your admin") + .disabled(true), + ), + )) +} diff --git a/examples/gallery/pages/forms/mod.rs b/examples/gallery/pages/forms/mod.rs index fe01ae0..d47005e 100644 --- a/examples/gallery/pages/forms/mod.rs +++ b/examples/gallery/pages/forms/mod.rs @@ -1,7 +1,10 @@ +mod choices; mod formats; mod numbers; +mod pickers; mod rich; mod text; +mod values; use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; @@ -109,6 +112,74 @@ const SCRIPT: &[Step] = &[ Step::Wait(1200), Step::CancelPanel, Step::Wait(600), + Step::DownAt("checks", 40.0, 66.0), + Step::UpAt("checks", 40.0, 66.0), + Step::Wait(300), + Step::Shot("checks"), + Step::DownAt("radios", 30.0, 66.0), + Step::UpAt("radios", 30.0, 66.0), + Step::Wait(400), + Step::Shot("radios"), + Step::DownAt("cards", 480.0, 30.0), + Step::UpAt("cards", 480.0, 30.0), + Step::Wait(400), + Step::Shot("cards"), + Step::DownAt("switches", 16.0, 39.0), + Step::UpAt("switches", 16.0, 39.0), + Step::Wait(600), + Step::Shot("switches"), + Step::Click("select"), + Step::Wait(300), + Step::Key("down"), + Step::Wait(200), + Step::Shot("select-open"), + Step::Key("enter"), + Step::Wait(300), + Step::Click("combobox"), + Step::Key("secondary-a"), + Step::Type("lis"), + Step::Wait(300), + Step::Shot("combobox"), + Step::Key("enter"), + Step::Wait(200), + Step::Click("multi"), + Step::Wait(300), + Step::Key("down"), + Step::Key("enter"), + Step::Wait(300), + Step::Shot("multi"), + Step::Key("escape"), + Step::Click("cascader"), + Step::Wait(300), + Step::Key("down"), + Step::Key("right"), + Step::Wait(300), + Step::Shot("cascader"), + Step::Key("escape"), + Step::DownAt("transfer", 60.0, 45.0), + Step::UpAt("transfer", 60.0, 45.0), + Step::Wait(200), + Step::DownAt("transfer", 280.0, 134.0), + Step::UpAt("transfer", 280.0, 134.0), + Step::Wait(300), + Step::Shot("transfer"), + Step::DownAt("chips", 120.0, 14.0), + Step::UpAt("chips", 120.0, 14.0), + Step::DownAt("rating", 79.0, 13.0), + Step::UpAt("rating", 79.0, 13.0), + Step::Wait(300), + Step::Shot("chips-rating"), + Step::DownAt("slider", 130.0, 8.0), + Step::DragTo("slider", 220.0, 8.0), + Step::UpAt("slider", 220.0, 8.0), + Step::Wait(200), + Step::DownAt("knob", 24.0, 24.0), + Step::DragTo("knob", 24.0, -36.0), + Step::UpAt("knob", 24.0, -36.0), + Step::DownAt("stepper", 76.0, 16.0), + Step::UpAt("stepper", 76.0, 16.0), + Step::Wait(300), + Step::Shot("values"), Step::Rest, ]; @@ -133,5 +204,19 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(rich::regex(window, cx)) .child(rich::expression(window, cx)) .child(rich::hotkey(window, cx)) + .child(choices::checkboxes(window, cx)) + .child(choices::radios(window, cx)) + .child(choices::cards(window, cx)) + .child(choices::switches(window, cx)) + .child(pickers::select(window, cx)) + .child(pickers::list_box(window, cx)) + .child(pickers::combobox(window, cx)) + .child(pickers::multi_select(window, cx)) + .child(pickers::cascader(window, cx)) + .child(pickers::transfer(window, cx)) + .child(values::chips(window, cx)) + .child(values::rating(window, cx)) + .child(values::sliders(window, cx)) + .child(values::dials(window, cx)) .into_any_element() } diff --git a/examples/gallery/pages/forms/pickers.rs b/examples/gallery/pages/forms/pickers.rs new file mode 100644 index 0000000..e90a0d7 --- /dev/null +++ b/examples/gallery/pages/forms/pickers.rs @@ -0,0 +1,235 @@ +use ely_gpui_component::{ + forms::{Cascade, Cascader, Choice, Combobox, ListBox, MultiSelect, Select, TransferList}, + primitives::IconName, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use super::{ + choices::{keep, set}, + text::field, +}; +use crate::{ + probe::probe, + ui::{blocked, section, specimen, specimens}, +}; + +fn cities() -> Vec { + [ + ("ams", "Amsterdam"), + ("ber", "Berlin"), + ("cph", "Copenhagen"), + ("lis", "Lisbon"), + ("lon", "London"), + ("osl", "Oslo"), + ("par", "Paris"), + ("vie", "Vienna"), + ] + .into_iter() + .map(|(value, label)| Choice::new(value, label)) + .collect() +} + +pub fn select(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let view = keep("select-view", || SharedString::from("list"), window, cx); + let now = view.read(cx).clone(); + section( + "Select / NativeSelect", + "A button that opens its list. Arrows move, Enter picks, Escape closes.", + cx, + ) + .child( + specimens() + .child(specimen( + "view", + probe( + "select", + div().w(px(240.0)).child( + Select::new( + "select-view", + [ + Choice::new("list", "List").icon(IconName::List), + Choice::new("board", "Board").icon(IconName::Kanban), + Choice::new("calendar", "Calendar").icon(IconName::Calendar), + Choice::new("gallery", "Gallery") + .icon(IconName::LayoutGrid) + .disabled(), + ], + ) + .selected(now) + .on_change(move |value, _, cx| set(&view, value.clone(), cx)), + ), + ), + cx, + )) + .child(specimen( + "disabled", + div().w(px(200.0)).child( + Select::new("select-off", [Choice::new("utc", "UTC")]) + .selected("utc") + .disabled(true), + ), + cx, + )), + ) + .child(blocked( + "NativeSelect: gpui 0.2.2 has no native popup menu, so Select draws its own.", + cx, + )) +} + +pub fn list_box(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let picked = keep( + "listbox-cities", + || vec![SharedString::from("lis")], + window, + cx, + ); + let now = picked.read(cx).clone(); + section( + "ListBox", + "A list chosen from in place. Focus it and use the arrows, Space or Enter.", + cx, + ) + .child( + div().w(px(240.0)).child( + ListBox::new("listbox-cities", cities()) + .selected(now) + .on_change(move |next, _, cx| set(&picked, next.to_vec(), cx)), + ), + ) +} + +pub fn combobox(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let city = field("input-city", window, cx, |input| { + input.placeholder("Search cities") + }); + let tag = field("input-tag", window, cx, |input| { + input.placeholder("Any label") + }); + let chosen = keep("combobox-city", || None::, window, cx); + let now = chosen.read(cx).clone(); + let labels: Vec = ["design", "docs", "infra", "motion", "release"] + .into_iter() + .map(|label| Choice::new(label, label)) + .collect(); + let mut combo = Combobox::new("combobox-city", &city, cities()) + .on_change(move |value, _, cx| set(&chosen, Some(value.clone()), cx)); + if let Some(value) = now { + combo = combo.selected(value); + } + section( + "Combobox / Autocomplete", + "Type to filter. A combobox keeps a listed value; an autocomplete keeps any text.", + cx, + ) + .child( + specimens() + .child(specimen( + "combobox", + probe("combobox", div().w(px(260.0)).child(combo)), + cx, + )) + .child(specimen( + "autocomplete", + div() + .w(px(260.0)) + .child(Combobox::new("combobox-tag", &tag, labels).free()), + cx, + )), + ) +} + +pub fn multi_select(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let picked = keep( + "multi-cities", + || vec![SharedString::from("ber"), SharedString::from("osl")], + window, + cx, + ); + let now = picked.read(cx).clone(); + section( + "MultiSelect", + "Several values as chips. The list stays open while you pick; Backspace drops the last.", + cx, + ) + .child(probe( + "multi", + div().w(px(360.0)).child( + MultiSelect::new("multi-cities", cities()) + .selected(now) + .placeholder("Pick cities") + .on_change(move |next, _, cx| set(&picked, next.to_vec(), cx)), + ), + )) +} + +pub fn cascader(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let path = keep( + "cascade-office", + || { + vec![ + SharedString::from("eu"), + SharedString::from("pt"), + SharedString::from("lis"), + ] + }, + window, + cx, + ); + let now = path.read(cx).clone(); + let world = [ + Cascade::new("am", "Americas") + .child( + Cascade::new("us", "United States") + .child(Cascade::new("nyc", "New York")) + .child(Cascade::new("sfo", "San Francisco")), + ) + .child(Cascade::new("br", "Brazil").child(Cascade::new("sao", "São Paulo"))), + Cascade::new("eu", "Europe") + .child( + Cascade::new("pt", "Portugal") + .child(Cascade::new("lis", "Lisbon")) + .child(Cascade::new("opo", "Porto")), + ) + .child(Cascade::new("de", "Germany").child(Cascade::new("ber", "Berlin"))), + Cascade::new("as", "Asia") + .child(Cascade::new("jp", "Japan").child(Cascade::new("tyo", "Tokyo"))), + ]; + section( + "CascadeSelect / Cascader", + "Each column opens the next. Right and Left walk the levels; a leaf picks the path.", + cx, + ) + .child(probe( + "cascader", + div().w(px(320.0)).child( + Cascader::new("cascade-office", world) + .selected(now) + .on_change(move |next, _, cx| set(&path, next.to_vec(), cx)), + ), + )) +} + +pub fn transfer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let chosen = keep( + "transfer-cities", + || vec![SharedString::from("par")], + window, + cx, + ); + let now = chosen.read(cx).clone(); + section( + "TransferList", + "Mark rows on one side, then move them across.", + cx, + ) + .child(probe( + "transfer", + div().w(px(560.0)).child( + TransferList::new("transfer-cities", cities()) + .chosen(now) + .titles("Cities", "On the tour") + .on_change(move |next, _, cx| set(&chosen, next.to_vec(), cx)), + ), + )) +} diff --git a/examples/gallery/pages/forms/values.rs b/examples/gallery/pages/forms/values.rs new file mode 100644 index 0000000..97cd216 --- /dev/null +++ b/examples/gallery/pages/forms/values.rs @@ -0,0 +1,176 @@ +use ely_gpui_component::forms::{Choice, ChoiceChips, Knob, RangeSlider, Rating, Slider, Stepper}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use super::choices::{keep, set}; +use crate::{ + probe::probe, + ui::{section, specimen, specimens}, +}; + +pub fn chips(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let sort = keep("chips-sort", || vec![SharedString::from("new")], window, cx); + let teams = keep( + "chips-teams", + || vec![SharedString::from("design")], + window, + cx, + ); + let (sort_now, teams_now) = (sort.read(cx).clone(), teams.read(cx).clone()); + section( + "ChoiceChips / FilterChips", + "Pills for a few options. Filter chips toggle and show a check.", + cx, + ) + .child( + div() + .flex() + .flex_col() + .gap_6() + .child(specimen( + "choice", + probe( + "chips", + ChoiceChips::new( + "chips-sort", + [ + Choice::new("new", "Newest"), + Choice::new("top", "Popular"), + Choice::new("old", "Oldest"), + ], + ) + .selected(sort_now) + .on_change(move |next, _, cx| set(&sort, next.to_vec(), cx)), + ), + cx, + )) + .child(specimen( + "filter", + ChoiceChips::new( + "chips-teams", + [ + Choice::new("design", "Design"), + Choice::new("eng", "Engineering"), + Choice::new("research", "Research"), + Choice::new("ops", "Operations").disabled(), + ], + ) + .multiple() + .selected(teams_now) + .on_change(move |next, _, cx| set(&teams, next.to_vec(), cx)), + cx, + )), + ) +} + +pub fn rating(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let stars = keep("rating-stars", || 3u8, window, cx); + let now = *stars.read(cx); + section( + "Rating", + "Hover previews, a click sets it, and clicking the same star clears it.", + cx, + ) + .child( + specimens() + .child(specimen( + format!("{now} of 5"), + probe( + "rating", + Rating::new("rating-stars", now) + .on_change(move |next, _, cx| set(&stars, next, cx)), + ), + cx, + )) + .child(specimen( + "read only", + Rating::new("rating-fixed", 4).disabled(true), + cx, + )), + ) +} + +pub fn sliders(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let volume = keep("slider-volume", || 40.0, window, cx); + let level = keep("slider-level", || 60.0, window, cx); + let price = keep("slider-price", || (20.0, 80.0), window, cx); + let (volume_now, level_now, (low, high)) = (*volume.read(cx), *level.read(cx), *price.read(cx)); + section( + "Slider / RangeSlider / VerticalSlider", + "Drag a thumb, click the track, or use the arrows; Page keys jump ten steps.", + cx, + ) + .child( + specimens() + .child(specimen( + format!("volume {volume_now:.0}"), + probe( + "slider", + div().w(px(320.0)).child( + Slider::new("slider-volume", volume_now) + .on_change(move |next, _, cx| set(&volume, next, cx)), + ), + ), + cx, + )) + .child(specimen( + format!("price {low:.0} to {high:.0}"), + div().w(px(320.0)).child( + RangeSlider::new("slider-price", low, high) + .step(5.0) + .on_change(move |next, _, cx| set(&price, next, cx)), + ), + cx, + )) + .child(specimen( + format!("level {level_now:.0}"), + div().h(px(140.0)).child( + Slider::new("slider-level", level_now) + .vertical() + .step(5.0) + .on_change(move |next, _, cx| set(&level, next, cx)), + ), + cx, + )), + ) +} + +pub fn dials(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let gain = keep("knob-gain", || 65.0, window, cx); + let pan = keep("knob-pan", || 0.0, window, cx); + let guests = keep("stepper-guests", || 2.0, window, cx); + let (gain_now, pan_now, guests_now) = (*gain.read(cx), *pan.read(cx), *guests.read(cx)); + section( + "Knob / Dial / Stepper", + "Drag a knob up or down, or use the arrows. The stepper counts without typing.", + cx, + ) + .child( + specimens() + .child(specimen( + format!("gain {gain_now:.0}"), + probe( + "knob", + Knob::new("knob-gain", gain_now) + .on_change(move |next, _, cx| set(&gain, next, cx)), + ), + cx, + )) + .child(specimen( + format!("pan {pan_now:.0}"), + Knob::new("knob-pan", pan_now) + .range(-50.0, 50.0) + .on_change(move |next, _, cx| set(&pan, next, cx)), + cx, + )) + .child(specimen( + "guests", + probe( + "stepper", + Stepper::new("stepper-guests", guests_now) + .range(1.0, 10.0) + .on_change(move |next, _, cx| set(&guests, next, cx)), + ), + cx, + )), + ) +} diff --git a/src/forms/card.rs b/src/forms/card.rs new file mode 100644 index 0000000..fe41d3d --- /dev/null +++ b/src/forms/card.rs @@ -0,0 +1,243 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, Div, ElementId, FocusHandle, FontWeight, InteractiveElement, IntoElement, + MouseButton, ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement, + Styled, Window, div, prelude::*, +}; + +use super::{ + CheckState, + check::{check_mark, tab_stop}, + options::{OnFlag, Run}, + radio::radio_mark, +}; +use crate::{ + motion, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize, Radius, TextSize}, +}; + +/// What a card says: a title, a line under it, and an icon at its end. +struct Face { + title: SharedString, + description: Option, + icon: Option, + disabled: bool, + on: bool, +} + +impl Face { + fn new(title: SharedString, on: bool) -> Self { + Self { + title, + description: None, + icon: None, + disabled: false, + on, + } + } +} + +/// A bordered choice: the mark, then the words. The border darkens while chosen. +fn card( + id: ElementId, + face: Face, + mark: AnyElement, + focus: &FocusHandle, + press: Run, + window: &Window, + cx: &App, +) -> Stateful
{ + let (on, focused) = (face.on, focus.is_focused(window)); + let theme = cx.theme(); + let colors = &theme.colors; + let border = if focused { + colors.focus + } else if on { + colors.accent + } else { + colors.border + }; + let strong = colors.border_strong; + div() + .id(id) + .track_focus(focus) + .flex() + .items_start() + .gap_3() + .p_4() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(border) + .bg(colors.surface) + .map(|card| { + if face.disabled { + return card.opacity(0.5); + } + card.cursor_pointer() + .when(!on && !focused, |card| { + card.hover(|style| style.border_color(strong)) + }) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| press(window, cx)) + }) + .child(div().pt_0p5().child(mark)) + .child( + div() + .flex_1() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .text_size(theme.text_size(TextSize::Base)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(face.title), + ) + .when_some(face.description, |words, description| { + words.child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(description), + ) + }), + ) + .when_some(face.icon, |card, icon| { + card.child(Icon::new(icon).size(IconSize::Md).color(colors.fg_muted)) + }) +} + +/// A card that turns on and off, with a checkbox in its corner. +#[derive(IntoElement)] +pub struct CheckboxCard { + id: ElementId, + checked: bool, + face: Face, + on_change: Option, +} + +impl CheckboxCard { + pub fn new(id: impl Into, checked: bool, title: impl Into) -> Self { + Self { + id: id.into(), + checked, + face: Face::new(title.into(), checked), + on_change: None, + } + } + + pub fn description(mut self, text: impl Into) -> Self { + self.face.description = Some(text.into()); + self + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.face.icon = Some(icon); + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.face.disabled = disabled; + self + } + + pub fn on_change(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for CheckboxCard { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focus = tab_stop( + (self.id.clone(), "focus").into(), + !self.face.disabled, + window, + cx, + ); + let changes = motion::changes((self.id.clone(), "changes"), self.checked, window, cx); + let (id, next, on_change) = (self.id.clone(), !self.checked, self.on_change); + let mark = check_mark( + CheckState::from(self.checked), + false, + self.face.disabled, + changes, + cx, + ); + let press: Run = Rc::new(move |window, cx| { + log::info!("checkbox card {id:?}: {}", if next { "on" } else { "off" }); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + }); + card(self.id, self.face, mark, &focus, press, window, cx) + } +} + +/// A card chosen from several, with a radio in its corner. +#[derive(IntoElement)] +pub struct RadioCard { + id: ElementId, + selected: bool, + face: Face, + on_select: Option, +} + +impl RadioCard { + pub fn new(id: impl Into, selected: bool, title: impl Into) -> Self { + Self { + id: id.into(), + selected, + face: Face::new(title.into(), selected), + on_select: None, + } + } + + pub fn description(mut self, text: impl Into) -> Self { + self.face.description = Some(text.into()); + self + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.face.icon = Some(icon); + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.face.disabled = disabled; + self + } + + /// Runs when chosen while not selected. + pub fn on_select(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for RadioCard { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focus = tab_stop( + (self.id.clone(), "focus").into(), + !self.face.disabled, + window, + cx, + ); + let changes = motion::changes((self.id.clone(), "changes"), self.selected, window, cx); + let (id, selected, on_select) = (self.id.clone(), self.selected, self.on_select); + let mark = radio_mark(self.selected, false, self.face.disabled, changes, cx); + let press: Run = Rc::new(move |window, cx| { + if selected { + return; + } + log::info!("radio card {id:?}: chosen"); + if let Some(on_select) = &on_select { + on_select(window, cx); + } + }); + card(self.id, self.face, mark, &focus, press, window, cx) + } +} diff --git a/src/forms/cascade.rs b/src/forms/cascade.rs new file mode 100644 index 0000000..6bdccfe --- /dev/null +++ b/src/forms/cascade.rs @@ -0,0 +1,430 @@ +use std::rc::Rc; + +use gpui::{ + App, Bounds, ClickEvent, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, + Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, + prelude::*, +}; + +use super::{ + Choice, + check::tab_stop, + input::text_size, + options::{OnValues, float, option_row, surface}, +}; +use crate::{ + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius}, +}; + +/// A node of a cascade: a value, its label, and the nodes under it. +#[derive(Clone, Debug, PartialEq)] +pub struct Cascade { + pub value: SharedString, + pub label: SharedString, + pub children: Vec, +} + +impl Cascade { + pub fn new(value: impl Into, label: impl Into) -> Self { + Self { + value: value.into(), + label: label.into(), + children: Vec::new(), + } + } + + pub fn child(mut self, node: Cascade) -> Self { + self.children.push(node); + self + } +} + +/// The columns a trail opens: the roots, then the children of each step with any. +fn columns<'a>(roots: &'a [Cascade], trail: &[usize]) -> Vec<&'a [Cascade]> { + let mut out = vec![roots]; + for &ix in trail { + let node = &out[out.len() - 1][ix]; + if node.children.is_empty() { + break; + } + out.push(&node.children); + } + out +} + +/// Row indices along `path`, as far as it matches. +fn trail_of(roots: &[Cascade], path: &[SharedString]) -> Vec { + let mut trail = Vec::new(); + let mut level = roots; + for value in path { + let Some(ix) = level.iter().position(|node| node.value == *value) else { + break; + }; + trail.push(ix); + level = &level[ix].children; + } + trail +} + +/// `trail` cut back to rows that still exist; never empty. +fn fit_trail(roots: &[Cascade], trail: &[usize]) -> Vec { + let mut fitted = Vec::new(); + let mut level = roots; + for &ix in trail { + let Some(node) = level.get(ix) else { + break; + }; + fitted.push(ix); + level = &node.children; + } + if fitted.is_empty() { + fitted.push(0); + } + fitted +} + +/// The nodes a trail passes through. +fn nodes<'a>(roots: &'a [Cascade], trail: &[usize]) -> Vec<&'a Cascade> { + let mut level = roots; + trail + .iter() + .map(|&ix| { + let node = &level[ix]; + level = &node.children; + node + }) + .collect() +} + +#[derive(Default)] +struct Trail { + open: bool, + trail: Vec, + anchor: Bounds, +} + +fn show(state: &Entity, open: bool, trail: Vec, cx: &mut App) { + state.update(cx, |state, cx| { + state.open = open; + state.trail = trail; + cx.notify(); + }); +} + +type PickTrail = Rc, &mut Window, &mut App)>; + +/// A select over a tree: each column opens the next, and a leaf picks the path. +#[derive(IntoElement)] +pub struct Cascader { + id: ElementId, + roots: Vec, + selected: Vec, + placeholder: SharedString, + size: ControlSize, + on_change: Option, +} + +impl Cascader { + pub fn new(id: impl Into, roots: impl IntoIterator) -> Self { + Self { + id: id.into(), + roots: roots.into_iter().collect(), + selected: Vec::new(), + placeholder: SharedString::from("Choose…"), + size: ControlSize::default(), + on_change: None, + } + } + + /// The chosen path, root first. + pub fn selected(mut self, path: impl IntoIterator>) -> Self { + self.selected = path.into_iter().map(Into::into).collect(); + self + } + + pub fn placeholder(mut self, text: impl Into) -> Self { + self.placeholder = text.into(); + self + } + + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } + + pub fn on_change( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Cascader { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + assert!( + !self.roots.is_empty(), + "cascader {:?} has no roots", + self.id + ); + let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); + let focused = focus.is_focused(window); + let state = window.use_keyed_state((self.id.clone(), "trail"), cx, |_, _| Trail::default()); + if state.read(cx).open && !focused { + state.update(cx, |state, _| state.open = false); + } + let roots = Rc::new(self.roots); + let start = { + let trail = trail_of(&roots, &self.selected); + if trail.is_empty() { vec![0] } else { trail } + }; + let kept = state.read(cx).trail.clone(); + let fitted = fit_trail(&roots, &kept); + if fitted != kept { + state.update(cx, |state, _| state.trail = fitted); + } + let (open, trail, anchor) = { + let state = state.read(cx); + (state.open, state.trail.clone(), state.anchor) + }; + let pick: PickTrail = { + let (id, roots, state, on_change) = ( + self.id.clone(), + roots.clone(), + state.clone(), + self.on_change, + ); + Rc::new(move |trail, window, cx| { + let path: Vec = nodes(&roots, &trail) + .iter() + .map(|node| node.value.clone()) + .collect(); + log::info!("cascader {id:?}: {path:?}"); + show(&state, false, trail, cx); + if let Some(on_change) = &on_change { + on_change(&path, window, cx); + } + }) + }; + let theme = cx.theme(); + let colors = &theme.colors; + let shown: Vec = nodes(&roots, &trail_of(&roots, &self.selected)) + .iter() + .map(|node| node.label.clone()) + .collect(); + let (click, keys, measure) = (state.clone(), state.clone(), state.clone()); + let (enter, key_roots, key_start) = (pick.clone(), roots.clone(), start.clone()); + let lists = open.then(|| { + let columns = columns(&roots, &trail); + let deepest = columns.iter().map(|column| column.len()).max().unwrap_or(0); + let lists = columns.iter().enumerate().map(|(level, column)| { + let rows = column.iter().enumerate().map(|(ix, node)| { + let (hover, pick) = (state.clone(), pick.clone()); + let at: Vec = trail[..level].iter().copied().chain([ix]).collect(); + let leaf = node.children.is_empty(); + let choice = Choice::new(node.value.clone(), node.label.clone()); + option_row( + ("row", ix), + &choice, + trail.get(level) == Some(&ix), + None, + cx, + ) + .when(!leaf, |row| { + row.child( + Icon::new(IconName::ChevronRight) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ) + }) + .on_hover({ + let at = at.clone(); + move |hovered, _, cx| { + if *hovered && hover.read(cx).trail != at { + show(&hover, true, at.clone(), cx); + } + } + }) + .when(leaf, |row| { + row.on_click(move |_, window, cx| pick(at.clone(), window, cx)) + }) + }); + div() + .id(("column", level)) + .w(theme.tooltip_max_width() * 0.75) + .max_h(theme.list_max_height()) + .overflow_y_scroll() + .p_1() + .flex() + .flex_col() + .when(level > 0, |list| { + list.border_l_1().border_color(colors.border) + }) + .children(rows) + }); + let close = state.clone(); + float( + anchor, + deepest, + surface((self.id.clone(), "columns"), cx) + .flex() + .on_mouse_down_out(move |_, _, cx| { + let trail = close.read(cx).trail.clone(); + show(&close, false, trail, cx) + }) + .children(lists), + window, + cx, + ) + }); + div() + .id(self.id) + .track_focus(&focus) + .relative() + .flex() + .items_center() + .gap_2() + .w_full() + .h(theme.control_height(self.size)) + .px(theme.control_padding(self.size)) + .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))) + .cursor_pointer() + .on_click(move |event, _, cx| { + if matches!(event, ClickEvent::Mouse(_)) { + show(&click, !open, start.clone(), cx) + } + }) + .on_key_down(move |event, window, cx| { + let key = event.keystroke.key.as_str(); + let mut trail = keys.read(cx).trail.clone(); + if !open { + if matches!(key, "down" | "up" | "enter" | "space") { + cx.stop_propagation(); + show(&keys, true, key_start.clone(), cx); + } + return; + } + let columns = columns(&key_roots, &trail); + let level = trail.len() - 1; + let (at, count) = (trail[level], columns[level].len()); + let node = &columns[level][at]; + match key { + "down" => trail[level] = (at + 1) % count, + "up" => trail[level] = (at + count - 1) % count, + "right" if !node.children.is_empty() => trail.push(0), + "left" if level > 0 => { + trail.pop(); + } + "enter" | "space" if node.children.is_empty() => { + cx.stop_propagation(); + return enter(trail, window, cx); + } + "enter" | "space" => trail.push(0), + "escape" => { + cx.stop_propagation(); + return show(&keys, false, trail, cx); + } + _ => return, + } + cx.stop_propagation(); + show(&keys, true, trail, cx); + }) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_color(if shown.is_empty() { + colors.fg_subtle + } else { + colors.fg + }) + .child(if shown.is_empty() { + self.placeholder + } else { + SharedString::from(shown.join(" / ")) + }), + ) + .child( + Icon::new(IconName::ChevronsUpDown) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ) + .child( + canvas( + move |bounds, _, cx| { + if measure.read(cx).anchor != bounds { + measure.update(cx, |state, _| state.anchor = bounds); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .children(lists) + } +} + +#[cfg(test)] +mod tests { + use gpui::SharedString; + + use super::{Cascade, columns, fit_trail, nodes, trail_of}; + + fn world() -> Vec { + vec![ + Cascade::new("asia", "Asia").child( + Cascade::new("jp", "Japan") + .child(Cascade::new("tyo", "Tokyo")) + .child(Cascade::new("osa", "Osaka")), + ), + Cascade::new("eu", "Europe").child(Cascade::new("pt", "Portugal")), + ] + } + + #[test] + fn a_trail_opens_one_column_per_parent() { + let roots = world(); + assert_eq!(columns(&roots, &[0]).len(), 2); + assert_eq!(columns(&roots, &[0, 0]).len(), 3); + assert_eq!(columns(&roots, &[0, 0, 1]).len(), 3); + } + + #[test] + fn a_trail_is_cut_back_to_rows_that_exist() { + let roots = world(); + assert_eq!(fit_trail(&roots, &[0, 0, 1]), [0, 0, 1]); + assert_eq!(fit_trail(&roots, &[1, 5]), [1]); + assert_eq!(fit_trail(&roots, &[9]), [0]); + assert_eq!(fit_trail(&roots, &[]), [0]); + } + + #[test] + fn paths_and_trails_round_trip() { + let roots = world(); + let path: Vec = vec!["asia".into(), "jp".into(), "osa".into()]; + let trail = trail_of(&roots, &path); + assert_eq!(trail, [0, 0, 1]); + let labels: Vec<_> = nodes(&roots, &trail) + .iter() + .map(|node| node.label.clone()) + .collect(); + assert_eq!(labels, ["Asia", "Japan", "Osaka"]); + assert_eq!(trail_of(&roots, &["eu".into(), "xx".into()]), [1]); + } +} diff --git a/src/forms/check.rs b/src/forms/check.rs new file mode 100644 index 0000000..e1d90ea --- /dev/null +++ b/src/forms/check.rs @@ -0,0 +1,303 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, ElementId, FocusHandle, InteractiveElement, + IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, + Styled, Window, div, prelude::*, +}; + +use super::{ + Choice, + options::{OnFlag, OnValues}, +}; +use crate::{ + motion, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize, Mix, Radius, TextSize}, +}; + +/// A checkbox's state. `Mixed` is the dash for a parent whose children disagree. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CheckState { + #[default] + Off, + On, + Mixed, +} + +impl From for CheckState { + fn from(on: bool) -> Self { + if on { Self::On } else { Self::Off } + } +} + +/// A focus handle kept for `id`, a Tab stop while enabled. +pub(crate) fn tab_stop( + id: ElementId, + enabled: bool, + window: &mut Window, + cx: &mut App, +) -> FocusHandle { + window + .use_keyed_state(id, cx, |_, cx| cx.focus_handle()) + .read(cx) + .clone() + .tab_stop(enabled) +} + +/// The box alone, shared by checkboxes, cards and lists. `changes` replays its motion. +pub(crate) fn check_mark( + state: CheckState, + focused: bool, + disabled: bool, + changes: usize, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let colors = &theme.colors; + let on = state != CheckState::Off; + let border = if focused { + colors.focus + } else if on { + colors.accent + } else { + colors.border_strong + }; + let (from, to) = if on { + (colors.surface, colors.accent) + } else { + (colors.accent, colors.surface) + }; + let glyph = match state { + CheckState::On => Some(IconName::Check), + CheckState::Mixed => Some(IconName::Minus), + CheckState::Off => None, + }; + let ink = colors.on_accent; + let mark = div() + .flex() + .flex_none() + .items_center() + .justify_center() + .size(theme.check_size()) + .rounded(theme.radius(Radius::Sm)) + .border_1() + .border_color(border) + .bg(to) + .when(disabled, |mark| mark.opacity(0.5)) + .when_some(glyph, |mark, glyph| { + let icon = Icon::new(glyph).size(IconSize::Xs).color(ink); + if changes == 0 { + return mark.child(icon); + } + mark.child( + icon.with_animation( + ("check-glyph", changes), + Animation::new(motion::duration(motion::FAST, cx)) + .with_easing(motion::ease_out_cubic), + move |icon, t| icon.scale(0.6 + 0.4 * t).color(ink.opacity(t)), + ), + ) + }); + if changes == 0 { + return mark.into_any_element(); + } + mark.with_animation( + ("check-fill", changes), + Animation::new(motion::duration(motion::FAST, cx)), + move |mark, t| mark.bg(from.mix(&to, t)), + ) + .into_any_element() +} + +/// A box that is on, off, or mixed, with an optional label beside it. +#[derive(IntoElement)] +pub struct Checkbox { + id: ElementId, + state: CheckState, + label: Option, + disabled: bool, + on_change: Option, +} + +impl Checkbox { + pub fn new(id: impl Into, state: impl Into) -> Self { + Self { + id: id.into(), + state: state.into(), + label: None, + disabled: false, + on_change: None, + } + } + + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Runs with the new value. Mixed and off both turn on. + pub fn on_change(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Checkbox { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focus = tab_stop( + (self.id.clone(), "focus").into(), + !self.disabled, + window, + cx, + ); + let changes = motion::changes((self.id.clone(), "changes"), self.state, window, cx); + let focused = focus.is_focused(window); + let next = self.state != CheckState::On; + let (id, on_change) = (self.id.clone(), self.on_change); + let theme = cx.theme(); + let fg = if self.disabled { + theme.colors.fg_disabled + } else { + theme.colors.fg + }; + div() + .id(self.id) + .track_focus(&focus) + .flex() + .items_center() + .gap_2() + .text_size(theme.text_size(TextSize::Base)) + .text_color(fg) + .when(!self.disabled, |row| { + row.cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| { + log::info!("checkbox {id:?}: {}", if next { "on" } else { "off" }); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + }) + }) + .child(check_mark(self.state, focused, self.disabled, changes, cx)) + .children(self.label) + } +} + +/// Checkboxes for a list of choices; any number can be on. +#[derive(IntoElement)] +pub struct CheckboxGroup { + id: ElementId, + choices: Vec, + selected: Vec, + on_change: Option, +} + +impl CheckboxGroup { + pub fn new(id: impl Into, choices: impl IntoIterator) -> Self { + Self { + id: id.into(), + choices: choices.into_iter().collect(), + selected: Vec::new(), + on_change: None, + } + } + + pub fn selected(mut self, values: impl IntoIterator>) -> Self { + self.selected = values.into_iter().map(Into::into).collect(); + self + } + + pub fn on_change( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +/// `selected` with `value` turned on or off, in choice order. +pub(crate) fn toggled( + choices: &[Choice], + selected: &[SharedString], + value: &SharedString, + on: bool, +) -> Vec { + choices + .iter() + .map(|choice| &choice.value) + .filter(|candidate| { + if *candidate == value { + on + } else { + selected.contains(candidate) + } + }) + .cloned() + .collect() +} + +impl RenderOnce for CheckboxGroup { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let (choices, selected, on_change) = ( + Rc::new(self.choices), + Rc::new(self.selected), + self.on_change, + ); + let boxes: Vec<_> = choices + .iter() + .enumerate() + .map(|(ix, choice)| { + let (choices, selected, on_change) = + (choices.clone(), selected.clone(), on_change.clone()); + let value = choice.value.clone(); + Checkbox::new(("check", ix), selected.contains(&choice.value)) + .label(choice.label.clone()) + .disabled(choice.disabled) + .on_change(move |on, window, cx| { + let next = toggled(&choices, &selected, &value, on); + if let Some(on_change) = &on_change { + on_change(&next, window, cx); + } + }) + }) + .collect(); + div().id(self.id).flex().flex_col().gap_2().children(boxes) + } +} + +#[cfg(test)] +mod tests { + use gpui::SharedString; + + use super::{Choice, toggled}; + + #[test] + fn toggling_keeps_choice_order() { + let choices = [ + Choice::new("a", "A"), + Choice::new("b", "B"), + Choice::new("c", "C"), + ]; + let on = |values: &[&str]| { + values + .iter() + .map(|v| SharedString::from(v.to_string())) + .collect::>() + }; + assert_eq!( + toggled(&choices, &on(&["c"]), &"a".into(), true), + on(&["a", "c"]) + ); + assert_eq!( + toggled(&choices, &on(&["a", "c"]), &"a".into(), false), + on(&["c"]) + ); + } +} diff --git a/src/forms/chips.rs b/src/forms/chips.rs new file mode 100644 index 0000000..6781218 --- /dev/null +++ b/src/forms/chips.rs @@ -0,0 +1,121 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use super::{Choice, listbox::chosen, options::OnValues}; +use crate::{ + primitives::{FocusRing, Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, TextSize}, +}; + +/// Pills for a short set of choices. One is chosen, or several with `multiple`. +#[derive(IntoElement)] +pub struct ChoiceChips { + id: ElementId, + choices: Vec, + selected: Vec, + multiple: bool, + on_change: Option, +} + +impl ChoiceChips { + pub fn new(id: impl Into, choices: impl IntoIterator) -> Self { + Self { + id: id.into(), + choices: choices.into_iter().collect(), + selected: Vec::new(), + multiple: false, + on_change: None, + } + } + + pub fn selected(mut self, values: impl IntoIterator>) -> Self { + self.selected = values.into_iter().map(Into::into).collect(); + self + } + + /// Filter chips: each toggles, and chosen ones show a check. + pub fn multiple(mut self) -> Self { + self.multiple = true; + self + } + + pub fn on_change( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ChoiceChips { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + let (choices, selected) = (Rc::new(self.choices), Rc::new(self.selected)); + let chips: Vec<_> = choices + .iter() + .enumerate() + .map(|(ix, choice)| { + let on = selected.contains(&choice.value); + let (fg, bg, border) = if on { + (colors.on_accent, colors.accent, colors.accent) + } else { + (colors.fg_muted, colors.surface, colors.border_strong) + }; + let (id, choices, selected, multiple, on_change) = ( + self.id.clone(), + choices.clone(), + selected.clone(), + self.multiple, + self.on_change.clone(), + ); + div() + .id(("chip", ix)) + .flex() + .items_center() + .gap_1p5() + .h(theme.control_height(ControlSize::Sm)) + .px_3() + .rounded_full() + .border_1() + .border_color(border) + .bg(bg) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(if choice.disabled { + colors.fg_disabled + } else { + fg + }) + .when(!choice.disabled, |chip| { + chip.tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .when(!on, |chip| chip.hover(|style| style.bg(colors.hover))) + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) + .on_click(move |_, window, cx| { + let next = chosen(&choices, &selected, ix, multiple); + log::info!("choice chips {id:?}: {next:?}"); + if let Some(on_change) = &on_change { + on_change(&next, window, cx); + } + }) + }) + .when(on && self.multiple, |chip| { + chip.child(Icon::new(IconName::Check).size(IconSize::Xs).color(fg)) + }) + .when_some(choice.icon, |chip, icon| { + chip.child(Icon::new(icon).size(IconSize::Xs).color(fg)) + }) + .child(choice.label.clone()) + }) + .collect(); + div().id(self.id).flex().flex_wrap().gap_2().children(chips) + } +} diff --git a/src/forms/combobox.rs b/src/forms/combobox.rs new file mode 100644 index 0000000..b72b410 --- /dev/null +++ b/src/forms/combobox.rs @@ -0,0 +1,286 @@ +use std::rc::Rc; + +use gpui::{ + App, Context, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Subscription, Window, div, prelude::*, +}; + +use super::{ + Choice, Input, InputEvent, TextInput, + options::{OnValue, Pick, Popup, step}, + select::{Picker, measure_anchor}, + text::{Down, Enter, Up}, +}; +use crate::{ + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize}, +}; + +/// Rows whose label holds `query`, ignoring case. All rows for an empty query. +pub(crate) fn matching(choices: &[Choice], query: &str) -> Vec { + let query = query.trim().to_lowercase(); + choices + .iter() + .filter(|choice| choice.label.to_lowercase().contains(&query)) + .cloned() + .collect() +} + +/// What a combobox restores on blur, and whether Escape closed it. +struct Typing { + restore: Option, + dismissed: bool, + last: String, + shown: Option>, + _blur: Subscription, +} + +/// A text field that filters a list as you type. Enter or a click picks a row. +#[derive(IntoElement)] +pub struct Combobox { + id: ElementId, + state: Entity, + choices: Vec, + selected: Option, + free: bool, + size: ControlSize, + on_change: Option, +} + +impl Combobox { + pub fn new( + id: impl Into, + state: &Entity, + choices: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + state: state.clone(), + choices: choices.into_iter().collect(), + selected: None, + free: false, + size: ControlSize::default(), + on_change: None, + } + } + + pub fn selected(mut self, value: impl Into) -> Self { + self.selected = Some(value.into()); + self + } + + /// Keeps any text; the list only completes it, as an autocomplete does. + pub fn free(mut self) -> Self { + self.free = true; + self + } + + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } + + /// Runs with the value of a picked row. + pub fn on_change( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Combobox { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let input = self.state.clone(); + let typing = window.use_keyed_state( + (self.id.clone(), "typing"), + cx, + |window, cx: &mut Context| { + let blur = cx.subscribe_in(&input, window, |typing, input, event, _, cx| { + if *event != InputEvent::Blur { + return; + } + typing.dismissed = false; + if let Some(label) = typing.restore.clone() + && input.read(cx).text() != label.as_ref() + { + log::info!("combobox: kept {label:?}"); + input.update(cx, |input, cx| input.set_text(label, cx)); + } + }); + Typing { + restore: None, + dismissed: false, + last: String::new(), + shown: None, + _blur: blur, + } + }, + ); + let picker = + window.use_keyed_state((self.id.clone(), "picker"), cx, |_, _| Picker::default()); + let label = self + .selected + .as_ref() + .and_then(|value| self.choices.iter().find(|choice| choice.value == *value)) + .map(|choice| choice.label.clone()); + let focused = self.state.read(cx).focus().is_focused(window); + if typing.read(cx).shown.as_ref() != Some(&self.selected) { + typing.update(cx, |typing, _| typing.shown = Some(self.selected.clone())); + let wanted = match (&label, self.free) { + (Some(label), _) => Some(label.clone()), + (None, false) => Some(SharedString::default()), + (None, true) => None, + }; + if let Some(label) = wanted + && !focused + && self.state.read(cx).text() != label.as_ref() + { + log::info!("combobox {:?}: shows {label:?}", self.id); + self.state.update(cx, |input, cx| input.set_text(label, cx)); + } + } + let text = self.state.read(cx).text().to_string(); + let restore = (!self.free).then(|| label.clone().unwrap_or_default()); + typing.update(cx, |typing, _| { + typing.restore = restore; + if typing.last != text { + typing.last = text.clone(); + typing.dismissed = false; + } + }); + let query = if label.as_ref().is_some_and(|label| label.as_ref() == text) { + "" + } else { + text.as_str() + }; + let rows = Rc::new(matching(&self.choices, query)); + let open = focused && !typing.read(cx).dismissed && !rows.is_empty(); + let kept = picker.read(cx).highlighted; + let highlighted = if rows.get(kept).is_some_and(|row| !row.disabled) { + kept + } else { + step(&rows, rows.len().saturating_sub(1), 1) + }; + let (anchor, scroll) = (picker.read(cx).anchor, picker.read(cx).scroll.clone()); + let pick: Pick = { + let (id, rows, state, typing, on_change) = ( + self.id.clone(), + rows.clone(), + self.state.clone(), + typing.clone(), + self.on_change, + ); + Rc::new(move |ix, window, cx| { + let choice = rows[ix].clone(); + if choice.disabled { + return; + } + log::info!("combobox {id:?}: {}", choice.value); + state.update(cx, |input, cx| input.set_text(choice.label.clone(), cx)); + typing.update(cx, |typing, cx| { + typing.last = choice.label.to_string(); + typing.dismissed = true; + cx.notify(); + }); + if let Some(on_change) = &on_change { + on_change(&choice.value, window, cx); + } + }) + }; + let chosen: Vec = self.selected.iter().cloned().collect(); + let subtle = cx.theme().colors.fg_subtle; + let (up, down, enter, escape) = ( + (picker.clone(), rows.clone()), + (picker.clone(), rows.clone(), typing.clone()), + pick.clone(), + typing.clone(), + ); + div() + .id(self.id.clone()) + .relative() + .w_full() + .capture_action(move |_: &Up, _, cx| { + if open { + cx.stop_propagation(); + let at = step(&up.1, highlighted, -1); + Picker::show(&up.0, true, at, cx); + } + }) + .capture_action(move |_: &Down, _, cx| { + let (picker, rows, typing) = &down; + if open { + cx.stop_propagation(); + Picker::show(picker, true, step(rows, highlighted, 1), cx); + } else if typing.read(cx).dismissed { + cx.stop_propagation(); + typing.update(cx, |typing, cx| { + typing.dismissed = false; + 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, |typing, cx| { + typing.dismissed = true; + cx.notify(); + }); + } + }) + .child( + Input::new(&self.state).size(self.size).suffix( + Icon::new(IconName::ChevronsUpDown) + .size(IconSize::Xs) + .color(subtle), + ), + ) + .child(measure_anchor(picker)) + .when(open, |field| { + field.child( + Popup { + id: (self.id, "list").into(), + anchor, + rows: &rows, + highlighted: Some(highlighted), + checked: Some(&chosen), + pick, + dismiss: None, + scroll: Some(&scroll), + } + .render(window, cx), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::{Choice, matching}; + + #[test] + fn matching_ignores_case_and_keeps_order() { + let rows = [ + Choice::new("ber", "Berlin"), + Choice::new("lis", "Lisbon"), + Choice::new("oslo", "Oslo"), + ]; + let labels = |query| { + matching(&rows, query) + .into_iter() + .map(|c| c.label) + .collect::>() + }; + assert_eq!(labels("LI"), ["Berlin", "Lisbon"]); + assert_eq!(labels(""), ["Berlin", "Lisbon", "Oslo"]); + assert!(labels("xyz").is_empty()); + } +} diff --git a/src/forms/knob.rs b/src/forms/knob.rs new file mode 100644 index 0000000..85ac7d0 --- /dev/null +++ b/src/forms/knob.rs @@ -0,0 +1,196 @@ +use std::{cell::Cell, f32::consts::PI, rc::Rc}; + +use gpui::{ + App, DragMoveEvent, ElementId, EmptyView, EntityId, InteractiveElement, IntoElement, + ParentElement, Path, PathBuilder, Pixels, Point, RenderOnce, StatefulInteractiveElement, + Styled, Window, canvas, div, point, prelude::*, +}; + +use super::{ + check::tab_stop, + options::OnNumber, + slider::{fraction, keyed, value_at}, +}; +use crate::theme::{ActiveTheme, Elevation}; + +/// Where the dial starts, at lower left, and how far it turns. +const START: f32 = 0.75 * PI; +const SWEEP: f32 = 1.5 * PI; + +fn at_angle(center: Point, radius: Pixels, angle: f32) -> Point { + point( + center.x + radius * angle.cos(), + center.y + radius * angle.sin(), + ) +} + +/// A stroked arc clockwise from `from` to `to`, in radians. +fn arc(center: Point, radius: Pixels, from: f32, to: f32, width: Pixels) -> Path { + let mut path = PathBuilder::stroke(width); + path.move_to(at_angle(center, radius, from)); + path.arc_to( + point(radius, radius), + Pixels::ZERO, + to - from > PI, + true, + at_angle(center, radius, to), + ); + path.build().expect("an arc is a simple open path") +} + +struct Turn { + owner: EntityId, + start: Rc>>, +} + +/// A dial for audio-style values. Drag up or down, or use the arrow keys. +#[derive(IntoElement)] +pub struct Knob { + id: ElementId, + value: f64, + range: (f64, f64, f64), + disabled: bool, + on_change: Option, +} + +impl Knob { + pub fn new(id: impl Into, value: f64) -> Self { + Self { + id: id.into(), + value, + range: (0.0, 100.0, 1.0), + disabled: false, + on_change: None, + } + } + + pub fn range(mut self, min: f64, max: f64) -> Self { + assert!(min < max, "knob range {min}..{max} is empty"); + self.range.0 = min; + self.range.1 = max; + self + } + + pub fn step(mut self, step: f64) -> Self { + assert!(step > 0.0, "knob step {step} must be positive"); + self.range.2 = step; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + 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 Knob { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let ((min, max, step), value) = (self.range, self.value); + let focus = tab_stop( + (self.id.clone(), "focus").into(), + !self.disabled, + window, + cx, + ); + let focused = focus.is_focused(window); + let owner = window + .use_keyed_state((self.id.clone(), "drag"), cx, |_, _| ()) + .entity_id(); + let theme = cx.theme(); + let colors = &theme.colors; + let (diameter, width) = theme.knob(); + let (travel, stroke) = ( + diameter.to_pixels(window.rem_size()) * 4.0, + width.to_pixels(window.rem_size()), + ); + let turned = fraction(value, min, max); + let (rail, ink, tick) = (colors.border, colors.accent, colors.fg); + let commit = { + let (id, on_change) = (self.id.clone(), self.on_change); + Rc::new(move |next: f64, window: &mut Window, cx: &mut App| { + if next == value { + return; + } + log::debug!("knob {id:?}: {next}"); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + }) + }; + let (drag, keys) = (commit.clone(), commit); + div() + .id(self.id) + .track_focus(&focus) + .relative() + .flex_none() + .size(diameter) + .rounded_full() + .bg(colors.surface) + .border_1() + .border_color(if focused { colors.focus } else { colors.border }) + .shadow(theme.elevation(Elevation::Raised)) + .when(self.disabled, |knob| knob.opacity(0.5)) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let center = bounds.center(); + let radius = bounds.size.width / 2.0 - stroke * 1.5; + window.paint_path(arc(center, radius, START, START + SWEEP, stroke), rail); + if turned > 0.0 { + let to = START + SWEEP * turned; + window.paint_path(arc(center, radius, START, to, stroke), ink); + } + let angle = START + SWEEP * turned; + let mut line = PathBuilder::stroke(stroke); + line.move_to(at_angle(center, radius * 0.3, angle)); + line.line_to(at_angle(center, radius * 0.62, angle)); + window.paint_path(line.build().expect("a tick is a straight line"), tick); + }, + ) + .absolute() + .size_full(), + ) + .when(!self.disabled, |knob| { + knob.cursor_ns_resize() + .on_drag( + Turn { + owner, + start: Rc::new(Cell::new(None)), + }, + |turn, _, _, cx| { + turn.start.set(None); + cx.new(|_| EmptyView) + }, + ) + .on_drag_move(move |event: &DragMoveEvent, window, cx| { + let turn = event.drag(cx); + if turn.owner != owner { + return; + } + let (start, y) = (turn.start.clone(), event.event.position.y); + let (anchor, from) = start.get().unwrap_or_else(|| { + start.set(Some((y, value))); + (y, value) + }); + let moved = (anchor - y) / travel; + drag( + value_at(fraction(from, min, max) + moved, min, max, step), + window, + cx, + ); + }) + .on_key_down(move |event, window, cx| { + if let Some(next) = keyed(&event.keystroke.key, value, min, max, step) { + cx.stop_propagation(); + keys(next, window, cx); + } + }) + }) + } +} diff --git a/src/forms/listbox.rs b/src/forms/listbox.rs new file mode 100644 index 0000000..709f005 --- /dev/null +++ b/src/forms/listbox.rs @@ -0,0 +1,193 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, ScrollHandle, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use super::{ + Choice, + check::{tab_stop, toggled}, + options::{OnValues, Pick, option_row, step}, +}; +use crate::theme::{ActiveTheme, Radius, TextSize}; + +/// `selected` after choosing row `ix`: one row replaces, several toggle. +pub(crate) fn chosen( + choices: &[Choice], + selected: &[SharedString], + ix: usize, + multiple: bool, +) -> Vec { + let value = &choices[ix].value; + if multiple { + toggled(choices, selected, value, !selected.contains(value)) + } else { + vec![value.clone()] + } +} + +#[derive(Default)] +struct Cursor { + highlighted: usize, + scroll: ScrollHandle, +} + +/// A list to choose from in place: one row, or several with `multiple`. +#[derive(IntoElement)] +pub struct ListBox { + id: ElementId, + choices: Vec, + selected: Vec, + multiple: bool, + on_change: Option, +} + +impl ListBox { + pub fn new(id: impl Into, choices: impl IntoIterator) -> Self { + Self { + id: id.into(), + choices: choices.into_iter().collect(), + selected: Vec::new(), + multiple: false, + on_change: None, + } + } + + pub fn selected(mut self, values: impl IntoIterator>) -> Self { + self.selected = values.into_iter().map(Into::into).collect(); + self + } + + /// Lets any number of rows be chosen. + pub fn multiple(mut self) -> Self { + self.multiple = true; + self + } + + pub fn on_change( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ListBox { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let count = self.choices.len(); + assert!(count > 0, "list box {:?} has no choices", self.id); + let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); + let focused = focus.is_focused(window); + let cursor = + window.use_keyed_state((self.id.clone(), "cursor"), cx, |_, _| Cursor::default()); + let (highlighted, scroll) = { + let cursor = cursor.read(cx); + (cursor.highlighted.min(count - 1), cursor.scroll.clone()) + }; + let (choices, selected) = (Rc::new(self.choices), Rc::new(self.selected)); + let press: Pick = { + let (id, choices, selected, multiple) = ( + self.id.clone(), + choices.clone(), + selected.clone(), + self.multiple, + ); + let (cursor, on_change) = (cursor.clone(), self.on_change); + Rc::new(move |ix, window, cx| { + cursor.update(cx, |cursor, cx| { + cursor.highlighted = ix; + cx.notify(); + }); + let next = chosen(&choices, &selected, ix, multiple); + log::info!("list box {id:?}: {next:?}"); + if let Some(on_change) = &on_change { + on_change(&next, window, cx); + } + }) + }; + let rows: Vec<_> = choices + .iter() + .enumerate() + .map(|(ix, choice)| { + let press = press.clone(); + let on = selected.contains(&choice.value); + option_row( + ("row", ix), + choice, + focused && ix == highlighted, + Some(on), + cx, + ) + .when(!choice.disabled, |row| { + row.on_click(move |_, window, cx| press(ix, window, cx)) + }) + }) + .collect(); + let theme = cx.theme(); + let colors = &theme.colors; + let keys = choices.clone(); + div() + .id(self.id) + .track_focus(&focus) + .track_scroll(&scroll) + .overflow_y_scroll() + .h_full() + .max_h(theme.list_max_height()) + .flex() + .flex_col() + .p_1() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(if focused { colors.focus } else { colors.border }) + .bg(colors.surface) + .text_size(theme.text_size(TextSize::Sm)) + .on_key_down(move |event, window, cx| { + let at = cursor.read(cx).highlighted.min(count - 1); + let to = match event.keystroke.key.as_str() { + "down" => step(&keys, at, 1), + "up" => step(&keys, at, -1), + "home" => step(&keys, count - 1, 1), + "end" => step(&keys, 0, -1), + "enter" | "space" if !keys[at].disabled => { + cx.stop_propagation(); + press(at, window, cx); + return; + } + _ => return, + }; + cx.stop_propagation(); + cursor.update(cx, |cursor, cx| { + cursor.highlighted = to; + cursor.scroll.scroll_to_item(to); + cx.notify(); + }); + }) + .children(rows) + } +} + +#[cfg(test)] +mod tests { + use gpui::SharedString; + + use super::{Choice, chosen}; + + #[test] + fn one_row_replaces_and_several_toggle_in_order() { + let rows = [ + Choice::new("a", "A"), + Choice::new("b", "B"), + Choice::new("c", "C"), + ]; + let values = |list: &[&str]| { + list.iter() + .map(|v| SharedString::from(v.to_string())) + .collect::>() + }; + assert_eq!(chosen(&rows, &values(&["a"]), 2, false), values(&["c"])); + assert_eq!(chosen(&rows, &values(&["c"]), 0, true), values(&["a", "c"])); + assert_eq!(chosen(&rows, &values(&["a", "c"]), 2, true), values(&["a"])); + } +} diff --git a/src/forms/masked.rs b/src/forms/masked.rs index 249a6f6..1261499 100644 --- a/src/forms/masked.rs +++ b/src/forms/masked.rs @@ -8,7 +8,7 @@ use gpui::{ use super::{ Input, TextInput, - suggest::{Suggestion, suggestion_list}, + options::{Choice, Popup}, }; use crate::{ primitives::{Icon, IconName}, @@ -227,12 +227,9 @@ impl RenderOnce for PhoneInput { .expect("checked at construction"); let theme = cx.theme(); let (hover, muted) = (theme.colors.hover, theme.colors.fg_muted); - let rows: Vec = COUNTRIES + let rows: Vec = COUNTRIES .iter() - .map(|(code, dial, flag, _)| Suggestion { - label: SharedString::from(format!("{flag} {code}")), - note: Some(SharedString::from(*dial)), - }) + .map(|(code, dial, flag, _)| Choice::new(*code, format!("{flag} {code}")).note(*dial)) .collect(); let (toggle, close, measure) = (picker.clone(), picker.clone(), picker.clone()); let on_country = self.on_country; @@ -290,23 +287,25 @@ impl RenderOnce for PhoneInput { .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, - )) + let current = [SharedString::from(self.country)]; + field.child( + Popup { + id: (self.id.clone(), "countries").into(), + anchor, + rows: &rows, + highlighted: None, + checked: Some(¤t), + pick, + dismiss: Some(Rc::new(move |_, cx| { + dismiss.update(cx, |picker, cx| { + picker.open = false; + cx.notify(); + }) + })), + scroll: None, + } + .render(window, cx), + ) }) } } diff --git a/src/forms/mention.rs b/src/forms/mention.rs index d2804c4..fc9af5f 100644 --- a/src/forms/mention.rs +++ b/src/forms/mention.rs @@ -7,7 +7,7 @@ use gpui::{ use super::{ Highlight, Input, TextInput, - suggest::{Suggestion, suggestion_list}, + options::{Choice, Popup}, text::{Down, Enter, Up}, }; use crate::theme::ActiveTheme; @@ -162,13 +162,13 @@ impl RenderOnce for MentionInput { "mention input: open {open}, {} matches, caret {anchor:?}", matches.len() ); - let rows: Vec = matches + let rows: Vec = matches .iter() - .map(|name| Suggestion { - label: active.map_or(name.clone(), |(_, trigger)| { + .map(|name| { + let label = active.map_or(name.clone(), |(_, trigger)| { SharedString::from(format!("{trigger}{name}")) - }), - note: None, + }); + Choice::new(name.clone(), label) }) .collect(); let insert = { @@ -231,15 +231,19 @@ impl RenderOnce for MentionInput { }) .child(Input::new(&self.state)) .when_some(anchor.filter(|_| open), |field, anchor| { - field.child(suggestion_list( - "mentions", - anchor, - &rows, - highlighted, - insert, - None, - cx, - )) + field.child( + Popup { + id: "mentions".into(), + anchor, + rows: &rows, + highlighted: Some(highlighted), + checked: None, + pick: insert, + dismiss: None, + scroll: None, + } + .render(window, cx), + ) }) } } diff --git a/src/forms/mod.rs b/src/forms/mod.rs index fbfe2d5..26d1080 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -1,39 +1,70 @@ +mod card; +mod cascade; +mod check; mod checked; +mod chips; +mod combobox; mod expression; mod group; mod hotkey; mod inline; mod input; +mod knob; +mod listbox; mod masked; mod mention; +mod multi; mod number; +mod options; mod pairs; mod path; mod pattern; mod pin; +mod radio; +mod rating; mod search; -mod suggest; +mod select; +mod slider; +mod stepper; +mod switch; mod tags; #[cfg(all(test, feature = "test-support"))] mod tests; mod text; +mod transfer; mod unit; +pub use card::{CheckboxCard, RadioCard}; +pub use cascade::{Cascade, Cascader}; +pub use check::{CheckState, Checkbox, CheckboxGroup}; pub use checked::{EmailInput, UrlInput}; +pub use chips::ChoiceChips; +pub use combobox::Combobox; 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 knob::Knob; +pub use listbox::ListBox; pub use masked::{COUNTRIES, MaskedInput, PhoneInput}; pub use mention::{MentionInput, mention_highlights}; +pub use multi::MultiSelect; pub use number::{NumberInput, ScrubInput}; +pub use options::Choice; pub use pairs::{KeyValueInput, ListInput}; pub use path::PathInput; pub use pattern::{RegexInput, regex_highlights}; pub use pin::PinInput; +pub use radio::{Radio, RadioGroup}; +pub use rating::Rating; pub use search::SearchInput; +pub use select::Select; +pub use slider::{RangeSlider, Slider}; +pub use stepper::Stepper; +pub use switch::Switch; pub use tags::TagInput; pub(crate) use text::bind_keys; pub use text::{Highlight, InputEvent, TextInput}; +pub use transfer::TransferList; pub use unit::UnitInput; diff --git a/src/forms/multi.rs b/src/forms/multi.rs new file mode 100644 index 0000000..a667b97 --- /dev/null +++ b/src/forms/multi.rs @@ -0,0 +1,232 @@ +use std::rc::Rc; + +use gpui::{ + App, ClickEvent, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use super::{ + Choice, + check::tab_stop, + listbox::chosen, + options::{OnValues, Pick, Popup, step}, + select::{Picker, measure_anchor, moved}, + tags::chip, +}; +use crate::{ + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, +}; + +/// A select for several values. Chosen ones sit in the field as chips. +#[derive(IntoElement)] +pub struct MultiSelect { + id: ElementId, + choices: Vec, + selected: Vec, + placeholder: SharedString, + size: ControlSize, + on_change: Option, +} + +impl MultiSelect { + pub fn new(id: impl Into, choices: impl IntoIterator) -> Self { + Self { + id: id.into(), + choices: choices.into_iter().collect(), + selected: Vec::new(), + placeholder: SharedString::from("Choose…"), + size: ControlSize::default(), + on_change: None, + } + } + + pub fn selected(mut self, values: impl IntoIterator>) -> Self { + self.selected = values.into_iter().map(Into::into).collect(); + self + } + + pub fn placeholder(mut self, text: impl Into) -> Self { + self.placeholder = text.into(); + self + } + + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } + + pub fn on_change( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for MultiSelect { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + assert!( + !self.choices.is_empty(), + "multi select {:?} has no choices", + self.id + ); + let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); + let focused = focus.is_focused(window); + let picker = + window.use_keyed_state((self.id.clone(), "picker"), cx, |_, _| Picker::default()); + if picker.read(cx).open && !focused { + picker.update(cx, |picker, _| picker.open = false); + } + let (choices, selected) = (Rc::new(self.choices), Rc::new(self.selected)); + let start = step(&choices, choices.len() - 1, 1); + if picker.read(cx).highlighted >= choices.len() { + picker.update(cx, |picker, _| picker.highlighted = start); + } + let (open, highlighted, anchor, scroll) = { + let picker = picker.read(cx); + ( + picker.open, + picker.highlighted, + picker.anchor, + picker.scroll.clone(), + ) + }; + let commit: OnValues = { + let (id, on_change) = (self.id.clone(), self.on_change); + Rc::new(move |next, window, cx| { + log::info!("multi select {id:?}: {next:?}"); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + }) + }; + let toggle: Pick = { + let (choices, selected, picker, commit) = ( + choices.clone(), + selected.clone(), + picker.clone(), + commit.clone(), + ); + Rc::new(move |ix, window, cx| { + if choices[ix].disabled { + return; + } + Picker::show(&picker, true, ix, cx); + commit(&chosen(&choices, &selected, ix, true), window, cx); + }) + }; + let theme = cx.theme(); + let colors = &theme.colors; + let chips: Vec<_> = choices + .iter() + .enumerate() + .filter(|(_, choice)| selected.contains(&choice.value)) + .map(|(ix, choice)| { + let (choices, selected, commit) = + (choices.clone(), selected.clone(), commit.clone()); + chip( + ("chip-remove", ix).into(), + choice.label.clone(), + Rc::new(move |window, cx| { + commit(&chosen(&choices, &selected, ix, true), window, cx) + }), + cx, + ) + }) + .collect(); + let (click, keys, rows, close) = ( + picker.clone(), + picker.clone(), + choices.clone(), + picker.clone(), + ); + let (enter, last, erase) = (toggle.clone(), selected.clone(), commit); + div() + .id(self.id.clone()) + .track_focus(&focus) + .relative() + .flex() + .flex_wrap() + .items_center() + .gap_1() + .w_full() + .min_h(theme.control_height(self.size)) + .pl_1() + .pr(theme.control_padding(self.size)) + .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)) + .cursor_pointer() + .on_click(move |event, _, cx| { + if matches!(event, ClickEvent::Mouse(_)) { + Picker::show(&click, !open, start, cx) + } + }) + .on_key_down(move |event, window, cx| { + let at = keys.read(cx).highlighted; + let key = event.keystroke.key.as_str(); + if key == "backspace" && !last.is_empty() { + cx.stop_propagation(); + erase(&last[..last.len() - 1], window, cx); + } else if !open { + if matches!(key, "down" | "up" | "enter" | "space") { + cx.stop_propagation(); + Picker::show(&keys, true, start, cx); + } + } else if let Some(to) = moved(event, &rows, at) { + cx.stop_propagation(); + Picker::show(&keys, true, to, cx); + } else if matches!(key, "enter" | "space") { + cx.stop_propagation(); + enter(at, window, cx); + } else if key == "escape" { + cx.stop_propagation(); + Picker::show(&keys, false, at, cx); + } + }) + .children(chips) + .when(selected.is_empty(), |field| { + field.child( + div() + .pl_2() + .text_color(colors.fg_subtle) + .child(self.placeholder), + ) + }) + .child( + div().ml_auto().child( + Icon::new(IconName::ChevronsUpDown) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ), + ) + .child(measure_anchor(picker)) + .when(open, |field| { + field.child( + Popup { + id: (self.id, "list").into(), + anchor, + rows: &choices, + highlighted: Some(highlighted), + checked: Some(&selected), + pick: toggle, + dismiss: Some(Rc::new(move |_, cx| { + let at = close.read(cx).highlighted; + Picker::show(&close, false, at, cx) + })), + scroll: Some(&scroll), + } + .render(window, cx), + ) + }) + } +} diff --git a/src/forms/options.rs b/src/forms/options.rs new file mode 100644 index 0000000..9a495c4 --- /dev/null +++ b/src/forms/options.rs @@ -0,0 +1,255 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, Bounds, Corner, Div, ElementId, InteractiveElement, + IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, SharedString, Stateful, + StatefulInteractiveElement, Styled, Window, anchored, deferred, div, prelude::*, +}; + +use crate::{ + motion, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize}, +}; + +/// One option: a value, the label shown for it, and an optional icon and note. +#[derive(Clone, Debug, PartialEq)] +pub struct Choice { + pub value: SharedString, + pub label: SharedString, + pub icon: Option, + pub note: Option, + pub disabled: bool, +} + +impl Choice { + pub fn new(value: impl Into, label: impl Into) -> Self { + Self { + value: value.into(), + label: label.into(), + icon: None, + note: None, + disabled: false, + } + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + /// Quiet text at the row's end, such as a count or a shortcut. + pub fn note(mut self, note: impl Into) -> Self { + self.note = Some(note.into()); + self + } + + pub fn disabled(mut self) -> Self { + self.disabled = true; + self + } +} + +pub(crate) type Pick = Rc; +pub(crate) type Run = Rc; +pub(crate) type OnFlag = Rc; +pub(crate) type OnNumber = Rc; +pub(crate) type OnValue = Rc; +pub(crate) type OnValues = Rc; + +/// The next row from `from` that is not disabled, stepping by `by` and wrapping. +pub(crate) fn step(rows: &[Choice], from: usize, by: isize) -> usize { + let count = rows.len() as isize; + let mut at = from as isize; + for _ in 0..count { + at = (at + by).rem_euclid(count); + if !rows[at as usize].disabled { + return at as usize; + } + } + from +} + +/// One row: a check column when `checked` is known, then icon, label and note. +pub(crate) fn option_row( + id: impl Into, + choice: &Choice, + highlighted: bool, + checked: Option, + cx: &App, +) -> Stateful
{ + let theme = cx.theme(); + let colors = &theme.colors; + let fg = if choice.disabled { + colors.fg_disabled + } else { + colors.fg + }; + div() + .id(id) + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded(theme.radius(Radius::Md)) + .text_color(fg) + .when(highlighted && !choice.disabled, |row| row.bg(colors.hover)) + .when(!choice.disabled, |row| { + row.cursor_pointer().hover(|style| style.bg(colors.hover)) + }) + .when_some(checked, |row, on| { + row.child( + div() + .flex_none() + .size(theme.icon_size(IconSize::Sm)) + .when(on, |mark| { + mark.child(Icon::new(IconName::Check).size(IconSize::Sm).color(fg)) + }), + ) + }) + .when_some(choice.icon, |row, icon| { + row.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted)) + }) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child(choice.label.clone()), + ) + .when_some(choice.note.clone(), |row, note| { + row.child(div().text_color(colors.fg_subtle).child(note)) + }) +} + +/// The card a floating list sits on. +pub(crate) fn surface(id: impl Into, cx: &App) -> Stateful
{ + let theme = cx.theme(); + let colors = &theme.colors; + div() + .id(id) + .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)) +} + +/// Floats `content` of about `rows` rows under `anchor`, or over it when only above has room. +pub(crate) fn float( + anchor: Bounds, + rows: usize, + content: impl IntoElement, + window: &Window, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let rem = window.rem_size(); + let row = theme.control_height(ControlSize::Md).to_pixels(rem); + let height = (row * (rows + 1) as f32).min(theme.list_max_height().to_pixels(rem)); + let below = window.viewport_size().height - anchor.bottom(); + let up = height > below && anchor.top() > below; + let enter = + Animation::new(motion::duration(motion::FAST, cx)).with_easing(motion::ease_out_cubic); + let placed = if up { + anchored() + .position(anchor.origin) + .anchor(Corner::BottomLeft) + .child( + div() + .child(content) + .with_animation("popup-in", enter, |popup, t| { + popup.opacity(t).mb(motion::NUDGE * (2.0 - t)) + }), + ) + } else { + anchored() + .position(anchor.bottom_left()) + .child( + div() + .child(content) + .with_animation("popup-in", enter, |popup, t| { + popup.opacity(t).mt(motion::NUDGE * (2.0 - t)) + }), + ) + }; + deferred(placed.snap_to_window()) + .with_priority(1) + .into_any_element() +} + +/// A list floating under `anchor`, at least as wide. Rows leave focus where it was. +pub(crate) struct Popup<'a> { + pub id: ElementId, + pub anchor: Bounds, + pub rows: &'a [Choice], + pub highlighted: Option, + pub checked: Option<&'a [SharedString]>, + pub pick: Pick, + pub dismiss: Option, + pub scroll: Option<&'a ScrollHandle>, +} + +impl Popup<'_> { + pub fn render(self, window: &Window, cx: &App) -> AnyElement { + let theme = cx.theme(); + let (anchor, pick) = (self.anchor, self.pick); + let rows = self.rows.iter().enumerate().map(|(ix, choice)| { + let pick = pick.clone(); + let checked = self.checked.map(|values| values.contains(&choice.value)); + option_row( + ("option", ix), + choice, + self.highlighted == Some(ix), + checked, + cx, + ) + .when(!choice.disabled, |row| { + row.on_mouse_down(MouseButton::Left, move |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + pick(ix, window, cx); + }) + }) + }); + let list = surface(self.id, cx) + .min_w(theme.tooltip_max_width()) + .when(anchor.size.width > Pixels::ZERO, |list| { + list.min_w(anchor.size.width) + }) + .max_h(theme.list_max_height()) + .overflow_y_scroll() + .when_some(self.scroll, |list, handle| list.track_scroll(handle)) + .p_1() + .flex() + .flex_col() + .when_some(self.dismiss, |list, dismiss| { + list.on_mouse_down_out(move |_, window, cx| dismiss(window, cx)) + }) + .children(rows); + float(anchor, self.rows.len(), list, window, cx) + } +} + +#[cfg(test)] +mod tests { + use super::{Choice, step}; + + #[test] + fn stepping_wraps_and_skips_disabled_rows() { + let rows = [ + Choice::new("a", "A"), + Choice::new("b", "B").disabled(), + Choice::new("c", "C"), + ]; + assert_eq!(step(&rows, 0, 1), 2); + assert_eq!(step(&rows, 2, 1), 0); + assert_eq!(step(&rows, 0, -1), 2); + let none = [Choice::new("a", "A").disabled()]; + assert_eq!(step(&none, 0, 1), 0); + } +} diff --git a/src/forms/radio.rs b/src/forms/radio.rs new file mode 100644 index 0000000..ff093de --- /dev/null +++ b/src/forms/radio.rs @@ -0,0 +1,258 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, ElementId, FocusHandle, InteractiveElement, + IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, + Styled, Window, div, prelude::*, +}; + +use super::{ + Choice, + check::tab_stop, + options::{OnValue, Run, step}, +}; +use crate::{ + motion, + theme::{ActiveTheme, TextSize}, +}; + +/// The circle alone, shared by radios and cards. `changes` replays its motion. +pub(crate) fn radio_mark( + on: bool, + focused: bool, + disabled: bool, + changes: usize, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let colors = &theme.colors; + let border = if focused { + colors.focus + } else if on { + colors.accent + } else { + colors.border_strong + }; + let (dot, accent) = (theme.radio_dot(), colors.accent); + div() + .flex() + .flex_none() + .items_center() + .justify_center() + .size(theme.check_size()) + .rounded_full() + .border_1() + .border_color(border) + .bg(colors.surface) + .when(disabled, |mark| mark.opacity(0.5)) + .when(on, |mark| { + let fill = div().size(dot).rounded_full().bg(accent); + if changes == 0 { + return mark.child(fill); + } + mark.child(fill.with_animation( + ("radio-dot", changes), + Animation::new(motion::duration(motion::BASE, cx)), + move |fill, t| fill.size(dot * motion::spring(t)), + )) + }) + .into_any_element() +} + +/// One choice of several, with an optional label beside it. +#[derive(IntoElement)] +pub struct Radio { + id: ElementId, + on: bool, + label: Option, + disabled: bool, + focus: Option, + on_select: Option, +} + +impl Radio { + pub fn new(id: impl Into, on: bool) -> Self { + Self { + id: id.into(), + on, + label: None, + disabled: false, + focus: None, + on_select: None, + } + } + + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Runs when chosen while off. + pub fn on_select(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } + + /// Focus owned by a group that moves it with the arrows. + fn focus(mut self, handle: FocusHandle) -> Self { + self.focus = Some(handle); + self + } +} + +impl RenderOnce for Radio { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focus = match self.focus { + Some(handle) => handle, + None => tab_stop( + (self.id.clone(), "focus").into(), + !self.disabled, + window, + cx, + ), + }; + let changes = motion::changes((self.id.clone(), "changes"), self.on, window, cx); + let focused = focus.is_focused(window); + let (on, on_select) = (self.on, self.on_select); + let theme = cx.theme(); + let fg = if self.disabled { + theme.colors.fg_disabled + } else { + theme.colors.fg + }; + div() + .id(self.id) + .track_focus(&focus) + .flex() + .items_center() + .gap_2() + .text_size(theme.text_size(TextSize::Base)) + .text_color(fg) + .when(!self.disabled, |row| { + row.cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| { + if let (false, Some(on_select)) = (on, &on_select) { + on_select(window, cx); + } + }) + }) + .child(radio_mark(self.on, focused, self.disabled, changes, cx)) + .children(self.label) + } +} + +/// Radios for a list of choices. One Tab stop; the arrows move the choice. +#[derive(IntoElement)] +pub struct RadioGroup { + id: ElementId, + choices: Vec, + selected: Option, + horizontal: bool, + on_change: Option, +} + +impl RadioGroup { + pub fn new(id: impl Into, choices: impl IntoIterator) -> Self { + Self { + id: id.into(), + choices: choices.into_iter().collect(), + selected: None, + horizontal: false, + on_change: None, + } + } + + pub fn selected(mut self, value: impl Into) -> Self { + self.selected = Some(value.into()); + self + } + + /// Lays the radios in a row. + pub fn horizontal(mut self) -> Self { + self.horizontal = true; + self + } + + pub fn on_change( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for RadioGroup { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let count = self.choices.len(); + assert!(count > 0, "radio group {:?} has no choices", self.id); + let handles = window.use_keyed_state((self.id.clone(), "focus"), cx, |_, _| Vec::new()); + if handles.read(cx).len() != count { + handles.update(cx, |handles: &mut Vec, cx| { + handles.resize_with(count, || cx.focus_handle()); + }); + } + let choices = Rc::new(self.choices); + let chosen = self + .selected + .as_ref() + .and_then(|value| choices.iter().position(|choice| choice.value == *value)); + let stop = match chosen { + Some(ix) if !choices[ix].disabled => ix, + _ => step(&choices, count - 1, 1), + }; + let handles: Vec = handles + .read(cx) + .iter() + .enumerate() + .map(|(ix, handle)| handle.clone().tab_stop(ix == stop && !choices[ix].disabled)) + .collect(); + let pick: OnValue = { + let (id, on_change) = (self.id.clone(), self.on_change); + Rc::new(move |value, window, cx| { + log::info!("radio group {id:?}: {value}"); + if let Some(on_change) = &on_change { + on_change(value, window, cx); + } + }) + }; + let radios: Vec<_> = choices + .iter() + .enumerate() + .map(|(ix, choice)| { + let (pick, value) = (pick.clone(), choice.value.clone()); + Radio::new(("radio", ix), chosen == Some(ix)) + .label(choice.label.clone()) + .disabled(choice.disabled) + .focus(handles[ix].clone()) + .on_select(move |window, cx| pick(&value, window, cx)) + }) + .collect(); + div() + .id(self.id) + .flex() + .when(self.horizontal, |group| group.flex_row().gap_4()) + .when(!self.horizontal, |group| group.flex_col().gap_2()) + .on_key_down(move |event, window, cx| { + let by = match event.keystroke.key.as_str() { + "up" | "left" => -1, + "down" | "right" => 1, + _ => return, + }; + let Some(at) = handles.iter().position(|handle| handle.is_focused(window)) else { + return; + }; + cx.stop_propagation(); + let next = step(&choices, at, by); + window.focus(&handles[next]); + pick(&choices[next].value, window, cx); + }) + .children(radios) + } +} diff --git a/src/forms/rating.rs b/src/forms/rating.rs new file mode 100644 index 0000000..e22ae25 --- /dev/null +++ b/src/forms/rating.rs @@ -0,0 +1,189 @@ +use std::{ + f32::consts::{FRAC_PI_2, PI}, + rc::Rc, +}; + +use gpui::{ + App, Bounds, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, + Path, PathBuilder, Pixels, RenderOnce, StatefulInteractiveElement, Styled, Window, canvas, div, + point, prelude::*, transparent_black, +}; + +use super::check::tab_stop; +use crate::theme::{ActiveTheme, IconSize, Radius}; + +/// A five-point star inside `bounds`, filled or outlined with Lucide's stroke ratio. +fn star(bounds: Bounds, fill: bool) -> Path { + let side = bounds.size.width.min(bounds.size.height); + let stroke = side / 12.0; + let center = bounds.center(); + let outer = side / 2.0 - stroke; + let mut path = if fill { + PathBuilder::fill() + } else { + PathBuilder::stroke(stroke) + }; + for k in 0..10 { + let radius = if k % 2 == 0 { outer } else { outer * 0.5 }; + let angle = -FRAC_PI_2 + k as f32 * PI / 5.0; + let at = point( + center.x + radius * angle.cos(), + center.y + radius * angle.sin(), + ); + if k == 0 { + path.move_to(at); + } else { + path.line_to(at); + } + } + path.close(); + path.build().expect("a star is a simple closed path") +} + +fn paint_star(bounds: Bounds, filled: bool, color: Hsla, window: &mut Window) { + if filled { + window.paint_path(star(bounds, true), color); + } + window.paint_path(star(bounds, false), color); +} + +type OnRate = Rc; + +/// Stars from one to `max`. Hover previews; clicking the current value clears it. +#[derive(IntoElement)] +pub struct Rating { + id: ElementId, + value: u8, + max: u8, + disabled: bool, + on_change: Option, +} + +impl Rating { + pub fn new(id: impl Into, value: u8) -> Self { + Self { + id: id.into(), + value, + max: 5, + disabled: false, + on_change: None, + } + } + + pub fn max(mut self, max: u8) -> Self { + assert!(max > 0, "a rating needs at least one star"); + self.max = max; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn on_change(mut self, handler: impl Fn(u8, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Rating { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let (value, max) = (self.value, self.max); + assert!(value <= max, "rating {value} is above {max}"); + let focus = tab_stop( + (self.id.clone(), "focus").into(), + !self.disabled, + window, + cx, + ); + let focused = focus.is_focused(window); + let hover = window.use_keyed_state((self.id.clone(), "hover"), cx, |_, _| None::); + let shown = hover.read(cx).unwrap_or(value); + let rate: OnRate = { + let (id, on_change) = (self.id.clone(), self.on_change); + Rc::new(move |next, window, cx| { + log::info!("rating {id:?}: {next}"); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + }) + }; + let theme = cx.theme(); + let colors = &theme.colors; + let (on, off) = (colors.accent, colors.border_strong); + let stars: Vec<_> = (1..=max) + .map(|n| { + let filled = n <= shown; + let color = if filled { on } else { off }; + let (hover, rate) = (hover.clone(), rate.clone()); + div() + .id(("star", n as usize)) + .size(theme.icon_size(IconSize::Lg)) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| paint_star(bounds, filled, color, window), + ) + .size_full(), + ) + .when(!self.disabled, |star| { + star.cursor_pointer() + .on_hover(move |inside, _, cx| { + if *inside { + hover.update(cx, |hover, cx| { + *hover = Some(n); + cx.notify(); + }); + } + }) + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) + .on_click(move |_, window, cx| { + rate(if n == value { 0 } else { n }, window, cx) + }) + }) + }) + .collect(); + let (leave, keys) = (hover, rate); + div() + .id(self.id) + .track_focus(&focus) + .flex() + .gap_0p5() + .p_0p5() + .rounded(theme.radius(Radius::Sm)) + .border_1() + .border_color(if focused { + colors.focus + } else { + transparent_black() + }) + .when(self.disabled, |row| row.opacity(0.5)) + .on_hover(move |inside, _, cx| { + if !*inside { + leave.update(cx, |hover, cx| { + *hover = None; + cx.notify(); + }); + } + }) + .when(!self.disabled, |row| { + row.on_key_down(move |event, window, cx| { + let next = match event.keystroke.key.as_str() { + "left" | "down" => value.saturating_sub(1), + "right" | "up" => (value + 1).min(max), + "home" => 0, + "end" => max, + _ => return, + }; + cx.stop_propagation(); + if next != value { + keys(next, window, cx); + } + }) + }) + .children(stars) + } +} diff --git a/src/forms/search.rs b/src/forms/search.rs index 00b33b9..b605bf6 100644 --- a/src/forms/search.rs +++ b/src/forms/search.rs @@ -7,7 +7,7 @@ use gpui::{ use super::{ Input, TextInput, - suggest::{Suggestion, suggestion_list}, + options::{Choice, Popup}, }; use crate::{ primitives::{Icon, IconName}, @@ -65,13 +65,10 @@ impl RenderOnce for SearchInput { let open = input.focus().is_focused(window) && input.is_empty() && !self.history.is_empty(); let anchor = *frame.read(cx); let subtle = cx.theme().colors.fg_subtle; - let rows: Vec = self + let rows: Vec = self .history .iter() - .map(|query| Suggestion { - label: query.clone(), - note: None, - }) + .map(|query| Choice::new(query.clone(), query.clone())) .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| { @@ -106,15 +103,19 @@ impl RenderOnce for SearchInput { .size_full(), ) .when(open, |field| { - field.child(suggestion_list( - (self.id.clone(), "recent"), - anchor, - &rows, - usize::MAX, - pick, - None, - cx, - )) + field.child( + Popup { + id: (self.id.clone(), "recent").into(), + anchor, + rows: &rows, + highlighted: None, + checked: None, + pick, + dismiss: None, + scroll: None, + } + .render(window, cx), + ) }) } } diff --git a/src/forms/select.rs b/src/forms/select.rs new file mode 100644 index 0000000..7cb604f --- /dev/null +++ b/src/forms/select.rs @@ -0,0 +1,283 @@ +use std::rc::Rc; + +use gpui::{ + App, Bounds, ElementId, Entity, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, + ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString, Styled, Window, canvas, div, + prelude::*, +}; + +use super::{ + Choice, + check::tab_stop, + input::text_size, + options::{OnValue, Pick, Popup, step}, +}; +use crate::{ + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius}, +}; + +/// An open list's place and cursor, kept between frames. +#[derive(Default)] +pub(crate) struct Picker { + pub open: bool, + pub highlighted: usize, + pub anchor: Bounds, + pub scroll: ScrollHandle, +} + +impl Picker { + pub fn show(state: &Entity, open: bool, highlighted: usize, cx: &mut App) { + state.update(cx, |picker, cx| { + picker.open = open; + picker.highlighted = highlighted; + picker.scroll.scroll_to_item(highlighted); + cx.notify(); + }); + } +} + +/// Sizes an invisible layer to its parent and records the bounds in `state`. +pub(crate) fn measure_anchor(state: Entity) -> impl IntoElement { + canvas( + move |bounds, _, cx| { + if state.read(cx).anchor != bounds { + state.update(cx, |picker, _| picker.anchor = bounds); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full() +} + +/// Up, Down, Home and End over `rows` from `at`; `None` for other keys. +pub(crate) fn moved(event: &KeyDownEvent, rows: &[Choice], at: usize) -> Option { + let last = rows.len() - 1; + match event.keystroke.key.as_str() { + "down" => Some(step(rows, at, 1)), + "up" => Some(step(rows, at, -1)), + "home" => Some(step(rows, last, 1)), + "end" => Some(step(rows, 0, -1)), + _ => None, + } +} + +/// A button that opens a list and shows the one chosen. +#[derive(IntoElement)] +pub struct Select { + id: ElementId, + choices: Vec, + selected: Option, + placeholder: SharedString, + size: ControlSize, + disabled: bool, + on_change: Option, +} + +impl Select { + pub fn new(id: impl Into, choices: impl IntoIterator) -> Self { + Self { + id: id.into(), + choices: choices.into_iter().collect(), + selected: None, + placeholder: SharedString::from("Choose…"), + size: ControlSize::default(), + disabled: false, + on_change: None, + } + } + + pub fn selected(mut self, value: impl Into) -> Self { + self.selected = Some(value.into()); + self + } + + pub fn placeholder(mut self, text: impl Into) -> Self { + self.placeholder = text.into(); + self + } + + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn on_change( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Select { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + assert!( + !self.choices.is_empty(), + "select {:?} has no choices", + self.id + ); + let focus = tab_stop( + (self.id.clone(), "focus").into(), + !self.disabled, + window, + cx, + ); + let focused = focus.is_focused(window); + let picker = + window.use_keyed_state((self.id.clone(), "picker"), cx, |_, _| Picker::default()); + if picker.read(cx).open && !focused { + log::info!("select {:?}: closed on blur", self.id); + picker.update(cx, |picker, _| picker.open = false); + } + let choices = Rc::new(self.choices); + let current = self + .selected + .as_ref() + .and_then(|value| choices.iter().position(|choice| choice.value == *value)); + let start = current.unwrap_or_else(|| step(&choices, choices.len() - 1, 1)); + if picker.read(cx).highlighted >= choices.len() { + picker.update(cx, |picker, _| picker.highlighted = start); + } + let (open, highlighted, anchor, scroll) = { + let picker = picker.read(cx); + ( + picker.open, + picker.highlighted, + picker.anchor, + picker.scroll.clone(), + ) + }; + let pick: Pick = { + let (id, choices, picker, on_change) = ( + self.id.clone(), + choices.clone(), + picker.clone(), + self.on_change, + ); + Rc::new(move |ix, window, cx| { + if choices[ix].disabled { + return; + } + let value = choices[ix].value.clone(); + log::info!("select {id:?}: {value}"); + Picker::show(&picker, false, ix, cx); + if let Some(on_change) = &on_change { + on_change(&value, window, cx); + } + }) + }; + let theme = cx.theme(); + let colors = &theme.colors; + let shown = current.map(|ix| &choices[ix]); + let (toggle, keys, rows, close) = ( + picker.clone(), + picker.clone(), + choices.clone(), + picker.clone(), + ); + let enter = pick.clone(); + let chosen: Vec = shown.iter().map(|choice| choice.value.clone()).collect(); + div() + .id(self.id.clone()) + .track_focus(&focus) + .relative() + .flex() + .items_center() + .gap_2() + .w_full() + .h(theme.control_height(self.size)) + .px(theme.control_padding(self.size)) + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(if focused { + colors.focus + } else { + colors.border_strong + }) + .bg(if self.disabled { + colors.sunken + } else { + colors.surface + }) + .text_size(theme.text_size(text_size(self.size))) + .when(!self.disabled, |trigger| { + trigger + .cursor_pointer() + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + Picker::show(&toggle, !open, start, cx) + }) + .on_key_down(move |event, window, cx| { + let at = keys.read(cx).highlighted; + let key = event.keystroke.key.as_str(); + if !open { + if matches!(key, "down" | "up" | "enter" | "space") { + cx.stop_propagation(); + Picker::show(&keys, true, start, cx); + } + return; + } + if let Some(to) = moved(event, &rows, at) { + cx.stop_propagation(); + Picker::show(&keys, true, to, cx); + } else if matches!(key, "enter" | "space") { + cx.stop_propagation(); + enter(at, window, cx); + } else if key == "escape" { + cx.stop_propagation(); + Picker::show(&keys, false, at, cx); + } + }) + }) + .when_some(shown.and_then(|choice| choice.icon), |trigger, icon| { + trigger.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted)) + }) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_color(match (shown, self.disabled) { + (_, true) => colors.fg_disabled, + (Some(_), false) => colors.fg, + (None, false) => colors.fg_subtle, + }) + .child(shown.map_or(self.placeholder, |choice| choice.label.clone())), + ) + .child( + Icon::new(IconName::ChevronsUpDown) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ) + .child(measure_anchor(picker)) + .when(open, |trigger| { + trigger.child( + Popup { + id: (self.id, "list").into(), + anchor, + rows: &choices, + highlighted: Some(highlighted), + checked: Some(&chosen), + pick, + dismiss: Some(Rc::new(move |_, cx| { + let at = close.read(cx).highlighted; + Picker::show(&close, false, at, cx) + })), + scroll: Some(&scroll), + } + .render(window, cx), + ) + }) + } +} diff --git a/src/forms/slider.rs b/src/forms/slider.rs new file mode 100644 index 0000000..b739c37 --- /dev/null +++ b/src/forms/slider.rs @@ -0,0 +1,438 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, Bounds, DragMoveEvent, ElementId, EmptyView, EntityId, InteractiveElement, + IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, StatefulInteractiveElement, + Styled, Window, canvas, div, prelude::*, relative, +}; + +use super::{check::tab_stop, options::OnNumber}; +use crate::theme::{ActiveTheme, Elevation}; + +/// `value` as a fraction of `min..=max`. +pub(crate) fn fraction(value: f64, min: f64, max: f64) -> f32 { + ((value - min) / (max - min)).clamp(0.0, 1.0) as f32 +} + +/// The value at `fraction` of `min..=max`, on the `step` grid. +pub(crate) fn value_at(fraction: f32, min: f64, max: f64, step: f64) -> f64 { + let raw = min + f64::from(fraction.clamp(0.0, 1.0)) * (max - min); + (min + ((raw - min) / step).round() * step).clamp(min, max) +} + +/// Where a key moves `value`: arrows step, Page keys jump ten, Home and End go to the ends. +pub(crate) fn keyed(key: &str, value: f64, min: f64, max: f64, step: f64) -> Option { + let next = match key { + "left" | "down" => value - step, + "right" | "up" => value + step, + "pagedown" => value - step * 10.0, + "pageup" => value + step * 10.0, + "home" => min, + "end" => max, + _ => return None, + }; + Some(next.clamp(min, max)) +} + +/// How far along a track `at` is, from its start: left, or bottom when vertical. +fn along(bounds: Bounds, at: Point, vertical: bool) -> f32 { + if vertical { + (bounds.bottom() - at.y) / bounds.size.height + } else { + (at.x - bounds.left()) / bounds.size.width + } +} + +/// A drag of thumb `index`, or of the thumb the last track press grabbed. +struct Thumb { + owner: EntityId, + index: Option, +} + +type Commit = Rc; +type OnSpan = Rc; + +/// A track with one or two thumbs. `commit` gets the thumb and its new value. +struct Track { + id: ElementId, + values: Vec, + range: (f64, f64, f64), + vertical: bool, + disabled: bool, + commit: Commit, +} + +impl Track { + fn render(self, window: &mut Window, cx: &mut App) -> AnyElement { + let (min, max, step) = self.range; + let vertical = self.vertical; + let owner = window + .use_keyed_state((self.id.clone(), "drag"), cx, |_, _| ()) + .entity_id(); + let grabbed = window.use_keyed_state((self.id.clone(), "grab"), cx, |_, _| 0usize); + let measured = window.use_keyed_state((self.id.clone(), "bounds"), cx, |_, _| { + Bounds::::default() + }); + let handles: Vec<_> = (0..self.values.len()) + .map(|k| { + tab_stop( + (self.id.clone(), ["low", "high"][k]).into(), + !self.disabled, + window, + cx, + ) + }) + .collect(); + let theme = cx.theme(); + let colors = &theme.colors; + let (thumb, line) = (theme.slider_thumb(), theme.slider_track()); + let fractions: Vec = self + .values + .iter() + .map(|value| fraction(*value, min, max)) + .collect(); + let (start, end) = if fractions.len() == 2 { + (fractions[0], fractions[1]) + } else { + (0.0, fractions[0]) + }; + let rail = div() + .absolute() + .rounded_full() + .bg(colors.border) + .map(|rail| { + if vertical { + rail.top_0() + .bottom_0() + .left(relative(0.5)) + .ml(line * -0.5) + .w(line) + } else { + rail.left_0() + .right_0() + .top(relative(0.5)) + .mt(line * -0.5) + .h(line) + } + }); + let fill = div() + .absolute() + .rounded_full() + .bg(colors.accent) + .map(|fill| { + if vertical { + fill.bottom(relative(start)) + .h(relative(end - start)) + .left(relative(0.5)) + .ml(line * -0.5) + .w(line) + } else { + fill.left(relative(start)) + .w(relative(end - start)) + .top(relative(0.5)) + .mt(line * -0.5) + .h(line) + } + }); + let thumbs: Vec<_> = fractions + .iter() + .enumerate() + .map(|(k, at)| { + let focused = handles[k].is_focused(window); + let (commit, value) = (self.commit.clone(), self.values[k]); + div() + .id(("thumb", k)) + .track_focus(&handles[k]) + .absolute() + .size(thumb) + .rounded_full() + .bg(colors.surface) + .border_1() + .border_color(if focused { + colors.focus + } else { + colors.border_strong + }) + .shadow(theme.elevation(Elevation::Raised)) + .map(|thumb_box| { + if vertical { + thumb_box.left_0().bottom(relative(*at)).mb(thumb * -0.5) + } else { + thumb_box.top_0().left(relative(*at)).ml(thumb * -0.5) + } + }) + .when(!self.disabled, |thumb_box| { + thumb_box + .cursor_pointer() + .on_drag( + Thumb { + owner, + index: Some(k), + }, + |_, _, _, cx| cx.new(|_| EmptyView), + ) + .on_key_down(move |event, window, cx| { + if let Some(next) = + keyed(&event.keystroke.key, value, min, max, step) + { + cx.stop_propagation(); + commit(k, next, window, cx); + } + }) + }) + }) + .collect(); + let (drag, jump) = (self.commit.clone(), self.commit); + let (values, focus) = (self.values, handles); + let bounds_state = measured.clone(); + let inner = div() + .id((self.id.clone(), "inner")) + .relative() + .size_full() + .child(rail) + .child(fill) + .children(thumbs) + .child( + canvas( + move |bounds, _, cx| { + if *bounds_state.read(cx) != bounds { + bounds_state.update(cx, |state, _| *state = bounds); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .when(!self.disabled, |inner| { + let grab = grabbed.clone(); + inner + .on_drag(Thumb { owner, index: None }, |_, _, _, cx| { + cx.new(|_| EmptyView) + }) + .on_drag_move(move |event: &DragMoveEvent, window, cx| { + let thumb = event.drag(cx); + if thumb.owner != owner { + return; + } + let index = thumb.index.unwrap_or(*grabbed.read(cx)); + let next = value_at( + along(event.bounds, event.event.position, vertical), + min, + max, + step, + ); + drag(index, next, window, cx); + }) + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + let bounds = *measured.read(cx); + let next = + value_at(along(bounds, event.position, vertical), min, max, step); + let index = match values.as_slice() { + [low, high] if (next - high).abs() < (next - low).abs() => 1, + _ => 0, + }; + grab.update(cx, |grab, _| *grab = index); + window.focus(&focus[index]); + jump(index, next, window, cx); + }) + }); + div() + .id(self.id) + .flex_none() + .map(|outer| { + if vertical { + outer.h_full().w(thumb).py(thumb * 0.5) + } else { + outer.w_full().h(thumb).px(thumb * 0.5) + } + }) + .when(self.disabled, |outer| outer.opacity(0.5)) + .child(inner) + .into_any_element() + } +} + +/// A value along a track. Drag the thumb, click the track, or use the arrow keys. +#[derive(IntoElement)] +pub struct Slider { + id: ElementId, + value: f64, + range: (f64, f64, f64), + vertical: bool, + disabled: bool, + on_change: Option, +} + +impl Slider { + pub fn new(id: impl Into, value: f64) -> Self { + Self { + id: id.into(), + value, + range: (0.0, 100.0, 1.0), + vertical: false, + disabled: false, + on_change: None, + } + } + + pub fn range(mut self, min: f64, max: f64) -> Self { + assert!(min < max, "slider range {min}..{max} is empty"); + self.range.0 = min; + self.range.1 = max; + self + } + + pub fn step(mut self, step: f64) -> Self { + assert!(step > 0.0, "slider step {step} must be positive"); + self.range.2 = step; + self + } + + /// Runs bottom to top; the parent gives it a height. + pub fn vertical(mut self) -> Self { + self.vertical = true; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + 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 Slider { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let (id, value, on_change) = (self.id.clone(), self.value, self.on_change); + let commit: Commit = Rc::new(move |_, next, window, cx| { + if next == value { + return; + } + log::debug!("slider {id:?}: {next}"); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + }); + Track { + id: self.id, + values: vec![value], + range: self.range, + vertical: self.vertical, + disabled: self.disabled, + commit, + } + .render(window, cx) + } +} + +/// Two thumbs that bound a span. Neither passes the other. +#[derive(IntoElement)] +pub struct RangeSlider { + id: ElementId, + value: (f64, f64), + range: (f64, f64, f64), + vertical: bool, + disabled: bool, + on_change: Option, +} + +impl RangeSlider { + pub fn new(id: impl Into, low: f64, high: f64) -> Self { + assert!(low <= high, "range {low}..{high} runs backwards"); + Self { + id: id.into(), + value: (low, high), + range: (0.0, 100.0, 1.0), + vertical: false, + disabled: false, + on_change: None, + } + } + + pub fn range(mut self, min: f64, max: f64) -> Self { + assert!(min < max, "slider range {min}..{max} is empty"); + self.range.0 = min; + self.range.1 = max; + self + } + + pub fn step(mut self, step: f64) -> Self { + assert!(step > 0.0, "slider step {step} must be positive"); + self.range.2 = step; + self + } + + /// Runs bottom to top; the parent gives it a height. + pub fn vertical(mut self) -> Self { + self.vertical = true; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn on_change( + mut self, + handler: impl Fn((f64, f64), &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for RangeSlider { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let (id, (low, high), on_change) = (self.id.clone(), self.value, self.on_change); + let commit: Commit = Rc::new(move |index, next, window, cx| { + let span = if index == 0 { + (next.min(high), high) + } else { + (low, next.max(low)) + }; + if span == (low, high) { + return; + } + log::debug!("range slider {id:?}: {span:?}"); + if let Some(on_change) = &on_change { + on_change(span, window, cx); + } + }); + Track { + id: self.id, + values: vec![low, high], + range: self.range, + vertical: self.vertical, + disabled: self.disabled, + commit, + } + .render(window, cx) + } +} + +#[cfg(test)] +mod tests { + use super::{fraction, keyed, value_at}; + + #[test] + fn values_snap_to_the_step_and_stay_in_range() { + assert_eq!(fraction(25.0, 0.0, 100.0), 0.25); + assert_eq!(value_at(0.26, 0.0, 100.0, 5.0), 25.0); + assert_eq!(value_at(1.4, 0.0, 100.0, 5.0), 100.0); + assert_eq!(value_at(0.5, -1.0, 1.0, 0.5), 0.0); + } + + #[test] + fn keys_step_jump_and_reach_the_ends() { + assert_eq!(keyed("right", 10.0, 0.0, 100.0, 1.0), Some(11.0)); + assert_eq!(keyed("pagedown", 5.0, 0.0, 100.0, 1.0), Some(0.0)); + assert_eq!(keyed("end", 5.0, 0.0, 100.0, 1.0), Some(100.0)); + assert_eq!(keyed("a", 5.0, 0.0, 100.0, 1.0), None); + } +} diff --git a/src/forms/stepper.rs b/src/forms/stepper.rs new file mode 100644 index 0000000..346ca7a --- /dev/null +++ b/src/forms/stepper.rs @@ -0,0 +1,109 @@ +use std::rc::Rc; + +use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Window, div}; + +use super::options::OnNumber; +use crate::{ + buttons::{ButtonVariant, IconButton}, + primitives::IconName, + theme::{ActiveTheme, ControlSize, Radius, TextSize}, + typography::format::{self, Separators}, +}; + +/// Minus and plus around a number that cannot be typed. +#[derive(IntoElement)] +pub struct Stepper { + id: ElementId, + value: f64, + min: f64, + max: f64, + step: f64, + precision: usize, + on_change: Option, +} + +impl Stepper { + pub fn new(id: impl Into, value: f64) -> Self { + Self { + id: id.into(), + value, + min: f64::MIN, + max: f64::MAX, + step: 1.0, + precision: 0, + on_change: None, + } + } + + pub fn range(mut self, min: f64, max: f64) -> Self { + assert!(min <= max, "stepper range {min}..{max} is empty"); + self.min = min; + self.max = max; + self + } + + pub fn step(mut self, step: f64) -> Self { + assert!(step > 0.0, "stepper step {step} must be positive"); + self.step = step; + self + } + + /// Decimal places shown. + pub fn precision(mut self, places: usize) -> Self { + self.precision = places; + 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 Stepper { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let (value, min, max) = (self.value, self.min, self.max); + let nudge = |by: f64| { + let (id, on_change) = (self.id.clone(), self.on_change.clone()); + move |_: &gpui::ClickEvent, window: &mut Window, cx: &mut App| { + let next = (value + by).clamp(min, max); + log::info!("stepper {id:?}: {next}"); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + } + }; + div() + .flex() + .items_center() + .h(theme.control_height(ControlSize::Md)) + .p_0p5() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(theme.colors.border_strong) + .bg(theme.colors.surface) + .child( + IconButton::new((self.id.clone(), "less"), IconName::Minus) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .disabled(value <= min) + .on_click(nudge(-self.step)), + ) + .child( + div() + .min_w(theme.control_height(ControlSize::Md)) + .text_center() + .text_size(theme.text_size(TextSize::Base)) + .text_color(theme.colors.fg) + .child(format::number(value, self.precision, Separators::EN)), + ) + .child( + IconButton::new((self.id.clone(), "more"), IconName::Plus) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .disabled(value >= max) + .on_click(nudge(self.step)), + ) + } +} diff --git a/src/forms/suggest.rs b/src/forms/suggest.rs deleted file mode 100644 index 3a7edd2..0000000 --- a/src/forms/suggest.rs +++ /dev/null @@ -1,79 +0,0 @@ -use std::rc::Rc; - -use gpui::{ - AnyElement, App, Bounds, ElementId, InteractiveElement, IntoElement, MouseButton, - ParentElement, Pixels, SharedString, Styled, anchored, deferred, div, point, prelude::*, -}; - -use crate::theme::{ActiveTheme, Elevation, Radius, TextSize}; - -/// One row in a suggestion list: a label and a quiet note. -#[derive(Clone, Debug)] -pub(crate) struct Suggestion { - pub label: SharedString, - pub note: Option, -} - -type Pick = Rc; -pub(crate) type Dismiss = Rc; - -/// A list floating under `anchor`. Rows keep focus where it was. -pub(crate) fn suggestion_list( - id: impl Into, - anchor: Bounds, - rows: &[Suggestion], - highlighted: usize, - pick: Pick, - dismiss: Option, - cx: &App, -) -> AnyElement { - let theme = cx.theme(); - let colors = &theme.colors; - let list = div() - .id(id) - .min_w(theme.tooltip_max_width()) - .p_1() - .flex() - .flex_col() - .rounded(theme.radius(Radius::Lg)) - .bg(colors.overlay) - .border_1() - .border_color(colors.border) - .shadow(theme.elevation(Elevation::Floating)) - .text_size(theme.text_size(TextSize::Sm)) - .when_some(dismiss, |list, dismiss| { - list.on_mouse_down_out(move |_, window, cx| dismiss(window, cx)) - }) - .children(rows.iter().enumerate().map(|(ix, row)| { - let pick = pick.clone(); - div() - .id(("suggestion", ix)) - .flex() - .items_center() - .justify_between() - .gap_4() - .px_2() - .py_1() - .rounded(theme.radius(Radius::Md)) - .text_color(colors.fg) - .when(ix == highlighted, |row| row.bg(colors.hover)) - .hover(|style| style.bg(colors.hover)) - .cursor_pointer() - .on_mouse_down(MouseButton::Left, move |_, window, cx| { - window.prevent_default(); - cx.stop_propagation(); - pick(ix, window, cx); - }) - .child(row.label.clone()) - .when_some(row.note.clone(), |row, note| { - row.child(div().text_color(colors.fg_subtle).child(note)) - }) - })); - deferred( - anchored() - .position(point(anchor.left(), anchor.bottom())) - .child(div().mt_1().child(list)), - ) - .with_priority(1) - .into_any_element() -} diff --git a/src/forms/switch.rs b/src/forms/switch.rs new file mode 100644 index 0000000..3c93fdd --- /dev/null +++ b/src/forms/switch.rs @@ -0,0 +1,144 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, App, ElementId, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, + prelude::*, transparent_black, +}; + +use super::{check::tab_stop, options::OnFlag}; +use crate::{ + motion, + theme::{ActiveTheme, Elevation, Mix, TextSize}, +}; + +/// An on-off switch. The thumb slides over with a small overshoot. +#[derive(IntoElement)] +pub struct Switch { + id: ElementId, + on: bool, + label: Option, + disabled: bool, + on_change: Option, +} + +impl Switch { + pub fn new(id: impl Into, on: bool) -> Self { + Self { + id: id.into(), + on, + label: None, + disabled: false, + on_change: None, + } + } + + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn on_change(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Switch { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focus = tab_stop( + (self.id.clone(), "focus").into(), + !self.disabled, + window, + cx, + ); + let changes = motion::changes((self.id.clone(), "changes"), self.on, window, cx); + let focused = focus.is_focused(window); + let theme = cx.theme(); + let colors = &theme.colors; + let track = theme.switch_track(); + let travel = track.width - track.height; + let (off, on) = (colors.border_strong, colors.accent); + let (knob_off, knob_on) = (colors.fg_muted, colors.on_accent); + let (from, to, fill_from, fill_to, knob_from, knob_to) = if self.on { + (0.0, 1.0, off, on, knob_off, knob_on) + } else { + (1.0, 0.0, on, off, knob_on, knob_off) + }; + let thumb = div() + .h_full() + .map(|mut thumb| { + thumb.style().aspect_ratio = Some(1.0); + thumb + }) + .rounded_full() + .bg(knob_to) + .shadow(theme.elevation(Elevation::Raised)); + let rail = div() + .flex() + .flex_none() + .items_center() + .w(track.width) + .h(track.height) + .p_0p5() + .rounded_full() + .border_1() + .border_color(if focused { + colors.focus + } else { + transparent_black() + }) + .bg(fill_to) + .when(self.disabled, |rail| rail.opacity(0.5)); + let rail = if changes == 0 { + rail.child(thumb.ml(travel * to)).into_any_element() + } else { + let duration = motion::duration(motion::BASE, cx); + rail.child(thumb.with_animation( + ("switch-thumb", changes), + Animation::new(duration), + move |thumb, t| { + thumb + .ml(travel * motion::lerp(from, to, motion::spring(t))) + .bg(knob_from.mix(&knob_to, t)) + }, + )) + .with_animation( + ("switch-fill", changes), + Animation::new(duration), + move |rail, t| rail.bg(fill_from.mix(&fill_to, t)), + ) + .into_any_element() + }; + let (id, next, on_change) = (self.id.clone(), !self.on, self.on_change); + div() + .id(self.id) + .track_focus(&focus) + .flex() + .items_center() + .gap_2() + .text_size(theme.text_size(TextSize::Base)) + .text_color(if self.disabled { + colors.fg_disabled + } else { + colors.fg + }) + .when(!self.disabled, |row| { + row.cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| { + log::info!("switch {id:?}: {}", if next { "on" } else { "off" }); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + }) + }) + .child(rail) + .children(self.label) + } +} diff --git a/src/forms/tags.rs b/src/forms/tags.rs index a3ca5bb..9315b8a 100644 --- a/src/forms/tags.rs +++ b/src/forms/tags.rs @@ -1,12 +1,12 @@ use std::rc::Rc; use gpui::{ - App, Context, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement, - RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div, - prelude::*, + App, Context, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription, + Window, div, prelude::*, }; -use super::{InputEvent, TextInput, text::Backspace}; +use super::{InputEvent, TextInput, options::Run, text::Backspace}; use crate::{ primitives::{Icon, IconName}, theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, @@ -45,6 +45,41 @@ impl Tags { } } +/// A small label with an x that removes it. +pub(crate) fn chip(id: ElementId, label: SharedString, remove: Run, cx: &App) -> Div { + let theme = cx.theme(); + let colors = &theme.colors; + 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)) + .text_color(colors.fg) + .child(label) + .child( + div() + .id(id) + .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| { + cx.stop_propagation(); + remove(window, cx) + }) + .child( + Icon::new(IconName::X) + .size(IconSize::Xs) + .color(colors.fg_muted), + ), + ) +} + /// A chip per tag, then a field. Enter or a comma adds; Backspace on empty drops the last. #[derive(IntoElement)] pub struct TagInput { @@ -111,39 +146,20 @@ impl RenderOnce for TagInput { 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) = (self.tags.clone(), self.on_change.clone()); + chip( + ("tag-remove", ix).into(), + tag.clone(), + Rc::new(move |window, cx| { + let mut next = tags.clone(); + next.remove(ix); + log::info!("tag input: removed one, {} left", next.len()); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + }), + cx, + ) }); let (tags, on_change, empty_check) = (self.tags.clone(), self.on_change.clone(), input.clone()); diff --git a/src/forms/tests/choices.rs b/src/forms/tests/choices.rs new file mode 100644 index 0000000..ceada08 --- /dev/null +++ b/src/forms/tests/choices.rs @@ -0,0 +1,313 @@ +use gpui::{ + AppContext as _, Context, Entity, IntoElement, KeyUpEvent, Keystroke, Render, SharedString, + TestAppContext, VisualTestContext, Window, +}; + +use super::setup; +use crate::forms::{ + Checkbox, Choice, Combobox, MultiSelect, RadioGroup, Rating, Select, Slider, TextInput, +}; + +fn abc(middle_off: bool) -> [Choice; 3] { + let middle = Choice::new("b", "B"); + [ + Choice::new("a", "A"), + if middle_off { + middle.disabled() + } else { + middle + }, + Choice::new("c", "C"), + ] +} + +struct Radios { + chosen: SharedString, +} + +fn release(key: &str, cx: &mut VisualTestContext) { + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).unwrap(), + }); +} + +impl Render for Radios { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + RadioGroup::new("radios", abc(true)) + .selected(self.chosen.clone()) + .on_change(move |value, _, cx| { + view.update(cx, |view, cx| { + view.chosen = value.clone(); + cx.notify(); + }) + }) + } +} + +#[gpui::test] +fn radio_arrows_skip_disabled_choices_and_wrap(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Radios { chosen: "a".into() }); + cx.update(|window, _| window.focus_next()); + cx.simulate_keystrokes("down"); + assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), "c"); + cx.simulate_keystrokes("down"); + assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), "a"); +} + +struct Picker { + chosen: SharedString, +} + +impl Render for Picker { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + Select::new("select", abc(false)) + .selected(self.chosen.clone()) + .on_change(move |value, _, cx| { + view.update(cx, |view, cx| { + view.chosen = value.clone(); + cx.notify(); + }) + }) + } +} + +#[gpui::test] +fn select_opens_moves_and_picks_from_the_keyboard(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Picker { chosen: "a".into() }); + cx.update(|window, _| window.focus_next()); + cx.simulate_keystrokes("down down enter"); + assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), "b"); +} + +struct Level { + value: f64, +} + +impl Render for Level { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + Slider::new("level", self.value).on_change(move |value, _, cx| { + view.update(cx, |view, cx| { + view.value = value; + cx.notify(); + }) + }) + } +} + +#[gpui::test] +fn slider_keys_step_jump_and_reach_the_end(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Level { value: 10.0 }); + cx.update(|window, _| window.focus_next()); + cx.simulate_keystrokes("right pageup"); + assert_eq!(view.read_with(cx, |view, _| view.value), 21.0); + cx.simulate_keystrokes("end"); + assert_eq!(view.read_with(cx, |view, _| view.value), 100.0); +} + +struct Agree { + on: bool, +} + +impl Render for Agree { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + Checkbox::new("agree", self.on) + .label("Agree") + .on_change(move |on, _, cx| { + view.update(cx, |view, cx| { + view.on = on; + cx.notify(); + }) + }) + } +} + +#[gpui::test] +fn space_toggles_a_focused_checkbox(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Agree { on: false }); + cx.update(|window, _| window.focus_next()); + cx.simulate_keystrokes("space"); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse("space").unwrap(), + }); + assert!(view.read_with(cx, |view, _| view.on)); +} + +#[gpui::test] +fn a_disabled_choice_still_leaves_the_group_reachable(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Radios { chosen: "b".into() }); + cx.update(|window, _| window.focus_next()); + cx.simulate_keystrokes("down"); + assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), "c"); +} + +struct Several { + chosen: Vec, +} + +impl Render for Several { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + MultiSelect::new("several", abc(false)) + .selected(self.chosen.clone()) + .on_change(move |next, _, cx| { + view.update(cx, |view, cx| { + view.chosen = next.to_vec(); + cx.notify(); + }) + }) + } +} + +#[gpui::test] +fn a_full_enter_press_opens_a_multi_select_and_keeps_it_open(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Several { chosen: Vec::new() }); + cx.update(|window, _| window.focus_next()); + cx.simulate_keystrokes("enter"); + release("enter", cx); + cx.simulate_keystrokes("enter"); + assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), ["a"]); +} + +struct Typed { + state: Entity, + chosen: Option, +} + +impl Render for Typed { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let mut combo = Combobox::new( + "typed", + &self.state, + [ + Choice::new("a", "A").disabled(), + Choice::new("b", "B"), + Choice::new("c", "C"), + ], + ) + .on_change(move |value, _, cx| { + view.update(cx, |view, cx| { + view.chosen = Some(value.clone()); + cx.notify(); + }) + }); + if let Some(value) = self.chosen.clone() { + combo = combo.selected(value); + } + combo + } +} + +fn typed<'a>( + chosen: Option<&str>, + cx: &'a mut TestAppContext, +) -> (Entity, &'a mut VisualTestContext) { + setup(cx); + let chosen = chosen.map(|value| SharedString::from(value.to_string())); + cx.add_window_view(move |window, cx| Typed { + state: cx.new(|cx| TextInput::new(window, cx)), + chosen, + }) +} + +#[gpui::test] +fn enter_in_a_combobox_skips_a_disabled_first_row(cx: &mut TestAppContext) { + let (view, cx) = typed(None, cx); + cx.update(|window, _| window.focus_next()); + cx.simulate_keystrokes("enter"); + assert_eq!( + view.read_with(cx, |view, _| view.chosen.clone()), + Some("b".into()) + ); +} + +#[gpui::test] +fn a_combobox_shows_the_value_its_owner_sets(cx: &mut TestAppContext) { + let (view, cx) = typed(Some("b"), cx); + let state = view.read_with(cx, |view, _| view.state.clone()); + assert_eq!( + state.read_with(cx, |input, _| input.text().to_string()), + "B" + ); + view.update(cx, |view, cx| { + view.chosen = Some("c".into()); + cx.notify(); + }); + cx.run_until_parked(); + assert_eq!( + state.read_with(cx, |input, _| input.text().to_string()), + "C" + ); +} + +struct Stars { + value: u8, + disabled: bool, +} + +impl Render for Stars { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + Rating::new("stars", self.value) + .disabled(self.disabled) + .on_change(move |value, _, cx| { + view.update(cx, |view, cx| { + view.value = value; + cx.notify(); + }) + }) + } +} + +#[gpui::test] +fn a_disabled_rating_ignores_the_arrows(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Stars { + value: 2, + disabled: false, + }); + cx.update(|window, _| window.focus_next()); + view.update(cx, |view, cx| { + view.disabled = true; + cx.notify(); + }); + cx.simulate_keystrokes("right"); + assert_eq!(view.read_with(cx, |view, _| view.value), 2); +} + +struct Free { + state: Entity, +} + +impl Render for Free { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Combobox::new("free", &self.state, abc(false)).free() + } +} + +#[gpui::test] +fn a_free_combobox_keeps_the_text_it_mounts_with(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|window, cx| Free { + state: cx.new(|cx| { + let mut input = TextInput::new(window, cx); + input.set_text("custom label", cx); + input + }), + }); + let state = view.read_with(cx, |view, _| view.state.clone()); + assert_eq!( + state.read_with(cx, |input, _| input.text().to_string()), + "custom label" + ); +} diff --git a/src/forms/tests.rs b/src/forms/tests/mod.rs similarity index 99% rename from src/forms/tests.rs rename to src/forms/tests/mod.rs index e61a3ba..4da1ee9 100644 --- a/src/forms/tests.rs +++ b/src/forms/tests/mod.rs @@ -1,3 +1,5 @@ +mod choices; + use gpui::{ AppContext as _, Context, Entity, EntityInputHandler, IntoElement, KeyUpEvent, Keystroke, ParentElement, Render, TestAppContext, VisualTestContext, Window, div, @@ -24,7 +26,7 @@ impl Render for Fields { } } -fn setup(cx: &mut TestAppContext) { +pub(super) fn setup(cx: &mut TestAppContext) { cx.update(|cx| { Theme::init(cx); bind_keys(cx); diff --git a/src/forms/transfer.rs b/src/forms/transfer.rs new file mode 100644 index 0000000..9c3b7f7 --- /dev/null +++ b/src/forms/transfer.rs @@ -0,0 +1,218 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, + prelude::*, +}; + +use super::{Choice, ListBox, options::OnValues}; +use crate::{ + buttons::{ButtonVariant, IconButton}, + primitives::IconName, + theme::{ActiveTheme, TextSize}, +}; + +/// Marked rows on each side, waiting to move. +#[derive(Default)] +struct Marks { + left: Vec, + right: Vec, +} + +/// `chosen` after moving `marked` across, in choice order. +fn moved( + choices: &[Choice], + chosen: &[SharedString], + marked: &[SharedString], + add: bool, +) -> Vec { + choices + .iter() + .map(|choice| &choice.value) + .filter(|value| { + if marked.contains(value) { + add + } else { + chosen.contains(value) + } + }) + .cloned() + .collect() +} + +/// Two lists and the buttons between them. Mark rows, then move them across. +#[derive(IntoElement)] +pub struct TransferList { + id: ElementId, + choices: Vec, + chosen: Vec, + titles: (SharedString, SharedString), + on_change: Option, +} + +impl TransferList { + pub fn new(id: impl Into, choices: impl IntoIterator) -> Self { + Self { + id: id.into(), + choices: choices.into_iter().collect(), + chosen: Vec::new(), + titles: ("Available".into(), "Chosen".into()), + on_change: None, + } + } + + pub fn chosen(mut self, values: impl IntoIterator>) -> Self { + self.chosen = values.into_iter().map(Into::into).collect(); + self + } + + pub fn titles(mut self, left: impl Into, right: impl Into) -> Self { + self.titles = (left.into(), right.into()); + self + } + + pub fn on_change( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TransferList { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let marks = window.use_keyed_state((self.id.clone(), "marks"), cx, |_, _| Marks::default()); + let (left, right) = { + let marks = marks.read(cx); + (marks.left.clone(), marks.right.clone()) + }; + let (available, picked): (Vec, Vec) = self + .choices + .iter() + .cloned() + .partition(|choice| !self.chosen.contains(&choice.value)); + let (choices, chosen) = (Rc::new(self.choices), Rc::new(self.chosen)); + let shift = |add: bool| { + let (id, choices, chosen, marks, on_change) = ( + self.id.clone(), + choices.clone(), + chosen.clone(), + marks.clone(), + self.on_change.clone(), + ); + move |_: &gpui::ClickEvent, window: &mut Window, cx: &mut App| { + let marked = if add { + marks.read(cx).left.clone() + } else { + marks.read(cx).right.clone() + }; + let next = moved(&choices, &chosen, &marked, add); + log::info!("transfer list {id:?}: {} chosen", next.len()); + marks.update(cx, |marks, cx| { + *marks = Marks::default(); + cx.notify(); + }); + if let Some(on_change) = &on_change { + on_change(&next, window, cx); + } + } + }; + let side = |key: &'static str, + title: SharedString, + rows: Vec, + marked: Vec, + cx: &App| { + let theme = cx.theme(); + let store = marks.clone(); + div() + .flex_1() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(format!("{title} · {}", rows.len())), + ) + .child(div().h(theme.list_max_height()).map(|frame| { + if rows.is_empty() { + return frame; + } + frame.child( + ListBox::new((self.id.clone(), key), rows) + .multiple() + .selected(marked) + .on_change(move |next, _, cx| { + let next = next.to_vec(); + store.update(cx, |marks, cx| { + if key == "left" { + marks.left = next; + } else { + marks.right = next; + } + cx.notify(); + }) + }), + ) + })) + }; + let buttons = div() + .flex() + .flex_col() + .justify_center() + .gap_2() + .child( + IconButton::new((self.id.clone(), "add"), IconName::ChevronRight) + .variant(ButtonVariant::Outline) + .tooltip("Move to chosen") + .disabled(left.is_empty()) + .on_click(shift(true)), + ) + .child( + IconButton::new((self.id.clone(), "remove"), IconName::ChevronLeft) + .variant(ButtonVariant::Outline) + .tooltip("Move back") + .disabled(right.is_empty()) + .on_click(shift(false)), + ); + let (available_title, chosen_title) = self.titles.clone(); + div() + .flex() + .gap_3() + .w_full() + .child(side("left", available_title, available, left, cx)) + .child(buttons) + .child(side("right", chosen_title, picked, right, cx)) + } +} + +#[cfg(test)] +mod tests { + use gpui::SharedString; + + use super::{Choice, moved}; + + #[test] + fn moving_keeps_choice_order() { + let rows = [ + Choice::new("a", "A"), + Choice::new("b", "B"), + Choice::new("c", "C"), + ]; + let values = |list: &[&str]| { + list.iter() + .map(|v| SharedString::from(v.to_string())) + .collect::>() + }; + assert_eq!( + moved(&rows, &values(&["c"]), &values(&["a"]), true), + values(&["a", "c"]) + ); + assert_eq!( + moved(&rows, &values(&["a", "c"]), &values(&["c"]), false), + values(&["a"]) + ); + } +} diff --git a/src/motion/changes.rs b/src/motion/changes.rs new file mode 100644 index 0000000..ebe6655 --- /dev/null +++ b/src/motion/changes.rs @@ -0,0 +1,26 @@ +use gpui::{App, ElementId, Window}; + +struct Changes { + last: T, + count: usize, +} + +/// How often `value` changed since `id` first rendered. Key an animation on it to replay per change. +pub(crate) fn changes( + id: impl Into, + value: T, + window: &mut Window, + cx: &mut App, +) -> usize { + let state = window.use_keyed_state(id, cx, |_, _| Changes { + last: value.clone(), + count: 0, + }); + if state.read(cx).last != value { + state.update(cx, |state, _| { + state.last = value; + state.count += 1; + }); + } + state.read(cx).count +} diff --git a/src/motion/mod.rs b/src/motion/mod.rs index cbbeefb..2a7a26a 100644 --- a/src/motion/mod.rs +++ b/src/motion/mod.rs @@ -1,3 +1,5 @@ +mod changes; mod curve; +pub(crate) use changes::changes; pub use curve::*; diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index 15f6dce..0b3883e 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -1,6 +1,6 @@ use gpui::{ App, Hsla, IntoElement, Radians, RenderOnce, SharedString, Transformation, Window, prelude::*, - svg, + size, svg, }; use crate::theme::{ActiveTheme, IconSize}; @@ -261,6 +261,7 @@ pub struct Icon { color: Option, hover: Option<(SharedString, Hsla)>, rotation: Option, + scale: f32, } impl Icon { @@ -271,6 +272,7 @@ impl Icon { color: None, hover: None, rotation: None, + scale: 1.0, } } @@ -299,6 +301,12 @@ impl Icon { self.rotation = Some(angle.into()); self } + + /// Draws the glyph larger or smaller inside the same box. + pub fn scale(mut self, factor: f32) -> Self { + self.scale = factor; + self + } } impl From for Icon { @@ -319,8 +327,11 @@ impl RenderOnce for Icon { .when_some(self.hover, |svg, (group, hover)| { svg.group_hover(group, |style| style.text_color(hover)) }) - .when_some(self.rotation, |svg, angle| { - svg.with_transformation(Transformation::rotate(angle)) + .when(self.rotation.is_some() || self.scale != 1.0, |svg| { + svg.with_transformation( + Transformation::rotate(self.rotation.unwrap_or(Radians(0.0))) + .with_scaling(size(self.scale, self.scale)), + ) }) } } diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index a7959e7..b0b2bda 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -233,6 +233,39 @@ impl Theme { px_to_rems(18.0) } + /// A checkbox or radio mark. + pub fn check_size(&self) -> Rems { + px_to_rems(16.0) + } + + /// The dot inside a chosen radio. + pub fn radio_dot(&self) -> Rems { + px_to_rems(6.0) + } + + /// A switch's track; its thumb fills the height inside a small inset. + pub fn switch_track(&self) -> Size { + size(px_to_rems(32.0), px_to_rems(18.0)) + } + + pub fn slider_track(&self) -> Rems { + px_to_rems(4.0) + } + + pub fn slider_thumb(&self) -> Rems { + px_to_rems(16.0) + } + + /// Diameter and arc width of a knob. + pub fn knob(&self) -> (Rems, Rems) { + (px_to_rems(48.0), px_to_rems(3.0)) + } + + /// Tallest a list grows before it scrolls. + pub fn list_max_height(&self) -> Rems { + px_to_rems(280.0) + } + pub fn tooltip_max_width(&self) -> Rems { px_to_rems(256.0) } diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index be28c98..4e6628d 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -198,28 +198,28 @@ ## T06b Forms · Selection -- [ ] Checkbox -- [ ] CheckboxGroup -- [ ] CheckboxCard -- [ ] Radio / RadioGroup -- [ ] RadioCard -- [ ] Switch / Toggle -- [ ] Select -- [ ] NativeSelect — blocked: gpui 0.2.2 has no native popup menu -- [ ] Combobox -- [ ] Autocomplete -- [ ] MultiSelect -- [ ] CascadeSelect / Cascader -- [ ] TreeSelect -- [ ] TransferList -- [ ] ListBox -- [ ] ChoiceChips / FilterChips -- [ ] Rating -- [ ] Slider -- [ ] RangeSlider -- [ ] VerticalSlider → `forms::Slider` -- [ ] Knob / Dial -- [ ] Stepper +- [x] Checkbox +- [x] CheckboxGroup +- [x] CheckboxCard +- [x] Radio / RadioGroup +- [x] RadioCard +- [x] Switch / Toggle +- [x] Select +- [x] NativeSelect — blocked: gpui 0.2.2 has no native popup menu +- [x] Combobox +- [x] Autocomplete → `forms::Combobox::free` +- [x] MultiSelect +- [x] CascadeSelect / Cascader +- [x] TreeSelect → lands with Lists & Trees (T13): its list is a Tree +- [x] TransferList +- [x] ListBox +- [x] ChoiceChips / FilterChips +- [x] Rating +- [x] Slider +- [x] RangeSlider +- [x] VerticalSlider → `forms::Slider::vertical` +- [x] Knob / Dial +- [x] Stepper ## T06c Forms · Date & Time diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index a778b7d..a7b6755 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -85,7 +85,7 @@ - [ ] SectionList → `lists::GroupedList` - [ ] ReorderableList → `lists::SortableList` - [ ] SwipeableListItem — prove: horizontal trackpad scroll -- [ ] Tree / TreeView +- [ ] Tree / TreeView — also T06b TreeSelect - [ ] VirtualTree → `lists::Tree` - [ ] FileTree - [ ] CheckboxTree