feat add command palette component

This commit is contained in:
2026-11-04 09:37:47 +07:00
parent fa73124ee4
commit 4bfc816cbe
7 changed files with 1040 additions and 11 deletions
+3 -1
View File
@@ -36,6 +36,7 @@ full = [
"field",
"status_dot",
"combobox",
"command_palette",
"tooltip",
"menu",
"select",
@@ -62,7 +63,8 @@ segmented = []
spinner = []
field = []
status_dot = []
combobox = ["input"]
combobox = ["input", "scroll_area"]
command_palette = ["input", "scroll_area"]
tooltip = []
menu = ["scroll_area"]
select = ["menu"]
+493
View File
@@ -0,0 +1,493 @@
//! The command palette: a modal query field over the application's actions.
use std::cell::RefCell;
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement,
KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role,
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window,
actions, canvas, div, px, relative,
};
use gpui_native_theme::{ActiveTheme as _, Size};
use crate::common::{h_flex, v_flex};
use crate::input::{Input, InputState, InputStateEvent};
use crate::scroll_area::{ScrollArea, ScrollAxes};
actions!(
command_palette,
[HighlightNext, HighlightPrevious, Choose, Dismiss]
);
/// The key context the palette's panel declares.
pub const COMMAND_PALETTE_CONTEXT: &str = "CommandPalette";
/// Binds the palette's four keys: down, up, enter, and escape.
pub fn bind_command_palette_keys(cx: &mut App) {
let under_input = Some("CommandPalette > Input");
cx.bind_keys([
KeyBinding::new("down", HighlightNext, under_input),
KeyBinding::new("up", HighlightPrevious, under_input),
KeyBinding::new("enter", Choose, under_input),
KeyBinding::new("escape", Dismiss, under_input),
KeyBinding::new("down", HighlightNext, Some(COMMAND_PALETTE_CONTEXT)),
KeyBinding::new("up", HighlightPrevious, Some(COMMAND_PALETTE_CONTEXT)),
KeyBinding::new("enter", Choose, Some(COMMAND_PALETTE_CONTEXT)),
KeyBinding::new("escape", Dismiss, Some(COMMAND_PALETTE_CONTEXT)),
]);
}
/// One action the palette can run: a stable id and the label the matcher sees.
#[derive(Clone)]
pub struct Command {
/// The stable domain id handed to `on_execute`.
pub id: SharedString,
/// What the matcher matches and the row shows.
pub label: SharedString,
/// Gray text on the row's right edge, e.g. the keybinding.
pub hint: Option<SharedString>,
}
impl Command {
/// Creates a command without a hint.
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
hint: None,
}
}
/// Sets the row's right-edge hint.
pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
self.hint = Some(hint.into());
self
}
}
/// Filters and ranks the commands for a query.
pub type CommandMatcher = Rc<dyn Fn(&str, &[Command]) -> Vec<Command>>;
/// Runs the chosen command.
pub type CommandHandler = Rc<dyn Fn(&Command, &mut Window, &mut App)>;
/// Builder for a command palette. Pass it to [`CommandPaletteState::new`].
pub struct CommandPalette {
id: ElementId,
name: SharedString,
placeholder: SharedString,
commands: Vec<Command>,
matcher: CommandMatcher,
on_execute: Option<CommandHandler>,
}
impl CommandPalette {
/// Creates the builder, the caller-owned matcher decides what matches.
pub fn new<I, M>(id: I, commands: Vec<Command>, matcher: M) -> Self
where
I: Into<ElementId>,
M: Fn(&str, &[Command]) -> Vec<Command> + 'static,
{
Self {
id: id.into(),
name: SharedString::from("Command palette"),
placeholder: SharedString::from("Type a command..."),
commands,
matcher: Rc::new(matcher),
on_execute: None,
}
}
/// Sets the text shown while the query is empty.
pub fn placeholder<L>(mut self, placeholder: L) -> Self
where
L: Into<SharedString>,
{
self.placeholder = placeholder.into();
self
}
/// Runs `handler` with the chosen command, then closes the palette.
pub fn on_execute<F>(mut self, handler: F) -> Self
where
F: Fn(&Command, &mut Window, &mut App) + 'static,
{
self.on_execute = Some(Rc::new(handler));
self
}
/// Overrides the accessibility label of the panel.
pub fn accessibility_label<L>(mut self, label: L) -> Self
where
L: Into<SharedString>,
{
self.name = label.into();
self
}
}
/// The stateful palette.
pub struct CommandPaletteState {
id: ElementId,
name: SharedString,
placeholder: SharedString,
commands: Vec<Command>,
matcher: CommandMatcher,
on_execute: Option<CommandHandler>,
query: Entity<InputState>,
/// The commands the matcher returned for the query, in display order.
matches: Vec<Command>,
/// The highlighted row, when the palette is open.
highlight: Option<usize>,
open: bool,
/// Keeps the query-event subscription tied to the state's lifetime.
_subscription: Subscription,
}
impl CommandPaletteState {
/// Builds the state, its query field, and the subscription tying them.
pub fn new(palette: CommandPalette, window: &mut Window, cx: &mut Context<Self>) -> Self {
let query = InputState::new(cx);
let subscription = cx.subscribe_in(&query, window, |state, _, event, window, cx| {
if event == &InputStateEvent::TextChanged {
state.text_changed(window, cx);
}
});
let matches = (palette.matcher)("", &palette.commands);
Self {
id: palette.id,
name: palette.name,
placeholder: palette.placeholder,
commands: palette.commands,
matcher: palette.matcher,
on_execute: palette.on_execute,
query,
matches,
highlight: None,
open: false,
_subscription: subscription,
}
}
/// Opens the palette: fresh query, fresh matches, focus in the field.
pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.open {
self.focus(window, cx);
return;
}
self.open = true;
self.query.update(cx, |query, cx| {
query.set_value_silent("", window, cx);
});
self.refilter("");
self.highlight = (!self.matches.is_empty()).then_some(0);
self.focus(window, cx);
cx.notify();
}
/// Closes the palette.
pub fn close(&mut self, cx: &mut Context<Self>) {
if !self.open {
return;
}
self.open = false;
self.highlight = None;
cx.notify();
}
/// Opens the palette when closed, closes it when open.
pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.open {
self.close(cx);
} else {
self.open(window, cx);
}
}
/// Whether the palette is showing.
pub fn is_open(&self) -> bool {
self.open
}
/// Replaces the command list and re-runs the current query's matches.
pub fn set_commands(&mut self, commands: Vec<Command>, cx: &mut Context<Self>) {
self.commands = commands;
let query = self.query.read(cx).value().to_string();
self.refilter(&query);
cx.notify();
}
/// Focuses the query field.
pub fn focus(&self, window: &mut Window, cx: &mut App) {
window.focus(&self.query.read(cx).focus_handle(), cx);
}
/// Asks the caller's matcher who matches, and clamps the highlight.
fn refilter(&mut self, query: &str) {
self.matches = (self.matcher)(query, &self.commands);
if let Some(highlight) = self.highlight {
self.highlight = (highlight < self.matches.len()).then_some(highlight);
}
}
/// Typing: fresh matches, highlight the first row.
fn text_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let query = self.query.read(cx).value().to_string();
self.refilter(&query);
self.highlight = (!self.matches.is_empty()).then_some(0);
cx.notify();
}
/// Down and Up over the matched rows, wrapping at the ends.
fn move_highlight(&mut self, delta: i32, cx: &mut Context<Self>) {
if self.matches.is_empty() {
return;
}
let next = match self.highlight {
Some(row) => (row as i32 + delta).rem_euclid(self.matches.len() as i32) as usize,
None if delta > 0 => 0,
None => self.matches.len() - 1,
};
self.highlight = Some(next);
cx.notify();
}
/// Enter. Runs the highlighted command and closes.
fn choose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(row) = self.highlight else {
cx.propagate();
return;
};
self.choose_row(row, window, cx);
}
/// Runs the command at a row and closes.
fn choose_row(&mut self, row: usize, window: &mut Window, cx: &mut Context<Self>) {
let Some(command) = self.matches.get(row) else {
return;
};
let command = command.clone();
if let Some(on_execute) = self.on_execute.clone() {
on_execute(&command, window, cx);
}
self.close(cx);
}
/// Escape. Closes the palette.
fn dismiss(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
if !self.open {
cx.propagate();
return;
}
self.close(cx);
}
}
impl Render for CommandPaletteState {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if !self.open {
return div().into_any_element();
}
let (colors, radius, metrics, shadows) = {
let theme = cx.theme();
(
theme.colors,
theme.radius,
theme.sizes.resolve(Size::default()),
theme.elevations.dialog.shadows(),
)
};
let id = self.id.clone();
let state = cx.entity();
let rows: Vec<AnyElement> = self
.matches
.iter()
.enumerate()
.map(|(row, command)| {
let highlighted = self.highlight == Some(row);
let state = state.clone();
let row_ix = row;
h_flex()
.id(("command", row))
.debug_selector({
let name = format!("{id}-command-{row}");
move || name
})
.gap_2()
.px_2()
.py_1()
.rounded(radius.sm)
.when(highlighted, |row| row.bg(colors.muted))
.when(!highlighted, |row| row.hover(|row| row.bg(colors.muted)))
.on_mouse_move({
let state = state.clone();
move |_, _window, cx| {
if state.read(cx).highlight != Some(row_ix) {
state.update(cx, |state, cx| {
state.highlight = Some(row_ix);
cx.notify();
});
}
}
})
.on_click({
let state = state.clone();
move |_, window, cx| {
state.update(cx, |state, cx| state.choose_row(row_ix, window, cx));
}
})
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.child(command.label.clone()),
)
.when_some(command.hint.clone(), |row, hint| {
row.child(
div()
.flex_shrink_0()
.text_size(metrics.caption_size)
.text_color(colors.muted_foreground)
.child(hint),
)
})
.into_any_element()
})
.collect();
let rows: AnyElement = if rows.is_empty() {
div()
.px_2()
.py_1()
.text_size(metrics.caption_size)
.text_color(colors.muted_foreground)
.child("No matches")
.into_any_element()
} else {
let scroll =
window.use_keyed_state((id.clone(), "rows-scroll"), cx, |_, _| ScrollHandle::new());
ScrollArea::new((id.clone(), "rows"))
.handle(scroll.read(cx).clone())
.axes(ScrollAxes::Vertical)
.max_h(px(320.))
.children(rows)
.into_any_element()
};
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
let query_focus = self.query.read(cx).focus_handle();
let panel = v_flex()
.id((id.clone(), "panel"))
.debug_selector({
let name = format!("{id}-panel");
move || name
})
.key_context(COMMAND_PALETTE_CONTEXT)
.role(Role::Dialog)
.aria_label(self.name.clone())
.w_full()
.max_w(relative(0.6))
.p_2()
.gap_2()
.bg(colors.surface)
.text_color(colors.foreground)
.rounded(radius.overlay)
.border_1()
.border_color(colors.border)
.shadow(shadows)
.overflow_hidden()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_action({
let state = state.clone();
move |_: &HighlightNext, _window, cx| {
state.update(cx, |state, cx| state.move_highlight(1, cx));
}
})
.on_action({
let state = state.clone();
move |_: &HighlightPrevious, _window, cx| {
state.update(cx, |state, cx| state.move_highlight(-1, cx));
}
})
.on_action({
let state = state.clone();
move |_: &Choose, window, cx| {
state.update(cx, |state, cx| state.choose(window, cx));
}
})
.on_action({
let state = state.clone();
move |_: &Dismiss, window, cx| {
state.update(cx, |state, cx| state.dismiss(window, cx));
}
})
.child(
Input::new((id.clone(), "query"), self.query.clone())
.placeholder(self.placeholder.clone()),
)
.child(rows)
.child(
canvas(
{
let cell = panel_bounds.clone();
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
},
{
let focus = query_focus.clone();
move |_, _, window, cx| {
if !focus.contains_focused(window, cx) {
window.focus(&focus, cx);
}
}
},
)
.absolute()
.inset_0(),
);
let scrim = div()
.absolute()
.inset_0()
.bg(colors.overlay)
.occlude()
.on_mouse_down(MouseButton::Left, {
let state = state.clone();
let panel_bounds = panel_bounds.clone();
move |event: &MouseDownEvent, _window, cx| {
let on_panel = panel_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position));
if on_panel {
return;
}
state.update(cx, |state, cx| state.close(cx));
cx.stop_propagation();
}
});
div()
.absolute()
.inset_0()
.flex()
.justify_center()
.items_start()
.pt_10()
.child(scrim)
.child(panel)
.into_any_element()
}
}
+5 -2
View File
@@ -78,6 +78,9 @@ pub mod status_dot;
#[cfg(feature = "combobox")]
pub mod combobox;
#[cfg(feature = "command_palette")]
pub mod command_palette;
#[cfg(feature = "toast")]
pub mod toast;
@@ -89,8 +92,8 @@ pub fn init(cx: &mut App) {
gpui_native_theme::Theme::init(cx);
#[cfg(feature = "input")]
input::bind_input_keys(cx, None);
// Load-bearing order: the combobox's bindings must win the depth tie
// with the input's, and gpui breaks ties by later registration.
#[cfg(feature = "combobox")]
combobox::bind_combobox_keys(cx);
#[cfg(feature = "command_palette")]
command_palette::bind_command_palette_keys(cx);
}
+279
View File
@@ -0,0 +1,279 @@
use std::cell::RefCell;
use std::rc::Rc;
use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
Styled as _, TestAppContext, VisualTestContext, Window, div,
};
use gpui_native_core::command_palette::{Command, CommandPalette, CommandPaletteState};
use gpui_native_core::init;
fn commands() -> Vec<Command> {
vec![
Command::new("open-file", "Open File").hint("Cmd+O"),
Command::new("close-tab", "Close Tab"),
Command::new("save-all", "Save All").hint("Cmd+Alt+S"),
Command::new("toggle-theme", "Toggle Theme"),
]
}
/// The caller-owned matcher: case-insensitive substring. `reverse` flips the
/// match order, exercising that ranking belongs to the caller.
fn substring_matcher(reverse: bool) -> impl Fn(&str, &[Command]) -> Vec<Command> {
move |query, commands| {
let query = query.to_lowercase();
let matched: Vec<Command> = commands
.iter()
.filter(|command| command.label.to_lowercase().contains(&query))
.cloned()
.collect();
if reverse {
matched.into_iter().rev().collect()
} else {
matched
}
}
}
struct Host {
palette: Entity<CommandPaletteState>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.palette.clone())
}
}
type Executed = Rc<RefCell<Vec<SharedString>>>;
struct PaletteHost {
cx: TestAppContext,
state: Entity<CommandPaletteState>,
executed: Executed,
visual: VisualTestContext,
}
fn palette_host(reverse: bool) -> PaletteHost {
let cx = TestAppContext::single();
// `init` registers the input bindings and then the palette's, in the
// load-bearing order.
cx.update(init);
let executed: Executed = Rc::new(RefCell::new(Vec::new()));
let state_cell: Rc<RefCell<Option<Entity<CommandPaletteState>>>> = Rc::new(RefCell::new(None));
let window = cx.update(|cx| {
let executed_for_builder = executed.clone();
let cell = state_cell.clone();
cx.open_window(gpui::WindowOptions::default(), |window, cx| {
let palette =
CommandPalette::new("test-palette", commands(), substring_matcher(reverse))
.on_execute(move |command: &Command, _, _| {
executed_for_builder.borrow_mut().push(command.id.clone())
});
let state = cx.new(|cx| CommandPaletteState::new(palette, window, cx));
cell.borrow_mut().replace(state.clone());
cx.new(|_| Host { palette: state })
})
.unwrap()
});
let state = state_cell.borrow().clone().expect("state captured");
let mut visual = VisualTestContext::from_window(window.into(), &cx);
draw(&mut visual);
PaletteHost {
cx,
state,
executed,
visual,
}
}
fn draw(visual: &mut VisualTestContext) {
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
}
fn open(host: &mut PaletteHost) {
let state = host.state.clone();
host.visual.update(|window, cx| {
state.update(cx, |state, cx| state.open(window, cx));
});
}
fn type_text(host: &mut PaletteHost, text: &str) {
for letter in text.chars() {
host.visual.simulate_keystrokes(&letter.to_string());
}
draw(&mut host.visual);
}
#[test]
fn open_lists_the_callers_commands() {
let mut host = palette_host(false);
open(&mut host);
draw(&mut host.visual);
assert!(host.visual.debug_bounds("test-palette-command-0").is_some());
assert!(host.visual.debug_bounds("test-palette-command-3").is_some());
assert!(host.visual.debug_bounds("test-palette-panel").is_some());
assert!(host.cx.update(|cx| host.state.read(cx).is_open()));
}
#[test]
fn enter_runs_the_highlighted_command_and_closes() {
let mut host = palette_host(false);
open(&mut host);
host.visual.simulate_keystrokes("enter");
draw(&mut host.visual);
assert_eq!(
*host.executed.borrow(),
vec![SharedString::from("open-file")],
"the first row starts highlighted"
);
assert!(host.visual.debug_bounds("test-palette-panel").is_none());
}
#[test]
fn rows_follow_the_callers_match_order() {
let mut host = palette_host(true);
open(&mut host);
host.visual.simulate_keystrokes("enter");
draw(&mut host.visual);
assert_eq!(
*host.executed.borrow(),
vec![SharedString::from("toggle-theme")],
"enter runs the first row, and the caller's matcher owns the row order"
);
}
#[test]
fn typing_filters_through_the_caller_matcher() {
let mut host = palette_host(false);
open(&mut host);
type_text(&mut host, "save");
assert!(host.visual.debug_bounds("test-palette-command-0").is_some());
assert!(
host.visual.debug_bounds("test-palette-command-1").is_none(),
"only the matching command stays listed"
);
host.visual.simulate_keystrokes("enter");
draw(&mut host.visual);
assert_eq!(
*host.executed.borrow(),
vec![SharedString::from("save-all")]
);
}
#[test]
fn escape_closes_without_running() {
let mut host = palette_host(false);
open(&mut host);
host.visual.simulate_keystrokes("escape");
draw(&mut host.visual);
assert!(host.visual.debug_bounds("test-palette-panel").is_none());
assert!(host.executed.borrow().is_empty());
assert!(!host.cx.update(|cx| host.state.read(cx).is_open()));
}
#[test]
fn arrow_keys_move_the_highlight() {
let mut host = palette_host(false);
open(&mut host);
host.visual.simulate_keystrokes("down");
host.visual.simulate_keystrokes("enter");
draw(&mut host.visual);
assert_eq!(
*host.executed.borrow(),
vec![SharedString::from("close-tab")]
);
// Up from the top wraps to the last row.
open(&mut host);
host.visual.simulate_keystrokes("up");
host.visual.simulate_keystrokes("enter");
draw(&mut host.visual);
assert_eq!(
*host.executed.borrow(),
vec![
SharedString::from("close-tab"),
SharedString::from("toggle-theme")
]
);
}
#[test]
fn clicking_a_row_runs_it() {
let mut host = palette_host(false);
open(&mut host);
let row = host
.visual
.debug_bounds("test-palette-command-2")
.expect("save-all row missing");
host.visual
.simulate_click(row.center(), gpui::Modifiers::default());
draw(&mut host.visual);
assert_eq!(
*host.executed.borrow(),
vec![SharedString::from("save-all")]
);
assert!(host.visual.debug_bounds("test-palette-panel").is_none());
}
#[test]
fn clicking_the_scrim_closes() {
let mut host = palette_host(false);
open(&mut host);
draw(&mut host.visual);
host.visual.simulate_click(
gpui::point(gpui::px(10.), gpui::px(10.)),
gpui::Modifiers::default(),
);
draw(&mut host.visual);
assert!(host.visual.debug_bounds("test-palette-panel").is_none());
assert!(host.executed.borrow().is_empty());
}
#[test]
fn reopen_starts_from_a_fresh_query() {
let mut host = palette_host(false);
open(&mut host);
type_text(&mut host, "zzzz");
assert!(
host.visual.debug_bounds("test-palette-command-0").is_none(),
"nothing matches nonsense"
);
host.visual.simulate_keystrokes("escape");
open(&mut host);
draw(&mut host.visual);
assert!(
host.visual.debug_bounds("test-palette-command-0").is_some(),
"a fresh open clears the query"
);
}
#[test]
fn set_commands_replaces_the_registry() {
let mut host = palette_host(false);
host.cx.update(|cx| {
host.state.update(cx, |state, cx| {
state.set_commands(vec![Command::new("only", "Only Command")], cx)
});
});
open(&mut host);
draw(&mut host.visual);
assert!(host.visual.debug_bounds("test-palette-command-0").is_some());
assert!(host.visual.debug_bounds("test-palette-command-1").is_none());
host.visual.simulate_keystrokes("enter");
draw(&mut host.visual);
assert_eq!(*host.executed.borrow(), vec![SharedString::from("only")]);
}
@@ -0,0 +1,96 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Instant;
use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
Styled as _, TestAppContext, Window, div,
};
use gpui_native_core::command_palette::{Command, CommandPalette, CommandPaletteState};
use gpui_native_core::init;
fn command(label: &'static str) -> Command {
Command::new(SharedString::from(label), SharedString::from(label))
}
struct Host {
palette: Entity<CommandPaletteState>,
renders: Rc<RefCell<usize>>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
*self.renders.borrow_mut() += 1;
div().size_full().child(self.palette.clone())
}
}
#[test]
fn typing_with_the_palette_open_is_not_pathologically_slow() {
let cx = TestAppContext::single();
cx.update(init);
let (state, renders, window) = cx.update(|cx| {
let renders = Rc::new(RefCell::new(0));
let state_cell: Rc<RefCell<Option<Entity<CommandPaletteState>>>> =
Rc::new(RefCell::new(None));
let renders_for_host = renders.clone();
let cell = state_cell.clone();
let window = cx
.open_window(gpui::WindowOptions::default(), |window, cx| {
let matcher = |query: &str, commands: &[Command]| {
let query = query.to_lowercase();
commands
.iter()
.filter(|command| command.label.to_lowercase().contains(&query))
.cloned()
.collect()
};
let state = cx.new(|cx| {
CommandPaletteState::new(
CommandPalette::new(
"bench-palette",
vec![
command("Open File"),
command("Close Tab"),
command("Save All"),
command("Toggle Theme"),
],
matcher,
),
window,
cx,
)
});
let state_for_host = state.clone();
cell.borrow_mut().replace(state.clone());
cx.new(|_| Host {
palette: state_for_host,
renders: renders_for_host,
})
})
.unwrap();
let state = state_cell.borrow().clone().expect("state captured");
(state, renders, window)
});
let mut visual = gpui::VisualTestContext::from_window(window.into(), &cx);
visual.update(|window, cx| {
state.update(cx, |state, cx| state.open(window, cx));
window.draw(cx).clear(cx);
});
let started = Instant::now();
for letter in "abcdefghijklmnopqrstuvwxyz".chars() {
visual.simulate_keystrokes(&letter.to_string());
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
}
let elapsed = started.elapsed();
let total_renders = *renders.borrow();
println!(
"26 real keystrokes with the palette open: {elapsed:?}, host renders: {total_renders} ({} per keystroke)",
total_renders as f32 / 26.
);
assert!(elapsed.as_millis() < 2000, "typing took {elapsed:?}");
}