Skip to main content

datui_lib/inspector/
inspector_modal.rs

1//! Row inspector state: the focused and listed fields, list or value focus, the
2//! find text, the value's view and read position, the compared row, and fields read
3//! for this row that the buffer lacks. Values are not kept here: the inspector
4//! draws the table's selected row from its buffer every frame.
5
6use crate::inspector::inspector_drill::{Drill, JsonWait, Level, Node};
7use crate::inspector::inspector_reader::{Reader, Wrap};
8use crate::table::{InspectField, InspectRow};
9use crate::widgets::inspector::Pane;
10use polars::prelude::DataFrame;
11use std::sync::Arc;
12
13/// The most bytes of a value one key formats: a nested value or a JSON document
14/// laid out in the pane stops here, and the reader wraps no more than this of a
15/// long text for a key.
16pub const CHUNK_BYTES: usize = 16 * 1024;
17
18/// The fields of one row that the buffer does not hold, read on request.
19#[derive(Debug, Clone)]
20pub enum FieldRead {
21    /// Asked for; the worker is reading.
22    Reading { frame: u64, row: usize },
23    /// One row, the fields read.
24    Read {
25        frame: u64,
26        row: usize,
27        values: DataFrame,
28    },
29    /// The read failed, or found a different row than the table shows.
30    Failed {
31        frame: u64,
32        row: usize,
33        message: String,
34    },
35}
36
37impl FieldRead {
38    /// The frame and row this read is for.
39    pub fn key(&self) -> (u64, usize) {
40        match self {
41            Self::Reading { frame, row }
42            | Self::Read { frame, row, .. }
43            | Self::Failed { frame, row, .. } => (*frame, *row),
44        }
45    }
46}
47
48/// Where the keys go: the field list, or the focused value's pane.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum Focus {
51    #[default]
52    List,
53    Value,
54}
55
56/// The order the fields are listed in.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum Order {
59    /// The table's column order, hidden columns last.
60    #[default]
61    Table,
62    /// By name.
63    Name,
64    /// Fields with a value first, then nulls and empties.
65    Filled,
66}
67
68impl Order {
69    pub fn next(self) -> Self {
70        match self {
71            Order::Table => Order::Name,
72            Order::Name => Order::Filled,
73            Order::Filled => Order::Table,
74        }
75    }
76
77    /// As the list's rule says it; nothing for the table's order.
78    pub fn label(self) -> Option<&'static str> {
79        match self {
80            Order::Table => None,
81            Order::Name => Some("A-Z"),
82            Order::Filled => Some("nulls last"),
83        }
84    }
85}
86
87/// A way of showing a value. Only the views that apply to a value are offered.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum View {
90    /// Text that parses as JSON, indented.
91    Json,
92    /// Text as itself.
93    Raw,
94    /// Text or bytes as an escaped literal.
95    Escaped,
96    /// Bytes as a hex dump.
97    Hex,
98    /// Bytes as the text they hold: UTF-8, or decompressed gzip or zstd.
99    Text,
100}
101
102impl View {
103    pub fn label(self) -> &'static str {
104        match self {
105            View::Json => "JSON",
106            View::Raw => "Raw",
107            View::Escaped => "Escaped",
108            View::Hex => "Hex",
109            View::Text => "Text",
110        }
111    }
112}
113
114/// A search inside the focused value.
115#[derive(Debug, Clone, Default)]
116pub struct ValueFind {
117    pub text: String,
118    /// The find line has the keys.
119    pub editing: bool,
120    /// Where the text is, for the pane `pane`: bytes, or rows of a short value.
121    pub hits: Vec<usize>,
122    pub current: Option<usize>,
123    pub pane: u64,
124}
125
126/// Long JSON text being indented on a worker, by frame, row and the text's place.
127#[derive(Debug, Clone)]
128pub enum Pretty {
129    Pending {
130        token: u64,
131        place: (u64, usize, String),
132    },
133    Ready {
134        place: (u64, usize, String),
135        text: Arc<str>,
136    },
137    Failed {
138        place: (u64, usize, String),
139    },
140}
141
142impl Pretty {
143    pub fn place(&self) -> &(u64, usize, String) {
144        match self {
145            Pretty::Pending { place, .. }
146            | Pretty::Ready { place, .. }
147            | Pretty::Failed { place } => place,
148        }
149    }
150}
151
152/// Bytes decompressed on a worker for their Text view, by frame, row and field.
153#[derive(Debug, Clone)]
154pub enum Unpack {
155    Pending {
156        token: u64,
157        place: (u64, usize, String),
158    },
159    Ready {
160        place: (u64, usize, String),
161        text: Arc<crate::inspector::inspector_bytes::Decoded>,
162    },
163    Failed {
164        place: (u64, usize, String),
165    },
166}
167
168impl Unpack {
169    pub fn place(&self) -> &(u64, usize, String) {
170        match self {
171            Unpack::Pending { place, .. }
172            | Unpack::Ready { place, .. }
173            | Unpack::Failed { place } => place,
174        }
175    }
176}
177
178/// What the value pane was built from: when any of it changes, the pane is
179/// built again, and a long value is not laid out again every frame.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct PaneKey {
182    pub frame: u64,
183    pub row: usize,
184    pub field: String,
185    pub view: Option<View>,
186    pub width: u16,
187    /// What was on hand for the field: a value, a null, or where its read stood.
188    pub state: u8,
189    /// Where an indented copy of long JSON stood: none, asked, ready, failed.
190    pub pretty: u8,
191    /// Where text decompressed from bytes stood, the same way.
192    pub unpacked: u8,
193}
194
195impl PaneKey {
196    /// The same value in the same view, perhaps at another width: a resize keeps
197    /// the pane's place in it.
198    pub fn same_value(&self, other: &Self) -> bool {
199        *self
200            == Self {
201                width: self.width,
202                ..other.clone()
203            }
204    }
205}
206
207#[derive(Default)]
208pub struct InspectorModal {
209    pub fields: Vec<InspectField>,
210    /// The find text over the fields' names, then their values.
211    pub filter: String,
212    /// The find line has the keys.
213    pub finding: bool,
214    /// The focused field, an index into `fields`, while it is listed.
215    selected: usize,
216    /// The fields listed, in the order listed: the find text, the nulls toggle and
217    /// the order applied. Kept by [`Self::set_visible`].
218    pub visible: Vec<usize>,
219    /// The first field listed when the list scrolls.
220    pub list_offset: usize,
221    /// Fields the list showed last frame: a page for PgUp/PgDn.
222    pub list_page: usize,
223    pub order: Order,
224    /// Only fields with a value, or with Compare on, only those that differ.
225    pub filled_only: bool,
226    pub focus: Focus,
227    /// The view chosen with `e`, for the field it was chosen on.
228    pub view: Option<View>,
229    view_field: Option<String>,
230    pub wrap: Wrap,
231    /// Where the value pane is in its value.
232    pub reader: Reader,
233    pub pane: Option<(PaneKey, Pane)>,
234    pane_id: u64,
235    pub value_find: Option<ValueFind>,
236    /// Lines the value pane showed last frame: a page for PgUp/PgDn.
237    pub page: usize,
238    /// The row the pane was last drawn for; a new one starts at its top.
239    pub shown_row: Option<(u64, usize)>,
240    pub read: Option<FieldRead>,
241    /// After Enter read a field, the rows moved to are read too while the focus
242    /// stays on that field.
243    pub follow: Option<String>,
244    /// The list has a column for another row: the pinned one, or the next.
245    pub compare: bool,
246    /// The row `m` pinned for Compare.
247    pub pinned: Option<InspectRow>,
248    /// Compare shows the row before as well as the next: the last frame was
249    /// wide enough for three.
250    pub compare_both: bool,
251    /// The levels opened under the focused field, when Enter drilled into it.
252    pub drill: Option<Drill>,
253    /// Text being parsed as JSON off this thread, to open as a level.
254    pub json_wait: Option<JsonWait>,
255    /// The last [`JsonWait::token`] handed out.
256    pub json_token: u64,
257    /// Text that looked like JSON and did not parse, by frame, row and path: Enter
258    /// there shows it as text, not a second try that fails the same way.
259    pub not_json: Option<(u64, usize, String)>,
260    /// Long JSON text indented off this thread for the JSON view.
261    pub pretty: Option<Pretty>,
262    pub pretty_token: u64,
263    /// Compressed bytes decompressed off this thread for the Text view.
264    pub unpack: Option<Unpack>,
265    pub unpack_token: u64,
266    /// What `visible` was worked out for: the list is worked out again only when
267    /// this changes, not each frame.
268    pub(crate) listed: Option<ListKey>,
269    /// Times the list was worked out, for a test that a frame reuses it.
270    #[cfg(test)]
271    pub(crate) list_builds: usize,
272}
273
274/// Everything the fields listed depend on besides the fields themselves, which only
275/// [`InspectorModal::open`] replaces.
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub(crate) struct ListKey {
278    pub order: Order,
279    pub filled_only: bool,
280    pub filter: String,
281    /// The row shown, by frame and row.
282    pub row: Option<(u64, usize)>,
283    /// The rows on hand, where Compare finds the next and the row before.
284    pub buffered: (usize, usize),
285    pub compare: bool,
286    pub compare_both: bool,
287    pub pinned: Option<(u64, usize)>,
288    /// The fields read for a row, and how far the read got.
289    pub read: Option<((u64, usize), u8)>,
290}
291
292impl InspectorModal {
293    pub fn new() -> Self {
294        Self::default()
295    }
296
297    /// Open over the table's selected row, focused on `current`, the table's column
298    /// cursor; without one, the last field focused while it still exists.
299    pub fn open(&mut self, fields: Vec<InspectField>, current: Option<&str>) {
300        let keep = current
301            .map(str::to_string)
302            .or_else(|| self.focused().map(|f| f.name.clone()))
303            .and_then(|name| fields.iter().position(|f| f.name == name));
304        self.selected = keep.unwrap_or(0);
305        self.visible = (0..fields.len()).collect();
306        self.fields = fields;
307        self.listed = None;
308        self.finding = false;
309        self.focus = Focus::List;
310        self.list_offset = 0;
311        self.read = None;
312        self.follow = None;
313        self.pane = None;
314        self.shown_row = None;
315        self.drill = None;
316        self.json_wait = None;
317        self.not_json = None;
318        self.pretty = None;
319        self.unpack = None;
320        self.value_find = None;
321        self.reader = Reader::default();
322    }
323
324    pub fn close(&mut self) {
325        self.finding = false;
326        self.focus = Focus::List;
327        self.read = None;
328        self.follow = None;
329        self.pane = None;
330        self.drill = None;
331        self.json_wait = None;
332        self.not_json = None;
333        self.pretty = None;
334        self.unpack = None;
335        self.value_find = None;
336    }
337
338    /// A new id for a pane just built: the reader starts at its top.
339    pub fn next_pane_id(&mut self) -> u64 {
340        self.pane_id += 1;
341        self.pane_id
342    }
343
344    /// Where text decompressed from the bytes at `place` stands.
345    pub fn unpacked(&self, place: &(u64, usize, String)) -> crate::widgets::inspector::Unpacked {
346        use crate::widgets::inspector::Unpacked;
347        match &self.unpack {
348            Some(Unpack::Pending { place: p, .. }) if p == place => Unpacked::Pending,
349            Some(Unpack::Ready { place: p, text }) if p == place => Unpacked::Ready(text.clone()),
350            Some(Unpack::Failed { place: p }) if p == place => Unpacked::Failed,
351            _ => Unpacked::None,
352        }
353    }
354
355    /// Whether the text at `path` of row `row` of frame `frame` was found not to be JSON.
356    pub fn known_not_json(&self, frame: u64, row: usize, path: &str) -> bool {
357        self.not_json
358            .as_ref()
359            .is_some_and(|(f, r, p)| (*f, *r) == (frame, row) && p == path)
360    }
361
362    /// The focused field: the one selected while it is listed, else the first
363    /// listed. None when nothing is listed.
364    pub fn focused(&self) -> Option<&InspectField> {
365        self.focused_index().and_then(|i| self.fields.get(i))
366    }
367
368    fn focused_index(&self) -> Option<usize> {
369        if self.visible.contains(&self.selected) {
370            Some(self.selected)
371        } else {
372            self.visible.first().copied()
373        }
374    }
375
376    /// Where the focused field is among those listed.
377    pub fn focused_position(&self) -> usize {
378        self.focused_index()
379            .and_then(|i| self.visible.iter().position(|&v| v == i))
380            .unwrap_or(0)
381    }
382
383    /// The fields listed. A focused field no longer listed gives the focus to the
384    /// first that is.
385    pub fn set_visible(&mut self, visible: Vec<usize>) {
386        if !visible.contains(&self.selected)
387            && let Some(&first) = visible.first()
388        {
389            self.selected = first;
390        }
391        self.visible = visible;
392    }
393
394    fn select_position(&mut self, at: usize) {
395        if let Some(&i) = self.visible.get(at)
396            && i != self.selected
397        {
398            self.selected = i;
399            self.field_changed();
400        }
401    }
402
403    /// The focus moved to another field: its value shows in its own view, and a
404    /// read follows the rows only while the focus stays on its field.
405    fn field_changed(&mut self) {
406        let name = self.focused().map(|f| f.name.clone());
407        if self.view_field != name {
408            self.view = None;
409        }
410        if self.follow.is_some() && self.follow != name {
411            self.follow = None;
412        }
413    }
414
415    /// Move the focus `delta` items in the level drilled into.
416    fn step(&mut self, delta: isize) -> bool {
417        let Some(drill) = self.drill.as_mut() else {
418            return false;
419        };
420        let level = drill.level_mut();
421        let last = level.node.len().saturating_sub(1);
422        level.selected = level.selected.saturating_add_signed(delta).min(last);
423        true
424    }
425
426    /// Move the focus by `step`: through the items of a level drilled into, or
427    /// through the fields, ↑↓ wrapping round and a page scrolling the list as far.
428    pub fn move_field(&mut self, step: crate::app::form::ListMove) {
429        use crate::app::form::ListMove;
430        let page = self.list_page.max(1);
431        if self.step(step.delta(page)) || self.visible.is_empty() {
432            return;
433        }
434        let n = self.visible.len();
435        let at = self.focused_position();
436        let to = match step {
437            ListMove::Up => (at + n - 1) % n,
438            ListMove::Down => (at + 1) % n,
439            ListMove::PageUp | ListMove::PageDown => {
440                self.list_offset = self
441                    .list_offset
442                    .saturating_add_signed(step.delta(page))
443                    .min(n - 1);
444                step.apply(at, n, page)
445            }
446            ListMove::Home | ListMove::End => step.apply(at, n, page),
447        };
448        self.select_position(to);
449    }
450
451    /// Choose the view `e` moves to.
452    pub fn choose_view(&mut self, view: View) {
453        self.view = Some(view);
454        self.view_field = self.focused().map(|f| f.name.clone());
455    }
456
457    /// Open `node` as a level under the one shown, or under the row's field.
458    pub fn drill_in(&mut self, frame: u64, row: usize, label: String, node: Node) {
459        let level = Level {
460            label,
461            node,
462            selected: 0,
463        };
464        match self.drill.as_mut() {
465            Some(drill) if (drill.frame, drill.row) == (frame, row) => drill.levels.push(level),
466            _ => {
467                self.drill = Some(Drill {
468                    frame,
469                    row,
470                    levels: vec![level],
471                })
472            }
473        }
474        self.json_wait = None;
475        self.focus = Focus::List;
476    }
477
478    /// Step up one level; false at the row, where there is no level to leave.
479    pub fn drill_out(&mut self) -> bool {
480        let Some(drill) = self.drill.as_mut() else {
481            return false;
482        };
483        drill.levels.pop();
484        if drill.levels.is_empty() {
485            self.drill = None;
486        }
487        self.json_wait = None;
488        self.focus = Focus::List;
489        true
490    }
491
492    /// A ticket for text about to be parsed as JSON off this thread.
493    pub fn wait_for_json(&mut self, frame: u64, row: usize, label: String, path: String) -> u64 {
494        self.json_token += 1;
495        self.json_wait = Some(JsonWait {
496            token: self.json_token,
497            frame,
498            row,
499            label,
500            path,
501        });
502        self.json_token
503    }
504
505    /// A typed key while finding: narrows the fields. Ctrl+W drops a word and
506    /// Ctrl+U the whole text; any other chord types nothing.
507    pub fn find_key(&mut self, c: char, mods: crossterm::event::KeyModifiers) {
508        edit_find(&mut self.filter, c, mods);
509    }
510
511    pub fn find_backspace(&mut self) {
512        self.filter.pop();
513    }
514
515    pub fn clear_find(&mut self) {
516        self.filter.clear();
517        self.finding = false;
518    }
519
520    /// The table moved to another row: what was read, opened or indented for the
521    /// last row is let go.
522    pub fn row_shown(&mut self, frame: u64, row: usize) {
523        if self.shown_row != Some((frame, row)) {
524            self.shown_row = Some((frame, row));
525            // A level opened under another row is not this row's.
526            if self
527                .drill
528                .as_ref()
529                .is_some_and(|d| (d.frame, d.row) != (frame, row))
530            {
531                self.drill = None;
532            }
533            if self
534                .json_wait
535                .as_ref()
536                .is_some_and(|w| (w.frame, w.row) != (frame, row))
537            {
538                self.json_wait = None;
539            }
540            if self.read.as_ref().is_some_and(|r| r.key() != (frame, row)) {
541                self.read = None;
542            }
543            if self
544                .pretty
545                .as_ref()
546                .is_some_and(|p| (p.place().0, p.place().1) != (frame, row))
547            {
548                self.pretty = None;
549            }
550            if self
551                .unpack
552                .as_ref()
553                .is_some_and(|u| (u.place().0, u.place().1) != (frame, row))
554            {
555                self.unpack = None;
556            }
557        }
558    }
559
560    /// The fields read for `(frame, row)`, if they are on hand.
561    pub fn read_values(&self, frame: u64, row: usize) -> Option<&DataFrame> {
562        match &self.read {
563            Some(FieldRead::Read {
564                frame: f,
565                row: r,
566                values,
567            }) if (*f, *r) == (frame, row) => Some(values),
568            _ => None,
569        }
570    }
571
572    /// The pane as last drawn, while it is for `field` of `(frame, row)`.
573    pub fn pane_for(&self, frame: u64, row: usize, field: &str) -> Option<&Pane> {
574        self.pane
575            .as_ref()
576            .filter(|(key, _)| (key.frame, key.row) == (frame, row) && key.field == field)
577            .map(|(_, pane)| pane)
578    }
579}
580
581/// A key typed into a find line: a character, Ctrl+W to drop a word, Ctrl+U to
582/// clear. Other chords type nothing.
583pub fn edit_find(text: &mut String, c: char, mods: crossterm::event::KeyModifiers) {
584    use crossterm::event::KeyModifiers;
585    let ctrl = mods.contains(KeyModifiers::CONTROL);
586    if ctrl && c == 'w' {
587        while text.ends_with(' ') {
588            text.pop();
589        }
590        while text.chars().next_back().is_some_and(|c| c != ' ') {
591            text.pop();
592        }
593    } else if ctrl && c == 'u' {
594        text.clear();
595    } else if !ctrl && !mods.contains(KeyModifiers::ALT) {
596        text.push(c);
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use crossterm::event::KeyModifiers;
604    use polars::prelude::DataType;
605
606    fn fields(names: &[&str]) -> Vec<InspectField> {
607        names
608            .iter()
609            .map(|n| InspectField {
610                name: n.to_string(),
611                dtype: DataType::String,
612                hidden: false,
613            })
614            .collect()
615    }
616
617    #[test]
618    fn the_focus_moves_among_the_fields_listed() {
619        let mut m = InspectorModal::new();
620        m.open(fields(&["id", "description", "amount", "status"]), None);
621        m.move_field(crate::app::form::ListMove::Down);
622        assert_eq!(m.focused().unwrap().name, "description");
623        m.set_visible(vec![2, 3]);
624        assert_eq!(
625            m.focused().unwrap().name,
626            "amount",
627            "unlisted: the first listed"
628        );
629        m.move_field(crate::app::form::ListMove::Down);
630        assert_eq!(m.focused().unwrap().name, "status");
631        m.move_field(crate::app::form::ListMove::Down);
632        assert_eq!(m.focused().unwrap().name, "amount", "round to the top");
633        m.set_visible(vec![0, 1, 2, 3]);
634        assert_eq!(m.focused().unwrap().name, "amount");
635        m.find_key('a', KeyModifiers::NONE);
636        m.find_key('m', KeyModifiers::CONTROL);
637        assert_eq!(m.filter, "a", "a chord types nothing");
638        m.find_key('w', KeyModifiers::CONTROL);
639        assert!(m.filter.is_empty());
640    }
641
642    #[test]
643    fn a_page_moves_the_focus_and_the_list_alike() {
644        let mut m = InspectorModal::new();
645        let names: Vec<String> = (0..50).map(|i| format!("f{i}")).collect();
646        let names: Vec<&str> = names.iter().map(String::as_str).collect();
647        m.open(fields(&names), None);
648        m.list_page = 10;
649        m.move_field(crate::app::form::ListMove::PageDown);
650        assert_eq!((m.focused_position(), m.list_offset), (10, 10));
651        for _ in 0..4 {
652            m.move_field(crate::app::form::ListMove::PageDown);
653        }
654        assert_eq!(m.focused_position(), 49, "stops at the last");
655        m.move_field(crate::app::form::ListMove::PageUp);
656        assert_eq!(m.focused_position(), 39);
657    }
658
659    #[test]
660    fn a_new_row_drops_the_last_read() {
661        let mut m = InspectorModal::new();
662        m.open(fields(&["a"]), None);
663        m.row_shown(1, 5);
664        m.read = Some(FieldRead::Reading { frame: 1, row: 5 });
665        m.row_shown(1, 5);
666        assert!(m.read.is_some(), "the same row keeps it");
667        m.row_shown(1, 6);
668        assert!(m.read.is_none());
669    }
670
671    #[test]
672    fn reopening_keeps_the_field_while_it_exists() {
673        let mut m = InspectorModal::new();
674        m.open(fields(&["a", "b", "c"]), None);
675        m.move_field(crate::app::form::ListMove::End);
676        m.close();
677        m.open(fields(&["c", "a"]), None);
678        assert_eq!(m.focused().unwrap().name, "c");
679        m.close();
680        m.open(fields(&["x", "y"]), None);
681        assert_eq!(m.focused().unwrap().name, "x");
682    }
683
684    #[test]
685    fn opens_on_the_column_cursors_field() {
686        let mut m = InspectorModal::new();
687        m.open(fields(&["a", "b", "c"]), Some("b"));
688        assert_eq!(m.focused().unwrap().name, "b");
689        m.move_field(crate::app::form::ListMove::End);
690        m.close();
691        // The cursor wins over the field focused last time.
692        m.open(fields(&["a", "b", "c"]), Some("a"));
693        assert_eq!(m.focused().unwrap().name, "a");
694    }
695
696    #[test]
697    fn a_view_is_chosen_for_its_field() {
698        let mut m = InspectorModal::new();
699        m.open(fields(&["a", "b"]), None);
700        m.choose_view(View::Escaped);
701        assert_eq!(m.view, Some(View::Escaped));
702        m.move_field(crate::app::form::ListMove::Down);
703        assert_eq!(m.view, None, "another field starts in its own view");
704    }
705}