Skip to main content

ftui_widgets/
json_view.rs

1//! JSON view widget for pretty-printing JSON text.
2//!
3//! Renders formatted JSON with indentation and optional syntax highlighting.
4//! Does not depend on serde; operates on raw JSON strings with a minimal
5//! tokenizer.
6//!
7//! # Example
8//!
9//! ```
10//! use ftui_widgets::json_view::JsonView;
11//!
12//! let json = r#"{"name": "Alice", "age": 30}"#;
13//! let view = JsonView::new(json);
14//! let lines = view.formatted_lines();
15//! assert!(lines.len() > 1); // Pretty-printed across multiple lines
16//! ```
17
18use crate::{Widget, draw_text_span};
19use ftui_core::geometry::Rect;
20use ftui_render::frame::Frame;
21use ftui_style::Style;
22
23/// A classified JSON token for rendering.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum JsonToken {
26    /// Object key (string before colon).
27    Key(String),
28    /// String value.
29    StringVal(String),
30    /// Number value.
31    Number(String),
32    /// Boolean or null literal.
33    Literal(String),
34    /// Structural character: `{`, `}`, `[`, `]`, `:`, `,`.
35    Punctuation(String),
36    /// Whitespace / indentation.
37    Whitespace(String),
38    /// Newline.
39    Newline,
40    /// Error text (invalid JSON portion).
41    Error(String),
42}
43
44/// Widget that renders pretty-printed JSON with syntax coloring.
45#[derive(Debug, Clone)]
46pub struct JsonView {
47    source: String,
48    indent: usize,
49    key_style: Style,
50    string_style: Style,
51    number_style: Style,
52    literal_style: Style,
53    punct_style: Style,
54    error_style: Style,
55}
56
57impl Default for JsonView {
58    fn default() -> Self {
59        Self::new("")
60    }
61}
62
63impl JsonView {
64    /// Create a new JSON view from a raw JSON string.
65    #[must_use]
66    pub fn new(source: impl Into<String>) -> Self {
67        Self {
68            source: source.into(),
69            indent: 2,
70            key_style: Style::new().bold(),
71            string_style: Style::default(),
72            number_style: Style::default(),
73            literal_style: Style::default(),
74            punct_style: Style::default(),
75            error_style: Style::default(),
76        }
77    }
78
79    /// Set the indentation width.
80    #[must_use]
81    pub fn with_indent(mut self, indent: usize) -> Self {
82        self.indent = indent;
83        self
84    }
85
86    /// Set style for object keys.
87    #[must_use]
88    pub fn with_key_style(mut self, style: Style) -> Self {
89        self.key_style = style;
90        self
91    }
92
93    /// Set style for string values.
94    #[must_use]
95    pub fn with_string_style(mut self, style: Style) -> Self {
96        self.string_style = style;
97        self
98    }
99
100    /// Set style for numbers.
101    #[must_use]
102    pub fn with_number_style(mut self, style: Style) -> Self {
103        self.number_style = style;
104        self
105    }
106
107    /// Set style for boolean/null literals.
108    #[must_use]
109    pub fn with_literal_style(mut self, style: Style) -> Self {
110        self.literal_style = style;
111        self
112    }
113
114    /// Set style for punctuation.
115    #[must_use]
116    pub fn with_punct_style(mut self, style: Style) -> Self {
117        self.punct_style = style;
118        self
119    }
120
121    /// Set style for error text.
122    #[must_use]
123    pub fn with_error_style(mut self, style: Style) -> Self {
124        self.error_style = style;
125        self
126    }
127
128    /// Set the source JSON.
129    pub fn set_source(&mut self, source: impl Into<String>) {
130        self.source = source.into();
131    }
132
133    /// Get the source JSON.
134    #[must_use]
135    pub fn source(&self) -> &str {
136        &self.source
137    }
138
139    /// Pretty-format the JSON into lines of tokens for rendering.
140    #[must_use]
141    pub fn formatted_lines(&self) -> Vec<Vec<JsonToken>> {
142        let trimmed = self.source.trim();
143        if trimmed.is_empty() {
144            return vec![];
145        }
146
147        let mut lines: Vec<Vec<JsonToken>> = Vec::new();
148        let mut current_line: Vec<JsonToken> = Vec::new();
149        let mut depth: usize = 0;
150        let mut chars = trimmed.chars().peekable();
151
152        while let Some(&ch) = chars.peek() {
153            match ch {
154                '{' | '[' => {
155                    chars.next();
156                    current_line.push(JsonToken::Punctuation(ch.to_string()));
157                    // Check if next non-whitespace is closing bracket
158                    skip_ws(&mut chars);
159                    let next = chars.peek().copied();
160                    if next == Some('}') || next == Some(']') {
161                        // Empty object/array
162                        let closing = chars.next().unwrap();
163                        current_line.push(JsonToken::Punctuation(closing.to_string()));
164                        // Check for comma
165                        skip_ws(&mut chars);
166                        if chars.peek() == Some(&',') {
167                            chars.next();
168                            current_line.push(JsonToken::Punctuation(",".to_string()));
169                        }
170                    } else {
171                        depth += 1;
172                        lines.push(current_line);
173                        current_line = vec![JsonToken::Whitespace(make_indent(
174                            depth.min(32),
175                            self.indent,
176                        ))];
177                    }
178                }
179                '}' | ']' => {
180                    chars.next();
181                    depth = depth.saturating_sub(1);
182                    lines.push(current_line);
183                    current_line = vec![
184                        JsonToken::Whitespace(make_indent(depth, self.indent)),
185                        JsonToken::Punctuation(ch.to_string()),
186                    ];
187                    // Check for comma
188                    skip_ws(&mut chars);
189                    if chars.peek() == Some(&',') {
190                        chars.next();
191                        current_line.push(JsonToken::Punctuation(",".to_string()));
192                    }
193                }
194                '"' => {
195                    let s = read_string(&mut chars);
196                    skip_ws(&mut chars);
197                    if chars.peek() == Some(&':') {
198                        // This is a key
199                        current_line.push(JsonToken::Key(s));
200                        chars.next();
201                        current_line.push(JsonToken::Punctuation(": ".to_string()));
202                        skip_ws(&mut chars);
203                    } else {
204                        current_line.push(JsonToken::StringVal(s));
205                        // Check for comma
206                        skip_ws(&mut chars);
207                        if chars.peek() == Some(&',') {
208                            chars.next();
209                            current_line.push(JsonToken::Punctuation(",".to_string()));
210                            lines.push(current_line);
211                            current_line = vec![JsonToken::Whitespace(make_indent(
212                                depth.min(32),
213                                self.indent,
214                            ))];
215                        }
216                    }
217                }
218                ',' => {
219                    chars.next();
220                    current_line.push(JsonToken::Punctuation(",".to_string()));
221                    lines.push(current_line);
222                    current_line = vec![JsonToken::Whitespace(make_indent(
223                        depth.min(32),
224                        self.indent,
225                    ))];
226                }
227                ':' => {
228                    chars.next();
229                    current_line.push(JsonToken::Punctuation(": ".to_string()));
230                    skip_ws(&mut chars);
231                }
232                ' ' | '\t' | '\r' | '\n' => {
233                    chars.next();
234                }
235                _ => {
236                    // Number, boolean, null, or error
237                    let literal = read_literal(&mut chars);
238                    let tok = classify_literal(&literal);
239                    current_line.push(tok);
240                    // Check for comma
241                    skip_ws(&mut chars);
242                    if chars.peek() == Some(&',') {
243                        chars.next();
244                        current_line.push(JsonToken::Punctuation(",".to_string()));
245                        lines.push(current_line);
246                        current_line = vec![JsonToken::Whitespace(make_indent(
247                            depth.min(32),
248                            self.indent,
249                        ))];
250                    }
251                }
252            }
253        }
254
255        if !current_line.is_empty() {
256            lines.push(current_line);
257        }
258
259        lines
260    }
261}
262
263fn make_indent(depth: usize, width: usize) -> String {
264    " ".repeat(depth * width)
265}
266
267fn skip_ws(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
268    while let Some(&ch) = chars.peek() {
269        if ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' {
270            chars.next();
271        } else {
272            break;
273        }
274    }
275}
276
277fn read_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
278    let mut s = String::new();
279    s.push('"');
280    chars.next(); // consume opening quote
281    let mut escaped = false;
282    for ch in chars.by_ref() {
283        s.push(ch);
284        if escaped {
285            escaped = false;
286        } else if ch == '\\' {
287            escaped = true;
288        } else if ch == '"' {
289            break;
290        }
291    }
292    s
293}
294
295fn read_literal(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
296    let mut s = String::new();
297    while let Some(&ch) = chars.peek() {
298        if ch == ','
299            || ch == '}'
300            || ch == ']'
301            || ch == ':'
302            || ch == ' '
303            || ch == '\n'
304            || ch == '\r'
305            || ch == '\t'
306        {
307            break;
308        }
309        s.push(ch);
310        chars.next();
311    }
312    s
313}
314
315fn classify_literal(s: &str) -> JsonToken {
316    match s {
317        "true" | "false" | "null" => JsonToken::Literal(s.to_string()),
318        _ => {
319            // Try as number
320            if s.bytes().all(|b| {
321                b.is_ascii_digit() || b == b'.' || b == b'-' || b == b'+' || b == b'e' || b == b'E'
322            }) && !s.is_empty()
323            {
324                JsonToken::Number(s.to_string())
325            } else {
326                JsonToken::Error(s.to_string())
327            }
328        }
329    }
330}
331
332impl Widget for JsonView {
333    fn render(&self, area: Rect, frame: &mut Frame) {
334        if area.width == 0 || area.height == 0 {
335            return;
336        }
337
338        let deg = frame.buffer.degradation;
339        let lines = self.formatted_lines();
340        let max_x = area.right();
341
342        for (row_idx, tokens) in lines.iter().enumerate() {
343            if row_idx >= area.height as usize {
344                break;
345            }
346
347            let y = area.y.saturating_add(row_idx as u16);
348            let mut x = area.x;
349
350            for token in tokens {
351                let (text, style) = match token {
352                    JsonToken::Key(s) => (s.as_str(), self.key_style),
353                    JsonToken::StringVal(s) => (s.as_str(), self.string_style),
354                    JsonToken::Number(s) => (s.as_str(), self.number_style),
355                    JsonToken::Literal(s) => (s.as_str(), self.literal_style),
356                    JsonToken::Punctuation(s) => (s.as_str(), self.punct_style),
357                    JsonToken::Whitespace(s) => (s.as_str(), Style::default()),
358                    JsonToken::Error(s) => (s.as_str(), self.error_style),
359                    JsonToken::Newline => continue,
360                };
361
362                if deg.apply_styling() {
363                    x = draw_text_span(frame, x, y, text, style, max_x);
364                } else {
365                    x = draw_text_span(frame, x, y, text, Style::default(), max_x);
366                }
367            }
368        }
369    }
370
371    fn is_essential(&self) -> bool {
372        false
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use ftui_render::frame::Frame;
380    use ftui_render::grapheme_pool::GraphemePool;
381
382    #[test]
383    fn empty_source() {
384        let view = JsonView::new("");
385        assert!(view.formatted_lines().is_empty());
386    }
387
388    #[test]
389    fn simple_object() {
390        let view = JsonView::new(r#"{"a": 1}"#);
391        let lines = view.formatted_lines();
392        assert!(lines.len() >= 3); // { + content + }
393    }
394
395    #[test]
396    fn nested_object() {
397        let view = JsonView::new(r#"{"a": {"b": 2}}"#);
398        let lines = view.formatted_lines();
399        assert!(lines.len() >= 3);
400    }
401
402    #[test]
403    fn array() {
404        let view = JsonView::new(r#"[1, 2, 3]"#);
405        let lines = view.formatted_lines();
406        assert!(lines.len() >= 3);
407    }
408
409    #[test]
410    fn empty_object() {
411        let view = JsonView::new(r#"{}"#);
412        let lines = view.formatted_lines();
413        assert!(!lines.is_empty());
414        // Should be compact: single line with {}
415    }
416
417    #[test]
418    fn empty_array() {
419        let view = JsonView::new(r#"[]"#);
420        let lines = view.formatted_lines();
421        assert!(!lines.is_empty());
422    }
423
424    #[test]
425    fn string_values() {
426        let view = JsonView::new(r#"{"msg": "hello world"}"#);
427        let lines = view.formatted_lines();
428        // Should contain StringVal token with quoted string
429        let has_string = lines.iter().any(|line| {
430            line.iter()
431                .any(|t| matches!(t, JsonToken::StringVal(s) if s.contains("hello")))
432        });
433        assert!(has_string);
434    }
435
436    #[test]
437    fn boolean_and_null() {
438        let view = JsonView::new(r#"{"a": true, "b": false, "c": null}"#);
439        let lines = view.formatted_lines();
440        let has_literal = lines.iter().any(|line| {
441            line.iter()
442                .any(|t| matches!(t, JsonToken::Literal(s) if s == "true"))
443        });
444        assert!(has_literal);
445    }
446
447    #[test]
448    fn numbers() {
449        let view = JsonView::new(r#"{"x": 42, "y": -3.14}"#);
450        let lines = view.formatted_lines();
451        let has_number = lines.iter().any(|line| {
452            line.iter()
453                .any(|t| matches!(t, JsonToken::Number(s) if s == "42"))
454        });
455        assert!(has_number);
456    }
457
458    #[test]
459    fn escaped_string() {
460        let view = JsonView::new(r#"{"msg": "hello \"world\""}"#);
461        let lines = view.formatted_lines();
462        let has_escaped = lines.iter().any(|line| {
463            line.iter()
464                .any(|t| matches!(t, JsonToken::StringVal(s) if s.contains("\\\"")))
465        });
466        assert!(has_escaped);
467    }
468
469    #[test]
470    fn indent_width() {
471        let view = JsonView::new(r#"{"a": 1}"#).with_indent(4);
472        let lines = view.formatted_lines();
473        let has_4_indent = lines.iter().any(|line| {
474            line.iter()
475                .any(|t| matches!(t, JsonToken::Whitespace(s) if s == "    "))
476        });
477        assert!(has_4_indent);
478    }
479
480    #[test]
481    fn render_basic() {
482        let view = JsonView::new(r#"{"key": "value"}"#);
483        let mut pool = GraphemePool::new();
484        let mut frame = Frame::new(40, 10, &mut pool);
485        let area = Rect::new(0, 0, 40, 10);
486        view.render(area, &mut frame);
487
488        // First char should be '{'
489        let cell = frame.buffer.get(0, 0).unwrap();
490        assert_eq!(cell.content.as_char(), Some('{'));
491    }
492
493    #[test]
494    fn render_zero_area() {
495        let view = JsonView::new(r#"{"a": 1}"#);
496        let mut pool = GraphemePool::new();
497        let mut frame = Frame::new(40, 10, &mut pool);
498        view.render(Rect::new(0, 0, 0, 0), &mut frame); // No panic
499    }
500
501    #[test]
502    fn render_truncated_height() {
503        let view = JsonView::new(r#"{"a": 1, "b": 2, "c": 3}"#);
504        let mut pool = GraphemePool::new();
505        let mut frame = Frame::new(40, 2, &mut pool);
506        let area = Rect::new(0, 0, 40, 2);
507        view.render(area, &mut frame); // Only first 2 lines, no panic
508    }
509
510    #[test]
511    fn is_not_essential() {
512        let view = JsonView::new("");
513        assert!(!view.is_essential());
514    }
515
516    #[test]
517    fn default_impl() {
518        let view = JsonView::default();
519        assert!(view.source().is_empty());
520    }
521
522    #[test]
523    fn set_source() {
524        let mut view = JsonView::new("");
525        view.set_source(r#"{"a": 1}"#);
526        assert!(!view.formatted_lines().is_empty());
527    }
528
529    #[test]
530    fn plain_literal() {
531        let view = JsonView::new("42");
532        let lines = view.formatted_lines();
533        assert_eq!(lines.len(), 1);
534    }
535
536    #[test]
537    fn classify_literal_types() {
538        assert_eq!(
539            classify_literal("true"),
540            JsonToken::Literal("true".to_string())
541        );
542        assert_eq!(
543            classify_literal("false"),
544            JsonToken::Literal("false".to_string())
545        );
546        assert_eq!(
547            classify_literal("null"),
548            JsonToken::Literal("null".to_string())
549        );
550        assert_eq!(classify_literal("42"), JsonToken::Number("42".to_string()));
551        assert_eq!(
552            classify_literal("-3.14"),
553            JsonToken::Number("-3.14".to_string())
554        );
555        assert!(matches!(classify_literal("invalid!"), JsonToken::Error(_)));
556    }
557}