#[cfg(feature = "terminal")]
mod terminal;
#[cfg(feature = "terminal")]
pub use crate::terminal::{
SltFocusMessage, SltKeyMessage, SltMouseMessage, SltPasteMessage, SltResizeMessage,
SltTerminalContext, SltTerminalPlugin, TerminalBackend, restore_terminal,
};
use bevy_app::{App, Plugin, PostUpdate};
use bevy_ecs::prelude::*;
use slt::{AppState, Backend, Buffer, Cell, Context, Event, Rect, RunConfig};
use std::io;
use std::sync::Mutex;
pub trait SltBackend: Backend {
fn resize(&mut self, width: u32, height: u32) -> io::Result<()>;
fn frame_buffer(&self) -> &Buffer;
}
pub struct SltContext<B: SltBackend> {
backend: B,
state: AppState,
config: RunConfig,
events: Vec<Event>,
last_mouse_pos: Option<(u32, u32)>,
}
impl<B: SltBackend> SltContext<B> {
fn from_parts(backend: B, config: RunConfig) -> Self {
Self {
backend,
state: AppState::new(),
config,
events: Vec::new(),
last_mouse_pos: None,
}
}
pub fn draw(&mut self, mut render: impl FnMut(&mut Context)) -> io::Result<bool> {
if let Some((x, y)) = self.last_mouse_pos
&& !self.events.iter().any(Event::is_mouse)
{
self.events.push(Event::mouse_move(x, y));
}
slt::frame_owned(
&mut self.backend,
&mut self.state,
&self.config,
std::mem::take(&mut self.events),
&mut render,
)
}
pub fn push_event(&mut self, event: Event) -> io::Result<()> {
match &event {
Event::Resize(width, height) => self.backend.resize(*width, *height)?,
Event::Mouse(mouse) => self.last_mouse_pos = Some((mouse.x, mouse.y)),
Event::FocusLost => self.last_mouse_pos = None,
_ => {}
}
self.events.push(event);
Ok(())
}
pub fn config(&self) -> &RunConfig {
&self.config
}
pub fn config_mut(&mut self) -> &mut RunConfig {
&mut self.config
}
pub fn size(&self) -> (u32, u32) {
self.backend.size()
}
pub fn resize(&mut self, width: u32, height: u32) -> io::Result<()> {
self.backend.resize(width, height)
}
pub fn frame_buffer(&self) -> &Buffer {
self.backend.frame_buffer()
}
#[cfg(feature = "terminal")]
pub(crate) fn backend_mut(&mut self) -> &mut B {
&mut self.backend
}
}
pub struct HeadlessBackend {
target: Buffer,
frame: Buffer,
}
impl HeadlessBackend {
pub fn new(width: u32, height: u32) -> Self {
let area = Rect::new(0, 0, width, height);
Self {
target: Buffer::empty(area),
frame: Buffer::empty(area),
}
}
}
impl Backend for HeadlessBackend {
fn size(&self) -> (u32, u32) {
(self.target.area.width, self.target.area.height)
}
fn buffer_mut(&mut self) -> &mut Buffer {
&mut self.target
}
fn flush(&mut self) -> io::Result<()> {
std::mem::swap(&mut self.target, &mut self.frame);
self.target.reset();
Ok(())
}
}
impl SltBackend for HeadlessBackend {
fn resize(&mut self, width: u32, height: u32) -> io::Result<()> {
let area = Rect::new(0, 0, width, height);
if self.target.area != area {
self.target.resize(area);
self.frame.resize(area);
}
Ok(())
}
fn frame_buffer(&self) -> &Buffer {
&self.frame
}
}
pub type SltHeadlessContext = SltContext<HeadlessBackend>;
impl SltContext<HeadlessBackend> {
pub fn headless(width: u32, height: u32, config: RunConfig) -> Self {
Self::from_parts(HeadlessBackend::new(width, height), config)
}
}
pub struct SltHeadlessPlugin {
width: u32,
height: u32,
config: Mutex<Option<RunConfig>>,
}
impl SltHeadlessPlugin {
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
config: Mutex::new(None),
}
}
pub fn config(self, config: RunConfig) -> Self {
Self {
config: Mutex::new(Some(config)),
..self
}
}
}
impl Default for SltHeadlessPlugin {
fn default() -> Self {
Self::new(80, 24)
}
}
impl Plugin for SltHeadlessPlugin {
fn build(&self, app: &mut App) {
let config = self
.config
.lock()
.ok()
.and_then(|mut slot| slot.take())
.unwrap_or_default();
app.insert_non_send_resource(SltContext::headless(self.width, self.height, config))
.init_resource::<SltOutput>()
.add_systems(PostUpdate, publish_headless_output);
}
}
fn publish_headless_output(context: NonSend<SltHeadlessContext>, mut output: ResMut<SltOutput>) {
output.update_from(context.frame_buffer());
}
#[derive(Debug, Default, Clone, Resource)]
pub struct SltOutput {
pub width: u32,
pub height: u32,
pub cells: Vec<Cell>,
pub text: String,
}
impl SltOutput {
pub fn cell(&self, x: u32, y: u32) -> Option<&Cell> {
if x >= self.width || y >= self.height {
return None;
}
let index = y.checked_mul(self.width)?.checked_add(x)? as usize;
self.cells.get(index)
}
fn update_from(&mut self, buffer: &Buffer) {
self.width = buffer.area.width;
self.height = buffer.area.height;
self.cells.clone_from(&buffer.content);
self.text.clear();
for y in 0..self.height {
if y > 0 {
self.text.push('\n');
}
for x in 0..self.width {
if let Some(cell) = buffer.try_get(x, y) {
self.text.push_str(cell.symbol.as_str());
}
}
}
}
}
#[cfg(test)]
mod tests {
use crate::{SltHeadlessContext, SltHeadlessPlugin, SltOutput};
use bevy_app::App;
use bevy_ecs::error::Result;
use bevy_ecs::prelude::*;
use slt::Event;
#[test]
fn headless_plugin_publishes_output() -> Result {
let mut app = App::new();
app.add_plugins(SltHeadlessPlugin::default()).add_systems(
bevy_app::Update,
|mut context: NonSendMut<SltHeadlessContext>| -> Result {
context.draw(|ui| {
ui.text("hello, bevy");
})?;
Ok(())
},
);
app.update();
let output = app.world().resource::<SltOutput>();
assert!(output.text.contains("hello, bevy"), "{:?}", output.text);
Ok(())
}
#[test]
fn resize_event_resizes_backend() -> Result {
let mut context = SltHeadlessContext::headless(10, 5, slt::RunConfig::default());
context.push_event(Event::resize(20, 8))?;
assert_eq!(context.size(), (20, 8));
Ok(())
}
#[test]
fn hover_survives_event_free_frames() -> Result {
let mut context = SltHeadlessContext::headless(40, 6, slt::RunConfig::default());
context.draw(|ui| {
let _ = ui.button("press me");
})?;
context.push_event(Event::mouse_move(2, 0))?;
let mut hovered = false;
context.draw(|ui| {
hovered = ui.button("press me").hovered;
})?;
assert!(hovered, "mouse move over the button must hover it");
let mut still_hovered = false;
context.draw(|ui| {
still_hovered = ui.button("press me").hovered;
})?;
assert!(still_hovered, "hover must survive an event-free frame");
Ok(())
}
#[test]
fn output_cell_bounds_are_checked() {
let mut output = SltOutput {
width: 1,
height: 1,
..Default::default()
};
assert!(output.cell(1, 0).is_none());
assert!(output.cell(0, 1).is_none());
output.cells.push(Default::default());
assert!(output.cell(0, 0).is_some());
}
}