Measure maximum width of each cell to render table (#14026)

Kyle Kelley created

Change summary

crates/repl/src/outputs.rs | 128 +++++++++++++++++++++++++++++----------
crates/repl/src/session.rs |   2 
2 files changed, 97 insertions(+), 33 deletions(-)

Detailed changes

crates/repl/src/outputs.rs 🔗

@@ -2,11 +2,13 @@ use std::sync::Arc;
 
 use crate::stdio::TerminalOutput;
 use anyhow::Result;
-use gpui::{img, AnyElement, FontWeight, ImageData, Render, View};
+use gpui::{img, AnyElement, FontWeight, ImageData, Render, TextRun, View};
 use runtimelib::datatable::TableSchema;
 use runtimelib::media::datatable::TabularDataResource;
 use runtimelib::{ExecutionState, JupyterMessageContent, MimeBundle, MimeType};
 use serde_json::Value;
+use settings::Settings;
+use theme::ThemeSettings;
 use ui::{div, prelude::*, v_flex, IntoElement, Styled, ViewContext};
 
 // Given these outputs are destined for the editor with the block decorations API, all of them must report
@@ -97,9 +99,67 @@ impl LineHeight for ImageView {
 /// It uses the https://specs.frictionlessdata.io/tabular-data-resource/ specification for data interchange.
 pub struct TableView {
     pub table: TabularDataResource,
+    pub widths: Vec<Pixels>,
+}
+
+fn cell_content(row: &Value, field: &str) -> String {
+    match row.get(&field) {
+        Some(Value::String(s)) => s.clone(),
+        Some(Value::Number(n)) => n.to_string(),
+        Some(Value::Bool(b)) => b.to_string(),
+        Some(Value::Array(arr)) => format!("{:?}", arr),
+        Some(Value::Object(obj)) => format!("{:?}", obj),
+        Some(Value::Null) | None => String::new(),
+    }
 }
 
 impl TableView {
+    pub fn new(table: TabularDataResource, cx: &mut WindowContext) -> Self {
+        let mut widths = Vec::with_capacity(table.schema.fields.len());
+
+        let text_system = cx.text_system();
+        let text_style = cx.text_style();
+        let text_font = ThemeSettings::get_global(cx).buffer_font.clone();
+        let font_size = ThemeSettings::get_global(cx).buffer_font_size;
+        let mut runs = [TextRun {
+            len: 0,
+            font: text_font,
+            color: text_style.color,
+            background_color: None,
+            underline: None,
+            strikethrough: None,
+        }];
+
+        for field in table.schema.fields.iter() {
+            runs[0].len = field.name.len();
+            let mut width = text_system
+                .layout_line(&field.name, font_size, &runs)
+                .map(|layout| layout.width)
+                .unwrap_or(px(0.));
+
+            let Some(data) = table.data.as_ref() else {
+                widths.push(width);
+                continue;
+            };
+
+            for row in data {
+                let content = cell_content(&row, &field.name);
+                runs[0].len = content.len();
+                let cell_width = cx
+                    .text_system()
+                    .layout_line(&content, font_size, &runs)
+                    .map(|layout| layout.width)
+                    .unwrap_or(px(0.));
+
+                width = width.max(cell_width)
+            }
+
+            widths.push(width)
+        }
+
+        Self { table, widths }
+    }
+
     pub fn render(&self, cx: &ViewContext<ExecutionView>) -> AnyElement {
         let data = match &self.table.data {
             Some(data) => data,
@@ -119,6 +179,8 @@ impl TableView {
             .map(|row| self.render_row(&self.table.schema, false, &row, cx));
 
         v_flex()
+            .id("table")
+            .overflow_x_scroll()
             .w_full()
             .child(header)
             .children(body)
@@ -137,7 +199,8 @@ impl TableView {
         let row_cells = schema
             .fields
             .iter()
-            .map(|field| {
+            .zip(self.widths.iter())
+            .map(|(field, width)| {
                 let container = match field.field_type {
                     runtimelib::datatable::FieldType::String => div(),
 
@@ -153,17 +216,11 @@ impl TableView {
                     _ => div(),
                 };
 
-                let value = match row.get(&field.name) {
-                    Some(Value::String(s)) => s.clone(),
-                    Some(Value::Number(n)) => n.to_string(),
-                    Some(Value::Bool(b)) => b.to_string(),
-                    Some(Value::Array(arr)) => format!("{:?}", arr),
-                    Some(Value::Object(obj)) => format!("{:?}", obj),
-                    Some(Value::Null) | None => String::new(),
-                };
+                let value = cell_content(row, &field.name);
 
                 let mut cell = container
-                    .w_full()
+                    .min_w(*width + px(22.))
+                    .w(*width + px(22.))
                     .child(value)
                     .px_2()
                     .py_1()
@@ -178,7 +235,16 @@ impl TableView {
             })
             .collect::<Vec<_>>();
 
-        h_flex().children(row_cells).into_any_element()
+        let mut total_width = px(0.);
+        for width in self.widths.iter() {
+            // Width fudge factor: border + 2 (heading), padding
+            total_width += *width + px(22.);
+        }
+
+        h_flex()
+            .w(total_width)
+            .children(row_cells)
+            .into_any_element()
     }
 }
 
@@ -266,6 +332,20 @@ impl OutputType {
 
         el
     }
+
+    pub fn new(data: &MimeBundle, cx: &mut WindowContext) -> Self {
+        match data.richest(rank_mime_type) {
+            Some(MimeType::Plain(text)) => OutputType::Plain(TerminalOutput::from(text)),
+            Some(MimeType::Markdown(text)) => OutputType::Plain(TerminalOutput::from(text)),
+            Some(MimeType::Png(data)) | Some(MimeType::Jpeg(data)) => match ImageView::from(data) {
+                Ok(view) => OutputType::Image(view),
+                Err(error) => OutputType::Message(format!("Failed to load image: {}", error)),
+            },
+            Some(MimeType::DataTable(data)) => OutputType::Table(TableView::new(data.clone(), cx)),
+            // Any other media types are not supported
+            _ => OutputType::Message("Unsupported media type".to_string()),
+        }
+    }
 }
 
 impl LineHeight for OutputType {
@@ -283,24 +363,6 @@ impl LineHeight for OutputType {
     }
 }
 
-impl From<&MimeBundle> for OutputType {
-    fn from(data: &MimeBundle) -> Self {
-        match data.richest(rank_mime_type) {
-            Some(MimeType::Plain(text)) => OutputType::Plain(TerminalOutput::from(text)),
-            Some(MimeType::Markdown(text)) => OutputType::Plain(TerminalOutput::from(text)),
-            Some(MimeType::Png(data)) | Some(MimeType::Jpeg(data)) => match ImageView::from(data) {
-                Ok(view) => OutputType::Image(view),
-                Err(error) => OutputType::Message(format!("Failed to load image: {}", error)),
-            },
-            Some(MimeType::DataTable(data)) => OutputType::Table(TableView {
-                table: data.clone(),
-            }),
-            // Any other media types are not supported
-            _ => OutputType::Message("Unsupported media type".to_string()),
-        }
-    }
-}
-
 #[derive(Default, Clone)]
 pub enum ExecutionStatus {
     #[default]
@@ -330,8 +392,8 @@ impl ExecutionView {
     /// Accept a Jupyter message belonging to this execution
     pub fn push_message(&mut self, message: &JupyterMessageContent, cx: &mut ViewContext<Self>) {
         let output: OutputType = match message {
-            JupyterMessageContent::ExecuteResult(result) => (&result.data).into(),
-            JupyterMessageContent::DisplayData(result) => (&result.data).into(),
+            JupyterMessageContent::ExecuteResult(result) => OutputType::new(&result.data, cx),
+            JupyterMessageContent::DisplayData(result) => OutputType::new(&result.data, cx),
             JupyterMessageContent::StreamContent(result) => {
                 // Previous stream data will combine together, handling colors, carriage returns, etc
                 if let Some(new_terminal) = self.apply_terminal_text(&result.text) {
@@ -357,7 +419,7 @@ impl ExecutionView {
                         // Pager data comes in via `?` at the end of a statement in Python, used for showing documentation.
                         // Some UI will show this as a popup. For ease of implementation, it's included as an output here.
                         runtimelib::Payload::Page { data, .. } => {
-                            let output: OutputType = (data).into();
+                            let output = OutputType::new(data, cx);
                             self.outputs.push(output);
                         }
 

crates/repl/src/session.rs 🔗

@@ -89,6 +89,7 @@ impl EditorBlock {
         let render = move |cx: &mut BlockContext| {
             let execution_view = execution_view.clone();
             let text_font = ThemeSettings::get_global(cx).buffer_font.family.clone();
+            let text_font_size = ThemeSettings::get_global(cx).buffer_font_size;
             // Note: we'll want to use `cx.anchor_x` when someone runs something with no output -- just show a checkmark and not make the full block below the line
 
             let gutter_width = cx.gutter_dimensions.width;
@@ -101,6 +102,7 @@ impl EditorBlock {
                 .pl(gutter_width)
                 .child(
                     div()
+                        .text_size(text_font_size)
                         .font_family(text_font)
                         // .ml(gutter_width)
                         .mx_1()