ui: split out the app struct into a new ui mod

This commit is contained in:
2026-06-10 00:13:13 +02:00
parent cac1822734
commit b1453b3fbc
4 changed files with 493 additions and 461 deletions
+7 -460
View File
@@ -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<Line> = 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<Text> = 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<Text> = self.scene.reply_options().iter().map(|option| {
let mut contents: Vec<Line> = vec![];
if let Some(direction) = &option.stage_direction {
let padded = format!("({})", direction);
let mut wrapped_direction: Vec<Line> = 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<Line> = 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<Line> = 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::<i64>() {
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
}
}
};
+42
View File
@@ -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<ChatCompletionRequestMessage> for ConversationEntry {
fn try_into(self) -> Result<ChatCompletionRequestMessage, Self::Error> {
match self {
+5 -1
View File
@@ -39,7 +39,7 @@ pub struct Scenery {
pub struct Scene {
reply_options: GeneratedResponses,
conversation: Vec<ConversationEntry>,
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
}
+439
View File
@@ -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<Line> = 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<Text> = 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<Text> = self.scene.reply_options().iter().map(|option| {
let mut contents: Vec<Line> = vec![];
if let Some(direction) = &option.stage_direction {
let padded = format!("({})", direction);
let mut wrapped_direction: Vec<Line> = 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<Line> = 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<Line> = 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::<i64>() {
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;
},
}
}
}