use bevy::input::ButtonState;
use bevy::input::keyboard::{KeyCode, KeyboardInput};
use bevy::input_focus::{AcquireFocus, FocusCause, InputFocus};
use bevy::picking::events::PointerMove;
use bevy::prelude::*;
use bevy::text::{EditableText, TextEdit};
use bevy::ui::{ComputedNode, InteractionDisabled, Selected, UiGlobalTransform};
use bevy::ui_widgets::{Activate, TextInput, ValueChange};
use crate::cancel::Cancel;
use crate::choice::ChoiceValue;
use crate::feedback::{UiFeedback, UiFeedbackKind};
use crate::model::{PAD_REPEAT_DELAY, PAD_REPEAT_RATE, fuzzy_score, repeat_steps};
use crate::shortcut::Shortcut;
use crate::skin::SAFE_EDGE_PERCENT;
use crate::text::{TextInputOf, TextInputValue};
pub const PALETTE_ROWS: usize = 8;
pub(crate) const NO_MATCH: &str = "No command matches";
pub(crate) const PALETTE_HINT: &str = "Type a command";
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct PaletteCommand {
pub id: String,
pub title: String,
pub category: Option<String>,
pub shortcut: Option<Shortcut>,
pub enabled: Result<(), String>,
}
impl PaletteCommand {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
category: None,
shortcut: None,
enabled: Ok(()),
}
}
pub fn category(mut self, category: impl Into<String>) -> Self {
self.category = Some(category.into());
self
}
pub fn shortcut(mut self, shortcut: Shortcut) -> Self {
self.shortcut = Some(shortcut);
self
}
pub fn disabled(mut self, reason: impl Into<String>) -> Self {
self.enabled = Err(reason.into());
self
}
pub fn label(&self) -> String {
match &self.category {
Some(category) => format!("{category}: {}", self.title),
None => self.title.clone(),
}
}
}
#[derive(Resource, Clone, Debug, Default)]
pub struct CommandList(pub Vec<PaletteCommand>);
#[derive(Resource, Clone, Debug, Default, PartialEq, Eq, Reflect)]
#[reflect(Resource, Default, Debug, PartialEq, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PaletteHistory(pub Vec<String>);
impl PaletteHistory {
pub const LIMIT: usize = 10;
pub fn record(&mut self, id: &str) {
self.0.retain(|seen| seen != id);
self.0.insert(0, id.to_owned());
self.0.truncate(Self::LIMIT);
}
}
#[derive(Event, Clone, Debug, PartialEq, Eq)]
pub struct RunCommand {
pub id: String,
}
#[derive(Component, Clone, Debug)]
pub struct CommandPalette {
selected: usize,
top: usize,
cap: usize,
query: String,
matches: Vec<usize>,
source: Option<PaletteSource>,
}
#[derive(Clone, Debug)]
struct PaletteSource {
options: Vec<PaletteCommand>,
select: Entity,
}
impl Default for CommandPalette {
fn default() -> Self {
Self {
selected: 0,
top: 0,
cap: PALETTE_ROWS,
query: String::new(),
matches: Vec::new(),
source: None,
}
}
}
impl CommandPalette {
pub(crate) fn is_for(&self, select: Entity) -> bool {
self.source
.as_ref()
.is_some_and(|source| source.select == select)
}
pub(crate) fn for_select(select: Entity, options: &[String], selected: usize) -> Self {
Self {
selected,
source: Some(PaletteSource {
options: options
.iter()
.enumerate()
.map(|(index, label)| PaletteCommand::new(index.to_string(), label.clone()))
.collect(),
select,
}),
..Self::default()
}
}
}
impl CommandPalette {
pub fn visible_rows(&self) -> usize {
self.cap
}
pub fn results(&self) -> &[usize] {
&self.matches
}
pub fn selected(&self) -> usize {
self.selected
}
pub fn selected_command<'a>(&self, list: &'a CommandList) -> Option<&'a PaletteCommand> {
self.matches
.get(self.selected)
.and_then(|&index| list.0.get(index))
}
}
#[derive(Component, Clone, Copy, Debug)]
pub struct PaletteInput(pub Entity);
#[derive(Component, Clone, Copy, Debug)]
pub struct PaletteRow {
pub palette: Entity,
pub slot: usize,
}
#[derive(Component, Clone, Copy, Debug)]
pub struct PaletteNote(pub Entity);
#[derive(Component, Clone, Copy, Debug)]
pub struct PaletteCount(pub Entity);
pub(crate) fn palette_on_cancel(mut cancel: On<Cancel>, mut commands: Commands) {
cancel.propagate(false);
commands.entity(cancel.entity).despawn();
}
pub(crate) fn palette_on_click(click: On<PointerClick>, mut commands: Commands) {
if click.original_event_target() == click.entity {
commands.entity(click.entity).despawn();
}
}
pub(crate) fn palette_on_acquire(
mut acquire: On<AcquireFocus>,
inputs: Query<(&PaletteInput, Entity)>,
fields: Query<(Entity, &TextInputOf), With<TextInput>>,
mut focus: ResMut<InputFocus>,
) {
let palette = acquire.focused_entity;
acquire.propagate(false);
let Some((field, _)) = fields
.iter()
.find(|(_, of)| inputs.get(of.0).is_ok_and(|(input, _)| input.0 == palette))
else {
return;
};
if focus.get() != Some(field) {
focus.set(field, FocusCause::Pressed);
}
}
pub(crate) fn palette_on_move(
moved: On<PointerMove>,
rows: Query<&PaletteRow>,
mut palettes: Query<&mut CommandPalette>,
) {
let Ok(row) = rows.get(moved.entity) else {
return;
};
let Ok(mut palette) = palettes.get_mut(row.palette) else {
return;
};
let index = palette.top + row.slot;
if index < palette.matches.len() && palette.selected != index {
palette.selected = index;
}
}
pub(crate) fn palette_on_activate(
activate: On<Activate>,
rows: Query<&PaletteRow>,
mut palettes: Query<&mut CommandPalette>,
list: Res<CommandList>,
mut history: ResMut<PaletteHistory>,
mut commands: Commands,
) {
let Ok(row) = rows.get(activate.entity) else {
return;
};
let Ok(mut palette) = palettes.get_mut(row.palette) else {
return;
};
let index = palette.top + row.slot;
if index < palette.matches.len() {
palette.selected = index;
}
run_selected(row.palette, &palette, &list, &mut history, &mut commands);
}
fn run_selected(
palette: Entity,
state: &CommandPalette,
list: &CommandList,
history: &mut PaletteHistory,
commands: &mut Commands,
) -> bool {
if let Some(source) = &state.source {
let Some(&index) = state.matches.get(state.selected) else {
return false;
};
let select = source.select;
commands.entity(palette).despawn();
commands.queue(move |world: &mut World| {
let now = world.get::<ChoiceValue>(select).map(|value| value.0);
if now.is_some_and(|now| now != index) {
world.trigger(ValueChange {
source: select,
value: index,
is_final: true,
});
}
});
return true;
}
let Some(command) = state.selected_command(list) else {
return false;
};
if command.enabled.is_err() {
return false;
}
let id = command.id.clone();
history.record(&id);
commands.entity(palette).despawn();
commands.trigger(RunCommand { id });
true
}
#[allow(clippy::too_many_arguments)]
pub fn palette_by_input(
mut keys: MessageReader<KeyboardInput>,
gamepads: Query<&Gamepad>,
time: Res<Time<Real>>,
focus: Res<InputFocus>,
list: Res<CommandList>,
mut history: ResMut<PaletteHistory>,
parents: Query<&ChildOf>,
mut palettes: Query<&mut CommandPalette>,
mut fields: Query<&mut EditableText, With<TextInput>>,
mut feedback: MessageWriter<UiFeedback>,
mut held: Local<(i8, f32)>,
mut commands: Commands,
) {
let pressed: Vec<(KeyCode, bool)> = keys
.read()
.filter(|key| key.state == ButtonState::Pressed)
.map(|key| (key.key_code, key.repeat))
.collect();
let field = focus.get().filter(|&f| fields.contains(f));
let palette = field.and_then(|f| {
parents
.iter_ancestors(f)
.find(|&ancestor| palettes.contains(ancestor))
});
let (Some(field), Some(palette)) = (field, palette) else {
*held = (0, 0.0);
return;
};
let Ok(mut state) = palettes.get_mut(palette) else {
return;
};
let down = |button| gamepads.iter().any(|g| g.pressed(button));
let pad_direction = down(GamepadButton::DPadDown) as i8 - down(GamepadButton::DPadUp) as i8;
let pad_steps = if pad_direction == 0 {
*held = (0, 0.0);
0
} else if held.0 == pad_direction {
let before = held.1;
held.1 += time.delta_secs();
repeat_steps(before, held.1, PAD_REPEAT_DELAY, PAD_REPEAT_RATE)
} else {
*held = (pad_direction, 0.0);
1
};
let count = state.matches.len();
let page = state.cap.max(1);
let mut selected = state.selected;
let mut typed_arrow = false;
let mut run = gamepads
.iter()
.any(|g| g.just_pressed(GamepadButton::South));
for (key, repeat) in pressed {
match key {
KeyCode::ArrowDown if count > 0 => {
selected = (selected + 1) % count;
typed_arrow = true;
}
KeyCode::ArrowUp if count > 0 => {
selected = (selected + count - 1) % count;
typed_arrow = true;
}
KeyCode::PageDown if count > 0 => selected = (selected + page).min(count - 1),
KeyCode::PageUp if count > 0 => selected = selected.saturating_sub(page),
KeyCode::Enter | KeyCode::NumpadEnter if !repeat => run = true,
_ => {}
}
}
if count > 0 && pad_steps > 0 {
for _ in 0..pad_steps {
selected = if pad_direction > 0 {
(selected + 1) % count
} else {
(selected + count - 1) % count
};
}
}
if selected != state.selected {
state.selected = selected;
feedback.write(UiFeedback {
entity: palette,
kind: UiFeedbackKind::Focus,
});
}
if typed_arrow && let Ok(mut text) = fields.get_mut(field) {
text.queue_edit(TextEdit::TextEnd(false));
}
let composing = fields.get(field).is_ok_and(|text| text.is_composing());
if run && !composing && run_selected(palette, &state, &list, &mut history, &mut commands) {
feedback.write(UiFeedback {
entity: palette,
kind: UiFeedbackKind::Activate,
});
}
}
fn rank(list: &[PaletteCommand], history: &[String], query: &str, ordered: bool) -> Vec<usize> {
let labels: Vec<String> = list.iter().map(PaletteCommand::label).collect();
let keys: Vec<String> = labels.iter().map(|label| label.to_lowercase()).collect();
let mut scored: Vec<(usize, u32, usize)> = labels
.iter()
.enumerate()
.filter_map(|(index, label)| {
let score = fuzzy_score(query, label)?;
let recent = history
.iter()
.position(|id| *id == list[index].id)
.unwrap_or(usize::MAX);
Some((index, score, recent))
})
.collect();
let searching = !query.trim().is_empty();
scored.sort_by(|a, b| {
a.2.cmp(&b.2)
.then_with(|| {
if searching {
b.1.cmp(&a.1)
} else {
std::cmp::Ordering::Equal
}
})
.then_with(|| {
if ordered {
a.0.cmp(&b.0)
} else {
keys[a.0].cmp(&keys[b.0])
}
})
});
scored.into_iter().map(|(index, ..)| index).collect()
}
type CountParts = (
&'static PaletteCount,
&'static mut Node,
&'static mut Text,
&'static ComputedNode,
);
type CountOnly = (Without<PaletteRow>, Without<PaletteNote>);
type RowParts = (
Entity,
&'static PaletteRow,
&'static Children,
&'static mut Node,
&'static ComputedNode,
Has<Selected>,
Has<InteractionDisabled>,
);
const COUNT_RESERVE: f32 = 0.75;
const ROUNDING_PX: f32 = 1.5;
fn rows_that_fit(
window: Vec2,
card_top: f32,
card_height: f32,
rows_height: f32,
count_height: f32,
row_height: f32,
held: usize,
) -> usize {
let chrome = card_height - rows_height - count_height;
let room = window.y
- window.x * SAFE_EDGE_PERCENT / 100.0
- card_top
- chrome
- row_height * COUNT_RESERVE;
let exact = room / row_height;
let slack = ROUNDING_PX / row_height;
let fit = exact.floor().max(1.0);
let held_rows = held as f32;
let fit = if held_rows > fit && exact + slack >= held_rows
|| held_rows < fit && exact - slack < held_rows + 1.0
{
held_rows
} else {
fit
};
(fit as usize).clamp(1, PALETTE_ROWS)
}
#[allow(clippy::too_many_arguments)]
pub fn update_palette(
list: Res<CommandList>,
history: Res<PaletteHistory>,
inputs: Query<(Entity, Ref<TextInputValue>, &PaletteInput)>,
mut palettes: Query<(Entity, &mut CommandPalette)>,
parents: Query<&ChildOf>,
layouts: Query<(Ref<ComputedNode>, &UiGlobalTransform)>,
mut rows: Query<RowParts>,
mut notes: Query<(&PaletteNote, &mut Node), Without<PaletteRow>>,
mut counts: Query<CountParts, CountOnly>,
mut texts: Query<&mut Text, (Without<PaletteCount>, Without<PaletteNote>)>,
mut commands: Commands,
) {
let card_of = |input: Entity| parents.get(input).map(ChildOf::parent).ok();
let resized = |entity: Entity, input: Entity| {
layouts.get(entity).is_ok_and(|(node, _)| node.is_changed())
|| card_of(input)
.and_then(|card| layouts.get(card).ok())
.is_some_and(|(node, _)| node.is_changed())
};
let dirty: Vec<Entity> = palettes
.iter_mut()
.filter(|(entity, palette)| {
palette.is_added()
|| palette.is_changed()
|| list.is_changed()
|| history.is_changed()
|| inputs.iter().any(|(input, value, of)| {
of.0 == *entity && (value.is_changed() || resized(*entity, input))
})
})
.map(|(entity, _)| entity)
.collect();
for entity in dirty {
let Some((input, query)) = inputs
.iter()
.find(|(_, _, of)| of.0 == entity)
.map(|(input, value, _)| (input, value.0.trim().to_owned()))
else {
continue;
};
let Ok((_, mut state)) = palettes.get_mut(entity) else {
continue;
};
let state = state.bypass_change_detection();
let own = state.source.clone();
let items: &[PaletteCommand] = own.as_ref().map_or(&list.0, |own| &own.options);
let recents: &[String] = if own.is_some() { &[] } else { &history.0 };
let shown: Vec<f32> = rows
.iter()
.filter(|(_, part, _, node, ..)| {
part.palette == entity && node.display != Display::None
})
.map(|(_, _, _, _, computed, ..)| computed.size().y)
.collect();
let count_height: f32 = counts
.iter()
.filter(|(part, node, ..)| part.0 == entity && node.display != Display::None)
.map(|(.., computed)| computed.size().y)
.sum();
let row_height = shown.iter().sum::<f32>() / shown.len().max(1) as f32;
if let (true, Some(card), Some(window)) = (
row_height > 0.0,
card_of(input).and_then(|card| layouts.get(card).ok()),
layouts.get(entity).ok(),
) && window.0.size().min_element() > 0.0
&& card.0.size().y > 0.0
{
let card_top = card.1.translation.y - card.0.size().y / 2.0;
state.cap = rows_that_fit(
window.0.size(),
card_top,
card.0.size().y,
shown.iter().sum(),
count_height,
row_height,
state.cap,
);
}
let matches = rank(items, recents, &query, own.is_some());
if query != state.query {
state.query = query;
state.selected = 0;
state.top = 0;
}
state.matches = matches;
let count = state.matches.len();
let cap = state.cap;
state.selected = state.selected.min(count.saturating_sub(1));
if state.selected < state.top {
state.top = state.selected;
} else if state.selected >= state.top + cap {
state.top = state.selected + 1 - cap;
}
state.top = state.top.min(count.saturating_sub(cap));
for (row, part, children, mut node, _, lit, disabled) in &mut rows {
if part.palette != entity {
continue;
}
let index = state.top + part.slot;
let command = state
.matches
.get(index)
.and_then(|&i| items.get(i))
.filter(|_| part.slot < cap);
let display = if command.is_some() {
Display::Flex
} else {
Display::None
};
if node.display != display {
node.display = display;
}
let (title, hint) = command.map_or((String::new(), String::new()), |command| {
let hint = match (&command.enabled, &command.shortcut) {
(Err(reason), _) => reason.clone(),
(Ok(()), Some(shortcut)) => shortcut.label(),
(Ok(()), None) => String::new(),
};
(command.label(), hint)
});
for (child, words) in children.iter().zip([title, hint]) {
if let Ok(mut text) = texts.get_mut(child)
&& text.0 != words
{
text.0 = words;
}
}
let want_lit = command.is_some() && index == state.selected;
if want_lit && !lit {
commands.entity(row).insert(Selected);
} else if !want_lit && lit {
commands.entity(row).remove::<Selected>();
}
let want_off = command.is_some_and(|c| c.enabled.is_err());
if want_off && !disabled {
commands.entity(row).insert(InteractionDisabled);
} else if !want_off && disabled {
commands.entity(row).remove::<InteractionDisabled>();
}
}
for (note, mut node) in &mut notes {
if note.0 != entity {
continue;
}
let display = if count == 0 {
Display::Flex
} else {
Display::None
};
if node.display != display {
node.display = display;
}
}
for (part, mut node, mut text, _) in &mut counts {
if part.0 != entity {
continue;
}
let display = if count > cap {
Display::Flex
} else {
Display::None
};
if node.display != display {
node.display = display;
}
let words = if count > cap {
format!("{} of {count}", state.selected + 1)
} else {
String::new()
};
if text.0 != words {
text.0 = words;
}
}
}
}