use std::collections::VecDeque;
use std::time::{Duration, Instant, SystemTime};
use futures::channel::oneshot;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use crate::runtime::Telemetry;
pub(crate) const LOG_CAPACITY: usize = 4000;
const WHEEL_LINES: usize = 3;
#[derive(Debug, Clone, Default)]
pub struct DeviceIdentity {
pub name: Option<String>,
pub device_id: Option<String>,
pub model_family: Option<String>,
pub owners: Vec<String>,
}
pub(crate) struct LogLine {
pub epoch_secs: u64,
pub level: log::Level,
pub target: String,
pub message: String,
}
pub(crate) enum Prompt {
Text {
label: String,
required: bool,
tx: Option<oneshot::Sender<Option<String>>>,
},
Decision {
message: String,
options: Vec<String>,
selected: usize,
deadline: Option<(Instant, usize)>,
tx: Option<oneshot::Sender<usize>>,
},
}
impl Prompt {
fn resolve_text(&mut self, answer: Option<String>) {
if let Prompt::Text { tx, .. } = self {
if let Some(tx) = tx.take() {
let _ = tx.send(answer);
}
}
}
fn resolve_decision(&mut self, choice: usize) {
if let Prompt::Decision { tx, .. } = self {
if let Some(tx) = tx.take() {
let _ = tx.send(choice);
}
}
}
}
pub(crate) struct State {
pub logs: VecDeque<LogLine>,
pub scroll_from_bottom: usize,
pub viewport_height: usize,
pub identity: DeviceIdentity,
pub telemetry: Option<Telemetry>,
pub cpu_percent: Option<f32>,
pub prompts: VecDeque<Prompt>,
pub input: String,
pub hint: Option<String>,
pub quit_requested: bool,
}
impl State {
pub fn new() -> Self {
Self {
logs: VecDeque::new(),
scroll_from_bottom: 0,
viewport_height: 20,
identity: DeviceIdentity::default(),
telemetry: None,
cpu_percent: None,
prompts: VecDeque::new(),
input: String::new(),
hint: None,
quit_requested: false,
}
}
pub fn push_log(&mut self, level: log::Level, target: String, message: String) {
let epoch_secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut lines = message.lines();
let first = lines.next().unwrap_or("").to_string();
self.push_log_line(LogLine {
epoch_secs,
level,
target,
message: first,
});
for line in lines {
self.push_log_line(LogLine {
epoch_secs,
level,
target: String::new(),
message: line.to_string(),
});
}
}
fn push_log_line(&mut self, line: LogLine) {
self.logs.push_back(line);
if self.logs.len() > LOG_CAPACITY {
self.logs.pop_front();
} else if self.scroll_from_bottom > 0 {
self.scroll_from_bottom = (self.scroll_from_bottom + 1).min(self.max_scroll());
}
}
fn max_scroll(&self) -> usize {
self.logs.len().saturating_sub(1)
}
fn scroll_up(&mut self, lines: usize) {
self.scroll_from_bottom = (self.scroll_from_bottom + lines).min(self.max_scroll());
}
fn scroll_down(&mut self, lines: usize) {
self.scroll_from_bottom = self.scroll_from_bottom.saturating_sub(lines);
}
fn finish_front_prompt(&mut self) {
self.prompts.pop_front();
self.input.clear();
self.hint = None;
}
pub fn tick(&mut self, now: Instant) {
if let Some(Prompt::Decision {
deadline: Some((deadline, default)),
..
}) = self.prompts.front()
{
if now >= *deadline {
let default = *default;
self.prompts
.front_mut()
.expect("front exists")
.resolve_decision(default);
self.finish_front_prompt();
}
}
}
pub fn handle_mouse(&mut self, event: MouseEvent) {
match event.kind {
MouseEventKind::ScrollUp => self.scroll_up(WHEEL_LINES),
MouseEventKind::ScrollDown => self.scroll_down(WHEEL_LINES),
_ => {}
}
}
pub fn handle_key(&mut self, key: KeyEvent) {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
self.quit_requested = true;
return;
}
match key.code {
KeyCode::PageUp => self.scroll_up(self.viewport_height.max(1)),
KeyCode::PageDown => self.scroll_down(self.viewport_height.max(1)),
KeyCode::Home => self.scroll_up(self.logs.len()),
KeyCode::End => self.scroll_from_bottom = 0,
_ => self.handle_prompt_key(key),
}
}
fn handle_prompt_key(&mut self, key: KeyEvent) {
match self.prompts.front_mut() {
None => {
if key.code == KeyCode::Char('q') {
self.quit_requested = true;
}
}
Some(Prompt::Text { required, .. }) => {
let required = *required;
match key.code {
KeyCode::Char(c) => {
self.input.push(c);
self.hint = None;
}
KeyCode::Backspace => {
self.input.pop();
}
KeyCode::Enter => {
let answer = self.input.trim().to_string();
if answer.is_empty() && required {
self.hint = Some("an answer is required".to_string());
} else {
let answer = (!answer.is_empty()).then_some(answer);
self.prompts
.front_mut()
.expect("front exists")
.resolve_text(answer);
self.finish_front_prompt();
}
}
_ => {}
}
}
Some(Prompt::Decision {
options, selected, ..
}) => {
let count = options.len().max(1);
let shortcut = |c: char| {
options
.iter()
.position(|o| o.chars().next().is_some_and(|f| f.eq_ignore_ascii_case(&c)))
};
match key.code {
KeyCode::Left => *selected = selected.checked_sub(1).unwrap_or(count - 1),
KeyCode::Right => *selected = (*selected + 1) % count,
KeyCode::Char(c) if c.is_ascii_digit() => {
let idx = (c as usize).wrapping_sub('1' as usize);
if idx < count {
*selected = idx;
}
}
KeyCode::Char(c) => {
if let Some(idx) = shortcut(c) {
self.prompts
.front_mut()
.expect("front exists")
.resolve_decision(idx);
self.finish_front_prompt();
}
}
KeyCode::Enter => {
let choice = *selected;
self.prompts
.front_mut()
.expect("front exists")
.resolve_decision(choice);
self.finish_front_prompt();
}
_ => {}
}
}
}
}
pub fn shortcuts(&self, now: Instant) -> String {
if let Some(hint) = &self.hint {
return format!("! {hint}");
}
match self.prompts.front() {
None => "wheel/PgUp/PgDn: scroll logs · End: follow · q / Ctrl-C: quit".to_string(),
Some(Prompt::Text { required, .. }) => {
if *required {
"type the answer · Enter: submit".to_string()
} else {
"type the answer · Enter: submit (empty: skip)".to_string()
}
}
Some(Prompt::Decision {
deadline, options, ..
}) => {
let mut s = "←/→ or 1-9: choose · Enter: confirm".to_string();
if let Some((deadline, default)) = deadline {
let remaining = deadline.saturating_duration_since(now);
s.push_str(&format!(
" · auto \"{}\" in {}s",
options.get(*default).map(String::as_str).unwrap_or("?"),
remaining.as_secs()
));
}
s
}
}
}
}
pub(crate) fn decision_prompt(
message: String,
options: Vec<String>,
default_after: Option<(Duration, usize)>,
) -> (Prompt, oneshot::Receiver<usize>) {
let (tx, rx) = oneshot::channel();
let deadline = default_after.map(|(after, default)| (Instant::now() + after, default));
(
Prompt::Decision {
message,
options,
selected: deadline.map(|(_, d)| d).unwrap_or(0),
deadline,
tx: Some(tx),
},
rx,
)
}
pub(crate) fn text_prompt(
label: String,
required: bool,
) -> (Prompt, oneshot::Receiver<Option<String>>) {
let (tx, rx) = oneshot::channel();
(
Prompt::Text {
label,
required,
tx: Some(tx),
},
rx,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn fill_logs(state: &mut State, n: usize) {
for i in 0..n {
state.push_log(log::Level::Info, "test".into(), format!("line {i}"));
}
}
#[test]
fn scrolling_clamps_to_the_buffer_and_follows_on_end() {
let mut state = State::new();
fill_logs(&mut state, 10);
state.viewport_height = 4;
state.handle_key(key(KeyCode::PageUp));
assert_eq!(state.scroll_from_bottom, 4);
state.handle_key(key(KeyCode::Home));
assert_eq!(state.scroll_from_bottom, 9, "clamped to len - 1");
state.handle_key(key(KeyCode::End));
assert_eq!(state.scroll_from_bottom, 0);
}
#[test]
fn appending_logs_keeps_a_scrolled_view_stable() {
let mut state = State::new();
fill_logs(&mut state, 10);
state.scroll_from_bottom = 5;
state.push_log(log::Level::Info, "t".into(), "new".into());
assert_eq!(state.scroll_from_bottom, 6, "view did not slide");
}
#[test]
fn text_prompt_requires_an_answer_when_required() {
let mut state = State::new();
let (prompt, mut rx) = text_prompt("Device name".into(), true);
state.prompts.push_back(prompt);
state.handle_key(key(KeyCode::Enter));
assert!(state.hint.is_some(), "empty answer rejected");
assert!(rx.try_recv().unwrap().is_none(), "not resolved yet");
for c in "Robo".chars() {
state.handle_key(key(KeyCode::Char(c)));
}
state.handle_key(key(KeyCode::Enter));
assert_eq!(rx.try_recv().unwrap(), Some(Some("Robo".into())));
assert!(state.prompts.is_empty());
}
#[test]
fn optional_text_prompt_skips_on_empty_enter() {
let mut state = State::new();
let (prompt, mut rx) = text_prompt("Description".into(), false);
state.prompts.push_back(prompt);
state.handle_key(key(KeyCode::Enter));
assert_eq!(rx.try_recv().unwrap(), Some(None));
}
#[test]
fn decision_prompt_resolves_by_arrows_enter_and_shortcut() {
let mut state = State::new();
let options = vec!["Allow".to_string(), "Reject".to_string()];
let (prompt, mut rx) = decision_prompt("join?".into(), options.clone(), None);
state.prompts.push_back(prompt);
state.handle_key(key(KeyCode::Right));
state.handle_key(key(KeyCode::Enter));
assert_eq!(rx.try_recv().unwrap(), Some(1), "arrow moved to Reject");
let (prompt, mut rx) = decision_prompt("join?".into(), options, None);
state.prompts.push_back(prompt);
state.handle_key(key(KeyCode::Char('r')));
assert_eq!(rx.try_recv().unwrap(), Some(1), "shortcut letter resolves");
}
#[test]
fn decision_prompt_times_out_to_its_default() {
let mut state = State::new();
let (prompt, mut rx) = decision_prompt(
"join?".into(),
vec!["Allow".into(), "Reject".into()],
Some((Duration::from_secs(10), 0)),
);
state.prompts.push_back(prompt);
state.tick(Instant::now());
assert!(rx.try_recv().unwrap().is_none(), "not expired yet");
state.tick(Instant::now() + Duration::from_secs(11));
assert_eq!(rx.try_recv().unwrap(), Some(0), "timed out to Allow");
assert!(state.prompts.is_empty());
}
#[test]
fn quit_needs_ctrl_c_or_q_outside_prompts() {
let mut state = State::new();
let (prompt, _rx) = text_prompt("Device name".into(), true);
state.prompts.push_back(prompt);
state.handle_key(key(KeyCode::Char('q')));
assert!(!state.quit_requested, "q types into the prompt");
assert_eq!(state.input, "q");
state.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(state.quit_requested, "Ctrl-C always quits");
let mut state = State::new();
state.handle_key(key(KeyCode::Char('q')));
assert!(state.quit_requested, "q quits outside prompts");
}
#[test]
fn prompts_queue_one_at_a_time() {
let mut state = State::new();
let (p1, mut rx1) = text_prompt("first".into(), false);
let (p2, mut rx2) = text_prompt("second".into(), false);
state.prompts.push_back(p1);
state.prompts.push_back(p2);
state.handle_key(key(KeyCode::Char('a')));
state.handle_key(key(KeyCode::Enter));
assert_eq!(rx1.try_recv().unwrap(), Some(Some("a".into())));
assert!(rx2.try_recv().unwrap().is_none(), "second still pending");
assert_eq!(state.prompts.len(), 1);
}
}