//! The command palette (Ctrl+K), as T3 Code has it: Actions and Recent
//! Threads at rest, plus Projects, Settings and Threads while searching,
//! with submenus for starting a thread in a project, the theme and the
//! appearance. A leading ">" searches actions only.
use std::rc::Rc;
use gpui_kit::component::{
Icon, IconName, WindowExt as _, h_flex,
input::{Input, InputEvent, InputState},
notification::Notification,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use super::actions::relative_time_long;
use super::Workspace;
use crate::settings::{self, ThemeMode};
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, MetalStyled as _};
use crate::ui::themes;
use crate::{Dismiss, PaletteBack, SelectNext, SelectPrevious};
const RECENT_THREADS: usize = 12;
/// Where the palette is: its root, or one of its submenus.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PaletteView {
Root,
NewThreadIn,
Themes,
Appearance,
}
pub(super) enum PaletteEvent {
Dismiss,
}
type Run = Rc<dyn Fn(&mut Workspace, &mut Window, &mut Context<Workspace>)>;
/// One row of the palette.
#[derive(Clone)]
pub(super) struct PaletteItem {
group: &'static str,
title: SharedString,
/// Set in bold after the title: "New thread in **project**".
emphasis: Option<SharedString>,
description: Option<SharedString>,
icon: Icon,
shortcut: Option<SharedString>,
timestamp: Option<String>,
/// Marks the theme or appearance in use.
current: bool,
/// Opens a submenu instead of running.
submenu: Option<PaletteView>,
run: Option<Run>,
/// What a search matches, most important first.
terms: Vec<String>,
}
impl PaletteItem {
fn new(group: &'static str, title: impl Into<SharedString>, icon: impl Into<Icon>) -> Self {
let title = title.into();
Self {
group,
terms: vec![title.to_string()],
title,
emphasis: None,
description: None,
icon: icon.into(),
shortcut: None,
timestamp: None,
current: false,
submenu: None,
run: None,
}
}
fn run(mut self, run: impl Fn(&mut Workspace, &mut Window, &mut Context<Workspace>) + 'static) -> Self {
self.run = Some(Rc::new(run));
self
}
fn submenu(mut self, view: PaletteView) -> Self {
self.submenu = Some(view);
self
}
fn shortcut(mut self, shortcut: impl Into<SharedString>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
fn description(mut self, description: impl Into<SharedString>) -> Self {
let description = description.into();
self.terms.push(description.to_string());
self.description = Some(description);
self
}
fn terms(mut self, terms: &[&str]) -> Self {
self.terms.extend(terms.iter().map(|term| term.to_string()));
self
}
/// How well the query matches: every word must appear in a term; an
/// exact match beats a prefix beats a substring, and earlier terms win.
fn score(&self, words: &[String]) -> Option<u32> {
let terms: Vec<String> = self.terms.iter().map(|term| term.to_lowercase()).collect();
let mut total = 0;
for word in words {
let best = terms
.iter()
.enumerate()
.filter_map(|(ix, term)| {
let rank = if term == word {
0
} else if term.starts_with(word.as_str()) {
1
} else if term.contains(word.as_str()) {
2
} else {
return None;
};
Some(rank * 10 + ix.min(9) as u32)
})
.min()?;
total += best;
}
Some(total)
}
}
/// Settings the palette finds by name, with the page they're on.
const SETTINGS: &[(&str, &str)] = &[
("Model", "General"),
("Permissions", "General"),
("Workspace", "General"),
("Submodules", "General"),
("Text generation model", "General"),
("Start from origin", "General"),
("Add project starts in", "General"),
("Project grouping", "General"),
("Project order", "General"),
("Auto-resume limited threads", "General"),
("Snooze limited threads", "General"),
("Working section (beta)", "General"),
("Auto-settle merged threads", "General"),
("Auto-settle inactive threads", "General"),
("Unpin confirmation", "General"),
("Settle confirmation", "General"),
("Delete confirmation", "General"),
("Quit shortcut", "General"),
("Thread notifications", "Behavior"),
("In-app notifications", "Behavior"),
("Time format", "Behavior"),
("Response streaming", "Behavior"),
("Fold finished turns", "Behavior"),
("Show costs", "Behavior"),
("Hide whitespace changes", "Behavior"),
("Diff layout", "Behavior"),
("Send shortcut", "Behavior"),
("Follow-up behavior", "Behavior"),
("Background activity", "Behavior"),
("Thread cards", "Appearance"),
("Theme", "Themes"),
("Providers", "Providers"),
("Usage", "Usage"),
("Allow remote connections", "Connections"),
("Keybindings", "Keybindings"),
("Archived threads", "Archive"),
("About", "About"),
];
impl Workspace {
/// Opens the palette, or closes it when it's open at the root.
pub(super) fn toggle_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.palette.is_some() {
self.close_palette(window, cx);
} else {
self.open_palette(PaletteView::Root, window, cx);
}
}
pub(super) fn open_palette(&mut self, view: PaletteView, window: &mut Window, cx: &mut Context<Self>) {
let workspace = cx.weak_entity();
let palette = cx.new(|cx| CommandPalette::new(workspace, view, window, cx));
let subscription = cx.subscribe_in(&palette, window, |this, _, event: &PaletteEvent, window, cx| match event {
PaletteEvent::Dismiss => this.close_palette(window, cx),
});
let input = palette.read(cx).input.clone();
input.update(cx, |input, cx| input.focus(window, cx));
self.palette = Some((palette, subscription));
cx.notify();
}
pub(super) fn close_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.palette.take().is_some() {
if let Some(chat) = self.active.as_ref().and_then(|session| self.chats.get(&session.entity_id())) {
chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
}
cx.notify();
}
}
/// Switches between System, Light and Dark, saying which in a toast.
pub(super) fn cycle_appearance(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let next = match settings::get(|settings| settings.theme_mode) {
ThemeMode::System => ThemeMode::Light,
ThemeMode::Light => ThemeMode::Dark,
ThemeMode::Dark => ThemeMode::System,
};
themes::set_mode(next, window, cx);
window.push_notification(Notification::info(format!("Appearance: {}", mode_label(next))), cx);
self.settings_changed(window, cx);
}
/// The palette's rows for a view.
fn palette_items(&self, view: PaletteView, searching: bool, cx: &App) -> Vec<PaletteItem> {
match view {
PaletteView::Root => {
let mut items = self.palette_actions(cx);
if searching {
items.extend(self.palette_projects(cx));
items.extend(SETTINGS.iter().map(|(title, page)| {
let page = *page;
PaletteItem::new("Settings", *title, IconName::Settings)
.description(format!("Settings · {page}"))
.run(move |this, window, cx| this.open_settings_page(page, window, cx))
}));
items.extend(self.palette_threads(usize::MAX, "Threads", cx));
} else {
items.extend(self.palette_threads(RECENT_THREADS, "Recent Threads", cx));
}
items
}
PaletteView::NewThreadIn => self.palette_new_thread_in(cx),
PaletteView::Themes => {
let (mode, light, dark) =
settings::get(|settings| (settings.theme_mode, settings.light_theme.clone(), settings.dark_theme.clone()));
let showing_dark = metal::palette(cx).dark;
themes::all()
.iter()
.enumerate()
.map(|(ix, theme)| {
let current = (theme.palette.dark && theme.id == dark && (showing_dark || mode == ThemeMode::Dark))
|| (!theme.palette.dark && theme.id == light && (!showing_dark || mode == ThemeMode::Light));
let mut item = PaletteItem::new("Themes", theme.name, IconName::Palette)
.description(if theme.palette.dark { "Dark" } else { "Light" })
.run(move |this, window, cx| {
themes::pick(&themes::all()[ix], window, cx);
this.settings_changed(window, cx);
});
item.current = current;
item
})
.collect()
}
PaletteView::Appearance => {
let mode = settings::get(|settings| settings.theme_mode);
[ThemeMode::System, ThemeMode::Light, ThemeMode::Dark]
.into_iter()
.map(|option| {
let mut item = PaletteItem::new("Appearance", mode_label(option), Lucide::Monitor).run(move |this, window, cx| {
themes::set_mode(option, window, cx);
this.settings_changed(window, cx);
});
item.current = option == mode;
item
})
.collect()
}
}
}
fn palette_actions(&self, cx: &App) -> Vec<PaletteItem> {
let mut items = Vec::new();
let contextual = (!self.projects.is_empty()).then(|| self.contextual_project());
if let Some(ix) = contextual {
let name = self.projects[ix].name.clone();
let mut item = PaletteItem::new("Actions", "New thread in ", Lucide::SquarePen)
.shortcut("Ctrl+N")
.terms(&[&format!("New thread in {name}"), "new chat"])
.run(move |this, window, cx| this.new_thread(ix, window, cx));
item.emphasis = Some(name);
items.push(item);
items.push(
PaletteItem::new("Actions", "New thread in...", Lucide::SquarePen)
.terms(&["project", "new chat"])
.submenu(PaletteView::NewThreadIn),
);
}
items.push(
PaletteItem::new("Actions", "New thread without a project", Lucide::MessageSquareDashed)
.shortcut("Ctrl+Alt+N")
.terms(&["scratch", "no project"])
.run(|this, window, cx| this.new_thread_without_project(window, cx)),
);
if let Some(session) = self.active.clone().filter(|session| !session.read(cx).is_blank()) {
let state = session.read(cx);
let (title, value) = match &state.pull_request {
Some(pr) => ("Copy PR link", pr.url.clone()),
None => ("Copy thread ID", state.id.clone()),
};
let copy_session = session.clone();
items.push(
PaletteItem::new("Actions", title, Lucide::Link)
.description(value)
.shortcut("Ctrl+Shift+C")
.run(move |_, window, cx| Workspace::copy_thread_reference(©_session, window, cx)),
);
items.push(
PaletteItem::new("Actions", "Restart agent session", Lucide::RotateCcw).terms(&["reset", "provider"]).run(
move |_, window, cx| {
let restarted = session.update(cx, |session, cx| session.restart_agent(cx));
if restarted {
window.push_notification(
Notification::success("Your next message starts a fresh session.").title("Agent session will restart"),
cx,
);
} else {
window.push_notification(
Notification::error("The agent is busy or not running.").title("Unable to run command"),
cx,
);
}
},
),
);
}
items.push(
PaletteItem::new("Actions", "Add project", Lucide::FolderPlus)
.terms(&["open folder", "local folder"])
.run(|this, window, cx| this.prompt_for_project(window, cx)),
);
items.push(
PaletteItem::new("Actions", "Change theme", IconName::Palette)
.shortcut("Ctrl+Alt+A")
.terms(&["color scheme", "colors"])
.submenu(PaletteView::Themes),
);
items.push(
PaletteItem::new("Actions", "Change appearance", Lucide::Monitor)
.shortcut("Ctrl+Alt+Shift+A")
.terms(&["light", "dark", "system", "mode"])
.submenu(PaletteView::Appearance),
);
items.push(
PaletteItem::new("Actions", "Open usage", Lucide::ChartNoAxesColumn)
.shortcut("Ctrl+U")
.terms(&["cost", "tokens", "limits"])
.run(|this, window, cx| this.open_settings_page("Usage", window, cx)),
);
items.push(
PaletteItem::new("Actions", "Open settings", IconName::Settings)
.shortcut("Ctrl+,")
.terms(&["preferences"])
.run(|this, window, cx| this.open_settings(window, cx)),
);
if let Some(ix) = contextual {
items.push(
PaletteItem::new("Actions", "Project settings", IconName::Folder)
.description(self.projects[ix].name.clone())
.terms(&["rename", "remove"])
.run(move |this, window, cx| this.open_project_settings(ix, window, cx)),
);
}
items
}
/// `{machine} · {path}`, the description of a project row.
fn project_description(&self, ix: usize) -> String {
let project = &self.projects[ix];
let machine = match &project.environment {
Some(env) => self.environment_name(env),
None => self.hostname.to_string(),
};
format!("{machine} · {}", super::display_path(&project.path))
}
fn palette_projects(&self, cx: &App) -> Vec<PaletteItem> {
let scratch = super::scratch_dir();
self.ordered_projects(cx)
.into_iter()
.filter(|&ix| Some(&self.projects[ix].path) != scratch.as_ref())
.map(|ix| {
PaletteItem::new("Projects", self.projects[ix].name.clone(), IconName::Folder)
.description(self.project_description(ix))
.run(move |this, window, cx| this.new_thread(ix, window, cx))
})
.collect()
}
fn palette_new_thread_in(&self, cx: &App) -> Vec<PaletteItem> {
let scratch = super::scratch_dir();
let current = (!self.projects.is_empty()).then(|| self.contextual_project());
let mut order: Vec<usize> = self
.ordered_projects(cx)
.into_iter()
.filter(|&ix| Some(&self.projects[ix].path) != scratch.as_ref())
.collect();
if let Some(current) = current
&& let Some(position) = order.iter().position(|&ix| ix == current)
{
order.remove(position);
order.insert(0, current);
}
let mut items = Vec::new();
let mut number = 0;
for (row, ix) in order.into_iter().enumerate() {
if row == 1 {
items.push(self.no_project_item());
}
number += 1;
let mut item = PaletteItem::new("Projects", self.projects[ix].name.clone(), IconName::Folder)
.description(self.project_description(ix))
.run(move |this, window, cx| this.new_thread(ix, window, cx));
if number <= 9 {
item = item.shortcut(format!("Ctrl+{number}"));
}
items.push(item);
}
if items.len() <= 1 {
items.push(self.no_project_item());
}
items
}
fn no_project_item(&self) -> PaletteItem {
PaletteItem::new("Projects", "No project", Lucide::MessageSquareDashed)
.shortcut("Ctrl+Alt+N")
.run(|this, window, cx| this.new_thread_without_project(window, cx))
}
fn palette_threads(&self, limit: usize, group: &'static str, cx: &App) -> Vec<PaletteItem> {
let mut threads: Vec<(usize, Entity<crate::session::Session>)> = self
.projects
.iter()
.enumerate()
.flat_map(|(ix, project)| project.sessions.iter().map(move |session| (ix, session.clone())))
.filter(|(_, session)| {
let state = session.read(cx);
!state.archived && !state.is_blank()
})
.collect();
threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
threads
.into_iter()
.take(limit)
.map(|(ix, session)| {
let state = session.read(cx);
let project = &self.projects[ix];
let mut description = project.name.to_string();
if let Some(branch) = self.thread_branch(&session, cx) {
description.push_str(&format!(" · #{branch}"));
}
if self.active.as_ref() == Some(&session) {
description.push_str(" · Current thread");
}
let mut item = PaletteItem::new(group, state.title.clone(), Lucide::MessageSquare).description(description);
item.timestamp = Some(relative_time_long(state.updated));
item.run(move |this, window, cx| {
if let Some(ix) = this.project_of(&session) {
this.select(ix, session.clone(), window, cx);
}
})
})
.collect()
}
/// Starts a thread without a project, in the scratch folder.
pub(super) fn new_thread_without_project(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(path) = super::scratch_dir() else { return };
let ix = self.ensure_project(path, Some("No project"));
self.new_thread(ix, window, cx);
}
}
fn mode_label(mode: ThemeMode) -> &'static str {
match mode {
ThemeMode::System => "System",
ThemeMode::Light => "Light",
ThemeMode::Dark => "Dark",
}
}
/// The palette itself.
pub(super) struct CommandPalette {
workspace: WeakEntity<Workspace>,
input: Entity<InputState>,
stack: Vec<PaletteView>,
highlight: usize,
scroll: ScrollHandle,
_subscription: Subscription,
}
impl EventEmitter<PaletteEvent> for CommandPalette {}
impl CommandPalette {
fn new(workspace: WeakEntity<Workspace>, view: PaletteView, window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx));
let subscription = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, window, cx| match event {
InputEvent::Change => {
this.highlight = 0;
cx.notify();
}
InputEvent::PressEnter { .. } => this.confirm(window, cx),
_ => {}
});
let mut stack = vec![PaletteView::Root];
if view != PaletteView::Root {
stack.push(view);
}
let palette = Self { workspace, input, stack, highlight: 0, scroll: ScrollHandle::new(), _subscription: subscription };
palette.sync_placeholder(window, cx);
palette
}
fn view(&self) -> PaletteView {
*self.stack.last().unwrap_or(&PaletteView::Root)
}
fn sync_placeholder(&self, window: &mut Window, cx: &mut Context<Self>) {
let placeholder = if self.view() == PaletteView::Root { "Search commands, projects, and threads..." } else { "Search..." };
self.input.update(cx, |input, cx| input.set_placeholder(placeholder, window, cx));
}
/// The rows that match the query, in their groups, best first.
fn results(&self, cx: &App) -> Vec<PaletteItem> {
let Some(workspace) = self.workspace.upgrade() else { return Vec::new() };
let raw = self.input.read(cx).value().to_string();
let actions_only = self.view() == PaletteView::Root && raw.trim_start().starts_with('>');
let query = raw.trim_start().trim_start_matches('>').trim().to_lowercase();
let words: Vec<String> = query.split_whitespace().map(str::to_string).collect();
let items = workspace.read(cx).palette_items(self.view(), !words.is_empty(), cx);
let items: Vec<PaletteItem> = items.into_iter().filter(|item| !actions_only || item.group == "Actions").collect();
if words.is_empty() {
return items;
}
let mut groups: Vec<&'static str> = Vec::new();
for item in &items {
if !groups.contains(&item.group) {
groups.push(item.group);
}
}
let mut scored: Vec<(usize, u32, usize, PaletteItem)> = items
.into_iter()
.enumerate()
.filter_map(|(order, item)| {
let score = item.score(&words)?;
let group = groups.iter().position(|group| *group == item.group).unwrap_or(0);
// Thread titles tie, keeping the most recent first.
let score = if item.group == "Threads" { 0 } else { score };
Some((group, score, order, item))
})
.collect();
scored.sort_by_key(|(group, score, order, _)| (*group, *score, *order));
scored.into_iter().map(|(_, _, _, item)| item).collect()
}
fn move_highlight(&mut self, delta: isize, cx: &mut Context<Self>) {
let results = self.results(cx);
if results.is_empty() {
return;
}
self.highlight = (self.highlight as isize + delta).rem_euclid(results.len() as isize) as usize;
// The list's children include a heading per group.
let headings = results[..=self.highlight]
.iter()
.enumerate()
.filter(|(ix, item)| *ix == 0 || results[ix - 1].group != item.group)
.count();
self.scroll.scroll_to_item(self.highlight + headings);
cx.notify();
}
fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let results = self.results(cx);
if let Some(item) = results.get(self.highlight).cloned() {
self.execute(item, window, cx);
}
}
fn execute(&mut self, item: PaletteItem, window: &mut Window, cx: &mut Context<Self>) {
if let Some(view) = item.submenu {
self.stack.push(view);
self.highlight = 0;
self.input.update(cx, |input, cx| input.set_value("", window, cx));
self.sync_placeholder(window, cx);
cx.notify();
return;
}
let Some(run) = item.run.clone() else { return };
cx.emit(PaletteEvent::Dismiss);
let _ = self.workspace.update(cx, |this, cx| run(this, window, cx));
}
/// Backspace on an empty query leaves a submenu.
fn back(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.input.read(cx).value().is_empty() && self.stack.len() > 1 {
self.stack.pop();
self.highlight = 0;
self.sync_placeholder(window, cx);
cx.notify();
} else {
cx.propagate();
}
}
/// Ctrl+1–9 runs the row with that number.
fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
let keystroke = &event.keystroke;
if !keystroke.modifiers.control || keystroke.modifiers.shift || keystroke.modifiers.alt {
return;
}
let Ok(digit) = keystroke.key.parse::<usize>() else { return };
let label = format!("Ctrl+{digit}");
let results = self.results(cx);
if let Some(item) = results.into_iter().find(|item| item.shortcut.as_deref() == Some(label.as_str())) {
cx.stop_propagation();
self.execute(item, window, cx);
}
}
}
impl Render for CommandPalette {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let results = self.results(cx);
let highlight = self.highlight.min(results.len().saturating_sub(1));
let in_submenu = self.stack.len() > 1;
let actions_only = self.input.read(cx).value().trim_start().starts_with('>');
let mut rows: Vec<AnyElement> = Vec::new();
let mut last_group = None;
for (ix, item) in results.iter().enumerate() {
if last_group != Some(item.group) {
last_group = Some(item.group);
rows.push(
div()
.flex_none()
.px(px(10.))
.pt(px(if ix == 0 { 4. } else { 10. }))
.pb(px(4.))
.engraved(p)
.child(item.group.to_uppercase())
.into_any_element(),
);
}
let selected = ix == highlight;
let item = item.clone();
let run_item = item.clone();
rows.push(
h_flex()
.id(("palette-row", ix))
.flex_none()
.min_h(px(36.))
.px(px(10.))
.py(px(6.))
.gap(px(10.))
.rounded(px(10.))
.cursor_pointer()
.when(selected, |this| this.bg(p.menu_row_hover))
.hover(|style| style.bg(p.menu_row_hover))
.on_click(cx.listener(move |this, _, window, cx| this.execute(run_item.clone(), window, cx)))
.child(item.icon.clone().size(px(16.)).text_color(p.icon))
.child(
v_flex()
.flex_1()
.min_w_0()
.child(
h_flex()
.min_w_0()
.text_size(px(13.5))
.text_color(p.ink)
.child(div().flex_none().whitespace_nowrap().child(item.title.clone()))
.when_some(item.emphasis.clone(), |this, emphasis| {
this.child(div().min_w_0().truncate().font_weight(FontWeight::SEMIBOLD).child(emphasis))
}),
)
.when_some(item.description.clone(), |this, description| {
this.child(div().truncate().text_size(px(12.)).text_color(p.ink3).child(description))
}),
)
.when(item.current, |this| this.child(metal::tag("Current", p)))
.when_some(item.timestamp.clone(), |this, timestamp| {
this.child(div().flex_none().min_w(px(48.)).text_right().text_size(px(12.)).text_color(p.ink3).child(timestamp))
})
.when_some(item.shortcut.clone(), |this, shortcut| this.child(metal::keycap(shortcut, p)))
.when(item.submenu.is_some(), |this| {
this.child(Icon::new(IconName::ChevronRight).size(px(16.)).text_color(p.ink3))
})
.into_any_element(),
);
}
let empty = results.is_empty();
let hint = |keys: Vec<AnyElement>, label: &'static str| {
h_flex().gap(px(4.)).children(keys).child(div().text_size(px(12.)).text_color(p.ink3).child(label))
};
v_flex()
.key_context("CommandPalette")
.on_action(cx.listener(|this, _: &SelectPrevious, _, cx| this.move_highlight(-1, cx)))
.on_action(cx.listener(|this, _: &SelectNext, _, cx| this.move_highlight(1, cx)))
.on_action(cx.listener(|_, _: &Dismiss, _, cx| cx.emit(PaletteEvent::Dismiss)))
.on_action(cx.listener(|this, _: &PaletteBack, window, cx| this.back(window, cx)))
.on_key_down(cx.listener(Self::on_key_down))
.w(px(600.))
.max_w_full()
.rounded(px(18.))
.bg(p.menu_bg)
.shadow(p.raise.clone())
.occlude()
.child(
h_flex()
.h(px(48.))
.px(px(14.))
.gap(px(10.))
.child(if in_submenu {
div()
.id("palette-back")
.cursor_pointer()
.on_click(cx.listener(|this, _, window, cx| {
this.stack.pop();
this.highlight = 0;
this.sync_placeholder(window, cx);
cx.notify();
}))
.child(Icon::new(IconName::ArrowLeft).size(px(16.)).text_color(p.ink2))
.into_any_element()
} else {
Icon::new(IconName::Search).size(px(16.)).text_color(p.field_ink).into_any_element()
})
.child(div().flex_1().text_size(px(14.)).child(Input::new(&self.input).appearance(false))),
)
.child(metal::rule(p))
.child(
v_flex()
.id("palette-results")
.track_scroll(&self.scroll)
.max_h(px(448.))
.overflow_y_scroll()
.p(px(6.))
.when(empty, |this| {
this.child(
div().py(px(40.)).flex().justify_center().text_size(px(13.)).text_color(p.ink3).child(if actions_only {
"No matching actions."
} else {
"No matching commands, projects, or threads."
}),
)
})
.children(rows),
)
.child(metal::rule(p))
.child(
h_flex()
.h(px(40.))
.px(px(14.))
.gap(px(14.))
.child(hint(
vec![
metal::keycap("↑", p).into_any_element(),
metal::keycap("↓", p).into_any_element(),
],
"Navigate",
))
.child(hint(vec![metal::keycap("Enter", p).into_any_element()], "Select"))
.when(in_submenu, |this| this.child(hint(vec![metal::keycap("Backspace", p).into_any_element()], "Back")))
.child(hint(vec![metal::keycap("Esc", p).into_any_element()], "Close")),
)
}
}