use crate::dev::termination::{classify_key, ScreenOutcome, TerminationIntent};
use crate::ui::LogBuffer;
use anyhow::Result;
use crossterm::{
event::{
Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement, EnterAlternateScreen, LeaveAlternateScreen,
},
};
use futures_util::StreamExt;
use mecha10_core::context::Context;
use mecha10_core::prelude::now_micros;
use mecha10_core::teleop::TeleopInput;
use mecha10_core::topics::Topic;
use ratatui::{
backend::CrosstermBackend,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame, Terminal,
};
use std::collections::HashMap;
use std::io::stdout;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::ui::tui_layout::{draw_dashboard_footer, SidebarTui};
const TELEOP_CMD: Topic<TeleopInput> = Topic::new("/teleop/cmd");
const HELD_WINDOW: Duration = Duration::from_millis(300);
const REPUBLISH_INTERVAL: Duration = Duration::from_millis(75);
const JOINT_STEP_RAD: f32 = 0.05;
#[derive(Debug, Clone, Copy)]
struct HeldKeyTracker {
last_event: Option<Instant>,
window: Duration,
}
impl HeldKeyTracker {
fn new(window: Duration) -> Self {
Self {
last_event: None,
window,
}
}
fn mark_pressed_at(&mut self, now: Instant) {
self.last_event = Some(now);
}
fn is_held_at(&self, now: Instant) -> bool {
self.last_event.is_some_and(|t| now.duration_since(t) < self.window)
}
fn clear(&mut self) {
self.last_event = None;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MovementKeyAction {
Activate,
Release,
}
fn classify_movement_key(kind: KeyEventKind) -> MovementKeyAction {
match kind {
KeyEventKind::Release => MovementKeyAction::Release,
KeyEventKind::Press | KeyEventKind::Repeat => MovementKeyAction::Activate,
}
}
fn teleop_key_binding_key(key: &KeyEvent) -> Option<String> {
match key.code {
KeyCode::Char(c) => Some(c.to_ascii_lowercase().to_string()),
KeyCode::Up => Some("up".to_string()),
KeyCode::Down => Some("down".to_string()),
KeyCode::Left => Some("left".to_string()),
KeyCode::Right => Some("right".to_string()),
_ => None,
}
}
pub struct TeleopUi {
redis_url: String,
log_buffer: LogBuffer,
max_linear_vel: f32,
max_angular_vel: f32,
priority: u8,
current_linear: f32,
current_angular: f32,
command_count: u64,
last_command: Option<Instant>,
scroll_offset: usize,
should_quit: bool,
dashboard_url: String,
held_tracker: HeldKeyTracker,
joint_names: Vec<String>,
joint_positions: Vec<f32>,
selected_joint: usize,
key_bindings: HashMap<String, String>,
}
impl TeleopUi {
pub fn new(
redis_url: String,
log_buffer: LogBuffer,
dashboard_url: String,
max_linear_vel: f32,
max_angular_vel: f32,
joint_names: Vec<String>,
key_bindings: HashMap<String, String>,
) -> Self {
let joint_positions = vec![0.0; joint_names.len()];
Self {
redis_url,
log_buffer,
max_linear_vel,
max_angular_vel,
priority: 50,
current_linear: 0.0,
current_angular: 0.0,
command_count: 0,
last_command: None,
scroll_offset: 0,
should_quit: false,
dashboard_url,
held_tracker: HeldKeyTracker::new(HELD_WINDOW),
joint_names,
joint_positions,
selected_joint: 0,
key_bindings,
}
}
fn is_actuator_mode(&self) -> bool {
!self.joint_names.is_empty()
}
fn bound_action(&self, key: &KeyEvent) -> Option<String> {
teleop_key_binding_key(key).and_then(|k| self.key_bindings.get(&k).cloned())
}
fn nudge_joint_by_name(&mut self, name: &str, delta: f32) {
if let Some(index) = self.joint_names.iter().position(|n| n == name) {
if let Some(position) = self.joint_positions.get_mut(index) {
*position += delta;
}
}
}
pub async fn run_with_interrupt(&mut self, running: Arc<AtomicBool>) -> Result<ScreenOutcome> {
std::env::set_var("REDIS_URL", &self.redis_url);
let ctx = Context::new("cli_teleop_tui").await?;
enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen)?;
let keyboard_enhancement_enabled = supports_keyboard_enhancement().unwrap_or(false);
if keyboard_enhancement_enabled {
execute!(
stdout,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)
)?;
}
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let outcome_result = self.event_loop(&mut terminal, &ctx, &running).await;
self.send_stop(&ctx).await?;
if keyboard_enhancement_enabled {
execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags)?;
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
outcome_result
}
async fn event_loop(
&mut self,
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
ctx: &Context,
running: &Arc<AtomicBool>,
) -> Result<ScreenOutcome> {
let mut events = EventStream::new();
let mut republish_ticker = tokio::time::interval(REPUBLISH_INTERVAL);
republish_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut outcome = ScreenOutcome::ReturnToParent;
loop {
if !running.load(Ordering::SeqCst) {
outcome = ScreenOutcome::QuitAll;
break;
}
if self.should_quit {
break;
}
terminal.draw(|f| self.draw(f))?;
tokio::select! {
maybe_event = events.next() => {
match maybe_event {
Some(Ok(Event::Key(key))) => {
match self.handle_key(key, ctx).await? {
ScreenOutcome::Continue => {}
terminal_outcome => {
outcome = terminal_outcome;
self.should_quit = true;
}
}
}
Some(Ok(_)) => {}
Some(Err(err)) => return Err(err.into()),
None => {
self.should_quit = true;
}
}
}
_ = republish_ticker.tick() => {
self.republish_if_held(ctx).await?;
}
}
}
Ok(outcome)
}
async fn republish_if_held(&mut self, ctx: &Context) -> Result<()> {
if self.held_tracker.is_held_at(Instant::now()) {
if self.is_actuator_mode() {
self.send_joint_positions(ctx).await?;
} else {
self.send_velocity(ctx).await?;
}
}
Ok(())
}
fn draw_teleop_panel(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let text = if self.is_actuator_mode() {
self.actuator_panel_lines()
} else {
self.drivetrain_panel_lines()
};
let paragraph = Paragraph::new(text).block(
Block::default()
.borders(Borders::ALL)
.title(" Teleop Controls ")
.border_style(Style::default().fg(Color::Cyan)),
);
f.render_widget(paragraph, area);
}
fn drivetrain_panel_lines(&self) -> Vec<Line<'static>> {
let status_color = if self.current_linear == 0.0 && self.current_angular == 0.0 {
Color::Gray
} else {
Color::Green
};
vec![
Line::from(vec![Span::styled(
"🎮 TELEOPERATION CONTROL",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from("Controls:"),
Line::from(" ↑ / W Forward"),
Line::from(" ↓ / S Backward"),
Line::from(" ← / A Turn Left"),
Line::from(" → / D Turn Right"),
Line::from(" SPACE Stop"),
Line::from(" E Emergency Stop"),
Line::from(""),
Line::from("Navigation:"),
Line::from(" j/k Scroll logs (or PgUp/PgDn)"),
Line::from(""),
Line::from(vec![Span::styled(
" Q / ESC / X Exit",
Style::default().fg(Color::Yellow),
)]),
Line::from(""),
Line::from("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"),
Line::from(""),
Line::from(vec![Span::styled(
"Status:",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::raw(" Linear: "),
Span::styled(format!("{:.2}", self.current_linear), Style::default().fg(status_color)),
Span::raw(" m/s"),
]),
Line::from(vec![
Span::raw(" Angular: "),
Span::styled(
format!("{:.2}", self.current_angular),
Style::default().fg(status_color),
),
Span::raw(" rad/s"),
]),
Line::from(""),
Line::from(vec![
Span::raw(" Commands: "),
Span::styled(self.command_count.to_string(), Style::default().fg(Color::Cyan)),
]),
]
}
fn actuator_panel_lines(&self) -> Vec<Line<'static>> {
let mut text = vec![
Line::from(vec![Span::styled(
"🎮 TELEOPERATION CONTROL (actuator)",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from("Controls:"),
Line::from(" ↑ / W Nudge selected joint +"),
Line::from(" ↓ / S Nudge selected joint -"),
Line::from(" ← / A, → / D Select joint"),
Line::from(" E Emergency Stop"),
Line::from(""),
Line::from("Navigation:"),
Line::from(" j/k Scroll logs (or PgUp/PgDn)"),
Line::from(""),
Line::from(vec![Span::styled(
" Q / ESC / X Exit",
Style::default().fg(Color::Yellow),
)]),
Line::from(""),
Line::from("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"),
Line::from(""),
Line::from(vec![Span::styled(
"Joints:",
Style::default().add_modifier(Modifier::BOLD),
)]),
];
for (i, name) in self.joint_names.iter().enumerate() {
let position = self.joint_positions.get(i).copied().unwrap_or(0.0);
let is_selected = i == self.selected_joint;
let marker = if is_selected { "▶ " } else { " " };
let color = if is_selected { Color::Green } else { Color::White };
text.push(Line::from(vec![
Span::raw(marker),
Span::styled(name.clone(), Style::default().fg(color)),
Span::raw(": "),
Span::styled(format!("{:.2} rad", position), Style::default().fg(color)),
]));
}
text.push(Line::from(""));
text.push(Line::from(vec![
Span::raw(" Commands: "),
Span::styled(self.command_count.to_string(), Style::default().fg(Color::Cyan)),
]));
text
}
fn draw_logs_panel(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let logs = self.log_buffer.get_all();
let items: Vec<ListItem> = logs
.iter()
.skip(self.scroll_offset)
.map(|log| {
let (style, icon) = if log.contains("ERROR") {
(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), "❌")
} else if log.contains("WARN") {
(Style::default().fg(Color::Yellow), "⚠️ ")
} else if log.contains("MODE:") {
(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), "⚡")
} else if log.contains("STARTED") || log.contains("✅") {
(Style::default().fg(Color::Green), "✅")
} else if log.contains("STOPPED") || log.contains("🛑") {
(Style::default().fg(Color::Red), "🛑")
} else if log.contains("INFO") {
(Style::default().fg(Color::Green), "ℹ️ ")
} else if log.contains("DEBUG") {
(Style::default().fg(Color::Blue), "🔍")
} else {
(Style::default().fg(Color::White), " ")
};
let formatted_log = format!("{} {}", icon, log);
ListItem::new(formatted_log).style(style)
})
.collect();
let title = if logs.is_empty() {
" System Logs (waiting...) ".to_string()
} else {
format!(" System Logs ({}) ", logs.len())
};
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::Yellow)),
);
f.render_widget(list, area);
}
async fn handle_key(&mut self, key: KeyEvent, ctx: &Context) -> Result<ScreenOutcome> {
if let Some(intent) = classify_key(&key) {
return Ok(match intent {
TerminationIntent::StopCurrent => ScreenOutcome::ReturnToParent,
TerminationIntent::QuitAll => ScreenOutcome::QuitAll,
});
}
if self.is_actuator_mode() {
return self.handle_actuator_key(key, ctx).await;
}
if let Some(action) = self.bound_action(&key) {
return self.handle_drivetrain_action(&action, key, ctx).await;
}
match key.code {
KeyCode::PageUp | KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll_offset = self.scroll_offset.saturating_sub(10);
}
KeyCode::PageDown | KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let logs = self.log_buffer.get_all();
self.scroll_offset = (self.scroll_offset + 10).min(logs.len().saturating_sub(1));
}
KeyCode::Char('k') if key.modifiers.is_empty() => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
KeyCode::Char('j') if key.modifiers.is_empty() => {
let logs = self.log_buffer.get_all();
self.scroll_offset = (self.scroll_offset + 1).min(logs.len().saturating_sub(1));
}
KeyCode::Char('w') | KeyCode::Up if key.modifiers.is_empty() || key.code == KeyCode::Up => {
match classify_movement_key(key.kind) {
MovementKeyAction::Activate => {
self.current_linear = self.max_linear_vel;
self.current_angular = 0.0;
self.held_tracker.mark_pressed_at(Instant::now());
}
MovementKeyAction::Release => {
self.current_linear = 0.0;
self.current_angular = 0.0;
self.held_tracker.clear();
}
}
self.send_velocity(ctx).await?;
}
KeyCode::Char('s') | KeyCode::Down if key.modifiers.is_empty() || key.code == KeyCode::Down => {
match classify_movement_key(key.kind) {
MovementKeyAction::Activate => {
self.current_linear = -self.max_linear_vel;
self.current_angular = 0.0;
self.held_tracker.mark_pressed_at(Instant::now());
}
MovementKeyAction::Release => {
self.current_linear = 0.0;
self.current_angular = 0.0;
self.held_tracker.clear();
}
}
self.send_velocity(ctx).await?;
}
KeyCode::Char('a') | KeyCode::Left if key.modifiers.is_empty() || key.code == KeyCode::Left => {
match classify_movement_key(key.kind) {
MovementKeyAction::Activate => {
self.current_linear = 0.0;
self.current_angular = self.max_angular_vel;
self.held_tracker.mark_pressed_at(Instant::now());
}
MovementKeyAction::Release => {
self.current_linear = 0.0;
self.current_angular = 0.0;
self.held_tracker.clear();
}
}
self.send_velocity(ctx).await?;
}
KeyCode::Char('d') | KeyCode::Right if key.modifiers.is_empty() || key.code == KeyCode::Right => {
match classify_movement_key(key.kind) {
MovementKeyAction::Activate => {
self.current_linear = 0.0;
self.current_angular = -self.max_angular_vel;
self.held_tracker.mark_pressed_at(Instant::now());
}
MovementKeyAction::Release => {
self.current_linear = 0.0;
self.current_angular = 0.0;
self.held_tracker.clear();
}
}
self.send_velocity(ctx).await?;
}
KeyCode::Char(' ') if key.modifiers.is_empty() => {
self.current_linear = 0.0;
self.current_angular = 0.0;
self.held_tracker.clear();
self.send_velocity(ctx).await?;
}
KeyCode::Char('e') if key.modifiers.is_empty() => {
self.held_tracker.clear();
self.send_emergency_stop(ctx).await?;
}
_ => {}
}
Ok(ScreenOutcome::Continue)
}
async fn handle_drivetrain_action(&mut self, action: &str, key: KeyEvent, ctx: &Context) -> Result<ScreenOutcome> {
match action {
"forward" => {
match classify_movement_key(key.kind) {
MovementKeyAction::Activate => {
self.current_linear = self.max_linear_vel;
self.current_angular = 0.0;
self.held_tracker.mark_pressed_at(Instant::now());
}
MovementKeyAction::Release => {
self.current_linear = 0.0;
self.current_angular = 0.0;
self.held_tracker.clear();
}
}
self.send_velocity(ctx).await?;
}
"backward" => {
match classify_movement_key(key.kind) {
MovementKeyAction::Activate => {
self.current_linear = -self.max_linear_vel;
self.current_angular = 0.0;
self.held_tracker.mark_pressed_at(Instant::now());
}
MovementKeyAction::Release => {
self.current_linear = 0.0;
self.current_angular = 0.0;
self.held_tracker.clear();
}
}
self.send_velocity(ctx).await?;
}
"left" => {
match classify_movement_key(key.kind) {
MovementKeyAction::Activate => {
self.current_linear = 0.0;
self.current_angular = self.max_angular_vel;
self.held_tracker.mark_pressed_at(Instant::now());
}
MovementKeyAction::Release => {
self.current_linear = 0.0;
self.current_angular = 0.0;
self.held_tracker.clear();
}
}
self.send_velocity(ctx).await?;
}
"right" => {
match classify_movement_key(key.kind) {
MovementKeyAction::Activate => {
self.current_linear = 0.0;
self.current_angular = -self.max_angular_vel;
self.held_tracker.mark_pressed_at(Instant::now());
}
MovementKeyAction::Release => {
self.current_linear = 0.0;
self.current_angular = 0.0;
self.held_tracker.clear();
}
}
self.send_velocity(ctx).await?;
}
"stop" => {
self.current_linear = 0.0;
self.current_angular = 0.0;
self.held_tracker.clear();
self.send_velocity(ctx).await?;
}
"estop" => {
self.held_tracker.clear();
self.send_emergency_stop(ctx).await?;
}
_ => {}
}
Ok(ScreenOutcome::Continue)
}
async fn handle_actuator_key(&mut self, key: KeyEvent, ctx: &Context) -> Result<ScreenOutcome> {
if let Some(action) = self.bound_action(&key) {
return self.handle_actuator_action(&action, key, ctx).await;
}
match key.code {
KeyCode::PageUp | KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll_offset = self.scroll_offset.saturating_sub(10);
}
KeyCode::PageDown | KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let logs = self.log_buffer.get_all();
self.scroll_offset = (self.scroll_offset + 10).min(logs.len().saturating_sub(1));
}
KeyCode::Char('k') if key.modifiers.is_empty() => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
KeyCode::Char('j') if key.modifiers.is_empty() => {
let logs = self.log_buffer.get_all();
self.scroll_offset = (self.scroll_offset + 1).min(logs.len().saturating_sub(1));
}
KeyCode::Char('w') | KeyCode::Up if key.modifiers.is_empty() || key.code == KeyCode::Up => {
if classify_movement_key(key.kind) == MovementKeyAction::Activate {
self.nudge_selected_joint(JOINT_STEP_RAD);
self.held_tracker.mark_pressed_at(Instant::now());
self.send_joint_positions(ctx).await?;
} else {
self.held_tracker.clear();
}
}
KeyCode::Char('s') | KeyCode::Down if key.modifiers.is_empty() || key.code == KeyCode::Down => {
if classify_movement_key(key.kind) == MovementKeyAction::Activate {
self.nudge_selected_joint(-JOINT_STEP_RAD);
self.held_tracker.mark_pressed_at(Instant::now());
self.send_joint_positions(ctx).await?;
} else {
self.held_tracker.clear();
}
}
KeyCode::Char('a') | KeyCode::Left if key.modifiers.is_empty() || key.code == KeyCode::Left => {
if classify_movement_key(key.kind) == MovementKeyAction::Activate && !self.joint_names.is_empty() {
self.selected_joint = (self.selected_joint + self.joint_names.len() - 1) % self.joint_names.len();
}
}
KeyCode::Char('d') | KeyCode::Right if key.modifiers.is_empty() || key.code == KeyCode::Right => {
if classify_movement_key(key.kind) == MovementKeyAction::Activate && !self.joint_names.is_empty() {
self.selected_joint = (self.selected_joint + 1) % self.joint_names.len();
}
}
KeyCode::Char(' ') if key.modifiers.is_empty() => {
self.held_tracker.clear();
}
KeyCode::Char('e') if key.modifiers.is_empty() => {
self.held_tracker.clear();
self.send_emergency_stop(ctx).await?;
}
_ => {}
}
Ok(ScreenOutcome::Continue)
}
async fn handle_actuator_action(&mut self, action: &str, key: KeyEvent, ctx: &Context) -> Result<ScreenOutcome> {
match action {
"select-next" => {
if classify_movement_key(key.kind) == MovementKeyAction::Activate && !self.joint_names.is_empty() {
self.selected_joint = (self.selected_joint + 1) % self.joint_names.len();
}
}
"select-previous" => {
if classify_movement_key(key.kind) == MovementKeyAction::Activate && !self.joint_names.is_empty() {
self.selected_joint = (self.selected_joint + self.joint_names.len() - 1) % self.joint_names.len();
}
}
"estop" => {
self.held_tracker.clear();
self.send_emergency_stop(ctx).await?;
}
_ if action.ends_with('+') => {
let joint_name = &action[..action.len() - 1];
if classify_movement_key(key.kind) == MovementKeyAction::Activate {
self.nudge_joint_by_name(joint_name, JOINT_STEP_RAD);
self.held_tracker.mark_pressed_at(Instant::now());
self.send_joint_positions(ctx).await?;
} else {
self.held_tracker.clear();
}
}
_ if action.ends_with('-') => {
let joint_name = &action[..action.len() - 1];
if classify_movement_key(key.kind) == MovementKeyAction::Activate {
self.nudge_joint_by_name(joint_name, -JOINT_STEP_RAD);
self.held_tracker.mark_pressed_at(Instant::now());
self.send_joint_positions(ctx).await?;
} else {
self.held_tracker.clear();
}
}
_ => {}
}
Ok(ScreenOutcome::Continue)
}
fn nudge_selected_joint(&mut self, delta: f32) {
if let Some(position) = self.joint_positions.get_mut(self.selected_joint) {
*position += delta;
}
}
async fn send_joint_positions(&mut self, ctx: &Context) -> Result<()> {
let input = TeleopInput::new_actuator(
"cli_tui",
self.joint_names.clone(),
self.joint_positions.clone(),
now_micros(),
)
.with_priority(self.priority);
ctx.publish_to(TELEOP_CMD, &input).await?;
self.last_command = Some(Instant::now());
self.command_count += 1;
Ok(())
}
async fn send_velocity(&mut self, ctx: &Context) -> Result<()> {
let input = TeleopInput::new("cli_tui", self.current_linear, self.current_angular, now_micros())
.with_priority(self.priority);
ctx.publish_to(TELEOP_CMD, &input).await?;
self.last_command = Some(Instant::now());
self.command_count += 1;
Ok(())
}
async fn send_stop(&mut self, ctx: &Context) -> Result<()> {
if self.is_actuator_mode() {
return Ok(());
}
let input = TeleopInput::new("cli_tui", 0.0, 0.0, now_micros()).with_priority(self.priority);
ctx.publish_to(TELEOP_CMD, &input).await?;
Ok(())
}
async fn send_emergency_stop(&mut self, ctx: &Context) -> Result<()> {
let input = TeleopInput::emergency_stop("cli_tui", now_micros());
ctx.publish_to(TELEOP_CMD, &input).await?;
self.current_linear = 0.0;
self.current_angular = 0.0;
self.last_command = Some(Instant::now());
self.command_count += 1;
Ok(())
}
}
impl SidebarTui for TeleopUi {
fn draw_main(&mut self, f: &mut Frame, area: ratatui::layout::Rect) {
self.draw_teleop_panel(f, area);
}
fn draw_sidebar(&self, f: &mut Frame, area: ratatui::layout::Rect) {
self.draw_logs_panel(f, area);
}
fn draw_footer(&self, f: &mut Frame, area: ratatui::layout::Rect) {
draw_dashboard_footer(f, area, &self.dashboard_url, "Q/ESC");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constants_are_tuned_safely_relative_to_each_other() {
assert!(
REPUBLISH_INTERVAL * 2 < HELD_WINDOW,
"REPUBLISH_INTERVAL should fire multiple times within a single HELD_WINDOW"
);
assert!(
REPUBLISH_INTERVAL < Duration::from_millis(100),
"REPUBLISH_INTERVAL should be safely faster than a typical 200ms command_timeout_ms"
);
}
#[test]
fn fresh_tracker_is_not_held() {
let tracker = HeldKeyTracker::new(HELD_WINDOW);
assert!(!tracker.is_held_at(Instant::now()));
}
#[test]
fn single_press_stays_held_until_window_expires_with_no_further_events() {
let mut tracker = HeldKeyTracker::new(HELD_WINDOW);
let t0 = Instant::now();
tracker.mark_pressed_at(t0);
assert!(tracker.is_held_at(t0), "should be held immediately after press");
assert!(
tracker.is_held_at(t0 + Duration::from_millis(50)),
"should still be held well within the window"
);
assert!(
tracker.is_held_at(t0 + HELD_WINDOW - Duration::from_millis(1)),
"should still be held just under the window boundary"
);
assert!(
!tracker.is_held_at(t0 + HELD_WINDOW),
"should no longer be held once the window has fully elapsed"
);
assert!(
!tracker.is_held_at(t0 + HELD_WINDOW + Duration::from_millis(500)),
"should stay not-held indefinitely after the window elapses (doesn't spin forever)"
);
}
#[test]
fn repeated_presses_keep_refreshing_the_held_window() {
let mut tracker = HeldKeyTracker::new(HELD_WINDOW);
let t0 = Instant::now();
let mut t = t0;
for _ in 0..10 {
tracker.mark_pressed_at(t);
assert!(tracker.is_held_at(t));
t += Duration::from_millis(100);
}
assert!(tracker.is_held_at(t - Duration::from_millis(1)));
}
#[test]
fn clear_stops_being_held_immediately() {
let mut tracker = HeldKeyTracker::new(HELD_WINDOW);
let t0 = Instant::now();
tracker.mark_pressed_at(t0);
assert!(tracker.is_held_at(t0));
tracker.clear();
assert!(
!tracker.is_held_at(t0),
"clearing should stop 'held' immediately, even though the window hasn't elapsed"
);
assert!(!tracker.is_held_at(t0 + Duration::from_millis(1)));
}
#[test]
fn press_activates_a_movement_key() {
assert_eq!(classify_movement_key(KeyEventKind::Press), MovementKeyAction::Activate);
}
#[test]
fn repeat_activates_a_movement_key_exactly_like_press() {
assert_eq!(classify_movement_key(KeyEventKind::Repeat), MovementKeyAction::Activate);
}
#[test]
fn release_stops_a_movement_key_immediately() {
assert_eq!(classify_movement_key(KeyEventKind::Release), MovementKeyAction::Release);
}
fn apply_movement_key(
action: MovementKeyAction,
activate_linear: f32,
activate_angular: f32,
current_linear: &mut f32,
current_angular: &mut f32,
held_tracker: &mut HeldKeyTracker,
now: Instant,
) {
match action {
MovementKeyAction::Activate => {
*current_linear = activate_linear;
*current_angular = activate_angular;
held_tracker.mark_pressed_at(now);
}
MovementKeyAction::Release => {
*current_linear = 0.0;
*current_angular = 0.0;
held_tracker.clear();
}
}
}
#[test]
fn release_event_zeroes_velocity_and_clears_held_tracker_regardless_of_window_remaining() {
let mut current_linear = 0.7_f32;
let mut current_angular = 0.0_f32;
let mut held_tracker = HeldKeyTracker::new(HELD_WINDOW);
let t0 = Instant::now();
held_tracker.mark_pressed_at(t0);
assert!(held_tracker.is_held_at(t0), "sanity: key is held right after press");
let t_release = t0 + Duration::from_millis(10);
apply_movement_key(
classify_movement_key(KeyEventKind::Release),
0.7,
0.0,
&mut current_linear,
&mut current_angular,
&mut held_tracker,
t_release,
);
assert_eq!(current_linear, 0.0, "Release must zero linear velocity immediately");
assert_eq!(current_angular, 0.0, "Release must zero angular velocity immediately");
assert!(
!held_tracker.is_held_at(t_release),
"Release must clear the held tracker immediately, not wait out HELD_WINDOW"
);
}
#[test]
fn press_and_repeat_events_activate_velocity_and_refresh_held_window_unchanged() {
let mut current_linear = 0.0_f32;
let mut current_angular = 0.0_f32;
let mut held_tracker = HeldKeyTracker::new(HELD_WINDOW);
let t0 = Instant::now();
for kind in [KeyEventKind::Press, KeyEventKind::Repeat] {
apply_movement_key(
classify_movement_key(kind),
0.5,
0.0,
&mut current_linear,
&mut current_angular,
&mut held_tracker,
t0,
);
assert_eq!(current_linear, 0.5, "{kind:?} must activate the key's velocity");
assert_eq!(current_angular, 0.0);
assert!(held_tracker.is_held_at(t0), "{kind:?} must mark the key as held");
}
}
#[test]
fn stop_current_intent_maps_to_return_to_parent() {
let intent = classify_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
.expect("bare x must classify as a termination intent");
let outcome = match intent {
TerminationIntent::StopCurrent => ScreenOutcome::ReturnToParent,
TerminationIntent::QuitAll => ScreenOutcome::QuitAll,
};
assert_eq!(outcome, ScreenOutcome::ReturnToParent);
}
#[test]
fn quit_all_intent_maps_to_quit_all() {
let intent = classify_key(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))
.expect("Ctrl+C must classify as a termination intent");
let outcome = match intent {
TerminationIntent::StopCurrent => ScreenOutcome::ReturnToParent,
TerminationIntent::QuitAll => ScreenOutcome::QuitAll,
};
assert_eq!(outcome, ScreenOutcome::QuitAll);
}
#[test]
fn space_is_never_classified_as_a_termination_key() {
let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
assert_eq!(classify_key(&key), None);
}
fn test_teleop_ui(joint_names: Vec<String>, key_bindings: HashMap<String, String>) -> TeleopUi {
TeleopUi::new(
"redis://localhost".to_string(),
LogBuffer::new(10),
"http://localhost".to_string(),
1.0,
1.0,
joint_names,
key_bindings,
)
}
#[test]
fn char_keys_bind_to_their_lowercased_char() {
let key = KeyEvent::new(KeyCode::Char('J'), KeyModifiers::NONE);
assert_eq!(teleop_key_binding_key(&key), Some("j".to_string()));
}
#[test]
fn arrow_keys_bind_to_their_direction_name() {
assert_eq!(
teleop_key_binding_key(&KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)),
Some("up".to_string())
);
assert_eq!(
teleop_key_binding_key(&KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
Some("down".to_string())
);
assert_eq!(
teleop_key_binding_key(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
Some("left".to_string())
);
assert_eq!(
teleop_key_binding_key(&KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
Some("right".to_string())
);
}
#[test]
fn empty_key_bindings_resolve_no_bound_action_preserving_default_wsad_behavior() {
let ui = test_teleop_ui(vec!["pan".to_string(), "tilt".to_string()], HashMap::new());
for key in [
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
] {
assert_eq!(
ui.bound_action(&key),
None,
"with no key_bindings configured, {key:?} must fall back to hardcoded behavior"
);
}
}
#[test]
fn configured_binding_resolves_to_its_action_name() {
let mut key_bindings = HashMap::new();
key_bindings.insert("j".to_string(), "tilt-".to_string());
let ui = test_teleop_ui(vec!["pan".to_string(), "tilt".to_string()], key_bindings);
let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
assert_eq!(ui.bound_action(&key), Some("tilt-".to_string()));
}
#[test]
fn configured_binding_nudges_the_named_joint_directly_not_the_selected_one() {
let mut key_bindings = HashMap::new();
key_bindings.insert("j".to_string(), "tilt-".to_string());
let mut ui = test_teleop_ui(vec!["pan".to_string(), "tilt".to_string()], key_bindings);
assert_eq!(ui.selected_joint, 0);
let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
let action = ui.bound_action(&key).expect("binding must resolve");
assert_eq!(action, "tilt-");
let joint_name = action.strip_suffix('-').expect("action must be a '-' nudge");
ui.nudge_joint_by_name(joint_name, -JOINT_STEP_RAD);
assert_eq!(ui.joint_positions[0], 0.0, "unselected/unbound 'pan' must be untouched");
assert_eq!(
ui.joint_positions[1], -JOINT_STEP_RAD,
"'tilt' must be nudged by the configured binding"
);
}
#[test]
fn nudge_joint_by_name_is_a_no_op_for_an_unknown_joint() {
let mut ui = test_teleop_ui(vec!["pan".to_string()], HashMap::new());
ui.nudge_joint_by_name("nonexistent", JOINT_STEP_RAD);
assert_eq!(ui.joint_positions, vec![0.0]);
}
}