Skip to main content

datui_lib/
inspector_modal.rs

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