1//! # Table Output for REPL
2//!
3//! This module provides functionality to render tabular data in Zed's REPL output.
4//!
5//! It supports the [Frictionless Data Table Schema](https://specs.frictionlessdata.io/table-schema/)
6//! for data interchange, implemented by Pandas in Python and Polars for Deno.
7//!
8//! # Python Example
9//!
10//! Tables can be created and displayed in two main ways:
11//!
12//! 1. Using raw JSON data conforming to the Tabular Data Resource specification.
13//! 2. Using Pandas DataFrames (in Python kernels).
14//!
15//! ## Raw JSON Method
16//!
17//! To create a table using raw JSON, you need to provide a JSON object that conforms
18//! to the Tabular Data Resource specification. Here's an example:
19//!
20//! ```json
21//! {
22//! "schema": {
23//! "fields": [
24//! {"name": "id", "type": "integer"},
25//! {"name": "name", "type": "string"},
26//! {"name": "age", "type": "integer"}
27//! ]
28//! },
29//! "data": [
30//! {"id": 1, "name": "Alice", "age": 30},
31//! {"id": 2, "name": "Bob", "age": 28},
32//! {"id": 3, "name": "Charlie", "age": 35}
33//! ]
34//! }
35//! ```
36//!
37//! ## Pandas Method
38//!
39//! To create a table using Pandas in a Python kernel, you can use the following steps:
40//!
41//! ```python
42//! import pandas as pd
43//!
44//! # Enable table schema output
45//! pd.set_option('display.html.table_schema', True)
46//!
47//! # Create a DataFrame
48//! df = pd.DataFrame({
49//! 'id': [1, 2, 3],
50//! 'name': ['Alice', 'Bob', 'Charlie'],
51//! 'age': [30, 28, 35]
52//! })
53//!
54//! # Display the DataFrame
55//! display(df)
56//! ```
57use gpui::{AnyElement, ClipboardItem, TextRun};
58use runtimelib::datatable::TableSchema;
59use runtimelib::media::datatable::TabularDataResource;
60use serde_json::Value;
61use settings::Settings;
62use theme::ThemeSettings;
63use ui::{div, prelude::*, v_flex, IntoElement, Styled};
64
65use crate::outputs::OutputContent;
66
67/// TableView renders a static table inline in a buffer.
68/// It uses the https://specs.frictionlessdata.io/tabular-data-resource/ specification for data interchange.
69pub struct TableView {
70 pub table: TabularDataResource,
71 pub widths: Vec<Pixels>,
72 cached_clipboard_content: ClipboardItem,
73}
74
75fn cell_content(row: &Value, field: &str) -> String {
76 match row.get(field) {
77 Some(Value::String(s)) => s.clone(),
78 Some(Value::Number(n)) => n.to_string(),
79 Some(Value::Bool(b)) => b.to_string(),
80 Some(Value::Array(arr)) => format!("{:?}", arr),
81 Some(Value::Object(obj)) => format!("{:?}", obj),
82 Some(Value::Null) | None => String::new(),
83 }
84}
85
86// Declare constant for the padding multiple on the line height
87const TABLE_Y_PADDING_MULTIPLE: f32 = 0.5;
88
89impl TableView {
90 pub fn new(table: &TabularDataResource, cx: &mut WindowContext) -> Self {
91 let mut widths = Vec::with_capacity(table.schema.fields.len());
92
93 let text_system = cx.text_system();
94 let text_style = cx.text_style();
95 let text_font = ThemeSettings::get_global(cx).buffer_font.clone();
96 let font_size = ThemeSettings::get_global(cx).buffer_font_size;
97 let mut runs = [TextRun {
98 len: 0,
99 font: text_font,
100 color: text_style.color,
101 background_color: None,
102 underline: None,
103 strikethrough: None,
104 }];
105
106 for field in table.schema.fields.iter() {
107 runs[0].len = field.name.len();
108 let mut width = text_system
109 .layout_line(&field.name, font_size, &runs)
110 .map(|layout| layout.width)
111 .unwrap_or(px(0.));
112
113 let Some(data) = table.data.as_ref() else {
114 widths.push(width);
115 continue;
116 };
117
118 for row in data {
119 let content = cell_content(row, &field.name);
120 runs[0].len = content.len();
121 let cell_width = cx
122 .text_system()
123 .layout_line(&content, font_size, &runs)
124 .map(|layout| layout.width)
125 .unwrap_or(px(0.));
126
127 width = width.max(cell_width)
128 }
129
130 widths.push(width)
131 }
132
133 let cached_clipboard_content = Self::create_clipboard_content(table);
134
135 Self {
136 table: table.clone(),
137 widths,
138 cached_clipboard_content: ClipboardItem::new_string(cached_clipboard_content),
139 }
140 }
141
142 fn escape_markdown(s: &str) -> String {
143 s.replace('|', "\\|")
144 .replace('*', "\\*")
145 .replace('_', "\\_")
146 .replace('`', "\\`")
147 .replace('[', "\\[")
148 .replace(']', "\\]")
149 .replace('<', "<")
150 .replace('>', ">")
151 }
152
153 fn create_clipboard_content(table: &TabularDataResource) -> String {
154 let data = match table.data.as_ref() {
155 Some(data) => data,
156 None => &Vec::new(),
157 };
158 let schema = table.schema.clone();
159
160 let mut markdown = format!(
161 "| {} |\n",
162 table
163 .schema
164 .fields
165 .iter()
166 .map(|field| field.name.clone())
167 .collect::<Vec<_>>()
168 .join(" | ")
169 );
170
171 markdown.push_str("|---");
172 for _ in 1..table.schema.fields.len() {
173 markdown.push_str("|---");
174 }
175 markdown.push_str("|\n");
176
177 let body = data
178 .iter()
179 .map(|record: &Value| {
180 let row_content = schema
181 .fields
182 .iter()
183 .map(|field| Self::escape_markdown(&cell_content(record, &field.name)))
184 .collect::<Vec<_>>();
185
186 row_content.join(" | ")
187 })
188 .collect::<Vec<String>>();
189
190 for row in body {
191 markdown.push_str(&format!("| {} |\n", row));
192 }
193
194 markdown
195 }
196
197 pub fn render_row(
198 &self,
199 schema: &TableSchema,
200 is_header: bool,
201 row: &Value,
202 cx: &WindowContext,
203 ) -> AnyElement {
204 let theme = cx.theme();
205
206 let line_height = cx.line_height();
207
208 let row_cells = schema
209 .fields
210 .iter()
211 .zip(self.widths.iter())
212 .map(|(field, width)| {
213 let container = match field.field_type {
214 runtimelib::datatable::FieldType::String => div(),
215
216 runtimelib::datatable::FieldType::Number
217 | runtimelib::datatable::FieldType::Integer
218 | runtimelib::datatable::FieldType::Date
219 | runtimelib::datatable::FieldType::Time
220 | runtimelib::datatable::FieldType::Datetime
221 | runtimelib::datatable::FieldType::Year
222 | runtimelib::datatable::FieldType::Duration
223 | runtimelib::datatable::FieldType::Yearmonth => v_flex().items_end(),
224
225 _ => div(),
226 };
227
228 let value = cell_content(row, &field.name);
229
230 let mut cell = container
231 .min_w(*width + px(22.))
232 .w(*width + px(22.))
233 .child(value)
234 .px_2()
235 .py((TABLE_Y_PADDING_MULTIPLE / 2.0) * line_height)
236 .border_color(theme.colors().border);
237
238 if is_header {
239 cell = cell.border_1().bg(theme.colors().border_focused)
240 } else {
241 cell = cell.border_1()
242 }
243 cell
244 })
245 .collect::<Vec<_>>();
246
247 let mut total_width = px(0.);
248 for width in self.widths.iter() {
249 // Width fudge factor: border + 2 (heading), padding
250 total_width += *width + px(22.);
251 }
252
253 h_flex()
254 .w(total_width)
255 .children(row_cells)
256 .into_any_element()
257 }
258}
259
260impl Render for TableView {
261 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
262 let data = match &self.table.data {
263 Some(data) => data,
264 None => return div().into_any_element(),
265 };
266
267 let mut headings = serde_json::Map::new();
268 for field in &self.table.schema.fields {
269 headings.insert(field.name.clone(), Value::String(field.name.clone()));
270 }
271 let header = self.render_row(&self.table.schema, true, &Value::Object(headings), cx);
272
273 let body = data
274 .iter()
275 .map(|row| self.render_row(&self.table.schema, false, row, cx));
276
277 v_flex()
278 .id("table")
279 .overflow_x_scroll()
280 .w_full()
281 .child(header)
282 .children(body)
283 .into_any_element()
284 }
285}
286
287impl OutputContent for TableView {
288 fn clipboard_content(&self, _cx: &WindowContext) -> Option<ClipboardItem> {
289 Some(self.cached_clipboard_content.clone())
290 }
291
292 fn has_clipboard_content(&self, _cx: &WindowContext) -> bool {
293 true
294 }
295}