output.rs 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006
  1. use crate::{
  2. serve::{ansi_buffer::AnsiStringLine, ServeUpdate, WebServer},
  3. BuildStage, BuilderUpdate, Platform, TraceContent, TraceMsg, TraceSrc,
  4. };
  5. use crossterm::{
  6. cursor::{Hide, Show},
  7. event::{
  8. DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange, Event,
  9. EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
  10. },
  11. terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType},
  12. ExecutableCommand,
  13. };
  14. use ratatui::{
  15. prelude::*,
  16. widgets::{Block, BorderType, Borders, LineGauge, Paragraph},
  17. TerminalOptions, Viewport,
  18. };
  19. use std::{
  20. cell::RefCell,
  21. collections::VecDeque,
  22. io::{self, stdout},
  23. rc::Rc,
  24. time::Duration,
  25. };
  26. use tracing::Level;
  27. use super::AppServer;
  28. const TICK_RATE_MS: u64 = 100;
  29. const VIEWPORT_MAX_WIDTH: u16 = 100;
  30. const VIEWPORT_HEIGHT_SMALL: u16 = 5;
  31. const VIEWPORT_HEIGHT_BIG: u16 = 13;
  32. /// The TUI that drives the console output.
  33. ///
  34. /// We try not to store too much state about the world here, just the state about the tui itself.
  35. /// This is to prevent out-of-sync issues with the rest of the build engine and to use the components
  36. /// of the serve engine as the source of truth.
  37. ///
  38. /// Please please, do not add state here that does not belong here. We should only be storing state
  39. /// here that is used to change how we display *other* state. Things like throbbers, modals, etc.
  40. pub struct Output {
  41. term: Rc<RefCell<Option<Terminal<CrosstermBackend<io::Stdout>>>>>,
  42. events: Option<EventStream>,
  43. // A list of all messages from build, dev, app, and more.
  44. more_modal_open: bool,
  45. interactive: bool,
  46. // Whether to show verbose logs or not
  47. // We automatically hide "debug" logs if verbose is false (only showing "info" / "warn" / "error")
  48. verbose: bool,
  49. trace: bool,
  50. // Pending logs
  51. pending_logs: VecDeque<TraceMsg>,
  52. dx_version: String,
  53. tick_animation: bool,
  54. tick_interval: tokio::time::Interval,
  55. // ! needs to be wrapped in an &mut since `render stateful widget` requires &mut... but our
  56. // "render" method only borrows &self (for no particular reason at all...)
  57. throbber: RefCell<throbber_widgets_tui::ThrobberState>,
  58. }
  59. #[derive(Clone, Copy)]
  60. struct RenderState<'a> {
  61. runner: &'a AppServer,
  62. server: &'a WebServer,
  63. }
  64. impl Output {
  65. pub(crate) async fn start(interactive: bool) -> crate::Result<Self> {
  66. let mut output = Self {
  67. interactive,
  68. term: Rc::new(RefCell::new(None)),
  69. dx_version: format!(
  70. "{}-{}",
  71. env!("CARGO_PKG_VERSION"),
  72. crate::dx_build_info::GIT_COMMIT_HASH_SHORT.unwrap_or("main")
  73. ),
  74. events: None,
  75. more_modal_open: false,
  76. pending_logs: VecDeque::new(),
  77. throbber: RefCell::new(throbber_widgets_tui::ThrobberState::default()),
  78. trace: crate::logging::VERBOSITY.get().unwrap().trace,
  79. verbose: crate::logging::VERBOSITY.get().unwrap().verbose,
  80. tick_animation: false,
  81. tick_interval: {
  82. let mut interval = tokio::time::interval(Duration::from_millis(TICK_RATE_MS));
  83. interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
  84. interval
  85. },
  86. };
  87. output.startup()?;
  88. Ok(output)
  89. }
  90. /// Call the startup functions that might mess with the terminal settings.
  91. /// This is meant to be paired with "shutdown" to restore the terminal to its original state.
  92. fn startup(&mut self) -> io::Result<()> {
  93. if self.interactive {
  94. // Check if writing the terminal is going to block infinitely.
  95. // If it does, we should disable interactive mode. This ensures we work with programs like `bg`
  96. // which suspend the process and cause us to block when writing output.
  97. if Self::enable_raw_mode().is_err() {
  98. self.term.take();
  99. self.interactive = false;
  100. return Ok(());
  101. }
  102. self.term.replace(
  103. Terminal::with_options(
  104. CrosstermBackend::new(stdout()),
  105. TerminalOptions {
  106. viewport: Viewport::Inline(VIEWPORT_HEIGHT_SMALL),
  107. },
  108. )
  109. .ok(),
  110. );
  111. // Initialize the event stream here - this is optional because an EvenStream in a non-interactive
  112. // terminal will cause a panic instead of simply doing nothing.
  113. // https://github.com/crossterm-rs/crossterm/issues/659
  114. self.events = Some(EventStream::new());
  115. }
  116. Ok(())
  117. }
  118. /// Enable raw mode, but don't let it block forever.
  119. ///
  120. /// This lets us check if writing to tty is going to block forever and then recover, allowing
  121. /// interopability with programs like `bg`.
  122. fn enable_raw_mode() -> io::Result<()> {
  123. #[cfg(unix)]
  124. {
  125. use tokio::signal::unix::{signal, SignalKind};
  126. // Ignore SIGTSTP, SIGTTIN, and SIGTTOU
  127. _ = signal(SignalKind::from_raw(20))?; // SIGTSTP
  128. _ = signal(SignalKind::from_raw(21))?; // SIGTTIN
  129. _ = signal(SignalKind::from_raw(22))?; // SIGTTOU
  130. }
  131. use std::io::IsTerminal;
  132. if !stdout().is_terminal() {
  133. return io::Result::Err(io::Error::new(io::ErrorKind::Other, "Not a terminal"));
  134. }
  135. enable_raw_mode()?;
  136. stdout()
  137. .execute(Hide)?
  138. .execute(EnableFocusChange)?
  139. .execute(EnableBracketedPaste)?;
  140. Ok(())
  141. }
  142. pub(crate) fn remote_shutdown(interactive: bool) -> io::Result<()> {
  143. if interactive {
  144. stdout()
  145. .execute(Show)?
  146. .execute(DisableFocusChange)?
  147. .execute(DisableBracketedPaste)?;
  148. disable_raw_mode()?;
  149. // print a line to force the cursor down (no tearing)
  150. println!();
  151. }
  152. Ok(())
  153. }
  154. pub(crate) async fn wait(&mut self) -> ServeUpdate {
  155. use futures_util::future::OptionFuture;
  156. use futures_util::StreamExt;
  157. if !self.interactive {
  158. return std::future::pending().await;
  159. }
  160. // Wait for the next user event or animation tick
  161. loop {
  162. let next = OptionFuture::from(self.events.as_mut().map(|f| f.next()));
  163. let event = tokio::select! {
  164. biased; // Always choose the event over the animation tick to not lose the event
  165. Some(Some(Ok(event))) = next => event,
  166. _ = self.tick_interval.tick(), if self.tick_animation => {
  167. self.throbber.borrow_mut().calc_next();
  168. return ServeUpdate::Redraw
  169. },
  170. else => futures_util::future::pending().await
  171. };
  172. match self.handle_input(event) {
  173. Ok(Some(update)) => return update,
  174. Err(ee) => {
  175. return ServeUpdate::Exit {
  176. error: Some(Box::new(ee)),
  177. }
  178. }
  179. Ok(None) => {}
  180. }
  181. }
  182. }
  183. /// Handle an input event, returning `true` if the event should cause the program to restart.
  184. fn handle_input(&mut self, input: Event) -> io::Result<Option<ServeUpdate>> {
  185. // handle ctrlc
  186. if let Event::Key(key) = input {
  187. if let KeyCode::Char('c') = key.code {
  188. if key.modifiers.contains(KeyModifiers::CONTROL) {
  189. return Ok(Some(ServeUpdate::Exit { error: None }));
  190. }
  191. }
  192. }
  193. match input {
  194. Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_keypress(key),
  195. _ => Ok(Some(ServeUpdate::Redraw)),
  196. }
  197. }
  198. fn handle_keypress(&mut self, key: KeyEvent) -> io::Result<Option<ServeUpdate>> {
  199. match key.code {
  200. KeyCode::Char('r') => return Ok(Some(ServeUpdate::RequestRebuild)),
  201. KeyCode::Char('o') => return Ok(Some(ServeUpdate::OpenApp)),
  202. KeyCode::Char('p') => return Ok(Some(ServeUpdate::ToggleShouldRebuild)),
  203. KeyCode::Char('v') => {
  204. self.verbose = !self.verbose;
  205. tracing::info!(
  206. "Verbose logging is now {}",
  207. if self.verbose { "on" } else { "off" }
  208. );
  209. }
  210. KeyCode::Char('t') => {
  211. self.trace = !self.trace;
  212. tracing::info!("Tracing is now {}", if self.trace { "on" } else { "off" });
  213. }
  214. KeyCode::Char('c') => {
  215. stdout()
  216. .execute(Clear(ClearType::All))?
  217. .execute(Clear(ClearType::Purge))?;
  218. // Clear the terminal and push the frame to the bottom
  219. _ = self.term.borrow_mut().as_mut().map(|t| {
  220. let frame_rect = t.get_frame().area();
  221. let term_size = t.size().unwrap();
  222. let remaining_space = term_size
  223. .height
  224. .saturating_sub(frame_rect.y + frame_rect.height);
  225. t.insert_before(remaining_space, |_| {})
  226. });
  227. }
  228. // Toggle the more modal by swapping the the terminal with a new one
  229. // This is a bit of a hack since crossterm doesn't technically support changing the
  230. // size of an inline viewport.
  231. KeyCode::Char('/') => {
  232. if let Some(terminal) = self.term.borrow_mut().as_mut() {
  233. // Toggle the more modal, which will change our current viewport height
  234. self.more_modal_open = !self.more_modal_open;
  235. // Clear the terminal before resizing it, such that it doesn't tear
  236. terminal.clear()?;
  237. // And then set the new viewport, which essentially mimics a resize
  238. *terminal = Terminal::with_options(
  239. CrosstermBackend::new(stdout()),
  240. TerminalOptions {
  241. viewport: Viewport::Inline(self.viewport_current_height()),
  242. },
  243. )?;
  244. }
  245. }
  246. _ => {}
  247. }
  248. // Out of safety, we always redraw, since it's relatively cheap operation
  249. Ok(Some(ServeUpdate::Redraw))
  250. }
  251. /// Push a TraceMsg to be printed on the next render
  252. pub fn push_log(&mut self, message: TraceMsg) {
  253. self.pending_logs.push_front(message);
  254. }
  255. pub fn push_cargo_log(&mut self, message: cargo_metadata::CompilerMessage) {
  256. use cargo_metadata::diagnostic::DiagnosticLevel;
  257. if self.trace || !matches!(message.message.level, DiagnosticLevel::Note) {
  258. self.push_log(TraceMsg::cargo(message));
  259. }
  260. }
  261. /// Add a message from stderr to the logs
  262. /// This will queue the stderr message as a TraceMsg and print it on the next render
  263. /// We'll use the `App` TraceSrc for the msg, and whatever level is provided
  264. pub fn push_stdio(&mut self, platform: Platform, msg: String, level: Level) {
  265. self.push_log(TraceMsg::text(TraceSrc::App(platform), level, msg));
  266. }
  267. /// Push a message from the websocket to the logs
  268. pub fn push_ws_message(&mut self, platform: Platform, message: &axum::extract::ws::Message) {
  269. use dioxus_devtools_types::ClientMsg;
  270. // We can only handle text messages from the websocket...
  271. let axum::extract::ws::Message::Text(text) = message else {
  272. return;
  273. };
  274. // ...and then decode them into a ClientMsg
  275. let res = serde_json::from_str::<ClientMsg>(text.as_str());
  276. // Client logs being errors aren't fatal, but we should still report them them
  277. let msg = match res {
  278. Ok(msg) => msg,
  279. Err(err) => {
  280. tracing::error!(dx_src = ?TraceSrc::Dev, "Error parsing message from {}: {} -> {:?}", platform, err, text.as_str());
  281. return;
  282. }
  283. };
  284. let ClientMsg::Log { level, messages } = msg else {
  285. return;
  286. };
  287. // FIXME(jon): why are we pulling only the first message here?
  288. let content = messages.first().unwrap_or(&String::new()).clone();
  289. let level = match level.as_str() {
  290. "trace" => Level::TRACE,
  291. "debug" => Level::DEBUG,
  292. "info" => Level::INFO,
  293. "warn" => Level::WARN,
  294. "error" => Level::ERROR,
  295. _ => Level::INFO,
  296. };
  297. // We don't care about logging the app's message so we directly push it instead of using tracing.
  298. self.push_log(TraceMsg::text(TraceSrc::App(platform), level, content));
  299. }
  300. /// Change internal state based on the build engine's update
  301. ///
  302. /// We want to keep internal state as limited as possible, so currently we're only setting our
  303. /// animation tick. We could, in theory, just leave animation running and have no internal state,
  304. /// but that seems a bit wasteful. We might eventually change this to be more of a "requestAnimationFrame"
  305. /// approach, but then we'd need to do that *everywhere* instead of simply performing a react-like
  306. /// re-render when external state changes. Ratatui will diff the intermediate buffer, so we at least
  307. /// we won't be drawing it.
  308. pub(crate) fn new_build_update(&mut self, update: &BuilderUpdate) {
  309. match update {
  310. BuilderUpdate::Progress {
  311. stage: BuildStage::Starting { .. },
  312. } => self.tick_animation = true,
  313. BuilderUpdate::BuildReady { .. } => self.tick_animation = false,
  314. BuilderUpdate::BuildFailed { .. } => self.tick_animation = false,
  315. _ => {}
  316. }
  317. }
  318. /// Render the current state of everything to the console screen
  319. pub fn render(&mut self, runner: &AppServer, server: &WebServer) {
  320. if !self.interactive {
  321. return;
  322. }
  323. // Get a handle to the terminal with a different lifetime so we can continue to call &self methods
  324. let owned_term = self.term.clone();
  325. let mut term = owned_term.borrow_mut();
  326. let Some(term) = term.as_mut() else {
  327. return;
  328. };
  329. // First, dequeue any logs that have built up from event handling
  330. _ = self.drain_logs(term);
  331. // Then, draw the frame, passing along all the state of the TUI so we can render it properly
  332. _ = term.draw(|frame| {
  333. self.render_frame(frame, RenderState { runner, server });
  334. });
  335. }
  336. fn render_frame(&self, frame: &mut Frame, state: RenderState) {
  337. // Use the max size of the viewport, but shrunk to a sensible max width
  338. let mut area = frame.area();
  339. area.width = area.width.clamp(0, VIEWPORT_MAX_WIDTH);
  340. let [_top, body, _bottom] = Layout::vertical([
  341. Constraint::Length(1),
  342. Constraint::Fill(1),
  343. Constraint::Length(1),
  344. ])
  345. .horizontal_margin(1)
  346. .areas(area);
  347. self.render_borders(frame, area);
  348. self.render_body(frame, body, state);
  349. self.render_body_title(frame, _top, state);
  350. }
  351. fn render_body_title(&self, frame: &mut Frame<'_>, area: Rect, _state: RenderState) {
  352. frame.render_widget(
  353. Line::from(vec![
  354. " ".dark_gray(),
  355. match self.more_modal_open {
  356. true => "/:more".light_yellow(),
  357. false => "/:more".dark_gray(),
  358. },
  359. " ".dark_gray(),
  360. ])
  361. .right_aligned(),
  362. area,
  363. );
  364. }
  365. fn render_body(&self, frame: &mut Frame<'_>, area: Rect, state: RenderState) {
  366. let [_title, body, more, _foot] = Layout::vertical([
  367. Constraint::Length(0),
  368. Constraint::Length(VIEWPORT_HEIGHT_SMALL - 2),
  369. Constraint::Fill(1),
  370. Constraint::Length(0),
  371. ])
  372. .horizontal_margin(1)
  373. .areas(area);
  374. let [col1, col2] = Layout::horizontal([Constraint::Length(50), Constraint::Fill(1)])
  375. .horizontal_margin(1)
  376. .areas(body);
  377. self.render_gauges(frame, col1, state);
  378. self.render_stats(frame, col2, state);
  379. if self.more_modal_open {
  380. self.render_more_modal(frame, more, state);
  381. }
  382. }
  383. fn render_gauges(&self, frame: &mut Frame<'_>, area: Rect, state: RenderState) {
  384. let [gauge_area, _margin] =
  385. Layout::horizontal([Constraint::Fill(1), Constraint::Length(3)]).areas(area);
  386. let [app_progress, second_progress, status_line]: [_; 3] = Layout::vertical([
  387. Constraint::Length(1),
  388. Constraint::Length(1),
  389. Constraint::Length(1),
  390. ])
  391. .areas(gauge_area);
  392. let client = &state.runner.client();
  393. self.render_single_gauge(
  394. frame,
  395. app_progress,
  396. client.compile_progress(),
  397. "App: ",
  398. state,
  399. client.compile_duration(),
  400. );
  401. if state.runner.is_fullstack() {
  402. self.render_single_gauge(
  403. frame,
  404. second_progress,
  405. state.runner.server_compile_progress(),
  406. "Server: ",
  407. state,
  408. client.compile_duration(),
  409. );
  410. } else {
  411. self.render_single_gauge(
  412. frame,
  413. second_progress,
  414. client.bundle_progress(),
  415. "Bundle: ",
  416. state,
  417. client.bundle_duration(),
  418. );
  419. }
  420. let mut lines = vec!["Status: ".white()];
  421. match &client.stage {
  422. BuildStage::Initializing => lines.push("Initializing".yellow()),
  423. BuildStage::Starting { patch, .. } => {
  424. if *patch {
  425. lines.push("Hot-patching...".yellow())
  426. } else {
  427. lines.push("Starting build".yellow())
  428. }
  429. }
  430. BuildStage::InstallingTooling => lines.push("Installing tooling".yellow()),
  431. BuildStage::Compiling {
  432. current,
  433. total,
  434. krate,
  435. ..
  436. } => {
  437. lines.push("Compiling ".yellow());
  438. lines.push(format!("{current}/{total} ").gray());
  439. lines.push(krate.as_str().dark_gray())
  440. }
  441. BuildStage::OptimizingWasm => lines.push("Optimizing wasm".yellow()),
  442. BuildStage::SplittingBundle => lines.push("Splitting bundle".yellow()),
  443. BuildStage::CompressingAssets => lines.push("Compressing assets".yellow()),
  444. BuildStage::RunningBindgen => lines.push("Running wasm-bindgen".yellow()),
  445. BuildStage::RunningGradle => lines.push("Running gradle assemble".yellow()),
  446. BuildStage::Bundling => lines.push("Bundling app".yellow()),
  447. BuildStage::CopyingAssets {
  448. current,
  449. total,
  450. path,
  451. } => {
  452. lines.push("Copying asset ".yellow());
  453. lines.push(format!("{current}/{total} ").gray());
  454. if let Some(name) = path.file_name().and_then(|f| f.to_str()) {
  455. lines.push(name.dark_gray())
  456. }
  457. }
  458. BuildStage::Success => {
  459. lines.push("Serving ".yellow());
  460. lines.push(client.build.executable_name().white());
  461. lines.push(" 🚀 ".green());
  462. if let Some(comp_time) = client.total_build_time() {
  463. lines.push(format!("{:.1}s", comp_time.as_secs_f32()).dark_gray());
  464. }
  465. }
  466. BuildStage::Failed => lines.push("Failed".red()),
  467. BuildStage::Aborted => lines.push("Aborted".red()),
  468. BuildStage::Restarting => lines.push("Restarting".yellow()),
  469. BuildStage::Linking => lines.push("Linking".yellow()),
  470. BuildStage::Hotpatching => lines.push("Hot-patching...".yellow()),
  471. BuildStage::ExtractingAssets => lines.push("Extracting assets".yellow()),
  472. _ => {}
  473. };
  474. frame.render_widget(Line::from(lines), status_line);
  475. }
  476. fn render_single_gauge(
  477. &self,
  478. frame: &mut Frame<'_>,
  479. area: Rect,
  480. value: f64,
  481. label: &str,
  482. state: RenderState,
  483. time_taken: Option<Duration>,
  484. ) {
  485. let failed = state.runner.client.stage == BuildStage::Failed;
  486. let value = if failed { 1.0 } else { value.clamp(0.0, 1.0) };
  487. let [gauge_row, _, icon] = Layout::horizontal([
  488. Constraint::Fill(1),
  489. Constraint::Length(2),
  490. Constraint::Length(10),
  491. ])
  492. .areas(area);
  493. frame.render_widget(
  494. LineGauge::default()
  495. .filled_style(Style::default().fg(match value {
  496. 1.0 if failed => Color::Red,
  497. 1.0 => Color::Green,
  498. _ => Color::Yellow,
  499. }))
  500. .unfilled_style(Style::default().fg(Color::DarkGray))
  501. .label(label.gray())
  502. .line_set(symbols::line::THICK)
  503. .ratio(if !failed { value } else { 1.0 }),
  504. gauge_row,
  505. );
  506. let [throbber_frame, time_frame] = Layout::default()
  507. .direction(Direction::Horizontal)
  508. .constraints([Constraint::Length(3), Constraint::Fill(1)])
  509. .areas(icon);
  510. if value != 1.0 {
  511. let throb = throbber_widgets_tui::Throbber::default()
  512. .style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan))
  513. .throbber_style(
  514. ratatui::style::Style::default()
  515. .fg(ratatui::style::Color::White)
  516. .add_modifier(ratatui::style::Modifier::BOLD),
  517. )
  518. .throbber_set(throbber_widgets_tui::BLACK_CIRCLE)
  519. .use_type(throbber_widgets_tui::WhichUse::Spin);
  520. frame.render_stateful_widget(throb, throbber_frame, &mut self.throbber.borrow_mut());
  521. } else {
  522. frame.render_widget(
  523. Line::from(vec![if failed {
  524. "❌ ".white()
  525. } else {
  526. "🎉 ".white()
  527. }])
  528. .left_aligned(),
  529. throbber_frame,
  530. );
  531. }
  532. if let Some(time_taken) = time_taken {
  533. if !failed {
  534. frame.render_widget(
  535. Line::from(vec![format!("{:.1}s", time_taken.as_secs_f32()).dark_gray()])
  536. .left_aligned(),
  537. time_frame,
  538. );
  539. }
  540. }
  541. }
  542. fn render_stats(&self, frame: &mut Frame<'_>, area: Rect, state: RenderState) {
  543. let [current_platform, app_features, serve_address]: [_; 3] = Layout::vertical([
  544. Constraint::Length(1),
  545. Constraint::Length(1),
  546. Constraint::Length(1),
  547. ])
  548. .areas(area);
  549. let client = &state.runner.client();
  550. frame.render_widget(
  551. Paragraph::new(Line::from(vec![
  552. "Platform: ".gray(),
  553. client.build.platform.expected_name().yellow(),
  554. if state.runner.is_fullstack() {
  555. " + fullstack".yellow()
  556. } else {
  557. " ".dark_gray()
  558. },
  559. ])),
  560. current_platform,
  561. );
  562. self.render_feature_list(frame, app_features, state);
  563. // todo(jon) should we write https ?
  564. let address = match state.server.displayed_address() {
  565. Some(address) => format!("http://{}", address).blue(),
  566. None => "no server address".dark_gray(),
  567. };
  568. frame.render_widget_ref(
  569. Paragraph::new(Line::from(vec![
  570. if client.build.platform == Platform::Web {
  571. "Serving at: ".gray()
  572. } else {
  573. "ServerFns at: ".gray()
  574. },
  575. address,
  576. ])),
  577. serve_address,
  578. );
  579. }
  580. fn render_feature_list(&self, frame: &mut Frame<'_>, area: Rect, state: RenderState) {
  581. frame.render_widget(
  582. Paragraph::new(Line::from({
  583. let mut lines = vec!["App features: ".gray(), "[".yellow()];
  584. let feature_list: Vec<String> = state.runner.client().build.all_target_features();
  585. let num_features = feature_list.len();
  586. for (idx, feature) in feature_list.into_iter().enumerate() {
  587. lines.push("\"".yellow());
  588. lines.push(feature.yellow());
  589. lines.push("\"".yellow());
  590. if idx != num_features - 1 {
  591. lines.push(", ".dark_gray());
  592. }
  593. }
  594. lines.push("]".yellow());
  595. lines
  596. })),
  597. area,
  598. );
  599. }
  600. fn render_more_modal(&self, frame: &mut Frame<'_>, area: Rect, state: RenderState) {
  601. let [col1, col2] =
  602. Layout::horizontal([Constraint::Length(50), Constraint::Fill(1)]).areas(area);
  603. let [top, bottom] = Layout::vertical([Constraint::Fill(1), Constraint::Length(2)])
  604. .horizontal_margin(1)
  605. .areas(col1);
  606. let meta_list: [_; 6] = Layout::vertical([
  607. Constraint::Length(1), // spacing
  608. Constraint::Length(1), // item 1
  609. Constraint::Length(1), // item 2
  610. Constraint::Length(1), // item 3
  611. Constraint::Length(1), // item 4
  612. Constraint::Length(1), // Spacing
  613. ])
  614. .areas(top);
  615. frame.render_widget(
  616. Paragraph::new(Line::from(vec![
  617. "dx version: ".gray(),
  618. self.dx_version.as_str().yellow(),
  619. ])),
  620. meta_list[1],
  621. );
  622. frame.render_widget(
  623. Paragraph::new(Line::from(vec![
  624. "rustc: ".gray(),
  625. state.runner.workspace.rustc_version.as_str().yellow(),
  626. ])),
  627. meta_list[2],
  628. );
  629. frame.render_widget(
  630. Paragraph::new(Line::from(vec![
  631. "Hotreload: ".gray(),
  632. "rsx and assets".yellow(),
  633. ])),
  634. meta_list[3],
  635. );
  636. let server_address = match state.server.server_address() {
  637. Some(address) => format!("http://{}", address).yellow(),
  638. None => "no address".dark_gray(),
  639. };
  640. frame.render_widget(
  641. Paragraph::new(Line::from(vec!["Network: ".gray(), server_address])),
  642. meta_list[4],
  643. );
  644. let links_list: [_; 2] =
  645. Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(bottom);
  646. frame.render_widget(
  647. Paragraph::new(Line::from(vec![
  648. "Read the docs: ".gray(),
  649. "https://dioxuslabs.com/0.6/docs".blue(),
  650. ])),
  651. links_list[0],
  652. );
  653. frame.render_widget(
  654. Paragraph::new(Line::from(vec![
  655. "Video tutorials: ".gray(),
  656. "https://youtube.com/@DioxusLabs".blue(),
  657. ])),
  658. links_list[1],
  659. );
  660. let cmds = [
  661. "",
  662. "r: rebuild the app",
  663. "o: open the app",
  664. "p: pause rebuilds",
  665. "v: toggle verbose logs",
  666. "t: toggle tracing logs ",
  667. "c: clear the screen",
  668. "/: toggle more commands",
  669. ];
  670. let layout: [_; 8] = Layout::vertical(cmds.iter().map(|_| Constraint::Length(1)))
  671. .horizontal_margin(1)
  672. .areas(col2);
  673. for (idx, cmd) in cmds.iter().enumerate() {
  674. if cmd.is_empty() {
  675. continue;
  676. }
  677. let (cmd, detail) = cmd.split_once(": ").unwrap_or((cmd, ""));
  678. frame.render_widget(
  679. Paragraph::new(Line::from(vec![
  680. cmd.gray(),
  681. ": ".gray(),
  682. detail.dark_gray(),
  683. ])),
  684. layout[idx],
  685. );
  686. }
  687. }
  688. /// Render borders around the terminal, forcing an inner clear while we're at it
  689. fn render_borders(&self, frame: &mut Frame, area: Rect) {
  690. frame.render_widget(ratatui::widgets::Clear, area);
  691. frame.render_widget(
  692. Block::default()
  693. .borders(Borders::ALL)
  694. .border_type(BorderType::Rounded)
  695. .border_style(Style::default().fg(Color::DarkGray)),
  696. area,
  697. );
  698. }
  699. /// Print logs to the terminal as close to a regular "println!()" as possible.
  700. ///
  701. /// We don't want alternate screens or other terminal tricks because we want these logs to be as
  702. /// close to real as possible. Once the log is printed, it is lost, so we need to be very careful
  703. /// here to not print it incorrectly.
  704. ///
  705. /// This method works by printing lines at the top of the viewport frame, and then scrolling up
  706. /// the viewport accordingly, such that our final call to "clear" will cause the terminal the viewport
  707. /// to be comlpetely erased and rewritten. This is slower since we're going around ratatui's diff
  708. /// logic, but it's the only way to do this that gives us "true println!" semantics.
  709. ///
  710. /// In the future, Ratatui's insert_before method will get scroll regions, which will make this logic
  711. /// much simpler. In that future, we'll simply insert a line into the scrollregion which should automatically
  712. /// force that portion of the terminal to scroll up.
  713. ///
  714. /// TODO(jon): we could look into implementing scroll regions ourselves, but I think insert_before will
  715. /// land in a reasonable amount of time.
  716. #[deny(clippy::manual_saturating_arithmetic)]
  717. fn drain_logs(
  718. &mut self,
  719. terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
  720. ) -> io::Result<()> {
  721. use unicode_segmentation::UnicodeSegmentation;
  722. let Some(log) = self.pending_logs.pop_back() else {
  723. return Ok(());
  724. };
  725. // Only show debug logs if verbose is enabled
  726. if log.level == Level::DEBUG && !self.verbose {
  727. return Ok(());
  728. }
  729. if log.level == Level::TRACE && !self.trace {
  730. return Ok(());
  731. }
  732. // Grab out the size and location of the terminal and its viewport before we start messing with it
  733. let frame_rect = terminal.get_frame().area();
  734. let term_size = terminal.size().unwrap();
  735. // Render the log into an ansi string
  736. // We're going to add some metadata to it like the timestamp and source and then dump it to the raw ansi sequences we need to send to crossterm
  737. let lines = Self::tracemsg_to_ansi_string(log);
  738. // Get the lines of the output sequence and their overflow
  739. let lines_printed = lines
  740. .iter()
  741. .map(|line| {
  742. // Very important to strip ansi codes before counting graphemes - the ansi codes count as multiple graphemes!
  743. let grapheme_count = console::strip_ansi_codes(line).graphemes(true).count();
  744. grapheme_count.max(1).div_ceil(term_size.width as usize) as u16
  745. })
  746. .sum::<u16>();
  747. // The viewport might be clipped, but the math still needs to work out.
  748. let actual_vh_height = self.viewport_current_height().min(term_size.height);
  749. // Move the terminal's cursor down to the number of lines printed
  750. let remaining_space = term_size
  751. .height
  752. .saturating_sub(frame_rect.y + frame_rect.height);
  753. // Calculate how many lines we need to push back
  754. // - padding equals lines_printed when the frame is at the bottom
  755. // - padding is zero when the remaining space is greater/equal than the scrollback (the frame will get pushed naturally)
  756. // Determine what extra padding is remaining after we've shifted the terminal down
  757. // this will be the distance between the final line and the top of the frame, only if the
  758. // final line has extended into the frame
  759. let final_line = frame_rect.y + lines_printed;
  760. let max_frame_top = term_size.height - actual_vh_height;
  761. let padding = final_line
  762. .saturating_sub(max_frame_top)
  763. .clamp(0, actual_vh_height - 1);
  764. // The only reliable way we can force the terminal downards is through "insert_before".
  765. //
  766. // If we need to push the terminal down, we'll use this method with the number of lines
  767. // Ratatui will handle this rest.
  768. //
  769. // This also calls `.clear()` so we don't need to call clear at the end of this function.
  770. //
  771. // FIXME(jon): eventually insert_before will get scroll regions, breaking this, but making the logic here simpler
  772. terminal.insert_before(remaining_space.min(lines_printed), |_| {})?;
  773. // Wipe the viewport clean so it doesn't tear
  774. crossterm::queue!(
  775. std::io::stdout(),
  776. crossterm::cursor::MoveTo(0, frame_rect.y),
  777. crossterm::terminal::Clear(ClearType::FromCursorDown),
  778. )?;
  779. // Start printing the log by writing on top of the topmost line
  780. for (idx, line) in lines.into_iter().enumerate() {
  781. // Move the cursor to the correct line offset but don't go past the bottom of the terminal
  782. let start = frame_rect.y + idx as u16;
  783. let start = start.min(term_size.height - 1);
  784. crossterm::queue!(
  785. std::io::stdout(),
  786. crossterm::cursor::MoveTo(0, start),
  787. crossterm::style::Print(line),
  788. crossterm::style::Print("\n"),
  789. )?;
  790. }
  791. // Scroll the terminal if we need to
  792. for _ in 0..padding {
  793. crossterm::queue!(
  794. std::io::stdout(),
  795. crossterm::cursor::MoveTo(0, term_size.height - 1),
  796. crossterm::style::Print("\n"),
  797. )?;
  798. }
  799. Ok(())
  800. }
  801. fn viewport_current_height(&self) -> u16 {
  802. match self.more_modal_open {
  803. true => VIEWPORT_HEIGHT_BIG,
  804. false => VIEWPORT_HEIGHT_SMALL,
  805. }
  806. }
  807. fn tracemsg_to_ansi_string(log: TraceMsg) -> Vec<String> {
  808. use ansi_to_tui::IntoText;
  809. use chrono::Timelike;
  810. let rendered = match log.content {
  811. TraceContent::Cargo(msg) => msg.message.rendered.unwrap_or_default(),
  812. TraceContent::Text(text) => text,
  813. };
  814. let mut lines = vec![];
  815. for (idx, raw_line) in rendered.lines().enumerate() {
  816. let line_as_text = raw_line.into_text().unwrap();
  817. let is_pretending_to_be_frame = !raw_line.is_empty()
  818. && raw_line
  819. .chars()
  820. .all(|c| c == '=' || c == '-' || c == ' ' || c == '─');
  821. for (subline_idx, mut line) in line_as_text.lines.into_iter().enumerate() {
  822. if idx == 0 && subline_idx == 0 {
  823. let mut formatted_line = Line::default();
  824. formatted_line.push_span(
  825. Span::raw(format!(
  826. "{:02}:{:02}:{:02} ",
  827. log.timestamp.hour(),
  828. log.timestamp.minute(),
  829. log.timestamp.second()
  830. ))
  831. .dark_gray(),
  832. );
  833. formatted_line.push_span(
  834. Span::raw(format!(
  835. "[{src}] {padding}",
  836. src = log.source,
  837. padding =
  838. " ".repeat(3usize.saturating_sub(log.source.to_string().len()))
  839. ))
  840. .style(match log.source {
  841. TraceSrc::App(_platform) => match log.level {
  842. Level::ERROR => Style::new().red(),
  843. Level::WARN => Style::new().yellow(),
  844. Level::INFO => Style::new().magenta(),
  845. Level::DEBUG => Style::new().magenta(),
  846. Level::TRACE => Style::new().magenta(),
  847. },
  848. TraceSrc::Dev => match log.level {
  849. Level::ERROR => Style::new().red(),
  850. Level::WARN => Style::new().yellow(),
  851. Level::INFO => Style::new().blue(),
  852. Level::DEBUG => Style::new().blue(),
  853. Level::TRACE => Style::new().blue(),
  854. },
  855. TraceSrc::Cargo => Style::new().yellow(),
  856. TraceSrc::Build => Style::new().blue(),
  857. TraceSrc::Bundle => Style::new().blue(),
  858. TraceSrc::Unknown => Style::new().gray(),
  859. }),
  860. );
  861. for span in line.spans {
  862. formatted_line.push_span(span);
  863. }
  864. line = formatted_line;
  865. }
  866. if is_pretending_to_be_frame {
  867. line = line.dark_gray();
  868. }
  869. // Create the ansi -> raw string line with a width of either the viewport width or the max width
  870. let line_length = line.styled_graphemes(Style::default()).count();
  871. if line_length < u16::MAX as usize {
  872. lines.push(AnsiStringLine::new(line_length as _).render(&line));
  873. } else {
  874. lines.push(line.to_string())
  875. }
  876. }
  877. }
  878. lines
  879. }
  880. }