output.rs 39 KB

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