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:
36 files changed
+5170
-198
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(¤t),
|
||||
pick,
|
||||
dismiss: Some(Rc::new(move |_, cx| {
|
||||
dismiss.update(cx, |picker, cx| {
|
||||
picker.open = false;
|
||||
cx.notify();
|
||||
})
|
||||
})),
|
||||
scroll: None,
|
||||
}
|
||||
.render(window, cx),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+19
-15
@@ -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
@@ -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;
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
@@ -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"])
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user