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>>>>, events: Option, // 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, 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, } #[derive(Clone, Copy)] struct RenderState<'a> { runner: &'a AppServer, server: &'a WebServer, } impl Output { pub(crate) async fn start(interactive: bool) -> crate::Result { 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> { // 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> { 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::(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, ) { 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 = 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>, ) -> 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::(); // 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 { 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 } }