Skip to main content

fpv/app/
state.rs

1use crate::config::load::StatusDisplayMode;
2use crate::fs::git::GitRepoStatus;
3use crossterm::event::Event;
4use ratatui::style::Style;
5use ratatui::text::Line;
6use std::path::PathBuf;
7use std::time::Instant;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum NodeType {
11    File,
12    Directory,
13    Symlink,
14    Unknown,
15}
16
17#[derive(Debug, Clone)]
18pub struct TreeNode {
19    pub path: PathBuf,
20    pub name: String,
21    pub node_type: NodeType,
22    pub depth: usize,
23    pub expanded: bool,
24    pub readable: bool,
25    pub children_loaded: bool,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum LoadState {
30    Idle,
31    Loading,
32    Ready,
33    Error,
34    Binary,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ContentType {
39    Highlighted,
40    PlainText,
41    Unsupported,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum PreviewFallbackReason {
46    UnsupportedExtension,
47    EngineFailure,
48    TooLarge,
49    DecodeUncertain,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct ContentPosition {
54    pub row: usize,
55    pub col: usize,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct PreviewSelection {
60    pub anchor: ContentPosition,
61    pub cursor: ContentPosition,
62}
63
64impl PreviewSelection {
65    pub fn ordered(&self) -> (ContentPosition, ContentPosition) {
66        if self.anchor.row < self.cursor.row
67            || (self.anchor.row == self.cursor.row && self.anchor.col <= self.cursor.col)
68        {
69            (self.anchor, self.cursor)
70        } else {
71            (self.cursor, self.anchor)
72        }
73    }
74}
75
76#[derive(Debug, Clone)]
77pub struct StyledPreviewSegment {
78    pub text: String,
79    pub style: Style,
80}
81
82pub type StyledPreviewLine = Vec<StyledPreviewSegment>;
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
85pub enum PreviewLineChange {
86    Added,
87    Deleted,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct PreviewSearch {
92    pub query: String,
93    pub case_sensitive: bool,
94    pub current_match_index: usize,
95    pub match_positions: Vec<(usize, usize, usize)>, // (line, col_start, col_end)
96}
97
98impl Default for PreviewSearch {
99    fn default() -> Self {
100        Self {
101            query: String::new(),
102            case_sensitive: false,
103            current_match_index: 0,
104            match_positions: Vec::new(),
105        }
106    }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct PreviewRenderCacheKey {
111    pub epoch: u64,
112    pub inner_width: u16,
113    pub show_line_numbers: bool,
114    pub wrap_enabled: bool,
115    pub content_hash: u64,
116    pub styled_lines_hash: u64,
117    pub line_changes_hash: u64,
118}
119
120#[derive(Debug, Clone)]
121pub struct PreviewRenderCache {
122    pub key: PreviewRenderCacheKey,
123    pub rendered_lines: Vec<Line<'static>>,
124    pub rendered_row_changes: Vec<Option<PreviewLineChange>>,
125    pub total_lines: usize,
126    pub line_number_cols: usize,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct PreviewDiffCacheKey {
131    pub path: PathBuf,
132    pub current_content_hash: u64,
133    pub content_type: ContentType,
134    pub language_id: Option<String>,
135    pub truncated: bool,
136}
137
138#[derive(Debug, Clone)]
139pub struct PreviewDiffCache {
140    pub key: PreviewDiffCacheKey,
141    pub merged_doc: PreviewDocument,
142}
143
144#[derive(Debug, Clone)]
145pub struct PreviewDocument {
146    pub source_path: PathBuf,
147    pub load_state: LoadState,
148    pub content_type: ContentType,
149    pub image_preview: bool,
150    pub image_preview_pending: bool,
151    pub language_id: Option<String>,
152    pub content_excerpt: String,
153    pub styled_lines: Vec<StyledPreviewLine>,
154    pub display_line_numbers: Vec<Option<usize>>,
155    pub line_changes: Vec<Option<PreviewLineChange>>,
156    pub fallback_reason: Option<PreviewFallbackReason>,
157    pub truncated: bool,
158    pub error_message: Option<String>,
159}
160
161impl Default for PreviewDocument {
162    fn default() -> Self {
163        Self {
164            source_path: PathBuf::new(),
165            load_state: LoadState::Idle,
166            content_type: ContentType::PlainText,
167            image_preview: false,
168            image_preview_pending: false,
169            language_id: None,
170            content_excerpt: String::new(),
171            styled_lines: Vec::new(),
172            display_line_numbers: Vec::new(),
173            line_changes: Vec::new(),
174            fallback_reason: None,
175            truncated: false,
176            error_message: None,
177        }
178    }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum FocusPane {
183    Tree,
184    Preview,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct SelectedEntryMetadata {
189    pub filename: String,
190    pub size_text: String,
191    pub permission_text: String,
192    pub modified_text: String,
193    pub hidden_text: String,
194}
195
196impl Default for SelectedEntryMetadata {
197    fn default() -> Self {
198        Self {
199            filename: "-".to_string(),
200            size_text: "-".to_string(),
201            permission_text: "-".to_string(),
202            modified_text: "-".to_string(),
203            hidden_text: "off".to_string(),
204        }
205    }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct LayoutRegions {
210    pub top_directory_header: bool,
211    pub left_navigation_panel: bool,
212    pub right_preview_panel: bool,
213    pub preview_top_status_bar: bool,
214    pub bottom_global_status_bar: bool,
215}
216
217impl Default for LayoutRegions {
218    fn default() -> Self {
219        Self {
220            top_directory_header: true,
221            left_navigation_panel: true,
222            right_preview_panel: true,
223            preview_top_status_bar: true,
224            bottom_global_status_bar: true,
225        }
226    }
227}
228
229#[derive(Debug, Clone)]
230pub struct SessionState {
231    pub root_path: PathBuf,
232    pub current_path: PathBuf,
233    pub selected_index: usize,
234    pub selected_path: PathBuf,
235    pub selected_changed_at: Instant,
236    pub focus_pane: FocusPane,
237    pub status_message: String,
238    pub current_dir_error: Option<String>,
239    pub last_preview_latency_ms: u128,
240    pub last_child_path: Option<PathBuf>,
241    pub show_hidden: bool,
242    pub selected_metadata: SelectedEntryMetadata,
243    pub layout_regions: LayoutRegions,
244    pub preview_width_cols: u16,
245    pub tree_min_width_cols: u16,
246    pub preview_min_width_cols: u16,
247    pub preview_resize_step_cols: u16,
248    pub preview_scroll_row: usize,
249    pub preview_show_line_numbers: bool,
250    pub preview_wrap_enabled: bool,
251    pub preview_fullscreen: bool,
252    pub divider_drag_active: bool,
253    pub divider_drag_column: Option<u16>,
254    pub preview_scroll_col: usize,
255    pub preview_selection: Option<PreviewSelection>,
256    pub preview_selecting: bool,
257    pub preview_scrollbar_dragging: bool,
258    pub preview_inner_rect: (u16, u16, u16, u16),
259    pub preview_line_number_cols: usize,
260    pub preview_render_epoch: u64,
261    pub preview_render_cache: Option<PreviewRenderCache>,
262    pub preview_diff_cache: Option<PreviewDiffCache>,
263    pub preview_copy_indicator: bool,
264    pub preview_copying_indicator: bool,
265    pub preview_diff_mode: bool,
266    pub preview_search: Option<PreviewSearch>,
267    pub preview_search_input_active: bool,
268    pub help_overlay_visible: bool,
269    pub status_display_mode: StatusDisplayMode,
270    pub git_status: Option<GitRepoStatus>,
271    pub deferred_input_event: Option<Event>,
272}
273
274impl SessionState {
275    const DEFAULT_TREE_WIDTH_DENOMINATOR: u16 = 6;
276    const DEFAULT_PANEL_MIN_WIDTH: u16 = 20;
277    const DEFAULT_RESIZE_STEP: u16 = 2;
278
279    pub fn new(root_path: PathBuf) -> Self {
280        Self {
281            root_path: root_path.clone(),
282            current_path: root_path.clone(),
283            selected_index: 0,
284            selected_path: root_path,
285            selected_changed_at: Instant::now(),
286            focus_pane: FocusPane::Tree,
287            status_message: String::new(),
288            current_dir_error: None,
289            last_preview_latency_ms: 0,
290            last_child_path: None,
291            show_hidden: false,
292            selected_metadata: SelectedEntryMetadata::default(),
293            layout_regions: LayoutRegions::default(),
294            preview_width_cols: 0,
295            tree_min_width_cols: Self::DEFAULT_PANEL_MIN_WIDTH,
296            preview_min_width_cols: Self::DEFAULT_PANEL_MIN_WIDTH,
297            preview_resize_step_cols: Self::DEFAULT_RESIZE_STEP,
298            preview_scroll_row: 0,
299            preview_show_line_numbers: true,
300            preview_wrap_enabled: false,
301            preview_fullscreen: false,
302            divider_drag_active: false,
303            divider_drag_column: None,
304            preview_scroll_col: 0,
305            preview_selection: None,
306            preview_selecting: false,
307            preview_scrollbar_dragging: false,
308            preview_inner_rect: (0, 0, 0, 0),
309            preview_line_number_cols: 0,
310            preview_render_epoch: 0,
311            preview_render_cache: None,
312            preview_diff_cache: None,
313            preview_copy_indicator: false,
314            preview_copying_indicator: false,
315            preview_diff_mode: false,
316            preview_search: None,
317            preview_search_input_active: false,
318            help_overlay_visible: false,
319            status_display_mode: StatusDisplayMode::Bar,
320            git_status: None,
321            deferred_input_event: None,
322        }
323    }
324
325    pub fn normalize_preview_width(&mut self, main_width: u16) {
326        self.preview_width_cols = self.effective_preview_width(main_width);
327    }
328
329    pub fn panel_widths(&self, main_width: u16) -> (u16, u16) {
330        let preview = self.effective_preview_width(main_width);
331        let tree = main_width.saturating_sub(preview);
332        (tree, preview)
333    }
334
335    pub fn resize_step(&self) -> u16 {
336        self.preview_resize_step_cols.max(1)
337    }
338
339    pub fn reset_preview_scroll(&mut self) {
340        self.preview_scroll_row = 0;
341        self.preview_scroll_col = 0;
342        self.preview_selection = None;
343        self.preview_selecting = false;
344        self.preview_scrollbar_dragging = false;
345    }
346
347    pub fn clamp_preview_scroll(&mut self, total_lines: usize, viewport_rows: usize) {
348        let max_scroll = max_scroll_row(total_lines, viewport_rows);
349        if self.preview_scroll_row > max_scroll {
350            self.preview_scroll_row = max_scroll;
351        }
352    }
353
354    pub fn scroll_preview_lines(
355        &mut self,
356        delta: isize,
357        total_lines: usize,
358        viewport_rows: usize,
359    ) -> bool {
360        let before = self.preview_scroll_row;
361        let max_scroll = max_scroll_row(total_lines, viewport_rows);
362        if delta < 0 {
363            self.preview_scroll_row = self.preview_scroll_row.saturating_sub((-delta) as usize);
364        } else if delta > 0 {
365            self.preview_scroll_row = self
366                .preview_scroll_row
367                .saturating_add(delta as usize)
368                .min(max_scroll);
369        }
370        self.preview_scroll_row != before
371    }
372
373    pub fn scroll_preview_cols(
374        &mut self,
375        delta: isize,
376        max_width: usize,
377        viewport_cols: usize,
378    ) -> bool {
379        let before = self.preview_scroll_col;
380        let max_scroll = max_width.saturating_sub(viewport_cols);
381        if delta < 0 {
382            self.preview_scroll_col = self.preview_scroll_col.saturating_sub((-delta) as usize);
383        } else {
384            self.preview_scroll_col = self
385                .preview_scroll_col
386                .saturating_add(delta as usize)
387                .min(max_scroll);
388        }
389        self.preview_scroll_col != before
390    }
391
392    pub fn page_scroll_preview_down(&mut self, total_lines: usize, viewport_rows: usize) -> bool {
393        let page = viewport_rows.max(1) as isize;
394        self.scroll_preview_lines(page, total_lines, viewport_rows)
395    }
396
397    pub fn page_scroll_preview_up(&mut self, total_lines: usize, viewport_rows: usize) -> bool {
398        let page = viewport_rows.max(1) as isize;
399        self.scroll_preview_lines(-page, total_lines, viewport_rows)
400    }
401
402    pub fn resize_preview_by(&mut self, delta_cols: i16, main_width: u16) {
403        let base = i32::from(self.effective_preview_width(main_width));
404        let desired = (base + i32::from(delta_cols)).max(0) as u16;
405        self.preview_width_cols = self.clamped_preview_width(main_width, desired);
406    }
407
408    pub fn set_preview_width_from_divider(&mut self, divider_col: u16, main_width: u16) {
409        let tree = divider_col.min(main_width);
410        let desired_preview = main_width.saturating_sub(tree);
411        self.preview_width_cols = self.clamped_preview_width(main_width, desired_preview);
412    }
413
414    pub fn clamped_divider_column(&self, divider_col: u16, main_width: u16) -> u16 {
415        let tree = divider_col.min(main_width);
416        let desired_preview = main_width.saturating_sub(tree);
417        let preview = self.clamped_preview_width(main_width, desired_preview);
418        main_width.saturating_sub(preview)
419    }
420
421    pub fn divider_column(&self, main_width: u16) -> u16 {
422        let (tree, _) = self.panel_widths(main_width);
423        tree
424    }
425
426    fn effective_preview_width(&self, main_width: u16) -> u16 {
427        let desired = if self.preview_width_cols == 0 {
428            let default_tree_width = main_width / Self::DEFAULT_TREE_WIDTH_DENOMINATOR;
429            main_width.saturating_sub(default_tree_width)
430        } else {
431            self.preview_width_cols
432        };
433        self.clamped_preview_width(main_width, desired)
434    }
435
436    fn clamped_preview_width(&self, main_width: u16, desired: u16) -> u16 {
437        if main_width == 0 {
438            return 0;
439        }
440
441        let preview_min = self.preview_min_width_cols.min(main_width);
442        let tree_min = self
443            .tree_min_width_cols
444            .min(main_width.saturating_sub(preview_min));
445        let preview_max = main_width.saturating_sub(tree_min).max(preview_min);
446        desired.clamp(preview_min, preview_max)
447    }
448
449    pub fn revalidate_selection(&mut self, nodes: &[TreeNode]) {
450        if nodes.is_empty() {
451            self.selected_index = 0;
452            return;
453        }
454        if self.selected_index >= nodes.len() {
455            self.selected_index = nodes.len().saturating_sub(1);
456        }
457    }
458
459    pub fn restore_or_default_selection(
460        &mut self,
461        nodes: &[TreeNode],
462        preferred: Option<&PathBuf>,
463    ) {
464        if nodes.is_empty() {
465            self.selected_index = 0;
466            return;
467        }
468
469        if let Some(path) = preferred {
470            if let Some(idx) = nodes.iter().position(|n| &n.path == path) {
471                self.selected_index = idx;
472                return;
473            }
474        }
475
476        self.selected_index = 0;
477    }
478
479    pub fn update_selected_path(&mut self, nodes: &[TreeNode]) {
480        let next = nodes
481            .get(self.selected_index)
482            .map(|n| n.path.clone())
483            .unwrap_or_else(|| self.current_path.clone());
484        self.set_selected_path(next);
485    }
486
487    pub fn set_selected_path(&mut self, path: PathBuf) {
488        if self.selected_path != path {
489            self.selected_path = path;
490            self.selected_changed_at = Instant::now();
491        }
492    }
493
494    pub fn set_current_dir_error(&mut self, message: impl Into<String>) {
495        self.current_dir_error = Some(message.into());
496    }
497
498    pub fn clear_current_dir_error(&mut self) {
499        self.current_dir_error = None;
500    }
501}
502
503fn max_scroll_row(total_lines: usize, viewport_rows: usize) -> usize {
504    total_lines.saturating_sub(viewport_rows.max(1))
505}