feat: add JSON file viewer with semantic rendering#40
Merged
Conversation
mdterm has been a markdown-only viewer since day one, which means opening a JSON file was about as useful as opening it in cat. Let's fix that. The new json.rs module parses JSON via serde_json and renders it into the same Vec<Line> data structure that markdown uses, which means *every* existing viewer feature — search, TOC, link picker, keyboard nav, auto-reload, multi-file switching, HTML export — just works. No viewer changes beyond a dispatch in rebuild(). The rendering is deliberately *not* raw syntax highlighting. Instead it produces a structured tree view with Unicode box-drawing guides, color-coded values by type (strings green, numbers orange, booleans yellow, null dim gray), top-level keys as headings for TOC navigation, URL detection in strings for the link picker, and — the nice bit — arrays of homogeneous objects automatically render as tables. Seven new theme colors (json_key, json_string, json_number, etc.) in both dark and light themes keep it consistent with the existing Catppuccin-inspired palette.
The old JSON renderer was a static tree view with connectors that ate horizontal space and added visual noise without actually helping you understand the data. Not great. Rip it out and replace with two rendering paths: The *non-interactive* renderer (piped output, HTML export) now uses clean 2-space indentation, no tree connectors. Primitive fields are grouped before nested sections, key-value pairs are column-aligned, primitive arrays get bullet points, empty containers show "empty" labels. Much easier to scan. The *interactive* renderer (TUI viewer) is the real change. JSON objects and arrays render as bordered cards with expand/collapse indicators. j/k navigates between expandable nodes, Enter/Space toggles, h/l collapse/expand, cursor line is highlighted. Nested cards render inside parent cards with post-processing that wraps content lines with side borders. Card width reduces by 7 chars per nesting level. Invalid JSON now shows a styled error display with source context, line numbers, and a caret pointing at the exact parse error position, instead of silently falling back to markdown rendering. While at it, killed the LineBuilder struct that had hardcoded RGB colors ignoring the theme. All value rendering now goes through theme-aware value_span().
…SON viewer The JSON interactive explorer had no sense of *where you are* in the tree, no keybinding hints, and no way to bulk-expand or bulk-collapse nodes. Navigating a deeply nested JSON file meant hammering Enter one node at a time like some kind of animal. Add a proper JSON-specific status bar that shows: a breadcrumb path (e.g., "config > theme > colors"), keybinding hints for JSON navigation, and a node position counter (3/15). This replaces the generic markdown hints when viewing JSON files. Add L (expand all) and H (collapse all) keybindings. The first iteration tried to scope these to the cursor's subtree, which led to wildly inconsistent behavior — L on a leaf node did the same thing as Enter, and H on a child didn't touch the parent. This is not great. The fix is the obvious one: L expands *every* node in the entire document, H collapses everything back to root-level toggles. Predictable and useful. While at it, rustfmt cleaned up a few line-wrapping decisions in the existing card renderer code.
The JSON card explorer is great for drilling into specific nodes, but it gives you *no* overview of the actual structure. You can't see the shape of the data at a glance. Add a tree diagram view (toggle with D) that renders the JSON hierarchy as a top-down box-and-arrow graph, reusing the Canvas infrastructure from diagram.rs. Objects get rounded boxes, arrays get rectangular ones, and leaf nodes show truncated values. The tricky part was width management. A naive recursive layout creates canvases that are hundreds or thousands of characters wide for any non-trivial JSON — test.json was producing a 1400-char wide canvas with 59 nodes at layer 3. This is not great. Fix it by trimming each layer to fit within the terminal width, cascading removals to child layers, and appending a "+N more" summary node for truncated siblings. Tree depth is capped at 3 levels and siblings at 8 per parent before the width trimming even kicks in. While at it, make the Canvas, NodeShape, NodeLayout, and related drawing infrastructure pub(crate) in diagram.rs so json.rs can reuse them directly instead of duplicating the rendering code.
…avigation The tree diagram (D key) was a static, read-only rendering with a fixed depth limit of 3. You could look at it, scroll around, and that was about it. Meanwhile the card explorer had full j/k navigation, expand/collapse, the works. It turns out that making the diagram interactive had two problems that were *not* obvious at first glance. First, the diagram was using a completely different path format from the card explorer. Cards used paths like "config", "config.nested", "users[0]". The diagram used "root.config", "root.config.nested", "root.users[0]". Since both modes share the same expanded HashSet, toggling a node in one mode did absolutely nothing in the other. Confusion ensues. Fix this by adding a separate nav_path field to DiagramNode that uses card-explorer-compatible paths, while keeping the diagram's internal id for layout purposes. Root uses an is_root flag instead of a path, since it's always expanded in diagram mode. Second, cursor highlighting in card mode happens at draw time via line-level background color, so j/k just needs dirty=true. But in diagram mode, the highlight is baked into the canvas as node border colors. Moving the cursor with j/k without rebuilding the canvas means the highlight never actually moves. Fix this by triggering a full rebuild on j/k in diagram mode, saving the cursor path first so restore_cursor() can find it after re-render. The tree now supports the same navigation as the card explorer: j/k to move between expandable nodes, Enter to toggle, h/l to collapse/expand, H/L for all, with no fixed depth limit.
… layout The graph view was, frankly, barely usable. Arrays auto-expanded every single element into separate cards — flooding the view with dozens of cards the moment you opened anything non-trivial. There was no horizontal scrolling, so wide graphs just overflowed off the right edge of the terminal. And navigation was flat: j/k trudged linearly through every row of every card, left to right, top to bottom. Want to get from a parent to its child three columns over? Good luck pressing j forty times. The navigation model is now graph-aware. In diagram mode, j/k moves within the current card then to sibling cards in the same column. l jumps directly to the child card (auto-expanding if collapsed), and h jumps back to the parent. This is the obvious UX — it's how you'd navigate a tree — and the previous linear approach was just wrong. While at it, fix the actual rendering: - Remove the bordered box wrapper that was squeezing the canvas into a fixed width and causing overflow - Add horizontal auto-pan so the viewport follows the focused card - Highlight the entire focused card with a distinct background and border color instead of just one row - Stagger edge routing so multiple edges from the same parent card don't all pile up on the same midpoint - Widen cards from 40 to 60 chars, increase gaps between them - Show up to 40 chars of string values instead of truncating at 16 - Show up to 12 rows per card instead of 8
The JSON viewer had several problems that needed addressing before this feature branch is ready for merge. First, value_to_short_string() was slicing strings with &s[..39], which is a *byte* index on a UTF-8 string. Feed it any non-ASCII JSON value and it panics. The fix is obvious: use .chars().take(39). The other truncation function (format_primitive_short) already did this correctly, which makes the inconsistency that much more irritating. Second, render_interactive() and render_diagram() were re-parsing the entire JSON from scratch on every single rebuild — and the diagram navigation code was calling rebuild() *twice* per keypress for the horizontal auto-pan. That's three full serde_json parses per arrow key. Cache the parsed Value in ViewerState and pass &Value to the renderers instead. expand_all() also no longer re-parses independently. Third, JsonRenderer and CardRenderer had ~400 lines of nearly identical helper methods (value_span, emit_kv, emit_bullet, emit_indexed_value, table rendering). Extract these into shared free functions (make_value_span, make_kv_line, build_table_lines, etc.) that both renderers delegate to. While at it: fix all four clippy warnings (collapsible ifs and type_complexity), remove the dead render_parse_error function and empty else-if branch, use the json_path theme field for breadcrumb coloring instead of leaving it #[allow(dead_code)] from day one, make json::render() return Err on parse failure so the piped output path actually reports errors, and move test.json out of the repo root into tests/fixtures/ where it belongs. Net result: -450 lines.
The PR review flagged several real problems. Let's knock them all out in one pass. The graph view had a hardcoded RGB color for the focused card background, cheerfully ignoring the theme system that *every other color in the viewer* goes through. Add json_focus_bg to the Theme struct with proper dark and light variants. diagram_rebuild_and_scroll() was calling rebuild() unconditionally *twice* per keystroke — once to get card positions, then again after adjusting h_offset. The irony is that commit 2f81894 specifically called out this exact double-rebuild pattern and fixed it in the card explorer. Now only re-render if h_offset actually changed, since card positions don't depend on it. The interactive viewer was silently swallowing JSON parse errors and falling back to markdown rendering with no indication to the user. The piped output path correctly reported errors, making this inconsistency that much more irritating. Show a toast so the user knows what happened. move_cursor() was doing `self.navigable.len() as i32 - 1`, which is technically an overflow waiting to happen with a sufficiently deranged JSON file. Replace with safe usize arithmetic. cell_color() was guessing the JSON type of table cells by parsing the display text as f64 — which means a JSON string "42" gets colored as a number. Thread the actual Value type through via a CellType enum so we color based on what the data *is*, not what it looks like.
The viewer-level cursor highlight was applying overlay_selected_bg across the entire line width in diagram mode. Since most of a graph line is empty canvas, this painted an ugly grey bar stretching all the way to the right edge, following the cursor around. It turns out the card-level highlight inside render_diagram already handles focused-card and focused-row highlighting perfectly fine. The line-level highlight was just piling on top of that and making a mess. Skip the viewer-level cursor highlight when diagram_mode is active. The cards can take care of themselves.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
json.rsmodule that parses JSON files and renders them as structured tree views with Unicode box-drawing guides, color-coded types, and automatic table detection for arrays of homogeneous objectsjson_key,json_string,json_number,json_bool,json_null,json_bracket,json_path) to both dark and light themesTest plan
cargo run -- test.json— verify tree view renders with colored types, headings, and table fordependencies/authorso— TOC shows top-level JSON keys as sections/and search — highlights matches in JSON contentf— link picker finds URLs from JSON string valuescargo run -- test.json README.mdand Tab — multi-file switching between JSON and markdowncargo run -- test.json --export html— HTML export workscat test.json | cargo run -- --no-color— piped output works (renders as markdown fallback for stdin)cargo test— all 123 existing tests passcargo clippy— clean