arora 9.6.0

Opinionated Arora runtime: an engine pre-wired with the native behavior-tree control nodes and the Semio backend.
//! The terminal operator UI: a device header, a scrollable log pane, a strip of
//! live indicators, and the prompt line the operator answers questions on.
//!
//! `State` holds everything shown and all the input logic (drawing-free, so it
//! is unit-tested on its own); `view` renders a frame from an immutable
//! `&State`. This module is the moving part around them: it owns the `State`
//! behind a shared mutex, runs the terminal in a background thread (input,
//! periodic ticks, an own-process CPU sample, and drawing), and captures the
//! `log` crate into the pane. [`Tui`](crate::tui::Tui) is also an
//! [`Operator`](crate::operator::Operator): it asks the
//! operator by pushing a `Prompt` and awaiting its reply, so a question raised
//! anywhere in the runtime appears in the prompt line.

mod state;
mod view;

use std::io::{self, Stdout};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};

use async_trait::async_trait;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{
    self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind,
};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::Terminal;
use sysinfo::{ProcessesToUpdate, System};

use arora_bridge::{AccessDecision, DeviceInfo};

use futures::channel::mpsc::{self, UnboundedReceiver, UnboundedSender};

use crate::operator::{
    AccessRequestSummary, AccessRuling, Frontend, Operator, DEFAULT_ACCESS_GRACE,
};
use arora_types::data::Subscription;
use state::{DeviceIdentity, State};

/// The shared UI state: the render thread, the input handling, the log capture,
/// and the operator prompts all touch this one instance.
type SharedState = Arc<Mutex<State>>;

/// A key-driven command an embedding application adds to the terminal UI
/// (via [`commands_frontend`]): the footer advertises it, and pressing its key
/// outside a prompt fires it.
pub struct TuiCommand {
    /// The key that triggers the command. `q` is the quit shortcut and cannot
    /// trigger a command.
    pub key: char,
    /// What the footer shows next to the key, e.g. `load GLB`.
    pub label: String,
    /// When set, the key opens a text prompt with this label and the command
    /// fires with what the operator types (an empty answer cancels it). When
    /// `None`, the command fires immediately.
    pub prompt: Option<String>,
}

/// A fired [`TuiCommand`]: its trigger key, and the prompt answer (`None` for
/// a prompt-less command).
#[derive(Debug, PartialEq, Eq)]
pub struct TuiCommandEvent {
    pub key: char,
    pub input: Option<String>,
}

/// Pick the standard front end — the terminal UI on an interactive terminal,
/// the headless front end otherwise — with `commands` installed when the
/// terminal UI is the pick. The receiver yields one event per fired command;
/// on the headless front end it never yields (its sender side is dropped —
/// nobody types where nobody watches).
pub fn commands_frontend(
    commands: Vec<TuiCommand>,
) -> (Frontend, UnboundedReceiver<TuiCommandEvent>) {
    let (tx, rx) = mpsc::unbounded();
    {
        use std::io::IsTerminal;
        if io::stdout().is_terminal() {
            match tui_frontend_with_commands(commands, Some(tx)) {
                Ok(frontend) => return (frontend, rx),
                Err(e) => eprintln!("arora: terminal UI unavailable ({e}); running headless"),
            }
        }
    }
    (crate::operator::default_frontend(), rx)
}

/// A live terminal operator UI. It owns the render thread and restores the
/// terminal when dropped; as an [`Operator`] it renders questions in the
/// prompt line and resolves them from the reply typed there.
pub struct Tui {
    state: SharedState,
    running: Arc<AtomicBool>,
    restored: Arc<AtomicBool>,
    ui: Mutex<Option<JoinHandle<()>>>,
}

