From 602d64f23847dc4a38c6b5f19810c7741154cf41 Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Tue, 3 Feb 2026 14:32:38 +1000 Subject: [PATCH] Add configurable REPL output size limits (#47114) Closes #47113 Adds configurable REPL output size limits with two new settings, `repl.output_max_height_lines` and `repl.output_max_width_columns`, so large outputs scroll instead of expanding and images scale down to fit the available space. The output containers in both inline REPL blocks and notebook cells now respect these bounds, and image sizing uses the same text metrics as the terminal output for consistent column-based width calculations. Release Notes: - REPL output now supports configurable max height and width limits, with large outputs scrolling and images scaling to stay within the viewport. --- assets/settings/default.json | 6 + crates/repl/src/notebook/cell.rs | 78 ++++++++++++- crates/repl/src/outputs.rs | 8 +- crates/repl/src/outputs/image.rs | 108 ++++++++++++++++-- crates/repl/src/outputs/plain.rs | 75 +++++++++++- crates/repl/src/repl_settings.rs | 12 ++ crates/repl/src/session.rs | 12 ++ .../settings_content/src/settings_content.rs | 10 ++ 8 files changed, 297 insertions(+), 12 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 2438b8fa73f146a224d0ca579433f78f91a2e9f5..9df12f83a78a898d7b875405b8c07975e88732c4 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -2279,6 +2279,12 @@ // Maximum number of lines to keep in REPL's scrollback buffer. // Clamped with [4, 256] range. "max_lines": 32, + // Maximum number of lines of output to display before scrolling. + // Set to 0 to disable output height limits. + "output_max_height_lines": 0, + // Maximum number of columns of output to display before scaling images. + // Set to 0 to disable output width limits. + "output_max_width_columns": 0, }, // Vim settings "vim": { diff --git a/crates/repl/src/notebook/cell.rs b/crates/repl/src/notebook/cell.rs index 625b2a8746c0f84fbf1d7a0f4dede9deb0de1451..02a8e0f7adaf90741c579584b5f58d4fe3b0e5bb 100644 --- a/crates/repl/src/notebook/cell.rs +++ b/crates/repl/src/notebook/cell.rs @@ -19,7 +19,8 @@ use util::ResultExt; use crate::{ notebook::{CODE_BLOCK_INSET, GUTTER_WIDTH}, - outputs::{Output, plain::TerminalOutput, user_error::ErrorView}, + outputs::{Output, plain, plain::TerminalOutput, user_error::ErrorView}, + repl_settings::ReplSettings, }; #[derive(Copy, Clone, PartialEq, PartialOrd)] @@ -1036,6 +1037,17 @@ impl RunnableCell for CodeCell { impl Render for CodeCell { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let output_max_height = ReplSettings::get_global(cx).output_max_height_lines; + let output_max_height = if output_max_height > 0 { + Some(window.line_height() * output_max_height as f32) + } else { + None + }; + let output_max_width = plain::max_width_for_columns( + ReplSettings::get_global(cx).output_max_width_columns, + window, + cx, + ); // get the language from the editor's buffer let language_name = self .editor @@ -1094,6 +1106,70 @@ impl Render for CodeCell { ), ) // Output portion + .child( + h_flex() + .w_full() + .pr_6() + .rounded_xs() + .items_start() + .gap(DynamicSpacing::Base08.rems(cx)) + .bg(self.selected_bg_color(window, cx)) + .child(self.gutter_output(window, cx)) + .child( + div().py_1p5().w_full().child( + div() + .flex() + .size_full() + .flex_1() + .py_3() + .px_5() + .rounded_lg() + .border_1() + .child( + div() + .id((ElementId::from(self.id.to_string()), "output-scroll")) + .w_full() + .when_some(output_max_width, |div, max_w| { + div.max_w(max_w).overflow_x_scroll() + }) + .when_some(output_max_height, |div, max_h| { + div.max_h(max_h).overflow_y_scroll() + }) + .children(self.outputs.iter().map(|output| { + let content = match output { + Output::Plain { content, .. } => { + Some(content.clone().into_any_element()) + } + Output::Markdown { content, .. } => { + Some(content.clone().into_any_element()) + } + Output::Stream { content, .. } => { + Some(content.clone().into_any_element()) + } + Output::Image { content, .. } => { + Some(content.clone().into_any_element()) + } + Output::Message(message) => Some( + div().child(message.clone()).into_any_element(), + ), + Output::Table { content, .. } => { + Some(content.clone().into_any_element()) + } + Output::Json { content, .. } => { + Some(content.clone().into_any_element()) + } + Output::ErrorOutput(error_view) => { + error_view.render(window, cx) + } + Output::ClearOutputWaitMarker => None, + }; + + div().children(content) + })), + ), + ), + ), + ) .when( self.has_outputs() || self.execution_duration.is_some() || self.is_executing, |this| { diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index 53448cc8ae1915582dd2d72b5475af0bb48ea42e..1f50813fc27be8e8697a4d3795e03412ca0db4b0 100644 --- a/crates/repl/src/outputs.rs +++ b/crates/repl/src/outputs.rs @@ -255,6 +255,11 @@ impl Output { window: &mut Window, cx: &mut Context, ) -> impl IntoElement + use<> { + let max_width = plain::max_width_for_columns( + ReplSettings::get_global(cx).output_max_width_columns, + window, + cx, + ); let content = match self { Self::Plain { content, .. } => Some(content.clone().into_any_element()), Self::Markdown { content, .. } => Some(content.clone().into_any_element()), @@ -272,7 +277,8 @@ impl Output { h_flex() .id("output-content") .w_full() - .when(needs_horizontal_scroll, |el| el.overflow_x_scroll()) + .when_some(max_width, |this, max_w| this.max_w(max_w)) + .overflow_x_scroll() .items_start() .child( div() diff --git a/crates/repl/src/outputs/image.rs b/crates/repl/src/outputs/image.rs index fefdbec2fa2770baa279a832bd55278bd502380d..9d1ffa3d2065281cd69e67b2faf960c9aa690bcb 100644 --- a/crates/repl/src/outputs/image.rs +++ b/crates/repl/src/outputs/image.rs @@ -4,10 +4,12 @@ use base64::{ engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}, }; use gpui::{App, ClipboardItem, Image, ImageFormat, RenderImage, Window, img}; +use settings::Settings as _; use std::sync::Arc; use ui::{IntoElement, Styled, div, prelude::*}; -use crate::outputs::OutputContent; +use crate::outputs::{OutputContent, plain}; +use crate::repl_settings::ReplSettings; /// ImageView renders an image inline in an editor, adapting to the line height to fit the image. pub struct ImageView { @@ -67,20 +69,60 @@ impl ImageView { image: Arc::new(gpui_image_data), }) } + + fn scaled_size( + &self, + line_height: Pixels, + max_width: Option, + max_height: Option, + ) -> (Pixels, Pixels) { + let (mut height, mut width) = if self.height as f32 / f32::from(line_height) + == u8::MAX as f32 + { + let height = u8::MAX as f32 * line_height; + let width = Pixels::from(self.width as f32 * f32::from(height) / self.height as f32); + (height, width) + } else { + (self.height.into(), self.width.into()) + }; + + let mut scale: f32 = 1.0; + if let Some(max_width) = max_width { + if width > max_width { + scale = scale.min(max_width / width); + } + } + + if let Some(max_height) = max_height { + if height > max_height { + scale = scale.min(max_height / height); + } + } + + if scale < 1.0 { + width *= scale; + height *= scale; + } + + (height, width) + } } impl Render for ImageView { - fn render(&mut self, window: &mut Window, _: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let settings = ReplSettings::get_global(cx); let line_height = window.line_height(); - let (height, width) = if self.height as f32 / f32::from(line_height) == u8::MAX as f32 { - let height = u8::MAX as f32 * line_height; - let width = self.width as f32 * height / self.height as f32; - (height, width) + let max_width = plain::max_width_for_columns(settings.output_max_width_columns, window, cx); + + let max_height = if settings.output_max_height_lines > 0 { + Some(line_height * settings.output_max_height_lines as f32) } else { - (self.height.into(), self.width.into()) + None }; + let (height, width) = self.scaled_size(line_height, max_width, max_height); + let image = self.image.clone(); div().h(height).w(width).child(img(image)) @@ -96,3 +138,55 @@ impl OutputContent for ImageView { true } } + +#[cfg(test)] +mod tests { + use super::*; + + fn encode_test_image(width: u32, height: u32) -> String { + let image_buffer = + image::ImageBuffer::from_pixel(width, height, image::Rgba([0, 0, 0, 255])); + let image = image::DynamicImage::ImageRgba8(image_buffer); + + let mut bytes = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut bytes); + if let Err(error) = image.write_to(&mut cursor, image::ImageFormat::Png) { + panic!("failed to encode test image: {error}"); + } + + base64::engine::general_purpose::STANDARD.encode(bytes) + } + + #[test] + fn test_image_view_scaled_size_respects_limits() { + let encoded = encode_test_image(200, 120); + let image_view = match ImageView::from(&encoded) { + Ok(view) => view, + Err(error) => panic!("failed to decode image view: {error}"), + }; + + let line_height = Pixels::from(10.0); + let max_width = Pixels::from(50.0); + let max_height = Pixels::from(40.0); + let (height, width) = + image_view.scaled_size(line_height, Some(max_width), Some(max_height)); + + assert_eq!(f32::from(width), 50.0); + assert_eq!(f32::from(height), 30.0); + } + + #[test] + fn test_image_view_scaled_size_unbounded() { + let encoded = encode_test_image(200, 120); + let image_view = match ImageView::from(&encoded) { + Ok(view) => view, + Err(error) => panic!("failed to decode image view: {error}"), + }; + + let line_height = Pixels::from(10.0); + let (height, width) = image_view.scaled_size(line_height, None, None); + + assert_eq!(f32::from(width), 200.0); + assert_eq!(f32::from(height), 120.0); + } +} diff --git a/crates/repl/src/outputs/plain.rs b/crates/repl/src/outputs/plain.rs index 54e4983b9f7f22965a3f92f60c2d5fe75841c781..0db2f811fb9ca3b82114db23826e37fe699bd3a0 100644 --- a/crates/repl/src/outputs/plain.rs +++ b/crates/repl/src/outputs/plain.rs @@ -55,7 +55,7 @@ pub struct TerminalOutput { } /// Returns the default text style for the terminal output. -pub fn text_style(window: &mut Window, cx: &mut App) -> TextStyle { +pub fn text_style(window: &mut Window, cx: &App) -> TextStyle { let settings = ThemeSettings::get_global(cx).clone(); let font_size = settings.buffer_font_size(cx).into(); @@ -94,8 +94,8 @@ pub fn terminal_size(window: &mut Window, cx: &mut App) -> terminal::TerminalBou let cell_width = text_system .advance(font_id, font_pixels, 'w') - .unwrap() - .width; + .map(|advance| advance.width) + .unwrap_or(Pixels::ZERO); let num_lines = ReplSettings::get_global(cx).max_lines; let columns = ReplSettings::get_global(cx).max_columns; @@ -114,6 +114,27 @@ pub fn terminal_size(window: &mut Window, cx: &mut App) -> terminal::TerminalBou } } +pub fn max_width_for_columns( + columns: usize, + window: &mut Window, + cx: &App, +) -> Option { + if columns == 0 { + return None; + } + + let text_style = text_style(window, cx); + let text_system = window.text_system(); + let font_pixels = text_style.font_size.to_pixels(window.rem_size()); + let font_id = text_system.resolve_font(&text_style.font()); + let cell_width = text_system + .advance(font_id, font_pixels, 'w') + .map(|advance| advance.width) + .unwrap_or(Pixels::ZERO); + + Some(cell_width * columns as f32) +} + impl TerminalOutput { /// Creates a new `TerminalOutput` instance. /// @@ -244,6 +265,54 @@ impl TerminalOutput { } } +#[cfg(test)] +mod tests { + use super::*; + use gpui::{TestAppContext, VisualTestContext}; + use settings::SettingsStore; + + fn init_test(cx: &mut TestAppContext) -> &mut VisualTestContext { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + }); + cx.add_empty_window() + } + + #[gpui::test] + fn test_max_width_for_columns_zero(cx: &mut TestAppContext) { + let cx = init_test(cx); + let result = cx.update(|window, cx| max_width_for_columns(0, window, cx)); + assert!(result.is_none()); + } + + #[gpui::test] + fn test_max_width_for_columns_matches_cell_width(cx: &mut TestAppContext) { + let cx = init_test(cx); + let columns = 5; + let (result, expected) = cx.update(|window, cx| { + let text_style = text_style(window, cx); + let text_system = window.text_system(); + let font_pixels = text_style.font_size.to_pixels(window.rem_size()); + let font_id = text_system.resolve_font(&text_style.font()); + let cell_width = text_system + .advance(font_id, font_pixels, 'w') + .map(|advance| advance.width) + .unwrap_or(gpui::Pixels::ZERO); + let result = max_width_for_columns(columns, window, cx); + (result, cell_width * columns as f32) + }); + + let Some(result) = result else { + panic!("expected max width for columns {columns}"); + }; + let result_f32: f32 = result.into(); + let expected_f32: f32 = expected.into(); + assert!((result_f32 - expected_f32).abs() < 0.01); + } +} + impl Render for TerminalOutput { /// Renders the terminal output as a GPUI element. /// diff --git a/crates/repl/src/repl_settings.rs b/crates/repl/src/repl_settings.rs index b7fb0672e1a0ee12e92479df573b150f71814211..302164a5b360157edceff1b1f2e18f6c6fd7a50b 100644 --- a/crates/repl/src/repl_settings.rs +++ b/crates/repl/src/repl_settings.rs @@ -22,6 +22,16 @@ pub struct ReplSettings { /// /// Default: 50 pub inline_output_max_length: usize, + /// Maximum number of lines of output to display before scrolling. + /// Set to 0 to disable output height limits. + /// + /// Default: 0 + pub output_max_height_lines: usize, + /// Maximum number of columns of output to display before scaling images. + /// Set to 0 to disable output width limits. + /// + /// Default: 0 + pub output_max_width_columns: usize, } impl Settings for ReplSettings { @@ -33,6 +43,8 @@ impl Settings for ReplSettings { max_columns: repl.max_columns.unwrap(), inline_output: repl.inline_output.unwrap_or(true), inline_output_max_length: repl.inline_output_max_length.unwrap_or(50), + output_max_height_lines: repl.output_max_height_lines.unwrap_or(0), + output_max_width_columns: repl.output_max_width_columns.unwrap_or(0), } } } diff --git a/crates/repl/src/session.rs b/crates/repl/src/session.rs index 57703c305badcf7f09d09e65e8d712c0ef3bef7a..49da9d0e312c1278b909442fe74438d099ab2325 100644 --- a/crates/repl/src/session.rs +++ b/crates/repl/src/session.rs @@ -7,6 +7,7 @@ use crate::{ outputs::{ ExecutionStatus, ExecutionView, ExecutionViewFinishedEmpty, ExecutionViewFinishedSmall, }, + repl_settings::ReplSettings, }; use anyhow::Context as _; use collections::{HashMap, HashSet}; @@ -34,6 +35,7 @@ use runtimelib::{ ExecuteRequest, ExecutionState, InterruptRequest, JupyterMessage, JupyterMessageContent, ShutdownRequest, }; +use settings::Settings as _; use std::{env::temp_dir, ops::Range, sync::Arc, time::Duration}; use theme::ActiveTheme; use ui::{IconButtonShape, Tooltip, prelude::*}; @@ -143,6 +145,12 @@ impl EditorBlock { let rem_size = cx.window.rem_size(); let text_line_height = text_style.line_height_in_pixels(rem_size); + let output_settings = ReplSettings::get_global(cx.app); + let output_max_height = if output_settings.output_max_height_lines > 0 { + Some(text_line_height * output_settings.output_max_height_lines as f32) + } else { + None + }; let close_button = h_flex() .flex_none() @@ -190,11 +198,15 @@ impl EditorBlock { ) .child( div() + .id((ElementId::from(cx.block_id), "output-scroll")) .flex_1() .overflow_x_hidden() .py(text_line_height / 2.) .mr(editor_margins.right) .pr_2() + .when_some(output_max_height, |div, max_h| { + div.max_h(max_h).overflow_y_scroll() + }) .child(execution_view), ) .into_any_element() diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 99870b5b1c3da2c405936c10c6fad06cc7499b2a..78451efc2f8186c587eb5a474271d81cc1106925 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1065,6 +1065,16 @@ pub struct ReplSettingsContent { /// /// Default: 50 pub inline_output_max_length: Option, + /// Maximum number of lines of output to display before scrolling. + /// Set to 0 to disable output height limits. + /// + /// Default: 0 + pub output_max_height_lines: Option, + /// Maximum number of columns of output to display before scaling images. + /// Set to 0 to disable output width limits. + /// + /// Default: 0 + pub output_max_width_columns: Option, } /// Settings for configuring the which-key popup behaviour.