From b1453b3fbca2dc685977376bb8fc4e97a6de8dce Mon Sep 17 00:00:00 2001 From: Victoria Fischer Date: Wed, 10 Jun 2026 00:13:13 +0200 Subject: [PATCH] ui: split out the app struct into a new ui mod --- src/main.rs | 467 +------------------------------------- src/scene/conversation.rs | 42 ++++ src/scene/mod.rs | 6 +- src/ui.rs | 439 +++++++++++++++++++++++++++++++++++ 4 files changed, 493 insertions(+), 461 deletions(-) create mode 100644 src/ui.rs diff --git a/src/main.rs b/src/main.rs index 9bbddca..a4ace8b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,22 +1,15 @@ use std::sync::Arc; use async_openai::types::chat::ChatCompletionRequestMessage; -use chrono::{Duration, Utc}; -use futures_timer::Delay; use serde::{Deserialize, Serialize}; -use ratatui::{Frame, layout::{Constraint, Direction, Layout}, widgets::{Block, BorderType, Clear, Gauge, List, ListDirection, ListState, Paragraph, Wrap}}; use static_cell::StaticCell; -use throbber_widgets_tui::{Throbber, ThrobberState}; use crossterm::{event::{self, EventStream, KeyCode, KeyModifiers}}; -use tokio::time::Instant; -use tui_input::{Input, backend::crossterm::EventHandler}; -use futures::{StreamExt, future::FutureExt}; +use futures::StreamExt; use ratatui::prelude::*; -use tui_skeleton::{AnimationMode, SkeletonText}; -use crate::{audio::{AudioInputControl, start_audio_input}, prediction::{PredictionAction, SessionControl, SessionUpdate}, scene::{Scene, Scenery, StageDirection, conversation::ConversationEntry}, transcription::TranscriptionControl, tts::{TtsControl, start_tts}}; +use crate::{audio::start_audio_input, scene::{Scenery, StageDirection}, tts::start_tts, ui::Ui}; mod scene; mod events; @@ -26,6 +19,7 @@ mod prediction; mod audio; mod artifacts; mod archive; +mod ui; // TODO: We should be able to delete entries from the conversation, or at least go back and edit something I said. // TODO: I want a "mark" command or keyboard shortcut, that inserts a marker into the log, so I know where to come back for the next speaking segment. @@ -56,430 +50,6 @@ mod archive; - Right panel: Shortcuts for triggering scenarios, soundboard events, etc */ -#[derive(Debug)] -struct App { - 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, - - transcription: TranscriptionControl, - audio: AudioInputControl, - tts: TtsControl, - predictions: SessionControl -} - -#[derive(Debug)] -enum FocusState { - Conversation, - UserInput -} - -impl App { - 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 - } - } - - fn format_line<'a>(entry: &ConversationEntry, max_width: usize) -> Text<'a> { - let prefix = match entry { - ConversationEntry::Eva(_) => "Eva: ", - ConversationEntry::User(_) => "Argee: ", - ConversationEntry::ShipComputer(_) => "Ship Computer: ", - _ => "", - }; - - let style = match entry { - ConversationEntry::Eva(_) => Style::new().fg(style::Color::Cyan), - ConversationEntry::User(_) => Style::new().fg(style::Color::Magenta), - ConversationEntry::ShipComputer(_) => Style::new().fg(style::Color::Green), - ConversationEntry::StageDirection(_) => Style::new().fg(style::Color::Yellow), - ConversationEntry::SystemMessage(_) => Style::new().fg(style::Color::DarkGray), - }; - - let text_style = match entry { - ConversationEntry::StageDirection(_) => Style::new().fg(style::Color::Yellow), - ConversationEntry::SystemMessage(_) => Style::new().fg(style::Color::DarkGray), - _ => Style::new() - }; - - let text = match entry { - ConversationEntry::Eva(text) => text, - ConversationEntry::ShipComputer(text) => text, - ConversationEntry::StageDirection(text) => text, - ConversationEntry::SystemMessage(text) => text, - ConversationEntry::User(text) => text - }; - - let avail_width = max_width - prefix.len(); - let indent = " ".repeat(prefix.len()); - let wrap_options = textwrap::Options::new(avail_width).initial_indent(prefix).subsequent_indent(&indent); - let wrapped: Vec = textwrap::wrap(text, wrap_options) - .iter() - .enumerate() - .map(|(idx, s)| { - if idx == 0 { - Line::from_iter([Span::from(prefix).style(style), Span::from(s[prefix.len()..].to_string()).style(text_style)]) - } else { - Line::from(s.to_string()).style(text_style) - } - }).collect(); - - Text::from_iter(wrapped) - } - - fn draw_conversation(&mut self, frame: &mut Frame, area: Rect) { - let width = area.width.into(); - let items: Vec = self.scene.conversation().iter().rev().map(|entry| { - Self::format_line(entry, width) - }).collect(); - // TODO: Would be nice to be able to scroll a longer conversation with the scroll wheel, or with page up/down - frame.render_stateful_widget( - List::new(items) - .block(Block::bordered().border_style(style::Color::LightYellow).title("Conversation (Press Tab to select and read Eva's lines aloud)")) - .direction(ListDirection::BottomToTop) - .highlight_symbol("> ") - .highlight_style(style::Style::new().bold().fg(style::Color::Cyan)), - area, - &mut self.conversation_state - ); - } - - 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 { - let wrap_options = textwrap::Options::new(area.width as usize).subsequent_indent("..."); - let options: Vec = self.scene.reply_options().iter().map(|option| { - let mut contents: Vec = vec![]; - - if let Some(direction) = &option.stage_direction { - let padded = format!("({})", direction); - let mut wrapped_direction: Vec = textwrap::wrap(&padded, wrap_options.clone()) - .iter() - .map(|x| { Line::from(x.to_string()).fg(style::Color::Yellow)}).collect(); - contents.append(&mut wrapped_direction); - } - - let mut text: Vec = textwrap::wrap(&option.text, wrap_options.clone()) - .iter() - .map(|x| { Line::from(x.to_string())}).collect(); - contents.append(&mut text); - Text::from_iter(contents) - }).collect(); - frame.render_stateful_widget( - List::new(options) - .block(borders) - .style(ratatui::style::Color::White) - .highlight_symbol("> ".fg(Color::Cyan)) - .highlight_style(style::Style::new().bold().bg(style::Color::DarkGray)) - .repeat_highlight_symbol(true), - 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_status(&self, frame: &mut Frame, area: Rect) { - let time_remaining: Duration = self.scene.direction.time_remaining(); - let minutes_remaining = time_remaining.num_seconds() / 60; - - let negative = time_remaining.abs() != time_remaining; - - let time_style = if minutes_remaining <= 0 || negative { - Style::new().fg(ratatui::style::Color::LightRed).bold() - } else if minutes_remaining < 5 { - Style::new().fg(ratatui::style::Color::LightRed).bold() - } else if minutes_remaining < 10 { - ratatui::style::Color::Red.into() - } else if minutes_remaining < 25 { - ratatui::style::Color::Yellow.into() - } else if minutes_remaining < 60 { - ratatui::style::Color::Green.into() - } else { - ratatui::style::Color::Blue.into() - }; - - let formatted_time = if negative { - format!("-{:0>2}:{:0>2}:{:0>2}", time_remaining.num_hours().abs(), time_remaining.num_minutes().abs()% 60, time_remaining.num_seconds().abs() % 60) - } else { - format!("{:0>2}:{:0>2}:{:0>2}", time_remaining.num_hours(), time_remaining.num_minutes() % 60, time_remaining.num_seconds() % 60) - }; - - let status_line = Line::from_iter([ - Span::from(format!("Episode {}", self.scene.direction.episode_number)).style(ratatui::style::Color::LightBlue), - Span::from(" | ").style(ratatui::style::Color::DarkGray), - Span::from(format!("{} tracks", self.scene.scenery().current_playlist.len())).style(ratatui::style::Color::LightBlue), - Span::from(" | ").style(ratatui::style::Color::DarkGray), - Span::from(format!("Time Remaining: {}", formatted_time)).style(time_style), - Span::from(" | ").style(ratatui::style::Color::DarkGray), - Span::from(format!("{} artifacts recorded", self.scene.scenery().artifacts.len())).style(ratatui::style::Color::LightBlue), - Span::from(" | ").style(ratatui::style::Color::DarkGray), - Span::from(format!("{} tokens sacrificed", self.scene.tokens_consumed)).style(ratatui::style::Color::LightCyan), - ]); - frame.render_widget(status_line, 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); - } - - fn draw_event_log(&self, frame: &mut Frame, area: Rect) { - let items: Vec = self.scene.conversation().iter().filter(|entry| { if let ConversationEntry::StageDirection(_) = entry { true } else { false }}).rev().map(|entry| { - match entry { - ConversationEntry::StageDirection(text) => Line::from_iter([text]).style(ratatui::style::Color::Yellow), - _ => unreachable!() - } - }).collect(); - frame.render_widget(Paragraph::new(items).block(Block::bordered().border_style(style::Color::Red).title("Event Log")).wrap(Wrap { trim: false }), area); - } - - 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]); - - let context_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Fill(1), Constraint::Fill(1)]) - .split(scene_layout[1]); - - self.draw_conversation(frame, scene_layout[0]); - self.draw_narration(frame, context_layout[0]); - self.draw_event_log(frame, context_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]); - self.draw_status(frame, status_layout[1]); - self.draw_recording_status(frame, status_layout[2]); - self.draw_volume(frame, status_layout[3]); - } - - fn draw_recording_status(&self, frame: &mut Frame, area: Rect) { - frame.render_widget(Line::from_iter( - if self.recording_audio { - [ - Span::from(" "), - Span::from(" Recording ").bg(style::Color::LightRed).fg(style::Color::White), - Span::from(" ") - ] - } else { - [ - Span::from(" "), - Span::from(" Stopped ").bg(style::Color::DarkGray).fg(style::Color::White), - Span::from(" ") - ] - } - ), area); - } - - fn draw_volume(&self, frame: &mut Frame, area: Rect) { - const NOISE_FLOOR: f64 = 50.; - let vu_pct = 1.0 - (self.audio_level.abs().min(NOISE_FLOOR) / NOISE_FLOOR); - - let volume_color = if self.recording_audio { - if vu_pct >= 0.85 { - style::Color::Red - } else if vu_pct >= 0.60 { - style::Color::Yellow - } else { - style::Color::LightGreen - } - } else { - style::Color::Gray - }; - - let gauge = Gauge::default() - .ratio(vu_pct) - .use_unicode(true) - .gauge_style(volume_color) - .label(format!("{:.01}dB", self.audio_level)); - frame.render_widget(gauge, area); - } - - 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 { - self.predictions.log("Invalid episode number format. Use /episode [number]".into()).await; - 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; - self.predictions.log(format!("Set timer for {} minutes.", minutes)).await; - } else { - self.predictions.log("Invalid timer format. Use /timer [minutes]".into()).await; - } - }, - "/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; - }, - _ => { - self.predictions.log("Unknown command. Available commands: /episode [number], /narrative [text], /event [text], /computer [text], /timer [minutes]".into()).await; - } - } - } - - 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);}, - } - } - } - } - } -} - #[derive(Serialize, Deserialize, Debug, Default)] pub struct SaveData { @@ -559,39 +129,16 @@ async fn main() { let tts_ctrl = start_tts(tts_output).await; let transcription_ctrl = transcription::start_transcription(mic_stream).await; - let mut app = App::new(prediction_ctrl, audio_ctrl, transcription_ctrl, tts_ctrl); + let mut app = Ui::new(prediction_ctrl, audio_ctrl, transcription_ctrl, tts_ctrl); let mut events = EventStream::new(); - let mut last_tick = Instant::now(); loop { - if last_tick.elapsed() >= std::time::Duration::from_millis(100) { - last_tick = Instant::now(); - app.throbber_state.calc_next(); - } terminal.draw(|frame| { app.draw(frame)}).unwrap(); - let delay = Delay::new(std::time::Duration::from_millis(60)).fuse(); - let event = events.next().fuse(); - tokio::select! { - _ = delay => (), - next_update = app.predictions.changed() => { - match next_update { - SessionUpdate::Thinking(is_thinking) => app.is_requesting = is_thinking, - SessionUpdate::Scene(scene) => { - app.scene = scene; - app.reply_state.select_first(); - } - } - }, - next_volume = app.audio.next() => { - app.audio_level = next_volume - }, - transcription_result = app.transcription.next() => { - app.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::User(transcription_result))).await; - }, - maybe_event = event => { + _ = app.update() => {}, + maybe_event = events.next() => { match maybe_event { Some(Ok(event)) => { @@ -603,7 +150,7 @@ async fn main() { app.on_event(event).await; }, - _ => () + _ => break } } }; diff --git a/src/scene/conversation.rs b/src/scene/conversation.rs index 97f5cc6..ea442fe 100644 --- a/src/scene/conversation.rs +++ b/src/scene/conversation.rs @@ -1,4 +1,5 @@ use async_openai::types::chat::{ChatCompletionRequestAssistantMessage, ChatCompletionRequestAssistantMessageContent, ChatCompletionRequestMessage, ChatCompletionRequestSystemMessage, ChatCompletionRequestSystemMessageContent, ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent}; +use ratatui::style::{self, Style}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -11,6 +12,47 @@ pub enum ConversationEntry { SystemMessage(String) } +impl ConversationEntry { + pub fn prefix(&self) -> Option<&str> { + match self { + ConversationEntry::Eva(_) => Some("Eva: "), + ConversationEntry::User(_) => Some("Argee: "), + ConversationEntry::ShipComputer(_) => Some("Ship Computer: "), + _ => None, + } + } + + pub fn prefix_style(&self) -> Style { + match self { + ConversationEntry::Eva(_) => Style::new().fg(style::Color::Cyan), + ConversationEntry::User(_) => Style::new().fg(style::Color::Magenta), + ConversationEntry::ShipComputer(_) => Style::new().fg(style::Color::Green), + ConversationEntry::StageDirection(_) => Style::new().fg(style::Color::Yellow), + ConversationEntry::SystemMessage(_) => Style::new().fg(style::Color::DarkGray), + } + } + + pub fn text_style(&self) -> Style { + match self { + ConversationEntry::StageDirection(_) => Style::new().fg(style::Color::Yellow), + ConversationEntry::SystemMessage(_) => Style::new().fg(style::Color::DarkGray), + _ => Style::new() + } + } +} + +impl ToString for ConversationEntry { + fn to_string(&self) -> String { + match self { + ConversationEntry::Eva(text) => text, + ConversationEntry::ShipComputer(text) => text, + ConversationEntry::StageDirection(text) => text, + ConversationEntry::SystemMessage(text) => text, + ConversationEntry::User(text) => text + }.clone() + } +} + impl TryInto for ConversationEntry { fn try_into(self) -> Result { match self { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 5e6de37..95ae057 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -39,7 +39,7 @@ pub struct Scenery { pub struct Scene { reply_options: GeneratedResponses, conversation: Vec, - pub direction: StageDirection, + direction: StageDirection, pub tokens_consumed: usize, scenery: Scenery } @@ -55,6 +55,10 @@ impl Scene { } } + pub fn direction(&self) -> &StageDirection { + &self.direction + } + pub fn scenery(&self) -> &Scenery { &self.scenery } diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..7d9fef5 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,439 @@ +use chrono::{Duration, Utc}; +use crossterm::event::{self, KeyCode, KeyModifiers}; +use ratatui::{Frame, layout::{Direction, Layout, Rect}, style::{self, Style, Stylize}, text::{Line, Span, Text}, widgets::{BorderType, Clear, Gauge, List, ListDirection, ListState, Paragraph, 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}; + +#[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 format_line<'a>(entry: &'a ConversationEntry, max_width: usize) -> Text<'a> { + let prefix = entry.prefix().unwrap_or_default(); + + let style = entry.prefix_style(); + + let text_style = entry.text_style(); + + let avail_width = max_width - prefix.len(); + let indent = " ".repeat(prefix.len()); + let wrap_options = textwrap::Options::new(avail_width).initial_indent(prefix).subsequent_indent(&indent); + let wrapped: Vec = textwrap::wrap(&entry.to_string(), wrap_options) + .iter() + .enumerate() + .map(|(idx, s)| { + if idx == 0 { + Line::from_iter([Span::from(prefix).style(style), Span::from(s[prefix.len()..].to_string()).style(text_style)]) + } else { + Line::from(s.to_string()).style(text_style) + } + }).collect(); + + Text::from_iter(wrapped) + } + + fn draw_conversation(&mut self, frame: &mut Frame, area: Rect) { + let width = area.width.into(); + let items: Vec = self.scene.conversation().iter().rev().map(|entry| { + Self::format_line(entry, width) + }).collect(); + // TODO: Would be nice to be able to scroll a longer conversation with the scroll wheel, or with page up/down + frame.render_stateful_widget( + List::new(items) + .block(Block::bordered().border_style(style::Color::LightYellow).title("Conversation (Press Tab to select and read Eva's lines aloud)")) + .direction(ListDirection::BottomToTop) + .highlight_symbol("> ") + .highlight_style(style::Style::new().bold().fg(style::Color::Cyan)), + area, + &mut self.conversation_state + ); + } + + 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 { + let wrap_options = textwrap::Options::new(area.width as usize).subsequent_indent("..."); + let options: Vec = self.scene.reply_options().iter().map(|option| { + let mut contents: Vec = vec![]; + + if let Some(direction) = &option.stage_direction { + let padded = format!("({})", direction); + let mut wrapped_direction: Vec = textwrap::wrap(&padded, wrap_options.clone()) + .iter() + .map(|x| { Line::from(x.to_string()).fg(style::Color::Yellow)}).collect(); + contents.append(&mut wrapped_direction); + } + + let mut text: Vec = textwrap::wrap(&option.text, wrap_options.clone()) + .iter() + .map(|x| { Line::from(x.to_string())}).collect(); + contents.append(&mut text); + Text::from_iter(contents) + }).collect(); + frame.render_stateful_widget( + List::new(options) + .block(borders) + .style(ratatui::style::Color::White) + .highlight_symbol("> ".fg(ratatui::style::Color::Cyan)) + .highlight_style(style::Style::new().bold().bg(style::Color::DarkGray)) + .repeat_highlight_symbol(true), + 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_status(&self, frame: &mut Frame, area: Rect) { + let time_remaining: Duration = self.scene.direction().time_remaining(); + let minutes_remaining = time_remaining.num_seconds() / 60; + + let negative = time_remaining.abs() != time_remaining; + + let time_style = if minutes_remaining <= 0 || negative { + Style::new().fg(ratatui::style::Color::LightRed).bold() + } else if minutes_remaining < 5 { + Style::new().fg(ratatui::style::Color::LightRed).bold() + } else if minutes_remaining < 10 { + ratatui::style::Color::Red.into() + } else if minutes_remaining < 25 { + ratatui::style::Color::Yellow.into() + } else if minutes_remaining < 60 { + ratatui::style::Color::Green.into() + } else { + ratatui::style::Color::Blue.into() + }; + + let formatted_time = if negative { + format!("-{:0>2}:{:0>2}:{:0>2}", time_remaining.num_hours().abs(), time_remaining.num_minutes().abs()% 60, time_remaining.num_seconds().abs() % 60) + } else { + format!("{:0>2}:{:0>2}:{:0>2}", time_remaining.num_hours(), time_remaining.num_minutes() % 60, time_remaining.num_seconds() % 60) + }; + + let status_line = Line::from_iter([ + Span::from(format!("Episode {}", self.scene.direction().episode_number)).style(ratatui::style::Color::LightBlue), + Span::from(" | ").style(ratatui::style::Color::DarkGray), + Span::from(format!("{} tracks", self.scene.scenery().current_playlist.len())).style(ratatui::style::Color::LightBlue), + Span::from(" | ").style(ratatui::style::Color::DarkGray), + Span::from(format!("Time Remaining: {}", formatted_time)).style(time_style), + Span::from(" | ").style(ratatui::style::Color::DarkGray), + Span::from(format!("{} artifacts recorded", self.scene.scenery().artifacts.len())).style(ratatui::style::Color::LightBlue), + Span::from(" | ").style(ratatui::style::Color::DarkGray), + Span::from(format!("{} tokens sacrificed", self.scene.tokens_consumed)).style(ratatui::style::Color::LightCyan), + ]); + frame.render_widget(status_line, 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); + } + + fn draw_event_log(&self, frame: &mut Frame, area: Rect) { + let items: Vec = self.scene.conversation().iter().filter(|entry| { if let ConversationEntry::StageDirection(_) = entry { true } else { false }}).rev().map(|entry| { + match entry { + ConversationEntry::StageDirection(text) => Line::from_iter([text]).style(ratatui::style::Color::Yellow), + _ => unreachable!() + } + }).collect(); + frame.render_widget(Paragraph::new(items).block(Block::bordered().border_style(style::Color::Red).title("Event Log")).wrap(Wrap { trim: false }), 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]); + + let context_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Fill(1), Constraint::Fill(1)]) + .split(scene_layout[1]); + + self.draw_conversation(frame, scene_layout[0]); + self.draw_narration(frame, context_layout[0]); + self.draw_event_log(frame, context_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]); + self.draw_status(frame, status_layout[1]); + self.draw_recording_status(frame, status_layout[2]); + self.draw_volume(frame, status_layout[3]); + } + + fn draw_recording_status(&self, frame: &mut Frame, area: Rect) { + frame.render_widget(Line::from_iter( + if self.recording_audio { + [ + Span::from(" "), + Span::from(" Recording ").bg(style::Color::LightRed).fg(style::Color::White), + Span::from(" ") + ] + } else { + [ + Span::from(" "), + Span::from(" Stopped ").bg(style::Color::DarkGray).fg(style::Color::White), + Span::from(" ") + ] + } + ), area); + } + + fn draw_volume(&self, frame: &mut Frame, area: Rect) { + const NOISE_FLOOR: f64 = 50.; + let vu_pct = 1.0 - (self.audio_level.abs().min(NOISE_FLOOR) / NOISE_FLOOR); + + let volume_color = if self.recording_audio { + if vu_pct >= 0.85 { + style::Color::Red + } else if vu_pct >= 0.60 { + style::Color::Yellow + } else { + style::Color::LightGreen + } + } else { + style::Color::Gray + }; + + let gauge = Gauge::default() + .ratio(vu_pct) + .use_unicode(true) + .gauge_style(volume_color) + .label(format!("{:.01}dB", self.audio_level)); + frame.render_widget(gauge, area); + } + + 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 { + self.predictions.log("Invalid episode number format. Use /episode [number]".into()).await; + 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; + self.predictions.log(format!("Set timer for {} minutes.", minutes)).await; + } else { + self.predictions.log("Invalid timer format. Use /timer [minutes]".into()).await; + } + }, + "/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; + }, + _ => { + self.predictions.log("Unknown command. Available commands: /episode [number], /narrative [text], /event [text], /computer [text], /timer [minutes]".into()).await; + } + } + } + + 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; + }, + } + } +} \ No newline at end of file