first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+621
View File
@@ -0,0 +1,621 @@
//! Generic, reusable JSON tree rendering component.
//!
//! Renders a `serde_json::Value` as an interactive, collapsible tree with
//! theme-driven colors.
// Callers are wired in later phases; suppress until then.
#![allow(dead_code)]
use std::collections::HashMap;
use std::sync::Arc;
use pathfinder_color::ColorU;
use galaxy_core::ui::icons::Icon;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::WarpTheme;
use warpui::elements::{
ConstrainedBox, CrossAxisAlignment, Empty, Flex, Hoverable, MainAxisSize, MouseStateHandle,
ParentElement, Shrinkable, Text,
};
use warpui::Element;
use crate::appearance::Appearance;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// The indent width in logical pixels per nesting level.
const INDENT_PX: f32 = 12.;
/// The icon size for chevron expanders.
const CHEVRON_SIZE: f32 = 12.;
/// The font size used for all tree rows.
const TREE_FONT_SIZE: f32 = 12.;
/// Strings longer than this character count, or containing `\n`, are elided
/// by default and can be expanded in place.
pub const LONG_STRING_THRESHOLD: usize = 120;
// ---------------------------------------------------------------------------
// PathSegment
// ---------------------------------------------------------------------------
/// A single segment of a path into a `serde_json::Value` tree.
///
/// A sequence of segments uniquely identifies any node in the tree by its
/// structural position (key in an object, index in an array).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PathSegment {
/// A named key in a JSON object.
Key(String),
/// A 0-based index in a JSON array.
Index(usize),
}
// ---------------------------------------------------------------------------
// JsonTreeState
// ---------------------------------------------------------------------------
/// Stores per-node expansion state for a rendered JSON tree.
///
/// State is keyed by `Vec<PathSegment>` which identifies each node by its
/// structural path in the tree. Path-keyed state is stable across
/// streaming re-parses because the path for any given node is deterministic
/// as long as the surrounding JSON structure is unchanged.
#[derive(Debug, Default, Clone)]
pub struct JsonTreeState {
/// Expansion state for object/array nodes. Absent = default (expanded at
/// depth 0, collapsed deeper).
node_expansion: HashMap<Vec<PathSegment>, bool>,
/// Expansion state for long string values. Absent = collapsed (elided).
string_expansion: HashMap<Vec<PathSegment>, bool>,
}
impl JsonTreeState {
/// Returns whether the node at `path` and `depth` should be expanded.
///
/// Default behaviour: expanded at depth 0, collapsed at depth 1+. An
/// explicit entry in the state map always takes precedence.
pub fn is_expanded(&self, path: &[PathSegment], depth: usize) -> bool {
if let Some(&explicit) = self.node_expansion.get(path) {
return explicit;
}
depth == 0
}
/// Returns whether the long string at `path` should be expanded.
pub fn is_string_expanded(&self, path: &[PathSegment]) -> bool {
self.string_expansion.get(path).copied().unwrap_or(false)
}
/// Toggles the expansion state of the node at `path`.
///
/// If no explicit state exists, the new state is the inverse of the
/// default (derived from depth). Callers must pass `depth` so we know
/// what the default would have been.
pub fn toggle(&mut self, path: &[PathSegment], depth: usize) {
let current = self.is_expanded(path, depth);
self.node_expansion.insert(path.to_vec(), !current);
}
/// Toggles the expansion state of the long string at `path`.
pub fn toggle_string(&mut self, path: &[PathSegment]) {
let current = self.is_string_expanded(path);
self.string_expansion.insert(path.to_vec(), !current);
}
}
// ---------------------------------------------------------------------------
// JsonTreeColors
// ---------------------------------------------------------------------------
/// Pre-resolved colors for each JSON value category, sourced from the active
/// `WarpTheme`. Build this once per render from `JsonTreeColors::from_theme`.
#[derive(Debug, Clone, Copy)]
pub struct JsonTreeColors {
/// Color for object/array keys and array indices.
pub key: ColorU,
/// Color for string values.
pub string: ColorU,
/// Color for number values.
pub number: ColorU,
/// Color for boolean values.
pub bool: ColorU,
/// Color for null values.
pub null: ColorU,
/// Color for type/size annotations (`{} 4 keys`) and punctuation.
pub annotation: ColorU,
}
impl JsonTreeColors {
/// Resolve colors from a `WarpTheme` and its background color.
///
/// Each JSON value type maps to a visually distinct ANSI foreground color
/// so that keys, strings, numbers, booleans, and null are easy to
/// distinguish at a glance. Annotations and punctuation use the theme's
/// subdued text color so they don't compete with value content.
pub fn from_theme(theme: &WarpTheme) -> Self {
let bg = theme.background();
Self {
key: theme.ansi_fg_cyan(),
string: theme.ansi_fg_green(),
number: theme.ansi_fg_yellow(),
bool: theme.ansi_fg_magenta(),
null: internal_colors::text_disabled(theme, bg),
annotation: internal_colors::text_sub(theme, bg),
}
}
}
// ---------------------------------------------------------------------------
// Annotation helpers
// ---------------------------------------------------------------------------
/// Formats the annotation for a collapsible container node, e.g. `{} 3 keys`.
pub fn format_object_annotation(key_count: usize) -> String {
match key_count {
0 => "{} 0 keys".to_string(),
1 => "{} 1 key".to_string(),
n => format!("{{}} {} keys", n),
}
}
/// Formats the annotation for a collapsible array node, e.g. `[] 2 items`.
pub fn format_array_annotation(item_count: usize) -> String {
match item_count {
0 => "[] 0 items".to_string(),
1 => "[] 1 item".to_string(),
n => format!("[] {} items", n),
}
}
// ---------------------------------------------------------------------------
// Long-string helpers
// ---------------------------------------------------------------------------
/// Returns `true` when a string should be elided by default:
/// - Length > `LONG_STRING_THRESHOLD`, OR
/// - Contains a newline character.
pub fn is_long_string(s: &str) -> bool {
s.len() > LONG_STRING_THRESHOLD || s.contains('\n')
}
/// Formats a `serde_json::Number` as a string, rendering whole-valued floats
/// as integers (e.g. `5.0` → `"5"`, `3.14` → `"3.14"`).
pub fn format_number(n: &serde_json::Number) -> String {
if let Some(i) = n.as_i64() {
return i.to_string();
}
if let Some(u) = n.as_u64() {
return u.to_string();
}
if let Some(f) = n.as_f64() {
// Display whole-valued floats without the `.0` suffix.
if f.fract() == 0.0 && f.is_finite() {
return format!("{}", f as i64);
}
return format!("{}", f);
}
n.to_string()
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Builds a `Box<dyn Element>` that renders `root` as an interactive,
/// collapsible JSON tree.
///
/// # Parameters
/// - `root` — the JSON value to render.
/// - `root_label` — optional label printed above the tree (e.g. "Request").
/// - `state` — current expansion state; queried on every render.
/// - `colors` — pre-resolved theme colors.
/// - `on_toggle` — called with the path of a clicked collapsible node.
/// - `on_copy_json` — called with the path and value when "Copy JSON" is
/// activated via right-click on a row in the tree.
/// - `appearance` — provides font families and sizes.
pub fn render_json_tree(
root: &serde_json::Value,
root_label: Option<&str>,
state: &JsonTreeState,
colors: &JsonTreeColors,
on_toggle: Arc<dyn Fn(Vec<PathSegment>, usize) + Send + Sync>,
on_copy_json: Arc<dyn Fn(Vec<PathSegment>, serde_json::Value) + Send + Sync>,
appearance: &Appearance,
) -> Box<dyn Element> {
let font_family = appearance.ui_font_family();
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
// Optional section label.
if let Some(label) = root_label {
let label_text = Text::new_inline(label.to_owned(), font_family, TREE_FONT_SIZE)
.with_color(colors.annotation)
.soft_wrap(false)
.finish();
column.add_child(label_text);
}
// Render the root node and all visible descendants.
render_value(
root,
vec![],
0,
None,
state,
colors,
&on_toggle,
&on_copy_json,
font_family,
&mut column,
);
column.finish()
}
/// Recursively renders a JSON value into `column`, producing one row per
/// visible node.
///
/// `label` is `Some("key")` for object members and `Some("0")` for array
/// elements; it is `None` for the root call.
#[allow(clippy::too_many_arguments)]
fn render_value(
value: &serde_json::Value,
path: Vec<PathSegment>,
depth: usize,
label: Option<String>,
state: &JsonTreeState,
colors: &JsonTreeColors,
on_toggle: &Arc<dyn Fn(Vec<PathSegment>, usize) + Send + Sync>,
on_copy_json: &Arc<dyn Fn(Vec<PathSegment>, serde_json::Value) + Send + Sync>,
font_family: warpui::fonts::FamilyId,
column: &mut Flex,
) {
match value {
serde_json::Value::Object(map) => {
render_container_node(
&format_object_annotation(map.len()),
map.len(),
value.clone(),
path.clone(),
depth,
label,
state,
colors,
on_toggle,
on_copy_json,
font_family,
column,
);
if state.is_expanded(&path, depth) {
for (key, child_value) in map {
let child_path = {
let mut p = path.clone();
p.push(PathSegment::Key(key.clone()));
p
};
render_value(
child_value,
child_path,
depth + 1,
Some(key.clone()),
state,
colors,
on_toggle,
on_copy_json,
font_family,
column,
);
}
}
}
serde_json::Value::Array(arr) => {
render_container_node(
&format_array_annotation(arr.len()),
arr.len(),
value.clone(),
path.clone(),
depth,
label,
state,
colors,
on_toggle,
on_copy_json,
font_family,
column,
);
if state.is_expanded(&path, depth) {
for (idx, child_value) in arr.iter().enumerate() {
let child_path = {
let mut p = path.clone();
p.push(PathSegment::Index(idx));
p
};
render_value(
child_value,
child_path,
depth + 1,
Some(idx.to_string()),
state,
colors,
on_toggle,
on_copy_json,
font_family,
column,
);
}
}
}
serde_json::Value::String(s) => {
render_scalar_row(
path,
depth,
label,
build_string_value_text(s, colors, font_family),
value.clone(),
colors,
on_copy_json,
font_family,
column,
);
}
serde_json::Value::Number(n) => {
let text = Text::new_inline(format_number(n), font_family, TREE_FONT_SIZE)
.with_color(colors.number)
.soft_wrap(false)
.finish();
render_scalar_row(
path,
depth,
label,
text,
value.clone(),
colors,
on_copy_json,
font_family,
column,
);
}
serde_json::Value::Bool(b) => {
let display = if *b { "true" } else { "false" };
let text = Text::new_inline(display, font_family, TREE_FONT_SIZE)
.with_color(colors.bool)
.soft_wrap(false)
.finish();
render_scalar_row(
path,
depth,
label,
text,
value.clone(),
colors,
on_copy_json,
font_family,
column,
);
}
serde_json::Value::Null => {
let text = Text::new_inline("null", font_family, TREE_FONT_SIZE)
.with_color(colors.null)
.soft_wrap(false)
.finish();
render_scalar_row(
path,
depth,
label,
text,
value.clone(),
colors,
on_copy_json,
font_family,
column,
);
}
}
}
/// Builds the value text for a string, applying long-string elision if needed.
/// Elided strings show a truncated preview with an ellipsis.
fn build_string_value_text(
s: &str,
colors: &JsonTreeColors,
font_family: warpui::fonts::FamilyId,
) -> Box<dyn Element> {
if is_long_string(s) {
// Show a preview: first line, capped at threshold characters.
let first_line = s.lines().next().unwrap_or("");
let preview: String = first_line.chars().take(LONG_STRING_THRESHOLD).collect();
let display = format!("\"{}\"", preview);
Text::new_inline(display, font_family, TREE_FONT_SIZE)
.with_color(colors.string)
.soft_wrap(false)
.finish()
} else {
let display = format!("\"{}\"", s);
Text::new_inline(display, font_family, TREE_FONT_SIZE)
.with_color(colors.string)
.soft_wrap(false)
.finish()
}
}
/// Renders a collapsible object/array node row with a chevron expander.
///
/// Empty containers (0 keys/items) are non-interactive (no chevron, no click).
#[allow(clippy::too_many_arguments)]
fn render_container_node(
annotation: &str,
child_count: usize,
value_for_copy: serde_json::Value,
path: Vec<PathSegment>,
depth: usize,
label: Option<String>,
state: &JsonTreeState,
colors: &JsonTreeColors,
on_toggle: &Arc<dyn Fn(Vec<PathSegment>, usize) + Send + Sync>,
on_copy_json: &Arc<dyn Fn(Vec<PathSegment>, serde_json::Value) + Send + Sync>,
font_family: warpui::fonts::FamilyId,
column: &mut Flex,
) {
// Empty containers have no chevron and are not interactive.
let is_empty = child_count == 0;
let is_expanded = state.is_expanded(&path, depth);
let mut row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Indent spacer.
row.add_child(indent_spacer(depth));
// Chevron or placeholder.
if is_empty {
// Empty containers: no chevron, render a same-width placeholder.
row.add_child(
ConstrainedBox::new(Empty::new().finish())
.with_width(CHEVRON_SIZE)
.with_height(CHEVRON_SIZE)
.finish(),
);
} else {
let icon = if is_expanded {
Icon::ChevronDown
} else {
Icon::ChevronRight
};
let icon_color = colors.annotation;
row.add_child(
ConstrainedBox::new(
icon.to_warpui_icon(galaxy_core::ui::theme::Fill::Solid(icon_color))
.finish(),
)
.with_width(CHEVRON_SIZE)
.with_height(CHEVRON_SIZE)
.finish(),
);
}
// Key/index label (if inside an object or array).
if let Some(ref key) = label {
let key_text = Text::new_inline(format!("{}: ", key), font_family, TREE_FONT_SIZE)
.with_color(colors.key)
.soft_wrap(false)
.finish();
row.add_child(key_text);
}
// Type annotation.
row.add_child(
Shrinkable::new(
1.,
Text::new_inline(annotation.to_owned(), font_family, TREE_FONT_SIZE)
.with_color(colors.annotation)
.soft_wrap(false)
.finish(),
)
.finish(),
);
let row_element = row.finish();
// Wrap in a Hoverable for click (toggle) and right-click (copy JSON).
if is_empty {
// Non-interactive.
column.add_child(row_element);
} else {
let on_toggle_clone = on_toggle.clone();
let path_for_toggle = path.clone();
let on_copy_clone = on_copy_json.clone();
let path_for_copy = path.clone();
let state_handle = MouseStateHandle::default();
let row_for_hover = row_element;
let hoverable = Hoverable::new(state_handle, move |_| row_for_hover)
.on_click(move |_ctx, _app, _pos| {
on_toggle_clone(path_for_toggle.clone(), depth);
})
.on_right_click(move |_ctx, _app, _pos| {
on_copy_clone(path_for_copy.clone(), value_for_copy.clone());
})
.finish();
column.add_child(hoverable);
}
}
/// Renders a scalar value row (string, number, bool, null).
#[allow(clippy::too_many_arguments)]
fn render_scalar_row(
path: Vec<PathSegment>,
depth: usize,
label: Option<String>,
value_element: Box<dyn Element>,
value_for_copy: serde_json::Value,
colors: &JsonTreeColors,
on_copy_json: &Arc<dyn Fn(Vec<PathSegment>, serde_json::Value) + Send + Sync>,
font_family: warpui::fonts::FamilyId,
column: &mut Flex,
) {
let mut row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Indent spacer.
row.add_child(indent_spacer(depth));
// Placeholder where chevron would be, to keep column alignment.
row.add_child(
ConstrainedBox::new(Empty::new().finish())
.with_width(CHEVRON_SIZE)
.with_height(CHEVRON_SIZE)
.finish(),
);
// Key/index label (if inside an object or array).
if let Some(ref key) = label {
let key_text = Text::new_inline(format!("{}: ", key), font_family, TREE_FONT_SIZE)
.with_color(colors.key)
.soft_wrap(false)
.finish();
row.add_child(key_text);
}
// The typed value element.
row.add_child(Shrinkable::new(1., value_element).finish());
// Wrap in a Hoverable for right-click (copy JSON).
let on_copy_clone = on_copy_json.clone();
let path_for_copy = path.clone();
let state_handle = MouseStateHandle::default();
let row_element = row.finish();
let hoverable = Hoverable::new(state_handle, move |_| row_element)
.on_right_click(move |_ctx, _app, _pos| {
on_copy_clone(path_for_copy.clone(), value_for_copy.clone());
})
.finish();
column.add_child(hoverable);
}
/// Returns a fixed-width transparent spacer for the given indentation depth.
fn indent_spacer(depth: usize) -> Box<dyn Element> {
if depth == 0 {
Empty::new().finish()
} else {
ConstrainedBox::new(Empty::new().finish())
.with_width(depth as f32 * INDENT_PX)
.finish()
}
}
#[cfg(test)]
#[path = "json_tree_tests.rs"]
mod tests;