Skip to main content

promkit_widgets/jsonstream/
config.rs

1use promkit_core::{
2    crossterm::style::{Attribute, ContentStyle},
3    grapheme::StyledGraphemes,
4};
5
6use super::jsonz::{ContainerType, Row, Value};
7
8/// Defines the behavior for handling lines that
9/// exceed the available width in the terminal when rendering JSON data.
10#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum OverflowMode {
13    #[default]
14    /// Truncates lines that exceed the available width
15    /// and appends an ellipsis character (…).
16    Truncate,
17    /// Wraps lines that exceed the available width
18    /// onto the next line without truncation.
19    Wrap,
20}
21
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23#[cfg_attr(feature = "serde", serde(default))]
24#[derive(Clone)]
25pub struct Config {
26    /// Style for {}.
27    #[cfg_attr(
28        feature = "serde",
29        serde(with = "termcfg::crossterm_config::content_style_serde")
30    )]
31    pub curly_brackets_style: ContentStyle,
32    /// Style for [].
33    #[cfg_attr(
34        feature = "serde",
35        serde(with = "termcfg::crossterm_config::content_style_serde")
36    )]
37    pub square_brackets_style: ContentStyle,
38    /// Style for "key".
39    #[cfg_attr(
40        feature = "serde",
41        serde(with = "termcfg::crossterm_config::content_style_serde")
42    )]
43    pub key_style: ContentStyle,
44    /// Style for string values.
45    #[cfg_attr(
46        feature = "serde",
47        serde(with = "termcfg::crossterm_config::content_style_serde")
48    )]
49    pub string_value_style: ContentStyle,
50    /// Style for number values.
51    #[cfg_attr(
52        feature = "serde",
53        serde(with = "termcfg::crossterm_config::content_style_serde")
54    )]
55    pub number_value_style: ContentStyle,
56    /// Style for boolean values.
57    #[cfg_attr(
58        feature = "serde",
59        serde(with = "termcfg::crossterm_config::content_style_serde")
60    )]
61    pub boolean_value_style: ContentStyle,
62    /// Style for null values.
63    #[cfg_attr(
64        feature = "serde",
65        serde(with = "termcfg::crossterm_config::content_style_serde")
66    )]
67    pub null_value_style: ContentStyle,
68
69    /// Attribute for the selected line.
70    #[cfg_attr(
71        feature = "serde",
72        serde(with = "termcfg::crossterm_config::attribute_serde")
73    )]
74    pub active_item_attribute: Attribute,
75    /// Attribute for unselected lines.
76    #[cfg_attr(
77        feature = "serde",
78        serde(with = "termcfg::crossterm_config::attribute_serde")
79    )]
80    pub inactive_item_attribute: Attribute,
81
82    /// The number of spaces used for indentation in the rendered JSON structure.
83    /// This value multiplies with the indentation level of a JSON element to determine
84    /// the total indentation space. For example, an `indent` value of 4 means each
85    /// indentation level will be 4 spaces wide.
86    pub indent: usize,
87
88    /// Rendering behavior when a line exceeds the terminal width.
89    pub overflow_mode: OverflowMode,
90    /// Number of lines available for rendering.
91    pub lines: Option<usize>,
92}
93
94impl Default for Config {
95    fn default() -> Self {
96        Self {
97            curly_brackets_style: Default::default(),
98            square_brackets_style: Default::default(),
99            key_style: Default::default(),
100            string_value_style: Default::default(),
101            number_value_style: Default::default(),
102            boolean_value_style: Default::default(),
103            null_value_style: Default::default(),
104            active_item_attribute: Attribute::NoBold,
105            inactive_item_attribute: Attribute::NoBold,
106            indent: Default::default(),
107            overflow_mode: OverflowMode::default(),
108            lines: Default::default(),
109        }
110    }
111}
112
113impl Config {
114    fn truncate_line_with_ellipsis(line: StyledGraphemes, width: usize) -> StyledGraphemes {
115        if line.widths() <= width {
116            return line;
117        }
118
119        if width == 0 {
120            return StyledGraphemes::default();
121        }
122
123        let ellipsis: StyledGraphemes = StyledGraphemes::from("…");
124        let ellipsis_width = ellipsis.widths();
125        if width <= ellipsis_width {
126            return ellipsis;
127        }
128
129        let mut truncated = StyledGraphemes::default();
130        let mut current_width = 0;
131        for g in line.iter() {
132            if current_width + g.width() + ellipsis_width > width {
133                break;
134            }
135            truncated.push_back(g.clone());
136            current_width += g.width();
137        }
138
139        vec![truncated, ellipsis].into_iter().collect()
140    }
141
142    fn wrap_line(line: StyledGraphemes, width: usize) -> Vec<StyledGraphemes> {
143        let mut wrapped = vec![StyledGraphemes::default()];
144        let mut current_width = 0;
145
146        for g in line.iter() {
147            if g.width() > width {
148                continue;
149            }
150            if current_width + g.width() > width {
151                wrapped.push(StyledGraphemes::default());
152                current_width = 0;
153            }
154            wrapped
155                .last_mut()
156                .expect("wrapped always contains at least one row")
157                .push_back(g.clone());
158            current_width += g.width();
159        }
160
161        wrapped
162    }
163
164    /// Formats a Vec<Row> into Vec<StyledGraphemes> with appropriate styling and width limits
165    pub fn format_for_terminal_display(&self, rows: &[Row], width: u16) -> Vec<StyledGraphemes> {
166        let mut formatted = Vec::new();
167        let width = width as usize;
168
169        for (i, row) in rows.iter().enumerate() {
170            let indent = StyledGraphemes::from(" ".repeat(self.indent * row.depth));
171            let mut parts = Vec::new();
172
173            if let Some(key) = &row.k {
174                parts.push(
175                    StyledGraphemes::from(format!("\"{}\"", key)).apply_style(self.key_style),
176                );
177                parts.push(StyledGraphemes::from(": "));
178            }
179
180            match &row.v {
181                Value::Null => {
182                    parts.push(StyledGraphemes::from("null").apply_style(self.null_value_style));
183                }
184                Value::Boolean(b) => {
185                    parts.push(
186                        StyledGraphemes::from(b.to_string()).apply_style(self.boolean_value_style),
187                    );
188                }
189                Value::Number(n) => {
190                    parts.push(
191                        StyledGraphemes::from(n.to_string()).apply_style(self.number_value_style),
192                    );
193                }
194                Value::String(s) => {
195                    let escaped = s.replace('\n', "\\n");
196                    parts.push(
197                        StyledGraphemes::from(format!("\"{}\"", escaped))
198                            .apply_style(self.string_value_style),
199                    );
200                }
201                Value::Empty { typ } => {
202                    let bracket_style = match typ {
203                        ContainerType::Object => self.curly_brackets_style,
204                        ContainerType::Array => self.square_brackets_style,
205                    };
206                    parts.push(StyledGraphemes::from(typ.empty_str()).apply_style(bracket_style));
207                }
208                Value::Open { typ, collapsed, .. } => {
209                    let bracket_style = match typ {
210                        ContainerType::Object => self.curly_brackets_style,
211                        ContainerType::Array => self.square_brackets_style,
212                    };
213                    if *collapsed {
214                        parts.push(
215                            StyledGraphemes::from(typ.collapsed_preview())
216                                .apply_style(bracket_style),
217                        );
218                    } else {
219                        parts
220                            .push(StyledGraphemes::from(typ.open_str()).apply_style(bracket_style));
221                    }
222                }
223                Value::Close { typ, .. } => {
224                    let bracket_style = match typ {
225                        ContainerType::Object => self.curly_brackets_style,
226                        ContainerType::Array => self.square_brackets_style,
227                    };
228                    // We don't need to check collapsed here because:
229                    // 1. If the corresponding Open is collapsed, this Close will be skipped during `extract_rows`
230                    // 2. If the Open is not collapsed, we want to show the closing bracket
231                    parts.push(StyledGraphemes::from(typ.close_str()).apply_style(bracket_style));
232                }
233            }
234
235            if i + 1 < rows.len() {
236                if let Value::Close { .. } = rows[i + 1].v {
237                } else if let Value::Open {
238                    collapsed: false, ..
239                } = rows[i].v
240                {
241                } else {
242                    parts.push(StyledGraphemes::from(","));
243                }
244            }
245
246            let mut content: StyledGraphemes = parts.into_iter().collect();
247
248            // Note that `extract_rows_from_current`
249            // returns rows starting from the current position,
250            // so the first row should always be highlighted as active
251            content = content.apply_attribute(if i == 0 {
252                self.active_item_attribute
253            } else {
254                self.inactive_item_attribute
255            });
256
257            let mut line: StyledGraphemes = vec![indent, content].into_iter().collect();
258
259            match self.overflow_mode {
260                OverflowMode::Truncate => {
261                    line = Self::truncate_line_with_ellipsis(line, width);
262                    formatted.push(line);
263                }
264                OverflowMode::Wrap => {
265                    formatted.extend(Self::wrap_line(line, width));
266                }
267            }
268        }
269
270        formatted
271    }
272
273    /// Formats a slice of Rows to a raw JSON string, ignoring collapsed and truncated states
274    pub fn format_raw_json(&self, rows: &[Row]) -> String {
275        let mut result = String::new();
276        let mut first_in_container = true;
277
278        for (i, row) in rows.iter().enumerate() {
279            // Add indentation
280            if !matches!(row.v, Value::Close { .. }) {
281                if !result.is_empty() {
282                    result.push('\n');
283                }
284                result.push_str(&" ".repeat(self.indent * row.depth));
285            }
286
287            // Add key if present
288            if let Some(key) = &row.k {
289                result.push('"');
290                result.push_str(key);
291                result.push_str("\": ");
292            }
293
294            // Add value
295            match &row.v {
296                Value::Null => result.push_str("null"),
297                Value::Boolean(b) => result.push_str(&b.to_string()),
298                Value::Number(n) => result.push_str(&n.to_string()),
299                Value::String(s) => {
300                    result.push('"');
301                    result.push_str(&s.replace('\n', "\\n"));
302                    result.push('"');
303                }
304                Value::Empty { typ } => {
305                    result.push_str(match typ {
306                        ContainerType::Object => "{}",
307                        ContainerType::Array => "[]",
308                    });
309                }
310                Value::Open { typ, .. } => {
311                    result.push(match typ {
312                        ContainerType::Object => '{',
313                        ContainerType::Array => '[',
314                    });
315                }
316                Value::Close { typ, .. } => {
317                    if !first_in_container {
318                        result.push('\n');
319                        result.push_str(&" ".repeat(self.indent * row.depth));
320                    }
321                    result.push(match typ {
322                        ContainerType::Object => '}',
323                        ContainerType::Array => ']',
324                    });
325                }
326            }
327
328            // Add comma if needed
329            if i + 1 < rows.len() {
330                if let Value::Close { .. } = rows[i + 1].v {
331                    // Don't add comma before closing bracket
332                } else if let Value::Open { .. } = rows[i].v {
333                    // Don't add comma after opening bracket
334                } else {
335                    result.push(',');
336                }
337            }
338
339            if let Value::Open { .. } = row.v {
340                first_in_container = true;
341            } else {
342                first_in_container = false;
343            }
344        }
345
346        result
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use serde_json::json;
354
355    mod format_raw_json {
356        use std::str::FromStr;
357
358        use super::*;
359
360        use crate::jsonstream::jsonz::create_rows;
361
362        #[test]
363        fn test() {
364            let expected = r#"
365{
366    "array": [
367        {
368            "key": "value"
369        },
370        [
371            1,
372            2,
373            3
374        ],
375        {
376            "nested": true
377        }
378    ],
379    "object": {
380        "array": [
381            1,
382            2,
383            3
384        ],
385        "nested": {
386            "value": "test"
387        }
388    }
389}"#
390            .trim();
391
392            assert_eq!(
393                Config {
394                    indent: 4,
395                    ..Default::default()
396                }
397                .format_raw_json(&create_rows([
398                    &serde_json::Value::from_str(&expected).unwrap()
399                ])),
400                expected,
401            );
402        }
403    }
404
405    mod format_for_terminal_display {
406        use super::*;
407
408        use crate::jsonstream::jsonz::create_rows;
409
410        #[test]
411        fn test_ellipsis_mode_truncates_with_ellipsis() {
412            let value = json!({
413                "very_long_key": "abcdefghijklmnopqrstuvwxyz",
414            });
415            let rows = create_rows([&value]);
416            let width = 12;
417
418            let lines = Config {
419                indent: 2,
420                overflow_mode: OverflowMode::Truncate,
421                ..Default::default()
422            }
423            .format_for_terminal_display(&rows, width);
424
425            assert_eq!(lines.len(), rows.len());
426            assert!(lines.iter().all(|line| line.widths() <= width as usize));
427            assert!(
428                lines
429                    .iter()
430                    .any(|line| line.chars().last().is_some_and(|ch| *ch == '…'))
431            );
432        }
433
434        #[test]
435        fn test_linewrap_mode_wraps_without_ellipsis() {
436            let value = json!({
437                "very_long_key": "abcdefghijklmnopqrstuvwxyz",
438            });
439            let rows = create_rows([&value]);
440            let width = 12;
441
442            let lines = Config {
443                indent: 2,
444                overflow_mode: OverflowMode::Wrap,
445                ..Default::default()
446            }
447            .format_for_terminal_display(&rows, width);
448
449            assert!(lines.len() > rows.len());
450            assert!(lines.iter().all(|line| line.widths() <= width as usize));
451            assert!(
452                lines
453                    .iter()
454                    .all(|line| !matches!(line.chars().last(), Some('…')))
455            );
456        }
457    }
458
459    #[cfg(feature = "serde")]
460    mod serde_compatibility {
461        use super::*;
462        use promkit_core::crossterm::style::{Attributes, Color};
463
464        #[test]
465        fn missing_new_fields_are_filled_by_default() {
466            let mut value = serde_json::to_value(Config {
467                indent: 4,
468                ..Default::default()
469            })
470            .unwrap();
471            let obj = value.as_object_mut().unwrap();
472            obj.remove("active_item_attribute");
473            obj.remove("inactive_item_attribute");
474            obj.remove("overflow_mode");
475            obj.remove("lines");
476
477            let formatter: Config = serde_json::from_value(value).unwrap();
478
479            assert_eq!(formatter.indent, 4);
480            assert_eq!(formatter.active_item_attribute, Attribute::NoBold);
481            assert_eq!(formatter.inactive_item_attribute, Attribute::NoBold);
482            assert_eq!(formatter.overflow_mode, OverflowMode::Truncate);
483            assert_eq!(formatter.lines, None);
484        }
485
486        #[test]
487        fn config_fields_are_fully_loaded_from_toml() {
488            let input = r#"
489indent = 4
490lines = 7
491curly_brackets_style = "attr=bold"
492square_brackets_style = "attr=bold"
493key_style = "fg=cyan"
494string_value_style = "fg=green"
495number_value_style = "fg=yellow"
496boolean_value_style = "fg=magenta"
497null_value_style = "fg=grey"
498active_item_attribute = "underlined"
499inactive_item_attribute = "dim"
500overflow_mode = "Wrap"
501"#;
502
503            let formatter: Config = toml::from_str(input).unwrap();
504
505            assert_eq!(formatter.indent, 4);
506            assert_eq!(formatter.lines, Some(7));
507            assert_eq!(
508                formatter.curly_brackets_style.attributes,
509                Attributes::from(Attribute::Bold),
510            );
511            assert_eq!(
512                formatter.square_brackets_style.attributes,
513                Attributes::from(Attribute::Bold),
514            );
515            assert_eq!(formatter.key_style.foreground_color, Some(Color::Cyan));
516            assert_eq!(
517                formatter.string_value_style.foreground_color,
518                Some(Color::Green),
519            );
520            assert_eq!(
521                formatter.number_value_style.foreground_color,
522                Some(Color::Yellow)
523            );
524            assert_eq!(
525                formatter.boolean_value_style.foreground_color,
526                Some(Color::Magenta),
527            );
528            assert_eq!(
529                formatter.null_value_style.foreground_color,
530                Some(Color::Grey)
531            );
532            assert_eq!(formatter.active_item_attribute, Attribute::Underlined);
533            assert_eq!(formatter.inactive_item_attribute, Attribute::Dim);
534            assert_eq!(formatter.overflow_mode, OverflowMode::Wrap);
535        }
536    }
537}