impl Tui {
    /// Take over the terminal: install the log capture, enter the alternate
    /// screen, and spawn the render/input thread. `commands` (with their event
    /// sender) are the embedding application's — empty for the plain device UI.
    fn start(
        commands: Vec<TuiCommand>,
        command_tx: Option<UnboundedSender<TuiCommandEvent>>,
    ) -> anyhow::Result<Self> {
        let mut initial = State::new();
        initial.commands = commands;
        initial.command_tx = command_tx;
        let state: SharedState = Arc::new(Mutex::new(initial));
        install_logger(state.clone());
        let terminal = setup_terminal()?;
        let running = Arc::new(AtomicBool::new(true));
        let restored = Arc::new(AtomicBool::new(false));
        let ui = std::thread::Builder::new()
            .name("arora-tui".to_string())
            .spawn({
                let state = state.clone();
                let running = running.clone();
                let restored = restored.clone();
                move || run_ui(terminal, state, running, restored)
            })?;
        Ok(Self {
            state,
            running,
            restored,
            ui: Mutex::new(Some(ui)),
        })
    }
}

#[async_trait]
impl Operator for Tui {
    async fn ask_text(&self, label: &str, required: bool) -> Option<String> {
        let (question, reply) = state::text_prompt(label.to_string(), required);
        if let Ok(mut state) = self.state.lock() {
            state.prompts.push_back(question);
        }
        reply.await.ok().flatten()
    }

    async fn decide_access(&self, request: &AccessRequestSummary) -> AccessRuling {
        let options = vec![
            "Allow".to_string(),
            "Reject".to_string(),
            "Always allow".to_string(),
            "Always reject".to_string(),
        ];
        // Default to allowing after the grace period, matching the unattended
        // operator's fallback when the operator does not answer in time.
        let (question, reply) =
            state::decision_prompt(request.describe(), options, Some((DEFAULT_ACCESS_GRACE, 0)));
        if let Ok(mut state) = self.state.lock() {
            state.prompts.push_back(question);
        }
        match reply.await.unwrap_or(0) {
            1 => AccessRuling {
                decision: AccessDecision::Rejected,
                remember: false,
            },
            2 => AccessRuling {
                decision: AccessDecision::Allowed,
                remember: true,
            },
            3 => AccessRuling {
                decision: AccessDecision::Rejected,
                remember: true,
            },
            _ => AccessRuling {
                decision: AccessDecision::Allowed,
                remember: false,
            },
        }
    }
}

impl Drop for Tui {
    fn drop(&mut self) {
        self.running.store(false, Ordering::SeqCst);
        if let Some(handle) = self.ui.lock().ok().and_then(|mut ui| ui.take()) {
            let _ = handle.join();
        }
        restore_terminal(&self.restored);
    }
}

/// Build a [`Frontend`] driven by the terminal UI: the [`Tui`] answers the
/// operator's questions, and the ready hook feeds it the live telemetry handle
/// and the device identity once the runtime and bridge are up.
pub(crate) fn tui_frontend() -> anyhow::Result<Frontend> {
    tui_frontend_with_commands(Vec::new(), None)
}

/// [`tui_frontend`] with an embedding application's commands installed — the
/// terminal-UI arm of [`commands_frontend`].
fn tui_frontend_with_commands(
    commands: Vec<TuiCommand>,
    command_tx: Option<UnboundedSender<TuiCommandEvent>>,
) -> anyhow::Result<Frontend> {
    let tui = Arc::new(Tui::start(commands, command_tx)?);
    let state = tui.state.clone();
    let operator: Arc<dyn Operator> = tui.clone();
    let on_ready = Box::new(
        move |device_state: Subscription, info: Option<DeviceInfo>, device_id: Option<String>| {
            if let Ok(mut state) = state.lock() {
                state.device_state = Some(device_state);
                state.identity = identity_from(info, device_id);
            }
        },
    );
    Ok(Frontend {
        operator,
        interactive: true,
        on_ready,
    })
}

/// The header identity, from what the bridge knows about the device.
fn identity_from(info: Option<DeviceInfo>, device_id: Option<String>) -> DeviceIdentity {
    DeviceIdentity {
        name: info.as_ref().and_then(|i| i.name.clone()),
        device_id,
        model_family: info.as_ref().and_then(|i| i.model_family.clone()),
        owners: info.map(|i| i.owners).unwrap_or_default(),
    }
}

