output.rs 34 KB

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