424 lines
13 KiB
Rust
424 lines
13 KiB
Rust
use ely_gpui_component::{
|
|
buttons::{Button, ButtonVariant},
|
|
forms::Choice,
|
|
navigation::{Command, CommandPalette, QuickLauncher, QuickOpen, QuickSwitcher, SearchPalette},
|
|
primitives::IconName,
|
|
};
|
|
use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Window};
|
|
|
|
use crate::{
|
|
probe::{Opened, probe},
|
|
ui::{keep, row, section, set, specimen},
|
|
};
|
|
|
|
/// Moves `value` to the front of a recent list, keeping `size` entries.
|
|
fn remember(list: &Entity<Vec<SharedString>>, value: &SharedString, size: usize, cx: &mut App) {
|
|
list.update(cx, |list, cx| {
|
|
list.retain(|kept| kept != value);
|
|
list.insert(0, value.clone());
|
|
list.truncate(size);
|
|
cx.notify();
|
|
});
|
|
}
|
|
|
|
fn commands() -> Vec<(&'static str, Vec<Command>)> {
|
|
let command =
|
|
|value: &'static str, label: &'static str, icon| Command::new(value, label).icon(icon);
|
|
vec![
|
|
(
|
|
"File",
|
|
vec![
|
|
command("new", "New file", IconName::FilePlus).keys("secondary-n"),
|
|
command("open", "Open…", IconName::FolderOpen).keys("secondary-o"),
|
|
command("save", "Save", IconName::Save).keys("secondary-s"),
|
|
command("share", "Share…", IconName::Share2),
|
|
],
|
|
),
|
|
(
|
|
"View",
|
|
vec![
|
|
command("sidebar", "Toggle sidebar", IconName::PanelLeft).keys("secondary-b"),
|
|
command("terminal", "Toggle terminal", IconName::Terminal).keys("ctrl-`"),
|
|
command("zoom-in", "Zoom in", IconName::ZoomIn).keys("secondary-="),
|
|
command("zoom-out", "Zoom out", IconName::ZoomOut).keys("secondary--"),
|
|
],
|
|
),
|
|
(
|
|
"Theme",
|
|
vec![
|
|
command("light", "Light theme", IconName::Sun),
|
|
command("dark", "Dark theme", IconName::Moon),
|
|
command("system", "Match the system", IconName::Monitor),
|
|
],
|
|
),
|
|
(
|
|
"Help",
|
|
vec![
|
|
command("keys", "Keyboard shortcuts", IconName::Keyboard).keys("secondary-/"),
|
|
command("bug", "Report a bug", IconName::Bug),
|
|
command("new-in", "What's new", IconName::Sparkles),
|
|
],
|
|
),
|
|
]
|
|
}
|
|
|
|
pub fn command_palette(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
|
let open = keep("commands-open", || false, window, cx);
|
|
let recent = keep(
|
|
"commands-recent",
|
|
|| vec!["sidebar".into(), "dark".into()],
|
|
window,
|
|
cx,
|
|
);
|
|
let ran = keep("commands-ran", || None::<SharedString>, window, cx);
|
|
let caption = ran
|
|
.read(cx)
|
|
.clone()
|
|
.map_or("apps open it with ⌘K".into(), |value| {
|
|
format!("ran {value}")
|
|
});
|
|
let (opener, closer) = (open.clone(), open.clone());
|
|
let palette = (*open.read(cx)).then(|| {
|
|
let recents = recent.read(cx).clone();
|
|
commands()
|
|
.into_iter()
|
|
.fold(
|
|
CommandPalette::new("commands", move |_, cx| set(&closer, false, cx)),
|
|
|palette, (title, commands)| palette.group(title, commands),
|
|
)
|
|
.recent(recents)
|
|
.on_run(move |value, _, cx| {
|
|
remember(&recent, value, 3, cx);
|
|
set(&ran, Some(value.clone()), cx);
|
|
})
|
|
});
|
|
section(
|
|
"CommandPalette",
|
|
"Every command in one place. Recent ones lead; typing ranks fuzzy matches and marks the letters that matched.",
|
|
cx,
|
|
)
|
|
.child(specimen(
|
|
caption,
|
|
probe(
|
|
"commands",
|
|
Button::new("commands-button", "Command palette")
|
|
.icon(IconName::Command)
|
|
.on_click(move |_, _, cx| set(&opener, true, cx)),
|
|
),
|
|
cx,
|
|
))
|
|
.children(palette)
|
|
}
|
|
|
|
const FILES: [&str; 24] = [
|
|
"Cargo.toml",
|
|
"README.md",
|
|
"AGENTS.md",
|
|
"src/lib.rs",
|
|
"src/theme/mod.rs",
|
|
"src/theme/palette.rs",
|
|
"src/theme/tokens.rs",
|
|
"src/motion/mod.rs",
|
|
"src/motion/slide.rs",
|
|
"src/primitives/icon.rs",
|
|
"src/primitives/focus.rs",
|
|
"src/buttons/button.rs",
|
|
"src/buttons/segmented.rs",
|
|
"src/forms/select.rs",
|
|
"src/forms/combobox.rs",
|
|
"src/forms/text/mod.rs",
|
|
"src/forms/date/picker.rs",
|
|
"src/navigation/tabs.rs",
|
|
"src/navigation/menu.rs",
|
|
"src/navigation/palette.rs",
|
|
"src/navigation/toc.rs",
|
|
"examples/gallery/main.rs",
|
|
"examples/gallery/pages/navigation/palettes.rs",
|
|
"frontend/package.json",
|
|
];
|
|
|
|
pub fn quick_open(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
|
let open = keep("files-open", || false, window, cx);
|
|
let recent = keep(
|
|
"files-recent",
|
|
|| vec!["src/theme/tokens.rs".into(), "AGENTS.md".into()],
|
|
window,
|
|
cx,
|
|
);
|
|
let opened = keep("files-opened", || None::<SharedString>, window, cx);
|
|
let caption = opened
|
|
.read(cx)
|
|
.clone()
|
|
.map_or("fuzzy over the whole path".into(), |path| {
|
|
format!("opened {path}")
|
|
});
|
|
let (opener, closer) = (open.clone(), open.clone());
|
|
let palette = (*open.read(cx)).then(|| {
|
|
QuickOpen::new("files", FILES, move |_, cx| set(&closer, false, cx))
|
|
.recent(recent.read(cx).clone())
|
|
.on_open(move |path, _, cx| {
|
|
remember(&recent, path, 4, cx);
|
|
set(&opened, Some(path.clone()), cx);
|
|
})
|
|
});
|
|
section(
|
|
"QuickOpen",
|
|
"Files by path. The name leads and its folder follows; recent files show before you type.",
|
|
cx,
|
|
)
|
|
.child(specimen(
|
|
caption,
|
|
probe(
|
|
"files",
|
|
Button::new("files-button", "Go to file")
|
|
.icon(IconName::File)
|
|
.on_click(move |_, _, cx| set(&opener, true, cx)),
|
|
),
|
|
cx,
|
|
))
|
|
.children(palette)
|
|
}
|
|
|
|
fn places() -> Vec<Choice> {
|
|
vec![
|
|
Choice::new("roadmap", "Roadmap 2027")
|
|
.icon(IconName::FileText)
|
|
.note("Document"),
|
|
Choice::new("standup", "Design standup")
|
|
.icon(IconName::MessageSquare)
|
|
.note("Session"),
|
|
Choice::new("ely", "Ely GPUI Component")
|
|
.icon(IconName::Box)
|
|
.note("Project"),
|
|
Choice::new("brand", "Brand guidelines")
|
|
.icon(IconName::FileText)
|
|
.note("Document"),
|
|
Choice::new("research", "User research")
|
|
.icon(IconName::MessageSquare)
|
|
.note("Session"),
|
|
Choice::new("site", "ely.dev")
|
|
.icon(IconName::Globe)
|
|
.note("Project"),
|
|
]
|
|
}
|
|
|
|
pub fn quick_switcher(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
|
let open = keep("switch-open", || false, window, cx);
|
|
let order = keep(
|
|
"switch-order",
|
|
|| {
|
|
places()
|
|
.into_iter()
|
|
.map(|place| place.value)
|
|
.collect::<Vec<_>>()
|
|
},
|
|
window,
|
|
cx,
|
|
);
|
|
let (opener, closer) = (open.clone(), open.clone());
|
|
let current = order.read(cx)[0].clone();
|
|
let palette = (*open.read(cx)).then(|| {
|
|
let all = places();
|
|
let items = order.read(cx).iter().map(|value| {
|
|
all.iter()
|
|
.find(|place| place.value == *value)
|
|
.expect("every kept place exists")
|
|
.clone()
|
|
});
|
|
QuickSwitcher::new("switcher", items, move |_, cx| set(&closer, false, cx))
|
|
.on_switch(move |value, _, cx| remember(&order, value, 6, cx))
|
|
});
|
|
section(
|
|
"QuickSwitcher",
|
|
"Documents, sessions and projects, most recent first. It opens on the one before, so Enter goes back.",
|
|
cx,
|
|
)
|
|
.child(specimen(
|
|
format!("in {current}"),
|
|
probe(
|
|
"switcher",
|
|
Button::new("switcher-button", "Switch")
|
|
.icon(IconName::Layers)
|
|
.on_click(move |_, _, cx| set(&opener, true, cx)),
|
|
),
|
|
cx,
|
|
))
|
|
.children(palette)
|
|
}
|
|
|
|
type Found = (
|
|
&'static str,
|
|
&'static str,
|
|
&'static str,
|
|
&'static str,
|
|
IconName,
|
|
);
|
|
|
|
const FOUND: [Found; 12] = [
|
|
(
|
|
"Components",
|
|
"palette",
|
|
"Command palette",
|
|
"Navigation · every command in one place",
|
|
IconName::Command,
|
|
),
|
|
(
|
|
"Components",
|
|
"date",
|
|
"Date picker",
|
|
"Forms · a calendar under a field",
|
|
IconName::Calendar,
|
|
),
|
|
(
|
|
"Components",
|
|
"color",
|
|
"Color picker",
|
|
"Forms · hue, saturation and alpha",
|
|
IconName::Pipette,
|
|
),
|
|
(
|
|
"Components",
|
|
"tabs",
|
|
"Tabs",
|
|
"Navigation · a line slides to the chosen tab",
|
|
IconName::PanelLeft,
|
|
),
|
|
(
|
|
"Components",
|
|
"slider",
|
|
"Slider",
|
|
"Forms · a thumb on a track, with steps",
|
|
IconName::SlidersHorizontal,
|
|
),
|
|
(
|
|
"Guides",
|
|
"tokens",
|
|
"Theme and tokens",
|
|
"Colors, sizes and radii come from the theme",
|
|
IconName::Palette,
|
|
),
|
|
(
|
|
"Guides",
|
|
"motion",
|
|
"Motion",
|
|
"Durations honor reduced motion; springs stay in animators",
|
|
IconName::Sparkles,
|
|
),
|
|
(
|
|
"Guides",
|
|
"focus",
|
|
"Focus",
|
|
"Tab and Shift-Tab walk every stop in order",
|
|
IconName::Keyboard,
|
|
),
|
|
(
|
|
"Guides",
|
|
"capture",
|
|
"Captures",
|
|
"The gallery photographs its own windows",
|
|
IconName::Image,
|
|
),
|
|
(
|
|
"People",
|
|
"ada",
|
|
"Ada Lovelace",
|
|
"Design · London",
|
|
IconName::User,
|
|
),
|
|
(
|
|
"People",
|
|
"grace",
|
|
"Grace Hopper",
|
|
"Engineering · Arlington",
|
|
IconName::User,
|
|
),
|
|
(
|
|
"People",
|
|
"alan",
|
|
"Alan Kay",
|
|
"Research · Los Angeles",
|
|
IconName::User,
|
|
),
|
|
];
|
|
|
|
/// Case-insensitive search over titles and notes; the empty query suggests a few.
|
|
fn search(query: &str) -> Vec<(SharedString, Vec<Choice>)> {
|
|
let query = query.to_lowercase();
|
|
let result =
|
|
|(_, value, label, note, icon): &Found| Choice::new(*value, *label).icon(*icon).note(*note);
|
|
if query.is_empty() {
|
|
let suggested = FOUND
|
|
.iter()
|
|
.filter(|found| ["palette", "tokens", "motion", "ada"].contains(&found.1));
|
|
return vec![("Suggested".into(), suggested.map(result).collect())];
|
|
}
|
|
["Components", "Guides", "People"]
|
|
.into_iter()
|
|
.map(|group| {
|
|
let hits = FOUND.iter().filter(|found| {
|
|
found.0 == group
|
|
&& (found.2.to_lowercase().contains(&query)
|
|
|| found.3.to_lowercase().contains(&query))
|
|
});
|
|
(group.into(), hits.map(result).collect())
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn search_palette(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
|
let open = keep("search-open", || false, window, cx);
|
|
let went = keep("search-went", || None::<SharedString>, window, cx);
|
|
let caption = went
|
|
.read(cx)
|
|
.clone()
|
|
.map_or("results grouped by where they were found".into(), |value| {
|
|
format!("opened {value}")
|
|
});
|
|
let (opener, closer, launched) = (open.clone(), open.clone(), went.clone());
|
|
let palette = (*open.read(cx)).then(|| {
|
|
SearchPalette::new("search", search, move |_, cx| set(&closer, false, cx))
|
|
.on_open(move |value, _, cx| set(&went, Some(value.clone()), cx))
|
|
});
|
|
section(
|
|
"SearchPalette / SpotlightSearch / QuickLauncher",
|
|
"One field searches everywhere and marks each match. As a launcher it opens alone, above every window, and closes when you look away.",
|
|
cx,
|
|
)
|
|
.child(
|
|
row()
|
|
.child(specimen(
|
|
caption,
|
|
probe(
|
|
"search",
|
|
Button::new("search-button", "Search everything")
|
|
.icon(IconName::Search)
|
|
.on_click(move |_, _, cx| set(&opener, true, cx)),
|
|
),
|
|
cx,
|
|
))
|
|
.child(specimen(
|
|
"a window above all others",
|
|
probe(
|
|
"launcher",
|
|
Button::new("launcher-button", "Open the launcher")
|
|
.variant(ButtonVariant::Ghost)
|
|
.icon(IconName::Rocket)
|
|
.on_click(move |_, _, cx| {
|
|
let went = launched.clone();
|
|
let opened = QuickLauncher::open(
|
|
search,
|
|
move |value, _, cx| set(&went, Some(value.clone()), cx),
|
|
cx,
|
|
);
|
|
match opened {
|
|
Ok(handle) => Opened::insert("launcher", handle.into(), cx),
|
|
Err(error) => log::error!("gallery: launcher: {error:#}"),
|
|
}
|
|
}),
|
|
),
|
|
cx,
|
|
)),
|
|
)
|
|
.children(palette)
|
|
}
|