Files
eva-pwm-cohost/src/ui.rs
T

283 lines
13 KiB
Rust

use chrono::{Duration, Utc};
use crossterm::event::{self, KeyCode, KeyModifiers};
use ratatui::{Frame, layout::{Direction, Layout, Rect}, style::{self, }, text::Span, widgets::{BorderType, Clear, ListState, Paragraph}};
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,
conversation: Vec<ConversationEntry>
}
#[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(),
conversation: vec![]
}
}
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.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::<u32>() {
let playlist_name = format!("BFF.fm - Episode {}", episode_number);
self.predictions.insert(PredictionAction::SetPlaylist(playlist_name)).await;
} else {
log::error!("Invalid episode number format. Use /episode [number]");
return;
}
},
"/playlist" => {
self.predictions.insert(PredictionAction::SetPlaylist(arg.trim().to_string())).await;
}
"/timer" => {
if let Ok(minutes) = arg.trim().parse::<i64>() {
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.conversation[self.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();
},
SessionUpdate::Conversation(conversation) => {
self.conversation = conversation;
},
}
},
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;
},
}
}
}