use std::collections::VecDeque;
use std::fs::{File, OpenOptions, create_dir_all};
use std::io::{self, BufWriter, Stdout, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use chrono::Local;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent,
KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::widgets::{
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
Wrap,
};
use ratatui::{Frame, Terminal};
use crate::ocpp::OcppVersion;
use crate::simulator::{
ConnectorSnapshot, SimulatorSnapshot, UiEvent, UiLogLevel,
};
mod completion;
mod history;
use completion::{CompletionState, completion_seed};
use history::CommandHistory;
const MAX_LOG_LINES: usize = 10_000;
const SCROLL_WHEEL_STEP: usize = 1;
pub struct TerminalSession {
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl TerminalSession {
pub fn new() -> Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(Self { terminal })
}
pub fn draw(&mut self, app: &mut TerminalApp) -> Result<()> {
self.terminal.draw(|frame| app.render(frame))?;
Ok(())
}
pub fn poll_input(&mut self, app: &mut TerminalApp) -> Result<InputAction> {
if !event::poll(Duration::from_millis(50))? {
return Ok(InputAction::None);
}
loop {
let action = match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
app.handle_key_event(key)
} else {
InputAction::None
}
}
Event::Mouse(mouse) => app.handle_mouse_event(mouse),
_ => InputAction::None,
};
if !matches!(action, InputAction::None) {
return Ok(action);
}
if !event::poll(Duration::ZERO)? {
break;
}
}
Ok(InputAction::None)
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
restore_console();
let _ = self.terminal.show_cursor();
}
}
pub fn restore_console() {
let _ = disable_raw_mode();
let mut stdout = io::stdout();
let _ = execute!(stdout, LeaveAlternateScreen, DisableMouseCapture);
}
#[derive(Debug, Clone)]
struct LogEntry {
timestamp: String,
level: UiLogLevel,
message: String,
}
#[derive(Debug)]
struct FileLogSink {
path: PathBuf,
writer: BufWriter<File>,
}
impl FileLogSink {
fn open(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
create_dir_all(parent).with_context(|| {
format!("Failed to create log directory {}", parent.display())
})?;
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("Failed to open log file {}", path.display()))?;
Ok(Self {
path: path.to_path_buf(),
writer: BufWriter::new(file),
})
}
fn append_line(
&mut self,
timestamp: &str,
level: UiLogLevel,
message: &str,
) -> io::Result<()> {
writeln!(self.writer, "{} [{}] {}", timestamp, level.label(), message)?;
self.writer.flush()
}
}
pub enum InputAction {
None,
Submitted(String),
ExitRequested,
}
pub struct TerminalApp {
protocol: OcppVersion,
ws_url: String,
logs: VecDeque<LogEntry>,
input: String,
cursor: usize,
log_scroll: usize,
follow_logs: bool,
log_view_height: usize,
log_view_width: usize,
history: CommandHistory,
known_connectors: Vec<u16>,
completion: Option<CompletionState>,
log_sink: Option<FileLogSink>,
}
impl TerminalApp {
pub fn new(protocol: OcppVersion) -> Self {
Self {
protocol,
ws_url: String::new(),
logs: VecDeque::new(),
input: String::new(),
cursor: 0,
log_scroll: 0,
follow_logs: true,
log_view_height: 1,
log_view_width: 1,
history: CommandHistory::new(),
known_connectors: Vec::new(),
completion: None,
log_sink: None,
}
}
pub fn enable_log_path(&mut self, path: &Path) -> Result<()> {
self.log_sink = Some(FileLogSink::open(path)?);
Ok(())
}
pub fn apply(&mut self, event: UiEvent) {
match event {
UiEvent::Log { level, message } => self.push_log(level, message),
UiEvent::Snapshot(snapshot) => self.push_snapshot(snapshot),
}
}
pub fn push_info<S: Into<String>>(&mut self, message: S) {
self.push_log(UiLogLevel::Info, message);
}
pub fn push_error<S: Into<String>>(&mut self, message: S) {
self.push_log(UiLogLevel::Error, message);
}
pub fn push_user_input(&mut self, message: &str) {
self.history.record(message);
self.push_log(UiLogLevel::Info, format!("> {}", message));
}
pub fn clear_logs(&mut self) {
self.logs.clear();
self.log_scroll = 0;
self.follow_logs = true;
}
pub fn handle_mouse_event(&mut self, event: MouseEvent) -> InputAction {
self.clear_completion();
match event.kind {
MouseEventKind::ScrollUp => {
self.scroll_logs_up(SCROLL_WHEEL_STEP);
}
MouseEventKind::ScrollDown => {
self.scroll_logs_down(SCROLL_WHEEL_STEP);
}
_ => {}
}
InputAction::None
}
pub fn handle_key_event(&mut self, key: KeyEvent) -> InputAction {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return self.handle_ctrl_key(key);
}
if key.modifiers.contains(KeyModifiers::ALT) {
return self.handle_alt_key(key);
}
match key.code {
KeyCode::BackTab => {
self.leave_history_navigation();
self.complete_input(true);
InputAction::None
}
KeyCode::Tab => {
self.leave_history_navigation();
self.complete_input(key.modifiers.contains(KeyModifiers::SHIFT));
InputAction::None
}
KeyCode::Up => {
self.clear_completion();
self.select_previous_history();
InputAction::None
}
KeyCode::Down => {
self.clear_completion();
self.select_next_history();
InputAction::None
}
KeyCode::Char(ch) => {
if key.modifiers != KeyModifiers::NONE
&& key.modifiers != KeyModifiers::SHIFT
{
return InputAction::None;
}
self.clear_completion();
self.leave_history_navigation();
self.input.insert(self.cursor, ch);
self.cursor += ch.len_utf8();
InputAction::None
}
KeyCode::Backspace => {
self.clear_completion();
self.leave_history_navigation();
if self.cursor > 0 {
let previous = previous_char_boundary(&self.input, self.cursor);
self.input.remove(previous);
self.cursor = previous;
}
InputAction::None
}
KeyCode::Delete => {
self.clear_completion();
self.leave_history_navigation();
if self.cursor < self.input.len() {
self.input.remove(self.cursor);
}
InputAction::None
}
KeyCode::Left => {
self.clear_completion();
if self.cursor > 0 {
self.cursor = previous_char_boundary(&self.input, self.cursor);
}
InputAction::None
}
KeyCode::Right => {
self.clear_completion();
if self.cursor < self.input.len() {
self.cursor = next_char_boundary(&self.input, self.cursor);
}
InputAction::None
}
KeyCode::Home => {
self.clear_completion();
self.cursor = 0;
InputAction::None
}
KeyCode::End => {
self.clear_completion();
self.cursor = self.input.len();
InputAction::None
}
KeyCode::Esc => {
self.clear_completion();
self.leave_history_navigation();
self.input.clear();
self.cursor = 0;
InputAction::None
}
KeyCode::Enter => {
self.clear_completion();
self.leave_history_navigation();
let line = self.input.trim().to_string();
self.input.clear();
self.cursor = 0;
InputAction::Submitted(line)
}
_ => InputAction::None,
}
}
pub fn render(&mut self, frame: &mut Frame<'_>) {
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(3)])
.split(frame.area());
self.log_view_height = areas[0].height.saturating_sub(2) as usize;
self.log_view_height = self.log_view_height.max(1);
self.log_view_width = areas[0].width.saturating_sub(2) as usize;
self.log_view_width = self.log_view_width.max(1);
let max_scroll = self.max_log_scroll();
if self.follow_logs || self.log_scroll > max_scroll {
self.log_scroll = max_scroll;
}
let mut scrollbar_state = ScrollbarState::new(max_scroll)
.viewport_content_length(self.log_view_height)
.position(self.log_scroll);
let title = if self.ws_url.is_empty() {
format!("Logs - OCPP {}", self.protocol.label())
} else {
format!("Logs - OCPP {} - {}", self.protocol.label(), self.ws_url)
};
let logs_block = Block::default().title(title).borders(Borders::ALL);
let lines = self.render_log_lines();
let logs = Paragraph::new(lines)
.block(logs_block)
.wrap(Wrap { trim: false })
.scroll((self.log_scroll as u16, 0));
frame.render_widget(logs, areas[0]);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight),
areas[0],
&mut scrollbar_state,
);
let input_block = Block::default().title("Command").borders(Borders::ALL);
let input = Paragraph::new(self.input.as_str()).block(input_block);
frame.render_widget(input, areas[1]);
let max_cursor = areas[1].width.saturating_sub(3) as usize;
let cursor = self.cursor_display_width().min(max_cursor);
frame.set_cursor_position((areas[1].x + 1 + cursor as u16, areas[1].y + 1));
}
fn handle_ctrl_key(&mut self, key: KeyEvent) -> InputAction {
match key.code {
KeyCode::Char('c') => InputAction::ExitRequested,
KeyCode::Char('d') => InputAction::ExitRequested,
KeyCode::Char('w') => {
self.clear_completion();
self.leave_history_navigation();
self.clear_previous_word();
InputAction::None
}
KeyCode::Char('u') => {
self.clear_completion();
self.leave_history_navigation();
self.input.clear();
self.cursor = 0;
InputAction::None
}
KeyCode::Char('a') => {
self.clear_completion();
self.cursor = 0;
InputAction::None
}
KeyCode::Char('e') => {
self.clear_completion();
self.cursor = self.input.len();
InputAction::None
}
_ => InputAction::None,
}
}
fn handle_alt_key(&mut self, key: KeyEvent) -> InputAction {
self.clear_completion();
match key.code {
KeyCode::Up => {
self.scroll_logs_half_page_up();
}
KeyCode::Down => {
self.scroll_logs_half_page_down();
}
_ => {}
}
InputAction::None
}
fn select_previous_history(&mut self) {
if let Some(input) = self.history.previous(&self.input) {
self.input = input;
self.cursor = self.input.len();
}
}
fn select_next_history(&mut self) {
if let Some(input) = self.history.next() {
self.input = input;
self.cursor = self.input.len();
}
}
fn leave_history_navigation(&mut self) {
self.history.leave_navigation();
}
fn complete_input(&mut self, reverse: bool) {
if self.cursor != self.input.len() {
self.cursor = self.input.len();
}
if let Some(state) = self.completion.as_mut() {
if state.is_empty() {
self.completion = None;
return;
}
self.input = state.next_value(reverse);
self.cursor = self.input.len();
return;
}
let Some((base, candidates)) =
completion_seed(&self.input, &self.known_connectors)
else {
return;
};
if candidates.is_empty() {
return;
}
let index = if reverse { candidates.len() - 1 } else { 0 };
self.input = format!("{}{}", base, candidates[index]);
self.cursor = self.input.len();
if candidates.len() > 1 {
self.completion = Some(CompletionState::new(base, candidates, index));
}
}
fn clear_previous_word(&mut self) {
if self.cursor == 0 {
return;
}
let mut start = self.cursor;
while start > 0 {
let previous = previous_char_boundary(&self.input, start);
let ch = self.input[previous..start]
.chars()
.next()
.expect("char boundary should contain one character");
if !ch.is_whitespace() {
break;
}
start = previous;
}
while start > 0 {
let previous = previous_char_boundary(&self.input, start);
let ch = self.input[previous..start]
.chars()
.next()
.expect("char boundary should contain one character");
if ch.is_whitespace() {
break;
}
start = previous;
}
self.input.drain(start..self.cursor);
self.cursor = start;
}
fn cursor_display_width(&self) -> usize {
Line::raw(self.input[..self.cursor].to_string()).width()
}
fn clear_completion(&mut self) {
self.completion = None;
}
fn scroll_logs_half_page_up(&mut self) {
let lines = (self.log_view_height / 2).max(1);
self.scroll_logs_up(lines);
}
fn scroll_logs_half_page_down(&mut self) {
let lines = (self.log_view_height / 2).max(1);
self.scroll_logs_down(lines);
}
fn scroll_logs_up(&mut self, lines: usize) {
if lines == 0 {
return;
}
let current = if self.follow_logs {
self.max_log_scroll()
} else {
self.log_scroll
};
self.log_scroll = current.saturating_sub(lines);
self.follow_logs = false;
}
fn scroll_logs_down(&mut self, lines: usize) {
if lines == 0 {
return;
}
let max_scroll = self.max_log_scroll();
let current = if self.follow_logs {
max_scroll
} else {
self.log_scroll
};
self.log_scroll = current.saturating_add(lines).min(max_scroll);
self.follow_logs = self.log_scroll == max_scroll;
}
fn max_log_scroll(&self) -> usize {
let wrapped_lines: usize = self
.logs
.iter()
.map(|entry| self.wrapped_log_line_count(entry))
.sum();
wrapped_lines.saturating_sub(self.log_view_height)
}
fn render_log_lines(&self) -> Vec<Line<'static>> {
self
.logs
.iter()
.map(|entry| {
let text = Self::format_log_entry(entry);
Line::styled(text, Style::default().fg(entry.level.color()))
})
.collect()
}
fn wrapped_log_line_count(&self, entry: &LogEntry) -> usize {
let width = self.log_view_width.max(1);
let line_width = Line::raw(Self::format_log_entry(entry)).width();
let wraps = (line_width + width.saturating_sub(1)) / width;
wraps.max(1)
}
fn format_log_entry(entry: &LogEntry) -> String {
format!(
"{} [{}] {}",
entry.timestamp,
entry.level.label(),
entry.message
)
}
fn push_log<S: Into<String>>(&mut self, level: UiLogLevel, message: S) {
let message = message.into();
let mut sink_error: Option<String> = None;
for line in message.lines() {
let timestamp = log_timestamp_now();
let entry = LogEntry {
timestamp: timestamp.clone(),
level,
message: line.to_string(),
};
self.logs.push_back(entry);
if let Some(sink) = self.log_sink.as_mut()
&& let Err(error) = sink.append_line(×tamp, level, line)
&& sink_error.is_none()
{
sink_error = Some(format!(
"Failed to append logs to {}: {}",
sink.path.display(),
error
));
}
}
if let Some(error) = sink_error {
self.log_sink = None;
self.push_log(UiLogLevel::Error, error);
}
let mut removed = 0;
while self.logs.len() > MAX_LOG_LINES {
let _ = self.logs.pop_front();
removed += 1;
}
if removed > 0 && !self.follow_logs {
self.log_scroll = self.log_scroll.saturating_sub(removed);
}
}
fn push_snapshot(&mut self, snapshot: SimulatorSnapshot) {
self.known_connectors =
snapshot.connectors.iter().map(|item| item.id).collect();
self.ws_url = snapshot.connection_url.clone();
self.push_log(
UiLogLevel::Info,
format!(
"CP {} protocol={} connected={} heartbeat={} queue={} pending={}",
snapshot.cp_id,
snapshot.protocol,
snapshot.connected,
display_heartbeat(snapshot.heartbeat_seconds),
snapshot.queue_depth,
snapshot.pending_action.unwrap_or_else(|| "-".to_string()),
),
);
for connector in snapshot.connectors {
self.push_log(UiLogLevel::Info, format_connector_line(connector));
}
}
}
fn format_connector_line(connector: ConnectorSnapshot) -> String {
let tx = connector.transaction.unwrap_or_else(|| "-".to_string());
format!(
"Connector {} status={} meter={}Wh tx={}",
connector.id, connector.status, connector.meter_wh, tx,
)
}
fn display_heartbeat(value: Option<u64>) -> String {
value
.map(|seconds| format!("{seconds}s"))
.unwrap_or_else(|| "-".to_string())
}
fn log_timestamp_now() -> String {
Local::now().format("%Y-%m-%d %H:%M:%S%.6f").to_string()
}
fn previous_char_boundary(input: &str, index: usize) -> usize {
input[..index]
.char_indices()
.next_back()
.map(|(position, _)| position)
.unwrap_or(0)
}
fn next_char_boundary(input: &str, index: usize) -> usize {
input[index..]
.char_indices()
.nth(1)
.map(|(position, _)| index + position)
.unwrap_or(input.len())
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::ocpp::OcppVersion;
use super::{InputAction, TerminalApp};
static TEMP_LOG_COUNTER: AtomicU64 = AtomicU64::new(0);
#[test]
fn appends_logs_to_file() {
let path = temp_log_path();
let mut app = TerminalApp::new(OcppVersion::V2_1);
app.enable_log_path(&path).expect("log file should open");
app.push_info("hello");
app.push_error("oops");
let content = std::fs::read_to_string(&path).expect("log file should read");
let first_line = content.lines().next().expect("first log line");
assert!(first_line.contains(" [INFO] hello"));
assert!(first_line.len() >= 20);
assert_eq!(first_line.chars().nth(4), Some('-'));
assert_eq!(first_line.chars().nth(7), Some('-'));
assert_eq!(first_line.chars().nth(10), Some(' '));
assert_eq!(first_line.chars().nth(13), Some(':'));
assert_eq!(first_line.chars().nth(16), Some(':'));
assert!(content.contains("[ERROR] oops"));
let _ = std::fs::remove_file(path);
}
#[test]
fn max_log_scroll_accounts_for_wrapping() {
let mut app = TerminalApp::new(OcppVersion::V2_1);
app.log_view_height = 3;
app.log_view_width = 20;
app.push_info("X".repeat(80));
let entry = app.logs.front().expect("log entry");
let wrapped = app.wrapped_log_line_count(entry);
assert!(wrapped > 1);
assert_eq!(app.max_log_scroll(), wrapped - app.log_view_height);
}
#[test]
fn edits_non_ascii_input_on_character_boundaries() {
let mut app = TerminalApp::new(OcppVersion::V2_1);
press(&mut app, KeyCode::Char('é'));
press(&mut app, KeyCode::Char('x'));
assert_eq!(app.input, "éx");
assert_eq!(app.cursor, app.input.len());
assert_eq!(app.cursor_display_width(), 2);
press(&mut app, KeyCode::Left);
assert_eq!(app.cursor, "é".len());
press(&mut app, KeyCode::Backspace);
assert_eq!(app.input, "x");
assert_eq!(app.cursor, 0);
press(&mut app, KeyCode::End);
press(&mut app, KeyCode::Char('ø'));
press(&mut app, KeyCode::Home);
press(&mut app, KeyCode::Right);
press(&mut app, KeyCode::Delete);
assert_eq!(app.input, "x");
assert_eq!(app.cursor, app.input.len());
}
#[test]
fn clears_non_ascii_words_without_panicking() {
let mut app = TerminalApp::new(OcppVersion::V2_1);
for ch in "start café now".chars() {
press(&mut app, KeyCode::Char(ch));
}
press_with_modifiers(&mut app, KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(app.input, "start café ");
assert_eq!(app.cursor, app.input.len());
press_with_modifiers(&mut app, KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(app.input, "start ");
assert_eq!(app.cursor, app.input.len());
press_with_modifiers(&mut app, KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(app.input, "");
assert_eq!(app.cursor, 0);
}
fn temp_log_path() -> PathBuf {
let base = std::env::current_dir().expect("cwd");
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let sequence = TEMP_LOG_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
base.join(format!(".tmp-ocppsim-log-{pid}-{timestamp}-{sequence}.log"))
}
fn press(app: &mut TerminalApp, code: KeyCode) {
press_with_modifiers(app, code, KeyModifiers::NONE);
}
fn press_with_modifiers(
app: &mut TerminalApp,
code: KeyCode,
modifiers: KeyModifiers,
) {
let action = app.handle_key_event(KeyEvent::new(code, modifiers));
assert!(matches!(action, InputAction::None));
}
}