hex_patch/app/
app.rs

1#![allow(clippy::module_inception)]
2use std::time::Duration;
3
4use crossterm::event;
5use ratatui::{
6    backend::Backend,
7    layout::Rect,
8    style::Style,
9    text::{Line, Text},
10    widgets::{Block, Borders, Clear, ScrollbarOrientation, ScrollbarState},
11};
12use termbg::Theme;
13
14use super::{
15    asm::assembly_line::AssemblyLine,
16    data::Data,
17    files::filesystem::FileSystem,
18    frame_info::{FrameInfo, InfoViewFrameInfo},
19    help::HelpLine,
20    info_mode::InfoMode,
21    log::{logger::Logger, NotificationLevel},
22    pane::Pane,
23    plugins::plugin_manager::PluginManager,
24    popup::popup_state::PopupState,
25    settings::{color_settings::ColorSettings, Settings},
26    widgets::logo::Logo,
27};
28
29use crate::{args::Args, get_app_context, headers::Header};
30
31pub struct App {
32    pub(super) plugin_manager: PluginManager,
33    pub(super) filesystem: FileSystem,
34    pub(super) header: Header,
35    pub(super) logger: Logger,
36    pub(super) help_list: Vec<HelpLine>,
37    pub(super) data: Data,
38    pub(super) assembly_offsets: Vec<usize>,
39    pub(super) assembly_instructions: Vec<AssemblyLine>,
40    pub(super) text_last_searched_string: String,
41    pub(super) info_mode: InfoMode,
42    pub(super) scroll: usize,
43    pub(super) cursor: (u16, u16),
44    pub(super) selected_pane: Pane,
45    pub(super) fullscreen: bool,
46    pub(super) poll_time: Duration,
47    pub(super) needs_to_exit: bool,
48    pub(super) screen_size: (u16, u16),
49
50    pub(super) settings: Settings,
51
52    pub(super) popup: Option<PopupState>,
53
54    pub(super) vertical_margin: u16,
55    pub(super) block_size: usize,
56    pub(super) blocks_per_row: usize,
57
58    pub(super) last_frame_info: FrameInfo,
59}
60
61impl App {
62    pub(super) fn print_loading_status<B: Backend>(
63        color_settings: &ColorSettings,
64        status: &str,
65        terminal: &mut ratatui::Terminal<B>,
66    ) -> Result<(), String> {
67        terminal
68            .draw(|f| {
69                let area = f.area();
70                let mut text = Text::default();
71                for _ in 0..(area.height.saturating_sub(1)) {
72                    text.lines.push(ratatui::text::Line::default());
73                }
74                text.lines
75                    .push(Line::styled(status.to_string(), color_settings.menu_text));
76                let paragraph = ratatui::widgets::Paragraph::new(text)
77                    .block(Block::default().borders(Borders::NONE));
78                let logo = Logo::default();
79                let logo_size = logo.get_size();
80                f.render_widget(paragraph, area);
81                if logo_size.0 < area.width && logo_size.1 < area.height {
82                    f.render_widget(
83                        logo,
84                        Rect::new(
85                            area.width / 2 - logo_size.0 / 2,
86                            area.height / 2 - logo_size.1 / 2,
87                            logo_size.0,
88                            logo_size.1,
89                        ),
90                    );
91                }
92            })
93            .map_err(|e| e.to_string())?;
94        Ok(())
95    }
96
97    pub(super) fn get_size<B: Backend>(
98        terminal: &mut ratatui::Terminal<B>,
99    ) -> Result<(u16, u16), String> {
100        terminal
101            .size()
102            .map_err(|e| e.to_string())
103            .map(|s| (s.width, s.height))
104    }
105
106    pub(super) fn switch_fullscreen(&mut self) {
107        self.fullscreen = !self.fullscreen;
108    }
109
110    pub fn new<B: Backend>(
111        args: Args,
112        terminal: &mut ratatui::Terminal<B>,
113        terminal_theme: Result<Theme, termbg::Error>,
114    ) -> Result<Self, String> {
115        let mut logger = Logger::default();
116        let terminal_theme = match terminal_theme {
117            Ok(theme) => theme,
118            Err(e) => {
119                logger.log(
120                    NotificationLevel::Debug,
121                    &format!("Could not detect terminal theme: {e}"),
122                );
123                Theme::Dark
124            }
125        };
126        let settings = match Settings::load_or_create(args.config.as_deref(), terminal_theme) {
127            Ok(settings) => settings,
128            Err(e) => {
129                logger.log(
130                    NotificationLevel::Error,
131                    &format!("Error loading settings: {e}"),
132                );
133                Settings::default()
134            }
135        };
136        logger.change_limit(settings.app.log_limit);
137        logger.change_verbosity(settings.app.log_level);
138        Self::print_loading_status(
139            &settings.color,
140            &format!("Opening \"{}\"...", args.path),
141            terminal,
142        )?;
143
144        let filesystem = if let Some(ssh) = &args.ssh {
145            FileSystem::new_remote(&args.path, ssh, args.password.as_deref())
146                .map_err(|e| format!("Failed to connect to {}: {e}", ssh))?
147        } else {
148            FileSystem::new_local(&args.path).map_err(|e| e.to_string())?
149        };
150        let screen_size = Self::get_size(terminal)?;
151
152        let mut app = App {
153            filesystem,
154            screen_size,
155            help_list: Self::help_list(&settings.key),
156            settings,
157            logger,
158            ..Default::default()
159        };
160
161        let mut app_context = get_app_context!(app);
162        app.plugin_manager = match PluginManager::load(args.plugins.as_deref(), &mut app_context) {
163            Ok(plugins) => plugins,
164            Err(e) => {
165                app.log(
166                    NotificationLevel::Error,
167                    &format!("Error loading plugins: {e}"),
168                );
169                PluginManager::default()
170            }
171        };
172
173        if app.filesystem.is_file(app.filesystem.pwd()) {
174            let path = app.filesystem.pwd().to_string();
175            app.open_file(&path, terminal).map_err(|e| e.to_string())?;
176        } else {
177            let dir = app.filesystem.pwd().to_string();
178            Self::open_dir(&mut app.popup, &dir, &mut app.filesystem).map_err(|e| e.to_string())?;
179        }
180
181        Ok(app)
182    }
183
184    pub fn draw<B: Backend>(
185        &mut self,
186        terminal: &mut ratatui::Terminal<B>,
187    ) -> Result<(), Box<dyn std::error::Error>> {
188        terminal.draw(|f| {
189            let screen_size = (f.area().width, f.area().height);
190            self.resize_to_size(screen_size.0, screen_size.1);
191
192            let min_width = self.block_size as u16 * 3 + 17 + 3;
193            if f.area().width < min_width {
194                return;
195            }
196            let status_rect = Rect::new(0, f.area().height - 1, f.area().width, 1);
197            let address_rect = Rect::new(0, 0, 17, f.area().height - status_rect.height);
198            let hex_editor_rect: Rect;
199            let info_view_rect: Rect;
200            if self.fullscreen {
201                hex_editor_rect = Rect::new(
202                    address_rect.width,
203                    0,
204                    f.area().width - address_rect.width - 2,
205                    f.area().height - status_rect.height,
206                );
207                info_view_rect = Rect::new(
208                    address_rect.width,
209                    0,
210                    f.area().width - address_rect.width - 2,
211                    f.area().height - status_rect.height,
212                );
213            } else {
214                hex_editor_rect = Rect::new(
215                    address_rect.width,
216                    0,
217                    (self.block_size * 3 * self.blocks_per_row + self.blocks_per_row) as u16,
218                    f.area().height - status_rect.height,
219                );
220                info_view_rect = Rect::new(
221                    address_rect.width + hex_editor_rect.width,
222                    0,
223                    f.area().width - hex_editor_rect.width - address_rect.width - 2,
224                    f.area().height - status_rect.height,
225                );
226            }
227
228            let scrollbar_rect = Rect::new(f.area().width - 1, 0, 1, f.area().height);
229
230            let status_block = ratatui::widgets::Paragraph::new(self.build_status_bar())
231                .block(Block::default().borders(Borders::NONE));
232
233            let scrolled_amount = self.get_cursor_position().global_byte_index;
234            let total_amount = self.data.len();
235            let scrollbar = ratatui::widgets::Scrollbar::new(ScrollbarOrientation::VerticalRight)
236                .style(self.settings.color.scrollbar)
237                .begin_symbol(None)
238                .end_symbol(None)
239                .track_symbol(None);
240            let mut scrollbar_state = ScrollbarState::new(total_amount).position(scrolled_amount);
241
242            let mut info_view_frame_info = InfoViewFrameInfo::TextView;
243
244            if !self.data.is_empty() {
245                let line_start_index = self.scroll;
246                let line_end_index = (self.scroll + f.area().height as usize).saturating_sub(2);
247
248                let address_view = self.get_address_view(line_start_index, line_end_index);
249                let hex_view = self.get_hex_view(line_start_index, line_end_index);
250
251                let address_block = ratatui::widgets::Paragraph::new(address_view).block(
252                    Block::default()
253                        .title("Address")
254                        .borders(Borders::LEFT | Borders::TOP),
255                );
256
257                let editor_title =
258                    format!("Hex Editor{}", if self.data.dirty() { " *" } else { "" });
259
260                let hex_border_style: Style;
261                let pretty_border_style: Style;
262                if self.selected_pane == Pane::Hex {
263                    hex_border_style = self.settings.color.pane_selected;
264                    pretty_border_style = self.settings.color.pane;
265                } else {
266                    hex_border_style = self.settings.color.pane;
267                    pretty_border_style = self.settings.color.pane_selected;
268                }
269
270                let hex_editor_block = ratatui::widgets::Paragraph::new(hex_view).block(
271                    Block::default()
272                        .title(editor_title)
273                        .borders(Borders::LEFT | Borders::TOP | Borders::RIGHT)
274                        .border_style(hex_border_style),
275                );
276
277                let info_view_block_flags = if self.fullscreen {
278                    Borders::TOP | Borders::RIGHT | Borders::LEFT
279                } else {
280                    Borders::TOP | Borders::RIGHT
281                };
282
283                let info_view_block = match &self.info_mode {
284                    InfoMode::Text => {
285                        let text_subview_lines =
286                            self.get_text_view(line_start_index, line_end_index);
287                        info_view_frame_info = InfoViewFrameInfo::TextView;
288                        let mut text_subview = Text::default();
289                        text_subview
290                            .lines
291                            .extend(text_subview_lines.iter().cloned());
292                        ratatui::widgets::Paragraph::new(text_subview).block(
293                            Block::default()
294                                .title("Text View")
295                                .borders(info_view_block_flags)
296                                .border_style(pretty_border_style),
297                        )
298                    }
299                    InfoMode::Assembly => {
300                        let assembly_start_index = self.get_assembly_view_scroll();
301                        info_view_frame_info = InfoViewFrameInfo::AssemblyView {
302                            scroll: assembly_start_index,
303                        };
304                        let assembly_end_index = (assembly_start_index + f.area().height as usize
305                            - 2)
306                        .min(self.assembly_instructions.len());
307                        let assembly_subview_lines =
308                            &self.assembly_instructions[assembly_start_index..assembly_end_index];
309                        let mut assembly_subview = Text::default();
310                        let address_min_width = self
311                            .assembly_instructions
312                            .last()
313                            .map(|x| format!("{:X}", x.file_address()).len() + 1)
314                            .unwrap_or(1);
315                        assembly_subview
316                            .lines
317                            .extend(assembly_subview_lines.iter().map(|x| {
318                                x.to_line(
319                                    &self.settings.color,
320                                    self.get_cursor_position().global_byte_index,
321                                    &self.header,
322                                    address_min_width,
323                                )
324                            }));
325                        ratatui::widgets::Paragraph::new(assembly_subview).block(
326                            Block::default()
327                                .title("Assembly View")
328                                .borders(info_view_block_flags)
329                                .border_style(pretty_border_style),
330                        )
331                    }
332                };
333
334                f.render_widget(address_block, address_rect);
335                if self.fullscreen {
336                    match self.selected_pane {
337                        Pane::Hex => f.render_widget(hex_editor_block, hex_editor_rect),
338                        Pane::View => f.render_widget(info_view_block, info_view_rect),
339                    }
340                } else {
341                    f.render_widget(hex_editor_block, hex_editor_rect);
342                    f.render_widget(info_view_block, info_view_rect);
343                }
344            }
345            f.render_widget(status_block, status_rect);
346            f.render_stateful_widget(scrollbar, scrollbar_rect, &mut scrollbar_state);
347
348            let mut this_frame_info = FrameInfo {
349                popup: None,
350                status_bar: status_rect,
351                scroll_bar: scrollbar_rect,
352                address_view: address_rect,
353                hex_view: if !self.fullscreen || self.selected_pane == Pane::Hex {
354                    Some(hex_editor_rect)
355                } else {
356                    None
357                }, // only save the hex view rect if it's visible, we need to know if it's visible to
358                // determine the cursor position
359                info_view: if !self.fullscreen || self.selected_pane == Pane::View {
360                    Some(info_view_rect)
361                } else {
362                    None
363                }, // only save the info view rect if it's visible, we need to know if it's visible to
364                // determine the cursor position
365                info_view_frame_info,
366                blocks_per_row: self.blocks_per_row,
367                scroll: self.scroll,
368                file_size: self.data.len(),
369            };
370
371            // Draw popup
372            if self.popup.is_some() {
373                let mut popup_text = Text::default();
374                let mut popup_title = "Popup".into();
375
376                let mut popup_width = 60;
377                let mut popup_height = 5;
378
379                let popup_result = self.fill_popup(
380                    &mut popup_title,
381                    &mut popup_text,
382                    &mut popup_height,
383                    &mut popup_width,
384                );
385
386                popup_height = popup_height.min(f.area().height.saturating_sub(2) as usize);
387                popup_width = popup_width.min(f.area().width.saturating_sub(1) as usize);
388                let popup_rect = Rect::new(
389                    (f.area().width / 2).saturating_sub((popup_width / 2 + 1) as u16),
390                    (f.area().height / 2).saturating_sub((popup_height / 2) as u16),
391                    popup_width as u16,
392                    popup_height as u16,
393                );
394
395                match popup_result {
396                    Ok(()) => {
397                        let popup = ratatui::widgets::Paragraph::new(popup_text)
398                            .block(Block::default().title(popup_title).borders(Borders::ALL))
399                            .alignment(ratatui::layout::Alignment::Center);
400                        f.render_widget(Clear, popup_rect);
401                        f.render_widget(popup, popup_rect);
402                    }
403                    Err(e) => {
404                        self.logger
405                            .log(NotificationLevel::Error, &format!("Filling popup: {e}"));
406                    }
407                }
408                this_frame_info.popup = Some(popup_rect)
409            }
410            self.last_frame_info = this_frame_info;
411        })?;
412
413        Ok(())
414    }
415
416    pub fn run<B: Backend>(
417        &mut self,
418        terminal: &mut ratatui::Terminal<B>,
419    ) -> Result<(), Box<dyn std::error::Error>> {
420        self.screen_size = (terminal.size()?.width, terminal.size()?.height);
421        self.resize_to_size(self.screen_size.0, self.screen_size.1);
422
423        while !self.needs_to_exit {
424            if event::poll(self.poll_time)? {
425                while event::poll(Duration::from_millis(0))? {
426                    let event = event::read()?;
427                    let event_result = self.handle_event(event, terminal);
428                    if let Err(e) = event_result {
429                        self.log(NotificationLevel::Error, &e.to_string());
430                    }
431                }
432            }
433
434            self.draw(terminal)?;
435        }
436
437        Ok(())
438    }
439}
440
441impl Default for App {
442    fn default() -> Self {
443        App {
444            plugin_manager: PluginManager::default(),
445            filesystem: FileSystem::default(),
446            header: Header::None,
447            logger: Logger::default(),
448            help_list: Self::help_list(&Settings::default().key),
449            data: Data::default(),
450            assembly_offsets: Vec::new(),
451            assembly_instructions: Vec::new(),
452            text_last_searched_string: String::new(),
453            info_mode: InfoMode::Text,
454            scroll: 0,
455            cursor: (0, 0),
456            selected_pane: Pane::Hex,
457            fullscreen: false,
458            poll_time: Duration::from_millis(1000),
459            needs_to_exit: false,
460            screen_size: (0, 0),
461
462            settings: Settings::default(),
463
464            popup: None,
465
466            vertical_margin: 2,
467            block_size: 8,
468            blocks_per_row: 1,
469
470            last_frame_info: FrameInfo {
471                popup: None,
472                status_bar: Rect::default(),
473                scroll_bar: Rect::default(),
474                address_view: Rect::default(),
475                hex_view: Some(Rect::default()),
476                info_view: Some(Rect::default()),
477                info_view_frame_info: InfoViewFrameInfo::TextView,
478                blocks_per_row: 1,
479                scroll: 0,
480                file_size: 0,
481            },
482        }
483    }
484}