Files
Ely-GPUI-Components/src/mail/labels.rs
T
ZacharyZhang-NY 81e73b79c1 Capture · Roots: roots drop w_full; hosts give the width
145 component roots drop w_full, the early returns included. Three
field rows keep it, Input, InputGroup and TagInput: stretched without
a width, taffy 0.9 laid the field's flex_1 share at no width in the
template picker at 280px, and the placeholder drew nothing. Container
keeps it, since mx_auto needs a width to fill.

Hosts that relied on a root's width now give it: MessageList boxes
each row at the list's width, the splash gives its bar the column's,
and test stages that mount a component as the window's root sit in a
size_full box. Tests hold a narrow row across the list and the splash
bar across its column; each goes red without its fix.

The gallery gains --narrow <px>, which lays each page in a padded
card at that width and shoots it without its script. The page
header wraps: the title takes the room and the theme switcher drops
below, where a long summary used to push it past the edge. A file
panel step waits for the panel to become key instead of a fixed
pause, which lost a race in a capture.
2026-09-28 13:07:22 -04:00

279 lines
10 KiB
Rust

use std::rc::Rc;
use gpui::{
AnyElement, App, Context, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
ParentElement, Point, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement,
Styled, Window, div, prelude::*,
};
use crate::{
data_display::color_mark,
forms::{
Choice, Down, Enter, OnValue, OnValues, Pick, SearchInput, TextInput, Up, marked_row,
reveal, revealer,
},
layout::on_axis,
primitives::IconName,
theme::{ActiveTheme, TextSize},
};
/// A label: its key, its name, and its hue among the theme's chart colors.
#[derive(Clone, Debug, PartialEq)]
pub struct Label {
pub key: SharedString,
pub name: SharedString,
pub hue: usize,
}
impl Label {
pub fn new(key: impl Into<SharedString>, name: impl Into<SharedString>, hue: usize) -> Self {
Self {
key: key.into(),
name: name.into(),
hue,
}
}
}
/// Label `name`'s dot in the chart color at `hue`, centered in an icon's box.
pub(super) fn hue_dot(name: &str, hue: usize, cx: &App) -> Div {
color_mark(cx.theme().colors.hue(hue, format_args!("label {name}")), cx)
}
/// The search field, the row under the keyboard, the query it was set for, and the list's scroll.
struct Finding {
search: Entity<TextInput>,
at: usize,
query: String,
revealed: Option<usize>,
scroll: ScrollHandle,
}
/// Moves the row under the keyboard by `by` among `count`, wrapping.
fn step(finding: &Entity<Finding>, count: usize, by: isize, cx: &mut App) {
if count == 0 {
return;
}
finding.update(cx, |finding, cx| {
let at = finding.at.min(count - 1) as isize;
finding.at = (at + by).rem_euclid(count as isize) as usize;
cx.notify();
})
}
/// Labels to put on a message: the field finds them by name, arrows move between them, and a press or Enter turns one on or off. With `on_create`, a name no label has can be made into one.
#[derive(IntoElement)]
pub struct LabelPicker {
id: ElementId,
labels: Vec<Label>,
selected: Vec<SharedString>,
on_change: Option<OnValues>,
on_create: Option<OnValue>,
}
impl LabelPicker {
pub fn new(id: impl Into<ElementId>, labels: impl IntoIterator<Item = Label>) -> Self {
let labels: Vec<Label> = labels.into_iter().collect();
for (ix, label) in labels.iter().enumerate() {
let before = &labels[..ix];
let key = before.iter().any(|other| other.key == label.key);
assert!(!key, "label {} twice", label.key);
let name = label.name.to_lowercase();
let named = before.iter().any(|other| other.name.to_lowercase() == name);
assert!(!named, "label name {} twice", label.name);
}
Self {
id: id.into(),
labels,
selected: Vec::new(),
on_change: None,
on_create: None,
}
}
/// The keys of the labels on.
pub fn selected(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.selected = keys.into_iter().map(Into::into).collect();
self
}
/// Gets the keys on, in the labels' order, each time one turns on or off.
pub fn on_change(
mut self,
handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
/// Offers to make a label of a name no label has, and gets the name. The owner adds the label, on if it should be.
pub fn on_create(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_create = Some(Rc::new(handler));
self
}
}
impl RenderOnce for LabelPicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
for key in &self.selected {
let known = self.labels.iter().any(|label| label.key == *key);
assert!(known, "label picker {:?}: no label {key}", self.id);
}
let finding = window.use_keyed_state(
(self.id.clone(), "finding"),
cx,
|window, cx: &mut Context<Finding>| Finding {
search: cx.new(|cx| TextInput::new(window, cx).placeholder("Find or make a label")),
at: 0,
query: String::new(),
revealed: None,
scroll: ScrollHandle::new(),
},
);
let search = finding.read(cx).search.clone();
let query = search.read(cx).text().trim().to_string();
if finding.read(cx).query != query {
finding.update(cx, |finding, _| {
finding.query = query.clone();
finding.at = 0;
finding.scroll.set_offset(Point::default());
});
}
let lower = query.to_lowercase();
let fits: Vec<&Label> = self
.labels
.iter()
.filter(|label| label.name.to_lowercase().contains(&lower))
.collect();
let fresh = self.on_create.is_some()
&& !query.is_empty()
&& !self
.labels
.iter()
.any(|label| label.name.to_lowercase() == lower);
let count = fits.len() + usize::from(fresh);
let at = finding.read(cx).at.min(count.saturating_sub(1));
let keyed = search.read(cx).focus().is_focused(window);
let shown = reveal(&finding, |finding| &mut finding.revealed, count > 0, at, cx);
let scroll = finding.read(cx).scroll.clone();
let selected = Rc::new(self.selected);
let pick: Pick = {
let keys: Vec<SharedString> = fits.iter().map(|label| label.key.clone()).collect();
let all: Vec<SharedString> =
self.labels.iter().map(|label| label.key.clone()).collect();
let (id, selected, search) = (self.id.clone(), selected.clone(), search.clone());
let (on_change, on_create) = (self.on_change, self.on_create.clone());
let name = SharedString::from(query.clone());
Rc::new(move |ix, window, cx| {
let Some(key) = keys.get(ix) else {
log::info!("label picker {id:?}: create {name}");
search.update(cx, |input, cx| input.set_text(String::new(), cx));
if let Some(on_create) = &on_create {
on_create(&name, window, cx);
}
return;
};
let on = selected.contains(key);
log::info!(
"label picker {id:?}: {key} {}",
if on { "off" } else { "on" }
);
let next: Vec<SharedString> = all
.iter()
.filter(|each| {
if *each == key {
!on
} else {
selected.contains(each)
}
})
.cloned()
.collect();
if let Some(on_change) = &on_change {
on_change(&next, window, cx);
}
})
};
let row = |ix: usize, choice: Choice, mark: Option<AnyElement>, on: bool| {
let pick = pick.clone();
let key = choice.value.clone();
marked_row(
(self.id.clone(), format!("label-{key}")),
&choice,
mark,
keyed && ix == at,
Some(on),
cx,
)
.debug_selector(move || format!("label {key}"))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| pick(ix, window, cx))
};
let mut rows: Vec<AnyElement> = fits
.iter()
.enumerate()
.map(|(ix, label)| {
let choice = Choice::new(label.key.clone(), label.name.clone());
let dot = hue_dot(&label.name, label.hue, cx).into_any_element();
row(ix, choice, Some(dot), selected.contains(&label.key)).into_any_element()
})
.collect();
if fresh {
let choice = Choice::new("+create", format!("Create “{query}”")).icon(IconName::Plus);
rows.push(row(fits.len(), choice, None, false).into_any_element());
}
let theme = cx.theme();
let none = rows.is_empty().then(|| {
div()
.px_2()
.py_2()
.text_color(theme.colors.fg_subtle)
.child(match (self.labels.is_empty(), self.on_create.is_some()) {
(true, true) => "No labels yet. Type a name to make one.",
(true, false) => "No labels yet.",
(false, _) => "No label fits.",
})
});
let (up, down, chosen) = (finding.clone(), finding.clone(), finding);
div()
.id(self.id.clone())
.flex()
.flex_col()
.gap_2()
.capture_action(move |_: &Up, _, cx| {
cx.stop_propagation();
step(&up, count, -1, cx);
})
.capture_action(move |_: &Down, _, cx| {
cx.stop_propagation();
step(&down, count, 1, cx);
})
.capture_action(move |_: &Enter, window, cx| {
if count > 0 {
cx.stop_propagation();
let at = chosen.read(cx).at.min(count - 1);
pick(at, window, cx);
}
})
.child(SearchInput::new((self.id.clone(), "search"), &search))
.child(
on_axis(
div()
.id((self.id.clone(), "labels"))
.track_scroll(&scroll)
.max_h(theme.list_max_height())
.overflow_y_scroll(),
)
.flex()
.flex_col()
.text_size(theme.text_size(TextSize::Sm))
.children(rows)
.children(none)
.children(shown.map(|(ix, done)| revealer(&scroll, ix, done))),
)
}
}