├── .gitignore ├── src ├── acts │ ├── tape │ │ ├── tape.png │ │ ├── squeak.ogg │ │ ├── play-loop.ogg │ │ ├── tape-load.ogg │ │ ├── record-loop.ogg │ │ ├── record-start.ogg │ │ ├── record-stop.ogg │ │ └── tape-rewind.ogg │ ├── blah.rs │ ├── arg.rs │ ├── collection.rs │ ├── cache.rs │ ├── plugin.rs │ ├── add_acts.rs │ ├── run_act.rs │ ├── mod.rs │ ├── basic_async.rs │ ├── builder.rs │ ├── basic.rs │ └── universal.rs ├── view.rs ├── lib.rs ├── hotkey.rs ├── sink.rs ├── plugin.rs ├── ui.rs ├── sync.rs ├── autocomplete │ └── mod.rs ├── prompt.rs ├── event.rs └── future.rs ├── assets └── fonts │ ├── FiraSans-Bold.ttf │ ├── FiraMono-Medium.ttf │ └── FiraMono-LICENSE ├── tests └── version-numbers.rs ├── todo.org ├── CHANGELOG.md ├── examples ├── opt-in.rs ├── add-act.rs ├── bind-hotkey.rs ├── solicit-user.rs ├── exclude-commands.rs ├── async │ ├── two-commands.rs │ ├── universal-arg.rs │ ├── cube.rs │ ├── demo.rs │ └── tab-completion.rs ├── two-commands.rs ├── universal-arg.rs ├── cube.rs ├── tab-completion.rs └── common │ └── lib.rs ├── docs ├── state-diagram.dot └── new-state-diagram.dot ├── LICENSE-MIT ├── Cargo.toml └── LICENSE-APACHE2 /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | /docs/state-diagram.png 4 | -------------------------------------------------------------------------------- /src/acts/tape/tape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shanecelis/bevy_minibuffer/HEAD/src/acts/tape/tape.png -------------------------------------------------------------------------------- /src/acts/tape/squeak.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shanecelis/bevy_minibuffer/HEAD/src/acts/tape/squeak.ogg -------------------------------------------------------------------------------- /src/acts/tape/play-loop.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shanecelis/bevy_minibuffer/HEAD/src/acts/tape/play-loop.ogg -------------------------------------------------------------------------------- /src/acts/tape/tape-load.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shanecelis/bevy_minibuffer/HEAD/src/acts/tape/tape-load.ogg -------------------------------------------------------------------------------- /assets/fonts/FiraSans-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shanecelis/bevy_minibuffer/HEAD/assets/fonts/FiraSans-Bold.ttf -------------------------------------------------------------------------------- /src/acts/tape/record-loop.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shanecelis/bevy_minibuffer/HEAD/src/acts/tape/record-loop.ogg -------------------------------------------------------------------------------- /src/acts/tape/record-start.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shanecelis/bevy_minibuffer/HEAD/src/acts/tape/record-start.ogg -------------------------------------------------------------------------------- /src/acts/tape/record-stop.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shanecelis/bevy_minibuffer/HEAD/src/acts/tape/record-stop.ogg -------------------------------------------------------------------------------- /src/acts/tape/tape-rewind.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shanecelis/bevy_minibuffer/HEAD/src/acts/tape/tape-rewind.ogg -------------------------------------------------------------------------------- /assets/fonts/FiraMono-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shanecelis/bevy_minibuffer/HEAD/assets/fonts/FiraMono-Medium.ttf -------------------------------------------------------------------------------- /src/view.rs: -------------------------------------------------------------------------------- 1 | //! Implements Asky's views for Minibuffer prompts 2 | #![allow(clippy::type_complexity)] 3 | use bevy_asky::view::color; 4 | pub use color::plugin_no_focus as plugin; 5 | pub use color::text_view; 6 | pub use color::View; 7 | -------------------------------------------------------------------------------- /tests/version-numbers.rs: -------------------------------------------------------------------------------- 1 | #[test] 2 | fn test_readme_deps() { 3 | version_sync::assert_markdown_deps_updated!("README.md"); 4 | } 5 | 6 | #[test] 7 | fn test_html_root_url() { 8 | version_sync::assert_html_root_url_updated!("src/lib.rs"); 9 | } 10 | -------------------------------------------------------------------------------- /todo.org: -------------------------------------------------------------------------------- 1 | #+title: Todo 2 | * TODO 3 | - [x] Make keys configurable. 4 | - [ ] Clear out old examples. 5 | - [ ] Make tab complete scrollable 6 | - [/] Modeline? 7 | No. Not if we can help it. 8 | - [x] Toggle visibility with ` 9 | * BUGS 10 | ** ADDTEST Can add two acts with same name 11 | -------------------------------------------------------------------------------- /src/acts/blah.rs: -------------------------------------------------------------------------------- 1 | enum Water { 2 | Fresh, 3 | Gray, 4 | Ballast 5 | } 6 | enum Label { 7 | Total, 8 | Water(Water) 9 | ... 10 | } 11 | pub fn update_water_tooltip( 12 | water_query: Query<&Water>, 13 | mut text_query: Query<(&mut Text, &Label), 14 | ) { 15 | for mut (text, label) in &mut text_query { 16 | let amount = match label { 17 | Label::Total => water_query.iter().len() 18 | Label::Water(water) => water_query.iter().filter(|w| w == water).len(), 19 | } 20 | text.0 = String::from(format!("{} L", amount)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [unreleased] 6 | 7 | ## [0.4.1] - 2025-04-26 8 | - Fix [issue 3](https://github.com/shanecelis/bevy_minibuffer/issues/3) broken docs-rs build. 9 | 10 | ## [0.4.0] - 2025-04-25 11 | - Add support for Bevy 0.16. 12 | 13 | ## [0.3.0] - 2025-01-01 14 | - Add `TapeActs`, similar to keyboard macros. 15 | - Add cargo feature "fun", adds sound and icon to tape acts. 16 | - Only add one new root entity named "minibuffer". 17 | 18 | ## [0.2.0] - 2024-12-11 19 | 20 | - Add support for Bevy 0.15. 21 | - Add features, async, and a la carte sections to README. 22 | 23 | ## [0.1.0] - 2024-12-06 24 | 25 | - Initial release for Bevy 0.14. 26 | -------------------------------------------------------------------------------- /src/acts/arg.rs: -------------------------------------------------------------------------------- 1 | use super::ActRef; 2 | use std::borrow::Cow; 3 | /// An act argument by name or value. 4 | #[derive(Debug, Clone)] 5 | pub enum ActArg { 6 | /// Reference by reference 7 | ActRef(ActRef), 8 | // /// Reference by value 9 | // Act(Act), 10 | /// Reference by name 11 | Name(Cow<'static, str>), 12 | } 13 | 14 | // impl From for ActArg { 15 | // fn from(act: Act) -> Self { 16 | // ActArg::Act(act) 17 | // } 18 | // } 19 | 20 | impl From for ActArg { 21 | fn from(act: ActRef) -> Self { 22 | ActArg::ActRef(act) 23 | } 24 | } 25 | 26 | impl>> From for ActArg { 27 | fn from(x: T) -> Self { 28 | ActArg::Name(x.into()) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/opt-in.rs: -------------------------------------------------------------------------------- 1 | //! Opt-in to basic acts. 2 | use bevy::prelude::*; 3 | use bevy_minibuffer::prelude::*; 4 | 5 | #[path = "common/lib.rs"] 6 | mod common; 7 | 8 | fn plugin(app: &mut App) { 9 | app.add_plugins(MinibufferPlugins) 10 | .add_acts(BasicActs::default()); 11 | } 12 | 13 | fn main() { 14 | App::new() 15 | // .add_plugins((DefaultPlugins, plugin)) 16 | .add_plugins(( 17 | common::VideoCapturePlugin::new("opt-in").background(Srgba::hex("ffbe0b").unwrap()), 18 | plugin, 19 | )) 20 | .add_systems( 21 | Startup, 22 | |mut commands: Commands, mut minibuffer: Minibuffer| { 23 | commands.spawn(Camera2d); 24 | minibuffer.message("Type 'Ctrl-H A' to see basic commands."); 25 | minibuffer.set_visible(true); 26 | }, 27 | ) 28 | .run(); 29 | } 30 | -------------------------------------------------------------------------------- /docs/state-diagram.dot: -------------------------------------------------------------------------------- 1 | digraph StateDiagram { 2 | // rankdir=TB; 3 | label=<Minibuffer State Transition Diagram>; 4 | 5 | node [shape = point] ENTRY; 6 | node [shape = circle]; 7 | // overlap = true; 8 | Inactive; 9 | CInvisible [label = "Invisible"]; 10 | CVisible [label = "Visible"]; 11 | 12 | subgraph cluster_0 { 13 | ENTRY->Inactive; 14 | Inactive->Active [label="P = AskyState::Waiting || AskyDelay"]; 15 | Active->Inactive [label="!P"]; 16 | label = "AskyPrompt"; 17 | } 18 | 19 | subgraph cluster_1 { 20 | label = "PromptState"; 21 | Invisible->Visible; 22 | Visible->Finished; 23 | Finished->Invisible; 24 | } 25 | 26 | subgraph cluster_2 { 27 | label = "CompletionState"; 28 | CInvisible->CVisible; 29 | CVisible->CInvisible; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /examples/add-act.rs: -------------------------------------------------------------------------------- 1 | //! Add an act. 2 | use bevy::prelude::*; 3 | use bevy_minibuffer::prelude::*; 4 | 5 | #[path = "common/lib.rs"] 6 | mod common; 7 | fn hello_world(mut minibuffer: Minibuffer) { 8 | minibuffer.message("Hello, World!"); 9 | } 10 | 11 | fn plugin(app: &mut App) { 12 | app.add_plugins(MinibufferPlugins) 13 | .add_acts((Act::new(hello_world), BasicActs::default())); 14 | } 15 | 16 | fn main() { 17 | App::new() 18 | .add_plugins(( 19 | common::VideoCapturePlugin::new("add-act").background(Srgba::hex("fb5607").unwrap()), 20 | plugin, 21 | )) 22 | .add_systems( 23 | Startup, 24 | |mut commands: Commands, mut minibuffer: Minibuffer| { 25 | commands.spawn(Camera2d); 26 | minibuffer.message("Type ': H E L L O Tab Enter'."); 27 | minibuffer.set_visible(true); 28 | }, 29 | ) 30 | .run(); 31 | } 32 | -------------------------------------------------------------------------------- /docs/new-state-diagram.dot: -------------------------------------------------------------------------------- 1 | digraph StateDiagram { 2 | // rankdir=TB; 3 | label=<New Minibuffer State Transition Diagram>; 4 | 5 | node [shape = point] ENTRY; 6 | node [shape = circle]; 7 | // overlap = true; 8 | Inactive; 9 | CInvisible [label = "Invisible"]; 10 | CVisible [label = "Visible"]; 11 | 12 | subgraph cluster_0 { 13 | Inactive->Active [label="P = AskyState::Waiting || AskyDelay"]; 14 | Active->Inactive [label="!P"]; 15 | label = "AskyPrompt"; 16 | } 17 | 18 | subgraph cluster_1 { 19 | label = "PromptState"; 20 | Invisible->Visible; 21 | Visible->Finished; 22 | Finished->Invisible; 23 | } 24 | 25 | subgraph cluster_2 { 26 | label = "CompletionState"; 27 | CInvisible->CVisible; 28 | CVisible->CInvisible; 29 | } 30 | 31 | subgraph cluster_3 { 32 | label = "MinibufferState"; 33 | MActive -> MInactive -> MActive; 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /examples/bind-hotkey.rs: -------------------------------------------------------------------------------- 1 | //! Bind an act to a hotkey. 2 | use bevy::prelude::*; 3 | use bevy_minibuffer::prelude::*; 4 | #[path = "common/lib.rs"] 5 | mod common; 6 | 7 | fn hello_world(mut minibuffer: Minibuffer) { 8 | minibuffer.message("Hello, World!"); 9 | minibuffer.set_visible(true); 10 | } 11 | 12 | fn plugin(app: &mut App) { 13 | app.add_plugins(MinibufferPlugins).add_acts(( 14 | Act::new(hello_world).bind(keyseq! { Ctrl-W }), 15 | BasicActs::default(), 16 | )); 17 | } 18 | 19 | fn main() { 20 | App::new() 21 | // .add_plugins((DefaultPlugins, plugin)) 22 | .add_plugins(( 23 | common::VideoCapturePlugin::new("bind-hotkey") 24 | .background(Srgba::hex("ff006e").unwrap()), 25 | plugin, 26 | )) 27 | .add_systems( 28 | Startup, 29 | |mut commands: Commands, mut minibuffer: Minibuffer| { 30 | commands.spawn(Camera2d); 31 | minibuffer.message("Type 'Ctrl-W' to run hello_world."); 32 | minibuffer.set_visible(true); 33 | }, 34 | ) 35 | .run(); 36 | } 37 | -------------------------------------------------------------------------------- /examples/solicit-user.rs: -------------------------------------------------------------------------------- 1 | //! Ask the user a question. 2 | use bevy::prelude::*; 3 | use bevy_minibuffer::prelude::*; 4 | 5 | #[path = "common/lib.rs"] 6 | mod common; 7 | 8 | fn hello_name(mut minibuffer: Minibuffer) { 9 | minibuffer 10 | .prompt::("What's your name? ") 11 | .observe( 12 | |mut trigger: Trigger>, mut minibuffer: Minibuffer| { 13 | minibuffer.message(format!( 14 | "Hello, {}.", 15 | trigger.event_mut().take_result().unwrap() 16 | )); 17 | }, 18 | ); 19 | } 20 | 21 | fn plugin(app: &mut App) { 22 | app.add_plugins(MinibufferPlugins) 23 | // .add_acts(BasicActs::default()) 24 | .add_systems(Startup, hello_name); 25 | } 26 | 27 | fn main() { 28 | App::new() 29 | // .add_plugins((DefaultPlugins, plugin)) 30 | .add_plugins(( 31 | common::VideoCapturePlugin::new("solicit-user") 32 | .background(Srgba::hex("8338ec").unwrap()), 33 | plugin, 34 | )) 35 | .add_systems(Startup, |mut commands: Commands| { 36 | commands.spawn(Camera2d); 37 | }) 38 | .run(); 39 | } 40 | -------------------------------------------------------------------------------- /examples/exclude-commands.rs: -------------------------------------------------------------------------------- 1 | //! Exclude all basic commands but list_acts. 2 | //! 3 | //! We keep the list_acts only because that is easiest way to show that acts 4 | //! have been excluded. However, in practice if one were to remove all but one 5 | //! command, one would probably keep run_act. 6 | use bevy::prelude::*; 7 | use bevy_minibuffer::prelude::*; 8 | #[path = "common/lib.rs"] 9 | mod common; 10 | 11 | fn plugin(app: &mut App) { 12 | let mut basic_acts = BasicActs::default(); 13 | // Acts is a HashMap of act names and [ActBuilder]s. 14 | let mut acts = basic_acts.take_acts(); 15 | let list_acts = acts.remove("list_acts").unwrap(); 16 | app.add_plugins(MinibufferPlugins) 17 | .add_acts((basic_acts, list_acts)); 18 | } 19 | 20 | fn main() { 21 | App::new() 22 | // .add_plugins((DefaultPlugins, plugin)) 23 | .add_plugins(( 24 | common::VideoCapturePlugin::new("exclude-commands") 25 | .background(Srgba::hex("023047").unwrap()), 26 | plugin, 27 | )) 28 | // .add_plugins(bevy_inspector_egui::quick::WorldInspectorPlugin::new()) 29 | .add_systems( 30 | Startup, 31 | |mut commands: Commands, mut minibuffer: Minibuffer| { 32 | commands.spawn(Camera2d); 33 | minibuffer.message("Type 'Ctrl-H A' to see only one command remains."); 34 | minibuffer.set_visible(true); 35 | }, 36 | ) 37 | .run(); 38 | } 39 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc(html_root_url = "https://docs.rs/bevy_minibuffer/0.4.1")] 2 | #![doc = include_str!("../README.md")] 3 | // #![forbid(missing_docs)] 4 | pub mod acts; 5 | pub mod autocomplete; 6 | pub mod event; 7 | #[cfg(feature = "async")] 8 | mod future; 9 | mod plugin; 10 | pub mod prompt; 11 | mod sync; 12 | pub mod ui; 13 | pub use plugin::Config; 14 | pub use plugin::Error; 15 | pub use plugin::MinibufferPlugin; 16 | pub use plugin::MinibufferPlugins; 17 | pub mod sink; 18 | pub mod view; 19 | #[cfg(feature = "async")] 20 | pub use future::MinibufferAsync; 21 | pub use sync::Minibuffer; 22 | mod hotkey; 23 | 24 | /// Input, mainly re-exports from [keyseq] 25 | pub mod input { 26 | pub use super::hotkey::*; 27 | pub use bevy_input_sequence::KeyChord; 28 | pub use keyseq::{ 29 | bevy::{pkey as key, pkeyseq as keyseq}, 30 | Modifiers, 31 | }; 32 | } 33 | 34 | /// Prelude for convenient splat importing, e.g., `use bevy_minibuffer::prelude::*`. 35 | pub mod prelude { 36 | pub use super::acts::basic::BasicActs; 37 | pub use super::acts::tape::TapeActs; 38 | pub use super::acts::universal::*; 39 | pub use super::acts::{ 40 | self, Act, ActBuilder, ActFlags, Acts, ActsPlugin, ActsPluginGroup, AddActs, 41 | }; 42 | pub use super::autocomplete::*; 43 | pub use super::event::RunActEvent; 44 | pub use super::input::{key, keyseq, KeyChord, Modifiers}; 45 | pub use super::prompt::*; 46 | #[cfg(feature = "async")] 47 | pub use super::sink; 48 | pub use super::sync::MinibufferCommands; 49 | pub use super::Config; 50 | pub use super::Minibuffer; 51 | #[cfg(feature = "async")] 52 | pub use super::MinibufferAsync; 53 | pub use super::{Error, MinibufferPlugin, MinibufferPlugins}; 54 | pub use std::time::Duration; 55 | } 56 | -------------------------------------------------------------------------------- /src/hotkey.rs: -------------------------------------------------------------------------------- 1 | //! Hotkey 2 | use bevy::prelude::*; 3 | use bevy_input_sequence::KeyChord; 4 | use std::{ 5 | borrow::Cow, 6 | fmt::{ 7 | self, 8 | Debug, 9 | // Write 10 | }, 11 | }; 12 | 13 | /// A key sequence and an optional alias 14 | #[derive(Debug, Clone, Reflect)] 15 | pub struct Hotkey { 16 | /// Key chord sequence 17 | pub chords: Vec, 18 | /// Alias 19 | pub alias: Option>, 20 | } 21 | 22 | impl PartialEq<[KeyChord]> for Hotkey { 23 | fn eq(&self, other: &[KeyChord]) -> bool { 24 | self.chords == other 25 | } 26 | } 27 | 28 | // impl PartialEq<[&KeyChord]> for Hotkey { 29 | // fn eq(&self, other: &[&KeyChord]) -> bool { 30 | // self.chords == *other 31 | // } 32 | // } 33 | 34 | impl Hotkey { 35 | /// New hotkey from any [KeyChord]-able sequence. 36 | pub fn new(chords: impl IntoIterator) -> Self 37 | where 38 | KeyChord: From, 39 | { 40 | Self { 41 | chords: chords.into_iter().map(|v| v.into()).collect(), 42 | alias: None, 43 | } 44 | } 45 | 46 | /// Return an empty hotkey. 47 | pub fn empty() -> Self { 48 | Self { 49 | chords: Vec::new(), 50 | alias: None, 51 | } 52 | } 53 | 54 | /// Define an alias. 55 | pub fn alias(mut self, name: impl Into>) -> Self { 56 | self.alias = Some(name.into()); 57 | self 58 | } 59 | } 60 | 61 | impl fmt::Display for Hotkey { 62 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 63 | if let Some(alias) = &self.alias { 64 | write!(f, "{}", alias) 65 | } else { 66 | let mut iter = self.chords.iter(); 67 | if let Some(first) = iter.next() { 68 | write!(f, "{}", first)?; 69 | } 70 | for key_chord in iter { 71 | write!(f, " {}", key_chord)?; 72 | } 73 | Ok(()) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /examples/async/two-commands.rs: -------------------------------------------------------------------------------- 1 | //! Demonstrate two commands using [MinibufferAsync]. 2 | use bevy::prelude::*; 3 | use bevy_minibuffer::prelude::*; 4 | 5 | #[path = "../common/lib.rs"] 6 | mod common; 7 | 8 | /// Ask the user for their name. Say hello. 9 | async fn ask_name(mut minibuffer: MinibufferAsync) -> Result<(), Error> { 10 | let first_name = minibuffer 11 | .prompt::("What's your first name? ") 12 | .await?; 13 | let last_name = minibuffer 14 | .prompt::("What's your last name? ") 15 | .await?; 16 | minibuffer.message(format!("Hello, {first_name} {last_name}!")); 17 | Ok(()) 18 | } 19 | 20 | // Ask the user for their age. 21 | async fn ask_age(mut minibuffer: MinibufferAsync) -> Result<(), Error> { 22 | let age = minibuffer.prompt::>("What's your age? ").await?; 23 | minibuffer.message(format!("You are {age} years old.")); 24 | Ok(()) 25 | } 26 | 27 | fn plugin(app: &mut App) { 28 | app.add_plugins(MinibufferPlugins) 29 | .add_acts(( 30 | Act::new(ask_name.pipe(sink::future_result)) 31 | .named("ask_name") 32 | .bind(keyseq!(N)), 33 | Act::new(ask_age.pipe(sink::future_result)) 34 | .named("ask_age") 35 | .bind(keyseq!(A)), 36 | // Add a basic act but just one of them. 37 | BasicActs::default().remove("run_act").unwrap(), 38 | )) 39 | .add_systems(Startup, |mut minibuffer: Minibuffer| { 40 | minibuffer.message("Hit 'N' for ask_name. Hit 'A' for ask_age."); 41 | minibuffer.set_visible(true); 42 | }); 43 | } 44 | 45 | fn main() { 46 | App::new() 47 | // .add_plugins((DefaultPlugins, plugin)) 48 | .add_plugins(( 49 | common::VideoCapturePlugin::new("two-commands-async") 50 | .background(Srgba::hex("8ecae6").unwrap()), 51 | plugin, 52 | )) 53 | .add_systems(Startup, |mut commands: Commands| { 54 | commands.spawn(Camera2d); 55 | }) 56 | .run(); 57 | } 58 | -------------------------------------------------------------------------------- /src/sink.rs: -------------------------------------------------------------------------------- 1 | //! Pipe systems with futures into a sink. 2 | #[cfg(doc)] 3 | use crate::acts::ActFlags; 4 | use crate::Minibuffer; 5 | use bevy::ecs::system::In; 6 | use std::fmt::Display; 7 | 8 | /// Show error if any in minibuffer. 9 | pub fn result(In(result): In>, mut minibuffer: Minibuffer) 10 | where 11 | T: 'static, 12 | E: 'static + Display, 13 | { 14 | if let Err(e) = result { 15 | minibuffer.message(format!("{e}")); 16 | } 17 | } 18 | 19 | /// Pipe a string to the message buffer. 20 | /// 21 | /// The minibuffer might not be visible when this is called. Consider adding 22 | /// [ActFlags::ShowMinibuffer] to the act's flags to ensure it will be shown. 23 | /// 24 | /// Used internally by `list_acts` for instance 25 | /// 26 | /// ```ignore 27 | /// ActBuilder::new(list_acts.pipe(message)) 28 | /// .named("list_acts") 29 | /// .add_flags(ActFlags::ShowMinibuffer) 30 | /// .hotkey(keyseq! { Ctrl-H A }), 31 | /// ``` 32 | pub fn string(In(msg): In, mut minibuffer: Minibuffer) { 33 | minibuffer.message(msg); 34 | } 35 | 36 | /// Can optinoally pipe any string to the message buffer. 37 | pub fn option_string(In(msg): In>, mut minibuffer: Minibuffer) { 38 | if let Some(msg) = msg { 39 | minibuffer.message(msg); 40 | } 41 | } 42 | 43 | #[cfg(feature = "async")] 44 | mod future { 45 | use super::*; 46 | use crate::MinibufferAsync as Minibuffer; 47 | use bevy_defer::{AsyncExecutor, NonSend}; 48 | use std::future::Future; 49 | /// Execute a future. 50 | pub fn future + 'static>( 51 | In(future): In, 52 | exec: NonSend, 53 | ) { 54 | exec.spawn_any(future); 55 | } 56 | 57 | /// Show error if any in minibuffer. 58 | pub fn future_result< 59 | T: 'static, 60 | E: 'static + Display, 61 | F: Future> + 'static, 62 | >( 63 | In(future): In, 64 | exec: NonSend, 65 | mut minibuffer: Minibuffer, 66 | ) { 67 | exec.spawn_any(async move { 68 | if let Err(e) = future.await { 69 | minibuffer.message(format!("{e}")); 70 | } 71 | }); 72 | } 73 | } 74 | #[cfg(feature = "async")] 75 | pub use future::*; 76 | -------------------------------------------------------------------------------- /src/acts/collection.rs: -------------------------------------------------------------------------------- 1 | use super::ActBuilder; 2 | use bevy::prelude::*; 3 | use std::{borrow::Cow, collections::HashMap}; 4 | 5 | /// A collection of acts 6 | /// 7 | /// Acts may be inspected and modified before adding to app. 8 | #[derive(Debug, Deref, DerefMut, Default)] 9 | pub struct Acts(pub HashMap, ActBuilder>); 10 | 11 | impl Acts { 12 | /// Create a new plugin with a set of acts. 13 | pub fn new>>(v: I) -> Self { 14 | Acts( 15 | v.into_iter() 16 | .map(|act| { 17 | let act = act.into(); 18 | (act.name(), act) 19 | }) 20 | .collect(), 21 | ) 22 | } 23 | 24 | /// Configure an act. 25 | /// 26 | /// Returns true if there was such an act to configure false otherwise. 27 | pub fn configure(&mut self, act_name: &str, f: F) -> bool { 28 | if let Some(ref mut builder) = self.0.get_mut(act_name) { 29 | f(builder); 30 | true 31 | } else { 32 | false 33 | } 34 | } 35 | 36 | /// Take the acts replacing self with its default value. 37 | pub fn take(&mut self) -> Self { 38 | std::mem::take(self) 39 | } 40 | 41 | /// Add an [ActBuilder]. 42 | pub fn push(&mut self, builder: impl Into) -> Option { 43 | let builder = builder.into(); 44 | self.insert(builder.name(), builder).inspect(|builder| { 45 | warn!("Replacing act '{}'.", builder.name()); 46 | }) 47 | } 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use super::*; 53 | use crate::prelude::*; 54 | 55 | fn act1() {} 56 | #[test] 57 | fn check_acts() { 58 | let acts = Acts::default(); 59 | assert_eq!(acts.len(), 0); 60 | } 61 | 62 | #[test] 63 | fn check_drain_read() { 64 | let mut acts = Acts::default(); 65 | acts.push(Act::new(act1)); 66 | assert_eq!(acts.len(), 1); 67 | } 68 | 69 | #[test] 70 | fn check_duplicate_names() { 71 | let mut acts = Acts::default(); 72 | acts.push(Act::new(act1)); 73 | assert!(acts.push(Act::new(act1)).is_some()); 74 | assert_eq!(acts.len(), 1); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bevy_minibuffer" 3 | description = "A gamedev console inspired by classic Unix text editors" 4 | version = "0.4.1" 5 | edition = "2021" 6 | authors = ["Shane Celis "] 7 | keywords = [ 8 | "bevy", 9 | "gamedev", 10 | "console", 11 | ] 12 | categories = [ 13 | "game-development" 14 | ] 15 | readme = "README.md" 16 | license = "MIT OR Apache-2.0" 17 | repository = "https://github.com/shanecelis/bevy_minibuffer" 18 | 19 | [dependencies] 20 | bevy = { version = "0.16.0", default-features = false, features = [ "bevy_text", "bevy_ui", "default_font", "bevy_window", "bevy_state", "bevy_log", "std", "async_executor" ] } 21 | bitflags = "2.3.1" 22 | trie-rs = "0.4.2" 23 | bevy-input-sequence = "0.8.0" 24 | keyseq = { version = "0.6.0", features = [ "bevy" ]} 25 | bevy_asky = { version = "0.3.0", default-features = false, features = [ "color" ] } 26 | futures = { version = "0.3.30", optional = true } 27 | tabular = "0.2.0" 28 | thiserror = "1.0.58" 29 | bevy_defer = { version = "0.14", optional = true } 30 | bevy_channel_trigger = { version = "0.4.0", optional = true } 31 | accesskit = "0.18.0" 32 | copypasta = { version = "0.10.1", optional = true } 33 | variadics_please = "1.1.0" 34 | foldhash = "0.1.5" 35 | 36 | [features] 37 | async = [ "dep:bevy_defer", "dep:futures", "bevy_asky/async", "dep:bevy_channel_trigger" ] 38 | fun = [ "bevy/bevy_audio" ] 39 | dev-capture = [] 40 | clipboard = ["dep:copypasta"] 41 | x11 = ["bevy/x11"] 42 | 43 | [dev-dependencies] 44 | bevy = "0.16.0" 45 | bevy-inspector-egui = "0.31" 46 | bevy_framepace = "0.19.1" 47 | bevy_image_export = "0.13.0" 48 | rand = "0.8.5" 49 | version-sync = "0.9.5" 50 | 51 | [[example]] 52 | name = "demo-async" 53 | path = "examples/async/demo.rs" 54 | required-features = [ "async" ] 55 | 56 | [[example]] 57 | name = "cube-async" 58 | path = "examples/async/cube.rs" 59 | required-features = [ "async" ] 60 | 61 | [[example]] 62 | name = "two-commands-async" 63 | path = "examples/async/two-commands.rs" 64 | required-features = [ "async" ] 65 | 66 | [[example]] 67 | name = "universal-arg-async" 68 | path = "examples/async/universal-arg.rs" 69 | required-features = [ "async" ] 70 | 71 | [[example]] 72 | name = "tab-completion-async" 73 | path = "examples/async/tab-completion.rs" 74 | required-features = [ "async" ] 75 | 76 | [package.metadata.docs.rs] 77 | features = ["x11", "async", "fun"] 78 | -------------------------------------------------------------------------------- /src/acts/cache.rs: -------------------------------------------------------------------------------- 1 | //! Acts and their flags, builders, and collections 2 | use crate::acts::{Act, ActFlags, ActRef}; 3 | use bevy::prelude::*; 4 | use bevy_input_sequence::KeyChord; 5 | use std::collections::HashMap; 6 | use trie_rs::map::{Trie, TrieBuilder}; 7 | 8 | pub(crate) fn plugin(app: &mut App) { 9 | app.init_resource::() 10 | .init_resource::(); 11 | } 12 | 13 | #[derive(Resource, Default)] 14 | pub struct NameActCache { 15 | trie: HashMap>, 16 | } 17 | 18 | impl NameActCache { 19 | /// Retrieve the cached trie without iterating through `acts`. Or if the 20 | /// cache has been invalidated, build and cache a new trie using the 21 | /// `acts` iterator. 22 | pub fn trie<'a>( 23 | &mut self, 24 | acts: impl Iterator, 25 | flags: ActFlags, 26 | ) -> &Trie { 27 | self.trie.entry(flags).or_insert_with(|| { 28 | let mut builder: TrieBuilder = TrieBuilder::new(); 29 | for (id, act) in acts { 30 | if act.flags.contains(flags) { 31 | builder.push(act.name.as_ref(), ActRef::from_act(act, id)); 32 | } 33 | } 34 | builder.build() 35 | }) 36 | } 37 | 38 | /// Invalidate the cache. 39 | pub fn invalidate(&mut self, flags: Option) { 40 | if let Some(flags) = flags { 41 | self.trie.remove(&flags); 42 | } else { 43 | self.trie.clear(); 44 | } 45 | } 46 | } 47 | 48 | /// Maps hotkeys to [Act]s 49 | /// 50 | /// This is a trie of hotkeys for better performance and it is only updated when 51 | /// acts with hotkeys are added or removed. 52 | #[derive(Resource, Default)] 53 | pub struct HotkeyActCache { 54 | trie: Option>, 55 | } 56 | 57 | impl HotkeyActCache { 58 | /// Retrieve the cached trie without iterating through `acts`. Or if 59 | /// the cache has been invalidated, build and cache a new trie using the 60 | /// `acts` iterator. 61 | pub fn trie<'a>( 62 | &mut self, 63 | acts: impl Iterator, 64 | ) -> &Trie { 65 | self.trie.get_or_insert_with(|| { 66 | let mut builder: TrieBuilder = TrieBuilder::new(); 67 | for (id, act) in acts { 68 | for hotkey in &act.hotkeys { 69 | builder.insert(hotkey.chords.clone(), ActRef::from_act(act, id)); 70 | } 71 | } 72 | builder.build() 73 | }) 74 | } 75 | 76 | /// Invalidate the cache. 77 | pub fn invalidate(&mut self) { 78 | self.trie = None; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /examples/two-commands.rs: -------------------------------------------------------------------------------- 1 | //! Demonstrate two commands using [Minibuffer]. 2 | use bevy::prelude::*; 3 | use bevy_minibuffer::prelude::*; 4 | 5 | #[path = "common/lib.rs"] 6 | mod common; 7 | 8 | /// Ask the user for their name. Say hello. 9 | fn ask_name(mut minibuffer: Minibuffer) { 10 | minibuffer 11 | .prompt::("What's your first name? ") 12 | .observe( 13 | |mut trigger: Trigger>, mut minibuffer: Minibuffer| { 14 | if let Ok(first_name) = trigger.event_mut().take_result() { 15 | minibuffer 16 | .prompt::("What's your last name? ") 17 | .observe( 18 | move |mut trigger: Trigger>, 19 | mut minibuffer: Minibuffer| { 20 | if let Ok(last_name) = trigger.event_mut().take_result() { 21 | minibuffer.message(format!("Hello, {first_name} {last_name}!")); 22 | } else { 23 | minibuffer.clear(); 24 | } 25 | }, 26 | ); 27 | } else { 28 | minibuffer.clear(); 29 | } 30 | }, 31 | ); 32 | } 33 | 34 | // Ask the user for their age. 35 | fn ask_age(mut minibuffer: Minibuffer) { 36 | minibuffer 37 | .prompt::>("What's your age? ") 38 | .observe( 39 | |mut trigger: Trigger>, mut minibuffer: Minibuffer| { 40 | if let Ok(age) = trigger.event_mut().take_result() { 41 | minibuffer.message(format!("You are {age} years old.")); 42 | } else { 43 | minibuffer.clear(); 44 | } 45 | }, 46 | ); 47 | } 48 | 49 | fn plugin(app: &mut App) { 50 | app.add_plugins(MinibufferPlugins) 51 | .add_acts(( 52 | Act::new(ask_name).named("ask_name").bind(keyseq!(N)), 53 | Act::new(ask_age).named("ask_age").bind(keyseq!(A)), 54 | // Add a basic act but just one of them. 55 | BasicActs::default().remove("run_act").unwrap(), 56 | )) 57 | .add_systems(Startup, |mut minibuffer: Minibuffer| { 58 | minibuffer.message("Hit 'N' for ask_name. Hit 'A' for ask_age."); 59 | minibuffer.set_visible(true); 60 | }); 61 | } 62 | 63 | fn main() { 64 | App::new() 65 | // .add_plugins((DefaultPlugins, plugin)) 66 | .add_plugins(( 67 | common::VideoCapturePlugin::new("two-commands") 68 | .background(Srgba::hex("219ebc").unwrap()), 69 | plugin, 70 | )) 71 | .add_systems(Startup, |mut commands: Commands| { 72 | commands.spawn(Camera2d); 73 | }) 74 | .run(); 75 | } 76 | -------------------------------------------------------------------------------- /examples/universal-arg.rs: -------------------------------------------------------------------------------- 1 | //! Demonstrate universal argument 2 | use bevy::prelude::*; 3 | use bevy_minibuffer::prelude::*; 4 | use rand::prelude::*; 5 | 6 | #[path = "common/lib.rs"] 7 | mod common; 8 | 9 | fn rnd_vec(rng: &mut R) -> Vec3 { 10 | 2.0 * Vec3::new(rng.gen(), rng.gen(), rng.gen()) - Vec3::ONE 11 | } 12 | 13 | fn make_cube( 14 | arg: Res, 15 | cubes: Query>, 16 | mut commands: Commands, 17 | mut meshes: ResMut>, 18 | mut materials: ResMut>, 19 | mut minibuffer: Minibuffer, 20 | ) { 21 | let mut rng = rand::thread_rng(); 22 | 23 | let cube_handle = meshes.add(Cuboid::new(rng.gen(), rng.gen(), rng.gen())); 24 | let cube_material_handle = materials.add(StandardMaterial { 25 | base_color: Color::srgb(rng.gen(), rng.gen(), rng.gen()), 26 | ..default() 27 | }); 28 | 29 | let count = arg.0.unwrap_or(1); 30 | if count < 0 { 31 | let mut despawned = 0; 32 | for id in &cubes { 33 | commands.entity(id).despawn(); 34 | despawned += 1; 35 | if despawned >= -count { 36 | break; 37 | } 38 | } 39 | minibuffer.message(format!( 40 | "Removed {} cube{}.", 41 | despawned, 42 | if count == 1 { "" } else { "s" } 43 | )); 44 | } else { 45 | for _ in 0..count { 46 | let v = 2.0 * rnd_vec(&mut rng); 47 | commands.spawn(( 48 | Mesh3d(cube_handle.clone()), 49 | MeshMaterial3d(cube_material_handle.clone()), 50 | Transform::from_translation(v), 51 | )); 52 | } 53 | minibuffer.message(format!( 54 | "Made {} cube{}.", 55 | count, 56 | if count == 1 { "" } else { "s" } 57 | )); 58 | } 59 | } 60 | 61 | fn open_door(universal_arg: Res, mut minibuffer: Minibuffer) { 62 | if universal_arg.is_none() { 63 | minibuffer.message("Open one door."); 64 | } else { 65 | minibuffer.message("Open all the doors."); 66 | } 67 | } 68 | 69 | fn plugin(app: &mut App) { 70 | app.add_plugins(MinibufferPlugins).add_acts(( 71 | BasicActs::default(), 72 | UniversalArgActs::default().include_display_act(), 73 | Act::new(make_cube).bind(keyseq! { Space }), 74 | Act::new(open_door).bind(keyseq! { O D }), 75 | )); 76 | } 77 | 78 | fn setup(mut commands: Commands) { 79 | // light 80 | commands.spawn((PointLight::default(), Transform::from_xyz(4.0, 5.0, -4.0))); 81 | // camera 82 | commands.spawn(( 83 | Camera3d::default(), 84 | Transform::from_xyz(5.0, 10.0, 10.0).looking_at(Vec3::ZERO, Vec3::Y), 85 | )); 86 | } 87 | 88 | fn main() { 89 | App::new() 90 | // .add_plugins((DefaultPlugins, plugin)) 91 | .add_plugins(( 92 | common::VideoCapturePlugin::new("universal-arg") 93 | .background(Srgba::hex("7678ed").unwrap()), 94 | plugin, 95 | )) 96 | .add_systems(Startup, setup) 97 | .add_systems(Startup, |mut minibuffer: Minibuffer| { 98 | minibuffer.message("Type 'Ctrl-U 1 0 Space' to make 10 cubes or 'O D' to open a door."); 99 | minibuffer.set_visible(true); 100 | }) 101 | .run(); 102 | } 103 | -------------------------------------------------------------------------------- /examples/async/universal-arg.rs: -------------------------------------------------------------------------------- 1 | //! Demonstrate universal argument 2 | use bevy::prelude::*; 3 | use bevy_minibuffer::prelude::*; 4 | use rand::prelude::*; 5 | 6 | #[path = "../common/lib.rs"] 7 | mod common; 8 | 9 | fn rnd_vec(rng: &mut R) -> Vec3 { 10 | 2.0 * Vec3::new(rng.gen(), rng.gen(), rng.gen()) - Vec3::ONE 11 | } 12 | 13 | fn make_cube( 14 | arg: Res, 15 | cubes: Query>, 16 | mut commands: Commands, 17 | mut meshes: ResMut>, 18 | mut materials: ResMut>, 19 | mut minibuffer: Minibuffer, 20 | ) { 21 | let mut rng = rand::thread_rng(); 22 | 23 | let cube_handle = meshes.add(Cuboid::new(rng.gen(), rng.gen(), rng.gen())); 24 | let cube_material_handle = materials.add(StandardMaterial { 25 | base_color: Color::srgb(rng.gen(), rng.gen(), rng.gen()), 26 | ..default() 27 | }); 28 | 29 | let count = arg.0.unwrap_or(1); 30 | if count < 0 { 31 | let mut despawned = 0; 32 | for id in &cubes { 33 | commands.entity(id).despawn(); 34 | despawned += 1; 35 | if despawned >= -count { 36 | break; 37 | } 38 | } 39 | minibuffer.message(format!( 40 | "Removed {} cube{}.", 41 | despawned, 42 | if count == 1 { "" } else { "s" } 43 | )); 44 | } else { 45 | for _ in 0..count { 46 | let v = 2.0 * rnd_vec(&mut rng); 47 | commands.spawn(( 48 | Mesh3d(cube_handle.clone()), 49 | MeshMaterial3d(cube_material_handle.clone()), 50 | Transform::from_translation(v), 51 | )); 52 | } 53 | minibuffer.message(format!( 54 | "Made {} cube{}.", 55 | count, 56 | if count == 1 { "" } else { "s" } 57 | )); 58 | } 59 | } 60 | 61 | fn open_door(universal_arg: Res, mut minibuffer: Minibuffer) { 62 | if universal_arg.is_none() { 63 | minibuffer.message("Open one door."); 64 | } else { 65 | minibuffer.message("Open all the doors."); 66 | } 67 | } 68 | 69 | fn plugin(app: &mut App) { 70 | app.add_plugins(MinibufferPlugins).add_acts(( 71 | BasicActs::default(), 72 | UniversalArgActs::default() 73 | .use_async() 74 | .include_display_act(), 75 | Act::new(make_cube).bind(keyseq! { Space }), 76 | Act::new(open_door).bind(keyseq! { O D }), 77 | )); 78 | } 79 | 80 | fn setup(mut commands: Commands) { 81 | // light 82 | commands.spawn((PointLight::default(), Transform::from_xyz(4.0, 5.0, -4.0))); 83 | // camera 84 | commands.spawn(( 85 | Camera3d::default(), 86 | Transform::from_xyz(5.0, 10.0, 10.0).looking_at(Vec3::ZERO, Vec3::Y), 87 | )); 88 | } 89 | 90 | fn main() { 91 | App::new() 92 | // .add_plugins((DefaultPlugins, plugin)) 93 | .add_plugins(( 94 | common::VideoCapturePlugin::new("universal-arg-async") 95 | .background(Srgba::hex("7678ed").unwrap()), 96 | plugin, 97 | )) 98 | .add_systems(Startup, setup) 99 | .add_systems(Startup, |mut minibuffer: Minibuffer| { 100 | minibuffer.message("Type 'Ctrl-U 1 0 Space' to make 10 cubes or 'O D' to open a door."); 101 | minibuffer.set_visible(true); 102 | }) 103 | .run(); 104 | } 105 | -------------------------------------------------------------------------------- /examples/async/cube.rs: -------------------------------------------------------------------------------- 1 | //! Illustrates how to interact with an entity with [MinibufferAsync]. 2 | use bevy::prelude::*; 3 | use bevy_defer::{AsyncAccess, AsyncWorld}; 4 | use bevy_minibuffer::prelude::*; 5 | use std::f32::consts::TAU; 6 | use std::future::Future; 7 | 8 | #[path = "../common/lib.rs"] 9 | mod common; 10 | 11 | // Define a component to designate a rotation speed to an entity. 12 | #[derive(Component)] 13 | struct Rotatable { 14 | speed: f32, 15 | } 16 | 17 | fn plugin(app: &mut App) { 18 | app.add_plugins(MinibufferPlugins) 19 | .add_acts(( 20 | BasicActs::default(), 21 | // Add commands. 22 | Act::new(stop).bind(keyseq! { A }), 23 | Act::new(speed.pipe(sink::future_result)).bind(keyseq! { S }), 24 | Act::new(start).bind(keyseq! { D }), 25 | )) 26 | .add_systems(Startup, |mut minibuffer: Minibuffer| { 27 | minibuffer.message("Hit A, S, or D to change cube speed. Hit 'Ctrl-H B' for keys."); 28 | minibuffer.set_visible(true); 29 | }); 30 | } 31 | 32 | fn main() { 33 | App::new() 34 | .add_plugins(( 35 | common::VideoCapturePlugin::new("cube-async").background(Srgba::hex("7e33ff").unwrap()), 36 | plugin, 37 | )) 38 | .add_systems(Startup, setup) 39 | .add_systems(Update, rotate_cube) 40 | .run(); 41 | } 42 | 43 | /// Start the cube spinning. 44 | fn start(mut query: Query<&mut Rotatable>) { 45 | if let Ok(mut r) = query.single_mut() { 46 | r.speed = 0.3; 47 | } 48 | } 49 | 50 | /// Stop the cube spinning. No input. 51 | fn stop(mut query: Query<&mut Rotatable>, mut minibuffer: MinibufferAsync) { 52 | minibuffer.clear(); 53 | if let Ok(mut r) = query.single_mut() { 54 | r.speed = 0.0; 55 | } 56 | } 57 | 58 | /// Set the speed of the spinning cube with input. 59 | fn speed( 60 | mut minibuffer: MinibufferAsync, 61 | query: Query>, 62 | ) -> impl Future> { 63 | let id = query.single().unwrap(); 64 | async move { 65 | let speed = minibuffer.prompt::>("speed: ").await?; 66 | let world = AsyncWorld::new(); 67 | world 68 | .entity(id) 69 | .component::() 70 | .get_mut(move |r| r.speed = speed)?; 71 | Ok(()) 72 | } 73 | } 74 | 75 | fn setup( 76 | mut commands: Commands, 77 | mut meshes: ResMut>, 78 | mut materials: ResMut>, 79 | ) { 80 | // Spawn a cube to rotate. 81 | commands.spawn(( 82 | Mesh3d(meshes.add(Cuboid::default())), 83 | MeshMaterial3d(materials.add(Color::WHITE)), 84 | Transform::from_translation(Vec3::ZERO), 85 | Rotatable { speed: 0.3 }, 86 | )); 87 | 88 | // Spawn a camera looking at the entities to show what's happening in this example. 89 | commands.spawn(( 90 | Camera3d::default(), 91 | Transform::from_xyz(0.0, 5.0, 5.0).looking_at(Vec3::ZERO, Vec3::Y), 92 | )); 93 | 94 | // Add a light source so we can see clearly. 95 | commands.spawn(( 96 | DirectionalLight::default(), 97 | Transform::from_xyz(3.0, 3.0, 3.0).looking_at(Vec3::ZERO, Vec3::Y), 98 | )); 99 | } 100 | 101 | // This system will rotate any entity in the scene with a Rotatable component around its y-axis. 102 | fn rotate_cube(mut cubes: Query<(&mut Transform, &Rotatable)>, timer: Res