use std::cell::Cell;
use std::rc::Rc;
use std::time::Duration;
use frust::Theme;
use frust::authoring::Role;
use frust::authoring::text::{
FontFamily, FontWeight, GenericSlot, LineHeight, TextContext, TextLayout, TextStyle,
};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
Key, LayoutCtx, NamedKey, PaintCtx, PaintScene, PointerButton, PointerEvent, PointerPhase,
SemanticsCtx, View, Widget, any,
};
use frust::{Curve, FrameTime};
use kurbo::{Affine, Point, Rect, RoundedRect, Shape, Size, Vec2};
use peniko::{Brush, Color};
use frust::Timing;
use frust::text_input;
use frust::{BackPolicy, NavigatorController, PopResult, PushOptions};
use frust::{TransitionDriver, TransitionSpec, make_driver};
use crate::press::presses;
const PANEL_PAD: f64 = 8.0;
const INPUT_ROW_H: f64 = 40.0;
const ROW_H: f64 = 34.0;
const ROW_PAD_X: f64 = 12.0;
const PROMPT_GAP: f64 = 8.0;
const MIN_WIDTH: f64 = 360.0;
const MAX_WIDTH: f64 = 560.0;
const TOP_FRACTION: f64 = 0.14;
const PROMPT: &str = ">";
const PROMPT_SIZE: f32 = 13.0;
const LABEL_SIZE: f32 = 13.0;
const HINT_SIZE: f32 = 11.0;
const LINE_HEIGHT: f32 = 1.4;
const CONTAINER: Color = Color::from_rgb8(0x27, 0x2d, 0x3d);
const RING: Color = Color::from_rgb8(0xff, 0xb6, 0x27);
const LABEL_INK: Color = Color::from_rgb8(0xf2, 0xea, 0xd9);
const MUTED_INK: Color = Color::from_rgb8(0xa3, 0x9c, 0x88);
const HOVER: Color = Color::from_rgb8(0x22, 0x28, 0x35);
const SCRIM: Color = Color::from_rgb8(0x00, 0x00, 0x00);
const RADIUS: f64 = 14.0;
const RING_ALPHA: f32 = 0.7;
const RING_WIDTH: f64 = 1.5;
const PATH_TOLERANCE: f64 = 0.1;
const SCRIM_ALPHA: f32 = 0.55;
const SHADOW_Y: f64 = 12.0;
const SHADOW_BLUR: f64 = 32.0;
const SHADOW_ALPHA: f32 = 0.45;
const ENTER_SCALE_START: f64 = 0.96;
const ENTER_DURATION: Duration = Duration::from_millis(150);
const ENTER_CURVE: Curve = Curve::Cubic(0.34, 1.35, 0.64, 1.0);
const EXIT_DURATION: Duration = Duration::from_millis(100);
const EXIT_CURVE: Curve = Curve::Cubic(0.4, 0.0, 1.0, 1.0);
const REDUCE_MOTION_DURATION: Duration = Duration::from_millis(100);
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn resolve_timings(theme: Option<&Theme>) -> (Timing, Timing) {
if theme.map(|t| t.motion.reduce_motion).unwrap_or(false) {
let t = Timing::Duration(REDUCE_MOTION_DURATION, Curve::Linear);
return (t, t);
}
match theme {
Some(t) => (
Timing::Duration(
Duration::from_secs_f64(t.motion.durations.fast / 1000.0),
t.motion.easing.spatial,
),
Timing::Duration(
Duration::from_secs_f64(t.motion.durations.instant / 1000.0),
t.motion.easing.exit,
),
),
None => (
Timing::Duration(ENTER_DURATION, ENTER_CURVE),
Timing::Duration(EXIT_DURATION, EXIT_CURVE),
),
}
}
fn resolve_container(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().surface_container_highest,
None => CONTAINER,
}
}
fn resolve_ring(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().primary,
None => RING,
}
}
fn resolve_label(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().on_surface,
None => LABEL_INK,
}
}
fn resolve_muted(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().on_surface_variant,
None => MUTED_INK,
}
}
fn resolve_hover(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().surface_container_high,
None => HOVER,
}
}
fn resolve_scrim(theme: Option<&Theme>) -> Color {
match theme {
Some(t) => t.scheme().scrim,
None => SCRIM,
}
}
fn resolve_radius(theme: Option<&Theme>) -> f64 {
match theme {
Some(t) => t.shape.large,
None => RADIUS,
}
}
fn resolve_shadow(theme: Option<&Theme>) -> (f64, f64, Color) {
match theme {
Some(t) => {
let s = t.elevation.level5.shadow(t.brightness);
(
s.y_offset,
s.blur_std_dev,
with_alpha(t.scheme().shadow, s.color_alpha),
)
}
None => (SHADOW_Y, SHADOW_BLUR, with_alpha(SCRIM, SHADOW_ALPHA)),
}
}
fn finite_or_zero(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
fn ui_face() -> FontFamily {
FontFamily::stack_with_generic(["IBM Plex Mono"], GenericSlot::Monospace)
}
fn display_face() -> FontFamily {
FontFamily::stack_with_generic(["Space Mono"], GenericSlot::Monospace)
}
fn palette_style(family: FontFamily, size: f32, weight: FontWeight, color: Color) -> TextStyle {
TextStyle {
family,
weight,
line_height: LineHeight::FontSizeRelative(LINE_HEIGHT),
..TextStyle::new(size, color)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PaletteItem {
pub label: String,
pub hint: Option<String>,
}
impl PaletteItem {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
hint: None,
}
}
pub fn hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
}
type OnQuery<State> = Rc<dyn Fn(&mut State, String)>;
type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
type OnClose = Rc<dyn Fn()>;
pub struct CommandPaletteView<State: 'static> {
query: String,
placeholder: String,
items: Vec<PaletteItem>,
on_query: OnQuery<State>,
on_select: OnSelect<State>,
dismissable: bool,
on_close: Option<OnClose>,
dismiss_signal: Option<Rc<Cell<u64>>>,
}
pub fn command_palette<State, Q, S>(
items: Vec<PaletteItem>,
on_query: Q,
on_select: S,
) -> CommandPaletteView<State>
where
State: 'static,
Q: Fn(&mut State, String) + 'static,
S: Fn(&mut State, usize) + 'static,
{
CommandPaletteView {
query: String::new(),
placeholder: "Search…".to_string(),
items,
on_query: Rc::new(on_query),
on_select: Rc::new(on_select),
dismissable: true,
on_close: None,
dismiss_signal: None,
}
}
impl<State: 'static> CommandPaletteView<State> {
pub fn query(mut self, query: impl Into<String>) -> Self {
self.query = query.into();
self
}
pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn dismissable(mut self, dismissable: bool) -> Self {
self.dismissable = dismissable;
self
}
pub fn on_close<F: Fn() + 'static>(mut self, on_close: F) -> Self {
self.on_close = Some(Rc::new(on_close));
self
}
pub fn dismiss_signal(mut self, signal: Rc<Cell<u64>>) -> Self {
self.dismiss_signal = Some(signal);
self
}
}
fn input_view<State: 'static>(
query: &str,
placeholder: &str,
on_query: OnQuery<State>,
) -> AnyView<State> {
let oq = on_query;
any::<State, _>(
text_input(query.to_string(), move |s: &mut State, t: String| oq(s, t))
.placeholder(placeholder.to_string())
.text_style(palette_style(
ui_face(),
LABEL_SIZE,
FontWeight::REGULAR,
LABEL_INK,
)),
)
}
pub fn show_command_palette<State, B, R>(
controller: &NavigatorController<State>,
build: B,
on_result: R,
) where
State: 'static,
B: Fn() -> CommandPaletteView<State> + 'static,
R: Fn(&mut State, PopResult) + 'static,
{
let close_ctrl = controller.clone();
let dismissable = build().dismissable;
let back_policy = if dismissable {
BackPolicy::DismissAnimated
} else {
BackPolicy::Veto
};
let dismiss_signal = Rc::new(Cell::new(0u64));
let signal_for_options = dismiss_signal.clone();
controller.push_with_options(
move || {
let ctrl = close_ctrl.clone();
any::<State, _>(
build()
.on_close(move || ctrl.pop())
.dismiss_signal(dismiss_signal.clone()),
)
},
PushOptions::transparent()
.transition(TransitionSpec::NONE)
.back(back_policy)
.dismiss_signal(signal_for_options)
.on_result(on_result),
);
}
struct GlyphLabel {
content: String,
layout: Option<TextLayout>,
laid_out_style: Option<TextStyle>,
}
impl GlyphLabel {
fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
layout: None,
laid_out_style: None,
}
}
fn layout(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
if let Some(cached) = &self.layout
&& self.laid_out_style.as_ref() == Some(style)
{
return cached.size();
}
let text_ctx = ctx.text_context::<TextContext>();
let laid = text_ctx.layout(&self.content, style, None);
let size = laid.size();
self.layout = Some(laid);
self.laid_out_style = Some(style.clone());
size
}
fn paint(&self, origin: Point, scene: &mut dyn PaintScene) {
if let Some(layout) = &self.layout {
for run in layout.to_scene_runs(origin) {
scene.draw_glyph_run(run);
}
}
}
}
struct Row {
label: GlyphLabel,
label_size: Size,
hint: Option<GlyphLabel>,
hint_size: Size,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Phase {
Enter,
Shown,
Exit,
Dismissed,
}
pub struct CommandPaletteWidget {
input: ChildPod,
prompt: GlyphLabel,
prompt_size: Size,
items: Vec<PaletteItem>,
rows: Vec<Row>,
on_select: frust::authoring::ErasedArgCallback<usize>,
dismissable: bool,
on_close: Option<OnClose>,
dismiss_signal: Option<Rc<Cell<u64>>>,
last_seen_dismiss: u64,
panel: Rect,
input_rect: Rect,
row_rects: Vec<Rect>,
phase: Phase,
driver: Option<TransitionDriver>,
autofocused: bool,
pressed_row: Option<usize>,
scrim_captured: bool,
}
fn make_rows(items: &[PaletteItem]) -> Vec<Row> {
items
.iter()
.map(|it| Row {
label: GlyphLabel::new(it.label.clone()),
label_size: Size::ZERO,
hint: it.hint.as_ref().map(GlyphLabel::new),
hint_size: Size::ZERO,
})
.collect()
}
impl<State: 'static> View<State> for CommandPaletteView<State> {
type Element = CommandPaletteWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> CommandPaletteWidget {
let input = frust::authoring::build_child(
&input_view::<State>(&self.query, &self.placeholder, self.on_query.clone()),
ctx,
);
CommandPaletteWidget {
input,
prompt: GlyphLabel::new(PROMPT),
prompt_size: Size::ZERO,
items: self.items.clone(),
rows: make_rows(&self.items),
on_select: frust::authoring::erase_callback_arg(&self.on_select),
dismissable: self.dismissable,
on_close: self.on_close.clone(),
last_seen_dismiss: self.dismiss_signal.as_ref().map(|s| s.get()).unwrap_or(0),
dismiss_signal: self.dismiss_signal.clone(),
panel: Rect::ZERO,
input_rect: Rect::ZERO,
row_rects: Vec::new(),
phase: Phase::Enter,
driver: None,
autofocused: false,
pressed_row: None,
scrim_captured: false,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut CommandPaletteWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
let prev_input = input_view::<State>(&prev.query, &prev.placeholder, prev.on_query.clone());
let next_input = input_view::<State>(&self.query, &self.placeholder, self.on_query.clone());
flags |= frust::authoring::rebuild_child(&prev_input, &next_input, &mut element.input, ctx);
if prev.items != self.items {
element.items = self.items.clone();
element.rows = make_rows(&self.items);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
element.dismissable = self.dismissable;
element.on_select = frust::authoring::erase_callback_arg(&self.on_select);
element.on_close = self.on_close.clone();
let signal_changed = self.dismiss_signal.as_ref().map(Rc::as_ptr)
!= element.dismiss_signal.as_ref().map(Rc::as_ptr);
if signal_changed {
element.last_seen_dismiss = self.dismiss_signal.as_ref().map(|s| s.get()).unwrap_or(0);
}
element.dismiss_signal = self.dismiss_signal.clone();
flags
}
fn teardown(&self, element: &mut CommandPaletteWidget, ctx: &mut BuildCtx<'_>) {
let input = input_view::<State>(&self.query, &self.placeholder, self.on_query.clone());
frust::authoring::teardown_child(&input, &mut element.input, ctx);
}
}
impl CommandPaletteWidget {
fn begin_exit(&mut self) {
if matches!(self.phase, Phase::Enter | Phase::Shown) {
self.phase = Phase::Exit;
self.driver = None;
}
}
fn advance(&mut self, now: FrameTime, enter: Timing, exit: Timing) -> (f64, f32, bool) {
match self.phase {
Phase::Enter => {
let adv = self
.driver
.get_or_insert_with(|| make_driver(enter).0)
.advance(now);
if adv.done {
self.phase = Phase::Shown;
self.driver = None;
}
let p = adv.value;
let scale = ENTER_SCALE_START + (1.0 - ENTER_SCALE_START) * p;
(scale, p.clamp(0.0, 1.0) as f32, !adv.done)
}
Phase::Shown => (1.0, 1.0, false),
Phase::Exit => {
let adv = self
.driver
.get_or_insert_with(|| make_driver(exit).0)
.advance(now);
let q = adv.value.clamp(0.0, 1.0);
let scale = 1.0 - (1.0 - ENTER_SCALE_START) * q;
if adv.done {
self.phase = Phase::Dismissed;
self.driver = None;
if let Some(on_close) = &self.on_close {
on_close();
}
}
(scale, (1.0 - q) as f32, !adv.done)
}
Phase::Dismissed => (ENTER_SCALE_START, 0.0, false),
}
}
fn row_at(&self, pos: Point) -> Option<usize> {
self.row_rects.iter().position(|r| r.contains(pos))
}
fn is_scrim_down(&self, event: &InputEvent) -> bool {
let InputEvent::Pointer(p) = event else {
return false;
};
p.phase == PointerPhase::Down
&& !self.input_rect.contains(p.position)
&& self.row_at(p.position).is_none()
}
fn autofocus_input(&mut self, ctx: &mut EventCtx) {
let target = Point::new(
self.input.origin().x + 2.0,
self.input.origin().y + INPUT_ROW_H / 2.0,
);
let down = InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Down,
position: target,
button: PointerButton::Primary,
});
let up = InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Up,
position: target,
button: PointerButton::Primary,
});
frust::authoring::route_event_single(&mut self.input, ctx, &down);
frust::authoring::route_event_single(&mut self.input, ctx, &up);
}
}
impl Widget for CommandPaletteWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let (prompt_style, label_style, hint_style) = {
let theme = Theme::from_layout_ctx(ctx);
let prompt_family =
theme.map_or_else(display_face, |t| t.type_scale.headline_small.family.clone());
let label_family =
theme.map_or_else(ui_face, |t| t.type_scale.body_large.family.clone());
let hint_family =
theme.map_or_else(ui_face, |t| t.type_scale.body_small.family.clone());
(
palette_style(
prompt_family,
PROMPT_SIZE,
FontWeight::BOLD,
resolve_ring(theme),
),
palette_style(
label_family,
LABEL_SIZE,
FontWeight::REGULAR,
resolve_label(theme),
),
palette_style(
hint_family,
HINT_SIZE,
FontWeight::REGULAR,
resolve_muted(theme),
),
)
};
let area_w = finite_or_zero(bc.max().width);
let area_h = finite_or_zero(bc.max().height);
let panel_w = area_w.clamp(0.0, MAX_WIDTH).max(MIN_WIDTH.min(area_w));
self.prompt_size = self.prompt.layout(ctx, &prompt_style);
let input_x = ROW_PAD_X + self.prompt_size.width + PROMPT_GAP;
let input_w = (panel_w - input_x - ROW_PAD_X).max(0.0);
self.input
.layout_child(ctx, &BoxConstraints::tight(Size::new(input_w, INPUT_ROW_H)));
for row in &mut self.rows {
row.label_size = row.label.layout(ctx, &label_style);
row.hint_size = row
.hint
.as_mut()
.map(|h| h.layout(ctx, &hint_style))
.unwrap_or(Size::ZERO);
}
let rows_h = self.rows.len() as f64 * ROW_H;
let panel_h = PANEL_PAD * 2.0 + INPUT_ROW_H + rows_h;
let panel_x = ((area_w - panel_w) / 2.0).max(0.0);
let panel_y = ((area_h - panel_h) * TOP_FRACTION).max(0.0);
self.panel = Rect::new(panel_x, panel_y, panel_x + panel_w, panel_y + panel_h);
self.input_rect = Rect::new(
panel_x + PANEL_PAD,
panel_y + PANEL_PAD,
panel_x + panel_w - PANEL_PAD,
panel_y + PANEL_PAD + INPUT_ROW_H,
);
self.input
.set_origin(Point::new(self.input_rect.x0 + input_x, self.input_rect.y0));
self.row_rects.clear();
let mut ry = self.input_rect.y1;
for _ in &self.rows {
self.row_rects.push(Rect::new(
panel_x + PANEL_PAD,
ry,
panel_x + panel_w - PANEL_PAD,
ry + ROW_H,
));
ry += ROW_H;
}
bc.constrain(Size::new(area_w, area_h))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
if let Some(generation) = self.dismiss_signal.as_ref().map(|s| s.get())
&& generation != self.last_seen_dismiss
{
self.last_seen_dismiss = generation;
self.begin_exit();
}
let theme = Theme::from_paint_ctx(ctx);
let (enter, exit) = resolve_timings(theme);
let (scale, scrim_frac, animating) = self.advance(ctx.frame_time(), enter, exit);
let origin_v = ctx.origin().to_vec2();
let scrim = with_alpha(resolve_scrim(theme), SCRIM_ALPHA * scrim_frac);
scene.fill_rect(ctx.origin(), ctx.size(), scrim);
let panel_origin = ctx.origin() + self.panel.origin().to_vec2();
let panel_size = self.panel.size();
let center = panel_origin + Vec2::new(panel_size.width / 2.0, panel_size.height / 2.0);
let transform = Affine::translate((center.x, center.y))
* Affine::scale(scale)
* Affine::translate((-center.x, -center.y));
scene.push_transform(transform);
let radius = resolve_radius(theme);
let (shadow_y, shadow_blur, shadow_color) = resolve_shadow(theme);
scene.draw_shadow(
Point::new(panel_origin.x, panel_origin.y + shadow_y),
panel_size,
radius,
shadow_blur,
shadow_color,
);
scene.fill_rounded_rect(panel_origin, panel_size, radius, resolve_container(theme));
if let Some(i) = self.pressed_row
&& let Some(r) = self.row_rects.get(i)
{
scene.fill_rect(
ctx.origin() + r.origin().to_vec2(),
r.size(),
resolve_hover(theme),
);
}
let rr = RoundedRect::from_rect(Rect::from_origin_size(Point::ORIGIN, panel_size), radius);
let path = rr.to_path(PATH_TOLERANCE);
scene.stroke_path(
panel_origin,
&path,
RING_WIDTH,
&Brush::Solid(with_alpha(resolve_ring(theme), RING_ALPHA)),
);
let prompt_origin = Point::new(
self.input_rect.x0 + ROW_PAD_X,
self.input_rect.y0 + (INPUT_ROW_H - self.prompt_size.height) / 2.0,
) + origin_v;
self.prompt.paint(prompt_origin, scene);
self.input.paint_child(ctx, scene);
for (i, row) in self.rows.iter().enumerate() {
let Some(r) = self.row_rects.get(i) else {
continue;
};
let label_origin = Point::new(
r.x0 + ROW_PAD_X,
r.y0 + (ROW_H - row.label_size.height) / 2.0,
) + origin_v;
row.label.paint(label_origin, scene);
if let Some(hint) = &row.hint {
let hint_origin = Point::new(
r.x1 - ROW_PAD_X - row.hint_size.width,
r.y0 + (ROW_H - row.hint_size.height) / 2.0,
) + origin_v;
hint.paint(hint_origin, scene);
}
}
scene.pop_transform();
if animating || self.phase == Phase::Dismissed {
ctx.request_frame();
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if event.is_broadcast() {
frust::authoring::route_event_single(&mut self.input, ctx, event);
return EventResult::Ignored;
}
if !self.autofocused {
self.autofocused = true;
if !self.is_scrim_down(event) {
self.autofocus_input(ctx);
}
}
if event.is_focus_routed() {
if let InputEvent::Key(k) = event
&& k.key == Key::Named(NamedKey::Escape)
{
if self.dismissable {
self.begin_exit();
}
return EventResult::Handled;
}
return frust::authoring::route_event_single(&mut self.input, ctx, event);
}
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
if self.input.is_active() {
return frust::authoring::route_event_single(&mut self.input, ctx, event);
}
match p.phase {
PointerPhase::Down => {
if self.input_rect.contains(p.position) {
return frust::authoring::route_event_single(&mut self.input, ctx, event);
}
if !presses(p) {
return EventResult::Handled;
}
if let Some(i) = self.row_at(p.position) {
self.pressed_row = Some(i);
ctx.capture_pointer();
ctx.request_redraw();
return EventResult::Handled;
}
self.scrim_captured = true;
ctx.capture_pointer();
EventResult::Handled
}
PointerPhase::Move => {
if self.pressed_row.is_some() || self.scrim_captured {
EventResult::Handled
} else {
EventResult::Ignored
}
}
PointerPhase::Up => {
if let Some(i) = self.pressed_row.take() {
if self.row_at(p.position) == Some(i) {
(self.on_select)(ctx, i);
}
ctx.request_redraw();
return EventResult::Handled;
}
if self.scrim_captured {
let outside = !self.panel.contains(p.position);
if self.dismissable && outside {
self.begin_exit();
}
self.scrim_captured = false;
return EventResult::Handled;
}
EventResult::Ignored
}
PointerPhase::Cancel => {
self.pressed_row = None;
self.scrim_captured = false;
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_container(
Role::Dialog,
|node| {
node.set_modal();
node.set_label("Command palette");
},
|ctx| {
self.input.semantics_child(ctx);
},
);
}
frust::authoring::visit_children!(input);
}
#[cfg(test)]
mod tests {
use super::*;
use frust::NavigatorView;
use frust::authoring::{KeyEvent, Modifiers, any as core_any};
use frust_core::RenderRoot;
use frust_widgets::navigator;
use std::any::Any;
use std::cell::Cell;
fn ft_ms(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn char_key(c: &str) -> InputEvent {
InputEvent::Key(KeyEvent {
key: Key::Character(c.to_string()),
modifiers: Modifiers::default(),
repeat: false,
})
}
fn items(n: usize) -> Vec<PaletteItem> {
(0..n)
.map(|i| PaletteItem::new(format!("Item {i}")).hint(format!("⌘{i}")))
.collect()
}
#[derive(Default)]
struct AppState {
queries: Vec<String>,
selected: Vec<usize>,
}
fn build_widget(view: &CommandPaletteView<AppState>) -> CommandPaletteWidget {
let mut counter = 0u64;
View::<AppState>::build(view, &mut BuildCtx::new(&mut counter))
}
fn layout(w: &mut CommandPaletteWidget) {
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::tight(Size::new(800.0, 600.0)));
}
fn dispatch(
w: &mut CommandPaletteWidget,
state: &mut AppState,
event: &InputEvent,
) -> EventResult {
let s: &mut dyn Any = state;
let mut ctx = EventCtx::new(s, Point::ZERO, Size::new(800.0, 600.0));
w.event(&mut ctx, event)
}
#[test]
fn first_event_claims_focus_for_the_input() {
let view: CommandPaletteView<AppState> = command_palette(
items(3),
|s: &mut AppState, q| s.queries.push(q),
|s: &mut AppState, i: usize| s.selected.push(i),
);
let mut w = build_widget(&view);
layout(&mut w);
assert!(!w.input.is_focused(), "not yet focused before any event");
let mut state = AppState::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert!(w.autofocused);
assert!(
w.input.is_focused(),
"the input claims focus on the first event"
);
}
#[test]
fn scrim_down_as_the_first_event_never_claims_focus_and_still_dismisses() {
let view: CommandPaletteView<AppState> = command_palette(
items(3),
|s: &mut AppState, q| s.queries.push(q),
|s: &mut AppState, i: usize| s.selected.push(i),
);
let mut w = build_widget(&view);
layout(&mut w);
w.phase = Phase::Shown;
assert!(!w.input.is_focused());
let mut state = AppState::default();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 2.0, 2.0));
assert!(w.autofocused, "the first-event gate still trips once");
assert!(
!w.input.is_focused(),
"a scrim Down never claims focus, even as the first event"
);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 2.0, 2.0));
assert!(
!w.input.is_focused(),
"no focus claim occurs anywhere in the scrim Down+Up sequence"
);
assert_eq!(w.phase, Phase::Exit, "the scrim tap still begins the exit");
}
#[test]
fn typing_streams_on_query_after_focus() {
let view: CommandPaletteView<AppState> = command_palette(
items(2),
|s: &mut AppState, q| s.queries.push(q),
|s: &mut AppState, i: usize| s.selected.push(i),
);
let mut w = build_widget(&view);
layout(&mut w);
let mut state = AppState::default();
dispatch(&mut w, &mut state, &char_key("a"));
assert!(w.input.is_focused());
assert_eq!(
state.queries,
vec!["a".to_string()],
"on_query streams the typed char"
);
}
#[test]
fn row_up_inside_fires_on_select() {
let view: CommandPaletteView<AppState> = command_palette(
items(3),
|s: &mut AppState, q| s.queries.push(q),
|s: &mut AppState, i: usize| s.selected.push(i),
);
let mut w = build_widget(&view);
layout(&mut w);
let mut state = AppState::default();
let r = w.row_rects[1];
let (cx, cy) = (r.center().x, r.center().y);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, cx, cy));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, cx, cy));
assert_eq!(state.selected, vec![1], "up-inside a row selects it");
}
#[test]
fn release_outside_the_pressed_row_does_not_select() {
let view: CommandPaletteView<AppState> = command_palette(
items(3),
|s: &mut AppState, q| s.queries.push(q),
|s: &mut AppState, i: usize| s.selected.push(i),
);
let mut w = build_widget(&view);
layout(&mut w);
let mut state = AppState::default();
let r1 = w.row_rects[1];
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, r1.center().x, r1.center().y),
);
let r2 = w.row_rects[2];
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, r2.center().x, r2.center().y),
);
assert!(
state.selected.is_empty(),
"a release off the pressed row cancels"
);
}
#[test]
fn exit_is_faster_than_enter() {
let view: CommandPaletteView<AppState> = command_palette(
items(1),
|s: &mut AppState, q| s.queries.push(q),
|s: &mut AppState, i: usize| s.selected.push(i),
);
let mut w = build_widget(&view);
let (enter, exit) = resolve_timings(None);
w.advance(ft_ms(0.0), enter, exit);
let (_, _, animating_enter_mid) = w.advance(ft_ms(120.0), enter, exit);
assert!(
animating_enter_mid,
"still entering at 120ms (< 150ms enter)"
);
w.advance(ft_ms(200.0), enter, exit);
assert_eq!(w.phase, Phase::Shown);
w.begin_exit();
w.advance(ft_ms(200.0), enter, exit); let (_, _, animating_exit_mid) = w.advance(ft_ms(200.0 + 99.0), enter, exit);
assert!(animating_exit_mid);
let (_, _, animating_exit_end) = w.advance(ft_ms(200.0 + 150.0), enter, exit);
assert!(!animating_exit_end, "the 100ms exit is done by 150ms");
assert_eq!(w.phase, Phase::Dismissed);
}
#[test]
fn scrim_cancel_begins_exit_and_fires_on_close() {
let closed = Rc::new(Cell::new(0u32));
let c = closed.clone();
let view: CommandPaletteView<AppState> = command_palette(
items(1),
|s: &mut AppState, q| s.queries.push(q),
|s: &mut AppState, i: usize| s.selected.push(i),
)
.on_close(move || c.set(c.get() + 1));
let mut w = build_widget(&view);
layout(&mut w);
let mut state = AppState::default();
w.phase = Phase::Shown;
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 2.0, 2.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 2.0, 2.0));
assert_eq!(w.phase, Phase::Exit);
let (enter, exit) = resolve_timings(None);
w.advance(ft_ms(0.0), enter, exit);
assert_eq!(closed.get(), 0, "on_close waits for the exit to finish");
w.advance(ft_ms(300.0), enter, exit);
assert_eq!(closed.get(), 1);
}
#[test]
fn dismissable_false_blocks_scrim_tap() {
let view: CommandPaletteView<AppState> = command_palette(
items(1),
|s: &mut AppState, q| s.queries.push(q),
|s: &mut AppState, i: usize| s.selected.push(i),
)
.dismissable(false);
let mut w = build_widget(&view);
layout(&mut w);
let mut state = AppState::default();
w.phase = Phase::Shown;
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 2.0, 2.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 2.0, 2.0));
assert_eq!(
w.phase,
Phase::Shown,
"a scrim tap is a no-op when not dismissable"
);
}
#[test]
fn dismissable_false_blocks_escape() {
let view: CommandPaletteView<AppState> = command_palette(
items(1),
|s: &mut AppState, q| s.queries.push(q),
|s: &mut AppState, i: usize| s.selected.push(i),
)
.dismissable(false);
let mut w = build_widget(&view);
layout(&mut w);
let mut state = AppState::default();
w.phase = Phase::Shown;
let escape = InputEvent::Key(KeyEvent {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
});
let result = dispatch(&mut w, &mut state, &escape);
assert_eq!(
w.phase,
Phase::Shown,
"Escape is a no-op when not dismissable"
);
assert_eq!(result, EventResult::Handled, "Escape is still consumed");
}
#[test]
fn dismissable_false_still_allows_on_select_close() {
let view: CommandPaletteView<AppState> = command_palette(
items(3),
|s: &mut AppState, q| s.queries.push(q),
|s: &mut AppState, i: usize| s.selected.push(i),
)
.dismissable(false);
let mut w = build_widget(&view);
layout(&mut w);
let mut state = AppState::default();
let r = w.row_rects[0];
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Down, r.center().x, r.center().y),
);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Up, r.center().x, r.center().y),
);
assert_eq!(
state.selected,
vec![0],
"the explicit row-select close path always works"
);
}
#[test]
fn dismiss_signal_bump_begins_exit_on_next_paint() {
let signal = Rc::new(Cell::new(0u64));
let view: CommandPaletteView<AppState> = command_palette(
items(1),
|s: &mut AppState, q| s.queries.push(q),
|s: &mut AppState, i: usize| s.selected.push(i),
)
.dismiss_signal(signal.clone());
let mut w = build_widget(&view);
layout(&mut w);
w.phase = Phase::Shown;
let mut rec = Recorder::default();
let area = Size::new(800.0, 600.0);
let mut pctx = PaintCtx::new(Point::ZERO, area);
w.paint(&mut pctx, &mut rec);
assert_eq!(w.phase, Phase::Shown, "no bump yet — nothing happens");
signal.set(signal.get() + 1);
let mut pctx = PaintCtx::new(Point::ZERO, area);
w.paint(&mut pctx, &mut rec);
assert_eq!(
w.phase,
Phase::Exit,
"the widget observes the bump on its next paint and begins exit"
);
w.phase = Phase::Shown;
let mut pctx = PaintCtx::new(Point::ZERO, area);
w.paint(&mut pctx, &mut rec);
assert_eq!(
w.phase,
Phase::Shown,
"an already-observed generation never re-triggers"
);
}
#[derive(Default)]
struct Recorder {
rects: Vec<(Point, Size, Color)>,
rrects: Vec<(Point, Size, f64, Color)>,
strokes: Vec<Color>,
transforms: usize,
transform_pops: u32,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
self.rects.push((o, s, c));
}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect(&mut self, o: Point, s: Size, r: f64, c: Color) {
self.rrects.push((o, s, r, c));
}
fn draw_shadow(&mut self, _o: Point, _s: Size, _r: f64, _b: f64, _c: Color) {}
fn stroke_path(&mut self, _o: Point, _p: &kurbo::BezPath, _w: f64, brush: &Brush) {
if let Brush::Solid(c) = brush {
self.strokes.push(*c);
}
}
fn draw_glyph_run(&mut self, _run: frust::authoring::scene::GlyphRun) {}
fn push_transform(&mut self, _t: Affine) {
self.transforms += 1;
}
fn pop_transform(&mut self) {
self.transform_pops += 1;
}
}
fn paint_shown(theme: &Theme) -> Recorder {
let view: CommandPaletteView<AppState> = command_palette(
items(2),
|s: &mut AppState, q| s.queries.push(q),
|s: &mut AppState, i: usize| s.selected.push(i),
);
let mut w = build_widget(&view);
let mut tcx = TextContext::new();
let area = Size::new(800.0, 600.0);
let mut lctx =
LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), Some(theme as &dyn Any));
w.layout(&mut lctx, &BoxConstraints::tight(area));
w.phase = Phase::Shown;
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, area).with_theme(theme);
w.paint(&mut pctx, &mut rec);
rec
}
#[test]
fn panel_ring_and_scrim_resolve_dark_and_light() {
let dark = crate::baseline();
let light = dark.clone().with_brightness(frust::Brightness::Light);
for theme in [&dark, &light] {
let rec = paint_shown(theme);
assert_eq!(rec.rects[0].1, Size::new(800.0, 600.0));
assert_eq!(
rec.rects[0].2,
with_alpha(theme.scheme().scrim, SCRIM_ALPHA)
);
assert_eq!(rec.rrects[0].3, theme.scheme().surface_container_highest);
assert_eq!(rec.rrects[0].2, theme.shape.large);
assert_eq!(
rec.strokes[0],
with_alpha(theme.scheme().primary, RING_ALPHA)
);
assert_eq!(rec.transforms, 1);
assert_eq!(rec.transform_pops, 1);
}
assert_ne!(
crate::baseline().scheme().surface_container_highest,
crate::baseline()
.with_brightness(frust::Brightness::Light)
.scheme()
.surface_container_highest
);
}
#[derive(Default)]
struct NavState {
results: Vec<Option<usize>>,
}
fn app_page(
controller: &NavigatorController<NavState>,
) -> impl FnMut(&mut NavState) -> NavigatorView<NavState> {
let ctrl = controller.clone();
move |_: &mut NavState| navigator(&ctrl, || core_any::<NavState, _>(sized(800.0, 600.0)))
}
struct Sized {
size: Size,
}
struct SizedW {
size: Size,
}
impl View<NavState> for Sized {
type Element = SizedW;
fn build(&self, _c: &mut BuildCtx<'_>) -> SizedW {
SizedW { size: self.size }
}
fn rebuild(&self, _p: &Self, _e: &mut SizedW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
ChangeFlags::NONE
}
}
impl Widget for SizedW {
fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(self.size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
}
}
fn sized(w: f64, h: f64) -> Sized {
Sized {
size: Size::new(w, h),
}
}
#[test]
fn show_command_palette_dismisses_and_clears_focus_via_navigator() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = app_page(&controller);
let mut state = NavState::default();
let area = Size::new(800.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_command_palette(
&controller,
|| command_palette(items(3), |_s: &mut NavState, _q| {}, |_s, _i| {}),
|state: &mut NavState, result: PopResult| state.results.push(result.take::<usize>()),
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(300.0));
root.event(&mut state, &ev(PointerPhase::Down, 300.0, 90.0));
root.event(&mut state, &ev(PointerPhase::Up, 300.0, 90.0));
assert!(
root.is_focus_active(),
"the input claimed focus through the modal chain"
);
root.event(&mut state, &ev(PointerPhase::Down, 5.0, 590.0));
root.event(&mut state, &ev(PointerPhase::Up, 5.0, 590.0));
root.paint(&mut Recorder::default(), ft_ms(400.0));
root.paint(&mut Recorder::default(), ft_ms(700.0));
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"scrim cancel pops with an empty result"
);
assert!(
!root.is_pointer_captured(),
"no capture survives the dismiss"
);
}
#[test]
fn back_request_animates_exit_then_pops_when_dismissable() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = app_page(&controller);
let mut state = NavState::default();
let area = Size::new(800.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_command_palette(
&controller,
|| command_palette(items(3), |_s: &mut NavState, _q| {}, |_s, _i| {}),
|state: &mut NavState, result: PopResult| state.results.push(result.take::<usize>()),
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(300.0));
assert_eq!(controller.depth(), 2);
controller.request_back();
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
assert_eq!(
controller.depth(),
2,
"DismissAnimated leaves the stack unchanged immediately"
);
root.paint(&mut Recorder::default(), ft_ms(400.0));
root.paint(&mut Recorder::default(), ft_ms(700.0));
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
assert_eq!(
state.results,
vec![None],
"back-dismiss pops with an empty result"
);
assert_eq!(controller.depth(), 1, "the palette page is gone");
}
#[test]
fn back_request_is_vetoed_when_not_dismissable() {
let controller: NavigatorController<NavState> = NavigatorController::new();
let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
let mut app = app_page(&controller);
let mut state = NavState::default();
let area = Size::new(800.0, 600.0);
root.rebuild(&mut app, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(area, &mut tcx as &mut dyn Any);
show_command_palette(
&controller,
|| {
command_palette(items(3), |_s: &mut NavState, _q| {}, |_s, _i| {})
.dismissable(false)
},
|state: &mut NavState, result: PopResult| state.results.push(result.take::<usize>()),
);
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(0.0));
root.paint(&mut Recorder::default(), ft_ms(300.0));
assert_eq!(controller.depth(), 2);
controller.request_back();
root.rebuild(&mut app, &mut state);
root.layout_with_text(area, &mut tcx as &mut dyn Any);
root.paint(&mut Recorder::default(), ft_ms(400.0));
assert_eq!(
controller.depth(),
2,
"Veto leaves the stack unchanged — the back press is consumed but nothing happens"
);
assert!(
state.results.is_empty(),
"no pop, so no result callback fires"
);
}
#[test]
fn semantics_is_a_modal_dialog() {
fn logic(_s: &mut AppState) -> CommandPaletteView<AppState> {
command_palette(
items(1),
|s: &mut AppState, q| s.queries.push(q),
|s: &mut AppState, i: usize| s.selected.push(i),
)
}
let mut root: RenderRoot<AppState, CommandPaletteView<AppState>> = RenderRoot::new();
let mut state = AppState::default();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(800.0, 600.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Dialog)
.expect("a Role::Dialog node is contributed");
assert!(node.is_modal());
assert_eq!(node.label(), Some("Command palette"));
}
#[cfg(feature = "bundled-fonts")]
fn one_hinted_row(_: &mut ()) -> CommandPaletteView<()> {
command_palette(
vec![PaletteItem::new("deploy").hint("ctrl d")],
|_s: &mut (), _q| {},
|_s: &mut (), _i: usize| {},
)
.placeholder("Search")
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn runs_paint_in_their_role_faces_under_the_glyph_theme() {
use crate::badge::typeface_probe::{Face, painted_faces};
let faces = painted_faces(one_hinted_row, crate::baseline(), Size::new(800.0, 600.0));
assert_eq!(
faces,
[
Face::SpaceMono,
Face::PlexMono,
Face::PlexMono,
Face::PlexMono
]
);
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn runs_follow_a_live_theme_family_swap_except_the_query_field() {
use crate::badge::typeface_probe::{Face, faces_across_a_live_swap};
let (before, after) = faces_across_a_live_swap(one_hinted_row, Size::new(800.0, 600.0));
assert_eq!(
before,
[
Face::SpaceMono,
Face::PlexMono,
Face::PlexMono,
Face::PlexMono
]
);
assert_eq!(
after,
[
Face::PlexMono,
Face::PlexMono,
Face::SpaceMono,
Face::SpaceMono
]
);
}
}