use crate::backend::tty;
use crate::compositor::{Compositor, Plane};
use crate::framework::animation::AnimationManager;
use crate::framework::command::{AppConfig, BoundCommand, CommandRunner};
use crate::framework::dirty_regions::DirtyRegionTracker;
use crate::framework::event_bus::EventBus;
use crate::framework::focus::FocusManager;
#[cfg(feature = "debug_events")]
use crate::framework::logging::{log_key_event, log_mouse_event};
use crate::framework::scene_router::SceneRouter;
use crate::framework::keybindings::{actions, KeybindingSet, resolve_keybindings};
use crate::framework::theme::Theme;
use crate::framework::widget::{Widget, WidgetId};
use crate::input::event::{Event, KeyEvent};
use crate::input::parser::Parser;
use crate::Terminal;
use crate::core::terminal::RESTORE_SEQ;
use ratatui::layout::Rect;
use signal_hook::consts::signal::{SIGINT, SIGTERM};
use std::cell::Ref;
use std::cell::RefCell;
use std::cell::RefMut;
use std::ops::Deref;
use std::ops::DerefMut;
use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::os::fd::AsFd;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub(crate) type TickCallback = Box<dyn FnMut(&mut Ctx, u64) + 'static>;
pub struct WidgetRef<'a> {
inner: Ref<'a, Box<dyn Widget>>,
}
impl<'a> Deref for WidgetRef<'a> {
type Target = Box<dyn Widget>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
pub struct WidgetRefMut<'a> {
inner: RefMut<'a, Box<dyn Widget>>,
}
impl<'a> Deref for WidgetRefMut<'a> {
type Target = Box<dyn Widget>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<'a> DerefMut for WidgetRefMut<'a> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
pub struct App {
terminal: Terminal<io::Stdout>,
compositor: Compositor,
parser: Parser,
title: String,
fps: u32,
theme: Theme,
running: Arc<AtomicBool>,
frame_count: Arc<AtomicU64>,
last_frame_time: Instant,
last_tick_time: Instant,
tick_interval: Duration,
tick_count: u64,
on_tick: RefCell<Option<TickCallback>>,
widgets: RefCell<Vec<Box<dyn Widget>>>,
focus_manager: FocusManager,
dirty_tracker: DirtyRegionTracker,
animations: AnimationManager,
next_widget_id: usize,
commands: RefCell<Vec<BoundCommand>>,
command_tracking: RefCell<HashMap<WidgetId, (Instant, BoundCommand)>>,
event_bus: EventBus,
scene_router: crate::framework::scene_router::SceneRouter,
keybindings: KeybindingSet,
}
impl App {
fn dispatch_key(&mut self, k: &crate::input::event::KeyEvent, running: &std::sync::atomic::AtomicBool) {
if self.keybindings.matches(actions::QUIT, k) {
running.store(false, Ordering::SeqCst);
return;
}
if self.keybindings.matches(actions::BACK, k) {
let consumed = self.focus_manager.focused()
.and_then(|id| self.widget_mut(id))
.map(|mut w| w.handle_key(*k))
.unwrap_or(false);
if !consumed {
running.store(false, Ordering::SeqCst);
}
return;
}
if k.code == crate::input::event::KeyCode::Tab {
let old = self.focus_manager.focused();
if k.modifiers.contains(crate::input::event::KeyModifiers::SHIFT) {
let _ = self.focus_manager.tab_prev();
} else {
let _ = self.focus_manager.tab_next();
}
let new = self.focus_manager.focused();
if new != old {
if let Some(old_id) = old {
if let Some(mut w) = self.widget_mut(old_id) {
w.on_blur();
}
}
if let Some(new_id) = new {
if let Some(mut w) = self.widget_mut(new_id) {
w.on_focus();
}
}
}
} else if let Some(focused) = self.focus_manager.focused() {
let new_theme = if let Some(mut widget) = self.widget_mut(focused) {
let _ = widget.handle_key(*k);
widget.current_theme()
} else {
None
};
if let Some(theme) = new_theme {
if theme.name != self.theme.name {
self.set_theme(theme);
}
}
}
}
fn dispatch_resize(&mut self, w: u16, h: u16) {
self.compositor.resize(w, h);
self.dirty_tracker.mark_all_dirty();
let rect = Rect::new(0, 0, w, h);
for w in self.widgets.borrow_mut().iter_mut() {
w.set_area(rect);
w.mark_dirty();
}
}
fn dispatch_mouse(&mut self, col: u16, row: u16, mouse_event: &crate::input::event::MouseEvent) {
let target_id = {
let widgets = self.widgets.borrow();
let mut sorted: Vec<_> = widgets.iter().collect();
sorted.sort_by_key(|w| w.z_index());
sorted
.into_iter()
.find(|w| {
let a = w.area();
col >= a.x && col < a.x + a.width && row >= a.y && row < a.y + a.height
})
.map(|w| w.id())
};
if let Some(id) = target_id {
let old = self.focus_manager.focused();
if old != Some(id) {
if let Some(old_id) = old {
if let Some(mut w) = self.widget_mut(old_id) {
w.on_blur();
}
}
self.focus_manager.set_focus(id);
if let Some(mut w) = self.widget_mut(id) {
w.on_focus();
}
}
if let Some(mut widget) = self.widget_mut(id) {
let a = widget.area();
let local_col = col.saturating_sub(a.x);
let local_row = row.saturating_sub(a.y);
let _ = widget.handle_mouse(mouse_event.kind, local_col, local_row);
}
}
}
fn handle_event(&mut self, event: &Event, running: &std::sync::atomic::AtomicBool) {
match event {
Event::Resize(w, h) => self.dispatch_resize(*w, *h),
Event::Key(k) => self.dispatch_key(k, running),
Event::Mouse(mouse_event) => self.dispatch_mouse(mouse_event.column, mouse_event.row, mouse_event),
Event::Paste(text) => self.dispatch_paste(text),
_ => {}
}
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all, fields(title = "Dracon App")))]
pub fn new() -> io::Result<Self> {
Self::new_impl()
}
fn new_impl() -> io::Result<Self> {
let terminal = Terminal::new(io::stdout())?;
let (w, h) = tty::get_window_size(io::stdout().as_fd()).unwrap_or((80, 24));
let mut compositor = Compositor::new(w, h);
compositor.set_clear_color(Theme::default().bg);
Ok(Self {
terminal,
compositor,
parser: Parser::new(),
title: String::from("Dracon App"),
fps: 30,
theme: Theme::default(),
running: Arc::new(AtomicBool::new(true)),
frame_count: Arc::new(AtomicU64::new(0)),
last_frame_time: Instant::now(),
last_tick_time: Instant::now(),
tick_interval: Duration::from_millis(250),
tick_count: 0,
on_tick: RefCell::new(None),
widgets: RefCell::new(Vec::new()),
focus_manager: FocusManager::new(),
dirty_tracker: DirtyRegionTracker::new(),
animations: AnimationManager::new(),
next_widget_id: 0,
commands: RefCell::new(Vec::new()),
command_tracking: RefCell::new(HashMap::new()),
event_bus: EventBus::new(),
scene_router: SceneRouter::new(),
keybindings: KeybindingSet::from_config(&resolve_keybindings()),
})
}
pub fn from_toml(path: &std::path::Path) -> io::Result<Self> {
let config = AppConfig::from_toml(path)?;
let terminal = Terminal::new(io::stdout())?;
let (w, h) = tty::get_window_size(io::stdout().as_fd()).unwrap_or((80, 24));
let mut app = Self {
terminal,
compositor: Compositor::new(w, h),
parser: Parser::new(),
title: config.title.clone(),
fps: config.fps.unwrap_or(30),
theme: Theme::default(),
running: Arc::new(AtomicBool::new(true)),
frame_count: Arc::new(AtomicU64::new(0)),
last_frame_time: Instant::now(),
last_tick_time: Instant::now(),
tick_interval: Duration::from_millis(250),
tick_count: 0,
on_tick: RefCell::new(None),
widgets: RefCell::new(Vec::new()),
focus_manager: FocusManager::new(),
dirty_tracker: DirtyRegionTracker::new(),
animations: AnimationManager::new(),
next_widget_id: 0,
commands: RefCell::new(config.commands),
command_tracking: RefCell::new(HashMap::new()),
event_bus: EventBus::new(),
scene_router: SceneRouter::new(),
keybindings: KeybindingSet::from_config(&resolve_keybindings()),
};
write!(app.terminal, "\x1b]0;{}\x07", app.title).ok();
Ok(app)
}
pub fn add_command(&mut self, cmd: BoundCommand) {
self.commands.borrow_mut().push(cmd);
}
pub fn available_commands(&self) -> Vec<BoundCommand> {
let mut cmds = self.commands.borrow().clone();
for widget in self.widgets.borrow().iter() {
cmds.extend(widget.commands());
}
cmds
}
pub fn title(mut self, title: &str) -> Self {
self.title = title.to_string();
write!(self.terminal, "\x1b]0;{title}\x07").ok();
self
}
pub fn fps(mut self, fps: u32) -> Self {
self.fps = fps.clamp(1, 120);
self
}
pub fn theme(mut self, theme: Theme) -> Self {
self.compositor.set_clear_color(theme.bg);
self.theme = theme;
for widget in self.widgets.borrow_mut().iter_mut() {
widget.on_theme_change(&self.theme);
}
self
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(theme_name = %theme.name)))]
pub fn set_theme(&mut self, theme: Theme) -> &mut Self {
self.compositor.set_clear_color(theme.bg);
self.theme = theme;
self.dirty_tracker.mark_all_dirty();
for widget in self.widgets.borrow_mut().iter_mut() {
widget.on_theme_change(&self.theme);
widget.mark_dirty();
}
self
}
fn dispatch_paste(&mut self, text: &str) {
if let Some(focused) = self.focus_manager.focused() {
for ch in text.chars() {
let code = match ch {
'\r' | '\n' => crate::input::event::KeyCode::Enter,
'\t' => crate::input::event::KeyCode::Tab,
c => crate::input::event::KeyCode::Char(c),
};
let key = crate::input::event::KeyEvent {
code,
modifiers: crate::input::event::KeyModifiers::empty(),
kind: crate::input::event::KeyEventKind::Press,
};
if let Some(mut widget) = self.widget_mut(focused) {
let _ = widget.handle_key(key);
}
}
}
}
pub fn on_tick<F>(self, f: F) -> Self
where
F: FnMut(&mut Ctx, u64) + 'static,
{
*self.on_tick.borrow_mut() = Some(Box::new(f));
self
}
pub fn tick_interval(mut self, ms: u64) -> Self {
self.tick_interval = Duration::from_millis(ms);
self
}
pub fn on_input<F>(mut self, handler: F) -> Self
where
F: FnMut(KeyEvent) -> bool + 'static,
{
let (w, h) = tty::get_window_size(io::stdout().as_fd()).unwrap_or((80, 24));
let input_widget = InputHandler {
handler: Box::new(handler),
id: WidgetId::new(self.next_widget_id),
area: Rect::new(0, 0, w, h),
};
self.add_widget(Box::new(input_widget), Rect::new(0, 0, w, h));
self
}
pub fn add_widget(&mut self, mut widget: Box<dyn Widget>, area: Rect) -> WidgetId {
let id = WidgetId(self.next_widget_id);
widget.set_id(id);
widget.set_area(area);
widget.on_mount();
widget.on_theme_change(&self.theme);
let focusable = widget.focusable();
let cmds = widget.commands();
self.widgets.borrow_mut().push(widget);
self.compositor.set_widget_count(self.widgets.borrow().len());
self.focus_manager.register(id, focusable);
if self.focus_manager.focused().is_none() && focusable {
self.focus_manager.set_focus(id);
if let Some(mut w) = self.widget_mut(id) {
w.on_focus();
}
}
self.next_widget_id += 1;
for cmd in cmds {
if cmd.refresh_seconds.is_some() {
self.command_tracking
.borrow_mut()
.insert(id, (Instant::now(), cmd));
}
}
id
}
pub fn remove_widget(&mut self, id: WidgetId) {
if let Some(w) = self.widgets.borrow_mut().iter_mut().find(|w| w.id() == id) {
w.on_unmount();
}
self.widgets.borrow_mut().retain(|w| w.id() != id);
self.compositor.set_widget_count(self.widgets.borrow().len());
self.focus_manager.unregister(id);
self.command_tracking.borrow_mut().remove(&id);
}
pub fn widget(&self, id: WidgetId) -> Option<WidgetRef<'_>> {
let widgets = self.widgets.borrow();
let idx = widgets.iter().position(|w| w.id() == id)?;
Some(WidgetRef {
inner: Ref::map(widgets, |w| &w[idx]),
})
}
pub fn widget_mut(&mut self, id: WidgetId) -> Option<WidgetRefMut<'_>> {
let widgets = self.widgets.borrow_mut();
let idx = widgets.iter().position(|w| w.id() == id)?;
Some(WidgetRefMut {
inner: RefMut::map(widgets, |w| &mut w[idx]),
})
}
pub fn widget_count(&self) -> usize {
self.widgets.borrow().len()
}
pub fn plane_count(&self) -> usize {
self.compositor.planes.len()
}
pub fn frame_time_ms(&self) -> f64 {
self.compositor.last_frame_duration_ms()
}
fn poll_and_dispatch_input(&mut self, stdin: &mut io::Stdin) {
let running = self.running.clone();
match tty::poll_input(stdin.as_fd(), 1) {
Ok(true) => {
let mut chunk_buf = [0u8; 1024];
if let Ok(n) = stdin.read(&mut chunk_buf) {
if n == 0 {
}
for byte in chunk_buf.iter().take(n) {
if let Some(event) = self.parser.advance(*byte) {
#[cfg(feature = "debug_events")]
match &event {
Event::Key(k) => log_key_event(k),
Event::Mouse(m) => log_mouse_event(m),
_ => {}
}
self.handle_event(&event, &running);
}
}
}
for _ in 0..64 {
match tty::poll_input(stdin.as_fd(), 0) {
Ok(true) => {
let mut drain_buf = [0u8; 1024];
if let Ok(dn) = stdin.read(&mut drain_buf) {
if dn == 0 { break; }
for byte in drain_buf.iter().take(dn) {
if let Some(event) = self.parser.advance(*byte) {
self.handle_event(&event, &running);
}
}
}
}
_ => break,
}
}
}
Ok(false) => {
if let Some(evt) = self.parser.check_timeout() {
#[cfg(feature = "debug_events")]
match &evt {
Event::Key(k) => log_key_event(k),
Event::Mouse(m) => log_mouse_event(m),
_ => {}
}
self.handle_event(&evt, &running);
}
}
Err(_) => {}
}
}
fn render_dirty_widgets(&mut self) {
#[cfg(feature = "tracing")]
let _widget_span = tracing::debug_span!("widget_dispatch").entered();
let mut widgets = self.widgets.borrow_mut();
let mut sorted: Vec<_> = widgets.iter_mut().collect();
sorted.sort_by_key(|w| w.z_index());
for w in sorted {
if !w.needs_render() {
continue;
}
let area = w.area();
#[cfg(feature = "tracing")]
let _render_span = tracing::debug_span!(
"widget_render",
widget_id = w.id().0,
width = area.width,
height = area.height
)
.entered();
let plane = w.render(area);
w.clear_dirty();
self.compositor.add_plane(plane);
}
}
fn run_tick_callback(&mut self, frame_count: &Arc<AtomicU64>) {
if self.last_tick_time.elapsed() < self.tick_interval {
return;
}
if let Some(ref mut tick_fn) = *self.on_tick.borrow_mut() {
let prev_theme_name = self.theme.name.clone();
tick_fn(
&mut Ctx {
compositor: &mut self.compositor,
theme: &mut self.theme,
frame_count: frame_count.load(Ordering::SeqCst),
last_frame: &self.last_frame_time,
terminal: &mut self.terminal,
focus_manager: &mut self.focus_manager,
animations: &mut self.animations,
dirty_tracker: &mut self.dirty_tracker,
commands: &self.commands,
running: &self.running,
event_bus: &self.event_bus,
scene_router: &mut self.scene_router,
},
self.tick_count,
);
if self.theme.name != prev_theme_name {
self.compositor.set_clear_color(self.theme.bg);
self.dirty_tracker.mark_all_dirty();
for widget in self.widgets.borrow_mut().iter_mut() {
widget.on_theme_change(&self.theme);
widget.mark_dirty();
}
}
self.tick_count += 1;
self.last_tick_time = Instant::now();
}
}
fn run_periodic_commands(&mut self) {
let now = Instant::now();
let mut to_reschedule: Vec<(WidgetId, BoundCommand)> = Vec::new();
let mut expired: Vec<WidgetId> = Vec::new();
{
let tracked = self.command_tracking.borrow();
for (&wid, (last_run, cmd)) in tracked.iter() {
let interval = Duration::from_secs(cmd.refresh_seconds.unwrap_or(0));
if interval.is_zero() || now.duration_since(*last_run) < interval {
continue;
}
expired.push(wid);
}
}
for wid in expired {
let cmd = match self.command_tracking.borrow().get(&wid) {
Some((_, c)) => c.clone(),
None => continue,
};
if let Some(mut w) = self.widget_mut(wid) {
let runner = CommandRunner::new(&cmd.command);
let (stdout, stderr, exit_code) = runner.run_sync();
let output = cmd.parse_output(&stdout, &stderr, exit_code);
w.apply_command_output(&output);
w.mark_dirty();
to_reschedule.push((wid, cmd));
}
}
for (wid, cmd) in to_reschedule {
self.command_tracking
.borrow_mut()
.insert(wid, (Instant::now(), cmd));
}
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all, fields(title = %self.title, fps = %self.fps)))]
pub fn run<F>(mut self, mut f: F) -> io::Result<()>
where
F: FnMut(&mut Ctx),
{
let running = self.running.clone();
let frame_count = self.frame_count.clone();
let title = self.title.clone();
write!(self.terminal, "\x1b]0;{title}\x07").ok();
let terminal_ptr = &mut self.terminal as *mut Terminal<io::Stdout> as usize;
let previous_hook = Arc::new(Mutex::new(std::panic::take_hook()));
let prev_hook_clone = previous_hook.clone();
std::panic::set_hook(Box::new(move |info| {
let t = unsafe { &mut *(terminal_ptr as *mut Terminal<io::Stdout>) };
let _ = write!(t, "{}", RESTORE_SEQ);
let _ = t.flush();
if let Ok(mut hook) = prev_hook_clone.lock() {
let original = std::mem::replace(&mut *hook, Box::new(|_| {}));
original(info);
}
}));
let running_for_signal = running.clone();
unsafe {
let running_int = running_for_signal.clone();
signal_hook::low_level::register(SIGINT, move || {
running_int.store(false, Ordering::SeqCst);
})
.ok();
let running_term = running_for_signal.clone();
signal_hook::low_level::register(SIGTERM, move || {
running_term.store(false, Ordering::SeqCst);
})
.ok();
}
let mut stdin = io::stdin();
let frame_duration = Duration::from_secs_f64(1.0 / self.fps as f64);
let (w, h) = self.compositor.size();
let full_rect = Rect::new(0, 0, w, h);
for w in self.widgets.borrow_mut().iter_mut() {
w.set_area(full_rect);
w.mark_dirty();
}
while running.load(Ordering::SeqCst) {
let frame_start = Instant::now();
#[cfg(feature = "tracing")]
let _frame_span = tracing::debug_span!("frame").entered();
#[cfg(feature = "tracing")]
let _input_span = tracing::debug_span!("input_poll").entered();
self.poll_and_dispatch_input(&mut stdin);
#[cfg(feature = "tracing")]
drop(_input_span);
self.render_dirty_widgets();
self.run_tick_callback(&frame_count);
self.run_periodic_commands();
f(&mut Ctx {
compositor: &mut self.compositor,
theme: &mut self.theme,
frame_count: frame_count.load(Ordering::SeqCst),
last_frame: &self.last_frame_time,
terminal: &mut self.terminal,
focus_manager: &mut self.focus_manager,
animations: &mut self.animations,
dirty_tracker: &mut self.dirty_tracker,
commands: &self.commands,
running: &self.running,
event_bus: &self.event_bus,
scene_router: &mut self.scene_router,
});
if !self.compositor.planes.is_empty() {
self.compositor.set_dirty_regions(&self.dirty_tracker);
self.compositor.render(&mut self.terminal)?;
}
self.animations.tick();
frame_count.fetch_add(1, Ordering::SeqCst);
let frame_elapsed = self.last_frame_time.elapsed().as_secs_f64() * 1000.0;
self.compositor.set_last_frame_duration(frame_elapsed);
self.last_frame_time = Instant::now();
let elapsed = frame_start.elapsed();
if elapsed < frame_duration {
std::thread::sleep(frame_duration - elapsed);
}
}
let _our_hook = std::panic::take_hook();
if let Ok(mut guard) = previous_hook.lock() {
let original = std::mem::replace(&mut *guard, Box::new(|_| {}));
std::panic::set_hook(original);
}
if let Ok(path) = std::env::var("DTRON_THEME_FILE") {
let _ = std::fs::write(&path, self.theme.name.as_bytes());
}
Ok(())
}
pub fn stop(&self) {
self.running.store(false, Ordering::SeqCst);
}
}
impl Default for App {
fn default() -> Self {
Self::new().expect("failed to initialize terminal")
}
}
pub struct Ctx<'a> {
pub(crate) compositor: &'a mut Compositor,
pub(crate) theme: &'a mut Theme,
pub(crate) frame_count: u64,
pub(crate) last_frame: &'a Instant,
pub(crate) terminal: &'a mut crate::Terminal<io::Stdout>,
pub(crate) focus_manager: &'a mut FocusManager,
pub(crate) animations: &'a mut AnimationManager,
pub(crate) dirty_tracker: &'a mut DirtyRegionTracker,
pub(crate) commands: &'a RefCell<Vec<BoundCommand>>,
pub(crate) running: &'a AtomicBool,
pub(crate) event_bus: &'a EventBus,
pub(crate) scene_router: &'a mut SceneRouter,
}
impl<'a> Ctx<'a> {
pub fn add_plane(&mut self, plane: Plane) {
self.compositor.add_plane(plane);
}
pub fn show_cursor(&mut self) -> io::Result<()> {
self.terminal.show_cursor()
}
pub fn hide_cursor(&mut self) -> io::Result<()> {
self.terminal.hide_cursor()
}
pub fn set_cursor(&mut self, col: u16, row: u16) -> io::Result<()> {
self.terminal.set_cursor(col, row)
}
pub fn suspend_terminal(&mut self) -> io::Result<()> {
self.terminal.suspend()
}
pub fn resume_terminal(&mut self) -> io::Result<()> {
self.terminal.resume()?;
self.compositor.invalidate_last_frame();
self.dirty_tracker.mark_all_dirty();
Ok(())
}
pub fn set_focus(&mut self, id: WidgetId) {
self.focus_manager.set_focus(id);
}
pub fn focused(&self) -> Option<WidgetId> {
self.focus_manager.focused()
}
pub fn animations(&self) -> &AnimationManager {
self.animations
}
pub fn widget_count(&self) -> usize {
self.compositor.widget_count()
}
pub fn plane_count(&self) -> usize {
self.compositor.planes.len()
}
pub fn frame_time_ms(&self) -> f64 {
self.compositor.last_frame_duration_ms()
}
pub fn animations_mut(&mut self) -> &mut AnimationManager {
self.animations
}
pub fn mark_dirty(&mut self, x: u16, y: u16, width: u16, height: u16) {
self.dirty_tracker.mark_dirty(x, y, width, height);
}
pub fn mark_all_dirty(&mut self) {
self.dirty_tracker.mark_all_dirty();
}
pub fn needs_full_refresh(&self) -> bool {
self.dirty_tracker.needs_full_refresh()
}
pub fn compositor(&self) -> &Compositor {
self.compositor
}
pub fn compositor_mut(&mut self) -> &mut Compositor {
self.compositor
}
pub fn theme(&self) -> &Theme {
self.theme
}
pub fn set_theme(&mut self, theme: Theme) {
self.compositor.set_clear_color(theme.bg);
*self.theme = theme;
}
pub fn clear(&mut self) {
self.compositor.force_clear();
}
pub fn fps(&self) -> u64 {
let elapsed = self.last_frame.elapsed().as_secs_f64();
if elapsed > 0.0 {
(self.frame_count as f64 / elapsed) as u64
} else {
0
}
}
pub fn split_h<F>(&mut self, f: F)
where
F: FnOnce(
&mut crate::framework::widgets::split::SplitPane,
&mut crate::framework::widgets::split::SplitPane,
),
{
let (w, h) = self.compositor.size();
let split = crate::framework::widgets::split::SplitPane::new(
crate::framework::widgets::split::Orientation::Horizontal,
)
.ratio(0.5);
let (r1, r2) = split.split(Rect::new(0, 0, w, h));
let mut left = crate::framework::widgets::split::SplitPane::from_rect(r1);
let mut right = crate::framework::widgets::split::SplitPane::from_rect(r2);
f(&mut left, &mut right);
}
pub fn split_v<F>(&mut self, f: F)
where
F: FnOnce(
&mut crate::framework::widgets::split::SplitPane,
&mut crate::framework::widgets::split::SplitPane,
),
{
let (w, h) = self.compositor.size();
let split = crate::framework::widgets::split::SplitPane::new(
crate::framework::widgets::split::Orientation::Vertical,
)
.ratio(0.5);
let (r1, r2) = split.split(Rect::new(0, 0, w, h));
let mut top = crate::framework::widgets::split::SplitPane::from_rect(r1);
let mut bottom = crate::framework::widgets::split::SplitPane::from_rect(r2);
f(&mut top, &mut bottom);
}
pub fn publish<E: std::any::Any + Clone>(&self, event: E) {
self.event_bus.publish(event);
}
pub fn subscribe<E: std::any::Any + Clone, F>(&self, callback: F) -> crate::framework::event_bus::SubscriptionId
where
F: Fn(&E) + 'static,
{
self.event_bus.subscribe(callback)
}
pub fn event_bus(&self) -> &EventBus {
self.event_bus
}
pub fn scene_router(&mut self) -> &mut SceneRouter {
self.scene_router
}
pub fn push_scene(&mut self, id: &str) {
self.scene_router.push(id);
}
pub fn pop_scene(&mut self) -> bool {
self.scene_router.pop()
}
pub fn replace_scene(&mut self, id: &str) {
self.scene_router.replace(id);
}
pub fn go_to_scene(&mut self, id: &str) {
self.scene_router.go(id);
}
pub fn layout(&self, constraints: Vec<crate::framework::layout::Constraint>) -> Vec<Rect> {
let (w, h) = self.compositor.size();
let layout = crate::framework::layout::Layout::new(constraints);
layout.layout(Rect::new(0, 0, w, h))
}
pub fn run_command(&self, cmd: &str) -> (String, String, i32) {
let runner = CommandRunner::new(cmd);
runner.run_sync()
}
pub fn available_commands(&self) -> Vec<BoundCommand> {
self.commands.borrow().clone()
}
pub fn stop(&mut self) {
self.running.store(false, Ordering::SeqCst);
}
}
struct InputHandler {
handler: Box<dyn FnMut(KeyEvent) -> bool>,
id: WidgetId,
area: Rect,
}
impl Widget for InputHandler {
fn id(&self) -> WidgetId {
self.id
}
fn set_id(&mut self, id: WidgetId) {
self.id = id;
}
fn area(&self) -> Rect {
self.area
}
fn set_area(&mut self, area: Rect) {
self.area = area;
}
fn z_index(&self) -> u16 {
0
}
fn needs_render(&self) -> bool {
false
}
fn mark_dirty(&mut self) {}
fn clear_dirty(&mut self) {}
fn focusable(&self) -> bool {
true
}
fn render(&self, _area: Rect) -> Plane {
Plane::new(0, 0, 0)
}
fn handle_key(&mut self, key: KeyEvent) -> bool {
(self.handler)(key)
}
}
#[cfg(test)]
mod tests {
use super::*;
static FAKE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
use crate::framework::command::{
AppConfig, AreaConfig, LayoutConfig, ParserConfig, WidgetConfig,
};
fn make_test_terminal() -> io::Result<crate::Terminal<io::Stdout>> {
crate::Terminal::new(io::stdout())
}
macro_rules! with_ctx {
(mut $ctx:ident, $body:expr) => {{
let mut compositor = Compositor::new(80, 24);
let mut focus_manager = FocusManager::new();
let mut dirty_tracker = DirtyRegionTracker::new();
let mut animations = AnimationManager::new();
let mut theme = Theme::default();
let last_frame = Instant::now();
let commands = RefCell::new(Vec::new());
let event_bus = EventBus::new();
let mut scene_router = SceneRouter::new();
let mut terminal = make_test_terminal().unwrap();
let mut $ctx = Ctx {
compositor: &mut compositor,
theme: &mut theme,
frame_count: 0,
last_frame: &last_frame,
running: &FAKE_RUNNING,
terminal: &mut terminal,
focus_manager: &mut focus_manager,
animations: &mut animations,
dirty_tracker: &mut dirty_tracker,
commands: &commands,
event_bus: &event_bus,
scene_router: &mut scene_router,
};
$body
}};
($ctx:ident, $body:expr) => {{
let mut compositor = Compositor::new(80, 24);
let mut focus_manager = FocusManager::new();
let mut dirty_tracker = DirtyRegionTracker::new();
let mut animations = AnimationManager::new();
let mut theme = Theme::default();
let last_frame = Instant::now();
let commands = RefCell::new(Vec::new());
let event_bus = EventBus::new();
let mut scene_router = SceneRouter::new();
let mut terminal = make_test_terminal().unwrap();
let $ctx = Ctx {
compositor: &mut compositor,
theme: &mut theme,
frame_count: 0,
last_frame: &last_frame,
running: &FAKE_RUNNING,
terminal: &mut terminal,
focus_manager: &mut focus_manager,
animations: &mut animations,
dirty_tracker: &mut dirty_tracker,
commands: &commands,
event_bus: &event_bus,
scene_router: &mut scene_router,
};
$body
}};
}
#[test]
fn test_app_new() {
let app = App::new();
assert!(app.is_ok());
let app = app.unwrap();
assert_eq!(app.widget_count(), 0);
assert_eq!(app.title, "Dracon App");
assert_eq!(app.fps, 30);
}
#[test]
fn test_app_default() {
let app = App::default();
assert_eq!(app.widget_count(), 0);
assert_eq!(app.title, "Dracon App");
}
#[test]
fn test_app_title_fps_builder() {
let app = App::new().unwrap().title("My Dashboard").fps(60);
assert_eq!(app.title, "My Dashboard");
assert_eq!(app.fps, 60);
}
#[test]
fn test_app_fps_clamped() {
let app = App::new().unwrap().fps(0);
assert_eq!(app.fps, 1);
let app = App::new().unwrap().fps(200);
assert_eq!(app.fps, 120);
}
#[test]
fn test_app_add_widget() {
use crate::framework::widgets::Label;
let mut app = App::new().unwrap();
let label = Label::new("test");
let id = app.add_widget(Box::new(label), Rect::new(0, 0, 10, 1));
assert_eq!(app.widget_count(), 1);
assert!(app.widget(id).is_some());
}
#[test]
fn test_app_widget_mut() {
use crate::framework::widgets::Label;
let mut app = App::new().unwrap();
let label = Label::new("test");
let id = app.add_widget(Box::new(label), Rect::new(0, 0, 10, 1));
let w = app.widget_mut(id);
assert!(w.is_some());
}
#[test]
fn test_app_remove_widget() {
use crate::framework::widgets::Label;
let mut app = App::new().unwrap();
let label = Label::new("test");
let id = app.add_widget(Box::new(label), Rect::new(0, 0, 10, 1));
assert_eq!(app.widget_count(), 1);
app.remove_widget(id);
assert_eq!(app.widget_count(), 0);
}
#[test]
fn test_app_widget_not_found() {
let mut app = App::new().unwrap();
let id = WidgetId(99999);
assert!(app.widget(id).is_none());
assert!(app.widget_mut(id).is_none());
}
#[test]
fn test_app_add_command() {
let mut app = App::new().unwrap();
let cmd = BoundCommand::new("ls -la");
app.add_command(cmd.clone());
let cmds = app.available_commands();
assert!(!cmds.is_empty());
}
#[test]
fn test_app_available_commands_includes_widget_commands() {
use crate::framework::widgets::Label;
let mut app = App::new().unwrap();
let label = Label::new("test");
let _id = app.add_widget(Box::new(label), Rect::new(0, 0, 10, 1));
let cmds = app.available_commands();
assert!(cmds.is_empty());
}
#[test]
fn test_app_set_theme() {
use crate::framework::widgets::Label;
let mut app = App::new().unwrap();
let label = Label::new("test");
app.add_widget(Box::new(label), Rect::new(0, 0, 10, 1));
let theme = Theme::cyberpunk();
app.set_theme(theme);
assert_eq!(&*app.theme.name, "cyberpunk");
}
#[test]
fn test_app_tick_interval() {
let app = App::new().unwrap().tick_interval(500);
assert_eq!(app.tick_interval, Duration::from_millis(500));
}
#[test]
fn test_app_stop() {
let app = App::new().unwrap();
app.stop();
}
#[test]
fn test_ctx_available_commands_empty() {
with_ctx!(ctx, {
let cmds = ctx.available_commands();
assert!(cmds.is_empty());
});
}
#[test]
fn test_ctx_add_plane() {
with_ctx!(mut ctx, {
let plane = Plane::new(0, 20, 10);
ctx.add_plane(plane);
assert_eq!(ctx.compositor().planes.len(), 1);
});
}
#[test]
fn test_ctx_mark_dirty() {
with_ctx!(mut ctx, {
ctx.mark_dirty(0, 0, 80, 24);
});
}
#[test]
fn test_ctx_set_focus() {
with_ctx!(mut ctx, {
let id = WidgetId(42);
ctx.set_focus(id);
let _ = ctx.focused();
});
}
#[test]
fn test_ctx_theme_access() {
with_ctx!(ctx, {
assert!(ctx.theme().name == Arc::from("default") || ctx.theme().name == Arc::from("dark"));
});
}
#[test]
fn test_ctx_mark_all_dirty() {
with_ctx!(mut ctx, {
ctx.mark_all_dirty();
assert!(ctx.dirty_tracker.needs_full_refresh());
});
}
#[test]
fn test_ctx_clear() {
with_ctx!(mut ctx, {
ctx.clear();
assert!(ctx.dirty_tracker.needs_full_refresh());
});
}
#[test]
fn test_ctx_compositor_access() {
with_ctx!(ctx, {
let (w, h) = ctx.compositor().size();
assert_eq!(w, 80);
assert_eq!(h, 24);
});
}
#[test]
fn test_ctx_fps_zero_elapsed() {
let mut compositor = Compositor::new(80, 24);
let mut focus_manager = FocusManager::new();
let mut dirty_tracker = DirtyRegionTracker::new();
let mut animations = AnimationManager::new();
let mut theme = Theme::default();
let last_frame = std::time::Instant::now();
let commands = RefCell::new(Vec::new());
let event_bus = EventBus::new();
let mut scene_router = SceneRouter::new();
let mut terminal = make_test_terminal().unwrap();
let ctx = Ctx {
compositor: &mut compositor,
theme: &mut theme,
frame_count: 100,
last_frame: &last_frame,
running: &FAKE_RUNNING,
terminal: &mut terminal,
focus_manager: &mut focus_manager,
animations: &mut animations,
dirty_tracker: &mut dirty_tracker,
commands: &commands,
event_bus: &event_bus,
scene_router: &mut scene_router,
};
let _fps = ctx.fps();
}
#[test]
fn test_ctx_split_h() {
with_ctx!(mut ctx, {
ctx.split_h(|left, right| {
let a = left.area();
let b = right.area();
assert!(a.width > 0);
assert!(b.width > 0);
});
});
}
#[test]
fn test_ctx_split_v() {
with_ctx!(mut ctx, {
ctx.split_v(|_top, _bottom| {});
});
}
#[test]
fn test_ctx_layout() {
with_ctx!(ctx, {
use crate::framework::layout::Constraint;
let rects = ctx.layout(vec![Constraint::Percentage(50), Constraint::Percentage(50)]);
assert_eq!(rects.len(), 2);
});
}
#[test]
fn test_ctx_run_command() {
with_ctx!(ctx, {
let (stdout, _stderr, code) = ctx.run_command("echo test_run_command");
assert!(stdout.contains("test_run_command"));
assert_eq!(code, 0);
});
}
#[test]
fn test_ctx_available_commands() {
let mut compositor = Compositor::new(80, 24);
let mut focus_manager = FocusManager::new();
let mut dirty_tracker = DirtyRegionTracker::new();
let mut animations = AnimationManager::new();
let mut theme = Theme::default();
let last_frame = Instant::now();
let commands = RefCell::new(vec![
BoundCommand::new("test cmd 1"),
BoundCommand::new("test cmd 2"),
]);
let event_bus = EventBus::new();
let mut scene_router = SceneRouter::new();
let mut terminal = make_test_terminal().unwrap();
let ctx = Ctx {
compositor: &mut compositor,
theme: &mut theme,
frame_count: 0,
last_frame: &last_frame,
running: &FAKE_RUNNING,
terminal: &mut terminal,
focus_manager: &mut focus_manager,
animations: &mut animations,
dirty_tracker: &mut dirty_tracker,
commands: &commands,
event_bus: &event_bus,
scene_router: &mut scene_router,
};
let cmds = ctx.available_commands();
assert_eq!(cmds.len(), 2);
assert_eq!(cmds[0].command, "test cmd 1");
assert_eq!(cmds[1].command, "test cmd 2");
}
#[test]
fn test_app_config_from_toml_str() {
let toml = r#"
title = "Test App"
fps = 60
"#;
let config = AppConfig::from_toml_str(toml).unwrap();
assert_eq!(config.title, "Test App");
assert_eq!(config.fps, Some(60));
}
#[test]
fn test_app_config_from_toml_str_widgets() {
let toml = r#"
title = "Widget Test"
[[widget]]
id = 1
type = "Label"
label = "Test Label"
"#;
let config = AppConfig::from_toml_str(toml).unwrap();
assert_eq!(config.title, "Widget Test");
}
#[test]
fn test_widget_config_all_fields() {
let toml = r#"
id = 5
type = "CustomWidget"
bind = "mycommand --arg"
refresh_seconds = 15
confirm = "Confirm?"
label = "My Label"
description = "Widget description"
"#;
let config: WidgetConfig = toml::from_str(toml).unwrap();
assert_eq!(config.id, Some(5));
assert_eq!(config.widget_type, Some("CustomWidget".to_string()));
assert_eq!(config.bind, Some("mycommand --arg".to_string()));
assert_eq!(config.refresh_seconds, Some(15));
assert_eq!(config.confirm, Some("Confirm?".to_string()));
assert_eq!(config.label, Some("My Label".to_string()));
assert_eq!(config.description, Some("Widget description".to_string()));
}
#[test]
fn test_widget_config_type_alias() {
let toml = r#"
type = "StatusBadge"
"#;
let config: WidgetConfig = toml::from_str(toml).unwrap();
assert_eq!(config.widget_type, Some("StatusBadge".to_string()));
}
#[test]
fn test_widget_config_default() {
let toml = "";
let config: WidgetConfig = toml::from_str(toml).unwrap();
assert_eq!(config.id, None);
assert_eq!(config.widget_type, None);
assert!(config.bind.is_none());
}
#[test]
fn test_area_config() {
let toml = r#"
x = 10
y = 20
width = 80
height = 24
"#;
let config: AreaConfig = toml::from_str(toml).unwrap();
assert_eq!(config.x, 10);
assert_eq!(config.y, 20);
assert_eq!(config.width, 80);
assert_eq!(config.height, 24);
}
#[test]
fn test_parser_config() {
let toml = r#"
type = "json_key"
key = "status"
"#;
let config: ParserConfig = toml::from_str(toml).unwrap();
assert_eq!(config.parser_type, "json_key");
assert_eq!(config.key, Some("status".to_string()));
}
#[test]
fn test_parser_config_json_path() {
let toml = r#"
type = "json_path"
path = "data.result"
"#;
let config: ParserConfig = toml::from_str(toml).unwrap();
assert_eq!(config.parser_type, "json_path");
assert_eq!(config.path, Some("data.result".to_string()));
}
#[test]
fn test_parser_config_regex() {
let toml = r#"
type = "regex"
pattern = "CPU: (\\d+)"
group = 1
"#;
let config: ParserConfig = toml::from_str(toml).unwrap();
assert_eq!(config.parser_type, "regex");
assert_eq!(config.pattern, Some("CPU: (\\d+)".to_string()));
assert_eq!(config.group, Some(1));
}
#[test]
fn test_layout_config() {
let toml = r#"
header_height = 3
sidebar_width = 25
footer_height = 2
"#;
let config: LayoutConfig = toml::from_str(toml).unwrap();
assert_eq!(config.header_height, Some(3));
assert_eq!(config.sidebar_width, Some(25));
assert_eq!(config.footer_height, Some(2));
}
#[test]
fn test_app_config_layout_only() {
let toml = r#"
title = "Layout Test"
[layout]
header_height = 5
"#;
let config = AppConfig::from_toml_str(toml).unwrap();
assert_eq!(config.title, "Layout Test");
assert!(config.layout.is_some());
}
#[test]
fn test_app_config_widgets_multiple() {
let toml = r#"
title = "Multi Widget"
[[widget]]
id = 1
type = "Label"
label = "First"
[[widget]]
id = 2
type = "Button"
label = "Second"
"#;
let config = AppConfig::from_toml_str(toml).unwrap();
assert_eq!(config.title, "Multi Widget");
}
#[test]
fn test_widget_config_options() {
let toml = r#"
type = "Custom"
[options]
width = 100
height = 50
enabled = true
"#;
let config: WidgetConfig = toml::from_str(toml).unwrap();
assert!(config.options.contains_key("width"));
assert!(config.options.contains_key("height"));
}
#[test]
fn test_app_config_commands() {
let toml = r#"
title = "Command Test"
[[commands]]
command = "dracon-system status --json"
label = "system status"
description = "Get system status"
refresh_seconds = 5
[[commands]]
command = "dracon-sync repos --json"
label = "sync repos"
description = "Get repo status"
refresh_seconds = 10
"#;
let config = AppConfig::from_toml_str(toml).unwrap();
assert_eq!(config.title, "Command Test");
assert_eq!(config.commands.len(), 2);
assert_eq!(config.commands[0].command, "dracon-system status --json");
assert_eq!(config.commands[0].label, "system status");
assert_eq!(config.commands[0].refresh_seconds, Some(5));
assert_eq!(config.commands[1].command, "dracon-sync repos --json");
assert_eq!(config.commands[1].refresh_seconds, Some(10));
}
#[test]
fn test_app_command_tracking_on_add_widget() {
use crate::framework::command::BoundCommand;
let mut app = App::new().unwrap();
let _cmd = BoundCommand::new("echo test").refresh(5);
let label = crate::framework::widgets::Label::new("test");
let _id = app.add_widget(Box::new(label), Rect::new(0, 0, 10, 1));
let tracking = app.command_tracking.borrow();
assert!(tracking.is_empty());
}
#[test]
fn test_app_command_tracking_removed_on_widget_remove() {
use crate::framework::command::BoundCommand;
let mut app = App::new().unwrap();
let _cmd = BoundCommand::new("echo test").refresh(5);
let label = crate::framework::widgets::Label::new("test");
let id = app.add_widget(Box::new(label), Rect::new(0, 0, 10, 1));
app.remove_widget(id);
assert!(app.command_tracking.borrow().is_empty());
}
}