pub mod render;
pub mod state;
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc};
use std::time::{Duration, Instant};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
pub use state::{LogSource, ServiceStatus};
const TICK: Duration = Duration::from_millis(30);
#[derive(Debug)]
pub struct LogEntry {
pub source: LogSource,
pub line: String,
}
#[derive(Debug)]
pub enum TuiEvent {
Log(LogEntry),
Status {
source: LogSource,
status: ServiceStatus,
detail: Option<String>,
},
}
pub type LogTx = mpsc::SyncSender<TuiEvent>;
pub type LogRx = mpsc::Receiver<TuiEvent>;
#[derive(Clone)]
pub struct DevSignals {
pub restart_requested: Arc<AtomicBool>,
pub build_now_requested: Arc<AtomicBool>,
pub quit_requested: Arc<AtomicBool>,
pub shutdown_complete: Arc<AtomicBool>,
}
impl DevSignals {
fn new() -> Self {
Self {
restart_requested: Arc::new(AtomicBool::new(false)),
build_now_requested: Arc::new(AtomicBool::new(false)),
quit_requested: Arc::new(AtomicBool::new(false)),
shutdown_complete: Arc::new(AtomicBool::new(false)),
}
}
pub fn take_restart(&self) -> bool {
self.restart_requested.swap(false, Ordering::SeqCst)
}
pub fn take_build_now(&self) -> bool {
self.build_now_requested.swap(false, Ordering::SeqCst)
}
pub fn should_quit(&self) -> bool {
self.quit_requested.load(Ordering::SeqCst)
}
pub fn mark_shutdown_complete(&self) {
self.shutdown_complete.store(true, Ordering::SeqCst);
}
}
pub fn setup() -> (LogTx, LogRx, DevSignals) {
let (tx, rx) = mpsc::sync_channel(512);
(tx, rx, DevSignals::new())
}
pub fn is_tty() -> bool {
use std::io::IsTerminal;
std::io::stdout().is_terminal()
}
pub fn sys_log(tx: Option<&LogTx>, line: impl Into<String>) {
match tx {
Some(t) => {
let _ = t.send(TuiEvent::Log(LogEntry {
source: LogSource::System,
line: line.into(),
}));
}
None => eprintln!("{}", line.into()),
}
}
pub fn update_status(
tx: Option<&LogTx>,
source: LogSource,
status: ServiceStatus,
detail: Option<String>,
) {
if let Some(t) = tx {
let _ = t.send(TuiEvent::Status {
source,
status,
detail,
});
}
}
pub fn run(rx: LogRx, mut app: state::AppState, signals: DevSignals) -> io::Result<()> {
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = render_loop(&mut terminal, rx, &mut app, &signals);
execute!(
terminal.backend_mut(),
DisableMouseCapture,
LeaveAlternateScreen,
)?;
terminal::disable_raw_mode()?;
terminal.show_cursor()?;
result
}
fn render_loop<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
rx: LogRx,
app: &mut state::AppState,
signals: &DevSignals,
) -> io::Result<()> {
use state::ShutdownPhase;
let mut last_tick = Instant::now();
loop {
loop {
match rx.try_recv() {
Ok(TuiEvent::Log(entry)) => app.push_log(entry),
Ok(TuiEvent::Status {
source,
status,
detail,
}) => app.update_service(source, status, detail),
Err(_) => break,
}
}
if signals.shutdown_complete.load(Ordering::SeqCst) {
app.shutdown_phase = ShutdownPhase::Done;
terminal.draw(|f| render::draw(f, app))?;
return Ok(());
}
terminal.draw(|f| render::draw(f, app))?;
if app.shutdown_phase == ShutdownPhase::ShuttingDown {
if last_tick.elapsed() >= TICK {
last_tick = Instant::now();
}
continue;
}
let remaining = TICK.saturating_sub(last_tick.elapsed());
if event::poll(remaining)? {
match event::read()? {
Event::Key(key) => {
if key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
begin_shutdown(app, signals);
} else if app.search_input_active {
handle_search_input(key, app);
} else {
handle_normal(key, app, signals);
}
}
Event::Mouse(mouse_event) => {
handle_mouse(mouse_event, app);
}
_ => {}
}
}
if app
.copy_flash
.is_some_and(|t| t.elapsed() > std::time::Duration::from_secs(2))
{
app.copy_flash = None;
}
if last_tick.elapsed() >= TICK {
last_tick = Instant::now();
}
}
}
fn begin_shutdown(app: &mut state::AppState, signals: &DevSignals) {
use state::ShutdownPhase;
if app.shutdown_phase != ShutdownPhase::Running {
return;
}
app.shutdown_phase = ShutdownPhase::ShuttingDown;
app.active_pane = LogSource::System;
app.scroll_bottom();
app.clear_search();
signals.quit_requested.store(true, Ordering::SeqCst);
}
fn handle_search_input(key: event::KeyEvent, app: &mut state::AppState) {
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Enter | KeyCode::Esc => app.exit_search_input(),
KeyCode::Backspace => app.search_backspace(),
KeyCode::Up => {
if shift { app.page_up() } else { app.scroll_up() }
}
KeyCode::Down => {
if shift { app.page_down() } else { app.scroll_down() }
}
KeyCode::Char(c) => app.search_push(c),
_ => {}
}
}
fn handle_normal(key: event::KeyEvent, app: &mut state::AppState, signals: &DevSignals) {
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Char('q') => begin_shutdown(app, signals),
KeyCode::Char('/') => app.enter_search(),
KeyCode::Esc => app.clear_search(),
KeyCode::Char('r') => {
signals.restart_requested.store(true, Ordering::SeqCst);
}
KeyCode::Char('b') => {
signals.build_now_requested.store(true, Ordering::SeqCst);
}
KeyCode::Tab => app.cycle_pane(1),
KeyCode::BackTab => app.cycle_pane(-1),
KeyCode::Char('1') => app.active_pane = LogSource::System,
KeyCode::Char('2') => app.active_pane = LogSource::Daemon,
KeyCode::Char('3') => app.active_pane = LogSource::UiServer,
KeyCode::Char('4') => app.active_pane = LogSource::Build,
KeyCode::Char('5') => app.active_pane = LogSource::App,
KeyCode::Up => {
if shift { app.page_up() } else { app.scroll_up() }
}
KeyCode::Down => {
if shift { app.page_down() } else { app.scroll_down() }
}
KeyCode::Char('g') => app.scroll_top(),
KeyCode::Char('G') => app.scroll_bottom(),
KeyCode::Char('c') => app.clear_active_pane(),
_ => {}
}
}
fn copy_to_clipboard(text: &str) -> bool {
use std::io::Write as _;
#[cfg(target_os = "macos")]
{
if let Ok(mut child) = std::process::Command::new("pbcopy")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
return child.wait().map(|s| s.success()).unwrap_or(false);
}
false
}
#[cfg(target_os = "linux")]
{
for (cmd, args) in [
("wl-copy", vec![] as Vec<&str>),
("xclip", vec!["-selection", "clipboard"]),
] {
if let Ok(mut child) = std::process::Command::new(cmd)
.args(&args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
if child.wait().map(|s| s.success()).unwrap_or(false) {
return true;
}
}
}
false
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = text;
false
}
}
fn handle_mouse(mouse: crossterm::event::MouseEvent, app: &mut state::AppState) {
use crossterm::event::{MouseButton, MouseEventKind};
use state::Selection;
let col = mouse.column;
let row = mouse.row;
let layout = app.layout;
let in_log_panel = col >= layout.tab_bar.x;
match mouse.kind {
MouseEventKind::ScrollUp if in_log_panel => {
app.scroll_up();
return;
}
MouseEventKind::ScrollDown if in_log_panel => {
app.scroll_down();
return;
}
_ => {}
}
if row == layout.tab_bar.y
&& col >= layout.tab_bar.x
&& col < layout.tab_bar.x + layout.tab_bar.width
{
let sources = LogSource::all();
for i in 0..sources.len() {
let x_start = layout.tab_edges[i];
let x_end = if i + 1 < sources.len() {
layout.tab_edges[i + 1]
} else {
u16::MAX
};
if col >= x_start && col < x_end {
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
app.active_pane = sources[i];
app.auto_scroll = true;
app.clear_search();
app.selection = None;
}
return;
}
}
return;
}
let sl = layout.service_list;
if col >= sl.x && col < sl.x + sl.width && row >= sl.y && row < sl.y + sl.height {
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && row > sl.y {
let row_in_items = (row - sl.y - 1) as usize;
let idx = row_in_items / 3;
let sources = LogSource::all();
if idx < sources.len() {
app.active_pane = sources[idx];
app.auto_scroll = true;
app.clear_search();
app.selection = None;
}
}
return;
}
let ls = layout.log_scroll;
if col < ls.x || col >= ls.x + ls.width || row < ls.y || row >= ls.y + ls.height {
return;
}
if !app.search_query.is_empty() {
return;
}
let row_in_area = (row - ls.y) as usize;
let total = app.log_lines(app.active_pane).len();
let line_idx = app.log_scroll_start + row_in_area;
if line_idx >= total {
return;
}
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
app.selection = Some(Selection { anchor: line_idx, head: line_idx });
app.auto_scroll = false;
if app.scroll_pos == 0 {
app.scroll_pos = app.log_scroll_start;
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(sel) = app.selection.as_mut() {
sel.head = line_idx;
}
}
MouseEventKind::Up(MouseButton::Left) => {
if let Some(sel) = app.selection {
let text = app.get_selected_text();
if !text.is_empty() && copy_to_clipboard(&text) {
app.copy_flash = Some(std::time::Instant::now());
}
let _ = sel;
}
}
_ => {}
}
}