diff --git a/Cargo.toml b/Cargo.toml index 71bba1f..7570d07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,15 @@ license = "MIT OR Apache-2.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] -default = ["builtin-parser"] +default = ["builtin-parser", "completions", "builtin-parser-completions"] + +completions = [] + +builtin-parser-completions = [ + "completions", + "builtin-parser", + "dep:fuzzy-matcher", +] builtin-parser = ["dep:logos"] @@ -24,6 +32,7 @@ web-time = "1.0.0" # builtin-parser features logos = { version = "0.14.0", optional = true } +fuzzy-matcher = { version = "0.3.7", optional = true } [dev-dependencies] bevy = "0.13.0" diff --git a/src/builtin_parser.rs b/src/builtin_parser.rs index 971bc0f..7b0ba86 100644 --- a/src/builtin_parser.rs +++ b/src/builtin_parser.rs @@ -10,6 +10,11 @@ use logos::Span; use crate::builtin_parser::runner::ExecutionError; use crate::command::{CommandHints, CommandParser, DefaultCommandParser}; +#[cfg(feature = "builtin-parser-completions")] +use crate::command::CompletionSuggestion; + +#[cfg(feature = "builtin-parser-completions")] +pub(crate) mod completions; pub(crate) mod lexer; pub(crate) mod number; pub(crate) mod parser; @@ -90,7 +95,6 @@ impl CommandParser for BuiltinCommandParser { .resource_mut::() .push(eval_error.hints()); } - error!("{error}") } }, @@ -100,5 +104,38 @@ impl CommandParser for BuiltinCommandParser { error!("{err}") } } + #[cfg(feature = "builtin-parser-completions")] + { + *world.resource_mut() = + completions::store_in_cache(world.non_send_resource::()); + } + } + + #[cfg(feature = "builtin-parser-completions")] + fn completion(&self, command: &str, world: &World) -> Vec { + use fuzzy_matcher::FuzzyMatcher; + + use crate::builtin_parser::completions::EnvironmentCache; + + let matcher = fuzzy_matcher::skim::SkimMatcherV2::default(); + let environment_cache = world.resource::(); + + let mut names: Vec<_> = environment_cache + .function_names + .iter() + .chain(environment_cache.variable_names.iter()) + .map(|name| (matcher.fuzzy_indices(name, command), name.clone())) + .filter_map(|(fuzzy, name)| fuzzy.map(|v| (v, name))) + .collect(); + names.sort_by_key(|((score, _), _)| std::cmp::Reverse(*score)); + names.truncate(crate::ui::MAX_COMPLETION_SUGGESTIONS); + + names + .into_iter() + .map(|((_, indices), name)| CompletionSuggestion { + suggestion: name, + highlighted_indices: indices, + }) + .collect() } } diff --git a/src/builtin_parser/completions.rs b/src/builtin_parser/completions.rs new file mode 100644 index 0000000..b861b2c --- /dev/null +++ b/src/builtin_parser/completions.rs @@ -0,0 +1,55 @@ +use bevy::prelude::*; + +use super::runner::environment::Variable; +use super::Environment; + +/// Stores the names of variables and functions for fast async access. +#[derive(Resource)] +pub struct EnvironmentCache { + pub function_names: Vec, + pub variable_names: Vec, +} +impl FromWorld for EnvironmentCache { + fn from_world(world: &mut World) -> Self { + if let Some(environment) = world.get_non_send_resource::() { + store_in_cache(environment) + } else { + Self::empty() + } + } +} +impl EnvironmentCache { + pub const fn empty() -> Self { + EnvironmentCache { + function_names: Vec::new(), + variable_names: Vec::new(), + } + } +} + +pub fn store_in_cache(environment: &Environment) -> EnvironmentCache { + let mut function_names = Vec::new(); + let mut variable_names = Vec::new(); + store_in_cache_vec(environment, &mut function_names, &mut variable_names); + + EnvironmentCache { + function_names, + variable_names, + } +} +fn store_in_cache_vec( + environment: &Environment, + function_names: &mut Vec, + variable_names: &mut Vec, +) { + for (name, variable) in &environment.variables { + match variable { + Variable::Function(_) => function_names.push(name.clone()), + Variable::Unmoved(_) => variable_names.push(name.clone()), + Variable::Moved => {} + } + } + if let Some(environment) = &environment.parent { + store_in_cache_vec(environment, function_names, variable_names); + } +} diff --git a/src/builtin_parser/runner/environment.rs b/src/builtin_parser/runner/environment.rs index 9e391af..bdbeda0 100644 --- a/src/builtin_parser/runner/environment.rs +++ b/src/builtin_parser/runner/environment.rs @@ -3,13 +3,12 @@ use std::collections::HashMap; use std::fmt::Debug; +use crate::builtin_parser::SpanExtension; use bevy::ecs::world::World; use bevy::log::warn; use bevy::reflect::TypeRegistration; use logos::Span; -use crate::builtin_parser::SpanExtension; - use super::super::parser::Expression; use super::super::Spanned; use super::error::EvalError; @@ -203,12 +202,10 @@ pub enum Variable { } /// The environment stores all variables and functions. -#[derive(Debug)] pub struct Environment { - parent: Option>, - variables: HashMap, + pub(crate) parent: Option>, + pub(crate) variables: HashMap, } - impl Default for Environment { fn default() -> Self { let mut env = Self { @@ -352,6 +349,7 @@ impl Environment { let name = name.into(); if self.variables.contains_key(&name) { warn!("Function {name} declared twice."); + } else { } self.variables .insert(name, Variable::Function(function.into_function())); diff --git a/src/command.rs b/src/command.rs index 0a09acc..8448c9f 100644 --- a/src/command.rs +++ b/src/command.rs @@ -12,9 +12,17 @@ pub struct DefaultCommandParser(pub Box); impl DefaultCommandParser { /// Shortcut method for calling `parser.0.parse(command, world)`. + #[inline] pub fn parse(&self, command: &str, world: &mut World) { self.0.parse(command, world) } + /// Shortcut method for calling `parser.0.completion(command, world)`. + #[inline] + #[must_use] + #[cfg(feature = "completions")] + pub fn completion(&self, keyword: &str, world: &World) -> Vec { + self.0.completion(keyword, world) + } } impl From for DefaultCommandParser { fn from(value: Parser) -> Self { @@ -129,6 +137,23 @@ impl CommandHints { pub trait CommandParser: Send + Sync + 'static { /// This method is called by the console when a command is ran. fn parse(&self, command: &str, world: &mut World); + /// This method is called by the console when the command is changed. + #[inline] + #[must_use] + #[cfg(feature = "completions")] + fn completion(&self, keyword: &str, world: &World) -> Vec { + let _ = (keyword, world); + Vec::new() + } +} + +/// A suggestion for autocomplete. +#[cfg(feature = "completions")] +pub struct CompletionSuggestion { + /// The suggestion string + pub suggestion: String, + /// The character indices of the [`suggestion`](Self::suggestion) to highlight. + pub highlighted_indices: Vec, } pub(crate) struct ExecuteCommand(pub String); @@ -142,3 +167,19 @@ impl Command for ExecuteCommand { } } } + +#[derive(Resource, Default, Deref, DerefMut)] +#[cfg(feature = "completions")] +pub(crate) struct AutoCompletions(pub Vec); +#[cfg(feature = "completions")] +pub(crate) struct UpdateAutoComplete(pub String); +#[cfg(feature = "completions")] +impl Command for UpdateAutoComplete { + fn apply(self, world: &mut World) { + if let Some(parser) = world.remove_resource::() { + let completions = parser.completion(&self.0, world); + world.resource_mut::().0 = completions; + world.insert_resource(parser); + } + } +} diff --git a/src/config.rs b/src/config.rs index 4a60b7a..adfdafd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -135,6 +135,16 @@ impl ConsoleTheme { } } + /// Returns a [`TextFormat`] with the default font and white color. + pub fn format_bold(&self) -> TextFormat { + TextFormat { + font_id: self.font.clone(), + color: Color32::WHITE, + + ..default() + } + } + define_text_format_method!(format_dark, dark); define_text_format_method!(format_error, error); define_text_format_method!(format_warning, warning); diff --git a/src/lib.rs b/src/lib.rs index d7460b5..057c3d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,7 +28,11 @@ impl Plugin for DevConsolePlugin { { app.init_non_send_resource::(); app.init_resource::(); + #[cfg(feature = "builtin-parser-completions")] + app.init_resource::(); } + #[cfg(feature = "completions")] + app.init_resource::(); app.init_resource::() .init_resource::() diff --git a/src/logging/log_plugin.rs b/src/logging/log_plugin.rs index a7535ce..d8f593e 100644 --- a/src/logging/log_plugin.rs +++ b/src/logging/log_plugin.rs @@ -29,6 +29,7 @@ use bevy::utils::tracing::Subscriber; pub use bevy::utils::tracing::{warn, Level}; use bevy::app::{App, Plugin, Update}; +use bevy::utils::tracing; use tracing_log::LogTracer; use tracing_subscriber::field::Visit; #[cfg(feature = "tracing-chrome")] diff --git a/src/ui.rs b/src/ui.rs index 2c836d3..031eca4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -14,6 +14,14 @@ use crate::config::ToColor32; use crate::logging::log_plugin::LogMessage; use crate::prelude::ConsoleConfig; +#[cfg(feature = "completions")] +use crate::command::AutoCompletions; + +#[cfg(feature = "completions")] +mod completions; +#[cfg(feature = "completions")] +pub use completions::MAX_COMPLETION_SUGGESTIONS; + /// Prefix for log messages that show a previous command. pub const COMMAND_MESSAGE_PREFIX: &str = "$ "; /// Prefix for log messages that show the result of a command. @@ -34,6 +42,8 @@ pub(crate) struct ConsoleUiState { pub(crate) log: Vec<(LogMessage, bool)>, /// The command in the text bar. pub(crate) command: String, + #[cfg(feature = "completions")] + pub(crate) selected_completion: usize, } fn system_time_to_chrono_utc(t: SystemTime) -> chrono::DateTime { @@ -67,24 +77,27 @@ pub(crate) fn render_ui( key: Res>, mut hints: ResMut, config: Res, + #[cfg(feature = "completions")] completions: Res, ) { - let mut submit_command = |command: &mut String| { + fn submit_command(command: &mut String, commands: &mut Commands) { if !command.trim().is_empty() { info!(name: COMMAND_MESSAGE_NAME, "{COMMAND_MESSAGE_PREFIX}{}", command.trim()); // Get the owned command string by replacing it with an empty string let command = std::mem::take(command); commands.add(ExecuteCommand(command)); } - }; + } if key.just_pressed(config.submit_key) { - submit_command(&mut state.command); + submit_command(&mut state.command, &mut commands); } egui::Window::new("Developer Console") .collapsible(false) .default_width(900.) .show(contexts.ctx_mut(), |ui| { + completions::change_selected_completion(ui, &mut state, &completions); + // A General rule when creating layouts in egui is to place elements which fill remaining space last. // Since immediate mode ui can't predict the final sizes of widgets until they've already been drawn @@ -101,22 +114,35 @@ pub(crate) fn render_ui( //We can use a right to left layout, so we can place the text input last and tell it to fill all remaining space ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // ui.button is a shorthand command, a similar command exists for text edits, but this is how to manually construct a widget. + // doing this also allows access to more options of the widget, rather than being stuck with the default the shorthand picks. if ui.button("Submit").clicked() { - submit_command(&mut state.command); + submit_command(&mut state.command, &mut commands); // Return keyboard focus to the text edit control. ui.ctx().memory_mut(|mem| mem.request_focus(text_edit_id)); } - // ui.button is a shorthand command, a similar command exists for text edits, but this is how to manually construct a widget. - // doing this also allows access to more options of the widget, rather than being stuck with the default the shorthand picks. + + #[cfg_attr(not(feature = "completions"), allow(unused_variables))] let text_edit = egui::TextEdit::singleline(&mut state.command) .id(text_edit_id) .desired_width(ui.available_width()) .margin(egui::Vec2::splat(4.0)) .font(config.theme.font.clone()) - .lock_focus(true); + .lock_focus(true) + .show(ui); - ui.add(text_edit); + // Display completions if the "completions" feature is enabled + #[cfg(feature = "completions")] + completions::completions( + text_edit, + text_edit_id, + &mut state, + ui, + commands, + &completions, + &config, + ); // Each time we open the console, we want to set focus to the text edit control. if !state.text_focus { diff --git a/src/ui/completions.rs b/src/ui/completions.rs new file mode 100644 index 0000000..64089ed --- /dev/null +++ b/src/ui/completions.rs @@ -0,0 +1,152 @@ +use bevy::ecs::system::Commands; +use bevy_egui::egui; + +use crate::command::{AutoCompletions, CompletionSuggestion, UpdateAutoComplete}; +use crate::prelude::ConsoleConfig; + +use super::ConsoleUiState; + +/// The max amount of completion suggestions shown at once. +pub const MAX_COMPLETION_SUGGESTIONS: usize = 6; + +pub fn completions( + text_edit: egui::text_edit::TextEditOutput, + text_edit_id: egui::Id, + state: &mut ConsoleUiState, + ui: &mut egui::Ui, + mut commands: Commands, + completions: &AutoCompletions, + config: &ConsoleConfig, +) { + let text_edit_complete_id = ui.make_persistent_id("text_edit_complete"); + + if let Some(cursor_range) = text_edit.state.cursor.char_range() { + let [primary, secondary] = cursor_range.sorted(); + + fn non_keyword(character: char) -> bool { + !(character.is_alphanumeric() || character == '_') + } + + let cursor_index = (|| { + // Conver the cursor's char index into a byte index + // aswell as returning the character at the cursor's position position + let (primary_index, char) = state + .command + .char_indices() + .nth(primary.index.saturating_sub(1))?; + + if non_keyword(char) { + return None; + } + + Some(primary_index) + })(); + if text_edit.response.changed() { + state.selected_completion = 0; + } + // todo check cursor position changed https://github.com/emilk/egui/discussions/4540 + // if text_edit.response.changed() { + if true { + if let Some(cursor_index) = cursor_index { + ui.memory_mut(|mem| { + if !completions.is_empty() { + mem.open_popup(text_edit_complete_id) + } + }); + let before_cursor = &state.command[..=cursor_index]; + let keyword_before = match before_cursor.rfind(non_keyword) { + // If found, return the slice from the end of the non-alphanumeric character to the cursor position + Some(index) => &before_cursor[(index + 1)..], + // If not found, the whole substring is a word + None => before_cursor, + }; + commands.add(UpdateAutoComplete(keyword_before.to_owned())); + } else { + ui.memory_mut(|mem| { + if mem.is_popup_open(text_edit_complete_id) { + mem.close_popup(); + } + }); + } + } + if let Some(cursor_index) = cursor_index { + if ui.input(|i| i.key_pressed(egui::Key::Tab)) { + // Remove the old text + let before_cursor = &state.command[..=cursor_index]; + let index_before = match before_cursor.rfind(non_keyword) { + Some(index) => index + 1, + None => 0, + }; + let after_cursor = &state.command[cursor_index..]; + match after_cursor.find(non_keyword) { + Some(characters_after) => state + .command + .drain(index_before..cursor_index + characters_after), + None => state.command.drain(index_before..), + }; + // Add the completed text + let completed_text = &completions.0[state.selected_completion].suggestion; + + state.command.insert_str(index_before, completed_text); + + // Set the cursor position + let mut text_edit_state = text_edit.state; + let mut cursor_range = egui::text::CCursorRange::two(primary, secondary); + + cursor_range.primary.index += + completed_text.len() - (cursor_index - index_before) - 1; + cursor_range.secondary.index += + completed_text.len() - (cursor_index - index_before) - 1; + + text_edit_state.cursor.set_char_range(Some(cursor_range)); + egui::TextEdit::store_state(ui.ctx(), text_edit_id, text_edit_state); + } + } + } + egui::popup_below_widget(ui, text_edit_complete_id, &text_edit.response, |ui| { + ui.vertical(|ui| { + for ( + i, + CompletionSuggestion { + suggestion, + highlighted_indices, + }, + ) in completions.iter().take(6).enumerate() + { + let mut layout = egui::text::LayoutJob::default(); + for (i, _) in suggestion.char_indices() { + layout.append( + &suggestion[i..=i], + 0.0, + if highlighted_indices.contains(&i) { + config.theme.format_bold() + } else { + config.theme.format_text() + }, + ); + } + let res = ui.label(layout); + if i == state.selected_completion { + res.highlight(); + } + } + }) + }); +} + +/// Also consumes the up and down arrow keys. +pub fn change_selected_completion( + ui: &mut egui::Ui, + state: &mut ConsoleUiState, + completions: &[CompletionSuggestion], +) { + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)) { + state.selected_completion = state.selected_completion.saturating_sub(1); + } + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)) { + state.selected_completion = state + .selected_completion + .saturating_add(1) + .min(completions.len() - 1); + } +}