diff --git a/Cargo.toml b/Cargo.toml index e30d87b..4cb2aa2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,13 +11,16 @@ readme = "README.md" [dependencies] bevy = { version = "0.14", default-features = false } -clap = { version = "4.5", features = ["derive"] } +clap = { version = "4.5", features = ["derive", "color", "help"] } bevy_console_derive = { path = "./bevy_console_derive", version = "0.5.0" } bevy_egui = "0.29.0" shlex = "1.3" +ansi-parser = "0.9" +strip-ansi-escapes = "0.2" [dev-dependencies] bevy = { version = "0.14" } +color-print = { version = "0.3" } [workspace] members = ["bevy_console_derive"] diff --git a/README.md b/README.md index dc8b848..bbbbac6 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,15 @@ A simple *Half-Life* inspired console with support for argument parsing powered

+## Features +- [x] Command parsing with `clap` +- [x] Command history +- [x] Command completion +- [x] Support for ansii colors +- [x] Customizable key bindings +- [x] Customizable theme +- [x] Supports capturing Bevy logs to console + ## Usage Add `ConsolePlugin` and optionally the resource `ConsoleConfiguration`. @@ -66,6 +75,7 @@ cargo run --example log_command - [raw_commands](/examples/raw_commands.rs) - [write_to_console](/examples/write_to_console.rs) - [change_console_key](/examples/change_console_key.rs) +- [capture_bevy_logs](/examples/capture_bevy_logs.rs) ## wasm diff --git a/examples/capture_bevy_logs.rs b/examples/capture_bevy_logs.rs new file mode 100644 index 0000000..2f78392 --- /dev/null +++ b/examples/capture_bevy_logs.rs @@ -0,0 +1,23 @@ +use bevy::log::LogPlugin; +use bevy::{log, prelude::*}; +use bevy_console::{make_layer, ConsolePlugin}; + +fn main() { + App::new() + .add_plugins(( + DefaultPlugins.set(LogPlugin { + level: log::Level::INFO, + filter: "error,capture_bevy_logs=info".to_owned(), + custom_layer: make_layer, + }), + ConsolePlugin, + )) + .add_systems(Startup, || { + log::info!("Hi!"); + log::warn!("This is a warning!"); + log::debug!("You won't see me!"); + log::error!("This is an error!"); + log::info!("Bye!"); + }) + .run(); +} diff --git a/run_all_examples.sh b/run_all_examples.sh index a6cebc4..d3a3d0a 100755 --- a/run_all_examples.sh +++ b/run_all_examples.sh @@ -1,8 +1,7 @@ #!/bin/bash # read all env variables from wsl.env -export $(egrep -v '^#' wsl.env | xargs) - +source wsl.env find ./examples -type f -name "*.rs" -exec basename {} \; | while read file; do echo "Running $file" cargo run --example ${file%.rs} diff --git a/src/color.rs b/src/color.rs new file mode 100644 index 0000000..f35ae3d --- /dev/null +++ b/src/color.rs @@ -0,0 +1,248 @@ +use std::collections::HashSet; + +use ansi_parser::AnsiParser; +use bevy_egui::egui::Color32; + +pub(crate) fn parse_ansi_styled_str( + ansi_string: &str, +) -> Vec<(usize, HashSet)> { + let mut result: Vec<(usize, HashSet)> = Vec::new(); + let mut offset = 0; + for element in ansi_string.ansi_parse() { + match element { + ansi_parser::Output::TextBlock(t) => { + offset += t.len(); + } + ansi_parser::Output::Escape(escape) => { + if let ansi_parser::AnsiSequence::SetGraphicsMode(mode) = escape { + let modes = parse_graphics_mode(mode.as_slice()); + if let Some((last_offset, last)) = result.last_mut() { + if *last_offset == offset { + last.extend(modes); + continue; + } + } + + result.push((offset, modes)); + }; + } + } + } + result +} + +fn parse_graphics_mode(modes: &[u8]) -> HashSet { + let mut results = HashSet::new(); + for mode in modes.iter() { + let result = match *mode { + 0 => TextFormattingOverride::Reset, + 1 => TextFormattingOverride::Bold, + 2 => TextFormattingOverride::Dim, + 3 => TextFormattingOverride::Italic, + 4 => TextFormattingOverride::Underline, + 9 => TextFormattingOverride::Strikethrough, + 30..=37 => TextFormattingOverride::Foreground(ansi_color_code_to_color32(mode - 30)), + 40..=47 => TextFormattingOverride::Background(ansi_color_code_to_color32(mode - 40)), + _ => TextFormattingOverride::Reset, + }; + results.insert(result); + } + results +} + +fn ansi_color_code_to_color32(color_code: u8) -> Color32 { + match color_code { + 1 => Color32::from_rgb(222, 56, 43), // red + 2 => Color32::from_rgb(57, 181, 74), // green + 3 => Color32::from_rgb(255, 199, 6), // yellow + 4 => Color32::from_rgb(0, 111, 184), // blue + 5 => Color32::from_rgb(118, 38, 113), // magenta + 6 => Color32::from_rgb(44, 181, 233), // cyan + 7 => Color32::from_rgb(204, 204, 204), // white + 8 => Color32::from_rgb(128, 128, 128), // bright black + 9 => Color32::from_rgb(255, 0, 0), // bright red + 10 => Color32::from_rgb(0, 255, 0), // bright green + 11 => Color32::from_rgb(255, 255, 0), // bright yellow + 12 => Color32::from_rgb(0, 0, 255), // bright blue + 13 => Color32::from_rgb(255, 0, 255), // bright magenta + 14 => Color32::from_rgb(0, 255, 255), // bright cyan + 15 => Color32::from_rgb(255, 255, 255), // bright white + _ => Color32::from_rgb(1, 1, 1), // black + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum TextFormattingOverride { + Reset, + Bold, + Dim, + Italic, + Underline, + Strikethrough, + Foreground(Color32), + Background(Color32), +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_bold_text() { + let ansi_string = color_print::cstr!(r#"12345"#); + let result = parse_ansi_styled_str(ansi_string); + assert_eq!( + result, + vec![ + (0, HashSet::from([TextFormattingOverride::Bold])), + (5, HashSet::from([TextFormattingOverride::Reset])) + ] + ); + } + + #[test] + fn test_underlined_text() { + let ansi_string = color_print::cstr!(r#"12345"#); + let result = parse_ansi_styled_str(ansi_string); + assert_eq!( + result, + vec![ + (0, HashSet::from([TextFormattingOverride::Underline])), + (5, HashSet::from([TextFormattingOverride::Reset])) + ] + ); + } + + #[test] + fn test_italics_text() { + let ansi_string = color_print::cstr!(r#"12345"#); + let result = parse_ansi_styled_str(ansi_string); + assert_eq!( + result, + vec![ + (0, HashSet::from([TextFormattingOverride::Italic])), + (5, HashSet::from([TextFormattingOverride::Reset])) + ] + ); + } + + #[test] + fn test_dim_text() { + let ansi_string = color_print::cstr!(r#"12345"#); + let result = parse_ansi_styled_str(ansi_string); + assert_eq!( + result, + vec![ + (0, HashSet::from([TextFormattingOverride::Dim])), + (5, HashSet::from([TextFormattingOverride::Reset])) + ] + ); + } + + #[test] + fn test_strikethrough_text() { + let ansi_string = color_print::cstr!(r#"12345"#); + let result = parse_ansi_styled_str(ansi_string); + assert_eq!( + result, + vec![ + (0, HashSet::from([TextFormattingOverride::Strikethrough])), + (5, HashSet::from([TextFormattingOverride::Reset])) + ] + ); + } + + #[test] + fn test_foreground_color() { + let ansi_string = color_print::cstr!(r#"12345"#); + let result = parse_ansi_styled_str(ansi_string); + assert_eq!( + result, + vec![ + ( + 0, + HashSet::from([TextFormattingOverride::Foreground(Color32::from_rgb( + 222, 56, 43 + ))]) + ), + (5, HashSet::from([TextFormattingOverride::Reset])) + ] + ); + } + + #[test] + fn test_background_color() { + let ansi_string = color_print::cstr!(r#"12345"#); + let result = parse_ansi_styled_str(ansi_string); + assert_eq!( + result, + vec![ + ( + 0, + HashSet::from([TextFormattingOverride::Background(Color32::from_rgb( + 222, 56, 43 + ))]) + ), + (5, HashSet::from([TextFormattingOverride::Reset])) + ] + ); + } + + #[test] + fn test_multiple_styles() { + let ansi_string = color_print::cstr!(r#"12345"#); + let result = parse_ansi_styled_str(ansi_string); + assert_eq!( + result, + vec![ + ( + 0, + HashSet::from([ + TextFormattingOverride::Foreground(Color32::from_rgb(222, 56, 43)), + TextFormattingOverride::Bold, + ]) + ), + (5, HashSet::from([TextFormattingOverride::Reset])) + ] + ); + } + + #[test] + fn non_overlapping_styles() { + let ansi_string = color_print::cstr!(r#"1234512345"#); + let result = parse_ansi_styled_str(ansi_string); + assert_eq!( + result, + vec![ + (0, HashSet::from([TextFormattingOverride::Bold])), + ( + 5, + HashSet::from([ + TextFormattingOverride::Reset, + TextFormattingOverride::Foreground(Color32::from_rgb(222, 56, 43)) + ]) + ), + (10, HashSet::from([TextFormattingOverride::Reset])) + ] + ); + } + + #[test] + fn overlapping_non_symmetric_styles() { + let ansi_string = color_print::cstr!(r#"1234512345"#); + let result = parse_ansi_styled_str(ansi_string); + assert_eq!( + result, + vec![ + (0, HashSet::from([TextFormattingOverride::Bold])), + ( + 5, + HashSet::from([TextFormattingOverride::Foreground(Color32::from_rgb( + 222, 56, 43 + ))]) + ), + (10, HashSet::from([TextFormattingOverride::Reset])) + ] + ); + } +} diff --git a/src/console.rs b/src/console.rs index b75709b..ef349bb 100644 --- a/src/console.rs +++ b/src/console.rs @@ -11,13 +11,19 @@ use bevy_egui::{ egui::{epaint::text::cursor::CCursor, Color32, FontId, TextFormat}, EguiContexts, }; -use clap::{builder::StyledStr, CommandFactory, FromArgMatches}; +use clap::{CommandFactory, FromArgMatches}; use shlex::Shlex; -use std::collections::{BTreeMap, VecDeque}; use std::marker::PhantomData; use std::mem; +use std::{ + collections::{BTreeMap, VecDeque}, + iter::once, +}; -use crate::ConsoleSet; +use crate::{ + color::{parse_ansi_styled_str, TextFormattingOverride}, + ConsoleSet, +}; type ConsoleCommandEnteredReaderSystemParam = EventReader<'static, 'static, ConsoleCommandEntered>; @@ -87,14 +93,14 @@ impl<'w, T> ConsoleCommand<'w, T> { /// Print a reply in the console. /// /// See [`reply!`](crate::reply) for usage with the [`format!`] syntax. - pub fn reply(&mut self, msg: impl Into) { + pub fn reply(&mut self, msg: impl Into) { self.console_line.send(PrintConsoleLine::new(msg.into())); } /// Print a reply in the console followed by `[ok]`. /// /// See [`reply_ok!`](crate::reply_ok) for usage with the [`format!`] syntax. - pub fn reply_ok(&mut self, msg: impl Into) { + pub fn reply_ok(&mut self, msg: impl Into) { self.console_line.send(PrintConsoleLine::new(msg.into())); self.ok(); } @@ -102,7 +108,7 @@ impl<'w, T> ConsoleCommand<'w, T> { /// Print a reply in the console followed by `[failed]`. /// /// See [`reply_failed!`](crate::reply_failed) for usage with the [`format!`] syntax. - pub fn reply_failed(&mut self, msg: impl Into) { + pub fn reply_failed(&mut self, msg: impl Into) { self.console_line.send(PrintConsoleLine::new(msg.into())); self.failed(); } @@ -165,7 +171,7 @@ unsafe impl SystemParam for ConsoleCommand<'_, T> { return Some(T::from_arg_matches(&matches)); } Err(err) => { - console_line.send(PrintConsoleLine::new(err.render())); + console_line.send(PrintConsoleLine::new(err.to_string())); return Some(Err(err)); } } @@ -192,12 +198,12 @@ pub struct ConsoleCommandEntered { #[derive(Clone, Debug, Eq, Event, PartialEq)] pub struct PrintConsoleLine { /// Console line - pub line: StyledStr, + pub line: String, } impl PrintConsoleLine { /// Creates a new console line to print. - pub const fn new(line: StyledStr) -> Self { + pub const fn new(line: String) -> Self { Self { line } } } @@ -323,8 +329,8 @@ pub struct ConsoleOpen { #[derive(Resource)] pub(crate) struct ConsoleState { pub(crate) buf: String, - pub(crate) scrollback: Vec, - pub(crate) history: VecDeque, + pub(crate) scrollback: Vec, + pub(crate) history: VecDeque, pub(crate) history_index: usize, } @@ -333,12 +339,58 @@ impl Default for ConsoleState { ConsoleState { buf: String::default(), scrollback: Vec::new(), - history: VecDeque::from([StyledStr::new()]), + history: VecDeque::from([String::new()]), history_index: 0, } } } +fn default_style(config: &ConsoleConfiguration) -> TextFormat { + TextFormat::simple(FontId::monospace(14f32), config.foreground_color) +} + +fn style_ansi_text(str: &str, config: &ConsoleConfiguration) -> LayoutJob { + let mut layout_job = LayoutJob::default(); + let mut current_style = default_style(config); + let mut last_offset = 0; + let str_without_ansi = strip_ansi_escapes::strip_str(str); + for (offset, overrides) in parse_ansi_styled_str(str) + .into_iter() + .chain(once((str_without_ansi.len(), Default::default()))) + { + // 12345 + // 01234 + let text = &str_without_ansi[(last_offset)..offset]; + if !text.is_empty() { + layout_job.append(text, 0f32, current_style.clone()); + } + + if overrides.contains(&TextFormattingOverride::Reset) { + current_style = default_style(config); + } + + for o in overrides { + match o { + TextFormattingOverride::Bold => current_style.font_id.size = 16f32, // no support for bold font families in egui TODO: when egui supports bold font families, use them here + TextFormattingOverride::Dim => current_style.font_id.size = 12f32, // no support for dim font families in egui TODO: when egui supports dim font families, use them here + TextFormattingOverride::Italic => current_style.italics = true, + TextFormattingOverride::Underline => { + current_style.underline = egui::Stroke::new(1., config.foreground_color) + } + TextFormattingOverride::Strikethrough => { + current_style.strikethrough = egui::Stroke::new(1., config.foreground_color) + } + TextFormattingOverride::Foreground(c) => current_style.color = c, + TextFormattingOverride::Background(c) => current_style.background = c, + _ => {} + } + } + + last_offset = offset; + } + layout_job +} + pub(crate) fn console_ui( mut egui_context: EguiContexts, config: Res, @@ -394,18 +446,7 @@ pub(crate) fn console_ui( .show(ui, |ui| { ui.vertical(|ui| { for line in &state.scrollback { - let mut text = LayoutJob::default(); - - text.append( - &line.to_string(), //TOOD: once clap supports custom styling use it here - 0f32, - TextFormat::simple( - FontId::monospace(14f32), - config.foreground_color, - ), - ); - - ui.label(text); + ui.label(style_ansi_text(line, &config)); } }); @@ -474,12 +515,12 @@ pub(crate) fn console_ui( && ui.input(|i| i.key_pressed(egui::Key::Enter)) { if state.buf.trim().is_empty() { - state.scrollback.push(StyledStr::new()); + state.scrollback.push(String::new()); } else { let msg = format!("{}{}", config.symbol, state.buf); - state.scrollback.push(msg.into()); + state.scrollback.push(msg); let cmd_string = state.buf.clone(); - state.history.insert(1, cmd_string.into()); + state.history.insert(1, cmd_string); if state.history.len() > config.history_size + 1 { state.history.pop_back(); } @@ -525,7 +566,7 @@ pub(crate) fn console_ui( && state.history_index < state.history.len() - 1 { if state.history_index == 0 && !state.buf.trim().is_empty() { - *state.history.get_mut(0).unwrap() = state.buf.clone().into(); + *state.history.get_mut(0).unwrap() = state.buf.clone(); } state.history_index += 1; diff --git a/src/lib.rs b/src/lib.rs index 4c66c05..b2d1d60 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,17 +12,17 @@ pub use crate::console::{ AddConsoleCommand, Command, ConsoleCommand, ConsoleCommandEntered, ConsoleConfiguration, ConsoleOpen, NamedCommand, PrintConsoleLine, }; -// pub use color::{Style, StyledStr}; +pub use crate::log::*; use crate::console::{console_ui, receive_console_line, ConsoleState}; - pub use clap; // mod color; +mod color; mod commands; mod console; +mod log; mod macros; - /// Console plugin. pub struct ConsolePlugin; @@ -62,6 +62,7 @@ impl Plugin for ConsolePlugin { ( console_ui.in_set(ConsoleSet::ConsoleUI), receive_console_line.in_set(ConsoleSet::PostCommands), + send_log_buffer_to_console.in_set(ConsoleSet::PostCommands), ), ) .configure_sets( diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..26659ca --- /dev/null +++ b/src/log.rs @@ -0,0 +1,73 @@ +use std::{ + io::{BufRead, Write}, + sync::{Arc, Mutex}, +}; + +use bevy::{ + app::App, + log::tracing_subscriber::{self, Registry}, + prelude::{EventWriter, ResMut, Resource}, +}; + +use crate::PrintConsoleLine; + +/// Buffers logs written by bevy at runtime +#[derive(Resource)] +pub struct BevyLogBuffer(Arc>>>); + +/// Writer implementation which writes into a buffer resource inside the bevy world +pub struct BevyLogBufferWriter(Arc>>>); + +impl Write for BevyLogBufferWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + // let lock = self.0.upgrade().unwrap(); + let mut lock = self.0.lock().map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to lock buffer: {}", e), + ) + })?; + lock.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + // let lock = self.0.upgrade().unwrap(); + let mut lock = self.0.lock().map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to lock buffer: {}", e), + ) + })?; + lock.flush() + } +} + +/// Flushes the log buffer and sends its content to the console +pub fn send_log_buffer_to_console( + buffer: ResMut, + mut console_lines: EventWriter, +) { + let mut buffer = buffer.0.lock().unwrap(); + // read and clean buffer + let buffer = buffer.get_mut(); + for line in buffer.lines().map_while(Result::ok) { + console_lines.send(PrintConsoleLine { line }); + } + buffer.clear(); +} + +/// Creates a tracing layer which writes logs into a buffer resource inside the bevy world +/// This is used by the console plugin to capture logs written by bevy +pub fn make_layer( + app: &mut App, +) -> Option + Send + Sync>> { + let buffer = Arc::new(Mutex::new(std::io::Cursor::new(Vec::new()))); + app.insert_resource(BevyLogBuffer(buffer.clone())); + + Some(Box::new( + tracing_subscriber::fmt::Layer::new() + .with_target(false) + .with_ansi(true) + .with_writer(move || BevyLogBufferWriter(buffer.clone())), + )) +} diff --git a/wsl.env b/wsl.sh similarity index 55% rename from wsl.env rename to wsl.sh index 75d920e..972a384 100644 --- a/wsl.env +++ b/wsl.sh @@ -1,3 +1,3 @@ # updating to bevy 0.14 caused issues with WSL for me, these vars help -WGPU_BACKEND=vulkan -WINIT_UNIX_BACKEND=x11 \ No newline at end of file +export WGPU_BACKEND=vulkan +export WINIT_UNIX_BACKEND=x11 \ No newline at end of file