123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487 |
- use super::BuildProgress;
- use crate::{config::Platform, TraceMsg, TraceSrc};
- use ansi_to_tui::IntoText as _;
- use ratatui::{
- layout::{Alignment, Constraint, Direction, Layout, Rect},
- style::{Color, Style, Stylize},
- text::{Line, Span, Text},
- widgets::{Block, Borders, Clear, List, ListState, Paragraph, Widget, Wrap},
- Frame,
- };
- use regex::Regex;
- use std::fmt::Write as _;
- use std::rc::Rc;
- use tracing::Level;
- pub struct TuiLayout {
- /// The entire TUI body.
- _body: Rc<[Rect]>,
- /// The console where build logs are displayed.
- console: Rc<[Rect]>,
- // The filter drawer if the drawer is open.
- filter_drawer: Option<Rc<[Rect]>>,
- // The border that separates the console and info bars.
- border_sep: Rect,
- // The status bar that displays build status, platform, versions, etc.
- status_bar: Rc<[Rect]>,
- // Misc
- filter_list_state: ListState,
- }
- impl TuiLayout {
- pub fn new(frame_size: Rect, filter_open: bool) -> Self {
- // The full layout
- let body = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- // Footer Status
- Constraint::Length(1),
- // Border Separator
- Constraint::Length(1),
- // Console
- Constraint::Fill(1),
- // Padding
- Constraint::Length(1),
- ])
- .split(frame_size);
- let mut console_constraints = vec![Constraint::Fill(1)];
- if filter_open {
- console_constraints.push(Constraint::Length(1));
- console_constraints.push(Constraint::Length(25));
- }
- // Build the console, where logs go.
- let console = Layout::default()
- .direction(Direction::Horizontal)
- .constraints(console_constraints)
- .split(body[2]);
- let filter_drawer = match filter_open {
- false => None,
- true => Some(
- Layout::default()
- .direction(Direction::Horizontal)
- .constraints([
- Constraint::Length(1),
- Constraint::Fill(1),
- Constraint::Length(1),
- ])
- .split(console[2]),
- ),
- };
- // Build the status bar.
- let status_bar = Layout::default()
- .direction(Direction::Horizontal)
- .constraints([Constraint::Fill(1), Constraint::Fill(1)])
- .split(body[0]);
- // Specify borders
- let border_sep_top = body[1];
- Self {
- _body: body,
- console,
- filter_drawer,
- border_sep: border_sep_top,
- status_bar,
- filter_list_state: ListState::default(),
- }
- }
- /// Render all decorations.
- pub fn render_decor(&self, frame: &mut Frame, filter_open: bool) {
- frame.render_widget(
- Block::new()
- .borders(Borders::TOP)
- .border_style(Style::new().white()),
- self.border_sep,
- );
- if filter_open {
- frame.render_widget(
- Block::new()
- .borders(Borders::LEFT)
- .border_style(Style::new().white()),
- self.console[1],
- );
- }
- }
- /// Render the console and it's logs, returning the number of lines required to render the entire log output.
- pub fn render_console(
- &self,
- frame: &mut Frame,
- scroll_position: u16,
- messages: &[TraceMsg],
- enabled_filters: &[String],
- ) -> u16 {
- const LEVEL_MAX: usize = "BUILD: ".len();
- let mut out_text = Text::default();
- // Assemble the messages
- for msg in messages.iter() {
- let mut sub_line_padding = 0;
- let text = msg.content.trim_end().into_text().unwrap_or_default();
- for (idx, line) in text.lines.into_iter().enumerate() {
- // Don't add any formatting for cargo messages.
- let out_line = if msg.source != TraceSrc::Cargo {
- if idx == 0 {
- match msg.source {
- TraceSrc::Dev => {
- let mut spans = vec![Span::from(" DEV: ").light_magenta()];
- for span in line.spans {
- spans.push(span);
- }
- spans
- }
- TraceSrc::Build => {
- let mut spans = vec![Span::from("BUILD: ").light_blue()];
- for span in line.spans {
- spans.push(span);
- }
- spans
- }
- _ => {
- // Build level tag: `INFO:``
- // We don't subtract 1 here for `:` because we still want at least 1 padding.
- let padding =
- build_msg_padding(LEVEL_MAX - msg.level.to_string().len() - 2);
- let level = format!("{padding}{}: ", msg.level);
- sub_line_padding += level.len();
- let level_span = Span::from(level);
- let level_span = match msg.level {
- Level::TRACE => level_span.black(),
- Level::DEBUG => level_span.light_magenta(),
- Level::INFO => level_span.light_green(),
- Level::WARN => level_span.light_yellow(),
- Level::ERROR => level_span.light_red(),
- };
- let mut out_line = vec![level_span];
- for span in line.spans {
- out_line.push(span);
- }
- out_line
- }
- }
- } else {
- // Not the first line. Append the padding and merge into list.
- let padding = build_msg_padding(sub_line_padding);
- let mut out_line = vec![Span::from(padding)];
- for span in line.spans {
- out_line.push(span);
- }
- out_line
- }
- } else {
- line.spans
- };
- out_text.push_line(Line::from(out_line));
- }
- }
- // Only show messages for filters that are enabled.
- let mut included_line_ids = Vec::new();
- for filter in enabled_filters {
- let re = Regex::new(filter);
- for (index, line) in out_text.lines.iter().enumerate() {
- let line_str = line.to_string();
- match re {
- Ok(ref re) => {
- // sort by provided regex
- if re.is_match(&line_str) {
- included_line_ids.push(index);
- }
- }
- Err(_) => {
- // default to basic string storing
- if line_str.contains(filter) {
- included_line_ids.push(index);
- }
- }
- }
- }
- }
- included_line_ids.sort_unstable();
- included_line_ids.dedup();
- let out_lines = out_text.lines;
- let mut out_text = Text::default();
- if enabled_filters.is_empty() {
- for line in out_lines {
- out_text.push_line(line.clone());
- }
- } else {
- for id in included_line_ids {
- if let Some(line) = out_lines.get(id) {
- out_text.push_line(line.clone());
- }
- }
- }
- let (console_width, _console_height) = self.get_console_size();
- let paragraph = Paragraph::new(out_text)
- .left_aligned()
- .wrap(Wrap { trim: false });
- let num_lines_wrapping = paragraph.line_count(console_width) as u16;
- paragraph
- .scroll((scroll_position, 0))
- .render(self.console[0], frame.buffer_mut());
- num_lines_wrapping
- }
- /// Render the status bar.
- pub fn render_status_bar(
- &self,
- frame: &mut Frame,
- _platform: Platform,
- build_progress: &BuildProgress,
- more_modal_open: bool,
- filter_menu_open: bool,
- dx_version: &str,
- ) {
- // left aligned text
- let mut spans = vec![
- Span::from("🧬 dx").white(),
- Span::from(" ").white(),
- Span::from(dx_version).white(),
- Span::from(" | ").dark_gray(),
- ];
- // If there is build progress, render the current status.
- let is_build_progress = !build_progress.current_builds.is_empty();
- if is_build_progress {
- // If the build failed, show a failed status.
- // Otherwise, render current status.
- let build_failed = build_progress
- .current_builds
- .values()
- .any(|b| b.failed.is_some());
- if build_failed {
- spans.push(Span::from("Build failed ❌").red());
- } else {
- // spans.push(Span::from("status: ").gray());
- let build = build_progress
- .current_builds
- .values()
- .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
- .unwrap();
- spans.extend_from_slice(&build.make_spans(Rect::new(
- 0,
- 0,
- build.max_layout_size(),
- 1,
- )));
- }
- }
- // right aligned text
- let more_span = Span::from("[/] more");
- let more_span = match more_modal_open {
- true => more_span.light_yellow(),
- false => more_span.gray(),
- };
- let filter_span = Span::from("[f] filter");
- let filter_span = match filter_menu_open {
- true => filter_span.light_yellow(),
- false => filter_span.gray(),
- };
- // Right-aligned text
- let right_line = Line::from(vec![
- Span::from("[o] open").gray(),
- Span::from(" | ").gray(),
- Span::from("[r] rebuild").gray(),
- Span::from(" | ").gray(),
- filter_span,
- Span::from(" | ").dark_gray(),
- more_span,
- ]);
- frame.render_widget(
- Paragraph::new(Line::from(spans)).left_aligned(),
- self.status_bar[0],
- );
- // Render the info
- frame.render_widget(
- Paragraph::new(right_line).right_aligned(),
- self.status_bar[1],
- );
- }
- /// Renders the "more" modal to show extra info/keybinds accessible via the more keybind.
- pub fn render_more_modal(&self, frame: &mut Frame) {
- let modal = Layout::default()
- .direction(Direction::Vertical)
- .constraints([Constraint::Fill(1), Constraint::Length(5)])
- .split(self.console[0])[1];
- frame.render_widget(Clear, modal);
- frame.render_widget(Block::default().borders(Borders::ALL), modal);
- // Render under construction message
- frame.render_widget(
- Paragraph::new("Under construction, please check back at a later date!")
- .alignment(Alignment::Center),
- modal,
- );
- }
- /// Render the filter drawer menu.
- pub fn render_filter_menu(
- &mut self,
- frame: &mut Frame,
- filters: &[(String, bool)],
- selected_filter_index: usize,
- search_mode: bool,
- search_input: Option<&String>,
- ) {
- let Some(ref filter_drawer) = self.filter_drawer else {
- return;
- };
- // Vertical layout
- let container = Layout::default()
- .constraints([
- Constraint::Length(4),
- Constraint::Fill(1),
- Constraint::Length(7),
- ])
- .direction(Direction::Vertical)
- .split(filter_drawer[1]);
- // Render the search section.
- let top_area = Layout::default()
- .constraints([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- ])
- .direction(Direction::Vertical)
- .split(container[0]);
- let search_title = Line::from("Search").gray();
- let search_input_block = Block::new().bg(Color::White);
- let search_text = match search_input {
- Some(s) => s,
- None => {
- if search_mode {
- "..."
- } else {
- "[enter] to type..."
- }
- }
- };
- let search_input = Paragraph::new(Line::from(search_text))
- .fg(Color::Black)
- .block(search_input_block);
- frame.render_widget(search_title, top_area[1]);
- frame.render_widget(search_input, top_area[2]);
- // Render the filters
- let list_area = container[1];
- let mut list_items = Vec::new();
- for (filter, enabled) in filters {
- let filter = Span::from(filter);
- let filter = match enabled {
- true => filter.light_yellow(),
- false => filter.dark_gray(),
- };
- list_items.push(filter);
- }
- list_items.reverse();
- let list = List::new(list_items).highlight_symbol("» ");
- self.filter_list_state.select(Some(selected_filter_index));
- frame.render_stateful_widget(list, list_area, &mut self.filter_list_state);
- // Render the keybind list at the bottom.
- let keybinds = container[2];
- let lines = vec![
- Line::from(""),
- Line::from("[↑] Up").white(),
- Line::from("[↓] Down").white(),
- Line::from("[←] Remove").white(),
- Line::from("[→] Toggle").white(),
- Line::from("[enter] Type / Submit").white(),
- ];
- let text = Text::from(lines);
- frame.render_widget(text, keybinds);
- }
- /// Returns the height of the console TUI area in number of lines.
- pub fn get_console_size(&self) -> (u16, u16) {
- (self.console[0].width, self.console[0].height)
- }
- /// Render the current scroll position at the top right corner of the frame
- pub(crate) fn render_current_scroll(
- &self,
- scroll_position: u16,
- lines: u16,
- console_height: u16,
- frame: &mut Frame<'_>,
- ) {
- let mut row = Layout::default()
- .direction(Direction::Vertical)
- .constraints([Constraint::Length(1)])
- .split(self.console[0])[0];
- // Hack: shove upwards the text to overlap with the border so text selection doesn't accidentally capture the number
- row.y -= 1;
- let max_scroll = lines.saturating_sub(console_height);
- if max_scroll == 0 {
- return;
- }
- let remaining_lines = max_scroll.saturating_sub(scroll_position);
- if remaining_lines != 0 {
- let text = vec![Span::from(format!(" {remaining_lines}⬇ ")).dark_gray()];
- frame.render_widget(
- Paragraph::new(Line::from(text))
- .alignment(Alignment::Right)
- .block(Block::default()),
- row,
- );
- }
- }
- }
- /// Generate a string with a specified number of spaces.
- fn build_msg_padding(padding_len: usize) -> String {
- let mut padding = String::new();
- for _ in 0..padding_len {
- _ = write!(padding, " ");
- }
- padding
- }
|