Files
Ely-GPUI-Components/src/documents/templates.rs
T

345 lines
13 KiB
Rust

use std::rc::Rc;
use gpui::{
App, AppContext as _, Context, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement, Styled,
Window, div, prelude::*,
};
use super::render::MarkdownRenderer;
use crate::{
buttons::{Button, ButtonVariant},
forms::{Choice, ChoiceChips, Input, Pick, TextInput},
i18n,
primitives::{FocusRing as _, Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// Every category at once.
const ALL: &str = "All";
/// A page to start from: its name, icon, category, a line about it, and its text as markdown.
#[derive(Clone, Debug, PartialEq)]
pub struct Template {
pub name: SharedString,
pub icon: SharedString,
pub category: SharedString,
pub description: SharedString,
pub markdown: SharedString,
}
/// What the picker holds: its search, the category, and the template in view.
struct Picking {
search: Entity<TextInput>,
category: SharedString,
highlighted: usize,
}
/// The templates `query` and `category` keep, by index, in order.
pub(crate) fn kept(templates: &[Template], query: &str, category: &str) -> Vec<usize> {
let query = query.trim().to_lowercase();
templates
.iter()
.enumerate()
.filter(|(_, template)| category == ALL || template.category == category)
.filter(|(_, template)| {
query.is_empty()
|| template.name.to_lowercase().contains(&query)
|| template.description.to_lowercase().contains(&query)
})
.map(|(ix, _)| ix)
.collect()
}
/// Pages to start from: found by name, kept by category, the one in view shown as it will read; Use takes it.
#[derive(IntoElement)]
pub struct TemplatePicker {
id: ElementId,
templates: Rc<Vec<Template>>,
on_use: Option<Pick>,
}
impl TemplatePicker {
pub fn new(id: impl Into<ElementId>, templates: impl Into<Rc<Vec<Template>>>) -> Self {
let templates = templates.into();
assert!(!templates.is_empty(), "a template picker has templates");
Self {
id: id.into(),
templates,
on_use: None,
}
}
/// Gets the index of the template taken.
pub fn on_use(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_use = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TemplatePicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let picking = window.use_keyed_state(
(self.id.clone(), "picking"),
cx,
|window, cx: &mut Context<Picking>| Picking {
search: cx.new(|cx| {
TextInput::new(window, cx).placeholder(i18n::text(cx, "templates.find", &[]))
}),
category: ALL.into(),
highlighted: 0,
},
);
let (search, category, highlighted) = {
let picking = picking.read(cx);
(
picking.search.clone(),
picking.category.clone(),
picking.highlighted,
)
};
let query = search.read(cx).text().to_string();
let shown = kept(&self.templates, &query, &category);
let current = shown
.iter()
.copied()
.find(|ix| *ix == highlighted)
.or(shown.first().copied());
// One stop for the list; arrows move, Enter uses.
let focus = tab_stop((self.id.clone(), "list-focus").into(), true, window, cx);
let mut categories: Vec<SharedString> = vec![ALL.into()];
for template in self.templates.iter() {
if !categories.contains(&template.category) {
categories.push(template.category.clone());
}
}
let theme = cx.theme();
let colors = theme.colors.clone();
let chips = {
let picking = picking.clone();
ChoiceChips::new(
(self.id.clone(), "categories"),
categories.iter().map(|name| {
let shown = match name.as_ref() {
ALL => i18n::text(cx, "templates.all", &[]),
_ => name.clone(),
};
Choice::new(name.clone(), shown)
}),
)
.selected([category.clone()])
.on_change(move |values, _, cx| {
let chosen = values.first().cloned().unwrap_or_else(|| ALL.into());
log::info!("template picker: {chosen}");
picking.update(cx, |picking, cx| {
picking.category = chosen;
cx.notify();
});
})
};
let count = shown.len();
let rows = shown.iter().enumerate().map(|(at, &ix)| {
let template = &self.templates[ix];
let lit = current == Some(ix);
let pick = picking.clone();
div()
.id((self.id.clone(), format!("template-{ix}")))
.role(Role::ListItem)
.aria_label(template.name.clone())
.aria_selected(lit)
.aria_position_in_set(at + 1)
.aria_size_of_set(count)
.flex()
.gap_2()
.p_2()
.rounded(theme.radius(Radius::Md))
.cursor_pointer()
.when(lit, |row| row.bg(colors.active))
.when(!lit, |row| row.hover(|row| row.bg(colors.hover)))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, _, cx| {
pick.update(cx, |picking, cx| {
picking.highlighted = ix;
cx.notify();
})
})
.child(template.icon.clone())
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
div()
.font_weight(FontWeight::MEDIUM)
.child(template.name.clone()),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(template.description.clone()),
),
)
});
// Arrows move, Enter uses.
let keys = {
let (picking, take, shown) = (picking.clone(), self.on_use.clone(), shown.clone());
move |event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut App| {
let at = current.and_then(|ix| shown.iter().position(|each| *each == ix));
let to = match (event.keystroke.key.as_str(), at) {
("down", Some(at)) => shown.get(at + 1).copied(),
("up", Some(at)) => at.checked_sub(1).map(|at| shown[at]),
("home", _) => shown.first().copied(),
("end", _) => shown.last().copied(),
("enter", Some(_)) => {
if let (Some(take), Some(ix)) = (&take, current) {
log::info!("template picker: use {ix}");
take(ix, window, cx);
}
return;
}
_ => return,
};
if let Some(ix) = to {
picking.update(cx, |picking, cx| {
picking.highlighted = ix;
cx.notify();
});
}
}
};
let preview = match current {
Some(ix) => {
let take = self.on_use.clone();
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
div()
.flex()
.items_center()
.justify_between()
.px_4()
.py_2()
.border_b_1()
.border_color(colors.border)
.child(
div()
.text_color(colors.fg_muted)
.child(self.templates[ix].name.clone()),
)
.child(
Button::new(
(self.id.clone(), "use"),
i18n::text(cx, "templates.use", &[]),
)
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.disabled(take.is_none())
.when_some(take, |button, take| {
button.on_click(move |_, window, cx| {
log::info!("template picker: use {ix}");
take(ix, window, cx)
})
}),
),
)
.child(
div()
.id((self.id.clone(), "preview"))
.flex_1()
.overflow_y_scroll()
.p_4()
.child(MarkdownRenderer::new(
(self.id.clone(), format!("preview-{ix}")),
self.templates[ix].markdown.clone(),
)),
)
.into_any_element()
}
None => div()
.flex_1()
.flex()
.items_center()
.justify_center()
.text_color(colors.fg_subtle)
.child(i18n::text(cx, "templates.none", &[]))
.into_any_element(),
};
div()
.flex()
.size_full()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.overflow_hidden()
.child(
div()
.flex_none()
.w(theme.sidebar_width(false))
.flex()
.flex_col()
.gap_2()
.p_2()
.border_r_1()
.border_color(colors.border)
.child(
Input::new(&search).size(ControlSize::Sm).prefix(
Icon::new(IconName::Search)
.size(IconSize::Sm)
.color(colors.fg_subtle),
),
)
.child(chips)
.child(
div()
.id((self.id.clone(), "list"))
.role(Role::List)
.aria_label(i18n::text(cx, "templates.list", &[]))
.track_focus(&focus)
.focus_ring(cx)
.on_key_down(keys)
.flex_1()
.overflow_y_scroll()
.flex()
.flex_col()
.gap_0p5()
.children(rows),
),
)
.child(preview)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn template(name: &str, category: &str, description: &str) -> Template {
Template {
name: name.to_string().into(),
icon: "📄".into(),
category: category.to_string().into(),
description: description.to_string().into(),
markdown: "".into(),
}
}
#[test]
fn templates_keep_by_category_and_query() {
let all = [
template("Meeting notes", "Team", "Agenda and actions"),
template("Roadmap", "Product", "Quarters ahead"),
template("Retro", "Team", "What went well"),
];
assert_eq!(kept(&all, "", ALL), [0, 1, 2]);
assert_eq!(kept(&all, "", "Team"), [0, 2]);
assert_eq!(kept(&all, "went", ALL), [2], "a description counts");
assert!(kept(&all, "zzz", ALL).is_empty());
}
}