lazylog_framework/app/
mod.rs

1use crate::{
2    app_block::AppBlock,
3    filter::FilterEngine,
4    log_list::LogList,
5    log_parser::{LogDetailLevel, LogItem},
6    provider::{LogParser, LogProvider, spawn_provider_thread},
7    status_bar::DisplayEvent,
8    theme,
9    ui_logger::UiLogger,
10};
11use anyhow::{Result, anyhow};
12use crossterm::event::{self, Event, MouseEvent};
13use ratatui::{Terminal, backend::CrosstermBackend, prelude::*, widgets::Widget};
14use ringbuf::{
15    HeapRb,
16    traits::{Consumer, Split},
17};
18use std::{
19    io,
20    sync::{
21        Arc, Mutex,
22        atomic::{AtomicBool, Ordering},
23    },
24    thread,
25    time::Duration,
26};
27
28mod events;
29mod render;
30mod scrolling;
31mod selection;
32
33// constants
34const DEFAULT_POLL_INTERVAL_MS: u64 = 100;
35const DEFAULT_RING_BUFFER_SIZE: usize = 16384;
36const HELP_POPUP_WIDTH: u16 = 60;
37const SCROLL_PAD: usize = 1;
38const HORIZONTAL_SCROLL_STEP: usize = 5;
39const DISPLAY_EVENT_DURATION_MS: u64 = 800;
40
41#[derive(Clone)]
42pub struct AppDesc {
43    pub poll_interval: Duration,
44    pub show_debug_logs: bool,
45    pub ring_buffer_size: usize,
46    pub parser: Arc<dyn LogParser>,
47}
48
49impl AppDesc {
50    pub fn new(parser: Arc<dyn LogParser>) -> Self {
51        Self {
52            poll_interval: Duration::from_millis(DEFAULT_POLL_INTERVAL_MS),
53            show_debug_logs: false,
54            ring_buffer_size: DEFAULT_RING_BUFFER_SIZE,
55            parser,
56        }
57    }
58}
59
60/// Start the application with default configuration
61pub fn start_with_provider<P>(
62    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
63    provider: P,
64    parser: Arc<dyn LogParser>,
65) -> Result<()>
66where
67    P: LogProvider + 'static,
68{
69    start_with_desc(terminal, provider, AppDesc::new(parser))
70}
71
72/// Start the application with custom configuration
73pub fn start_with_desc<P>(
74    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
75    provider: P,
76    desc: AppDesc,
77) -> Result<()>
78where
79    P: LogProvider + 'static,
80{
81    color_eyre::install().or(Err(anyhow!("Error installing color_eyre")))?;
82
83    let app = App::new(provider, desc.clone());
84    app.run(terminal, &desc)
85}
86
87struct App {
88    is_exiting: bool,
89    raw_logs: Vec<LogItem>,
90    displaying_logs: LogList,
91    log_consumer: ringbuf::HeapCons<LogItem>, // receives logs from provider thread
92    provider_thread: Option<thread::JoinHandle<()>>,
93    provider_stop_signal: Arc<AtomicBool>,
94    autoscroll: bool,
95    filter_input: String, // Current filter input text (includes leading '/')
96    filter_focused: bool, // Whether the filter input is focused
97    filter_engine: FilterEngine, // Filtering engine with incremental + parallel support
98    detail_level: LogDetailLevel, // Detail level for log display
99    parser: Arc<dyn LogParser>, // Parser for log items (handles both parsing and formatting)
100    debug_logs: Arc<Mutex<Vec<String>>>, // Debug log messages for UI display
101    hard_focused_block_id: uuid::Uuid, // Hard focus: set by clicking, persists until another click (defaults to logs_block)
102    soft_focused_block_id: Option<uuid::Uuid>, // Soft focus: set by hovering, changes with mouse movement
103    logs_block: AppBlock,
104    details_block: AppBlock,
105    debug_block: AppBlock,
106    prev_selected_log_id: Option<uuid::Uuid>, // Track previous selected log item ID for details reset
107    selected_log_uuid: Option<uuid::Uuid>,    // Track currently selected log item UUID
108    last_logs_area: Option<Rect>, // Store the last rendered logs area for selection visibility
109    last_details_area: Option<Rect>, // Store the last rendered details area
110    last_debug_area: Option<Rect>, // Store the last rendered debug area
111    last_logs_viewport_height: Option<usize>, // Track viewport height to preserve bottom item on resize
112    text_wrapping_enabled: bool,              // Whether text wrapping is enabled (default false)
113    show_debug_logs: bool,                    // Whether to show the debug logs block
114    show_help_popup: bool,                    // Whether to show the help popup
115    display_event: Option<DisplayEvent>,      // Temporary event to display in footer
116    prev_hard_focused_block_id: uuid::Uuid,   // Track previous hard focus to detect changes
117
118    mouse_event: Option<MouseEvent>,
119}
120
121#[derive(Copy, Clone)]
122pub(super) enum ScrollableBlockType {
123    Details,
124    Debug,
125}
126
127// ============================================================================
128// Initialization
129// ============================================================================
130impl App {
131    fn setup_logger() -> Arc<Mutex<Vec<String>>> {
132        let debug_logs = Arc::new(Mutex::new(Vec::new()));
133        let logger = Box::new(UiLogger::new(debug_logs.clone()));
134
135        if log::set_logger(Box::leak(logger)).is_ok() {
136            log::set_max_level(log::LevelFilter::Debug);
137        }
138
139        debug_logs
140    }
141
142    fn new<P>(provider: P, desc: AppDesc) -> Self
143    where
144        P: LogProvider + 'static,
145    {
146        let debug_logs = Self::setup_logger();
147
148        // create ring buffer
149        let ring_buffer = HeapRb::<LogItem>::new(desc.ring_buffer_size);
150        let (producer, consumer) = ring_buffer.split();
151
152        // spawn provider thread
153        let poll_interval = desc.poll_interval;
154        let (provider_thread, provider_stop_signal) =
155            spawn_provider_thread(provider, desc.parser.clone(), producer, poll_interval);
156
157        // create blocks first so we can reference their IDs
158        let logs_block = AppBlock::new().set_title("[1]─Logs".to_string());
159        let details_block = AppBlock::new()
160            .set_title("[2]─Details")
161            .set_padding(ratatui::widgets::Padding::horizontal(1));
162        let debug_block = AppBlock::new()
163            .set_title("[3]─Debug Logs")
164            .set_padding(ratatui::widgets::Padding::horizontal(1));
165
166        let logs_block_id = logs_block.id();
167
168        // setup filter engine with parser
169        let mut filter_engine = FilterEngine::new();
170        filter_engine.set_formatter(desc.parser.clone());
171
172        Self {
173            is_exiting: false,
174            raw_logs: Vec::new(),
175            displaying_logs: LogList::new(Vec::new()),
176            log_consumer: consumer,
177            provider_thread: Some(provider_thread),
178            provider_stop_signal,
179            autoscroll: true,
180            filter_input: String::new(),
181            filter_focused: false,
182            filter_engine,
183            detail_level: 1, // default detail level (was Basic)
184            parser: desc.parser,
185            debug_logs,
186            hard_focused_block_id: logs_block_id,
187            soft_focused_block_id: None,
188            logs_block,
189            details_block,
190            debug_block,
191            prev_selected_log_id: None,
192            selected_log_uuid: None,
193            last_logs_area: None,
194            last_details_area: None,
195            last_debug_area: None,
196            last_logs_viewport_height: None,
197            text_wrapping_enabled: true,
198            show_debug_logs: desc.show_debug_logs,
199            show_help_popup: false,
200            display_event: None,
201            prev_hard_focused_block_id: logs_block_id,
202
203            mouse_event: None,
204        }
205    }
206}
207
208// ============================================================================
209// Lifecycle
210// ============================================================================
211impl App {
212    fn run(
213        mut self,
214        terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
215        desc: &AppDesc,
216    ) -> Result<()> {
217        let poll_interval = desc.poll_interval;
218
219        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<()> {
220            while !self.is_exiting {
221                self.poll_event(poll_interval)?;
222                self.update_logs()?;
223                self.check_and_clear_expired_event();
224                terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
225            }
226            Ok(())
227        }));
228
229        // cleanup provider thread before returning
230        self.cleanup();
231
232        match result {
233            Ok(r) => r,
234            Err(_) => {
235                eprintln!("Application panicked, terminal restored");
236                std::process::exit(1);
237            }
238        }
239    }
240
241    fn cleanup(&mut self) {
242        // signal provider thread to stop
243        self.provider_stop_signal.store(true, Ordering::Relaxed);
244
245        // join the provider thread
246        if let Some(handle) = self.provider_thread.take() {
247            log::debug!("Waiting for provider thread to finish...");
248            if let Err(e) = handle.join() {
249                log::error!("Provider thread panicked: {:?}", e);
250            }
251        }
252    }
253
254    fn poll_event(&mut self, poll_interval: Duration) -> Result<()> {
255        if event::poll(poll_interval)? {
256            let event = event::read()?;
257            match event {
258                Event::Key(key) => self.handle_key(key)?,
259                Event::Mouse(mouse) => {
260                    self.handle_mouse_event(&mouse)?;
261                    self.mouse_event = Some(mouse);
262                }
263                Event::Resize(width, height) => {
264                    log::debug!("Terminal resized to {}x{}", width, height);
265                }
266                _ => {}
267            }
268        }
269
270        Ok(())
271    }
272}
273
274// ============================================================================
275// Utility methods
276// ============================================================================
277impl App {
278    fn to_underlying_index(_total: usize, visual_index: usize) -> usize {
279        visual_index
280    }
281
282    fn to_visual_index(_total: usize, underlying_index: usize) -> usize {
283        underlying_index
284    }
285
286    fn is_log_block_focused(&self) -> Result<bool> {
287        Ok(self.get_display_focused_block() == self.logs_block.id())
288    }
289}
290
291// ============================================================================
292// Log and filter management
293// ============================================================================
294impl App {
295    fn update_logs(&mut self) -> Result<()> {
296        // consume all available logs from ring buffer
297        let mut new_logs = Vec::new();
298        while let Some(log) = self.log_consumer.try_pop() {
299            new_logs.push(log);
300        }
301
302        if new_logs.is_empty() {
303            return Ok(());
304        }
305
306        let previous_uuid = self.selected_log_uuid;
307        let previous_scroll_pos = Some(self.logs_block.get_scroll_position());
308
309        log::debug!("Received {} new log items from provider", new_logs.len());
310        let old_raw_count = self.raw_logs.len();
311        self.raw_logs.extend(new_logs);
312
313        // use incremental filtering for efficiency (only filters new logs)
314        let filter_query = self.get_filter_query().to_string();
315        let filtered_indices = self.filter_engine.filter_new_logs(
316            &self.raw_logs,
317            old_raw_count,
318            &filter_query,
319            self.detail_level,
320        );
321        self.displaying_logs = LogList::new(filtered_indices);
322
323        if previous_uuid.is_some() {
324            self.update_selection_by_uuid();
325        }
326
327        {
328            let new_items_count = self.displaying_logs.len();
329
330            if self.autoscroll {
331                // scroll to bottom (stop when last item is fully displayed)
332                let viewport_height = if let Some(area) = self.last_logs_area {
333                    let is_focused = self.is_log_block_focused().unwrap_or(false);
334                    let [main_content_area, _] =
335                        Layout::horizontal([Constraint::Fill(1), Constraint::Length(1)])
336                            .margin(0)
337                            .areas(area);
338
339                    let [content_area, _] =
340                        Layout::vertical([Constraint::Fill(1), Constraint::Length(1)])
341                            .margin(0)
342                            .areas(main_content_area);
343
344                    let inner_area = self.logs_block.get_content_rect(content_area, is_focused);
345                    inner_area.height as usize
346                } else {
347                    1 // fallback if area not yet rendered
348                };
349
350                let max_scroll = new_items_count.saturating_sub(viewport_height);
351                self.logs_block.set_scroll_position(max_scroll);
352            } else if previous_scroll_pos.is_some() {
353                // oldest is at visual index 0, newest at end;
354                // adding items doesn't change visual position of existing items,
355                // so scroll position stays the same
356                // (scroll position is already set correctly, no adjustment needed)
357            }
358
359            self.logs_block.set_lines_count(new_items_count);
360            self.logs_block.update_scrollbar_state(
361                new_items_count,
362                Some(self.logs_block.get_scroll_position()),
363            );
364        }
365
366        Ok(())
367    }
368
369    fn get_filter_query(&self) -> &str {
370        // filter_input includes the leading '/', so skip it
371        if self.filter_input.starts_with('/') && self.filter_input.len() > 1 {
372            &self.filter_input[1..]
373        } else {
374            ""
375        }
376    }
377
378    fn apply_filter(&mut self) {
379        let previous_uuid = self.selected_log_uuid;
380        let prev_scroll_pos = self.logs_block.get_scroll_position();
381
382        // calculate the relative position of the selected item in the viewport
383        let prev_relative_offset = if let Some(selected_idx) = self.displaying_logs.state.selected()
384        {
385            selected_idx.saturating_sub(prev_scroll_pos)
386        } else {
387            0
388        };
389
390        self.rebuild_filtered_list();
391
392        if previous_uuid.is_some() {
393            self.update_selection_by_uuid();
394
395            // if the previously selected item is no longer in the filtered list,
396            // clear selection
397            if self.selected_log_uuid.is_none() {
398                self.displaying_logs.state.select(None);
399            }
400        }
401
402        {
403            let new_total = self.displaying_logs.len();
404            let mut pos = prev_scroll_pos;
405            if new_total == 0 {
406                pos = 0;
407            } else {
408                // try to preserve the relative screen position of the selected item
409                if let Some(selected_idx) = self.displaying_logs.state.selected() {
410                    // calculate desired scroll position to maintain relative offset
411                    let desired_scroll = selected_idx.saturating_sub(prev_relative_offset);
412                    // clamp to valid range
413                    pos = desired_scroll.min(new_total.saturating_sub(1));
414                } else {
415                    // fallback to previous scroll position
416                    pos = pos.min(new_total.saturating_sub(1));
417                }
418            }
419            self.logs_block.set_scroll_position(pos);
420            self.logs_block.set_lines_count(new_total);
421            self.logs_block.update_scrollbar_state(new_total, Some(pos));
422        }
423
424        // ensure the selected item is scrolled into view after filter changes
425        let _ = self.ensure_selection_visible();
426    }
427
428    fn rebuild_filtered_list(&mut self) {
429        let filter_query = self.get_filter_query().to_string();
430
431        // use FilterEngine for filtering (incremental + parallel)
432        let filtered_indices =
433            self.filter_engine
434                .filter(&self.raw_logs, &filter_query, self.detail_level);
435
436        self.displaying_logs = LogList::new(filtered_indices);
437    }
438
439    fn update_logs_scrollbar_state(&mut self) {
440        let total = self.displaying_logs.len();
441
442        {
443            let max_top = total.saturating_sub(1);
444            let pos = self.logs_block.get_scroll_position().min(max_top);
445            self.logs_block.set_scroll_position(pos);
446
447            self.logs_block.set_lines_count(total);
448            self.logs_block.update_scrollbar_state(total, Some(pos));
449        }
450    }
451}
452
453// ============================================================================
454// Focus management
455// ============================================================================
456impl App {
457    fn set_hard_focused_block(&mut self, block_id: uuid::Uuid) {
458        self.hard_focused_block_id = block_id;
459    }
460
461    fn set_soft_focused_block(&mut self, block_id: uuid::Uuid) {
462        if self.soft_focused_block_id != Some(block_id) {
463            self.soft_focused_block_id = Some(block_id);
464        }
465    }
466
467    fn get_display_focused_block(&self) -> uuid::Uuid {
468        self.hard_focused_block_id
469    }
470
471    fn is_mouse_in_area(&self, mouse: &MouseEvent, area: Rect) -> bool {
472        mouse.column >= area.x
473            && mouse.column < area.x + area.width
474            && mouse.row >= area.y
475            && mouse.row < area.y + area.height
476    }
477
478    fn get_block_under_mouse(&self, mouse: &MouseEvent) -> Option<uuid::Uuid> {
479        if let Some(area) = self.last_logs_area
480            && self.is_mouse_in_area(mouse, area)
481        {
482            return Some(self.logs_block.id());
483        }
484
485        if let Some(area) = self.last_details_area
486            && self.is_mouse_in_area(mouse, area)
487        {
488            return Some(self.details_block.id());
489        }
490
491        if let Some(area) = self.last_debug_area
492            && self.is_mouse_in_area(mouse, area)
493        {
494            return Some(self.debug_block.id());
495        }
496
497        None
498    }
499}
500
501// ============================================================================
502// Display events
503// ============================================================================
504impl App {
505    /// Set a display event to show in the footer for a given duration
506    pub fn set_display_event(&mut self, text: String, duration: Duration, style: Option<Style>) {
507        self.display_event = Some(DisplayEvent::create(
508            text,
509            duration,
510            style,
511            theme::DISPLAY_EVENT_STYLE,
512        ));
513    }
514
515    /// Check if the current display event has expired and clear it if so
516    fn check_and_clear_expired_event(&mut self) {
517        self.display_event = DisplayEvent::check_and_clear(self.display_event.take());
518    }
519
520    fn clear_event(&mut self) {
521        self.mouse_event = None;
522    }
523}
524
525// ============================================================================
526// Widget implementation
527// ============================================================================
528impl Widget for &mut App {
529    fn render(self, area: Rect, buf: &mut Buffer) {
530        // detect if hard focus changed since last render
531        let focus_changed = self.hard_focused_block_id != self.prev_hard_focused_block_id;
532
533        // determine dynamic layout based on hard focus
534        let (logs_percentage, details_percentage) =
535            if self.hard_focused_block_id == self.logs_block.id() {
536                (60, 40)
537            } else if self.hard_focused_block_id == self.details_block.id() {
538                (40, 60)
539            } else {
540                (60, 40) // default for debug block or any other case
541            };
542
543        if self.show_debug_logs {
544            let [main, debug_area, footer_area] = Layout::vertical([
545                Constraint::Fill(1),
546                Constraint::Length(6),
547                Constraint::Length(1),
548            ])
549            .areas(area);
550
551            let [logs_area, details_area] = Layout::vertical([
552                Constraint::Percentage(logs_percentage),
553                Constraint::Percentage(details_percentage),
554            ])
555            .areas(main);
556
557            self.render_logs(logs_area, buf).unwrap();
558            self.render_details(details_area, buf).unwrap();
559            self.render_debug_logs(debug_area, buf).unwrap();
560            self.render_footer(footer_area, buf).unwrap();
561        } else {
562            let [main, footer_area] =
563                Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
564
565            let [logs_area, details_area] = Layout::vertical([
566                Constraint::Percentage(logs_percentage),
567                Constraint::Percentage(details_percentage),
568            ])
569            .areas(main);
570
571            self.render_logs(logs_area, buf).unwrap();
572            self.render_details(details_area, buf).unwrap();
573            self.render_footer(footer_area, buf).unwrap();
574        }
575
576        // render help popup on top if visible
577        if self.show_help_popup {
578            self.render_help_popup(area, buf).unwrap();
579        }
580
581        // adjust viewport if hard focus changed (panels resized)
582        if focus_changed {
583            log::debug!("Hard focus changed, adjusting viewport");
584            let _ = self.ensure_selection_visible();
585            self.prev_hard_focused_block_id = self.hard_focused_block_id;
586        }
587
588        self.clear_event();
589    }
590}