Skip to main content

datui_lib/app/keys/
hex_keys.rs

1//! The hex view's App side: opening a file in it and its keys; its find is
2//! [`crate::app::keys::hex_find`] and the view itself [`crate::app::hex_view`].
3
4use crate::app::hex_view::{HexSource, HexView, MAX_RECORD_SIZE, Origin, PromptKind};
5use crate::app::jobs::{Answer, Job};
6use crate::{App, AppEvent, InputMode, Overlay};
7use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8use std::path::PathBuf;
9
10/// The hex view, and the number its next read is tagged with.
11#[derive(Default)]
12pub struct HexState {
13    /// The hex view (`InputMode::Hex`), kept while it is up.
14    pub view: Option<crate::app::hex_view::HexView>,
15    /// Bumped per hex view opened, so a find's answer for another is dropped.
16    pub(crate) serial: u64,
17}
18
19impl HexState {
20    /// The hex view is closed; the serial goes on counting.
21    pub(crate) fn close(&mut self) {
22        self.view = None;
23    }
24}
25
26impl App {
27    /// Whether any format spec is on the search path: `B` has something to offer.
28    pub(crate) fn has_format_specs(&self) -> bool {
29        !self.formats.is_empty()
30    }
31
32    /// The hex view on screen, if it is.
33    pub fn hex_view(&self) -> Option<&HexView> {
34        self.hex
35            .view
36            .as_ref()
37            .filter(|_| self.overlay == Overlay::Hex)
38    }
39
40    /// The one local file the dataset was read from, which Info's `x` shows as hex: not
41    /// a glob, remote source, stdin, or several paths.
42    pub(crate) fn hex_target(&self) -> Option<PathBuf> {
43        let several = self
44            .source
45            .opened
46            .as_ref()
47            .is_some_and(|(paths, options)| paths.len() > 1 || options.hive);
48        self.path.clone().filter(|path| {
49            self.data_table_state.is_some()
50                && !several
51                && !self.reads_stdin()
52                && !crate::cloud::source::is_remote_url(path)
53                && !crate::cloud::source::is_prefix_or_glob(&path.to_string_lossy())
54        })
55    }
56
57    /// Show `path` in the hex view once a worker maps it. `fallback`: no reader or spec
58    /// took it.
59    pub(crate) fn open_hex(
60        &mut self,
61        path: PathBuf,
62        origin: Origin,
63        fallback: bool,
64        record_size: Option<usize>,
65    ) {
66        self.open_hex_fetched(path, None, origin, fallback, record_size);
67    }
68
69    /// [`Self::open_hex`] for a file that may be a download: `fetched` is the URL it came
70    /// from and the hold on it, which the view keeps.
71    fn open_hex_fetched(
72        &mut self,
73        path: PathBuf,
74        fetched: Option<(PathBuf, crate::cloud::download::TempDownload)>,
75        origin: Origin,
76        fallback: bool,
77        record_size: Option<usize>,
78    ) {
79        self.stop_hex_find();
80        let job = Job::HexOpen {
81            origin,
82            fallback,
83            record_size,
84        };
85        self.spawn_job(job, Some("Opening as hex..."), move |_| {
86            HexSource::open(path.clone())
87                .map(|source| Answer::HexOpened(Box::new(HexSource { fetched, ..source })))
88                .map_err(|e| format!("{}: {e}", path.display()))
89        });
90    }
91
92    /// The file is mapped: the view opens on it.
93    pub(crate) fn hex_opened(
94        &mut self,
95        origin: Origin,
96        fallback: bool,
97        record_size: Option<usize>,
98        source: HexSource,
99    ) {
100        self.hex.serial += 1;
101        let mut view = HexView::new(source, origin, fallback, self.hex.serial);
102        view.record_size = record_size.map(|n| n.clamp(1, MAX_RECORD_SIZE));
103        view.input = crate::widgets::text_input::TextInput::new().with_theme(&self.theme);
104        self.hex.view = Some(view);
105        self.open_overlay(Overlay::Hex);
106    }
107
108    /// An open found a local file nothing reads, or was asked for its bytes.
109    pub(crate) fn land_on_hex(&mut self, hex: crate::loading::Hex) {
110        let crate::loading::Hex {
111            file,
112            fetched,
113            from_home,
114            asked,
115            record_size,
116        } = hex;
117        self.status_message = None;
118        self.busy = false;
119        let origin = if from_home {
120            Origin::Home
121        } else if self.data_table_state.is_some() {
122            Origin::Table
123        } else {
124            Origin::Launch
125        };
126        self.open_hex_fetched(file, fetched, origin, !asked, record_size);
127    }
128
129    /// Leave the hex view the way it was entered: home, the table, or (from the command
130    /// line) out of datui.
131    fn leave_hex(&mut self, quit: bool) -> Option<AppEvent> {
132        let origin = self.hex.view.as_ref()?.origin;
133        match origin {
134            Origin::Home => {
135                self.enter_home();
136                None
137            }
138            Origin::Table | Origin::Info if quit && !self.source.opened_from_home => {
139                Some(AppEvent::Exit)
140            }
141            Origin::Table | Origin::Info if quit => {
142                self.enter_home();
143                None
144            }
145            Origin::Table | Origin::Info => {
146                self.close_overlay();
147                if self.data_table_state.is_none() {
148                    self.input_mode = InputMode::Home;
149                }
150                // Back to the panel it was opened from, on the tab it was on.
151                if origin == Origin::Info && self.data_table_state.is_some() {
152                    self.open_overlay(Overlay::Info);
153                }
154                None
155            }
156            Origin::Launch if quit => Some(AppEvent::Exit),
157            Origin::Launch => None,
158        }
159    }
160
161    /// What `q` says it does in the hex view.
162    pub(crate) fn hex_q_label(&self) -> &'static str {
163        match self.hex.view.as_ref().map(|v| v.origin) {
164            Some(Origin::Home) => "Home",
165            Some(Origin::Table | Origin::Info) if self.source.opened_from_home => "Home",
166            _ => "Quit",
167        }
168    }
169
170    /// Keys in the hex view.
171    pub(crate) fn hex_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
172        if !event.is_press() {
173            return None;
174        }
175        let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
176        let view = self.hex.view.as_mut()?;
177
178        // The spec picker owns the keys while it is open.
179        if let Some(picker) = view.picker.as_mut() {
180            use crate::app::form::{PickerKey, picker_key};
181            match picker_key(picker, false, event) {
182                PickerKey::Close => view.picker = None,
183                PickerKey::Choose => {
184                    let name = picker
185                        .selected_original()
186                        .map(|i| picker.items()[i].clone());
187                    view.picker = None;
188                    return name.and_then(|name| self.read_hex_with_spec(name));
189                }
190                // No field to move on to.
191                PickerKey::ChooseAndMove(_)
192                | PickerKey::Toggle
193                | PickerKey::Handled
194                | PickerKey::Other => {}
195            }
196            return None;
197        }
198
199        // So does the prompt.
200        if let Some(kind) = view.prompt {
201            match event.code {
202                KeyCode::Esc => {
203                    view.prompt = None;
204                    view.prompt_error = None;
205                    view.input.set_focused(false);
206                }
207                KeyCode::Enter => return self.hex_prompt_submit(kind),
208                KeyCode::Char('u') if ctrl && kind == PromptKind::Find => {
209                    view.utf16 = !view.utf16;
210                    view.prompt_error = None;
211                }
212                _ => {
213                    view.input.handle_key(event, None);
214                    view.prompt_error = None;
215                }
216            }
217            return None;
218        }
219
220        let page = view.geometry.rows.max(1) as i64;
221        match event.code {
222            KeyCode::Esc => {
223                if view.inspector_open {
224                    view.inspector_open = false;
225                } else if view.mark.is_some() {
226                    view.mark = None;
227                } else {
228                    return self.leave_hex(false);
229                }
230            }
231            KeyCode::Char('q') => return self.leave_hex(true),
232            KeyCode::Char('?') => self.open_help_overlay(),
233            KeyCode::Char('f') if ctrl => view.step_rows(page),
234            KeyCode::Char('b') if ctrl => view.step_rows(-page),
235            KeyCode::Char('d') if ctrl => view.step_rows((page / 2).max(1)),
236            KeyCode::Char('u') if ctrl => view.step_rows(-(page / 2).max(1)),
237            _ if ctrl => {}
238            KeyCode::Left | KeyCode::Char('h') => view.step(-1),
239            KeyCode::Right | KeyCode::Char('l') => view.step(1),
240            KeyCode::Up | KeyCode::Char('k') => view.step_rows(-1),
241            KeyCode::Down | KeyCode::Char('j') => view.step_rows(1),
242            KeyCode::Char('w') => view.next_group(),
243            KeyCode::Char('b') => view.previous_group(),
244            KeyCode::Char('0') => view.row_start(),
245            KeyCode::Char('$') => view.row_end(),
246            KeyCode::Home | KeyCode::Char('g') => view.go(0),
247            KeyCode::End | KeyCode::Char('G') => view.go(u64::MAX),
248            KeyCode::PageDown => view.step_rows(page),
249            KeyCode::PageUp => view.step_rows(-page),
250            KeyCode::Char('v') => {
251                view.mark = match view.mark {
252                    Some(_) => None,
253                    None if view.is_empty() => None,
254                    None => Some(view.cursor),
255                };
256            }
257            KeyCode::Char('#') => view.decimal = !view.decimal,
258            KeyCode::Char('i') | KeyCode::Enter => {
259                if view.geometry.room_for_panel {
260                    view.inspector = !view.inspector;
261                } else {
262                    view.inspector_open = !view.inspector_open;
263                }
264            }
265            KeyCode::Char(':') => open_prompt(view, PromptKind::GoTo, String::new()),
266            KeyCode::Char('f') | KeyCode::Char('/') => {
267                let last = view
268                    .found
269                    .as_ref()
270                    .map(|f| f.pattern.label.clone())
271                    .unwrap_or_default();
272                open_prompt(view, PromptKind::Find, last);
273            }
274            KeyCode::Char('r') => {
275                let now = view.record_size.map(|n| n.to_string()).unwrap_or_default();
276                open_prompt(view, PromptKind::RecordSize, now);
277            }
278            KeyCode::Char('R') => {
279                if let Some(stride) = view.found.as_ref().and_then(|f| f.stride)
280                    && (1..=MAX_RECORD_SIZE as u64).contains(&stride)
281                {
282                    view.record_size = Some(stride as usize);
283                }
284            }
285            KeyCode::Char('n') => return self.hex_find_again(true),
286            KeyCode::Char('N') => return self.hex_find_again(false),
287            KeyCode::Char('B') => self.open_hex_spec_picker(),
288            _ => {}
289        }
290        None
291    }
292
293    /// Enter in a prompt: go to the offset, set the bytes per row, or find.
294    fn hex_prompt_submit(&mut self, kind: PromptKind) -> Option<AppEvent> {
295        let view = self.hex.view.as_mut()?;
296        let text = view.input.value().to_string();
297        match kind {
298            PromptKind::GoTo => {
299                match crate::app::hex_view::parse_offset(&text, view.cursor, view.len()) {
300                    Ok(at) => {
301                        view.go(at);
302                        close_prompt(view);
303                    }
304                    Err(e) => view.prompt_error = Some(e),
305                }
306            }
307            PromptKind::RecordSize => {
308                let text = text.trim();
309                if text.is_empty() || text == "0" || text.eq_ignore_ascii_case("auto") {
310                    view.record_size = None;
311                    close_prompt(view);
312                } else {
313                    match text.parse::<usize>() {
314                        Ok(n) if (1..=MAX_RECORD_SIZE).contains(&n) => {
315                            view.record_size = Some(n);
316                            close_prompt(view);
317                        }
318                        _ => {
319                            view.prompt_error = Some(format!(
320                                "{text}: bytes per row are 1 to {MAX_RECORD_SIZE}, or empty for as many as fit"
321                            ));
322                        }
323                    }
324                }
325            }
326            PromptKind::Find => match crate::app::hex_view::parse_pattern(&text, view.utf16) {
327                Ok(pattern) => {
328                    close_prompt(view);
329                    let from = view.cursor;
330                    self.start_hex_find(pattern, from, true);
331                }
332                Err(e) => view.prompt_error = Some(e),
333            },
334        }
335        None
336    }
337
338    /// `B`: every spec on the search path, to read the file with.
339    fn open_hex_spec_picker(&mut self) {
340        if self.formats.is_empty() {
341            self.flash_note(
342                "No format specs on the search path; `datui formats` lists where it looks"
343                    .to_string(),
344            );
345            return;
346        }
347        let names: Vec<String> = self
348            .formats
349            .specs
350            .iter()
351            .map(|found| found.spec.name.clone())
352            .collect();
353        if let Some(view) = self.hex.view.as_mut() {
354            view.picker = Some(crate::widgets::ui::PickerState::new(names));
355        }
356    }
357
358    /// Read the hex view's file with the spec `name`.
359    fn read_hex_with_spec(&mut self, name: String) -> Option<AppEvent> {
360        let view = self.hex.view.as_ref()?;
361        let path = view.path.clone();
362        let from_home = view.origin == Origin::Home;
363        let options = crate::OpenOptions {
364            spec_name: Some(name),
365            ..self
366                .source
367                .opened
368                .as_ref()
369                .filter(|(paths, _)| paths.first() == Some(&path))
370                .map(|(_, options)| options.clone())
371                .unwrap_or_default()
372        };
373        let options = crate::OpenOptions {
374            hex: false,
375            spec_file: None,
376            spec_fetched: None,
377            format_read: None,
378            format: None,
379            ..options
380        };
381        self.source.opened_from_home = from_home || self.source.opened_from_home;
382        // The table takes the screen; a read that fails says why over it.
383        self.close_overlay();
384        self.set_loading_phase("Scanning input", 10);
385        self.name_what_is_loading(path.clone());
386        Some(AppEvent::Open(vec![path], options))
387    }
388}
389
390fn open_prompt(view: &mut HexView, kind: PromptKind, text: String) {
391    view.prompt = Some(kind);
392    view.prompt_error = None;
393    view.input.set_value(text);
394    view.input.select_all();
395    view.input.set_focused(true);
396}
397
398fn close_prompt(view: &mut HexView) {
399    view.prompt = None;
400    view.prompt_error = None;
401    view.input.set_focused(false);
402}