ui: split out lots of code into a widets mod

This commit is contained in:
2026-06-11 21:37:23 +02:00
parent 5aa2631b99
commit f269e62e34
3 changed files with 202 additions and 178 deletions
+1
View File
@@ -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.
+13 -178
View File
@@ -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<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 {
@@ -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<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
);
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<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)
@@ -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::<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;
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]");
}
}
}
+188
View File
@@ -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<PossibleResponse>);
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<Text> = self.0.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();
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<ConversationEntry>);
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<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)
}
}
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<Text> = 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);
}
}