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.