From f269e62e34bd99ec38e6044c4bb840af3d182f4e Mon Sep 17 00:00:00 2001 From: Victoria Fischer Date: Thu, 11 Jun 2026 21:37:23 +0200 Subject: [PATCH] ui: split out lots of code into a widets mod --- src/main.rs | 1 + src/ui.rs | 191 ++++--------------------------------------------- src/widgets.rs | 188 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 178 deletions(-) create mode 100644 src/widgets.rs diff --git a/src/main.rs b/src/main.rs index a4ace8b..2d10ae2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod audio; mod artifacts; mod archive; mod ui; +mod widgets; // 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. diff --git a/src/ui.rs b/src/ui.rs index 7d9fef5..46e7a82 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,12 +1,13 @@ 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 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 { @@ -54,47 +55,6 @@ impl Ui { } } - 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 { @@ -105,34 +65,7 @@ impl Ui { .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 - ); + frame.render_stateful_widget(Options(self.scene.reply_options()), area, &mut self.reply_state); } } @@ -155,46 +88,6 @@ impl Ui { } } - 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) @@ -205,16 +98,6 @@ impl Ui { 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) @@ -231,14 +114,8 @@ impl Ui { .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]); + 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() @@ -248,51 +125,9 @@ impl Ui { 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); + 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) { @@ -313,7 +148,7 @@ impl Ui { 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; + log::error!("Invalid episode number format. Use /episode [number]"); return; } }, @@ -321,9 +156,9 @@ impl Ui { 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; + log::info!("Set timer for {} minutes.", minutes); } else { - self.predictions.log("Invalid timer format. Use /timer [minutes]".into()).await; + log::error!("Invalid timer format. Use /timer [minutes]"); } }, "/narrative" => { @@ -336,7 +171,7 @@ impl Ui { 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; + log::error!("Unknown command. Available commands: /episode [number], /narrative [text], /event [text], /computer [text], /timer [minutes]"); } } } diff --git a/src/widgets.rs b/src/widgets.rs new file mode 100644 index 0000000..82d1828 --- /dev/null +++ b/src/widgets.rs @@ -0,0 +1,188 @@ +use chrono::Duration; +use ratatui::{layout::Rect, style::Style, text::{Line, Text}, widgets::*}; +use tui_skeleton::Block; +use ratatui::prelude::*; + +use crate::{prediction::PossibleResponse, scene::{Scene, conversation::ConversationEntry}}; + +pub struct Options<'a>(pub &'a Vec); + +impl StatefulWidget for Options<'_> { + type State = ListState; + fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer, state: &mut Self::State) { + let borders = Block::bordered().border_style(style::Color::LightGreen).title("Reply Options (Press 'Ctrl+R' to regenerate, Ctrl+Enter to use)"); + let wrap_options = textwrap::Options::new(area.width as usize).subsequent_indent("..."); + let options: Vec = self.0.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(); + let list = 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); + StatefulWidget::render(list, area, buf, state); + } +} + +pub struct Conversation<'a>(pub &'a Vec); + +impl Conversation<'_> { + 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) + } +} + +impl<'a> StatefulWidget for Conversation<'a> { + fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer, state: &mut Self::State) { + let width = area.width.into(); + let items: Vec = self.0.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 + let list = List::new(items) + .block(Block::bordered().border_style(Color::LightYellow).title("Conversation (Press Tab to select and read Eva's lines aloud)")) + .direction(ListDirection::BottomToTop) + .highlight_symbol("> ") + .highlight_style(Style::new().bold().fg(Color::Cyan)); + StatefulWidget::render(list, area, buf, state); + } + + type State = ListState; +} + +pub struct Volume(pub f64, pub bool); + +impl Widget for Volume { + fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer) + where + Self: Sized + { + const NOISE_FLOOR: f64 = 50.; + let vu_pct = 1.0 - (self.0.abs().min(NOISE_FLOOR) / NOISE_FLOOR); + + let volume_color = if self.1 { + 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.0)); + gauge.render(area, buf); + } +} + +pub struct RecordingStatus(pub bool); + +impl Widget for RecordingStatus { + fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer) + where + Self: Sized + { + Line::from_iter( + if self.0 { + [ + 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(" ") + ] + } + ).render(area, buf); + } +} + +pub struct StatusBar<'a>(pub &'a Scene); + +impl Widget for StatusBar<'_> { + fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer) + where + Self: Sized { + let time_remaining: Duration = self.0.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.0.direction().episode_number)).style(ratatui::style::Color::LightBlue), + Span::from(" | ").style(ratatui::style::Color::DarkGray), + Span::from(format!("{} tracks", self.0.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.0.scenery().artifacts.len())).style(ratatui::style::Color::LightBlue), + Span::from(" | ").style(ratatui::style::Color::DarkGray), + Span::from(format!("{} tokens sacrificed", self.0.tokens_consumed)).style(ratatui::style::Color::LightCyan), + ]); + status_line.render(area, buf); + } +} \ No newline at end of file