12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037 |
- use crate::{
- serve::{ansi_buffer::AnsiStringLine, ServeUpdate, WebServer},
- BuildId, BuildStage, BuilderUpdate, Platform, TraceContent, TraceMsg, TraceSrc,
- };
- use cargo_metadata::diagnostic::Diagnostic;
- use crossterm::{
- cursor::{Hide, Show},
- event::{
- DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange, Event,
- EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
- },
- terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType},
- ExecutableCommand,
- };
- use ratatui::{
- prelude::*,
- widgets::{Block, BorderType, Borders, LineGauge, Paragraph},
- TerminalOptions, Viewport,
- };
- use std::{
- cell::RefCell,
- collections::VecDeque,
- io::{self, stdout},
- rc::Rc,
- time::Duration,
- };
- use tracing::Level;
- use super::AppServer;
- const TICK_RATE_MS: u64 = 100;
- const VIEWPORT_MAX_WIDTH: u16 = 100;
- const VIEWPORT_HEIGHT_SMALL: u16 = 5;
- const VIEWPORT_HEIGHT_BIG: u16 = 13;
- /// The TUI that drives the console output.
- ///
- /// We try not to store too much state about the world here, just the state about the tui itself.
- /// This is to prevent out-of-sync issues with the rest of the build engine and to use the components
- /// of the serve engine as the source of truth.
- ///
- /// Please please, do not add state here that does not belong here. We should only be storing state
- /// here that is used to change how we display *other* state. Things like throbbers, modals, etc.
- pub struct Output {
- term: Rc<RefCell<Option<Terminal<CrosstermBackend<io::Stdout>>>>>,
- events: Option<EventStream>,
- // A list of all messages from build, dev, app, and more.
- more_modal_open: bool,
- interactive: bool,
- // Whether to show verbose logs or not
- // We automatically hide "debug" logs if verbose is false (only showing "info" / "warn" / "error")
- verbose: bool,
- trace: bool,
- // Pending logs
- pending_logs: VecDeque<TraceMsg>,
- dx_version: String,
- tick_animation: bool,
- tick_interval: tokio::time::Interval,
- // ! needs to be wrapped in an &mut since `render stateful widget` requires &mut... but our
- // "render" method only borrows &self (for no particular reason at all...)
- throbber: RefCell<throbber_widgets_tui::ThrobberState>,
- }
- #[derive(Clone, Copy)]
- struct RenderState<'a> {
- runner: &'a AppServer,
- server: &'a WebServer,
- }
- impl Output {
- pub(crate) async fn start(interactive: bool) -> crate::Result<Self> {
- let mut output = Self {
- interactive,
- term: Rc::new(RefCell::new(None)),
- dx_version: format!(
- "{}-{}",
- env!("CARGO_PKG_VERSION"),
- crate::dx_build_info::GIT_COMMIT_HASH_SHORT.unwrap_or("main")
- ),
- events: None,
- more_modal_open: false,
- pending_logs: VecDeque::new(),
- throbber: RefCell::new(throbber_widgets_tui::ThrobberState::default()),
- trace: crate::logging::VERBOSITY.get().unwrap().trace,
- verbose: crate::logging::VERBOSITY.get().unwrap().verbose,
- tick_animation: false,
- tick_interval: {
- let mut interval = tokio::time::interval(Duration::from_millis(TICK_RATE_MS));
- interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
- interval
- },
- };
- output.startup()?;
- Ok(output)
- }
- /// Call the startup functions that might mess with the terminal settings.
- /// This is meant to be paired with "shutdown" to restore the terminal to its original state.
- fn startup(&mut self) -> io::Result<()> {
- if self.interactive {
- // Check if writing the terminal is going to block infinitely.
- // If it does, we should disable interactive mode. This ensures we work with programs like `bg`
- // which suspend the process and cause us to block when writing output.
- if Self::enable_raw_mode().is_err() {
- self.term.take();
- self.interactive = false;
- return Ok(());
- }
- self.term.replace(
- Terminal::with_options(
- CrosstermBackend::new(stdout()),
- TerminalOptions {
- viewport: Viewport::Inline(VIEWPORT_HEIGHT_SMALL),
- },
- )
- .ok(),
- );
- // Initialize the event stream here - this is optional because an EvenStream in a non-interactive
- // terminal will cause a panic instead of simply doing nothing.
- // https://github.com/crossterm-rs/crossterm/issues/659
- self.events = Some(EventStream::new());
- }
- Ok(())
- }
- /// Enable raw mode, but don't let it block forever.
- ///
- /// This lets us check if writing to tty is going to block forever and then recover, allowing
- /// interopability with programs like `bg`.
- fn enable_raw_mode() -> io::Result<()> {
- #[cfg(unix)]
- {
- use tokio::signal::unix::{signal, SignalKind};
- // Ignore SIGTSTP, SIGTTIN, and SIGTTOU
- _ = signal(SignalKind::from_raw(20))?; // SIGTSTP
- _ = signal(SignalKind::from_raw(21))?; // SIGTTIN
- _ = signal(SignalKind::from_raw(22))?; // SIGTTOU
- }
- use std::io::IsTerminal;
- if !stdout().is_terminal() {
- return io::Result::Err(io::Error::other("Not a terminal"));
- }
- enable_raw_mode()?;
- stdout()
- .execute(Hide)?
- .execute(EnableFocusChange)?
- .execute(EnableBracketedPaste)?;
- Ok(())
- }
- pub(crate) fn remote_shutdown(interactive: bool) -> io::Result<()> {
- if interactive && crossterm::terminal::is_raw_mode_enabled().unwrap_or(true) {
- stdout()
- .execute(Show)?
- .execute(DisableFocusChange)?
- .execute(DisableBracketedPaste)?;
- disable_raw_mode()?;
- // print a line to force the cursor down (no tearing)
- println!();
- }
- Ok(())
- }
- pub(crate) async fn wait(&mut self) -> ServeUpdate {
- use futures_util::future::OptionFuture;
- use futures_util::StreamExt;
- if !self.interactive {
- return std::future::pending().await;
- }
- // Wait for the next user event or animation tick
- loop {
- let next = OptionFuture::from(self.events.as_mut().map(|f| f.next()));
- let event = tokio::select! {
- biased; // Always choose the event over the animation tick to not lose the event
- Some(Some(Ok(event))) = next => event,
- _ = self.tick_interval.tick(), if self.tick_animation => {
- self.throbber.borrow_mut().calc_next();
- return ServeUpdate::Redraw
- },
- else => futures_util::future::pending().await
- };
- match self.handle_input(event) {
- Ok(Some(update)) => return update,
- Err(ee) => {
- return ServeUpdate::Exit {
- error: Some(Box::new(ee)),
- }
- }
- Ok(None) => {}
- }
- }
- }
- /// Handle an input event, returning `true` if the event should cause the program to restart.
- fn handle_input(&mut self, input: Event) -> io::Result<Option<ServeUpdate>> {
- // handle ctrlc
- if let Event::Key(key) = input {
- if let KeyCode::Char('c') = key.code {
- if key.modifiers.contains(KeyModifiers::CONTROL) {
- return Ok(Some(ServeUpdate::Exit { error: None }));
- }
- }
- }
- match input {
- Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_keypress(key),
- _ => Ok(Some(ServeUpdate::Redraw)),
- }
- }
- fn handle_keypress(&mut self, key: KeyEvent) -> io::Result<Option<ServeUpdate>> {
- match key.code {
- KeyCode::Char('r') => return Ok(Some(ServeUpdate::RequestRebuild)),
- KeyCode::Char('o') => return Ok(Some(ServeUpdate::OpenApp)),
- KeyCode::Char('p') => return Ok(Some(ServeUpdate::ToggleShouldRebuild)),
- KeyCode::Char('v') => {
- self.verbose = !self.verbose;
- tracing::info!(
- "Verbose logging is now {}",
- if self.verbose { "on" } else { "off" }
- );
- }
- KeyCode::Char('t') => {
- self.trace = !self.trace;
- tracing::info!("Tracing is now {}", if self.trace { "on" } else { "off" });
- }
- KeyCode::Char('D') => {
- return Ok(Some(ServeUpdate::OpenDebugger {
- id: BuildId::SERVER,
- }));
- }
- KeyCode::Char('d') => {
- return Ok(Some(ServeUpdate::OpenDebugger {
- id: BuildId::CLIENT,
- }));
- }
- KeyCode::Char('c') => {
- stdout()
- .execute(Clear(ClearType::All))?
- .execute(Clear(ClearType::Purge))?;
- // Clear the terminal and push the frame to the bottom
- _ = self.term.borrow_mut().as_mut().map(|t| {
- let frame_rect = t.get_frame().area();
- let term_size = t.size().unwrap();
- let remaining_space = term_size
- .height
- .saturating_sub(frame_rect.y + frame_rect.height);
- t.insert_before(remaining_space, |_| {})
- });
- }
- // Toggle the more modal by swapping the the terminal with a new one
- // This is a bit of a hack since crossterm doesn't technically support changing the
- // size of an inline viewport.
- KeyCode::Char('/') => {
- if let Some(terminal) = self.term.borrow_mut().as_mut() {
- // Toggle the more modal, which will change our current viewport height
- self.more_modal_open = !self.more_modal_open;
- // Clear the terminal before resizing it, such that it doesn't tear
- terminal.clear()?;
- // And then set the new viewport, which essentially mimics a resize
- *terminal = Terminal::with_options(
- CrosstermBackend::new(stdout()),
- TerminalOptions {
- viewport: Viewport::Inline(self.viewport_current_height()),
- },
- )?;
- }
- }
- _ => {}
- }
- // Out of safety, we always redraw, since it's relatively cheap operation
- Ok(Some(ServeUpdate::Redraw))
- }
- /// Push a TraceMsg to be printed on the next render
- pub fn push_log(&mut self, message: TraceMsg) {
- self.pending_logs.push_front(message);
- }
- pub fn push_cargo_log(&mut self, message: Diagnostic) {
- use cargo_metadata::diagnostic::DiagnosticLevel;
- if self.trace || !matches!(message.level, DiagnosticLevel::Note) {
- self.push_log(TraceMsg::cargo(message));
- }
- }
- /// Add a message from stderr to the logs
- /// This will queue the stderr message as a TraceMsg and print it on the next render
- /// We'll use the `App` TraceSrc for the msg, and whatever level is provided
- pub fn push_stdio(&mut self, platform: Platform, msg: String, level: Level) {
- self.push_log(TraceMsg::text(TraceSrc::App(platform), level, msg));
- }
- /// Push a message from the websocket to the logs
- pub fn push_ws_message(&mut self, platform: Platform, message: &axum::extract::ws::Message) {
- use dioxus_devtools_types::ClientMsg;
- // We can only handle text messages from the websocket...
- let axum::extract::ws::Message::Text(text) = message else {
- return;
- };
- // ...and then decode them into a ClientMsg
- let res = serde_json::from_str::<ClientMsg>(text.as_str());
- // Client logs being errors aren't fatal, but we should still report them them
- let msg = match res {
- Ok(msg) => msg,
- Err(err) => {
- tracing::error!(dx_src = ?TraceSrc::Dev, "Error parsing message from {}: {} -> {:?}", platform, err, text.as_str());
- return;
- }
- };
- let ClientMsg::Log { level, messages } = msg else {
- return;
- };
- // FIXME(jon): why are we pulling only the first message here?
- let content = messages.first().unwrap_or(&String::new()).clone();
- let level = match level.as_str() {
- "trace" => Level::TRACE,
- "debug" => Level::DEBUG,
- "info" => Level::INFO,
- "warn" => Level::WARN,
- "error" => Level::ERROR,
- _ => Level::INFO,
- };
- // We don't care about logging the app's message so we directly push it instead of using tracing.
- self.push_log(TraceMsg::text(TraceSrc::App(platform), level, content));
- }
- /// Change internal state based on the build engine's update
- ///
- /// We want to keep internal state as limited as possible, so currently we're only setting our
- /// animation tick. We could, in theory, just leave animation running and have no internal state,
- /// but that seems a bit wasteful. We might eventually change this to be more of a "requestAnimationFrame"
- /// approach, but then we'd need to do that *everywhere* instead of simply performing a react-like
- /// re-render when external state changes. Ratatui will diff the intermediate buffer, so we at least
- /// we won't be drawing it.
- pub(crate) fn new_build_update(&mut self, update: &BuilderUpdate) {
- match update {
- BuilderUpdate::Progress {
- stage: BuildStage::Starting { .. },
- } => self.tick_animation = true,
- BuilderUpdate::BuildReady { .. } => self.tick_animation = false,
- BuilderUpdate::BuildFailed { .. } => self.tick_animation = false,
- _ => {}
- }
- }
- /// Render the current state of everything to the console screen
- pub fn render(&mut self, runner: &AppServer, server: &WebServer) {
- if !self.interactive {
- return;
- }
- // Get a handle to the terminal with a different lifetime so we can continue to call &self methods
- let owned_term = self.term.clone();
- let mut term = owned_term.borrow_mut();
- let Some(term) = term.as_mut() else {
- return;
- };
- // First, dequeue any logs that have built up from event handling
- _ = self.drain_logs(term);
- // Then, draw the frame, passing along all the state of the TUI so we can render it properly
- _ = term.draw(|frame| {
- self.render_frame(frame, RenderState { runner, server });
- });
- }
- fn render_frame(&self, frame: &mut Frame, state: RenderState) {
- // Use the max size of the viewport, but shrunk to a sensible max width
- let mut area = frame.area();
- area.width = area.width.clamp(0, VIEWPORT_MAX_WIDTH);
- let [_top, body, _bottom] = Layout::vertical([
- Constraint::Length(1),
- Constraint::Fill(1),
- Constraint::Length(1),
- ])
- .horizontal_margin(1)
- .areas(area);
- self.render_borders(frame, area);
- self.render_body(frame, body, state);
- self.render_body_title(frame, _top, state);
- }
- fn render_body_title(&self, frame: &mut Frame<'_>, area: Rect, _state: RenderState) {
- frame.render_widget(
- Line::from(vec![
- " ".dark_gray(),
- match self.more_modal_open {
- true => "/:more".light_yellow(),
- false => "/:more".dark_gray(),
- },
- " ".dark_gray(),
- ])
- .right_aligned(),
- area,
- );
- }
- fn render_body(&self, frame: &mut Frame<'_>, area: Rect, state: RenderState) {
- let [_title, body, more, _foot] = Layout::vertical([
- Constraint::Length(0),
- Constraint::Length(VIEWPORT_HEIGHT_SMALL - 2),
- Constraint::Fill(1),
- Constraint::Length(0),
- ])
- .horizontal_margin(1)
- .areas(area);
- let [col1, col2] = Layout::horizontal([Constraint::Length(50), Constraint::Fill(1)])
- .horizontal_margin(1)
- .areas(body);
- self.render_gauges(frame, col1, state);
- self.render_stats(frame, col2, state);
- if self.more_modal_open {
- self.render_more_modal(frame, more, state);
- }
- }
- fn render_gauges(&self, frame: &mut Frame<'_>, area: Rect, state: RenderState) {
- let [gauge_area, _margin] =
- Layout::horizontal([Constraint::Fill(1), Constraint::Length(3)]).areas(area);
- let [app_progress, second_progress, status_line]: [_; 3] = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- ])
- .areas(gauge_area);
- let client = &state.runner.client();
- self.render_single_gauge(
- frame,
- app_progress,
- client.compile_progress(),
- "App: ",
- state,
- client.compile_duration(),
- );
- if state.runner.is_fullstack() {
- self.render_single_gauge(
- frame,
- second_progress,
- state.runner.server_compile_progress(),
- "Server: ",
- state,
- client.compile_duration(),
- );
- } else {
- self.render_single_gauge(
- frame,
- second_progress,
- client.bundle_progress(),
- "Bundle: ",
- state,
- client.bundle_duration(),
- );
- }
- let mut lines = vec!["Status: ".white()];
- match &client.stage {
- BuildStage::Initializing => lines.push("Initializing".yellow()),
- BuildStage::Starting { patch, .. } => {
- if *patch {
- lines.push("Hot-patching...".yellow())
- } else {
- lines.push("Starting build".yellow())
- }
- }
- BuildStage::InstallingTooling => lines.push("Installing tooling".yellow()),
- BuildStage::Compiling {
- current,
- total,
- krate,
- ..
- } => {
- lines.push("Compiling ".yellow());
- lines.push(format!("{current}/{total} ").gray());
- lines.push(krate.as_str().dark_gray())
- }
- BuildStage::OptimizingWasm => lines.push("Optimizing wasm".yellow()),
- BuildStage::SplittingBundle => lines.push("Splitting bundle".yellow()),
- BuildStage::CompressingAssets => lines.push("Compressing assets".yellow()),
- BuildStage::RunningBindgen => lines.push("Running wasm-bindgen".yellow()),
- BuildStage::RunningGradle => lines.push("Running gradle assemble".yellow()),
- BuildStage::Bundling => lines.push("Bundling app".yellow()),
- BuildStage::CopyingAssets {
- current,
- total,
- path,
- } => {
- lines.push("Copying asset ".yellow());
- lines.push(format!("{current}/{total} ").gray());
- if let Some(name) = path.file_name().and_then(|f| f.to_str()) {
- lines.push(name.dark_gray())
- }
- }
- BuildStage::Success => {
- lines.push("Serving ".yellow());
- lines.push(client.build.executable_name().white());
- lines.push(" 🚀 ".green());
- if let Some(comp_time) = client.total_build_time() {
- lines.push(format!("{:.1}s", comp_time.as_secs_f32()).dark_gray());
- }
- }
- BuildStage::Failed => lines.push("Failed".red()),
- BuildStage::Aborted => lines.push("Aborted".red()),
- BuildStage::Restarting => lines.push("Restarting".yellow()),
- BuildStage::Linking => lines.push("Linking".yellow()),
- BuildStage::Hotpatching => lines.push("Hot-patching...".yellow()),
- BuildStage::ExtractingAssets => lines.push("Extracting assets".yellow()),
- BuildStage::Prerendering => lines.push("Pre-rendering...".yellow()),
- _ => {}
- };
- frame.render_widget(Line::from(lines), status_line);
- }
- fn render_single_gauge(
- &self,
- frame: &mut Frame<'_>,
- area: Rect,
- value: f64,
- label: &str,
- state: RenderState,
- time_taken: Option<Duration>,
- ) {
- let failed = state.runner.client.stage == BuildStage::Failed;
- let value = if failed { 1.0 } else { value.clamp(0.0, 1.0) };
- let [gauge_row, _, icon] = Layout::horizontal([
- Constraint::Fill(1),
- Constraint::Length(2),
- Constraint::Length(10),
- ])
- .areas(area);
- frame.render_widget(
- LineGauge::default()
- .filled_style(Style::default().fg(match value {
- 1.0 if failed => Color::Red,
- 1.0 => Color::Green,
- _ => Color::Yellow,
- }))
- .unfilled_style(Style::default().fg(Color::DarkGray))
- .label(label.gray())
- .line_set(symbols::line::THICK)
- .ratio(if !failed { value } else { 1.0 }),
- gauge_row,
- );
- let [throbber_frame, time_frame] = Layout::default()
- .direction(Direction::Horizontal)
- .constraints([Constraint::Length(3), Constraint::Fill(1)])
- .areas(icon);
- if value != 1.0 {
- let throb = throbber_widgets_tui::Throbber::default()
- .style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan))
- .throbber_style(
- ratatui::style::Style::default()
- .fg(ratatui::style::Color::White)
- .add_modifier(ratatui::style::Modifier::BOLD),
- )
- .throbber_set(throbber_widgets_tui::BLACK_CIRCLE)
- .use_type(throbber_widgets_tui::WhichUse::Spin);
- frame.render_stateful_widget(throb, throbber_frame, &mut self.throbber.borrow_mut());
- } else {
- frame.render_widget(
- Line::from(vec![if failed {
- "❌ ".white()
- } else {
- "🎉 ".white()
- }])
- .left_aligned(),
- throbber_frame,
- );
- }
- if let Some(time_taken) = time_taken {
- if !failed {
- frame.render_widget(
- Line::from(vec![format!("{:.1}s", time_taken.as_secs_f32()).dark_gray()])
- .left_aligned(),
- time_frame,
- );
- }
- }
- }
- fn render_stats(&self, frame: &mut Frame<'_>, area: Rect, state: RenderState) {
- let [current_platform, app_features, serve_address]: [_; 3] = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- ])
- .areas(area);
- let client = &state.runner.client();
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- "Platform: ".gray(),
- client.build.platform.expected_name().yellow(),
- if state.runner.is_fullstack() {
- " + fullstack".yellow()
- } else {
- " ".dark_gray()
- },
- ])),
- current_platform,
- );
- self.render_feature_list(frame, app_features, state);
- // todo(jon) should we write https ?
- let address = match state.server.displayed_address() {
- Some(address) => format!(
- "http://{}{}",
- address,
- state
- .runner
- .client
- .build
- .base_path()
- .map(|f| format!("/{f}/"))
- .unwrap_or_default()
- )
- .blue(),
- None => "no server address".dark_gray(),
- };
- frame.render_widget_ref(
- Paragraph::new(Line::from(vec![
- if client.build.platform == Platform::Web {
- "Serving at: ".gray()
- } else {
- "ServerFns at: ".gray()
- },
- address,
- ])),
- serve_address,
- );
- }
- fn render_feature_list(&self, frame: &mut Frame<'_>, area: Rect, state: RenderState) {
- frame.render_widget(
- Paragraph::new(Line::from({
- let mut lines = vec!["App features: ".gray(), "[".yellow()];
- let feature_list: Vec<String> = state.runner.client().build.all_target_features();
- let num_features = feature_list.len();
- for (idx, feature) in feature_list.into_iter().enumerate() {
- lines.push("\"".yellow());
- lines.push(feature.yellow());
- lines.push("\"".yellow());
- if idx != num_features - 1 {
- lines.push(", ".dark_gray());
- }
- }
- lines.push("]".yellow());
- lines
- })),
- area,
- );
- }
- fn render_more_modal(&self, frame: &mut Frame<'_>, area: Rect, state: RenderState) {
- let [col1, col2] =
- Layout::horizontal([Constraint::Length(50), Constraint::Fill(1)]).areas(area);
- let [top, bottom] = Layout::vertical([Constraint::Fill(1), Constraint::Length(2)])
- .horizontal_margin(1)
- .areas(col1);
- let meta_list: [_; 6] = Layout::vertical([
- Constraint::Length(1), // spacing
- Constraint::Length(1), // item 1
- Constraint::Length(1), // item 2
- Constraint::Length(1), // item 3
- Constraint::Length(1), // item 4
- Constraint::Length(1), // Spacing
- ])
- .areas(top);
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- "dx version: ".gray(),
- self.dx_version.as_str().yellow(),
- ])),
- meta_list[1],
- );
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- "rustc: ".gray(),
- state.runner.workspace.rustc_version.as_str().yellow(),
- ])),
- meta_list[2],
- );
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- "Hotreload: ".gray(),
- if !state.runner.automatic_rebuilds {
- "disabled".dark_gray()
- } else if state.runner.use_hotpatch_engine {
- "hot-patching".yellow()
- } else {
- "rsx and assets".yellow()
- },
- ])),
- meta_list[3],
- );
- let server_address = match state.server.server_address() {
- Some(address) => format!("http://{}", address).yellow(),
- None => "no address".dark_gray(),
- };
- frame.render_widget(
- Paragraph::new(Line::from(vec!["Network: ".gray(), server_address])),
- meta_list[4],
- );
- let links_list: [_; 2] =
- Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(bottom);
- if state.runner.client.build.using_dioxus_explicitly {
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- "Read the docs: ".gray(),
- "https://dioxuslabs.com/0.6/docs".blue(),
- ])),
- links_list[0],
- );
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- "Video tutorials: ".gray(),
- "https://youtube.com/@DioxusLabs".blue(),
- ])),
- links_list[1],
- );
- }
- let cmds = [
- "",
- "r: rebuild the app",
- "o: open the app",
- "p: pause rebuilds",
- "v: toggle verbose logs",
- "t: toggle tracing logs ",
- "c: clear the screen",
- "/: toggle more commands",
- ];
- let layout: [_; 8] = Layout::vertical(cmds.iter().map(|_| Constraint::Length(1)))
- .horizontal_margin(1)
- .areas(col2);
- for (idx, cmd) in cmds.iter().enumerate() {
- if cmd.is_empty() {
- continue;
- }
- let (cmd, detail) = cmd.split_once(": ").unwrap_or((cmd, ""));
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- cmd.gray(),
- ": ".gray(),
- detail.dark_gray(),
- ])),
- layout[idx],
- );
- }
- }
- /// Render borders around the terminal, forcing an inner clear while we're at it
- fn render_borders(&self, frame: &mut Frame, area: Rect) {
- frame.render_widget(ratatui::widgets::Clear, area);
- frame.render_widget(
- Block::default()
- .borders(Borders::ALL)
- .border_type(BorderType::Rounded)
- .border_style(Style::default().fg(Color::DarkGray)),
- area,
- );
- }
- /// Print logs to the terminal as close to a regular "println!()" as possible.
- ///
- /// We don't want alternate screens or other terminal tricks because we want these logs to be as
- /// close to real as possible. Once the log is printed, it is lost, so we need to be very careful
- /// here to not print it incorrectly.
- ///
- /// This method works by printing lines at the top of the viewport frame, and then scrolling up
- /// the viewport accordingly, such that our final call to "clear" will cause the terminal the viewport
- /// to be comlpetely erased and rewritten. This is slower since we're going around ratatui's diff
- /// logic, but it's the only way to do this that gives us "true println!" semantics.
- ///
- /// In the future, Ratatui's insert_before method will get scroll regions, which will make this logic
- /// much simpler. In that future, we'll simply insert a line into the scrollregion which should automatically
- /// force that portion of the terminal to scroll up.
- ///
- /// TODO(jon): we could look into implementing scroll regions ourselves, but I think insert_before will
- /// land in a reasonable amount of time.
- #[deny(clippy::manual_saturating_arithmetic)]
- fn drain_logs(
- &mut self,
- terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
- ) -> io::Result<()> {
- use unicode_segmentation::UnicodeSegmentation;
- let Some(log) = self.pending_logs.pop_back() else {
- return Ok(());
- };
- // Only show debug logs if verbose is enabled
- if log.level == Level::DEBUG && !self.verbose {
- return Ok(());
- }
- if log.level == Level::TRACE && !self.trace {
- return Ok(());
- }
- // Grab out the size and location of the terminal and its viewport before we start messing with it
- let frame_rect = terminal.get_frame().area();
- let term_size = terminal.size().unwrap();
- // Render the log into an ansi string
- // 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
- let lines = Self::tracemsg_to_ansi_string(log);
- // Get the lines of the output sequence and their overflow
- let lines_printed = lines
- .iter()
- .map(|line| {
- // Very important to strip ansi codes before counting graphemes - the ansi codes count as multiple graphemes!
- let grapheme_count = console::strip_ansi_codes(line).graphemes(true).count();
- grapheme_count.max(1).div_ceil(term_size.width as usize) as u16
- })
- .sum::<u16>();
- // The viewport might be clipped, but the math still needs to work out.
- let actual_vh_height = self.viewport_current_height().min(term_size.height);
- // Move the terminal's cursor down to the number of lines printed
- let remaining_space = term_size
- .height
- .saturating_sub(frame_rect.y + frame_rect.height);
- // Calculate how many lines we need to push back
- // - padding equals lines_printed when the frame is at the bottom
- // - padding is zero when the remaining space is greater/equal than the scrollback (the frame will get pushed naturally)
- // Determine what extra padding is remaining after we've shifted the terminal down
- // this will be the distance between the final line and the top of the frame, only if the
- // final line has extended into the frame
- let final_line = frame_rect.y + lines_printed;
- let max_frame_top = term_size.height - actual_vh_height;
- let padding = final_line
- .saturating_sub(max_frame_top)
- .clamp(0, actual_vh_height - 1);
- // The only reliable way we can force the terminal downards is through "insert_before".
- //
- // If we need to push the terminal down, we'll use this method with the number of lines
- // Ratatui will handle this rest.
- //
- // This also calls `.clear()` so we don't need to call clear at the end of this function.
- //
- // FIXME(jon): eventually insert_before will get scroll regions, breaking this, but making the logic here simpler
- terminal.insert_before(remaining_space.min(lines_printed), |_| {})?;
- // Wipe the viewport clean so it doesn't tear
- crossterm::queue!(
- std::io::stdout(),
- crossterm::cursor::MoveTo(0, frame_rect.y),
- crossterm::terminal::Clear(ClearType::FromCursorDown),
- )?;
- // Start printing the log by writing on top of the topmost line
- for (idx, line) in lines.into_iter().enumerate() {
- // Move the cursor to the correct line offset but don't go past the bottom of the terminal
- let start = frame_rect.y + idx as u16;
- let start = start.min(term_size.height - 1);
- crossterm::queue!(
- std::io::stdout(),
- crossterm::cursor::MoveTo(0, start),
- crossterm::style::Print(line),
- crossterm::style::Print("\n"),
- )?;
- }
- // Scroll the terminal if we need to
- for _ in 0..padding {
- crossterm::queue!(
- std::io::stdout(),
- crossterm::cursor::MoveTo(0, term_size.height - 1),
- crossterm::style::Print("\n"),
- )?;
- }
- Ok(())
- }
- fn viewport_current_height(&self) -> u16 {
- match self.more_modal_open {
- true => VIEWPORT_HEIGHT_BIG,
- false => VIEWPORT_HEIGHT_SMALL,
- }
- }
- fn tracemsg_to_ansi_string(log: TraceMsg) -> Vec<String> {
- use ansi_to_tui::IntoText;
- use chrono::Timelike;
- let rendered = match log.content {
- TraceContent::Cargo(msg) => msg.rendered.unwrap_or_default(),
- TraceContent::Text(text) => text,
- };
- let mut lines = vec![];
- for (idx, raw_line) in rendered.lines().enumerate() {
- let line_as_text = raw_line.into_text().unwrap();
- let is_pretending_to_be_frame = !raw_line.is_empty()
- && raw_line
- .chars()
- .all(|c| c == '=' || c == '-' || c == ' ' || c == '─');
- for (subline_idx, mut line) in line_as_text.lines.into_iter().enumerate() {
- if idx == 0 && subline_idx == 0 {
- let mut formatted_line = Line::default();
- formatted_line.push_span(
- Span::raw(format!(
- "{:02}:{:02}:{:02} ",
- log.timestamp.hour(),
- log.timestamp.minute(),
- log.timestamp.second()
- ))
- .dark_gray(),
- );
- formatted_line.push_span(
- Span::raw(format!(
- "[{src}] {padding}",
- src = log.source,
- padding =
- " ".repeat(3usize.saturating_sub(log.source.to_string().len()))
- ))
- .style(match log.source {
- TraceSrc::App(_platform) => match log.level {
- Level::ERROR => Style::new().red(),
- Level::WARN => Style::new().yellow(),
- Level::INFO => Style::new().magenta(),
- Level::DEBUG => Style::new().magenta(),
- Level::TRACE => Style::new().magenta(),
- },
- TraceSrc::Dev => match log.level {
- Level::ERROR => Style::new().red(),
- Level::WARN => Style::new().yellow(),
- Level::INFO => Style::new().blue(),
- Level::DEBUG => Style::new().blue(),
- Level::TRACE => Style::new().blue(),
- },
- TraceSrc::Cargo => Style::new().yellow(),
- TraceSrc::Build => Style::new().blue(),
- TraceSrc::Bundle => Style::new().blue(),
- TraceSrc::Unknown => Style::new().gray(),
- }),
- );
- for span in line.spans {
- formatted_line.push_span(span);
- }
- line = formatted_line;
- }
- if is_pretending_to_be_frame {
- line = line.dark_gray();
- }
- // Create the ansi -> raw string line with a width of either the viewport width or the max width
- let line_length = line.styled_graphemes(Style::default()).count();
- if line_length < u16::MAX as usize {
- lines.push(AnsiStringLine::new(line_length as _).render(&line));
- } else {
- lines.push(line.to_string())
- }
- }
- }
- lines
- }
- }
|