Skip to content

feat: add JSON file viewer with semantic rendering#40

Merged
bahdotsh merged 9 commits into
mainfrom
feat/json-viewer
Mar 30, 2026
Merged

feat: add JSON file viewer with semantic rendering#40
bahdotsh merged 9 commits into
mainfrom
feat/json-viewer

Conversation

@bahdotsh
Copy link
Copy Markdown
Owner

@bahdotsh bahdotsh commented Mar 29, 2026

Summary

  • Adds a new json.rs module 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 objects
  • All existing viewer features (search, TOC, link picker, keyboard nav, auto-reload, multi-file switching, HTML export) work automatically for JSON files — no viewer logic changes needed beyond a render dispatch
  • Adds 7 new JSON-specific theme colors (json_key, json_string, json_number, json_bool, json_null, json_bracket, json_path) to both dark and light themes

Test plan

  • cargo run -- test.json — verify tree view renders with colored types, headings, and table for dependencies/authors
  • Press o — TOC shows top-level JSON keys as sections
  • Press / and search — highlights matches in JSON content
  • Press f — link picker finds URLs from JSON string values
  • cargo run -- test.json README.md and Tab — multi-file switching between JSON and markdown
  • cargo run -- test.json --export html — HTML export works
  • cat test.json | cargo run -- --no-color — piped output works (renders as markdown fallback for stdin)
  • cargo test — all 123 existing tests pass
  • cargo clippy — clean

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.
@bahdotsh bahdotsh merged commit bdbe001 into main Mar 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant