render.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. use super::BuildProgress;
  2. use crate::{config::Platform, TraceMsg, TraceSrc};
  3. use ansi_to_tui::IntoText as _;
  4. use ratatui::{
  5. layout::{Alignment, Constraint, Direction, Layout, Rect},
  6. style::{Color, Style, Stylize},
  7. text::{Line, Span, Text},
  8. widgets::{Block, Borders, Clear, List, ListState, Paragraph, Widget, Wrap},
  9. Frame,
  10. };
  11. use regex::Regex;
  12. use std::fmt::Write as _;
  13. use std::rc::Rc;
  14. use tracing::Level;
  15. pub struct TuiLayout {
  16. /// The entire TUI body.
  17. _body: Rc<[Rect]>,
  18. /// The console where build logs are displayed.
  19. console: Rc<[Rect]>,
  20. // The filter drawer if the drawer is open.
  21. filter_drawer: Option<Rc<[Rect]>>,
  22. // The border that separates the console and info bars.
  23. border_sep: Rect,
  24. // The status bar that displays build status, platform, versions, etc.
  25. status_bar: Rc<[Rect]>,
  26. // Misc
  27. filter_list_state: ListState,
  28. }
  29. impl TuiLayout {
  30. pub fn new(frame_size: Rect, filter_open: bool) -> Self {
  31. // The full layout
  32. let body = Layout::default()
  33. .direction(Direction::Vertical)
  34. .constraints([
  35. // Footer Status
  36. Constraint::Length(1),
  37. // Border Separator
  38. Constraint::Length(1),
  39. // Console
  40. Constraint::Fill(1),
  41. // Padding
  42. Constraint::Length(1),
  43. ])
  44. .split(frame_size);
  45. let mut console_constraints = vec![Constraint::Fill(1)];
  46. if filter_open {
  47. console_constraints.push(Constraint::Length(1));
  48. console_constraints.push(Constraint::Length(25));
  49. }
  50. // Build the console, where logs go.
  51. let console = Layout::default()
  52. .direction(Direction::Horizontal)
  53. .constraints(console_constraints)
  54. .split(body[2]);
  55. let filter_drawer = match filter_open {
  56. false => None,
  57. true => Some(
  58. Layout::default()
  59. .direction(Direction::Horizontal)
  60. .constraints([
  61. Constraint::Length(1),
  62. Constraint::Fill(1),
  63. Constraint::Length(1),
  64. ])
  65. .split(console[2]),
  66. ),
  67. };
  68. // Build the status bar.
  69. let status_bar = Layout::default()
  70. .direction(Direction::Horizontal)
  71. .constraints([Constraint::Fill(1), Constraint::Fill(1)])
  72. .split(body[0]);
  73. // Specify borders
  74. let border_sep_top = body[1];
  75. Self {
  76. _body: body,
  77. console,
  78. filter_drawer,
  79. border_sep: border_sep_top,
  80. status_bar,
  81. filter_list_state: ListState::default(),
  82. }
  83. }
  84. /// Render all decorations.
  85. pub fn render_decor(&self, frame: &mut Frame, filter_open: bool) {
  86. frame.render_widget(
  87. Block::new()
  88. .borders(Borders::TOP)
  89. .border_style(Style::new().white()),
  90. self.border_sep,
  91. );
  92. if filter_open {
  93. frame.render_widget(
  94. Block::new()
  95. .borders(Borders::LEFT)
  96. .border_style(Style::new().white()),
  97. self.console[1],
  98. );
  99. }
  100. }
  101. /// Render the console and it's logs, returning the number of lines required to render the entire log output.
  102. pub fn render_console(
  103. &self,
  104. frame: &mut Frame,
  105. scroll_position: u16,
  106. messages: &[TraceMsg],
  107. enabled_filters: &[String],
  108. ) -> u16 {
  109. const LEVEL_MAX: usize = "BUILD: ".len();
  110. let mut out_text = Text::default();
  111. // Assemble the messages
  112. for msg in messages.iter() {
  113. let mut sub_line_padding = 0;
  114. let text = msg.content.trim_end().into_text().unwrap_or_default();
  115. for (idx, line) in text.lines.into_iter().enumerate() {
  116. // Don't add any formatting for cargo messages.
  117. let out_line = if msg.source != TraceSrc::Cargo {
  118. if idx == 0 {
  119. match msg.source {
  120. TraceSrc::Dev => {
  121. let mut spans = vec![Span::from(" DEV: ").light_magenta()];
  122. for span in line.spans {
  123. spans.push(span);
  124. }
  125. spans
  126. }
  127. TraceSrc::Build => {
  128. let mut spans = vec![Span::from("BUILD: ").light_blue()];
  129. for span in line.spans {
  130. spans.push(span);
  131. }
  132. spans
  133. }
  134. _ => {
  135. // Build level tag: `INFO:``
  136. // We don't subtract 1 here for `:` because we still want at least 1 padding.
  137. let padding =
  138. build_msg_padding(LEVEL_MAX - msg.level.to_string().len() - 2);
  139. let level = format!("{padding}{}: ", msg.level);
  140. sub_line_padding += level.len();
  141. let level_span = Span::from(level);
  142. let level_span = match msg.level {
  143. Level::TRACE => level_span.black(),
  144. Level::DEBUG => level_span.light_magenta(),
  145. Level::INFO => level_span.light_green(),
  146. Level::WARN => level_span.light_yellow(),
  147. Level::ERROR => level_span.light_red(),
  148. };
  149. let mut out_line = vec![level_span];
  150. for span in line.spans {
  151. out_line.push(span);
  152. }
  153. out_line
  154. }
  155. }
  156. } else {
  157. // Not the first line. Append the padding and merge into list.
  158. let padding = build_msg_padding(sub_line_padding);
  159. let mut out_line = vec![Span::from(padding)];
  160. for span in line.spans {
  161. out_line.push(span);
  162. }
  163. out_line
  164. }
  165. } else {
  166. line.spans
  167. };
  168. out_text.push_line(Line::from(out_line));
  169. }
  170. }
  171. // Only show messages for filters that are enabled.
  172. let mut included_line_ids = Vec::new();
  173. for filter in enabled_filters {
  174. let re = Regex::new(filter);
  175. for (index, line) in out_text.lines.iter().enumerate() {
  176. let line_str = line.to_string();
  177. match re {
  178. Ok(ref re) => {
  179. // sort by provided regex
  180. if re.is_match(&line_str) {
  181. included_line_ids.push(index);
  182. }
  183. }
  184. Err(_) => {
  185. // default to basic string storing
  186. if line_str.contains(filter) {
  187. included_line_ids.push(index);
  188. }
  189. }
  190. }
  191. }
  192. }
  193. included_line_ids.sort_unstable();
  194. included_line_ids.dedup();
  195. let out_lines = out_text.lines;
  196. let mut out_text = Text::default();
  197. if enabled_filters.is_empty() {
  198. for line in out_lines {
  199. out_text.push_line(line.clone());
  200. }
  201. } else {
  202. for id in included_line_ids {
  203. if let Some(line) = out_lines.get(id) {
  204. out_text.push_line(line.clone());
  205. }
  206. }
  207. }
  208. let (console_width, _console_height) = self.get_console_size();
  209. let paragraph = Paragraph::new(out_text)
  210. .left_aligned()
  211. .wrap(Wrap { trim: false });
  212. let num_lines_wrapping = paragraph.line_count(console_width) as u16;
  213. paragraph
  214. .scroll((scroll_position, 0))
  215. .render(self.console[0], frame.buffer_mut());
  216. num_lines_wrapping
  217. }
  218. /// Render the status bar.
  219. pub fn render_status_bar(
  220. &self,
  221. frame: &mut Frame,
  222. _platform: Platform,
  223. build_progress: &BuildProgress,
  224. more_modal_open: bool,
  225. filter_menu_open: bool,
  226. dx_version: &str,
  227. ) {
  228. // left aligned text
  229. let mut spans = vec![
  230. Span::from("🧬 dx").white(),
  231. Span::from(" ").white(),
  232. Span::from(dx_version).white(),
  233. Span::from(" | ").dark_gray(),
  234. ];
  235. // If there is build progress, render the current status.
  236. let is_build_progress = !build_progress.current_builds.is_empty();
  237. if is_build_progress {
  238. // If the build failed, show a failed status.
  239. // Otherwise, render current status.
  240. let build_failed = build_progress
  241. .current_builds
  242. .values()
  243. .any(|b| b.failed.is_some());
  244. if build_failed {
  245. spans.push(Span::from("Build failed ❌").red());
  246. } else {
  247. // spans.push(Span::from("status: ").gray());
  248. let build = build_progress
  249. .current_builds
  250. .values()
  251. .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
  252. .unwrap();
  253. spans.extend_from_slice(&build.make_spans(Rect::new(
  254. 0,
  255. 0,
  256. build.max_layout_size(),
  257. 1,
  258. )));
  259. }
  260. }
  261. // right aligned text
  262. let more_span = Span::from("[/] more");
  263. let more_span = match more_modal_open {
  264. true => more_span.light_yellow(),
  265. false => more_span.gray(),
  266. };
  267. let filter_span = Span::from("[f] filter");
  268. let filter_span = match filter_menu_open {
  269. true => filter_span.light_yellow(),
  270. false => filter_span.gray(),
  271. };
  272. // Right-aligned text
  273. let right_line = Line::from(vec![
  274. Span::from("[o] open").gray(),
  275. Span::from(" | ").gray(),
  276. Span::from("[r] rebuild").gray(),
  277. Span::from(" | ").gray(),
  278. filter_span,
  279. Span::from(" | ").dark_gray(),
  280. more_span,
  281. ]);
  282. frame.render_widget(
  283. Paragraph::new(Line::from(spans)).left_aligned(),
  284. self.status_bar[0],
  285. );
  286. // Render the info
  287. frame.render_widget(
  288. Paragraph::new(right_line).right_aligned(),
  289. self.status_bar[1],
  290. );
  291. }
  292. /// Renders the "more" modal to show extra info/keybinds accessible via the more keybind.
  293. pub fn render_more_modal(&self, frame: &mut Frame) {
  294. let modal = Layout::default()
  295. .direction(Direction::Vertical)
  296. .constraints([Constraint::Fill(1), Constraint::Length(5)])
  297. .split(self.console[0])[1];
  298. frame.render_widget(Clear, modal);
  299. frame.render_widget(Block::default().borders(Borders::ALL), modal);
  300. // Render under construction message
  301. frame.render_widget(
  302. Paragraph::new("Under construction, please check back at a later date!")
  303. .alignment(Alignment::Center),
  304. modal,
  305. );
  306. }
  307. /// Render the filter drawer menu.
  308. pub fn render_filter_menu(
  309. &mut self,
  310. frame: &mut Frame,
  311. filters: &[(String, bool)],
  312. selected_filter_index: usize,
  313. search_mode: bool,
  314. search_input: Option<&String>,
  315. ) {
  316. let Some(ref filter_drawer) = self.filter_drawer else {
  317. return;
  318. };
  319. // Vertical layout
  320. let container = Layout::default()
  321. .constraints([
  322. Constraint::Length(4),
  323. Constraint::Fill(1),
  324. Constraint::Length(7),
  325. ])
  326. .direction(Direction::Vertical)
  327. .split(filter_drawer[1]);
  328. // Render the search section.
  329. let top_area = Layout::default()
  330. .constraints([
  331. Constraint::Length(1),
  332. Constraint::Length(1),
  333. Constraint::Length(1),
  334. Constraint::Length(1),
  335. ])
  336. .direction(Direction::Vertical)
  337. .split(container[0]);
  338. let search_title = Line::from("Search").gray();
  339. let search_input_block = Block::new().bg(Color::White);
  340. let search_text = match search_input {
  341. Some(s) => s,
  342. None => {
  343. if search_mode {
  344. "..."
  345. } else {
  346. "[enter] to type..."
  347. }
  348. }
  349. };
  350. let search_input = Paragraph::new(Line::from(search_text))
  351. .fg(Color::Black)
  352. .block(search_input_block);
  353. frame.render_widget(search_title, top_area[1]);
  354. frame.render_widget(search_input, top_area[2]);
  355. // Render the filters
  356. let list_area = container[1];
  357. let mut list_items = Vec::new();
  358. for (filter, enabled) in filters {
  359. let filter = Span::from(filter);
  360. let filter = match enabled {
  361. true => filter.light_yellow(),
  362. false => filter.dark_gray(),
  363. };
  364. list_items.push(filter);
  365. }
  366. list_items.reverse();
  367. let list = List::new(list_items).highlight_symbol("» ");
  368. self.filter_list_state.select(Some(selected_filter_index));
  369. frame.render_stateful_widget(list, list_area, &mut self.filter_list_state);
  370. // Render the keybind list at the bottom.
  371. let keybinds = container[2];
  372. let lines = vec![
  373. Line::from(""),
  374. Line::from("[↑] Up").white(),
  375. Line::from("[↓] Down").white(),
  376. Line::from("[←] Remove").white(),
  377. Line::from("[→] Toggle").white(),
  378. Line::from("[enter] Type / Submit").white(),
  379. ];
  380. let text = Text::from(lines);
  381. frame.render_widget(text, keybinds);
  382. }
  383. /// Returns the height of the console TUI area in number of lines.
  384. pub fn get_console_size(&self) -> (u16, u16) {
  385. (self.console[0].width, self.console[0].height)
  386. }
  387. /// Render the current scroll position at the top right corner of the frame
  388. pub(crate) fn render_current_scroll(
  389. &self,
  390. scroll_position: u16,
  391. lines: u16,
  392. console_height: u16,
  393. frame: &mut Frame<'_>,
  394. ) {
  395. let mut row = Layout::default()
  396. .direction(Direction::Vertical)
  397. .constraints([Constraint::Length(1)])
  398. .split(self.console[0])[0];
  399. // Hack: shove upwards the text to overlap with the border so text selection doesn't accidentally capture the number
  400. row.y -= 1;
  401. let max_scroll = lines.saturating_sub(console_height);
  402. if max_scroll == 0 {
  403. return;
  404. }
  405. let remaining_lines = max_scroll.saturating_sub(scroll_position);
  406. if remaining_lines != 0 {
  407. let text = vec![Span::from(format!(" {remaining_lines}⬇ ")).dark_gray()];
  408. frame.render_widget(
  409. Paragraph::new(Line::from(text))
  410. .alignment(Alignment::Right)
  411. .block(Block::default()),
  412. row,
  413. );
  414. }
  415. }
  416. }
  417. /// Generate a string with a specified number of spaces.
  418. fn build_msg_padding(padding_len: usize) -> String {
  419. let mut padding = String::new();
  420. for _ in 0..padding_len {
  421. _ = write!(padding, " ");
  422. }
  423. padding
  424. }