use chrono::{Duration, Utc}; use crossterm::event::{self, KeyCode, KeyModifiers, MediaKeyCode::Record}; use ratatui::{Frame, layout::{Direction, Layout, Rect}, style::{self, Style, Stylize}, text::{Line, Span, Text}, widgets::{BorderType, Clear, Gauge, List, ListDirection, ListState, Paragraph, StatefulWidget, Widget, Wrap}}; use throbber_widgets_tui::{Throbber, ThrobberState}; use tokio::time::Instant; use tui_input::{Input, backend::crossterm::EventHandler}; use tui_skeleton::{AnimationMode, Block, Constraint, SkeletonText}; use crate::{audio::AudioInputControl, prediction::{PredictionAction, SessionControl, SessionUpdate}, scene::{Scene, conversation::ConversationEntry}, transcription::TranscriptionControl, tts::TtsControl}; use crate::widgets::*; #[derive(Debug)] pub struct Ui { scene: Scene, reply_state: ListState, conversation_state: ListState, user_input: Input, throbber_state: ThrobberState, is_requesting: bool, audio_level: f64, recording_audio: bool, focus_state: FocusState, last_tick: Instant, transcription: TranscriptionControl, audio: AudioInputControl, tts: TtsControl, predictions: SessionControl } #[derive(Debug)] enum FocusState { Conversation, UserInput } impl Ui { pub fn new(predictions: SessionControl, audio: AudioInputControl, transcription: TranscriptionControl, tts: TtsControl) -> Self { Self { scene: Default::default(), reply_state: Default::default(), conversation_state: Default::default(), user_input: Default::default(), throbber_state: Default::default(), is_requesting: false, audio_level: -60., audio, recording_audio: false, transcription, focus_state: FocusState::UserInput, tts, predictions, last_tick: Instant::now() } } fn draw_options(&mut self, frame: &mut Frame, area: Rect) { let borders = Block::bordered().border_style(style::Color::LightGreen).title("Reply Options (Press 'Ctrl+R' to regenerate, Ctrl+Enter to use)"); if self.is_requesting { let list = SkeletonText::new(std::time::SystemTime::now().duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64) .braille(true) .line_widths(&[0.25, 0.5, 0.4, 0.6]) .mode(AnimationMode::Noise) .block(borders); frame.render_widget(list, area); } else { frame.render_stateful_widget(Options(self.scene.reply_options()), area, &mut self.reply_state); } } fn draw_user_input(&mut self, frame: &mut Frame, area: Rect) { let width = area.width.max(3) - 3; let scroll = self.user_input.visual_scroll(width as usize); let input = Paragraph::new(self.user_input.value()).block(Block::bordered().border_type(BorderType::LightDoubleDashed).title("User Input (Press 'Ctrl-X' to start/stop audio transcription)")).scroll((0, scroll as u16)); frame.render_widget(input, area); let x = self.user_input.visual_cursor().max(scroll); frame.set_cursor_position((area.x + x as u16 + 1, area.y + 1)); } fn draw_io_throbber(&mut self, frame: &mut Frame, area: Rect) { let throb_area = area.centered(Constraint::Max(1), Constraint::Max(1)); if self.is_requesting { let throbber = Throbber::default(); frame.render_stateful_widget(throbber, throb_area, &mut self.throbber_state); } else { frame.render_widget(Clear::default(), throb_area); } } fn draw_narration(&self, frame: &mut Frame, area: Rect) { let narrative_desc = if self.scene.direction().narrative.is_empty() { Span::from("No narrative available.").style(ratatui::style::Color::DarkGray) } else { Span::from(self.scene.direction().narrative.clone()) }; let setting = Paragraph::new(narrative_desc).block(Block::bordered().border_style(style::Color::LightMagenta).title("Stage Direction")).wrap(ratatui::widgets::Wrap { trim: false }); frame.render_widget(setting, area); } pub fn draw(&mut self, frame: &mut Frame) { let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Fill(3), Constraint::Min(9), Constraint::Max(3), Constraint::Max(1) ]) .split(frame.area()); let scene_layout = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Fill(4), Constraint::Fill(1)]) .split(layout[0]); frame.render_stateful_widget(Conversation(self.scene.conversation()), scene_layout[0], &mut self.conversation_state); self.draw_narration(frame, scene_layout[1]); self.draw_options(frame, layout[1]); let status_layout = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Max(3), Constraint::Fill(2), Constraint::Max(13), Constraint::Min(50)]) .split(layout[3]); self.draw_user_input(frame, layout[2]); self.draw_io_throbber(frame, status_layout[0]); frame.render_widget(StatusBar(&self.scene), status_layout[1]); frame.render_widget(RecordingStatus(self.recording_audio), status_layout[2]); frame.render_widget(Volume(self.audio_level, self.recording_audio), status_layout[3]); } async fn insert_selected_prompt(&mut self) { let selected = self.scene.reply_options()[self.reply_state.selected().unwrap()].clone(); if let Some(direction) = &selected.stage_direction { self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::StageDirection(direction.clone()))).await; } self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::Eva(selected.text.clone()))).await; } async fn on_command(&mut self, command: &str) { let mut parts = command.splitn(2, " "); let command = parts.next().unwrap(); let arg = parts.next().unwrap_or(""); match command { // FIXME: Need some new kind of /bandcamp command to force loading of specific urls "/episode" => { if let Ok(episode_number) = arg.trim().parse() { self.predictions.insert(PredictionAction::SetEpisodeNumber(episode_number)).await; } else { log::error!("Invalid episode number format. Use /episode [number]"); return; } }, "/timer" => { if let Ok(minutes) = arg.trim().parse::() { let end_time = Utc::now() + Duration::minutes(minutes); self.predictions.insert(PredictionAction::SetShowEndTime(end_time)).await; log::info!("Set timer for {} minutes.", minutes); } else { log::error!("Invalid timer format. Use /timer [minutes]"); } }, "/narrative" => { self.predictions.insert(PredictionAction::SetNarrative(arg.to_string())).await; }, "/event" => { self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::StageDirection(arg.to_string()))).await; }, "/computer" => { self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::ShipComputer(arg.to_string()))).await; }, _ => { log::error!("Unknown command. Available commands: /episode [number], /narrative [text], /event [text], /computer [text], /timer [minutes]"); } } } pub async fn on_event(&mut self, evt: event::Event) { if let Some(key) = evt.as_key_press_event() { match self.focus_state { FocusState::Conversation => { match key.code { KeyCode::Tab => { self.focus_state = FocusState::UserInput; self.conversation_state.select(None); self.reply_state.select_first(); }, KeyCode::PageUp => self.conversation_state.scroll_down_by(5), KeyCode::PageDown => self.conversation_state.scroll_up_by(5), KeyCode::Home => self.conversation_state.select_last(), KeyCode::End => self.conversation_state.select_first(), KeyCode::Down => self.conversation_state.select_previous(), KeyCode::Up => self.conversation_state.select_next(), KeyCode::Enter => { let row_num = self.conversation_state.selected().unwrap(); if let ConversationEntry::Eva(text) = &self.scene.conversation()[self.scene.conversation().len() - 1 - row_num] { self.tts.speak(text.clone()).await; self.focus_state = FocusState::UserInput; self.conversation_state.select(None); self.reply_state.select_first(); } }, _ => () } }, FocusState::UserInput => { match key.code { KeyCode::Tab => { self.focus_state = FocusState::Conversation; self.conversation_state.select_first(); self.reply_state.select(None); }, KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => self.predictions.regenerate_options().await, KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { if self.recording_audio { self.recording_audio = false; self.transcription.stop(); } else { self.recording_audio = true; self.transcription.start(); } }, KeyCode::Down => self.reply_state.select_next(), KeyCode::Up => self.reply_state.select_previous(), KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => { self.insert_selected_prompt().await; }, KeyCode::Enter => { let next_msg = self.user_input.value_and_reset(); if next_msg.trim().is_empty() { self.insert_selected_prompt().await; } else { if next_msg.starts_with("/") { self.on_command(&next_msg).await; } else { self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::User(next_msg))).await; } } }, _ => {self.user_input.handle_event(&evt);}, } } } } } pub async fn update(&mut self) { if self.last_tick.elapsed() >= std::time::Duration::from_millis(100) { self.last_tick = Instant::now(); self.throbber_state.calc_next(); } tokio::select!{ _ = tokio::time::sleep(std::time::Duration::from_millis(60)), if self.is_requesting => (), next_update = self.predictions.changed() => { match next_update { SessionUpdate::Thinking(is_thinking) => self.is_requesting = is_thinking, SessionUpdate::Scene(scene) => { self.scene = scene; self.reply_state.select_first(); } } }, next_volume = self.audio.next() => { self.audio_level = next_volume }, transcription_result = self.transcription.next() => { self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::User(transcription_result))).await; }, } } }