T06b forms selection: checkbox, radio, cards, switch, select, combobox, multi select, cascader, transfer list, list box, chips, rating, sliders, knob, stepper

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 06:41:35 -04:00
1 parent ec5713c10b
commit e39d623b58
36 files changed
+5170 -198

No files matched your search

+2
View File
@@ -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.
+4 -1
View File
@@ -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 | |
+214
View File
@@ -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<T: 'static>(
key: &'static str,
init: impl FnOnce() -> T,
window: &mut Window,
cx: &mut App,
) -> Entity<T> {
window.use_keyed_state(key, cx, move |_, _| init())
}
pub(super) fn set<T: 'static>(state: &Entity<T>, value: T, cx: &mut App) {
state.update(cx, |state, cx| {
*state = value;
cx.notify();
});
}
fn values(list: &[&'static str]) -> Vec<SharedString> {
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<SharedString> = 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),
),
))
}
+85
View File
@@ -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()
}
+235
View File
@@ -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<Choice> {
[
("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::<SharedString>, window, cx);
let now = chosen.read(cx).clone();
let labels: Vec<Choice> = ["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)),
),
))
}
+176
View File
@@ -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,
)),
)
}
+243
View File
@@ -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<SharedString>,
icon: Option<IconName>,
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<Div> {
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<OnFlag>,
}
impl CheckboxCard {
pub fn new(id: impl Into<ElementId>, checked: bool, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
checked,
face: Face::new(title.into(), checked),
on_change: None,
}
}
pub fn description(mut self, text: impl Into<SharedString>) -> 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<Run>,
}
impl RadioCard {
pub fn new(id: impl Into<ElementId>, selected: bool, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
selected,
face: Face::new(title.into(), selected),
on_select: None,
}
}
pub fn description(mut self, text: impl Into<SharedString>) -> 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)
}
}
+430
View File
@@ -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<Cascade>,
}
impl Cascade {
pub fn new(value: impl Into<SharedString>, label: impl Into<SharedString>) -> 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<usize> {
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<usize> {
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<usize>,
anchor: Bounds<Pixels>,
}
fn show(state: &Entity<Trail>, open: bool, trail: Vec<usize>, cx: &mut App) {
state.update(cx, |state, cx| {
state.open = open;
state.trail = trail;
cx.notify();
});
}
type PickTrail = Rc<dyn Fn(Vec<usize>, &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<Cascade>,
selected: Vec<SharedString>,
placeholder: SharedString,
size: ControlSize,
on_change: Option<OnValues>,
}
impl Cascader {
pub fn new(id: impl Into<ElementId>, roots: impl IntoIterator<Item = Cascade>) -> 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<Item = impl Into<SharedString>>) -> Self {
self.selected = path.into_iter().map(Into::into).collect();
self
}
pub fn placeholder(mut self, text: impl Into<SharedString>) -> 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<SharedString> = 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<SharedString> = 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<usize> = 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<Cascade> {
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<SharedString> = 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]);
}
}
+303
View File
@@ -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<bool> 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<SharedString>,
disabled: bool,
on_change: Option<OnFlag>,
}
impl Checkbox {
pub fn new(id: impl Into<ElementId>, state: impl Into<CheckState>) -> Self {
Self {
id: id.into(),
state: state.into(),
label: None,
disabled: false,
on_change: None,
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> 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<Choice>,
selected: Vec<SharedString>,
on_change: Option<OnValues>,
}
impl CheckboxGroup {
pub fn new(id: impl Into<ElementId>, choices: impl IntoIterator<Item = Choice>) -> Self {
Self {
id: id.into(),
choices: choices.into_iter().collect(),
selected: Vec::new(),
on_change: None,
}
}
pub fn selected(mut self, values: impl IntoIterator<Item = impl Into<SharedString>>) -> 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<SharedString> {
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::<Vec<_>>()
};
assert_eq!(
toggled(&choices, &on(&["c"]), &"a".into(), true),
on(&["a", "c"])
);
assert_eq!(
toggled(&choices, &on(&["a", "c"]), &"a".into(), false),
on(&["c"])
);
}
}
+121
View File
@@ -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<Choice>,
selected: Vec<SharedString>,
multiple: bool,
on_change: Option<OnValues>,
}
impl ChoiceChips {
pub fn new(id: impl Into<ElementId>, choices: impl IntoIterator<Item = Choice>) -> 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<Item = impl Into<SharedString>>) -> 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)
}
}
+286
View File
@@ -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<Choice> {
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<SharedString>,
dismissed: bool,
last: String,
shown: Option<Option<SharedString>>,
_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<TextInput>,
choices: Vec<Choice>,
selected: Option<SharedString>,
free: bool,
size: ControlSize,
on_change: Option<OnValue>,
}
impl Combobox {
pub fn new(
id: impl Into<ElementId>,
state: &Entity<TextInput>,
choices: impl IntoIterator<Item = Choice>,
) -> 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<SharedString>) -> 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<Typing>| {
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<SharedString> = 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::<Vec<_>>()
};
assert_eq!(labels("LI"), ["Berlin", "Lisbon"]);
assert_eq!(labels(""), ["Berlin", "Lisbon", "Oslo"]);
assert!(labels("xyz").is_empty());
}
}
+196
View File
@@ -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<Pixels>, radius: Pixels, angle: f32) -> Point<Pixels> {
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<Pixels>, radius: Pixels, from: f32, to: f32, width: Pixels) -> Path<Pixels> {
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<Cell<Option<(Pixels, f64)>>>,
}
/// 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<OnNumber>,
}
impl Knob {
pub fn new(id: impl Into<ElementId>, 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<Turn>, 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);
}
})
})
}
}
+193
View File
@@ -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<SharedString> {
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<Choice>,
selected: Vec<SharedString>,
multiple: bool,
on_change: Option<OnValues>,
}
impl ListBox {
pub fn new(id: impl Into<ElementId>, choices: impl IntoIterator<Item = Choice>) -> 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<Item = impl Into<SharedString>>) -> 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::<Vec<_>>()
};
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"]));
}
}
+22 -23
View File
@@ -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<Suggestion> = COUNTRIES
let rows: Vec<Choice> = 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(&current),
pick,
dismiss: Some(Rc::new(move |_, cx| {
dismiss.update(cx, |picker, cx| {
picker.open = false;
cx.notify();
})
})),
scroll: None,
}
.render(window, cx),
)
})
}
}
+19 -15
View File
@@ -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<Suggestion> = matches
let rows: Vec<Choice> = 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),
)
})
}
}
+32 -1
View File
@@ -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;
+232
View File
@@ -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<Choice>,
selected: Vec<SharedString>,
placeholder: SharedString,
size: ControlSize,
on_change: Option<OnValues>,
}
impl MultiSelect {
pub fn new(id: impl Into<ElementId>, choices: impl IntoIterator<Item = Choice>) -> 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<Item = impl Into<SharedString>>) -> Self {
self.selected = values.into_iter().map(Into::into).collect();
self
}
pub fn placeholder(mut self, text: impl Into<SharedString>) -> 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),
)
})
}
}
+255
View File
@@ -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<IconName>,
pub note: Option<SharedString>,
pub disabled: bool,
}
impl Choice {
pub fn new(value: impl Into<SharedString>, label: impl Into<SharedString>) -> 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<SharedString>) -> Self {
self.note = Some(note.into());
self
}
pub fn disabled(mut self) -> Self {
self.disabled = true;
self
}
}
pub(crate) type Pick = Rc<dyn Fn(usize, &mut Window, &mut App)>;
pub(crate) type Run = Rc<dyn Fn(&mut Window, &mut App)>;
pub(crate) type OnFlag = Rc<dyn Fn(bool, &mut Window, &mut App)>;
pub(crate) type OnNumber = Rc<dyn Fn(f64, &mut Window, &mut App)>;
pub(crate) type OnValue = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
pub(crate) type OnValues = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
/// 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<ElementId>,
choice: &Choice,
highlighted: bool,
checked: Option<bool>,
cx: &App,
) -> Stateful<Div> {
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<ElementId>, cx: &App) -> Stateful<Div> {
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<Pixels>,
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<Pixels>,
pub rows: &'a [Choice],
pub highlighted: Option<usize>,
pub checked: Option<&'a [SharedString]>,
pub pick: Pick,
pub dismiss: Option<Run>,
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);
}
}
+258
View File
@@ -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<SharedString>,
disabled: bool,
focus: Option<FocusHandle>,
on_select: Option<Run>,
}
impl Radio {
pub fn new(id: impl Into<ElementId>, 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<SharedString>) -> 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<Choice>,
selected: Option<SharedString>,
horizontal: bool,
on_change: Option<OnValue>,
}
impl RadioGroup {
pub fn new(id: impl Into<ElementId>, choices: impl IntoIterator<Item = Choice>) -> 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<SharedString>) -> 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<FocusHandle>, 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<FocusHandle> = 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)
}
}
+189
View File
@@ -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<Pixels>, fill: bool) -> Path<Pixels> {
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<Pixels>, 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<dyn Fn(u8, &mut Window, &mut App)>;
/// 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<OnRate>,
}
impl Rating {
pub fn new(id: impl Into<ElementId>, 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::<u8>);
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)
}
}
+16 -15
View File
@@ -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<Suggestion> = self
let rows: Vec<Choice> = 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),
)
})
}
}
+283
View File
@@ -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<Pixels>,
pub scroll: ScrollHandle,
}
impl Picker {
pub fn show(state: &Entity<Picker>, 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<Picker>) -> 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<usize> {
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<Choice>,
selected: Option<SharedString>,
placeholder: SharedString,
size: ControlSize,
disabled: bool,
on_change: Option<OnValue>,
}
impl Select {
pub fn new(id: impl Into<ElementId>, choices: impl IntoIterator<Item = Choice>) -> 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<SharedString>) -> Self {
self.selected = Some(value.into());
self
}
pub fn placeholder(mut self, text: impl Into<SharedString>) -> 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<SharedString> = 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),
)
})
}
}
+438
View File
@@ -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<f64> {
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<Pixels>, at: Point<Pixels>, 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<usize>,
}
type Commit = Rc<dyn Fn(usize, f64, &mut Window, &mut App)>;
type OnSpan = Rc<dyn Fn((f64, f64), &mut Window, &mut App)>;
/// A track with one or two thumbs. `commit` gets the thumb and its new value.
struct Track {
id: ElementId,
values: Vec<f64>,
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::<Pixels>::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<f32> = 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<Thumb>, 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<OnNumber>,
}
impl Slider {
pub fn new(id: impl Into<ElementId>, 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<OnSpan>,
}
impl RangeSlider {
pub fn new(id: impl Into<ElementId>, 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);
}
}
+109
View File
@@ -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<OnNumber>,
}
impl Stepper {
pub fn new(id: impl Into<ElementId>, value: f64) -> Self {
Self {
id: id.into(),
value,
min: f64::MIN,
max: f64::MAX,
step: 1.0,
precision: 0,
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)),
)
}
}
-79
View File
@@ -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<SharedString>,
}
type Pick = Rc<dyn Fn(usize, &mut gpui::Window, &mut App)>;
pub(crate) type Dismiss = Rc<dyn Fn(&mut gpui::Window, &mut App)>;
/// A list floating under `anchor`. Rows keep focus where it was.
pub(crate) fn suggestion_list(
id: impl Into<ElementId>,
anchor: Bounds<Pixels>,
rows: &[Suggestion],
highlighted: usize,
pick: Pick,
dismiss: Option<Dismiss>,
cx: &App,
) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let list = div()
.id(id)
.min_w(theme.tooltip_max_width())
.p_1()
.flex()
.flex_col()
.rounded(theme.radius(Radius::Lg))
.bg(colors.overlay)
.border_1()
.border_color(colors.border)
.shadow(theme.elevation(Elevation::Floating))
.text_size(theme.text_size(TextSize::Sm))
.when_some(dismiss, |list, dismiss| {
list.on_mouse_down_out(move |_, window, cx| dismiss(window, cx))
})
.children(rows.iter().enumerate().map(|(ix, row)| {
let pick = pick.clone();
div()
.id(("suggestion", ix))
.flex()
.items_center()
.justify_between()
.gap_4()
.px_2()
.py_1()
.rounded(theme.radius(Radius::Md))
.text_color(colors.fg)
.when(ix == highlighted, |row| row.bg(colors.hover))
.hover(|style| style.bg(colors.hover))
.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
pick(ix, window, cx);
})
.child(row.label.clone())
.when_some(row.note.clone(), |row, note| {
row.child(div().text_color(colors.fg_subtle).child(note))
})
}));
deferred(
anchored()
.position(point(anchor.left(), anchor.bottom()))
.child(div().mt_1().child(list)),
)
.with_priority(1)
.into_any_element()
}
+144
View File
@@ -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<SharedString>,
disabled: bool,
on_change: Option<OnFlag>,
}
impl Switch {
pub fn new(id: impl Into<ElementId>, on: bool) -> Self {
Self {
id: id.into(),
on,
label: None,
disabled: false,
on_change: None,
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> 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)
}
}
+53 -37
View File
@@ -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());
+313
View File
@@ -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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<SharedString>,
}
impl Render for Several {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<TextInput>,
chosen: Option<SharedString>,
}
impl Render for Typed {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Typed>, &'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<Self>) -> 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<TextInput>,
}
impl Render for Free {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> 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"
);
}
@@ -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);
+218
View File
@@ -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<SharedString>,
right: Vec<SharedString>,
}
/// `chosen` after moving `marked` across, in choice order.
fn moved(
choices: &[Choice],
chosen: &[SharedString],
marked: &[SharedString],
add: bool,
) -> Vec<SharedString> {
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<Choice>,
chosen: Vec<SharedString>,
titles: (SharedString, SharedString),
on_change: Option<OnValues>,
}
impl TransferList {
pub fn new(id: impl Into<ElementId>, choices: impl IntoIterator<Item = Choice>) -> 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<Item = impl Into<SharedString>>) -> Self {
self.chosen = values.into_iter().map(Into::into).collect();
self
}
pub fn titles(mut self, left: impl Into<SharedString>, right: impl Into<SharedString>) -> 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<Choice>, Vec<Choice>) = 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<Choice>,
marked: Vec<SharedString>,
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::<Vec<_>>()
};
assert_eq!(
moved(&rows, &values(&["c"]), &values(&["a"]), true),
values(&["a", "c"])
);
assert_eq!(
moved(&rows, &values(&["a", "c"]), &values(&["c"]), false),
values(&["a"])
);
}
}
+26
View File
@@ -0,0 +1,26 @@
use gpui::{App, ElementId, Window};
struct Changes<T> {
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<T: Clone + PartialEq + 'static>(
id: impl Into<ElementId>,
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
}
+2
View File
@@ -1,3 +1,5 @@
mod changes;
mod curve;
pub(crate) use changes::changes;
pub use curve::*;
+14 -3
View File
@@ -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<Hsla>,
hover: Option<(SharedString, Hsla)>,
rotation: Option<Radians>,
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<IconName> 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)),
)
})
}
}
+33
View File
@@ -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<Rems> {
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)
}
+22 -22
View File
@@ -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
+1 -1
View File
@@ -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