Skip to main content

datui_lib/widgets/
inspector.rs

1//! The row inspector: every field of the table's selected row, and the focused
2//! field's whole value. A takeover over the table: one Surface titled with the
3//! row. Below 140 columns the fields are listed above the value; wider, the
4//! fields sit on the left, in as many columns as fit, and the value on the right
5//! at full height. Tab moves the focus between the list and the value, and the
6//! rail moves with it.
7//!
8//! The value pane reads any length: only the rows on screen are wrapped (see
9//! [`crate::inspector_reader`]), so the end of a 2 MiB value is a key away.
10
11use crate::copy_modal::thousands;
12use crate::exact;
13use crate::inspector_bytes::{self, Decoded, Sniffed};
14use crate::inspector_drill::{JSON_INLINE_BYTES, Node, Shape, json_text, looks_like_json};
15use crate::inspector_modal::{
16    CHUNK_BYTES, FieldRead, Focus, InspectorModal, Order, PaneKey, Pretty, View,
17};
18pub use crate::inspector_reader::Tone;
19use crate::inspector_reader::{self as reader, Content, TextForm, Window};
20use crate::render::context::RenderContext;
21use crate::widgets::datatable::{DataTableState, InspectField, InspectRow, NullKind, dtype_label};
22use crate::widgets::ui::{HintBar, SectionRule, Surface};
23use polars::prelude::*;
24use ratatui::buffer::Buffer;
25use ratatui::layout::Rect;
26use ratatui::style::{Modifier, Style};
27use ratatui::text::{Line, Span};
28use ratatui::widgets::{Paragraph, Widget};
29use serde_json::Value as JsonValue;
30use std::sync::Arc;
31
32/// The longest line the value pane wraps to: a reading surface keeps its
33/// measure on a wide terminal.
34const MEASURE: usize = 100;
35/// Cells between a field's name, type and preview.
36const GAP: usize = 2;
37/// The fewest lines the value pane keeps beside the field list.
38const VALUE_MIN: usize = 3;
39/// The Surface's inner width from which the fields and the value sit side by
40/// side: a 140-column terminal.
41pub const WIDE: usize = 136;
42/// The Surface's inner width from which a row with bytes gives the value pane a
43/// 32-byte hex row: a 240-column terminal.
44pub const WIDER: usize = 236;
45/// Cells between the field list and the value side by side.
46const PANE_GAP: usize = 3;
47/// The narrowest the value pane gets beside a list that needs the room: still a
48/// comfortable measure for prose.
49const VALUE_FLOOR: usize = 56;
50/// The rail and a hex dump row of 32 bytes: the value pane's width for bytes
51/// where the list keeps room beside it.
52const HEX_WIDE: usize = 1 + 8 + 1 + 32 * 3 + 2 + 32;
53/// The narrowest preview a column of fields keeps.
54const PREVIEW_MIN: usize = 14;
55/// JSON text up to this long has a JSON view; longer text reads raw.
56pub const PRETTY_MAX: usize = 1024 * 1024;
57/// Bytes shown escaped: the start of a long binary value.
58const ESCAPED_BYTES: usize = 64 * 1024;
59/// Bytes of a value matched by the find text.
60const MATCH_BYTES: usize = 4096;
61
62/// What the inspector has for one field of the row.
63#[derive(Debug, Clone)]
64pub enum Shown<'a> {
65    Value(AnyValue<'a>),
66    Null(NullKind),
67    /// Not among the rows read for the table: hidden, or binary.
68    Unread,
69    Reading,
70    Failed(&'a str),
71}
72
73impl Shown<'_> {
74    /// One number per kind, for the pane's cache key.
75    fn kind(&self) -> u8 {
76        match self {
77            Self::Value(_) => 0,
78            Self::Null(_) => 1,
79            Self::Unread => 2,
80            Self::Reading => 3,
81            Self::Failed(_) => 4,
82        }
83    }
84}
85
86/// `field` of `row`: from the buffer, or from what was read for the row.
87pub fn shown<'a>(
88    field: &InspectField,
89    row: &'a InspectRow,
90    read: Option<&'a FieldRead>,
91    state: &DataTableState,
92) -> Shown<'a> {
93    let value = if field.buffered() {
94        row.values
95            .column(&field.name)
96            .ok()
97            .and_then(|c| c.get(0).ok())
98    } else {
99        match read {
100            Some(r) if r.key() != (row.frame, row.row) => return Shown::Unread,
101            Some(FieldRead::Read { values, .. }) => {
102                values.column(&field.name).ok().and_then(|c| c.get(0).ok())
103            }
104            Some(FieldRead::Reading { .. }) => return Shown::Reading,
105            Some(FieldRead::Failed { message, .. }) => return Shown::Failed(message),
106            None => return Shown::Unread,
107        }
108    };
109    match value {
110        None => Shown::Unread,
111        Some(AnyValue::Null) => Shown::Null(state.null_kind(&field.name, row.drift_group)),
112        Some(v) => Shown::Value(v),
113    }
114}
115
116/// Whether a field holds something: a value, nothing (null or empty), or not
117/// known until it is read.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum Fill {
120    Value,
121    Null,
122    Empty,
123    Unknown,
124}
125
126pub fn fill_of(shown: &Shown) -> Fill {
127    match shown {
128        Shown::Null(_) => Fill::Null,
129        Shown::Value(v) if empty_preview(v).is_some() => Fill::Empty,
130        Shown::Value(_) => Fill::Value,
131        _ => Fill::Unknown,
132    }
133}
134
135/// Whether two rows' values of a field differ; None when either is not read.
136pub fn differs(a: &Shown, b: &Shown) -> Option<bool> {
137    match (a, b) {
138        (Shown::Value(x), Shown::Value(y)) => {
139            if x == y {
140                return Some(false);
141            }
142            // NaN is not equal to itself; their exact texts are.
143            let nested = exact::is_nested_value(x) || exact::is_nested_value(y);
144            Some(nested || exact::value_text(x) != exact::value_text(y))
145        }
146        (Shown::Null(_), Shown::Null(_)) => Some(false),
147        (Shown::Null(_), Shown::Value(_)) | (Shown::Value(_), Shown::Null(_)) => Some(true),
148        _ => None,
149    }
150}
151
152/// A type as the pane names it, with the unit and zone the short label drops.
153/// ASCII only: `us`, not `μs`.
154pub fn type_text(dtype: &DataType) -> String {
155    let unit = |u: &TimeUnit| match u {
156        TimeUnit::Milliseconds => "ms",
157        TimeUnit::Microseconds => "us",
158        TimeUnit::Nanoseconds => "ns",
159    };
160    match dtype {
161        DataType::Datetime(u, Some(tz)) => format!("datetime[{}, {tz}]", unit(u)),
162        DataType::Datetime(u, None) => format!("datetime[{}]", unit(u)),
163        DataType::Duration(u) => format!("duration[{}]", unit(u)),
164        DataType::Decimal(p, s) => format!("decimal({p},{s})"),
165        DataType::List(inner) => format!("list[{}]", type_text(inner)),
166        DataType::Array(inner, n) => format!("array[{}; {n}]", type_text(inner)),
167        other => dtype_label(other),
168    }
169}
170
171fn plural(n: usize, one: &str, many: &str) -> String {
172    format!("{} {}", thousands(n), if n == 1 { one } else { many })
173}
174
175/// A byte count as people read it, and exactly: `1.0 MB (1,048,576 bytes)`.
176pub fn size_text(n: usize) -> String {
177    if n < 1024 {
178        plural(n, "byte", "bytes")
179    } else {
180        format!(
181            "{} ({} bytes)",
182            crate::discover::format_size(n as u64),
183            thousands(n)
184        )
185    }
186}
187
188/// What `y` copies from the pane.
189#[derive(Debug, Clone)]
190pub enum CopyAs {
191    /// The stored value, exact: text as itself, numbers exact, lists as JSON.
192    Stored,
193    /// Bytes, as base64.
194    Base64,
195    /// The text the view shows: indented JSON, or text decoded from bytes.
196    Text(Arc<str>),
197    /// The escaped literal of the stored text.
198    Escaped,
199}
200
201/// The value pane for the focused value: what its rule says, what it reads, the
202/// views it has and the one shown, and what `y` copies.
203#[derive(Debug, Clone)]
204pub struct Pane {
205    pub id: u64,
206    pub facts: String,
207    pub content: Content,
208    /// The views that apply, the default first. Empty: `e` does nothing.
209    pub views: Vec<View>,
210    pub view: Option<View>,
211    pub copy: CopyAs,
212    /// Text longer than is indented on a key, shown raw until a worker indents it.
213    pub indent: bool,
214    /// Compressed bytes in their Text view, waiting on a worker to decompress them.
215    pub unpack: bool,
216}
217
218impl Pane {
219    fn lines(lines: Vec<(String, Tone)>, facts: Vec<String>) -> Self {
220        Self {
221            id: 0,
222            facts: join_facts(&facts),
223            content: Content::Lines(lines),
224            views: Vec::new(),
225            view: None,
226            copy: CopyAs::Stored,
227            indent: false,
228            unpack: false,
229        }
230    }
231
232    /// The view `e` moves to: the next of those that apply.
233    pub fn next_view(&self) -> Option<View> {
234        if self.views.len() < 2 {
235            return None;
236        }
237        let at = self
238            .view
239            .and_then(|v| self.views.iter().position(|w| *w == v))
240            .unwrap_or(0);
241        Some(self.views[(at + 1) % self.views.len()])
242    }
243}
244
245fn join_facts(facts: &[String]) -> String {
246    facts.join(&format!(" {} ", crate::glyphs::get().middot))
247}
248
249/// Where an indented copy of long JSON text stands.
250#[derive(Debug, Clone)]
251pub enum Indented {
252    /// Not asked for, or not wanted.
253    None,
254    Pending,
255    Ready(Arc<str>),
256    Failed,
257}
258
259impl Indented {
260    fn code(&self) -> u8 {
261        match self {
262            Indented::None => 0,
263            Indented::Pending => 1,
264            Indented::Ready(_) => 2,
265            Indented::Failed => 3,
266        }
267    }
268}
269
270/// The pane for text: as itself, escaped, or indented when it is JSON.
271fn text_pane(
272    s: &str,
273    kind: String,
274    choice: Option<View>,
275    indented: &Indented,
276    not_json: bool,
277) -> Pane {
278    let f = exact::text_facts(s);
279    let mut facts = vec![kind];
280    facts.push(if s.is_empty() {
281        "empty".to_string()
282    } else {
283        plural(f.chars, "char", "chars")
284    });
285    if f.lines > 1 {
286        facts.push(plural(f.lines, "line", "lines"));
287    }
288    if f.leading_spaces > 0 {
289        facts.push(plural(f.leading_spaces, "leading space", "leading spaces"));
290    }
291    if f.trailing_spaces > 0 {
292        facts.push(plural(
293            f.trailing_spaces,
294            "trailing space",
295            "trailing spaces",
296        ));
297    }
298    let json = !not_json && s.len() <= PRETTY_MAX && looks_like_json(s);
299    let (json_ok, pretty) = if json && s.len() <= JSON_INLINE_BYTES {
300        match serde_json::from_str::<JsonValue>(s) {
301            Ok(v) => (
302                true,
303                Some(Arc::<str>::from(json_text(&v, true, usize::MAX).0)),
304            ),
305            Err(_) => (false, None),
306        }
307    } else if json {
308        match indented {
309            Indented::Ready(text) => (true, Some(text.clone())),
310            Indented::Failed => (false, None),
311            _ => (true, None),
312        }
313    } else {
314        (false, None)
315    };
316    let mut views = Vec::new();
317    if json_ok {
318        views.push(View::Json);
319    }
320    views.extend([View::Raw, View::Escaped]);
321    let view = choice.filter(|v| views.contains(v)).unwrap_or(views[0]);
322    let mut pane = Pane {
323        id: 0,
324        facts: String::new(),
325        content: Content::Lines(Vec::new()),
326        views,
327        view: Some(view),
328        copy: CopyAs::Stored,
329        indent: false,
330        unpack: false,
331    };
332    match view {
333        View::Json => match pretty {
334            Some(text) => {
335                facts.push("json".to_string());
336                pane.content = Content::text(text.clone(), TextForm::Raw);
337                pane.copy = CopyAs::Text(text);
338            }
339            None => {
340                facts.push("json, indenting...".to_string());
341                pane.content = Content::text(Arc::from(s), TextForm::Raw);
342                pane.indent = true;
343            }
344        },
345        View::Escaped => {
346            facts.push("escaped".to_string());
347            pane.content = Content::text(Arc::from(s), TextForm::Escaped);
348            pane.copy = CopyAs::Escaped;
349        }
350        _ if s.is_empty() => {
351            pane.content = Content::Lines(vec![("empty string".to_string(), Tone::Dim)]);
352        }
353        _ => pane.content = Content::text(Arc::from(s), TextForm::Raw),
354    }
355    pane.facts = join_facts(&facts);
356    pane
357}
358
359/// Where text decompressed from gzip or zstd bytes stands, for the pane.
360#[derive(Debug, Clone)]
361pub enum Unpacked {
362    /// No worker answers here (a level drilled into): compressed bytes have no
363    /// Text view.
364    Unavailable,
365    /// Not asked for yet.
366    None,
367    Pending,
368    Ready(Arc<Decoded>),
369    Failed,
370}
371
372impl Unpacked {
373    fn code(&self) -> u8 {
374        match self {
375            Unpacked::Unavailable => 0,
376            Unpacked::None => 1,
377            Unpacked::Pending => 2,
378            Unpacked::Ready(_) => 3,
379            Unpacked::Failed => 4,
380        }
381    }
382}
383
384/// The pane for bytes: a hex dump, the text they hold, or escaped. UTF-8 is its
385/// own text; gzip and zstd are decompressed by a worker when their Text view is
386/// asked for (`unpack`), never while the pane is built.
387fn binary_pane(bytes: &[u8], choice: Option<View>, width: usize, unpacked: &Unpacked) -> Pane {
388    let sniffed = inspector_bytes::sniff(bytes);
389    let mut facts = vec!["binary".to_string(), size_text(bytes.len())];
390    if bytes.is_empty() {
391        facts.push("empty".to_string());
392    }
393    if let Some(kind) = sniffed.filter(|k| *k != Sniffed::Utf8) {
394        facts.push(kind.label());
395    }
396    let compressed = matches!(sniffed, Some(Sniffed::Gzip | Sniffed::Zstd));
397    let decoded = match unpacked {
398        Unpacked::Ready(d) if compressed => Some(Decoded::clone(d)),
399        _ if compressed => None,
400        _ => inspector_bytes::decode_text(bytes, sniffed),
401    };
402    if compressed && matches!(unpacked, Unpacked::Failed) {
403        facts.push("not text".to_string());
404    }
405    let unpacks = compressed && matches!(unpacked, Unpacked::None | Unpacked::Pending);
406    let mut views = Vec::new();
407    if decoded.is_some() && sniffed == Some(Sniffed::Utf8) {
408        views.push(View::Text);
409    }
410    views.push(View::Hex);
411    if (decoded.is_some() || unpacks) && sniffed != Some(Sniffed::Utf8) {
412        views.push(View::Text);
413    }
414    views.push(View::Escaped);
415    let view = choice.filter(|v| views.contains(v)).unwrap_or(views[0]);
416    let mut pane = Pane {
417        id: 0,
418        facts: String::new(),
419        content: Content::Lines(Vec::new()),
420        views,
421        view: Some(view),
422        copy: CopyAs::Base64,
423        indent: false,
424        unpack: false,
425    };
426    match (view, decoded) {
427        (View::Text, Some(d)) => {
428            facts.push(if d.from == "UTF-8" {
429                "UTF-8 text".to_string()
430            } else {
431                format!("{} text", d.from)
432            });
433            if d.cut {
434                facts.push(format!("first {} KB", inspector_bytes::DECODE_MAX / 1024));
435            }
436            let text: Arc<str> = Arc::from(d.text);
437            pane.content = Content::text(text.clone(), TextForm::Raw);
438            pane.copy = CopyAs::Text(text);
439        }
440        (View::Text, None) => {
441            facts.push("decompressing...".to_string());
442            pane.content = Content::Lines(vec![("Decompressing...".to_string(), Tone::Dim)]);
443            pane.unpack = true;
444        }
445        (View::Escaped, _) => {
446            let head = &bytes[..bytes.len().min(ESCAPED_BYTES)];
447            let mut literal = exact::escaped_bytes(head);
448            if head.len() < bytes.len() {
449                literal.pop();
450                facts.push(format!("first {} KB", ESCAPED_BYTES / 1024));
451            }
452            facts.push("escaped".to_string());
453            pane.content = Content::text(Arc::from(literal), TextForm::Raw);
454        }
455        _ if bytes.is_empty() => {
456            pane.content = Content::Lines(vec![("empty binary".to_string(), Tone::Dim)]);
457        }
458        _ => {
459            pane.content = Content::Hex {
460                bytes: Arc::from(bytes),
461                per_line: reader::hex_per_line(width),
462            };
463        }
464    }
465    pane.facts = join_facts(&facts);
466    pane
467}
468
469/// Everything a pane is built from beside the value.
470pub struct PaneAsk<'a> {
471    pub choice: Option<View>,
472    /// The pane's width: text wraps to the reading measure inside it, and a hex
473    /// dump fills it.
474    pub width: usize,
475    /// The table's preview of a scalar, said beside the exact value when they differ.
476    pub table: Option<&'a str>,
477    pub indented: Indented,
478    /// Text found not to be JSON: no JSON view.
479    pub not_json: bool,
480    /// Where text decompressed from the bytes stands.
481    pub unpacked: Unpacked,
482    /// The key that reads a field not read yet.
483    pub read_key: &'a str,
484}
485
486/// The value pane for `shown`, a value of type `dtype`.
487pub fn pane(dtype: &DataType, shown: &Shown, ask: &PaneAsk) -> Pane {
488    let g = crate::glyphs::get();
489    let width = ask.width.clamp(1, MEASURE);
490    let kind = type_text(dtype);
491    let mut lines = Vec::new();
492    match shown {
493        Shown::Unread => {
494            reader::wrap_lines(
495                &format!(
496                    "Not read {} {} reads hidden and binary fields",
497                    g.middot, ask.read_key
498                ),
499                width,
500                Tone::Dim,
501                &mut lines,
502            );
503            Pane::lines(lines, vec![kind, "not read".to_string()])
504        }
505        Shown::Reading => {
506            reader::wrap_lines("Reading...", width, Tone::Dim, &mut lines);
507            Pane::lines(lines, vec![kind])
508        }
509        Shown::Failed(message) => {
510            reader::wrap_lines(message, width, Tone::Warn, &mut lines);
511            Pane::lines(lines, vec![kind])
512        }
513        Shown::Null(null) => {
514            let (glyph, word, why) = match null {
515                NullKind::Null => (g.null, "null", None),
516                NullKind::Absent => (g.absent, "absent", Some("not in this row's file")),
517                NullKind::Conflict => (
518                    g.conflict,
519                    "conflicting",
520                    Some("another type in this row's file, not read"),
521                ),
522            };
523            let text = match why {
524                Some(why) => format!("{glyph} {word}: {why}"),
525                None => format!("{glyph} {word}"),
526            };
527            reader::wrap_lines(&text, width, Tone::Dim, &mut lines);
528            Pane::lines(lines, vec![kind, word.to_string()])
529        }
530        Shown::Value(value) => match value {
531            AnyValue::String(s) => text_pane(s, kind, ask.choice, &ask.indented, ask.not_json),
532            AnyValue::StringOwned(s) => {
533                text_pane(s.as_str(), kind, ask.choice, &ask.indented, ask.not_json)
534            }
535            AnyValue::Categorical(..)
536            | AnyValue::CategoricalOwned(..)
537            | AnyValue::Enum(..)
538            | AnyValue::EnumOwned(..) => {
539                let s = exact::value_text(value);
540                text_pane(&s, kind, ask.choice, &Indented::None, true)
541            }
542            AnyValue::Binary(b) => binary_pane(b, ask.choice, ask.width, &ask.unpacked),
543            AnyValue::BinaryOwned(b) => binary_pane(b, ask.choice, ask.width, &ask.unpacked),
544            v if exact::is_nested_value(v) => {
545                let mut facts = vec![kind];
546                if let Some(n) = exact::nested_len(v) {
547                    facts.push(match v {
548                        AnyValue::Struct(..) | AnyValue::StructOwned(_) => {
549                            plural(n, "field", "fields")
550                        }
551                        _ => plural(n, "item", "items"),
552                    });
553                }
554                let pretty = exact::nested_pretty(v, CHUNK_BYTES);
555                if pretty.cut {
556                    facts.push(format!("first {} KB", CHUNK_BYTES / 1024));
557                }
558                Pane {
559                    id: 0,
560                    facts: join_facts(&facts),
561                    content: Content::text(Arc::from(pretty.text), TextForm::Raw),
562                    views: Vec::new(),
563                    view: None,
564                    copy: CopyAs::Stored,
565                    indent: false,
566                    unpack: false,
567                }
568            }
569            v => {
570                let text = exact::value_text(v);
571                reader::wrap_lines(&text, width, Tone::Plain, &mut lines);
572                if let Some(table) = ask.table.filter(|t| *t != text) {
573                    lines.push((String::new(), Tone::Plain));
574                    reader::wrap_lines(
575                        &format!("In the table: {table}"),
576                        width,
577                        Tone::Dim,
578                        &mut lines,
579                    );
580                }
581                Pane::lines(lines, vec![kind])
582            }
583        },
584    }
585}
586
587/// A preview for a value the table would show as nothing: an empty string or
588/// empty bytes, said so rather than left blank.
589fn empty_preview(value: &AnyValue) -> Option<String> {
590    let g = crate::glyphs::get();
591    match value {
592        AnyValue::String("") => Some("\"\"".to_string()),
593        AnyValue::StringOwned(s) if s.is_empty() => Some("\"\"".to_string()),
594        AnyValue::Binary([]) => Some(format!("0 bytes {} empty", g.middot)),
595        AnyValue::BinaryOwned(b) if b.is_empty() => Some(format!("0 bytes {} empty", g.middot)),
596        _ => None,
597    }
598}
599
600/// The table's one-line preview of a value, formatted as the table formats it,
601/// and only as much of it as `room` cells can show. A long text or bytes say
602/// their size after a cut preview, so a huge value shows before it is focused.
603fn preview(field: &InspectField, value: &AnyValue, room: usize, ctx: &RenderContext) -> String {
604    let g = crate::glyphs::get();
605    let budget = room.saturating_mul(4).max(16);
606    let sized = |text: String, len: usize| {
607        if len > 1024 && crate::glyphs::cell_width(&text) > room {
608            let size = format!(" {} {}", g.middot, crate::discover::format_size(len as u64));
609            let keep = room.saturating_sub(crate::glyphs::cell_width(&size));
610            let cut = crate::glyphs::fit_cells(&text, keep, g.ellipsis).into_owned();
611            format!("{cut}{size}")
612        } else {
613            text
614        }
615    };
616    match value {
617        AnyValue::String(s) => sized(
618            exact::preview(exact::prefix(s, budget), g).into_owned(),
619            s.len(),
620        ),
621        AnyValue::StringOwned(s) => sized(
622            exact::preview(exact::prefix(s, budget), g).into_owned(),
623            s.len(),
624        ),
625        AnyValue::Binary(b) => binary_preview(b),
626        AnyValue::BinaryOwned(b) => binary_preview(b),
627        v if exact::is_nested_value(v) => {
628            exact::preview(&exact::nested_compact(v, budget).text, g).into_owned()
629        }
630        v => {
631            let formatter = ctx.number_format.formatter_for(&field.name, &field.dtype);
632            let mut scratch = String::new();
633            let text = crate::numfmt::format_any_value(&formatter, v, &mut scratch);
634            exact::preview(&text, g).into_owned()
635        }
636    }
637}
638
639/// Bytes in the list: their size, and what they are when that is known.
640fn binary_preview(b: &[u8]) -> String {
641    let size = if b.len() < 1024 {
642        plural(b.len(), "byte", "bytes")
643    } else {
644        crate::discover::format_size(b.len() as u64)
645    };
646    match inspector_bytes::sniff(&b[..b.len().min(4096)]) {
647        // A prefix that is UTF-8 says little about the rest.
648        Some(Sniffed::Utf8) | None => size,
649        Some(kind) => format!("{size} {} {}", crate::glyphs::get().middot, kind.label()),
650    }
651}
652
653/// The text of a value the find text is matched against: its start, lowercased.
654fn match_text(shown: &Shown) -> Option<String> {
655    let Shown::Value(v) = shown else {
656        return None;
657    };
658    Some(
659        match v {
660            AnyValue::String(s) => exact::prefix(s, MATCH_BYTES).to_string(),
661            AnyValue::StringOwned(s) => exact::prefix(s, MATCH_BYTES).to_string(),
662            AnyValue::Binary(_) | AnyValue::BinaryOwned(_) => return None,
663            v if exact::is_nested_value(v) => exact::nested_compact(v, MATCH_BYTES).text,
664            v => exact::value_text(v),
665        }
666        .to_lowercase(),
667    )
668}
669
670/// A value the pane shows as one exact line: not text, bytes or nested.
671fn is_scalar(value: &AnyValue) -> bool {
672    !matches!(
673        value,
674        AnyValue::String(_)
675            | AnyValue::StringOwned(_)
676            | AnyValue::Binary(_)
677            | AnyValue::BinaryOwned(_)
678    ) && !exact::is_nested_value(value)
679}
680
681fn null_glyph(kind: NullKind) -> &'static str {
682    let g = crate::glyphs::get();
683    match kind {
684        NullKind::Null => g.null,
685        NullKind::Absent => g.absent,
686        NullKind::Conflict => g.conflict,
687    }
688}
689
690/// The rows Compare puts beside a row: the pinned one, or the next; and from
691/// [`WIDER`], unpinned, the row before it too, the three in row order.
692#[derive(Clone)]
693pub struct Compared {
694    /// The row before, in a three-row compare. None at the first row, whose
695    /// column stays, empty, so nothing moves.
696    pub before: Option<InspectRow>,
697    pub after: Option<InspectRow>,
698    /// Three columns: before, this, after.
699    pub both: bool,
700    pub pinned: bool,
701}
702
703impl Compared {
704    /// The rows compared with, in row order.
705    pub fn rows(&self) -> impl Iterator<Item = &InspectRow> {
706        self.before.iter().chain(self.after.iter())
707    }
708}
709
710/// What Compare puts beside `row`, while it is on.
711pub fn compared(
712    modal: &InspectorModal,
713    state: &DataTableState,
714    row: &InspectRow,
715) -> Option<Compared> {
716    if !modal.compare {
717        return None;
718    }
719    if let Some(pinned) = &modal.pinned
720        && (pinned.frame, pinned.row) != (row.frame, row.row)
721    {
722        return Some(Compared {
723            before: None,
724            after: Some(pinned.clone()),
725            both: false,
726            pinned: true,
727        });
728    }
729    let after = state.inspect_row_at(row.row + 1);
730    let before = modal
731        .compare_both
732        .then(|| row.row.checked_sub(1))
733        .flatten()
734        .and_then(|r| state.inspect_row_at(r));
735    (after.is_some() || before.is_some()).then_some(Compared {
736        before,
737        after,
738        both: modal.compare_both,
739        pinned: false,
740    })
741}
742
743/// Whether `field`'s value `this` differs from any compared row's; None when
744/// no compared row's value is read.
745fn differs_from(
746    this: &Shown,
747    field: &InspectField,
748    other: &Compared,
749    state: &DataTableState,
750) -> Option<bool> {
751    let mut known = None;
752    for row in other.rows() {
753        match differs(this, &shown(field, row, None, state)) {
754            Some(true) => return Some(true),
755            Some(false) => known = Some(false),
756            None => {}
757        }
758    }
759    known
760}
761
762/// The fields listed, in the order listed: the order chosen, then the nulls toggle (or,
763/// comparing, only the fields that differ), then the find text — names first,
764/// then values.
765pub fn visible_fields(modal: &InspectorModal, state: &DataTableState) -> Vec<usize> {
766    let fields = &modal.fields;
767    let row = state.inspect_row();
768    let mut order: Vec<usize> = (0..fields.len()).collect();
769    if modal.order == Order::Name {
770        order.sort_by_cached_key(|&i| fields[i].name.to_lowercase());
771    }
772    let Some(row) = row else {
773        return order;
774    };
775    let read = modal.read.as_ref();
776    let shown_at = |i: usize| shown(&fields[i], &row, read, state);
777    if modal.order == Order::Filled {
778        order.sort_by_key(|&i| fill_of(&shown_at(i)) != Fill::Value);
779    }
780    if modal.filled_only {
781        match compared(modal, state, &row) {
782            Some(other) => order
783                .retain(|&i| differs_from(&shown_at(i), &fields[i], &other, state) == Some(true)),
784            None => order.retain(|&i| matches!(fill_of(&shown_at(i)), Fill::Value | Fill::Unknown)),
785        }
786    }
787    if !modal.filter.is_empty() {
788        let needle = modal.filter.to_lowercase();
789        let (names, rest): (Vec<usize>, Vec<usize>) = order
790            .into_iter()
791            .partition(|&i| fields[i].name.to_lowercase().contains(&needle));
792        let values = rest
793            .into_iter()
794            .filter(|&i| match_text(&shown_at(i)).is_some_and(|t| t.contains(&needle)));
795        order = names.into_iter().chain(values).collect();
796    }
797    order
798}
799
800/// Counts for the list's rule: nulls and empties, and with Compare, how many
801/// fields differ.
802struct Counts {
803    nulls: usize,
804    empties: usize,
805    differ: Option<usize>,
806}
807
808fn counts(
809    modal: &InspectorModal,
810    state: &DataTableState,
811    row: &InspectRow,
812    other: Option<&Compared>,
813) -> Counts {
814    let mut c = Counts {
815        nulls: 0,
816        empties: 0,
817        differ: other.map(|_| 0),
818    };
819    for field in &modal.fields {
820        let this = shown(field, row, modal.read.as_ref(), state);
821        match fill_of(&this) {
822            Fill::Null => c.nulls += 1,
823            Fill::Empty => c.empties += 1,
824            _ => {}
825        }
826        if let (Some(other), Some(n)) = (other, c.differ.as_mut())
827            && differs_from(&this, field, other, state) == Some(true)
828        {
829            *n += 1;
830        }
831    }
832    c
833}
834
835/// The inspector's title: the row, of how many, the group it is in inside a
836/// drill-down (the breadcrumb the takeover covers), and the row compared with.
837fn title(display_row: usize, state: &DataTableState, other: Option<&Compared>) -> String {
838    let g = crate::glyphs::get();
839    let mut title = format!("Row {}", thousands(display_row));
840    if let Some(total) = state.num_rows_if_valid() {
841        title.push_str(&format!(" of {}", thousands(total)));
842    }
843    if state.is_drilled_down()
844        && let Some((columns, values)) = state.drilled_group_key()
845    {
846        let key: Vec<String> = columns
847            .iter()
848            .zip(values)
849            .map(|(c, v)| format!("{c}={v}"))
850            .collect();
851        if !key.is_empty() {
852            title.push_str(&format!(
853                " {} {}",
854                g.middot,
855                key.join(&format!(" {} ", g.middot))
856            ));
857        }
858    }
859    if let Some(other) = other {
860        let pinned = if other.pinned { "pinned " } else { "" };
861        let rows: Vec<String> = other.rows().map(|r| thousands(r.display_row)).collect();
862        title.push_str(&format!(
863            " {} compare with {pinned}{}",
864            g.middot,
865            rows.join(" and ")
866        ));
867    }
868    title
869}
870
871/// Where the list starts, given `cap` slots for `n` items with the focus on
872/// `sel` and the list last starting at `offset`; and whether the first slot
873/// counts the items above and the last the items below.
874pub fn list_window(n: usize, sel: usize, offset: usize, cap: usize) -> (usize, bool, bool) {
875    if n <= cap {
876        return (0, false, false);
877    }
878    if cap < 3 {
879        let o = sel.min(n.saturating_sub(cap));
880        return (o, false, false);
881    }
882    let fits = |o: usize| {
883        let above = o > 0;
884        let room = cap - usize::from(above);
885        let below = o + room < n;
886        (above, below, room - usize::from(below))
887    };
888    // No room left empty past the end: the last page is full.
889    let mut o = offset.min(n + 1 - cap);
890    for _ in 0..4 {
891        let (above, below, items) = fits(o);
892        if sel < o {
893            o = sel;
894        } else if sel >= o + items {
895            o += sel + 1 - (o + items);
896        } else {
897            return (o, above, below);
898        }
899    }
900    let (above, below, _) = fits(o);
901    (o, above, below)
902}
903
904/// What the side-by-side layout sizes the field list from.
905#[derive(Debug, Clone, Copy, PartialEq, Eq)]
906pub struct ListShape {
907    /// Every field of the row, listed or not: narrowing the list moves nothing.
908    pub fields: usize,
909    /// A column of fields at its narrowest.
910    pub min_col: usize,
911    /// One column: Compare puts other rows beside each field.
912    pub single: bool,
913    /// The row has bytes: the value pane widens for a 32-byte hex row where the
914    /// list keeps room beside it.
915    pub bytes: bool,
916}
917
918/// The value pane's width side by side with the list in `width` cells and
919/// `rows` rows. It depends on the terminal and the row's fields, never on the
920/// focused one, so nothing moves as the focus does. A list longer than the
921/// screen takes the columns it needs, down to [`VALUE_FLOOR`] for the value;
922/// otherwise the value has its measure, or from [`WIDER`] a 32-byte hex row
923/// for a row with bytes.
924fn value_pane_width(width: usize, rows: usize, list: ListShape) -> usize {
925    let mut max = (MEASURE + 1).min(width * 45 / 100);
926    if list.bytes && !list.single && width >= WIDER {
927        max = HEX_WIDE;
928    }
929    if list.single {
930        return max;
931    }
932    let floor = VALUE_FLOOR.min(max);
933    let need = list.fields.div_ceil(rows.max(1)).max(1);
934    let fit = ((width.saturating_sub(floor + PANE_GAP) + GAP) / (list.min_col + GAP)).max(1);
935    let cols = need.min(fit);
936    let list_w = cols * list.min_col + (cols - 1) * GAP;
937    width.saturating_sub(list_w + PANE_GAP).clamp(floor, max)
938}
939
940/// Where each part of the inspector goes inside the Surface.
941#[derive(Debug, Clone, Copy, PartialEq, Eq)]
942pub struct Layout {
943    pub wide: bool,
944    pub list_rule: Rect,
945    pub list: Rect,
946    /// Columns of fields across the list.
947    pub cols: usize,
948    pub value_rule: Rect,
949    /// The value's rows, with a column for the rail at its left.
950    pub value: Rect,
951}
952
953/// Lay out `content` for `fields` listed and a value that needs `value_need`
954/// rows. Below [`WIDE`] the list sits above the value and takes the rows it
955/// needs, leaving the value what its lines need. The split follows what the
956/// panes hold, never where the focus is, so Tab moves nothing. From [`WIDE`] the two
957/// sit side by side, each at full height, the value as wide as
958/// [`value_pane_width`] says, and fields flow into as many columns of
959/// `list.min_col` cells as fit.
960pub fn layout(content: Rect, fields: usize, value_need: usize, list: ListShape) -> Layout {
961    let line = |y: u16, x: u16, width: u16| Rect {
962        x,
963        y,
964        width,
965        height: 1,
966    };
967    let width = content.width as usize;
968    let h = content.height as usize;
969    if width >= WIDE {
970        let rows = h.saturating_sub(1).max(1);
971        let value_w = value_pane_width(width, rows, list);
972        let list_w = width - value_w - PANE_GAP;
973        let mut cols = ((list_w + GAP) / (list.min_col + GAP)).max(1);
974        if list.single {
975            cols = 1;
976        }
977        while cols > 1 && (cols - 1) * rows >= fields {
978            cols -= 1;
979        }
980        let value_x = content.x + (list_w + PANE_GAP) as u16;
981        return Layout {
982            wide: true,
983            list_rule: line(content.y, content.x, list_w as u16),
984            list: Rect {
985                x: content.x,
986                y: content.y + 1,
987                width: list_w as u16,
988                height: rows as u16,
989            },
990            cols,
991            value_rule: line(content.y, value_x, value_w as u16),
992            value: Rect {
993                x: value_x,
994                y: content.y + 1,
995                width: value_w as u16,
996                height: rows as u16,
997            },
998        };
999    }
1000    let avail = h.saturating_sub(2);
1001    let fields = fields.max(1);
1002    let list_h = if fields + value_need.max(VALUE_MIN) <= avail {
1003        fields
1004    } else {
1005        // A long value takes up to half, and the rest goes to the list, but only
1006        // the rows its fields fill: the value has the remainder.
1007        let value_h = value_need.clamp(VALUE_MIN, (avail / 2).max(VALUE_MIN));
1008        avail.saturating_sub(value_h).clamp(1, fields)
1009    }
1010    .min(avail.saturating_sub(1))
1011    .max(1);
1012    let value_y = content.y + 1 + list_h as u16;
1013    Layout {
1014        wide: false,
1015        list_rule: line(content.y, content.x, content.width),
1016        list: Rect {
1017            x: content.x,
1018            y: content.y + 1,
1019            width: content.width,
1020            height: list_h as u16,
1021        },
1022        cols: 1,
1023        value_rule: line(value_y, content.x, content.width),
1024        value: Rect {
1025            x: content.x,
1026            y: value_y + 1,
1027            width: content.width,
1028            height: (content.y + content.height).saturating_sub(value_y + 1),
1029        },
1030    }
1031}
1032
1033/// The pane's width inside `value`, past the rail column: a hex dump's.
1034fn pane_width(value: Rect) -> usize {
1035    (value.width as usize).saturating_sub(1).max(1)
1036}
1037
1038/// The pane's wrapping width inside `value`: capped at the reading measure.
1039fn value_width(value: Rect) -> usize {
1040    pane_width(value).min(MEASURE)
1041}
1042
1043/// Build, or take from the cache, the pane for the focused field of `row`.
1044fn field_pane(
1045    modal: &mut InspectorModal,
1046    state: &DataTableState,
1047    row: &InspectRow,
1048    field: &InspectField,
1049    width: usize,
1050    ctx: &RenderContext,
1051) -> Pane {
1052    let value = shown(field, row, modal.read.as_ref(), state);
1053    let place = (row.frame, row.row, field.name.clone());
1054    let indented = match &modal.pretty {
1055        Some(Pretty::Pending { place: p, .. }) if *p == place => Indented::Pending,
1056        Some(Pretty::Ready { place: p, text }) if *p == place => Indented::Ready(text.clone()),
1057        Some(Pretty::Failed { place: p }) if *p == place => Indented::Failed,
1058        _ => Indented::None,
1059    };
1060    let unpacked = modal.unpacked(&place);
1061    let key = PaneKey {
1062        frame: row.frame,
1063        row: row.row,
1064        field: field.name.clone(),
1065        view: modal.view,
1066        width: width as u16,
1067        state: value.kind(),
1068        pretty: indented.code(),
1069        unpacked: unpacked.code(),
1070    };
1071    if let Some((cached, pane)) = &modal.pane
1072        && *cached == key
1073    {
1074        return pane.clone();
1075    }
1076    // Text, bytes and nested values are shown whole; only a scalar's preview can
1077    // say something the exact text does not.
1078    let table = match &value {
1079        Shown::Value(v) if is_scalar(v) => Some(preview(field, v, 64, ctx)),
1080        _ => None,
1081    };
1082    let read_key = if state.can_drill_down() { "r" } else { "Enter" };
1083    let mut built = pane(
1084        &field.dtype,
1085        &value,
1086        &PaneAsk {
1087            choice: modal.view,
1088            width,
1089            table: table.as_deref(),
1090            indented,
1091            not_json: modal.known_not_json(row.frame, row.row, &field.name),
1092            unpacked,
1093            read_key,
1094        },
1095    );
1096    built.id = renewed(modal, &key, &built);
1097    modal.pane = Some((key, built.clone()));
1098    built
1099}
1100
1101/// The id for a pane just built for `key`: the last one's while it shows the same
1102/// value at another width, so the reader stays where it was; else a new one,
1103/// which the reader starts at the top of.
1104fn renewed(modal: &mut InspectorModal, key: &PaneKey, built: &Pane) -> u64 {
1105    match &modal.pane {
1106        Some((cached, old)) if cached.same_value(key) => {
1107            let id = old.id;
1108            let old = old.content.clone();
1109            modal.reader.carry(&old, &built.content);
1110            id
1111        }
1112        _ => modal.next_pane_id(),
1113    }
1114}
1115
1116/// What Enter does on the focused field, for the footer.
1117fn enter_label(
1118    modal: &InspectorModal,
1119    state: &DataTableState,
1120    row: &InspectRow,
1121    field: &InspectField,
1122) -> Option<&'static str> {
1123    // On a group's row, Enter drills into its rows, as at the table.
1124    if state.can_drill_down() {
1125        return Some("Rows");
1126    }
1127    match shown(field, row, modal.read.as_ref(), state) {
1128        Shown::Unread => Some("Read"),
1129        Shown::Failed(_) => Some("Retry"),
1130        Shown::Value(v)
1131            if value_opens(&v) && !modal.known_not_json(row.frame, row.row, &field.name) =>
1132        {
1133            Some("Open")
1134        }
1135        _ => None,
1136    }
1137}
1138
1139/// Draw the inspector over `area` for the table's selected row.
1140pub fn render(
1141    area: Rect,
1142    buf: &mut Buffer,
1143    modal: &mut InspectorModal,
1144    state: &DataTableState,
1145    codebook: Option<&crate::codebook::Codebook>,
1146    ctx: &RenderContext,
1147) {
1148    let row = state.inspect_row();
1149    if let Some(row) = &row {
1150        modal.row_shown(row.frame, row.row);
1151    }
1152    if let (Some(row), Some(_)) = (&row, &modal.drill) {
1153        let title = title(row.display_row, state, None);
1154        render_drill(area, buf, modal, &title, ctx);
1155        return;
1156    }
1157    let content = Surface::content_area(area);
1158    modal.compare_both = content.width as usize >= WIDER;
1159    let visible = visible_fields(modal, state);
1160    modal.set_visible(visible);
1161    let other = row.as_ref().and_then(|r| compared(modal, state, r));
1162
1163    let focused = modal.focused().cloned();
1164
1165    // The list's columns, measured over every field so moving moves nothing.
1166    let g = crate::glyphs::get();
1167    let name_w = modal
1168        .fields
1169        .iter()
1170        .map(|f| {
1171            crate::glyphs::cell_width(&f.name)
1172                + if f.hidden {
1173                    1 + crate::glyphs::cell_width(g.hidden_mark)
1174                } else {
1175                    0
1176                }
1177        })
1178        .max()
1179        .unwrap_or(0)
1180        .clamp(4, 28);
1181    let type_w = modal
1182        .fields
1183        .iter()
1184        .map(|f| crate::glyphs::cell_width(&dtype_label(&f.dtype)))
1185        .max()
1186        .unwrap_or(0)
1187        .min(14);
1188    let shape = ListShape {
1189        fields: modal.fields.len(),
1190        min_col: 1 + name_w + GAP + type_w + GAP + PREVIEW_MIN,
1191        single: other.is_some(),
1192        bytes: modal
1193            .fields
1194            .iter()
1195            .any(|f| matches!(f.dtype, DataType::Binary | DataType::BinaryOffset)),
1196    };
1197    // The value's width does not depend on what it needs; only the stacked
1198    // layout's heights do.
1199    let probe = layout(content, modal.visible.len(), VALUE_MIN, shape);
1200    let width = value_width(probe.value);
1201    let pane = match (&row, &focused) {
1202        (Some(row), Some(field)) => Some(field_pane(
1203            modal,
1204            state,
1205            row,
1206            field,
1207            pane_width(probe.value),
1208            ctx,
1209        )),
1210        _ => None,
1211    };
1212    let pane = pane.unwrap_or_else(|| {
1213        let message = if row.is_none() {
1214            "Reading the row..."
1215        } else {
1216            "No field matches"
1217        };
1218        Pane::lines(vec![(message.to_string(), Tone::Dim)], Vec::new())
1219    });
1220    modal.reader.prepare(pane.id, width, modal.wrap);
1221    // What the codebook says of the field, and of the value under the cursor.
1222    let note = match (&row, &focused, codebook) {
1223        (Some(row), Some(field), Some(book)) => book
1224            .column(&field.name)
1225            .map(|column| {
1226                codebook_lines(
1227                    column,
1228                    &shown(field, row, modal.read.as_ref(), state),
1229                    width,
1230                )
1231            })
1232            .unwrap_or_default(),
1233        _ => Vec::new(),
1234    };
1235    let value_need = modal
1236        .reader
1237        .rows_needed(&pane.content, content.height as usize);
1238    let need = value_need + if note.is_empty() { 0 } else { note.len() + 1 };
1239
1240    let mut lay = layout(content, modal.visible.len(), need, shape);
1241    // The note follows the value, a blank row between, and never takes more than half
1242    // the pane: a long value scrolls above it.
1243    let note_rows = if note.is_empty() || lay.value.height < 3 {
1244        0
1245    } else {
1246        (note.len() + 1).min(lay.value.height as usize / 2) as u16
1247    };
1248    lay.value.height -= note_rows;
1249    let note_area = Rect {
1250        y: lay.value.y + lay.value.height.min(value_need as u16),
1251        height: note_rows,
1252        ..lay.value
1253    };
1254
1255    // The footer, from what the layout leaves visible.
1256    let enter = match (&row, &focused) {
1257        (Some(row), Some(field)) => enter_label(modal, state, row, field),
1258        _ => None,
1259    };
1260    let unread_on_group = state.can_drill_down()
1261        && matches!(
1262            (&row, &focused),
1263            (Some(row), Some(field))
1264                if matches!(shown(field, row, modal.read.as_ref(), state), Shown::Unread | Shown::Failed(_))
1265        );
1266    let value_rows = lay.value.height as usize;
1267    let overflows = need > value_rows || modal.reader.window(&pane.content, value_rows).above;
1268    let footer = footer(
1269        modal,
1270        &pane,
1271        &FooterFacts {
1272            enter,
1273            read_key: unread_on_group,
1274            has_value: row.is_some() && focused.is_some(),
1275            many_fields: modal.visible.len() > 1,
1276            list_overflows: modal.visible.len() > lay.list.height as usize * lay.cols,
1277            value_overflows: overflows,
1278            comparing: other.is_some(),
1279        },
1280        ctx,
1281    );
1282    let title = match &row {
1283        Some(row) => title(row.display_row, state, other.as_ref()),
1284        None => "Row".to_string(),
1285    };
1286    let title = crate::glyphs::fit_cells(&title, area.width.saturating_sub(4) as usize, g.ellipsis);
1287    let content = Surface::new(&title).footer(&footer).render(area, buf, ctx);
1288    if content.height < 4 || content.width < 12 {
1289        return;
1290    }
1291
1292    // The list's rule: the find line while finding, else the counts.
1293    let mut rule_used = None;
1294    if modal.finding || !modal.filter.is_empty() {
1295        draw_find_line(
1296            buf,
1297            lay.list_rule,
1298            &modal.filter,
1299            modal.finding,
1300            &format!(
1301                "{} of {}",
1302                thousands(modal.visible.len()),
1303                thousands(modal.fields.len())
1304            ),
1305            ctx,
1306        );
1307    } else {
1308        let chip = match &row {
1309            Some(row) => list_chip(modal, state, row, other.as_ref()),
1310            None => thousands(modal.fields.len()),
1311        };
1312        SectionRule {
1313            title: "Fields",
1314            chip: Some(&chip),
1315        }
1316        .render(lay.list_rule, buf, ctx);
1317        rule_used = Some("Fields".len() + 1 + crate::glyphs::cell_width(&chip) + 3);
1318    }
1319
1320    draw_fields(
1321        buf,
1322        &lay,
1323        modal,
1324        state,
1325        ListOf {
1326            row: row.as_ref(),
1327            other: other.as_ref(),
1328            name_w,
1329            type_w,
1330            rule_used,
1331        },
1332        ctx,
1333    );
1334
1335    let name = focused.as_ref().map(|f| f.name.clone()).unwrap_or_default();
1336    draw_value(buf, &lay, &name, &pane, modal, ctx);
1337    for (i, (text, tone)) in note
1338        .iter()
1339        .take(note_rows.saturating_sub(1) as usize)
1340        .enumerate()
1341    {
1342        let style = match tone {
1343            Tone::Dim => Style::default().fg(ctx.dimmed),
1344            _ => Style::default().fg(ctx.text_primary),
1345        };
1346        Paragraph::new(text.as_str()).style(style).render(
1347            Rect {
1348                x: note_area.x + 1,
1349                y: note_area.y + 1 + i as u16,
1350                width: note_area.width.saturating_sub(1),
1351                height: 1,
1352            },
1353            buf,
1354        );
1355    }
1356}
1357
1358/// The codebook's lines for a field: its meaning and unit, then what the value under
1359/// the cursor stands for when the codebook lists it.
1360pub fn codebook_lines(
1361    column: &crate::codebook::Column,
1362    shown: &Shown,
1363    width: usize,
1364) -> Vec<(String, Tone)> {
1365    let mut lines = Vec::new();
1366    let about = column.about();
1367    if !about.is_empty() {
1368        reader::wrap_lines(&about, width.max(1), Tone::Dim, &mut lines);
1369    }
1370    let value = match shown {
1371        Shown::Null(_) => Some(None),
1372        Shown::Value(v) => Some(Some(exact::value_text(v))),
1373        _ => None,
1374    };
1375    if let Some(line) = value.and_then(|v| column.legend_line(v.as_deref())) {
1376        reader::wrap_lines(&line, width.max(1), Tone::Plain, &mut lines);
1377    }
1378    lines
1379}
1380
1381/// The chip on the list's rule: how many fields, how many null and empty, how
1382/// many differ, and the order when it is not the table's.
1383fn list_chip(
1384    modal: &InspectorModal,
1385    state: &DataTableState,
1386    row: &InspectRow,
1387    other: Option<&Compared>,
1388) -> String {
1389    let c = counts(modal, state, row, other);
1390    let total = thousands(modal.fields.len());
1391    let mut parts = vec![if modal.visible.len() < modal.fields.len() {
1392        format!("{} of {total}", thousands(modal.visible.len()))
1393    } else {
1394        total
1395    }];
1396    match c.differ {
1397        Some(n) => parts.push(format!("{} differ", thousands(n))),
1398        None => {
1399            if c.nulls > 0 {
1400                parts.push(format!("{} null", thousands(c.nulls)));
1401            }
1402            if c.empties > 0 {
1403                parts.push(format!("{} empty", thousands(c.empties)));
1404            }
1405        }
1406    }
1407    if modal.filled_only {
1408        parts.push(
1409            if c.differ.is_some() {
1410                "differ only"
1411            } else {
1412                "nulls hidden"
1413            }
1414            .to_string(),
1415        );
1416    }
1417    if let Some(order) = modal.order.label() {
1418        parts.push(order.to_string());
1419    }
1420    join_facts(&parts)
1421}
1422
1423/// A find line where a rule stands: the text, the cursor while typing, a count.
1424fn draw_find_line(
1425    buf: &mut Buffer,
1426    at: Rect,
1427    text: &str,
1428    typing: bool,
1429    count: &str,
1430    ctx: &RenderContext,
1431) {
1432    let g = crate::glyphs::get();
1433    let label_style = if typing {
1434        Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD)
1435    } else {
1436        Style::default().fg(ctx.dimmed)
1437    };
1438    let mut spans = vec![
1439        Span::styled("Find: ", label_style),
1440        Span::styled(text.to_string(), Style::default().fg(ctx.text_primary)),
1441    ];
1442    if typing {
1443        spans.push(Span::styled(g.cursor, Style::default().fg(ctx.accent)));
1444    }
1445    spans.push(Span::styled(
1446        format!("   {count}"),
1447        Style::default().fg(ctx.dimmed),
1448    ));
1449    Paragraph::new(Line::from(spans)).render(at, buf);
1450}
1451
1452/// What the footer offers, as the frame found it.
1453struct FooterFacts {
1454    enter: Option<&'static str>,
1455    /// On a group's row, a field not read yet: `r` reads it.
1456    read_key: bool,
1457    has_value: bool,
1458    many_fields: bool,
1459    list_overflows: bool,
1460    value_overflows: bool,
1461    comparing: bool,
1462}
1463
1464/// The keys that act now, primary first; the weights say which yield first on
1465/// a narrow footer, the way out last.
1466fn footer<'a>(
1467    modal: &InspectorModal,
1468    pane: &'a Pane,
1469    f: &FooterFacts,
1470    ctx: &RenderContext,
1471) -> HintBar<'a> {
1472    let g = crate::glyphs::get();
1473    let mut bar = HintBar::from_ctx(ctx);
1474    if modal.finding {
1475        return bar
1476            .hint_weighted("Enter", "Done", 3)
1477            .hint_weighted("type", "Find", 1)
1478            .hint_weighted("Esc", "Clear", 4);
1479    }
1480    if modal.value_find.as_ref().is_some_and(|f| f.editing) {
1481        return bar
1482            .hint_weighted("Enter", "Find", 3)
1483            .hint_weighted("type", "Text", 1)
1484            .hint_weighted("Esc", "Clear", 4);
1485    }
1486    let copy = match pane.copy {
1487        CopyAs::Base64 => "Copy base64",
1488        CopyAs::Text(_) if pane.view == Some(View::Json) => "Copy JSON",
1489        CopyAs::Text(_) => "Copy text",
1490        _ => "Copy",
1491    };
1492    let view = pane.next_view().map(View::label);
1493    if modal.focus == Focus::Value {
1494        if f.value_overflows {
1495            bar = bar
1496                .hint_weighted(g.updown, "Scroll", 8)
1497                .hint_weighted("Home/End", "Top/End", 6);
1498        }
1499        bar = bar.hint_weighted("/", "Find", 7);
1500        if modal
1501            .value_find
1502            .as_ref()
1503            .is_some_and(|f| !f.hits.is_empty())
1504        {
1505            bar = bar.hint_weighted("n/N", "Next", 7);
1506        }
1507        if let Some(view) = view {
1508            bar = bar.hint_weighted("e", view, 5);
1509        }
1510        if pane.content.wraps() {
1511            let wrap = match modal.wrap {
1512                reader::Wrap::Word => "Hard wrap",
1513                reader::Wrap::Hard => "Word wrap",
1514            };
1515            bar = bar.hint_weighted("w", wrap, 3);
1516        }
1517        if f.has_value {
1518            bar = bar
1519                .hint_weighted("y", copy, 4)
1520                .hint_weighted("o", "Open", 2);
1521        }
1522        bar = bar.hint_weighted(g.updown_lr, "Row", 1);
1523        return bar.hint_weighted("Esc", "Fields", 10);
1524    }
1525    if let Some(label) = f.enter {
1526        bar = bar.hint_weighted("Enter", label, 9);
1527    }
1528    if f.read_key {
1529        bar = bar.hint_weighted("r", "Read", 9);
1530    }
1531    if f.has_value {
1532        bar = bar.hint_weighted("Tab", "Value", 8);
1533        if !matches!(f.enter, Some("Read" | "Retry")) {
1534            bar = bar.hint_weighted("y", copy, 7);
1535        }
1536    }
1537    // The arrows are the first keys anyone tries: their chip yields before the
1538    // view key, which nothing else would reveal.
1539    if f.many_fields {
1540        bar = bar.hint_weighted(g.updown, "Field", 4);
1541    }
1542    bar = bar
1543        .hint_weighted(g.updown_lr, "Row", 6)
1544        .hint_weighted("/", "Find", 5);
1545    if let Some(view) = view {
1546        bar = bar.hint_weighted("e", view, 5);
1547    }
1548    if f.list_overflows {
1549        bar = bar.hint_weighted("PgUp/PgDn", "Page", 3);
1550    }
1551    // The toggle names its state: what `f` hides is the nulls and empties.
1552    let filled = match (f.comparing, modal.filled_only) {
1553        (true, _) => "Differ",
1554        (false, true) => "Nulls: hidden",
1555        (false, false) => "Nulls: shown",
1556    };
1557    bar = bar.hint_weighted("Y", "Copy row", 2);
1558    // Comparing, Esc is the way out of Compare, and says so.
1559    if !modal.compare {
1560        bar = bar.hint_weighted("c", "Compare", 2);
1561    }
1562    bar = bar.hint_weighted("f", filled, 1);
1563    if f.comparing {
1564        bar = bar.hint_weighted("m", "Pin", 1);
1565    }
1566    // Esc backs out a level at a time: the find, Compare, then the inspector.
1567    let esc = if !modal.filter.is_empty() {
1568        "Clear"
1569    } else if modal.compare {
1570        "No compare"
1571    } else {
1572        "Close"
1573    };
1574    bar.hint_weighted("Esc", esc, 10)
1575}
1576
1577/// The rows the list shows, and its name and type columns' widths.
1578struct ListOf<'a> {
1579    row: Option<&'a InspectRow>,
1580    other: Option<&'a Compared>,
1581    name_w: usize,
1582    type_w: usize,
1583    /// Cells of the list's rule its title and chip take, when it is a rule:
1584    /// Compare names its rows over the rest.
1585    rule_used: Option<usize>,
1586}
1587
1588/// The fields, in as many columns as the layout has: rail, name, type, the
1589/// table's preview, and with Compare the other rows' previews in row order, a
1590/// mark where they differ and the rows named over them on the rule. The first
1591/// slot counts the fields above, the last those below.
1592fn draw_fields(
1593    buf: &mut Buffer,
1594    lay: &Layout,
1595    modal: &mut InspectorModal,
1596    state: &DataTableState,
1597    of: ListOf,
1598    ctx: &RenderContext,
1599) {
1600    let ListOf {
1601        row,
1602        other,
1603        name_w,
1604        type_w,
1605        rule_used,
1606    } = of;
1607    let g = crate::glyphs::get();
1608    let rows = lay.list.height as usize;
1609    let cols = lay.cols.max(1);
1610    let cap = rows * cols;
1611    let n = modal.visible.len();
1612    let sel = modal.focused_position();
1613    let (offset, above, below) = list_window(n, sel, modal.list_offset, cap);
1614    let items = cap - usize::from(above) - usize::from(below);
1615    modal.list_offset = offset;
1616    modal.list_page = items.max(1);
1617    let list_w = lay.list.width as usize;
1618    let col_w = if cols > 1 {
1619        (list_w - GAP * (cols - 1)) / cols
1620    } else {
1621        list_w
1622    };
1623    // Names stay whole while a column has its narrowest preview beside them.
1624    let beside = 1 + GAP + type_w + GAP + PREVIEW_MIN;
1625    let name_w = name_w.min((col_w / 3).max(col_w.saturating_sub(beside)).max(4));
1626    let rest = col_w.saturating_sub(1 + name_w + GAP + type_w + GAP);
1627    let read = modal.read.as_ref();
1628    // The rows previewed, in row order, each with what was read for it.
1629    let cells: Vec<(Option<&InspectRow>, Option<&FieldRead>)> = match other {
1630        Some(c) if c.both => vec![
1631            (c.before.as_ref(), None),
1632            (row, read),
1633            (c.after.as_ref(), None),
1634        ],
1635        Some(c) => vec![(row, read), (c.after.as_ref(), None)],
1636        None => vec![(row, read)],
1637    };
1638    // With Compare, a gap between previews and two cells for the mark.
1639    let each = match cells.len() {
1640        1 => rest,
1641        n => rest.saturating_sub(GAP * (n - 1) + 2) / n,
1642    };
1643    let slot_rect = |slot: usize| {
1644        let col = slot / rows;
1645        let r = slot % rows;
1646        Rect {
1647            x: lay.list.x + (col * (col_w + GAP)) as u16,
1648            y: lay.list.y + r as u16,
1649            width: col_w as u16,
1650            height: 1,
1651        }
1652    };
1653    if n == 0 {
1654        Paragraph::new("No field matches")
1655            .style(Style::default().fg(ctx.dimmed))
1656            .render(slot_rect(0), buf);
1657        return;
1658    }
1659    let dim = Style::default().fg(ctx.dimmed);
1660    if above {
1661        Paragraph::new(format!("  {} {} above", g.ellipsis, thousands(offset)))
1662            .style(dim)
1663            .render(slot_rect(0), buf);
1664    }
1665    if below {
1666        let left = n - offset - items;
1667        Paragraph::new(format!("  {} {} more", g.ellipsis, thousands(left)))
1668            .style(dim)
1669            .render(slot_rect(cap - 1), buf);
1670    }
1671    if let (Some(used), Some(row), Some(_)) = (rule_used, row, other) {
1672        let first = 1 + name_w + GAP + type_w + GAP;
1673        let rows: Vec<(usize, usize, bool)> = cells
1674            .iter()
1675            .enumerate()
1676            .filter_map(|(j, (at, _))| {
1677                at.map(|r| (first + j * (each + GAP), r.display_row, r.row == row.row))
1678            })
1679            .collect();
1680        draw_compare_labels(buf, lay.list_rule, &rows, each, used, ctx);
1681    }
1682    let list_focused = modal.focus == Focus::List && !modal.finding;
1683    let value_cell = |field: &InspectField, at: Option<&InspectRow>, room: usize, own_read| match at
1684    {
1685        Some(r) => match shown(field, r, own_read, state) {
1686            Shown::Value(v) => match empty_preview(&v) {
1687                Some(empty) => (empty, dim),
1688                None => (
1689                    preview(field, &v, room, ctx),
1690                    Style::default().fg(ctx.text_primary),
1691                ),
1692            },
1693            Shown::Null(kind) => (
1694                null_glyph(kind).to_string(),
1695                dim.add_modifier(Modifier::ITALIC),
1696            ),
1697            Shown::Unread => ("not read".to_string(), dim),
1698            Shown::Reading => ("reading...".to_string(), dim),
1699            Shown::Failed(_) => ("not read".to_string(), Style::default().fg(ctx.warning)),
1700        },
1701        None => (String::new(), Style::default()),
1702    };
1703    for k in 0..items.min(n - offset) {
1704        let i = offset + k;
1705        let index = modal.visible[i];
1706        let field = &modal.fields[index];
1707        let at = slot_rect(k + usize::from(above));
1708        let is_selected = i == sel;
1709        let marker = match (is_selected, modal.finding, list_focused) {
1710            (true, false, true) => g.rail,
1711            (true, true, _) => g.middot,
1712            _ => " ",
1713        };
1714        let mut name = field.name.clone();
1715        if field.hidden {
1716            name = format!("{name} {}", g.hidden_mark);
1717        }
1718        let name = crate::glyphs::fit_cells(&name, name_w, g.ellipsis);
1719        let name_pad = name_w.saturating_sub(crate::glyphs::cell_width(&name));
1720        let label =
1721            crate::glyphs::fit_cells(&dtype_label(&field.dtype), type_w, g.ellipsis).into_owned();
1722        let label_pad = type_w.saturating_sub(crate::glyphs::cell_width(&label));
1723        let name_style = if is_selected {
1724            Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD)
1725        } else if ctx.column_colors {
1726            Style::default().fg(ctx.type_color(&field.dtype))
1727        } else {
1728            Style::default().fg(ctx.text_primary)
1729        };
1730        let mut spans = vec![
1731            Span::styled(marker, Style::default().fg(ctx.accent)),
1732            Span::styled(name.into_owned(), name_style),
1733            Span::raw(" ".repeat(name_pad + GAP)),
1734            Span::styled(label, dim),
1735            Span::raw(" ".repeat(label_pad + GAP)),
1736        ];
1737        for (j, (at_row, own)) in cells.iter().enumerate() {
1738            let (text, style) = value_cell(field, *at_row, each, *own);
1739            let text = crate::glyphs::fit_cells(&text, each, g.ellipsis).into_owned();
1740            let pad = each.saturating_sub(crate::glyphs::cell_width(&text));
1741            spans.push(Span::styled(text, style));
1742            if j + 1 < cells.len() {
1743                spans.push(Span::raw(" ".repeat(pad + GAP)));
1744            } else if other.is_some() {
1745                spans.push(Span::raw(" ".repeat(pad + 1)));
1746            }
1747        }
1748        if let (Some(other), Some(this_row)) = (other, row) {
1749            let differ = differs_from(&shown(field, this_row, read, state), field, other, state);
1750            if differ == Some(true) {
1751                spans.push(Span::styled(
1752                    g.diff_mark,
1753                    Style::default()
1754                        .fg(ctx.text_primary)
1755                        .add_modifier(Modifier::BOLD),
1756                ));
1757            }
1758        }
1759        let mut paragraph = Paragraph::new(Line::from(spans));
1760        if is_selected && list_focused {
1761            paragraph = paragraph.style(ctx.highlight_style());
1762        }
1763        paragraph.render(at, buf);
1764    }
1765}
1766
1767/// Compare's rows named over their previews on the list's rule, `Row 41`: the
1768/// row shown in the text color, the others dimmed. `rows` are each preview's
1769/// cells into the rule and its row's number, and whether it is the row shown.
1770/// A name is drawn where it fits its column and clears the rule's title and
1771/// chip, the first `used` cells.
1772fn draw_compare_labels(
1773    buf: &mut Buffer,
1774    rule: Rect,
1775    rows: &[(usize, usize, bool)],
1776    each: usize,
1777    used: usize,
1778    ctx: &RenderContext,
1779) {
1780    for &(x, n, this) in rows {
1781        let label = format!(" Row {} ", thousands(n));
1782        // A name starts a cell left of its preview, over the gap, so its words
1783        // sit over the values.
1784        if x <= used + 1
1785            || crate::glyphs::cell_width(&label) > each + 1
1786            || x + each >= rule.width as usize
1787        {
1788            continue;
1789        }
1790        let style = if this {
1791            Style::default().fg(ctx.text_primary)
1792        } else {
1793            Style::default().fg(ctx.dimmed)
1794        };
1795        Paragraph::new(label.clone()).style(style).render(
1796            Rect {
1797                x: rule.x + (x - 1) as u16,
1798                width: crate::glyphs::cell_width(&label) as u16,
1799                ..rule
1800            },
1801            buf,
1802        );
1803    }
1804}
1805
1806/// The focused value under its rule: its rows from where the reader is, the
1807/// rail down its left while it has the focus, and where it is on the rule.
1808fn draw_value(
1809    buf: &mut Buffer,
1810    lay: &Layout,
1811    name: &str,
1812    pane: &Pane,
1813    modal: &mut InspectorModal,
1814    ctx: &RenderContext,
1815) {
1816    let g = crate::glyphs::get();
1817    let h = lay.value.height as usize;
1818    modal.page = h.max(1);
1819    let focused = modal.focus == Focus::Value;
1820    // A search's places are for the pane they were found in.
1821    if let Some(find) = modal.value_find.as_mut()
1822        && find.pane != pane.id
1823        && !find.text.is_empty()
1824    {
1825        find.hits = reader::find_hits(&pane.content, &find.text);
1826        find.current = None;
1827        find.pane = pane.id;
1828    }
1829    let win: Window = modal.reader.window(&pane.content, h);
1830    let rule = lay.value_rule;
1831    match modal
1832        .value_find
1833        .as_ref()
1834        .filter(|f| f.editing || !f.text.is_empty())
1835    {
1836        Some(find) => {
1837            let count = match (find.hits.len(), find.current) {
1838                (0, _) if !find.editing => "no match".to_string(),
1839                (0, _) => String::new(),
1840                (n, Some(at)) => format!("{} of {}", thousands(at + 1), thousands(n)),
1841                (n, None) => plural(n, "match", "matches"),
1842            };
1843            draw_find_line(buf, rule, &find.text, find.editing, &count, ctx);
1844        }
1845        None => {
1846            let width = rule.width as usize;
1847            let name = crate::glyphs::fit_cells(name, width / 3, g.ellipsis);
1848            let name_w = crate::glyphs::cell_width(&name);
1849            let overflows = win.above || win.below;
1850            // The position, at its widest plus its padding and a cell of rule after
1851            // it, comes before the facts: reading a long value, where you are
1852            // matters more than its size, and the facts must not shift as it changes.
1853            let reserve = if overflows {
1854                reader::Reader::position_width(&pane.content) + 3
1855            } else {
1856                0
1857            };
1858            // The title and its space, the chip's padding and space, a bit of rule.
1859            let room = width.saturating_sub(name_w + 1 + 3 + 2 + reserve);
1860            let facts = (!pane.facts.is_empty() && room >= 4)
1861                .then(|| crate::glyphs::fit_cells(&pane.facts, room, g.ellipsis));
1862            SectionRule {
1863                title: &name,
1864                chip: facts.as_deref(),
1865            }
1866            .render(rule, buf, ctx);
1867            let used = name_w
1868                + 1
1869                + facts
1870                    .as_deref()
1871                    .map_or(0, |f| crate::glyphs::cell_width(f) + 3);
1872            if overflows {
1873                let text = format!(" {} ", modal.reader.position(&pane.content, &win));
1874                let w = crate::glyphs::cell_width(&text);
1875                // Over the rule's tail, where the rule has room for it.
1876                if used + 2 + w < width {
1877                    Paragraph::new(text).style(dim_or(ctx, focused)).render(
1878                        Rect {
1879                            x: rule.x + (width - w - 1) as u16,
1880                            width: w as u16,
1881                            ..rule
1882                        },
1883                        buf,
1884                    );
1885                }
1886            }
1887        }
1888    }
1889    let needle = modal
1890        .value_find
1891        .as_ref()
1892        .map(|f| f.text.clone())
1893        .unwrap_or_default();
1894    let text_w = pane_width(lay.value) as u16;
1895    for (i, row) in win.rows.iter().enumerate() {
1896        let y = lay.value.y + i as u16;
1897        if focused {
1898            Paragraph::new(g.rail)
1899                .style(Style::default().fg(ctx.accent))
1900                .render(
1901                    Rect {
1902                        x: lay.value.x,
1903                        y,
1904                        width: 1,
1905                        height: 1,
1906                    },
1907                    buf,
1908                );
1909        }
1910        let style = match row.tone {
1911            Tone::Plain => Style::default().fg(ctx.text_primary),
1912            Tone::Dim => Style::default().fg(ctx.dimmed),
1913            Tone::Warn => Style::default().fg(ctx.warning),
1914        };
1915        let mut spans = Vec::new();
1916        let mut at = 0;
1917        for (a, b) in reader::hits_in_row(&row.text, &needle) {
1918            spans.push(Span::styled(row.text[at..a].to_string(), style));
1919            spans.push(Span::styled(row.text[a..b].to_string(), ctx.find_match));
1920            at = b;
1921        }
1922        spans.push(Span::styled(row.text[at..].to_string(), style));
1923        Paragraph::new(Line::from(spans)).render(
1924            Rect {
1925                x: lay.value.x + 1,
1926                y,
1927                width: text_w.min(lay.value.width.saturating_sub(1)),
1928                height: 1,
1929            },
1930            buf,
1931        );
1932    }
1933}
1934
1935fn dim_or(ctx: &RenderContext, focused: bool) -> Style {
1936    if focused {
1937        Style::default().fg(ctx.accent)
1938    } else {
1939        Style::default().fg(ctx.dimmed)
1940    }
1941}
1942
1943/// Whether Enter opens `value` as a level: a list, array or struct, or text that
1944/// reads as a JSON object or array.
1945pub fn value_opens(value: &AnyValue) -> bool {
1946    match value {
1947        AnyValue::String(s) => crate::inspector_drill::opens_as_json(s),
1948        AnyValue::StringOwned(s) => crate::inspector_drill::opens_as_json(s),
1949        v => exact::is_nested_value(v),
1950    }
1951}
1952
1953/// The value pane for an item of a level drilled into, labelled `label`.
1954pub fn node_pane(node: &Node, choice: Option<View>, width: usize) -> Pane {
1955    let ask = PaneAsk {
1956        choice,
1957        width,
1958        table: None,
1959        indented: Indented::None,
1960        not_json: false,
1961        unpacked: Unpacked::Unavailable,
1962        read_key: "Enter",
1963    };
1964    match node {
1965        Node::Native(series) => {
1966            let shown = match series.get(0) {
1967                Ok(AnyValue::Null) | Err(_) => Shown::Null(NullKind::Null),
1968                Ok(v) => Shown::Value(v),
1969            };
1970            pane(series.dtype(), &shown, &ask)
1971        }
1972        Node::Json { .. } => json_pane(node.json().unwrap_or(&JsonValue::Null), &ask),
1973    }
1974}
1975
1976/// The value pane for a JSON value: text as text is shown, an object or array
1977/// indented up to a few chunks.
1978fn json_pane(value: &JsonValue, ask: &PaneAsk) -> Pane {
1979    let g = crate::glyphs::get();
1980    let width = ask.width.clamp(1, MEASURE);
1981    let scalar = |text: String, kind: &str| {
1982        let mut lines = Vec::new();
1983        reader::wrap_lines(&text, width, Tone::Plain, &mut lines);
1984        Pane::lines(lines, vec![kind.to_string()])
1985    };
1986    match value {
1987        JsonValue::String(s) => pane(&DataType::String, &Shown::Value(AnyValue::String(s)), ask),
1988        JsonValue::Null => Pane::lines(
1989            vec![(format!("{} null", g.null), Tone::Dim)],
1990            vec!["null".to_string()],
1991        ),
1992        JsonValue::Bool(b) => scalar(b.to_string(), "bool"),
1993        JsonValue::Number(n) => scalar(n.to_string(), "number"),
1994        JsonValue::Array(_) | JsonValue::Object(_) => {
1995            let (kind, count) = match value {
1996                JsonValue::Object(map) => ("object", plural(map.len(), "key", "keys")),
1997                JsonValue::Array(items) => ("array", plural(items.len(), "item", "items")),
1998                _ => unreachable!(),
1999            };
2000            let cap = CHUNK_BYTES * 4;
2001            let (text, cut) = json_text(value, true, cap);
2002            let mut facts = vec![kind.to_string(), count];
2003            if cut {
2004                facts.push(format!("first {} KB", cap / 1024));
2005            }
2006            Pane {
2007                id: 0,
2008                facts: join_facts(&facts),
2009                content: Content::text(Arc::from(text), TextForm::Raw),
2010                views: Vec::new(),
2011                view: None,
2012                copy: CopyAs::Stored,
2013                indent: false,
2014                unpack: false,
2015            }
2016        }
2017    }
2018}
2019
2020/// An item's one-line preview in a level's list, and its style.
2021fn node_preview(node: &Node, room: usize, ctx: &RenderContext) -> (String, Style) {
2022    let g = crate::glyphs::get();
2023    let plain = Style::default().fg(ctx.text_primary);
2024    let dim = Style::default().fg(ctx.dimmed);
2025    let null = (g.null.to_string(), dim.add_modifier(Modifier::ITALIC));
2026    let budget = room.saturating_mul(4).max(16);
2027    match node {
2028        Node::Native(series) => match series.get(0) {
2029            Ok(AnyValue::Null) | Err(_) => null,
2030            Ok(v) => match empty_preview(&v) {
2031                Some(empty) => (empty, dim),
2032                None => {
2033                    let field = InspectField {
2034                        name: series.name().to_string(),
2035                        dtype: series.dtype().clone(),
2036                        hidden: false,
2037                    };
2038                    (preview(&field, &v, room, ctx), plain)
2039                }
2040            },
2041        },
2042        Node::Json { .. } => match node.json() {
2043            None | Some(JsonValue::Null) => null,
2044            Some(JsonValue::String(s)) if s.is_empty() => ("\"\"".to_string(), dim),
2045            Some(JsonValue::String(s)) => (
2046                exact::preview(exact::prefix(s, budget), g).into_owned(),
2047                plain,
2048            ),
2049            Some(v) => (
2050                exact::preview(&json_text(v, false, budget).0, g).into_owned(),
2051                plain,
2052            ),
2053        },
2054    }
2055}
2056
2057/// The title inside a drill: the row's, then each level's step. When it does not
2058/// fit, the first steps after the row give way to an ellipsis: the row and where
2059/// the drill is now stay.
2060fn drill_title(root: &str, labels: &[&str], max: usize) -> String {
2061    let g = crate::glyphs::get();
2062    let sep = format!(" {} ", g.trail);
2063    let steps: Vec<String> = labels
2064        .iter()
2065        .map(|l| exact::preview(l, g).into_owned())
2066        .collect();
2067    let joined = |skip: usize| {
2068        let mut parts = vec![root.to_string()];
2069        if skip > 0 {
2070            parts.push(g.ellipsis.to_string());
2071        }
2072        parts.extend(steps[skip..].iter().cloned());
2073        parts.join(&sep)
2074    };
2075    for skip in 0..steps.len() {
2076        let title = joined(skip);
2077        if crate::glyphs::cell_width(&title) <= max {
2078            return title;
2079        }
2080    }
2081    let last = joined(steps.len().saturating_sub(1));
2082    crate::glyphs::fit_cells(&last, max, g.ellipsis).into_owned()
2083}
2084
2085/// Draw a level of a drill: its items (a table, for a list of structs) above the
2086/// focused item's value.
2087fn render_drill(
2088    area: Rect,
2089    buf: &mut Buffer,
2090    modal: &mut InspectorModal,
2091    root_title: &str,
2092    ctx: &RenderContext,
2093) {
2094    let g = crate::glyphs::get();
2095    let Some(drill) = modal.drill.clone() else {
2096        return;
2097    };
2098    let level = drill.level();
2099    let node = &level.node;
2100    let shape = node.shape();
2101    let len = node.len();
2102    let selected = level.selected.min(len.saturating_sub(1));
2103    let focused = node.child(selected);
2104    let content = Surface::content_area(area);
2105    let columns = node.table_columns();
2106    let header = usize::from(columns.is_some());
2107    // Stacked at every width: a table of items wants the width.
2108    let stacked = Rect {
2109        width: content.width.min(WIDE as u16 - 1),
2110        ..content
2111    };
2112    let full = pane_width(content);
2113    let width = full.min(MEASURE);
2114
2115    let pane = match &focused {
2116        Some((label, child)) => {
2117            let key = PaneKey {
2118                frame: drill.frame,
2119                row: drill.row,
2120                field: drill.item_key(label),
2121                view: modal.view,
2122                width: full as u16,
2123                state: 5,
2124                pretty: 0,
2125                unpacked: 0,
2126            };
2127            match &modal.pane {
2128                Some((cached, pane)) if *cached == key => pane.clone(),
2129                _ => {
2130                    let mut built = node_pane(child, modal.view, full);
2131                    built.id = renewed(modal, &key, &built);
2132                    modal.pane = Some((key, built.clone()));
2133                    built
2134                }
2135            }
2136        }
2137        None => Pane::lines(
2138            vec![(
2139                format!("No {}", shape.items_title().to_lowercase()),
2140                Tone::Dim,
2141            )],
2142            Vec::new(),
2143        ),
2144    };
2145    modal.reader.prepare(pane.id, width, modal.wrap);
2146    let need = modal
2147        .reader
2148        .rows_needed(&pane.content, content.height as usize);
2149    let mut lay = layout(
2150        Rect {
2151            height: content.height.saturating_sub(header as u16),
2152            ..stacked
2153        },
2154        len,
2155        need,
2156        ListShape {
2157            fields: len,
2158            min_col: 1,
2159            single: true,
2160            bytes: false,
2161        },
2162    );
2163    // The table's header takes the row under the rule; the rest move down one.
2164    lay.list.y += header as u16;
2165    lay.value_rule.y += header as u16;
2166    lay.value.y += header as u16;
2167    lay.value_rule.width = content.width;
2168    lay.value.width = content.width;
2169    lay.list.width = content.width;
2170    lay.list_rule.width = content.width;
2171    let list_h = lay.list.height as usize;
2172    let value_rows = lay.value.height as usize;
2173    let overflows = need > value_rows || modal.reader.window(&pane.content, value_rows).above;
2174    let opens = focused.as_ref().is_some_and(|(label, child)| {
2175        child.opens() && !modal.known_not_json(drill.frame, drill.row, &drill.item_key(label))
2176    });
2177
2178    let mut bar = HintBar::from_ctx(ctx);
2179    if modal.focus == Focus::Value {
2180        bar = footer(
2181            modal,
2182            &pane,
2183            &FooterFacts {
2184                enter: None,
2185                read_key: false,
2186                has_value: focused.is_some(),
2187                many_fields: len > 1,
2188                list_overflows: false,
2189                value_overflows: overflows,
2190                comparing: false,
2191            },
2192            ctx,
2193        );
2194    } else {
2195        if opens {
2196            bar = bar.hint_weighted("Enter", "Open", 9);
2197        }
2198        if focused.is_some() {
2199            bar = bar
2200                .hint_weighted("Tab", "Value", 8)
2201                .hint_weighted("y", "Copy", 7);
2202        }
2203        if len > 1 {
2204            let word = match shape {
2205                Shape::Struct => "Field",
2206                Shape::Object => "Key",
2207                _ => "Item",
2208            };
2209            bar = bar.hint_weighted(g.updown, word, 6);
2210        }
2211        if let Some(view) = pane.next_view() {
2212            bar = bar.hint_weighted("e", view.label(), 3);
2213        }
2214        if len > list_h {
2215            bar = bar.hint_weighted("PgUp/PgDn", "Page", 2);
2216        }
2217        bar = bar.hint_weighted("Esc", "Back", 10);
2218    }
2219
2220    let labels: Vec<&str> = drill.levels.iter().map(|l| l.label.as_str()).collect();
2221    let title = drill_title(root_title, &labels, area.width.saturating_sub(4) as usize);
2222    let content = Surface::new(&title).footer(&bar).render(area, buf, ctx);
2223    if content.height < 4 || content.width < 12 {
2224        return;
2225    }
2226    let count = thousands(len);
2227    SectionRule {
2228        title: shape.items_title(),
2229        chip: Some(&count),
2230    }
2231    .render(lay.list_rule, buf, ctx);
2232
2233    let offset = list_window(len, selected, modal.list_offset, list_h).0;
2234    modal.list_offset = offset;
2235    modal.list_page = list_h.saturating_sub(2).max(1);
2236    let below = len.saturating_sub(offset + list_h);
2237    let page = node.children(offset, list_h);
2238    let rows = ListRows {
2239        y: lay.list.y,
2240        offset,
2241        selected,
2242        below,
2243        list_h,
2244        focused: modal.focus == Focus::List,
2245    };
2246    match columns {
2247        Some(columns) => draw_table(buf, content, node, &columns, &page, &rows, ctx),
2248        None => draw_items(buf, content, node, &page, &rows, ctx),
2249    }
2250
2251    let name = focused.map(|(label, _)| label).unwrap_or_default();
2252    draw_value(buf, &lay, &name, &pane, modal, ctx);
2253}
2254
2255/// Where a level's items are drawn, and which of them.
2256struct ListRows {
2257    y: u16,
2258    offset: usize,
2259    selected: usize,
2260    /// Items past the last row drawn.
2261    below: usize,
2262    list_h: usize,
2263    focused: bool,
2264}
2265
2266impl ListRows {
2267    /// The `i`th row drawn is the last, with items under it: it counts them instead.
2268    fn more_at(&self, i: usize) -> bool {
2269        i + 1 == self.list_h && self.below > 0 && self.offset + i != self.selected
2270    }
2271
2272    fn more_line(&self) -> String {
2273        format!(
2274            "  {} {} more",
2275            crate::glyphs::get().ellipsis,
2276            thousands(self.below + 1)
2277        )
2278    }
2279}
2280
2281/// The rail and the label at the start of an item's row, and the row's style.
2282fn item_head(
2283    label: &str,
2284    label_w: usize,
2285    name_style: Style,
2286    is_selected: bool,
2287    focused: bool,
2288    ctx: &RenderContext,
2289) -> Vec<Span<'static>> {
2290    let g = crate::glyphs::get();
2291    let label = exact::preview(label, g);
2292    let label = crate::glyphs::fit_cells(&label, label_w, g.ellipsis).into_owned();
2293    let pad = label_w.saturating_sub(crate::glyphs::cell_width(&label));
2294    let name_style = if is_selected {
2295        Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD)
2296    } else {
2297        name_style
2298    };
2299    vec![
2300        Span::styled(
2301            if is_selected && focused { g.rail } else { " " },
2302            Style::default().fg(ctx.accent),
2303        ),
2304        Span::styled(label, name_style),
2305        Span::raw(" ".repeat(pad + GAP)),
2306    ]
2307}
2308
2309/// A struct's fields, a list's items or an object's keys: name, type, preview.
2310fn draw_items(
2311    buf: &mut Buffer,
2312    content: Rect,
2313    node: &Node,
2314    page: &[(String, Node)],
2315    rows: &ListRows,
2316    ctx: &RenderContext,
2317) {
2318    let g = crate::glyphs::get();
2319    let width = content.width as usize;
2320    // An object's keys are measured only up to its first thousand, so a page past
2321    // them is measured too: a wider key there would be cut to the same few cells.
2322    let page_w = page
2323        .iter()
2324        .map(|(label, _)| crate::glyphs::cell_width(&exact::preview(label, g)))
2325        .max()
2326        .unwrap_or(0);
2327    let label_w = node
2328        .label_width()
2329        .max(page_w)
2330        .clamp(3, (width / 3).clamp(4, 28));
2331    // Measured over the level, not the page, so scrolling moves no column.
2332    let type_w = match node.shape() {
2333        Shape::Object | Shape::Array => "object".len(),
2334        Shape::List => node
2335            .child(0)
2336            .map_or(0, |(_, c)| crate::glyphs::cell_width(&c.type_label())),
2337        _ => node
2338            .children(0, usize::MAX)
2339            .iter()
2340            .map(|(_, c)| crate::glyphs::cell_width(&c.type_label()))
2341            .max()
2342            .unwrap_or(0),
2343    }
2344    .min(14);
2345    let preview_w = width.saturating_sub(1 + label_w + GAP + type_w + GAP);
2346    for (i, (label, child)) in page.iter().enumerate() {
2347        let y = rows.y + i as u16;
2348        let at = Rect {
2349            y,
2350            height: 1,
2351            ..content
2352        };
2353        if rows.more_at(i) {
2354            Paragraph::new(rows.more_line())
2355                .style(Style::default().fg(ctx.dimmed))
2356                .render(at, buf);
2357            break;
2358        }
2359        let is_selected = rows.offset + i == rows.selected;
2360        let name_style = if ctx.column_colors {
2361            Style::default().fg(ctx.type_color(&child.color_dtype()))
2362        } else {
2363            Style::default().fg(ctx.text_primary)
2364        };
2365        let mut spans = item_head(label, label_w, name_style, is_selected, rows.focused, ctx);
2366        let kind = crate::glyphs::fit_cells(&child.type_label(), type_w, g.ellipsis).into_owned();
2367        let kind_pad = type_w.saturating_sub(crate::glyphs::cell_width(&kind));
2368        spans.push(Span::styled(kind, Style::default().fg(ctx.dimmed)));
2369        spans.push(Span::raw(" ".repeat(kind_pad + GAP)));
2370        let (text, style) = node_preview(child, preview_w, ctx);
2371        let text = crate::glyphs::fit_cells(&text, preview_w, g.ellipsis).into_owned();
2372        spans.push(Span::styled(text, style));
2373        let mut paragraph = Paragraph::new(Line::from(spans));
2374        if is_selected && rows.focused {
2375            paragraph = paragraph.style(ctx.highlight_style());
2376        }
2377        paragraph.render(at, buf);
2378    }
2379}
2380
2381/// Items measured for a table's column widths: the first ones, so scrolling the
2382/// table moves no column.
2383const TABLE_SAMPLE: usize = 32;
2384/// The widest a table column grows.
2385const TABLE_CELL_MAX: usize = 24;
2386
2387/// A list of structs or an array of objects as a table: a header of the fields,
2388/// then a row per item, as many columns as fit.
2389fn draw_table(
2390    buf: &mut Buffer,
2391    content: Rect,
2392    node: &Node,
2393    columns: &[(String, DataType)],
2394    page: &[(String, Node)],
2395    rows: &ListRows,
2396    ctx: &RenderContext,
2397) {
2398    let g = crate::glyphs::get();
2399    let width = content.width as usize;
2400    let label_w = node.label_width().max(3);
2401    let sample = node.children(0, TABLE_SAMPLE);
2402    let cell_text = |item: &Node, column: &str, room: usize| -> (String, Style) {
2403        if item.shape() == Shape::Leaf {
2404            // A null item, or one that is not an object, fills its row's first cell.
2405            return node_preview(item, room, ctx);
2406        }
2407        match item.cell(column) {
2408            Some(cell) => node_preview(&cell, room, ctx),
2409            None => (g.absent.to_string(), Style::default().fg(ctx.dimmed)),
2410        }
2411    };
2412    let mut widths = Vec::new();
2413    let mut used = 1 + label_w + GAP;
2414    for (column, _) in columns {
2415        let w = sample
2416            .iter()
2417            .map(|(_, item)| crate::glyphs::cell_width(&cell_text(item, column, TABLE_CELL_MAX).0))
2418            .chain(std::iter::once(crate::glyphs::cell_width(column)))
2419            .max()
2420            .unwrap_or(0)
2421            .clamp(3, TABLE_CELL_MAX);
2422        if used + w > width {
2423            break;
2424        }
2425        used += w + GAP;
2426        widths.push(w);
2427    }
2428    // At least one column, cut to the room there is.
2429    if widths.is_empty() {
2430        widths.push(width.saturating_sub(1 + label_w + GAP).max(1));
2431    }
2432    let hidden = columns.len() - widths.len();
2433
2434    let cell = |text: &str, w: usize| {
2435        let text = crate::glyphs::fit_cells(text, w, g.ellipsis).into_owned();
2436        let pad = w.saturating_sub(crate::glyphs::cell_width(&text));
2437        (text, " ".repeat(pad + GAP))
2438    };
2439    let mut head = vec![Span::raw(" ".repeat(1 + label_w + GAP))];
2440    for ((column, dtype), &w) in columns.iter().zip(&widths) {
2441        let (text, pad) = cell(&exact::preview(column, g), w);
2442        // A column's name takes its type's color, as the table's header does.
2443        let fg = if ctx.column_colors {
2444            ctx.type_color(dtype)
2445        } else {
2446            ctx.dimmed
2447        };
2448        head.push(Span::styled(
2449            text,
2450            Style::default().fg(fg).add_modifier(Modifier::BOLD),
2451        ));
2452        head.push(Span::raw(pad));
2453    }
2454    if hidden > 0 {
2455        head.push(Span::styled(
2456            format!("+{hidden}"),
2457            Style::default().fg(ctx.dimmed),
2458        ));
2459    }
2460    Paragraph::new(Line::from(head)).render(
2461        Rect {
2462            // The header stands on the row above the items.
2463            y: rows.y - 1,
2464            height: 1,
2465            ..content
2466        },
2467        buf,
2468    );
2469
2470    for (i, (label, item)) in page.iter().enumerate() {
2471        let at = Rect {
2472            y: rows.y + i as u16,
2473            height: 1,
2474            ..content
2475        };
2476        if rows.more_at(i) {
2477            Paragraph::new(rows.more_line())
2478                .style(Style::default().fg(ctx.dimmed))
2479                .render(at, buf);
2480            break;
2481        }
2482        let is_selected = rows.offset + i == rows.selected;
2483        let mut spans = item_head(
2484            label,
2485            label_w,
2486            Style::default().fg(ctx.dimmed),
2487            is_selected,
2488            rows.focused,
2489            ctx,
2490        );
2491        for (k, ((column, _), &w)) in columns.iter().zip(&widths).enumerate() {
2492            if k > 0 && item.shape() == Shape::Leaf {
2493                break;
2494            }
2495            let (text, style) = cell_text(item, column, w);
2496            let (text, pad) = cell(&text, w);
2497            spans.push(Span::styled(text, style));
2498            spans.push(Span::raw(pad));
2499        }
2500        let mut paragraph = Paragraph::new(Line::from(spans));
2501        if is_selected && rows.focused {
2502            paragraph = paragraph.style(ctx.highlight_style());
2503        }
2504        paragraph.render(at, buf);
2505    }
2506}
2507
2508/// The row as one JSON object, field by field in the table's order: numbers
2509/// exact, text and dates as strings, lists and structs as JSON, bytes as base64.
2510/// Fields not read are left out and counted.
2511pub fn row_json(
2512    fields: &[InspectField],
2513    row: &InspectRow,
2514    read: Option<&FieldRead>,
2515) -> (String, usize, usize) {
2516    let mut out = String::from("{");
2517    let (mut kept, mut unread) = (0usize, 0usize);
2518    for field in fields {
2519        let column = if field.buffered() {
2520            row.values.column(&field.name).ok().cloned()
2521        } else {
2522            match read {
2523                Some(FieldRead::Read { values, .. })
2524                    if read.is_some_and(|r| r.key() == (row.frame, row.row)) =>
2525                {
2526                    values.column(&field.name).ok().cloned()
2527                }
2528                _ => None,
2529            }
2530        };
2531        let Some(column) = column else {
2532            unread += 1;
2533            continue;
2534        };
2535        let Ok(value) = column.get(0) else {
2536            unread += 1;
2537            continue;
2538        };
2539        if kept > 0 {
2540            out.push_str(", ");
2541        }
2542        out.push_str(&serde_json::to_string(&field.name).unwrap_or_default());
2543        out.push_str(": ");
2544        out.push_str(&json_value_text(&column, &value));
2545        kept += 1;
2546    }
2547    out.push('}');
2548    (out, kept, unread)
2549}
2550
2551/// One value as JSON: exact numbers bare, everything else as the exact text in
2552/// quotes, nested values as the JSON a copy writes.
2553fn json_value_text(column: &Column, value: &AnyValue) -> String {
2554    let quoted = |s: &str| serde_json::to_string(s).unwrap_or_default();
2555    match value {
2556        AnyValue::Null => "null".to_string(),
2557        AnyValue::Boolean(b) => b.to_string(),
2558        AnyValue::Int8(_)
2559        | AnyValue::Int16(_)
2560        | AnyValue::Int32(_)
2561        | AnyValue::Int64(_)
2562        | AnyValue::Int128(_)
2563        | AnyValue::UInt8(_)
2564        | AnyValue::UInt16(_)
2565        | AnyValue::UInt32(_)
2566        | AnyValue::UInt64(_) => exact::value_text(value),
2567        AnyValue::Float32(_) | AnyValue::Float64(_) => {
2568            let text = exact::value_text(value);
2569            // NaN and the infinities have no JSON number.
2570            if text.parse::<f64>().is_ok_and(f64::is_finite) {
2571                text
2572            } else {
2573                quoted(&text)
2574            }
2575        }
2576        v if exact::is_nested_value(v) => {
2577            exact::copy_text(column).unwrap_or_else(|_| "null".to_string())
2578        }
2579        v => quoted(&exact::value_text(v)),
2580    }
2581}
2582
2583#[cfg(test)]
2584mod tests {
2585    use super::*;
2586
2587    fn ask(width: usize) -> PaneAsk<'static> {
2588        PaneAsk {
2589            choice: None,
2590            width,
2591            table: None,
2592            indented: Indented::None,
2593            not_json: false,
2594            unpacked: Unpacked::None,
2595            read_key: "Enter",
2596        }
2597    }
2598
2599    fn lines(p: &Pane) -> Vec<String> {
2600        let mut r = reader::Reader::default();
2601        r.prepare(1, 100, reader::Wrap::Word);
2602        r.window(&p.content, 1000)
2603            .rows
2604            .into_iter()
2605            .map(|s| s.text)
2606            .collect()
2607    }
2608
2609    #[test]
2610    fn a_float_shows_exact_and_what_the_table_rounds_it_to() {
2611        let p = pane(
2612            &DataType::Float64,
2613            &Shown::Value(AnyValue::Float64(1000000.125)),
2614            &PaneAsk {
2615                table: Some("1.0000e6"),
2616                ..ask(40)
2617            },
2618        );
2619        assert_eq!(lines(&p), ["1000000.125", "", "In the table: 1.0000e6"]);
2620        assert_eq!(p.facts, "f64");
2621        let p = pane(
2622            &DataType::Float64,
2623            &Shown::Value(AnyValue::Float64(2.5)),
2624            &PaneAsk {
2625                table: Some("2.5"),
2626                ..ask(40)
2627            },
2628        );
2629        assert_eq!(lines(&p), ["2.5"]);
2630        assert!(p.views.is_empty(), "a number has one view");
2631    }
2632
2633    #[test]
2634    fn text_reads_raw_or_escaped_and_names_its_facts() {
2635        let value = Shown::Value(AnyValue::String("line1\nline2\ttab\\n"));
2636        let raw = pane(&DataType::String, &value, &ask(40));
2637        assert_eq!(lines(&raw), ["line1", "line2   tab\\n"]);
2638        let m = crate::glyphs::get().middot;
2639        assert_eq!(raw.facts, format!("str {m} 17 chars {m} 2 lines"));
2640        assert_eq!(raw.views, [View::Raw, View::Escaped]);
2641        assert_eq!(raw.next_view(), Some(View::Escaped));
2642        let esc = pane(
2643            &DataType::String,
2644            &value,
2645            &PaneAsk {
2646                choice: Some(View::Escaped),
2647                ..ask(40)
2648            },
2649        );
2650        assert_eq!(lines(&esc), [r#""line1\nline2\ttab\\n""#]);
2651        assert!(esc.facts.ends_with("escaped"));
2652        assert_eq!(esc.next_view(), Some(View::Raw));
2653    }
2654
2655    #[test]
2656    fn empty_text_and_edge_spaces_are_named() {
2657        let empty = pane(
2658            &DataType::String,
2659            &Shown::Value(AnyValue::String("")),
2660            &ask(40),
2661        );
2662        assert_eq!(lines(&empty), ["empty string"]);
2663        let esc = pane(
2664            &DataType::String,
2665            &Shown::Value(AnyValue::String("")),
2666            &PaneAsk {
2667                choice: Some(View::Escaped),
2668                ..ask(40)
2669            },
2670        );
2671        assert_eq!(lines(&esc), ["\"\""]);
2672        let padded = pane(
2673            &DataType::String,
2674            &Shown::Value(AnyValue::String("  x ")),
2675            &ask(40),
2676        );
2677        assert!(
2678            padded.facts.contains("2 leading spaces"),
2679            "{}",
2680            padded.facts
2681        );
2682        assert!(
2683            padded.facts.contains("1 trailing space"),
2684            "{}",
2685            padded.facts
2686        );
2687    }
2688
2689    #[test]
2690    fn a_null_is_not_an_empty_string_and_says_which_null() {
2691        let g = crate::glyphs::get();
2692        let null = pane(&DataType::String, &Shown::Null(NullKind::Null), &ask(60));
2693        assert_eq!(lines(&null), [format!("{} null", g.null)]);
2694        let absent = pane(&DataType::String, &Shown::Null(NullKind::Absent), &ask(80));
2695        assert!(lines(&absent)[0].starts_with(&format!("{} absent", g.absent)));
2696    }
2697
2698    /// Improvement 5: JSON text reads indented, with `json` on the rule; `e`
2699    /// cycles JSON, raw and escaped, and copy follows the view.
2700    #[test]
2701    fn json_text_reads_indented_and_cycles_its_views() {
2702        let doc = r#"{"order": {"id": 1, "items": [{"sku": "A1"}]}, "flags": ["vip"]}"#;
2703        let p = pane(
2704            &DataType::String,
2705            &Shown::Value(AnyValue::String(doc)),
2706            &ask(80),
2707        );
2708        assert_eq!(p.views, [View::Json, View::Raw, View::Escaped]);
2709        assert_eq!(p.view, Some(View::Json));
2710        assert!(p.facts.ends_with("json"), "{}", p.facts);
2711        assert_eq!(lines(&p)[..3], ["{", "  \"order\": {", "    \"id\": 1,"]);
2712        assert!(matches!(p.copy, CopyAs::Text(_)));
2713        assert_eq!(p.next_view(), Some(View::Raw));
2714        // Text that only looks like JSON has no JSON view.
2715        let bad = pane(
2716            &DataType::String,
2717            &Shown::Value(AnyValue::String("{not json}")),
2718            &ask(80),
2719        );
2720        assert_eq!(bad.views, [View::Raw, View::Escaped]);
2721        // Long JSON waits on a worker, raw meanwhile.
2722        let long = format!("[{}0]", "1, ".repeat(30_000));
2723        let p = pane(
2724            &DataType::String,
2725            &Shown::Value(AnyValue::String(&long)),
2726            &ask(80),
2727        );
2728        assert!(p.indent && p.facts.ends_with("indenting..."), "{}", p.facts);
2729    }
2730
2731    /// M4: bytes say their size in human units and what they are; UTF-8 bytes
2732    /// read as text by default, gzip offers its text, and hex is 32 bytes a row
2733    /// where that fits.
2734    #[test]
2735    fn bytes_are_sized_sniffed_and_read_as_text_where_they_are() {
2736        let m = crate::glyphs::get().middot;
2737        let utf8 = "Grüße\nsecond line".as_bytes();
2738        let p = pane(
2739            &DataType::Binary,
2740            &Shown::Value(AnyValue::Binary(utf8)),
2741            &ask(80),
2742        );
2743        assert_eq!(p.views, [View::Text, View::Hex, View::Escaped]);
2744        assert_eq!(lines(&p), ["Grüße", "second line"]);
2745        assert!(p.facts.contains("UTF-8 text"), "{}", p.facts);
2746
2747        let big = vec![0x90u8; 1 << 20];
2748        let p = pane(
2749            &DataType::Binary,
2750            &Shown::Value(AnyValue::Binary(&big)),
2751            &ask(140),
2752        );
2753        assert_eq!(p.facts, format!("binary {m} 1.0 MB (1,048,576 bytes)"));
2754        assert_eq!(p.views, [View::Hex, View::Escaped]);
2755        assert!(matches!(p.content, Content::Hex { per_line: 32, .. }));
2756        assert!(matches!(p.copy, CopyAs::Base64));
2757
2758        let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2759        std::io::Write::write_all(&mut e, b"hello gzip").unwrap();
2760        let gz = e.finish().unwrap();
2761        let p = pane(
2762            &DataType::Binary,
2763            &Shown::Value(AnyValue::Binary(&gz)),
2764            &ask(80),
2765        );
2766        assert!(p.facts.ends_with("gzip"), "{}", p.facts);
2767        assert_eq!(p.views, [View::Hex, View::Text, View::Escaped]);
2768        // Its Text view asks a worker to decompress it, and shows what it answers;
2769        // building the pane decompresses nothing.
2770        let text = pane(
2771            &DataType::Binary,
2772            &Shown::Value(AnyValue::Binary(&gz)),
2773            &PaneAsk {
2774                choice: Some(View::Text),
2775                ..ask(80)
2776            },
2777        );
2778        assert!(text.unpack);
2779        assert_eq!(lines(&text), ["Decompressing..."]);
2780        let decoded = inspector_bytes::decode_text(&gz, inspector_bytes::sniff(&gz)).unwrap();
2781        let text = pane(
2782            &DataType::Binary,
2783            &Shown::Value(AnyValue::Binary(&gz)),
2784            &PaneAsk {
2785                choice: Some(View::Text),
2786                unpacked: Unpacked::Ready(Arc::new(decoded)),
2787                ..ask(80)
2788            },
2789        );
2790        assert!(!text.unpack);
2791        assert_eq!(lines(&text), ["hello gzip"]);
2792        // Bytes that do not decompress to text lose the Text view, and say so.
2793        let failed = pane(
2794            &DataType::Binary,
2795            &Shown::Value(AnyValue::Binary(&gz)),
2796            &PaneAsk {
2797                choice: Some(View::Text),
2798                unpacked: Unpacked::Failed,
2799                ..ask(80)
2800            },
2801        );
2802        assert_eq!(failed.views, [View::Hex, View::Escaped]);
2803        assert_eq!(failed.view, Some(View::Hex));
2804        assert!(failed.facts.ends_with("not text"), "{}", failed.facts);
2805
2806        let empty = pane(
2807            &DataType::Binary,
2808            &Shown::Value(AnyValue::Binary(b"")),
2809            &ask(80),
2810        );
2811        assert_eq!(lines(&empty), ["empty binary"]);
2812        assert!(empty.facts.contains("empty"));
2813    }
2814
2815    #[test]
2816    fn empty_values_are_visible_in_the_list() {
2817        let g = crate::glyphs::get();
2818        assert_eq!(
2819            empty_preview(&AnyValue::String("")).as_deref(),
2820            Some("\"\"")
2821        );
2822        assert_eq!(empty_preview(&AnyValue::String(" ")), None);
2823        assert_eq!(
2824            empty_preview(&AnyValue::Binary(b"")),
2825            Some(format!("0 bytes {} empty", g.middot))
2826        );
2827        assert_eq!(fill_of(&Shown::Value(AnyValue::String(""))), Fill::Empty);
2828        assert_eq!(fill_of(&Shown::Null(NullKind::Null)), Fill::Null);
2829        assert_eq!(fill_of(&Shown::Unread), Fill::Unknown);
2830    }
2831
2832    #[test]
2833    fn values_differ_by_their_exact_text() {
2834        let v = |x: f64| Shown::Value(AnyValue::Float64(x));
2835        assert_eq!(differs(&v(1.0), &v(1.0)), Some(false));
2836        assert_eq!(differs(&v(f64::NAN), &v(f64::NAN)), Some(false));
2837        assert_eq!(differs(&v(1.0), &v(2.0)), Some(true));
2838        assert_eq!(differs(&v(1.0), &Shown::Null(NullKind::Null)), Some(true));
2839        assert_eq!(differs(&v(1.0), &Shown::Unread), None);
2840    }
2841
2842    /// The list counts what is above and below, and the focus is always among
2843    /// the fields shown.
2844    #[test]
2845    fn the_list_window_keeps_the_focus_and_marks_both_ends() {
2846        assert_eq!(list_window(10, 3, 0, 20), (0, false, false));
2847        // 214 fields in 20 slots: the last slot counts the rest.
2848        let (o, above, below) = list_window(214, 0, 0, 20);
2849        assert_eq!((o, above, below), (0, false, true));
2850        let (o, above, below) = list_window(214, 19, 0, 20);
2851        assert!(above && below);
2852        assert!(19 >= o && 19 < o + 18, "{o}");
2853        let (o, above, below) = list_window(214, 213, 0, 20);
2854        assert!(above && !below);
2855        assert_eq!(o, 214 - 19);
2856        for sel in 0..214 {
2857            let (o, above, below) = list_window(214, sel, 100, 20);
2858            let items = 20 - usize::from(above) - usize::from(below);
2859            assert!(sel >= o && sel < o + items, "{sel}: {o}");
2860        }
2861    }
2862
2863    /// M2: a short row lists whole at 80x24; a long one shares the rows and the
2864    /// value keeps what its lines need; from 140 columns the panes sit side by
2865    /// side at full height, and the fields flow into columns that fit.
2866    #[test]
2867    fn the_layout_fits_the_row_and_the_terminal() {
2868        let list = |fields: usize, min_col: usize| ListShape {
2869            fields,
2870            min_col,
2871            single: false,
2872            bytes: false,
2873        };
2874        // 80x24: the Surface's content is 76x20, 18 rows past the two rules.
2875        let content = Rect::new(2, 1, 76, 20);
2876        let l = layout(content, 14, 1, list(14, 40));
2877        assert!(!l.wide);
2878        assert_eq!(l.list.height, 14, "every field listed");
2879        assert_eq!(l.value.height, 4);
2880        let l = layout(content, 214, 1, list(214, 40));
2881        assert_eq!(
2882            (l.list.height, l.value.height),
2883            (15, 3),
2884            "the value keeps three"
2885        );
2886        let l = layout(content, 214, 40, list(214, 40));
2887        assert_eq!(l.value.height, 9, "a long value takes half");
2888        // Two fields and a long value: the list takes its two rows, the value the rest.
2889        let l = layout(content, 2, 1_000, list(2, 40));
2890        assert_eq!((l.list.height, l.value.height), (2, 16));
2891        // 200x50: side by side. 214 fields take three columns and the value
2892        // narrows to its floor; a short row leaves the value its measure.
2893        let content = Rect::new(2, 1, 196, 46);
2894        let l = layout(content, 214, 1, list(214, 44));
2895        assert!(l.wide);
2896        assert_eq!(l.value.height, 45);
2897        assert_eq!(l.cols, 3, "{l:?}");
2898        assert!(l.value.width as usize >= VALUE_FLOOR, "{l:?}");
2899        assert!(l.cols * l.list.height as usize >= 130);
2900        let l = layout(content, 14, 1, list(14, 44));
2901        assert_eq!((l.cols, l.value.width), (1, 88), "{l:?}");
2902        // Narrowing the list (a find, hidden nulls) moves nothing: the pane is sized
2903        // from every field.
2904        let narrowed = layout(content, 3, 1, list(214, 44));
2905        assert_eq!(narrowed.value, layout(content, 214, 1, list(214, 44)).value);
2906        // 300x80: every one of 214 fields; a row with bytes gives the value a
2907        // 32-byte hex row.
2908        let content = Rect::new(2, 1, 296, 76);
2909        let l = layout(content, 214, 1, list(214, 45));
2910        assert!(l.cols * l.list.height as usize >= 214, "{l:?}");
2911        assert_eq!(l.value.width as usize, MEASURE + 1);
2912        let bytes = ListShape {
2913            bytes: true,
2914            ..list(214, 45)
2915        };
2916        let l = layout(content, 214, 1, bytes);
2917        assert_eq!(l.value.width as usize, HEX_WIDE);
2918        assert_eq!(reader::hex_per_line(pane_width(l.value)), 32);
2919        assert!(l.cols * l.list.height as usize >= 214, "{l:?}");
2920        // Compare keeps the list one column and the value its measure.
2921        let compare = ListShape {
2922            single: true,
2923            ..bytes
2924        };
2925        let l = layout(content, 214, 1, compare);
2926        assert_eq!((l.cols, l.value.width as usize), (1, MEASURE + 1));
2927        // A short row keeps one column.
2928        let l = layout(content, 14, 1, list(14, 45));
2929        assert_eq!(l.cols, 1);
2930        // 140 columns: side by side.
2931        assert!(layout(Rect::new(2, 1, 136, 30), 14, 1, list(14, 45)).wide);
2932        assert!(!layout(Rect::new(2, 1, 135, 30), 14, 1, list(14, 45)).wide);
2933    }
2934
2935    /// A long trail keeps the row and where the drill is now; the steps between
2936    /// give way to an ellipsis.
2937    #[test]
2938    fn a_long_trail_drops_its_middle_steps() {
2939        let g = crate::glyphs::get();
2940        let t = g.trail;
2941        let labels = ["customer", "address", "geo", "point"];
2942        let whole = drill_title("Row 1 of 5", &labels, 200);
2943        assert_eq!(
2944            whole,
2945            format!("Row 1 of 5 {t} customer {t} address {t} geo {t} point")
2946        );
2947        let cut = drill_title("Row 1 of 5", &labels, 34);
2948        assert_eq!(
2949            cut,
2950            format!("Row 1 of 5 {t} {} {t} geo {t} point", g.ellipsis)
2951        );
2952        let marked = drill_title("Row 1", &["a\nb"], 40);
2953        assert!(!marked.contains('\n'), "{marked}");
2954    }
2955
2956    #[test]
2957    fn json_values_show_as_themselves() {
2958        let m = crate::glyphs::get().middot;
2959        let doc: JsonValue = serde_json::from_str(r#"{"a": [1, 2], "s": "x\ny"}"#).unwrap();
2960        let b = json_pane(&doc, &ask(40));
2961        assert_eq!(b.facts, format!("object {m} 2 keys"));
2962        assert_eq!(lines(&b)[..2], ["{", "  \"a\": ["]);
2963        let s = json_pane(&doc["s"], &ask(40));
2964        assert_eq!(lines(&s), ["x", "y"]);
2965        assert!(s.views.contains(&View::Escaped), "text has an escaped form");
2966        let n = json_pane(&JsonValue::from(1.5), &ask(40));
2967        assert_eq!(
2968            (lines(&n), n.facts.as_str()),
2969            (vec!["1.5".to_string()], "number")
2970        );
2971    }
2972
2973    #[test]
2974    fn types_name_their_unit_and_zone_in_ascii() {
2975        let tz = TimeZone::opt_try_new(Some("Europe/Paris")).unwrap();
2976        assert_eq!(
2977            type_text(&DataType::Datetime(TimeUnit::Microseconds, tz)),
2978            "datetime[us, Europe/Paris]"
2979        );
2980        assert_eq!(type_text(&DataType::Decimal(10, 4)), "decimal(10,4)");
2981    }
2982
2983    #[test]
2984    fn sizes_read_in_human_units_and_exactly() {
2985        assert_eq!(size_text(73), "73 bytes");
2986        assert_eq!(size_text(1), "1 byte");
2987        assert_eq!(size_text(4 << 20), "4.0 MB (4,194,304 bytes)");
2988    }
2989}