Detailed changes
@@ -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": {
@@ -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<Self>) -> 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| {
@@ -255,6 +255,11 @@ impl Output {
window: &mut Window,
cx: &mut Context<ExecutionView>,
) -> 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()
@@ -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<Pixels>,
+ max_height: Option<Pixels>,
+ ) -> (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<Self>) -> impl IntoElement {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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);
+ }
+}
@@ -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<gpui::Pixels> {
+ 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.
///
@@ -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),
}
}
}
@@ -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()
@@ -1065,6 +1065,16 @@ pub struct ReplSettingsContent {
///
/// Default: 50
pub inline_output_max_length: Option<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: Option<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: Option<usize>,
}
/// Settings for configuring the which-key popup behaviour.