274 lines
13 KiB
Rust
274 lines
13 KiB
Rust
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::<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.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;
|
|
},
|
|
}
|
|
}
|
|
} |