/// The render/input thread: drain terminal events into the state, advance
/// deadline-driven prompts, sample own-process CPU, and draw — until the handle
/// is dropped or the operator asks to quit.
fn run_ui(
    mut terminal: Terminal<CrosstermBackend<Stdout>>,
    state: SharedState,
    running: Arc<AtomicBool>,
    restored: Arc<AtomicBool>,
) {
    let mut system = System::new();
    let pid = sysinfo::get_current_pid().ok();
    // Backdate so the first pass takes a CPU sample immediately.
    let mut last_cpu_sample = Instant::now()
        .checked_sub(Duration::from_secs(1))
        .unwrap_or_else(Instant::now);
    let mut quit = false;

    while running.load(Ordering::SeqCst) {
        // Terminal events (the poll timeout also paces the redraw).
        if event::poll(Duration::from_millis(100)).unwrap_or(false) {
            if let Ok(event) = event::read() {
                if let Ok(mut state) = state.lock() {
                    match event {
                        Event::Key(key) if key.kind == KeyEventKind::Press => state.handle_key(key),
                        Event::Mouse(mouse) => state.handle_mouse(mouse),
                        _ => {}
                    }
                }
            }
        }

        // Own-process CPU, sampled about once a second.
        if last_cpu_sample.elapsed() >= Duration::from_secs(1) {
            let cpu = pid.and_then(|pid| {
                system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
                system.process(pid).map(|process| process.cpu_usage())
            });
            last_cpu_sample = Instant::now();
            if let Ok(mut state) = state.lock() {
                state.cpu_percent = cpu;
            }
        }

        let now = Instant::now();
        if let Ok(mut state) = state.lock() {
            state.read_device_state();
            state.tick(now);
            if state.quit_requested {
                quit = true;
                break;
            }
            let size = terminal.size().unwrap_or_default();
            state.viewport_height =
                view::log_height(size, state.prompts.front().is_some()) as usize;
            let _ = terminal.draw(|frame| view::draw(frame, &state, now));
        }
    }

    restore_terminal(&restored);
    if quit {
        // The operator asked to quit. The synchronous step loop on the main
        // thread does not return on its own, so end the process now that the
        // terminal is back to its normal state.
        std::process::exit(0);
    }
}

/// The pane the global logger currently feeds. The `log` crate's logger can
/// only ever be set once per process, so the logger itself is a fixed shim
/// over this slot — each terminal UI (a host may run several in sequence,
/// rebuilding its device) retargets the slot to its own state.
static PANE: std::sync::RwLock<Option<(SharedState, env_filter::Filter)>> =
    std::sync::RwLock::new(None);

/// Capture the `log` crate into the pane, filtered the `env_logger` way
/// (`RUST_LOG`, defaulting to `info`).
fn install_logger(state: SharedState) {
    let mut builder = env_filter::Builder::new();
    match std::env::var("RUST_LOG") {
        Ok(directives) => {
            builder.parse(&directives);
        }
        Err(_) => {
            builder.filter_level(log::LevelFilter::Info);
        }
    }
    let filter = builder.build();
    log::set_max_level(filter.filter());
    if let Ok(mut pane) = PANE.write() {
        *pane = Some((state, filter));
    }
    let _ = log::set_boxed_logger(Box::new(TuiLogger));
}

/// A `log::Log` that appends matching records to the active pane's state.
struct TuiLogger;

impl log::Log for TuiLogger {
    fn enabled(&self, metadata: &log::Metadata) -> bool {
        let pane = PANE.read().expect("pane lock");
        matches!(&*pane, Some((_, filter)) if filter.enabled(metadata))
    }

    fn log(&self, record: &log::Record) {
        let Ok(pane) = PANE.read() else { return };
        if let Some((state, filter)) = &*pane {
            if !filter.matches(record) {
                return;
            }
            if let Ok(mut state) = state.lock() {
                state.push_log(
                    record.level(),
                    record.target().to_string(),
                    record.args().to_string(),
                );
            }
        }
    }

    fn flush(&self) {}
}

/// Enter raw mode + the alternate screen with mouse capture.
fn setup_terminal() -> anyhow::Result<Terminal<CrosstermBackend<Stdout>>> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    Ok(Terminal::new(CrosstermBackend::new(stdout))?)
}

/// Leave the alternate screen and drop raw mode. Idempotent: only the first call
/// restores, so the render thread and the handle's `Drop` can both call it.
fn restore_terminal(restored: &AtomicBool) {
    if restored.swap(true, Ordering::SeqCst) {
        return;
    }
    let _ = disable_raw_mode();
    let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
}