Skip to main content

datui_lib/
hex_keys.rs

1//! The hex view's part of the App: opening a file in it, its keys, and its find.
2//! The view itself, and everything about it that needs no App, is [`crate::hex_view`].
3
4use crate::hex_view::{
5    Found, HexFindRun, HexHit, HexSource, HexView, MAX_RECORD_SIZE, Origin, PromptKind,
6};
7use crate::jobs::{Answer, Job, Progress};
8use crate::{App, AppEvent, InputMode};
9use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10use std::path::PathBuf;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, Ordering};
13
14impl App {
15    /// Whether any format spec is on the search path: `B` has something to offer.
16    pub(crate) fn has_format_specs(&self) -> bool {
17        !self.formats.is_empty()
18    }
19
20    /// The hex view on screen, if it is.
21    pub fn hex_view(&self) -> Option<&HexView> {
22        self.hex
23            .as_ref()
24            .filter(|_| self.input_mode == InputMode::Hex)
25    }
26
27    /// The one local file the dataset on screen was read from, which the Info panel's
28    /// `x` shows as hex: not a glob, a remote source, standard input, or several paths.
29    pub(crate) fn hex_target(&self) -> Option<PathBuf> {
30        let several = self
31            .opened
32            .as_ref()
33            .is_some_and(|(paths, options)| paths.len() > 1 || options.hive);
34        self.path.clone().filter(|path| {
35            self.data_table_state.is_some()
36                && !several
37                && !self.reads_stdin()
38                && !crate::source::is_remote_url(path)
39                && !crate::source::is_prefix_or_glob(&path.to_string_lossy())
40        })
41    }
42
43    /// Show `path` in the hex view: it is mapped on a worker, and the view opens when it
44    /// is. `fallback` says no reader and no spec took it.
45    pub(crate) fn open_hex(
46        &mut self,
47        path: PathBuf,
48        origin: Origin,
49        fallback: bool,
50        record_size: Option<usize>,
51    ) {
52        self.stop_hex_find();
53        let job = Job::HexOpen {
54            origin,
55            fallback,
56            record_size,
57        };
58        self.spawn_job(job, Some("Opening as hex..."), move |_| {
59            HexSource::open(path.clone())
60                .map(|source| Answer::HexOpened(Box::new(source)))
61                .map_err(|e| format!("{}: {e}", path.display()))
62        });
63    }
64
65    /// The file is mapped: the view opens on it.
66    pub(crate) fn hex_opened(&mut self, job: Job, current: bool, source: HexSource) {
67        let Job::HexOpen {
68            origin,
69            fallback,
70            record_size,
71        } = job
72        else {
73            return;
74        };
75        if !current {
76            return;
77        }
78        self.hex_serial += 1;
79        let mut view = HexView::new(source, origin, fallback, self.hex_serial);
80        view.record_size = record_size.map(|n| n.clamp(1, MAX_RECORD_SIZE));
81        view.input = crate::widgets::text_input::TextInput::new().with_theme(&self.theme);
82        self.hex = Some(view);
83        self.info_modal.close();
84        self.input_mode = InputMode::Hex;
85    }
86
87    /// An open found a local file nothing reads, or was asked for its bytes.
88    pub(crate) fn land_on_hex(&mut self, hex: crate::loading::Hex) {
89        let crate::loading::Hex {
90            file,
91            from_home,
92            asked,
93            record_size,
94        } = hex;
95        self.status_message = None;
96        self.busy = false;
97        let origin = if from_home {
98            Origin::Home
99        } else if self.data_table_state.is_some() {
100            Origin::Table
101        } else {
102            Origin::Launch
103        };
104        self.open_hex(file, origin, !asked, record_size);
105    }
106
107    /// Leave the hex view the way it was entered: home, the table, or (from the
108    /// command line) out of datui.
109    fn leave_hex(&mut self, quit: bool) -> Option<AppEvent> {
110        let origin = self.hex.as_ref()?.origin;
111        match origin {
112            Origin::Home => {
113                self.enter_home();
114                None
115            }
116            Origin::Table | Origin::Info if quit && !self.opened_from_home => Some(AppEvent::Exit),
117            Origin::Table | Origin::Info if quit => {
118                self.enter_home();
119                None
120            }
121            Origin::Table | Origin::Info => {
122                self.stop_hex_find();
123                self.hex = None;
124                self.input_mode = if self.data_table_state.is_some() {
125                    InputMode::Normal
126                } else {
127                    InputMode::Home
128                };
129                // Back to the panel it was opened from, on the tab it was on.
130                if origin == Origin::Info && self.data_table_state.is_some() {
131                    self.info_modal.active = true;
132                    self.input_mode = InputMode::Info;
133                }
134                None
135            }
136            Origin::Launch if quit => Some(AppEvent::Exit),
137            Origin::Launch => None,
138        }
139    }
140
141    /// What `q` says it does in the hex view.
142    pub(crate) fn hex_q_label(&self) -> &'static str {
143        match self.hex.as_ref().map(|v| v.origin) {
144            Some(Origin::Home) => "Home",
145            Some(Origin::Table | Origin::Info) if self.opened_from_home => "Home",
146            _ => "Quit",
147        }
148    }
149
150    /// Keys in the hex view.
151    pub(crate) fn hex_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
152        if !event.is_press() {
153            return None;
154        }
155        let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
156        let view = self.hex.as_mut()?;
157
158        // The spec picker owns the keys while it is open.
159        if let Some(picker) = view.picker.as_mut() {
160            match event.code {
161                KeyCode::Esc => view.picker = None,
162                KeyCode::Enter => {
163                    let name = picker
164                        .selected_original()
165                        .map(|i| picker.items()[i].clone());
166                    view.picker = None;
167                    return name.and_then(|name| self.read_hex_with_spec(name));
168                }
169                KeyCode::Up => picker.move_up(),
170                KeyCode::Down => picker.move_down(),
171                KeyCode::Backspace => picker.backspace(),
172                KeyCode::Char(c) => picker.filter_key(c, event.modifiers),
173                _ => {}
174            }
175            return None;
176        }
177
178        // So does the prompt.
179        if let Some(kind) = view.prompt {
180            match event.code {
181                KeyCode::Esc => {
182                    view.prompt = None;
183                    view.prompt_error = None;
184                    view.input.set_focused(false);
185                }
186                KeyCode::Enter => return self.hex_prompt_submit(kind),
187                KeyCode::Char('u') if ctrl && kind == PromptKind::Find => {
188                    view.utf16 = !view.utf16;
189                    view.prompt_error = None;
190                }
191                _ => {
192                    view.input.handle_key(event, None);
193                    view.prompt_error = None;
194                }
195            }
196            return None;
197        }
198
199        let page = view.geometry.rows.max(1) as i64;
200        match event.code {
201            KeyCode::Esc => {
202                if view.inspector_open {
203                    view.inspector_open = false;
204                } else if view.mark.is_some() {
205                    view.mark = None;
206                } else {
207                    return self.leave_hex(false);
208                }
209            }
210            KeyCode::Char('q') => return self.leave_hex(true),
211            KeyCode::Char('?') => self.open_help_overlay(),
212            KeyCode::Char('f') if ctrl => view.step_rows(page),
213            KeyCode::Char('b') if ctrl => view.step_rows(-page),
214            KeyCode::Char('d') if ctrl => view.step_rows((page / 2).max(1)),
215            KeyCode::Char('u') if ctrl => view.step_rows(-(page / 2).max(1)),
216            _ if ctrl => {}
217            KeyCode::Left | KeyCode::Char('h') => view.step(-1),
218            KeyCode::Right | KeyCode::Char('l') => view.step(1),
219            KeyCode::Up | KeyCode::Char('k') => view.step_rows(-1),
220            KeyCode::Down | KeyCode::Char('j') => view.step_rows(1),
221            KeyCode::Char('w') => view.next_group(),
222            KeyCode::Char('b') => view.previous_group(),
223            KeyCode::Char('0') => view.row_start(),
224            KeyCode::Char('$') => view.row_end(),
225            KeyCode::Home | KeyCode::Char('g') => view.go(0),
226            KeyCode::End | KeyCode::Char('G') => view.go(u64::MAX),
227            KeyCode::PageDown => view.step_rows(page),
228            KeyCode::PageUp => view.step_rows(-page),
229            KeyCode::Char('v') => {
230                view.mark = match view.mark {
231                    Some(_) => None,
232                    None if view.is_empty() => None,
233                    None => Some(view.cursor),
234                };
235            }
236            KeyCode::Char('#') => view.decimal = !view.decimal,
237            KeyCode::Char('i') | KeyCode::Enter => {
238                if view.geometry.room_for_panel {
239                    view.inspector = !view.inspector;
240                } else {
241                    view.inspector_open = !view.inspector_open;
242                }
243            }
244            KeyCode::Char(':') => open_prompt(view, PromptKind::GoTo, String::new()),
245            KeyCode::Char('f') | KeyCode::Char('/') => {
246                let last = view
247                    .found
248                    .as_ref()
249                    .map(|f| f.pattern.label.clone())
250                    .unwrap_or_default();
251                open_prompt(view, PromptKind::Find, last);
252            }
253            KeyCode::Char('r') => {
254                let now = view.record_size.map(|n| n.to_string()).unwrap_or_default();
255                open_prompt(view, PromptKind::RecordSize, now);
256            }
257            KeyCode::Char('R') => {
258                if let Some(stride) = view.found.as_ref().and_then(|f| f.stride)
259                    && (1..=MAX_RECORD_SIZE as u64).contains(&stride)
260                {
261                    view.record_size = Some(stride as usize);
262                }
263            }
264            KeyCode::Char('n') => return self.hex_find_again(true),
265            KeyCode::Char('N') => return self.hex_find_again(false),
266            KeyCode::Char('B') => self.open_hex_spec_picker(),
267            _ => {}
268        }
269        None
270    }
271
272    /// Enter in a prompt: go to the offset, set the bytes per row, or find.
273    fn hex_prompt_submit(&mut self, kind: PromptKind) -> Option<AppEvent> {
274        let view = self.hex.as_mut()?;
275        let text = view.input.value().to_string();
276        match kind {
277            PromptKind::GoTo => {
278                match crate::hex_view::parse_offset(&text, view.cursor, view.len()) {
279                    Ok(at) => {
280                        view.go(at);
281                        close_prompt(view);
282                    }
283                    Err(e) => view.prompt_error = Some(e),
284                }
285            }
286            PromptKind::RecordSize => {
287                let text = text.trim();
288                if text.is_empty() || text == "0" || text.eq_ignore_ascii_case("auto") {
289                    view.record_size = None;
290                    close_prompt(view);
291                } else {
292                    match text.parse::<usize>() {
293                        Ok(n) if (1..=MAX_RECORD_SIZE).contains(&n) => {
294                            view.record_size = Some(n);
295                            close_prompt(view);
296                        }
297                        _ => {
298                            view.prompt_error = Some(format!(
299                                "{text}: bytes per row are 1 to {MAX_RECORD_SIZE}, or empty for as many as fit"
300                            ));
301                        }
302                    }
303                }
304            }
305            PromptKind::Find => match crate::hex_view::parse_pattern(&text, view.utf16) {
306                Ok(pattern) => {
307                    close_prompt(view);
308                    let from = view.cursor;
309                    self.start_hex_find(pattern, from, true);
310                }
311                Err(e) => view.prompt_error = Some(e),
312            },
313        }
314        None
315    }
316
317    /// `n` and `N`: the next or previous match of the last pattern, from the cursor.
318    fn hex_find_again(&mut self, forward: bool) -> Option<AppEvent> {
319        let view = self.hex.as_ref()?;
320        let pattern = view.found.as_ref()?.pattern.clone();
321        let len = view.len();
322        if len == 0 {
323            return None;
324        }
325        let from = if forward {
326            if view.cursor + 1 >= len {
327                0
328            } else {
329                view.cursor + 1
330            }
331        } else if view.cursor == 0 {
332            len - 1
333        } else {
334            view.cursor - 1
335        };
336        self.start_hex_find(pattern, from, forward);
337        None
338    }
339
340    /// Find `pattern` from `from` on a worker. The keys wait, and Esc stops it.
341    fn start_hex_find(&mut self, pattern: crate::hex_view::Pattern, from: u64, forward: bool) {
342        self.stop_hex_find();
343        let Some(view) = self.hex.as_mut() else {
344            return;
345        };
346        let stop = Arc::new(AtomicBool::new(false));
347        let run = HexFindRun {
348            stop: stop.clone(),
349            view: view.serial,
350            pattern: pattern.clone(),
351        };
352        let bytes = view.bytes.clone();
353        let total = view.len();
354        let status = format!("Finding {}...", pattern.label);
355        self.spawn_job(Job::HexFind(run), Some(&status), move |worker| {
356            let report = worker.reporter();
357            let hay = bytes.as_slice();
358            let mut hit = crate::hex_view::find(hay, &pattern, from, forward, &stop, |read| {
359                report(Progress::HexFinding { read, total })
360            })
361            .map_err(|_| crate::find::CANCELLED.to_string())?;
362            hit.stride = hit
363                .at
364                .and_then(|at| crate::hex_view::stride(hay, &pattern, at, &stop));
365            Ok(Answer::HexFound(hit))
366        });
367    }
368
369    /// A find's progress through the file.
370    pub(crate) fn hex_find_progress(&mut self, read: u64, total: u64) {
371        let Some(label) = self
372            .jobs
373            .current(|job| matches!(job, Job::HexFind(_)))
374            .and_then(|(_, job)| match job {
375                Job::HexFind(run) => Some(run.pattern.label.clone()),
376                _ => None,
377            })
378        else {
379            return;
380        };
381        let percent = (read.min(total) * 100).checked_div(total).unwrap_or(100);
382        self.status_message = Some(format!("Finding {label}... {percent}%"));
383    }
384
385    /// A find answered: the cursor goes to the match.
386    pub(crate) fn hex_found(&mut self, job: Job, current: bool, hit: HexHit) {
387        let Job::HexFind(run) = job else {
388            return;
389        };
390        if !current {
391            return;
392        }
393        self.status_message = None;
394        let Some(view) = self.hex.as_mut().filter(|v| v.serial == run.view) else {
395            return;
396        };
397        view.found = Some(Found {
398            pattern: run.pattern.clone(),
399            hit: hit.at,
400            stride: hit.stride,
401        });
402        match hit.at {
403            Some(at) => {
404                view.go(at);
405                if hit.wrapped {
406                    self.flash_note(format!("Found {}, round past the end", run.pattern.label));
407                }
408            }
409            None => self.flash_note(format!("No match for {}", run.pattern.label)),
410        }
411    }
412
413    /// Stop the hex view's find, if one is running. Returns whether one was.
414    pub(crate) fn stop_hex_find(&mut self) -> bool {
415        let Some((_, Job::HexFind(run))) = self.jobs.current(|job| matches!(job, Job::HexFind(_)))
416        else {
417            return false;
418        };
419        run.stop.store(true, Ordering::Relaxed);
420        self.jobs.cancel(|job| matches!(job, Job::HexFind(_)));
421        self.status_message = None;
422        true
423    }
424
425    /// `B`: every spec on the search path, to read the file with.
426    fn open_hex_spec_picker(&mut self) {
427        if self.formats.is_empty() {
428            self.flash_note(
429                "No format specs on the search path; `datui formats` lists where it looks"
430                    .to_string(),
431            );
432            return;
433        }
434        let names: Vec<String> = self
435            .formats
436            .specs
437            .iter()
438            .map(|found| found.spec.name.clone())
439            .collect();
440        if let Some(view) = self.hex.as_mut() {
441            view.picker = Some(crate::widgets::ui::PickerState::new(names));
442        }
443    }
444
445    /// Read the hex view's file with the spec `name`.
446    fn read_hex_with_spec(&mut self, name: String) -> Option<AppEvent> {
447        let view = self.hex.as_ref()?;
448        let path = view.path.clone();
449        let from_home = view.origin == Origin::Home;
450        let options = crate::OpenOptions {
451            spec_name: Some(name),
452            ..self
453                .opened
454                .as_ref()
455                .filter(|(paths, _)| paths.first() == Some(&path))
456                .map(|(_, options)| options.clone())
457                .unwrap_or_default()
458        };
459        let options = crate::OpenOptions {
460            hex: false,
461            spec_file: None,
462            spec_fetched: None,
463            format_read: None,
464            format: None,
465            ..options
466        };
467        self.opened_from_home = from_home || self.opened_from_home;
468        // The table takes the screen; a read that fails says why over it.
469        self.stop_hex_find();
470        self.hex = None;
471        self.input_mode = InputMode::Normal;
472        self.set_loading_phase("Scanning input", 10);
473        self.name_what_is_loading(path.clone());
474        Some(AppEvent::Open(vec![path], options))
475    }
476}
477
478fn open_prompt(view: &mut HexView, kind: PromptKind, text: String) {
479    view.prompt = Some(kind);
480    view.prompt_error = None;
481    view.input.set_value(text);
482    view.input.select_all();
483    view.input.set_focused(true);
484}
485
486fn close_prompt(view: &mut HexView) {
487    view.prompt = None;
488    view.prompt_error = None;
489    view.input.set_focused(false);
490}