ui: split out lots of code into a widets mod
This commit is contained in:
@@ -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]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user