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