Skip to main content

datui_lib/widgets/
inspector.rs

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