From 5d89a1ee7ad7e4cbe3bc647bf1718efa07efa9f8 Mon Sep 17 00:00:00 2001
From: Maksymilian Mozolewski
Date: Fri, 6 Sep 2024 01:04:23 +0100
Subject: [PATCH] ANSII Support & Bevy log capture (#74)
* change readme
* fix CI
---
Cargo.toml | 5 +-
README.md | 10 ++
examples/capture_bevy_logs.rs | 23 ++++
run_all_examples.sh | 3 +-
src/color.rs | 248 ++++++++++++++++++++++++++++++++++
src/console.rs | 97 +++++++++----
src/lib.rs | 7 +-
src/log.rs | 73 ++++++++++
wsl.env => wsl.sh | 4 +-
9 files changed, 434 insertions(+), 36 deletions(-)
create mode 100644 examples/capture_bevy_logs.rs
create mode 100644 src/color.rs
create mode 100644 src/log.rs
rename wsl.env => wsl.sh (55%)
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