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
33const 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
60pub 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
72pub 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>, provider_thread: Option<thread::JoinHandle<()>>,
93 provider_stop_signal: Arc<AtomicBool>,
94 autoscroll: bool,
95 filter_input: String, filter_focused: bool, filter_engine: FilterEngine, detail_level: LogDetailLevel, parser: Arc<dyn LogParser>, debug_logs: Arc<Mutex<Vec<String>>>, hard_focused_block_id: uuid::Uuid, soft_focused_block_id: Option<uuid::Uuid>, logs_block: AppBlock,
104 details_block: AppBlock,
105 debug_block: AppBlock,
106 prev_selected_log_id: Option<uuid::Uuid>, selected_log_uuid: Option<uuid::Uuid>, last_logs_area: Option<Rect>, last_details_area: Option<Rect>, last_debug_area: Option<Rect>, last_logs_viewport_height: Option<usize>, text_wrapping_enabled: bool, show_debug_logs: bool, show_help_popup: bool, display_event: Option<DisplayEvent>, prev_hard_focused_block_id: uuid::Uuid, mouse_event: Option<MouseEvent>,
119}
120
121#[derive(Copy, Clone)]
122pub(super) enum ScrollableBlockType {
123 Details,
124 Debug,
125}
126
127impl 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 let ring_buffer = HeapRb::<LogItem>::new(desc.ring_buffer_size);
150 let (producer, consumer) = ring_buffer.split();
151
152 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 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 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, 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
208impl 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 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 self.provider_stop_signal.store(true, Ordering::Relaxed);
244
245 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
274impl 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
291impl App {
295 fn update_logs(&mut self) -> Result<()> {
296 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 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 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 };
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 }
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 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 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 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 if let Some(selected_idx) = self.displaying_logs.state.selected() {
410 let desired_scroll = selected_idx.saturating_sub(prev_relative_offset);
412 pos = desired_scroll.min(new_total.saturating_sub(1));
414 } else {
415 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 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 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
453impl 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
501impl App {
505 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 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
525impl Widget for &mut App {
529 fn render(self, area: Rect, buf: &mut Buffer) {
530 let focus_changed = self.hard_focused_block_id != self.prev_hard_focused_block_id;
532
533 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) };
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 if self.show_help_popup {
578 self.render_help_popup(area, buf).unwrap();
579 }
580
581 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}