mod bui;
mod themed;
#[cfg(test)]
mod tests;
use std::time::Duration;
use bevy_app::{App, AppExit, PreUpdate, ScheduleRunnerPlugin, Startup, Update};
use bevy_ecs::change_detection::{DetectChanges, DetectChangesMut};
use bevy_ecs::prelude::{
Commands, Component, Entity, Has, IntoScheduleConfigs, MessageReader, MessageWriter, On, Or,
Query, Res, ResMut, Resource, With, Without,
};
use bevy_input_focus::InputFocus;
use plurimus::bui::BuiPlugin;
use plurimus::core::ratatui_core::layout::Rect;
use plurimus::core::{
CameraSystems, CorePlugin, ResolvedViewport, TerminalCamera, TerminalSize, UiArea, UiWidget,
Viewport,
};
use plurimus::crossterm::CrosstermPlugin;
use plurimus::term::{KeyCode, KeyKind, KeyMessage};
use plurimus::ui::InteractionDisabled;
use plurimus::widgets::ratatui_widgets::paragraph::Paragraph;
use plurimus::widgets::{
Activate, Button, Checkbox, EditableText, ListBox, MenuButton, RadioButton, Slider, TextEditor,
WidgetsPlugin,
};
const STATUS_ROWS: u16 = 2;
const SLIDER_START: f32 = 50.0;
const SLIDER_KEY_STEP: f32 = 5.0;
const RADIO_LABELS: [&str; 3] = ["snap", "crackle", "pop"];
const CHECKBOX_LABEL: &str = "enable tachyons";
const FIELD_TEXT: &str = "edit me";
#[derive(Component)]
struct ThemedCamera;
#[derive(Component)]
struct BuiCamera;
#[derive(Component)]
struct StatusLine;
struct SideState {
presses: u32,
slider: f32,
radio: String,
choice: String,
}
impl Default for SideState {
fn default() -> Self {
Self {
presses: 0,
slider: SLIDER_START,
radio: String::new(),
choice: String::new(),
}
}
}
#[derive(Resource, Default)]
struct DemoState {
themed: SideState,
bui: SideState,
}
fn main() -> AppExit {
let mut app = App::new();
app.add_plugins((
ScheduleRunnerPlugin::run_loop(Duration::from_millis(16)),
CorePlugin,
CrosstermPlugin::default(),
));
add_demo(&mut app);
app.run()
}
fn add_demo(app: &mut App) {
app.add_plugins((WidgetsPlugin, BuiPlugin));
app.init_resource::<DemoState>();
app.add_systems(Startup, (spawn_cameras, spawn_status));
app.add_systems(
PreUpdate,
resize_cameras.before(CameraSystems::ResolveViewports),
);
app.add_systems(Update, (anchor_status, update_status, quit_on_key));
themed::add_themed_side(app);
bui::add_bui_side(app);
}
fn spawn_cameras(mut commands: Commands) {
commands.spawn((
TerminalCamera::default().with_viewport(Viewport::Fixed(Rect::ZERO)),
ThemedCamera,
));
commands.spawn((
TerminalCamera::default()
.with_order(1)
.with_viewport(Viewport::Fixed(Rect::ZERO)),
BuiCamera,
));
}
fn spawn_status(mut commands: Commands) {
commands.spawn((
UiWidget::new(Paragraph::new("")),
UiArea::Fixed(Rect::ZERO),
StatusLine,
));
}
fn resize_cameras(
size: Res<TerminalSize>,
mut cameras: Query<(&mut TerminalCamera, Has<BuiCamera>)>,
) {
let split = size.cols / 2;
for (mut camera, is_bui) in &mut cameras {
let viewport = Viewport::Fixed(if is_bui {
Rect::new(split, 0, size.cols.saturating_sub(split), size.rows)
} else {
Rect::new(0, 0, split, size.rows)
});
if camera.viewport != viewport {
camera.viewport = viewport;
}
}
}
fn anchor_status(
cameras: Query<&ResolvedViewport, With<ThemedCamera>>,
mut status: Query<&mut UiArea, With<StatusLine>>,
) {
let Ok(viewport) = cameras.single().map(|resolved| resolved.0) else {
return;
};
for mut area in &mut status {
area.set_if_neq(UiArea::Fixed(Rect::new(
0,
viewport.height.saturating_sub(STATUS_ROWS),
viewport.width,
STATUS_ROWS,
)));
}
}
fn update_status(state: Res<DemoState>, mut lines: Query<&mut UiWidget, With<StatusLine>>) {
if !state.is_changed() {
return;
}
let text = format!(
"themed presses {} slider {:.0} {}\nbui presses {} slider {:.0} {}",
state.themed.presses,
state.themed.slider,
choice_summary(&state.themed),
state.bui.presses,
state.bui.slider,
choice_summary(&state.bui),
);
for mut widget in &mut lines {
*widget = UiWidget::new(Paragraph::new(text.clone()));
}
}
fn choice_summary(side: &SideState) -> String {
match (side.radio.as_str(), side.choice.as_str()) {
("", "") => String::new(),
(radio, "") => radio.to_owned(),
("", choice) => choice.to_owned(),
(radio, choice) => format!("{radio}/{choice}"),
}
}
type Interactive = (
Or<(
With<Button>,
With<Slider>,
With<Checkbox>,
With<RadioButton>,
With<ListBox>,
With<EditableText>,
With<TextEditor>,
)>,
Without<MenuButton>,
);
pub(crate) fn toggle_disabled(
_on: On<Activate>,
widgets: Query<(Entity, Has<InteractionDisabled>), Interactive>,
mut commands: Commands,
) {
for (entity, disabled) in &widgets {
if disabled {
commands.entity(entity).remove::<InteractionDisabled>();
} else {
commands.entity(entity).insert(InteractionDisabled);
}
}
}
fn quit_on_key(
mut keys: MessageReader<KeyMessage>,
mut focus: ResMut<InputFocus>,
mut exit: MessageWriter<AppExit>,
) {
for key in keys.read() {
if key.kind != KeyKind::Press {
continue;
}
if key.modifiers.ctrl && key.code == KeyCode::Char('c') {
exit.write(AppExit::Success);
continue;
}
match key.code {
KeyCode::Esc => focus.clear(),
KeyCode::Char('q') if focus.get().is_none() => {
exit.write(AppExit::Success);
}
_ => {}
}
}
}