Skip to main content

ftui_widgets/
log_viewer.rs

1#![forbid(unsafe_code)]
2
3//! A scrolling log viewer widget optimized for streaming append-only content.
4//!
5//! `LogViewer` is THE essential widget for agent harness UIs. It displays streaming
6//! logs with scrollback while maintaining UI chrome and handles:
7//!
8//! - High-frequency log line additions without flicker
9//! - Auto-scroll behavior for "follow" mode
10//! - Manual scrolling to inspect history
11//! - Memory bounds via circular buffer eviction
12//! - Substring filtering for log lines
13//! - Text search with next/prev match navigation
14//!
15//! # Architecture
16//!
17//! LogViewer delegates storage and scroll state to [`Virtualized<Text>`], gaining
18//! momentum scrolling, overscan, and page navigation for free. LogViewer adds
19//! capacity management (eviction), wrapping, filtering, and search on top.
20//!
21//! # Example
22//! ```ignore
23//! use ftui_widgets::log_viewer::{LogViewer, LogViewerState, LogWrapMode};
24//! use ftui_text::Text;
25//!
26//! // Create a viewer with 10,000 line capacity
27//! let mut viewer = LogViewer::new(10_000);
28//!
29//! // Push log lines (styled or plain)
30//! viewer.push("Starting process...");
31//! viewer.push(Text::styled("ERROR: failed", Style::new().fg(Color::Red)));
32//!
33//! // Render with state
34//! let mut state = LogViewerState::default();
35//! viewer.render(area, frame, &mut state);
36//! ```
37
38use ftui_core::geometry::Rect;
39use ftui_render::frame::Frame;
40use ftui_style::Style;
41use ftui_text::search::{search_ascii_case_insensitive, search_exact};
42use ftui_text::{Text, WrapMode, WrapOptions, display_width, wrap_with_options};
43
44use crate::virtualized::Virtualized;
45use crate::{StatefulWidget, draw_text_span, draw_text_span_with_link};
46
47/// Line wrapping mode for log lines.
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum LogWrapMode {
50    /// No wrapping, truncate long lines.
51    #[default]
52    NoWrap,
53    /// Wrap at any character boundary.
54    CharWrap,
55    /// Wrap at word boundaries (Unicode-aware).
56    WordWrap,
57}
58
59impl From<LogWrapMode> for WrapMode {
60    fn from(mode: LogWrapMode) -> Self {
61        match mode {
62            LogWrapMode::NoWrap => WrapMode::None,
63            LogWrapMode::CharWrap => WrapMode::Char,
64            LogWrapMode::WordWrap => WrapMode::Word,
65        }
66    }
67}
68
69/// Search mode for log search.
70#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
71pub enum SearchMode {
72    /// Plain substring matching.
73    #[default]
74    Literal,
75    /// Regular expression matching (requires `regex-search` feature).
76    Regex,
77}
78
79/// Search configuration.
80#[derive(Clone, Debug)]
81pub struct SearchConfig {
82    /// Search mode (literal or regex).
83    pub mode: SearchMode,
84    /// Whether the search is case-sensitive.
85    pub case_sensitive: bool,
86    /// Number of context lines around matches (0 = only matching lines).
87    pub context_lines: usize,
88}
89
90impl Default for SearchConfig {
91    fn default() -> Self {
92        Self {
93            mode: SearchMode::Literal,
94            case_sensitive: true,
95            context_lines: 0,
96        }
97    }
98}
99
100/// Search state for text search within the log.
101#[derive(Debug, Clone)]
102struct SearchState {
103    /// The search query string (retained for re-search after eviction).
104    query: String,
105    /// Lowercase query for case-insensitive search optimization.
106    query_lower: Option<String>,
107    /// Current search configuration.
108    config: SearchConfig,
109    /// Indices of matching lines.
110    matches: Vec<usize>,
111    /// Current match index within the matches vector.
112    current: usize,
113    /// Per-match-line byte ranges for highlighting. Indexed by position in `matches`.
114    highlight_ranges: Vec<Vec<(usize, usize)>>,
115    /// Compiled regex pattern (behind feature gate).
116    #[cfg(feature = "regex-search")]
117    compiled_regex: Option<regex::Regex>,
118    /// Indices including context lines around matches (sorted, deduped).
119    /// `None` when `config.context_lines == 0`.
120    context_expanded: Option<Vec<usize>>,
121}
122
123/// Statistics tracking incremental vs full-rescan filter/search operations.
124///
125/// Useful for monitoring the efficiency of the streaming update path.
126/// Reset with [`FilterStats::reset`].
127#[derive(Debug, Clone, Default)]
128pub struct FilterStats {
129    /// Lines checked incrementally (O(1) per push).
130    pub incremental_checks: u64,
131    /// Lines that matched during incremental checks.
132    pub incremental_matches: u64,
133    /// Full rescans triggered (e.g., by `set_filter` or `search`).
134    pub full_rescans: u64,
135    /// Total lines scanned during full rescans.
136    pub full_rescan_lines: u64,
137    /// Search matches added incrementally on push.
138    pub incremental_search_matches: u64,
139}
140
141impl FilterStats {
142    /// Reset all counters to zero.
143    pub fn reset(&mut self) {
144        *self = Self::default();
145    }
146}
147
148/// A scrolling log viewer optimized for streaming append-only content.
149///
150/// Internally uses [`Virtualized<Text>`] for storage and scroll management,
151/// adding capacity enforcement, wrapping, filtering, and search on top.
152///
153/// # Design Rationale
154/// - Virtualized handles scroll offset, follow mode, momentum, page navigation
155/// - LogViewer adds max_lines eviction (Virtualized has no built-in capacity limit)
156/// - Separate scroll semantics: Virtualized uses "offset from top"; LogViewer
157///   exposes "follow mode" (newest at bottom) as the default behavior
158/// - wrap_mode configurable per-instance for different use cases
159/// - Stateful widget pattern for scroll state preservation across renders
160#[derive(Debug, Clone)]
161pub struct LogViewer {
162    /// Virtualized storage with scroll state management.
163    virt: Virtualized<Text>,
164    /// Maximum lines to retain (memory bound).
165    max_lines: usize,
166    /// Line wrapping mode.
167    wrap_mode: LogWrapMode,
168    /// Default style for lines.
169    style: Style,
170    /// Highlight style for selected/focused line.
171    highlight_style: Option<Style>,
172    /// Highlight style for search matches within a line.
173    search_highlight_style: Option<Style>,
174    /// Active filter pattern (plain substring match).
175    filter: Option<String>,
176    /// Indices of lines matching the filter (None = show all).
177    filtered_indices: Option<Vec<usize>>,
178    /// Scroll offset within the filtered set (top index of filtered list).
179    filtered_scroll_offset: usize,
180    /// Active search state.
181    search: Option<SearchState>,
182    /// Incremental filter/search statistics.
183    filter_stats: FilterStats,
184}
185
186/// Separate state for StatefulWidget pattern.
187#[derive(Debug, Clone, Default)]
188pub struct LogViewerState {
189    /// Viewport height from last render (for page up/down).
190    pub last_viewport_height: u16,
191    /// Total visible line count from last render.
192    pub last_visible_lines: usize,
193    /// Selected line index (for copy/selection features).
194    pub selected_line: Option<usize>,
195}
196
197impl LogViewer {
198    /// Create a new LogViewer with specified max line capacity.
199    ///
200    /// # Arguments
201    /// * `max_lines` - Maximum lines to retain. When exceeded, oldest lines
202    ///   are evicted. Recommend 10,000-100,000 for typical agent use cases.
203    #[must_use]
204    pub fn new(max_lines: usize) -> Self {
205        Self {
206            virt: Virtualized::new(max_lines).with_follow(true),
207            max_lines,
208            wrap_mode: LogWrapMode::NoWrap,
209            style: Style::default(),
210            highlight_style: None,
211            search_highlight_style: None,
212            filter: None,
213            filtered_indices: None,
214            filtered_scroll_offset: 0,
215            search: None,
216            filter_stats: FilterStats::default(),
217        }
218    }
219
220    /// Set the wrap mode.
221    #[must_use]
222    pub fn wrap_mode(mut self, mode: LogWrapMode) -> Self {
223        self.wrap_mode = mode;
224        self
225    }
226
227    /// Set the default style for lines.
228    #[must_use]
229    pub fn style(mut self, style: Style) -> Self {
230        self.style = style;
231        self
232    }
233
234    /// Set the highlight style for selected lines.
235    #[must_use]
236    pub fn highlight_style(mut self, style: Style) -> Self {
237        self.highlight_style = Some(style);
238        self
239    }
240
241    /// Set the highlight style for search matches within lines.
242    #[must_use]
243    pub fn search_highlight_style(mut self, style: Style) -> Self {
244        self.search_highlight_style = Some(style);
245        self
246    }
247
248    /// Returns the total number of log lines.
249    #[must_use]
250    pub fn len(&self) -> usize {
251        self.virt.len()
252    }
253
254    /// Returns true if there are no log lines.
255    #[must_use]
256    pub fn is_empty(&self) -> bool {
257        self.virt.is_empty()
258    }
259
260    /// Append a single log line.
261    ///
262    /// # Performance
263    /// - O(1) amortized for append
264    /// - O(1) for eviction when at capacity
265    ///
266    /// # Auto-scroll Behavior
267    /// If follow mode is enabled, view stays at bottom after push.
268    pub fn push(&mut self, line: impl Into<Text>) {
269        let follow_filtered = self.filtered_indices.as_ref().is_some_and(|indices| {
270            self.is_filtered_at_bottom(indices.len(), self.virt.visible_count())
271        });
272        let text: Text = line.into();
273
274        // Split multi-line text into individual items for smooth scrolling
275        for line in text.into_iter() {
276            let item = Text::from_line(line);
277            let plain = item.to_plain_text();
278
279            // Incremental filter check: test new line against active filter.
280            let filter_matched = if let Some(filter) = self.filter.as_ref() {
281                self.filter_stats.incremental_checks += 1;
282                let matched = plain.contains(filter.as_str());
283                if matched {
284                    if let Some(indices) = self.filtered_indices.as_mut() {
285                        let idx = self.virt.len();
286                        indices.push(idx);
287                    }
288                    self.filter_stats.incremental_matches += 1;
289                }
290                matched
291            } else {
292                false
293            };
294
295            // Incremental search check: test new line against active search query.
296            // Only add to search matches if (a) there is no filter or (b) the
297            // line passed the filter, because search results respect the filter.
298            if let Some(ref mut search) = self.search {
299                let should_check = self.filter.is_none() || filter_matched;
300                if should_check {
301                    let ranges = find_match_ranges(
302                        &plain,
303                        &search.query,
304                        search.query_lower.as_deref(),
305                        &search.config,
306                        #[cfg(feature = "regex-search")]
307                        search.compiled_regex.as_ref(),
308                    );
309                    if !ranges.is_empty() {
310                        let idx = self.virt.len();
311                        search.matches.push(idx);
312                        search.highlight_ranges.push(ranges);
313                        self.filter_stats.incremental_search_matches += 1;
314                    }
315                }
316            }
317
318            self.virt.push(item);
319
320            // Enforce capacity
321            if self.virt.len() > self.max_lines {
322                let removed = self.virt.trim_front(self.max_lines);
323
324                // Adjust filtered indices
325                if let Some(ref mut indices) = self.filtered_indices {
326                    let mut filtered_removed = 0usize;
327                    indices.retain_mut(|idx| {
328                        if *idx < removed {
329                            filtered_removed += 1;
330                            false
331                        } else {
332                            *idx -= removed;
333                            true
334                        }
335                    });
336                    if filtered_removed > 0 {
337                        self.filtered_scroll_offset =
338                            self.filtered_scroll_offset.saturating_sub(filtered_removed);
339                    }
340                    if indices.is_empty() {
341                        self.filtered_scroll_offset = 0;
342                    }
343                }
344
345                // Adjust search match indices and corresponding highlight_ranges
346                if let Some(ref mut search) = self.search {
347                    let mut keep = Vec::with_capacity(search.matches.len());
348                    let mut new_highlights = Vec::with_capacity(search.highlight_ranges.len());
349                    for (i, idx) in search.matches.iter_mut().enumerate() {
350                        if *idx < removed {
351                            // evicted
352                        } else {
353                            *idx -= removed;
354                            keep.push(*idx);
355                            if i < search.highlight_ranges.len() {
356                                new_highlights
357                                    .push(std::mem::take(&mut search.highlight_ranges[i]));
358                            }
359                        }
360                    }
361                    search.matches = keep;
362                    search.highlight_ranges = new_highlights;
363                    // Clamp current to valid range
364                    if !search.matches.is_empty() {
365                        search.current = search.current.min(search.matches.len() - 1);
366                    }
367                    // Recompute context expansion if needed
368                    if search.config.context_lines > 0 {
369                        search.context_expanded = Some(expand_context(
370                            &search.matches,
371                            search.config.context_lines,
372                            self.virt.len(),
373                        ));
374                    }
375                }
376            }
377
378            if follow_filtered
379                && let Some(indices) = self.filtered_indices.as_ref()
380                && !indices.is_empty()
381            {
382                self.filtered_scroll_offset = indices.len().saturating_sub(1);
383            }
384        }
385    }
386
387    /// Append multiple lines efficiently.
388    pub fn push_many(&mut self, lines: impl IntoIterator<Item = impl Into<Text>>) {
389        for line in lines {
390            self.push(line);
391        }
392    }
393
394    /// Scroll up by N lines. Disables follow mode.
395    pub fn scroll_up(&mut self, lines: usize) {
396        if self.filtered_indices.is_some() {
397            self.filtered_scroll_offset = self.filtered_scroll_offset.saturating_sub(lines);
398        } else {
399            self.virt.scroll(-(lines as i32));
400        }
401    }
402
403    /// Scroll down by N lines. Re-enables follow mode if at bottom.
404    pub fn scroll_down(&mut self, lines: usize) {
405        if let Some(filtered_total) = self.filtered_indices.as_ref().map(Vec::len) {
406            if filtered_total == 0 {
407                self.filtered_scroll_offset = 0;
408            } else {
409                self.filtered_scroll_offset = self.filtered_scroll_offset.saturating_add(lines);
410                let max_offset = filtered_total.saturating_sub(1);
411                if self.filtered_scroll_offset > max_offset {
412                    self.filtered_scroll_offset = max_offset;
413                }
414            }
415        } else {
416            self.virt.scroll(lines as i32);
417            if self.virt.is_at_bottom() {
418                self.virt.set_follow(true);
419            }
420        }
421    }
422
423    /// Jump to top of log history.
424    pub fn scroll_to_top(&mut self) {
425        if self.filtered_indices.is_some() {
426            self.filtered_scroll_offset = 0;
427        } else {
428            self.virt.scroll_to_top();
429        }
430    }
431
432    /// Jump to bottom and re-enable follow mode.
433    pub fn scroll_to_bottom(&mut self) {
434        if let Some(filtered_total) = self.filtered_indices.as_ref().map(Vec::len) {
435            if filtered_total == 0 {
436                self.filtered_scroll_offset = 0;
437            } else if self.virt.visible_count() > 0 {
438                self.filtered_scroll_offset =
439                    filtered_total.saturating_sub(self.virt.visible_count());
440            } else {
441                self.filtered_scroll_offset = filtered_total.saturating_sub(1);
442            }
443        } else {
444            self.virt.scroll_to_end();
445        }
446    }
447
448    /// Page up (scroll by viewport height).
449    ///
450    /// Uses the visible count tracked by the Virtualized container.
451    /// The `state` parameter is accepted for API compatibility.
452    pub fn page_up(&mut self, _state: &LogViewerState) {
453        if self.filtered_indices.is_some() {
454            let lines = _state.last_viewport_height as usize;
455            if lines > 0 {
456                self.scroll_up(lines);
457            }
458        } else {
459            self.virt.page_up();
460        }
461    }
462
463    /// Page down (scroll by viewport height).
464    ///
465    /// Uses the visible count tracked by the Virtualized container.
466    /// The `state` parameter is accepted for API compatibility.
467    pub fn page_down(&mut self, _state: &LogViewerState) {
468        if self.filtered_indices.is_some() {
469            let lines = _state.last_viewport_height as usize;
470            if lines > 0 {
471                self.scroll_down(lines);
472            }
473        } else {
474            self.virt.page_down();
475            if self.virt.is_at_bottom() {
476                self.virt.set_follow(true);
477            }
478        }
479    }
480
481    /// Check if currently scrolled to the bottom.
482    ///
483    /// Returns `true` when follow mode is active (even before first render
484    /// when the viewport size is unknown).
485    #[must_use]
486    pub fn is_at_bottom(&self) -> bool {
487        if let Some(indices) = self.filtered_indices.as_ref() {
488            self.is_filtered_at_bottom(indices.len(), self.virt.visible_count())
489        } else {
490            self.virt.follow_mode() || self.virt.is_at_bottom()
491        }
492    }
493
494    /// Total line count in buffer.
495    #[must_use]
496    pub fn line_count(&self) -> usize {
497        self.virt.len()
498    }
499
500    /// Check if follow mode (auto-scroll) is enabled.
501    #[must_use]
502    pub fn auto_scroll_enabled(&self) -> bool {
503        self.virt.follow_mode()
504    }
505
506    /// Set follow mode (auto-scroll) state.
507    pub fn set_auto_scroll(&mut self, enabled: bool) {
508        self.virt.set_follow(enabled);
509    }
510
511    /// Toggle follow mode on/off.
512    pub fn toggle_follow(&mut self) {
513        let current = self.virt.follow_mode();
514        self.virt.set_follow(!current);
515    }
516
517    /// Clear all lines.
518    pub fn clear(&mut self) {
519        self.virt.clear();
520        self.filtered_indices = self.filter.as_ref().map(|_| Vec::new());
521        self.filtered_scroll_offset = 0;
522        self.search = None;
523        self.filter_stats.reset();
524    }
525
526    /// Get a reference to the incremental filter/search statistics.
527    ///
528    /// Use this to monitor how often the streaming incremental path is used
529    /// versus full rescans.
530    #[must_use]
531    pub fn filter_stats(&self) -> &FilterStats {
532        &self.filter_stats
533    }
534
535    /// Get a mutable reference to the filter statistics (for resetting).
536    pub fn filter_stats_mut(&mut self) -> &mut FilterStats {
537        &mut self.filter_stats
538    }
539
540    /// Set a filter pattern (plain substring match).
541    ///
542    /// Only lines containing the pattern will be shown. Pass `None` to clear.
543    pub fn set_filter(&mut self, pattern: Option<&str>) {
544        match pattern {
545            Some(pat) if !pat.is_empty() => {
546                // Full rescan: rebuild filtered indices from all lines.
547                self.filter_stats.full_rescans += 1;
548                self.filter_stats.full_rescan_lines += self.virt.len() as u64;
549                let mut indices = Vec::new();
550                for idx in 0..self.virt.len() {
551                    if let Some(item) = self.virt.get(idx)
552                        && item.to_plain_text().contains(pat)
553                    {
554                        indices.push(idx);
555                    }
556                }
557                self.filter = Some(pat.to_string());
558                self.filtered_indices = Some(indices);
559                self.filtered_scroll_offset = if let Some(indices) = self.filtered_indices.as_ref()
560                {
561                    if indices.is_empty() {
562                        0
563                    } else if self.virt.follow_mode() || self.virt.is_at_bottom() {
564                        indices.len().saturating_sub(1)
565                    } else {
566                        let scroll_offset = self.virt.scroll_offset();
567                        indices.partition_point(|&idx| idx < scroll_offset)
568                    }
569                } else {
570                    0
571                };
572                self.search = None;
573            }
574            _ => {
575                self.filter = None;
576                self.filtered_indices = None;
577                self.filtered_scroll_offset = 0;
578                self.search = None;
579            }
580        }
581    }
582
583    /// Search for text and return match count.
584    ///
585    /// Convenience wrapper using default config (literal, case-sensitive, no context).
586    /// Sets up search state for navigation with `next_match` / `prev_match`.
587    pub fn search(&mut self, query: &str) -> usize {
588        self.search_with_config(query, SearchConfig::default())
589    }
590
591    /// Search with full configuration (mode, case sensitivity, context lines).
592    ///
593    /// Returns match count. Sets up state for `next_match` / `prev_match`.
594    pub fn search_with_config(&mut self, query: &str, config: SearchConfig) -> usize {
595        if query.is_empty() {
596            self.search = None;
597            return 0;
598        }
599
600        // Compile regex if needed
601        #[cfg(feature = "regex-search")]
602        let compiled_regex = if config.mode == SearchMode::Regex {
603            match compile_regex(query, &config) {
604                Some(re) => Some(re),
605                None => {
606                    // Invalid regex — clear search and return 0
607                    self.search = None;
608                    return 0;
609                }
610            }
611        } else {
612            None
613        };
614
615        // Pre-compute lowercase query for optimization
616        let query_lower = if !config.case_sensitive {
617            Some(query.to_ascii_lowercase())
618        } else {
619            None
620        };
621
622        // Full rescan for search matches.
623        self.filter_stats.full_rescans += 1;
624        let mut matches = Vec::new();
625        let mut highlight_ranges = Vec::new();
626
627        let iter: Box<dyn Iterator<Item = usize>> =
628            if let Some(indices) = self.filtered_indices.as_ref() {
629                self.filter_stats.full_rescan_lines += indices.len() as u64;
630                Box::new(indices.iter().copied())
631            } else {
632                self.filter_stats.full_rescan_lines += self.virt.len() as u64;
633                Box::new(0..self.virt.len())
634            };
635
636        for idx in iter {
637            if let Some(item) = self.virt.get(idx) {
638                let plain = item.to_plain_text();
639                let ranges = find_match_ranges(
640                    &plain,
641                    query,
642                    query_lower.as_deref(),
643                    &config,
644                    #[cfg(feature = "regex-search")]
645                    compiled_regex.as_ref(),
646                );
647                if !ranges.is_empty() {
648                    matches.push(idx);
649                    highlight_ranges.push(ranges);
650                }
651            }
652        }
653
654        let count = matches.len();
655
656        let context_expanded = if config.context_lines > 0 {
657            Some(expand_context(
658                &matches,
659                config.context_lines,
660                self.virt.len(),
661            ))
662        } else {
663            None
664        };
665
666        self.search = Some(SearchState {
667            query: query.to_string(),
668            query_lower,
669            config,
670            matches,
671            current: 0,
672            highlight_ranges,
673            #[cfg(feature = "regex-search")]
674            compiled_regex,
675            context_expanded,
676        });
677
678        // Jump to first match
679        if let Some(ref search) = self.search
680            && let Some(&idx) = search.matches.first()
681        {
682            self.scroll_to_match(idx);
683        }
684
685        count
686    }
687
688    /// Jump to next search match.
689    pub fn next_match(&mut self) {
690        if let Some(ref mut search) = self.search
691            && !search.matches.is_empty()
692        {
693            search.current = (search.current + 1) % search.matches.len();
694            let idx = search.matches[search.current];
695            self.scroll_to_match(idx);
696        }
697    }
698
699    /// Jump to previous search match.
700    pub fn prev_match(&mut self) {
701        if let Some(ref mut search) = self.search
702            && !search.matches.is_empty()
703        {
704            search.current = if search.current == 0 {
705                search.matches.len() - 1
706            } else {
707                search.current - 1
708            };
709            let idx = search.matches[search.current];
710            self.scroll_to_match(idx);
711        }
712    }
713
714    /// Clear active search.
715    pub fn clear_search(&mut self) {
716        self.search = None;
717    }
718
719    /// Get current search match info: (current_match_1indexed, total_matches).
720    #[must_use]
721    pub fn search_info(&self) -> Option<(usize, usize)> {
722        self.search.as_ref().and_then(|s| {
723            if s.matches.is_empty() {
724                None
725            } else {
726                Some((s.current + 1, s.matches.len()))
727            }
728        })
729    }
730
731    /// Get the highlight byte ranges for a given line index, if any.
732    ///
733    /// Returns `Some(&[(start, end)])` when the line is a search match.
734    #[must_use]
735    pub fn highlight_ranges_for_line(&self, line_idx: usize) -> Option<&[(usize, usize)]> {
736        let search = self.search.as_ref()?;
737        let pos = search.matches.iter().position(|&m| m == line_idx)?;
738        search.highlight_ranges.get(pos).map(|v| v.as_slice())
739    }
740
741    /// Get the context-expanded line indices, if context lines are configured.
742    ///
743    /// Returns `None` when no search is active or `context_lines == 0`.
744    #[must_use]
745    pub fn context_line_indices(&self) -> Option<&[usize]> {
746        self.search
747            .as_ref()
748            .and_then(|s| s.context_expanded.as_deref())
749    }
750
751    /// Returns the fraction of recent pushes that matched the active search.
752    ///
753    /// Useful for callers integrating with an `EProcessThrottle` to decide
754    /// when to trigger a full UI refresh vs. deferring.
755    /// Returns 0.0 when no search is active or no incremental checks occurred.
756    #[must_use]
757    pub fn search_match_rate_hint(&self) -> f64 {
758        let stats = &self.filter_stats;
759        if stats.incremental_checks == 0 {
760            return 0.0;
761        }
762        stats.incremental_search_matches as f64 / stats.incremental_checks as f64
763    }
764
765    /// Render a single line with optional wrapping and search highlighting.
766    #[allow(clippy::too_many_arguments)]
767    fn render_line(
768        &self,
769        text: &Text,
770        line_idx: usize,
771        x: u16,
772        y: u16,
773        width: u16,
774        max_y: u16,
775        frame: &mut Frame,
776        is_selected: bool,
777    ) -> u16 {
778        let effective_style = if is_selected {
779            self.highlight_style.unwrap_or(self.style)
780        } else {
781            self.style
782        };
783
784        let line = text.lines().first();
785        let content = text.to_plain_text();
786        let content_width = display_width(&content);
787        let hl_ranges = self.highlight_ranges_for_line(line_idx);
788
789        // Handle wrapping
790        match self.wrap_mode {
791            LogWrapMode::NoWrap => {
792                if y < max_y {
793                    if hl_ranges.is_some_and(|r| !r.is_empty()) {
794                        self.draw_highlighted_line(
795                            &content,
796                            hl_ranges.unwrap(),
797                            x,
798                            y,
799                            x.saturating_add(width),
800                            frame,
801                            effective_style,
802                        );
803                    } else {
804                        self.draw_text_line(
805                            line,
806                            &content,
807                            x,
808                            y,
809                            x.saturating_add(width),
810                            frame,
811                            effective_style,
812                        );
813                    }
814                }
815                1
816            }
817            LogWrapMode::CharWrap | LogWrapMode::WordWrap => {
818                if content_width <= width as usize {
819                    if y < max_y {
820                        if hl_ranges.is_some_and(|r| !r.is_empty()) {
821                            self.draw_highlighted_line(
822                                &content,
823                                hl_ranges.unwrap(),
824                                x,
825                                y,
826                                x.saturating_add(width),
827                                frame,
828                                effective_style,
829                            );
830                        } else {
831                            self.draw_text_line(
832                                line,
833                                &content,
834                                x,
835                                y,
836                                x.saturating_add(width),
837                                frame,
838                                effective_style,
839                            );
840                        }
841                    }
842                    1
843                } else {
844                    let options = WrapOptions::new(width as usize).mode(self.wrap_mode.into());
845                    let wrapped = wrap_with_options(&content, &options);
846                    let mut lines_rendered = 0u16;
847
848                    for (i, part) in wrapped.into_iter().enumerate() {
849                        let line_y = y.saturating_add(i as u16);
850                        if line_y >= max_y {
851                            break;
852                        }
853                        draw_text_span(
854                            frame,
855                            x,
856                            line_y,
857                            &part,
858                            effective_style,
859                            x.saturating_add(width),
860                        );
861                        lines_rendered += 1;
862                    }
863
864                    lines_rendered.max(1)
865                }
866            }
867        }
868    }
869
870    /// Draw a line with search match highlights.
871    #[allow(clippy::too_many_arguments)]
872    fn draw_highlighted_line(
873        &self,
874        content: &str,
875        ranges: &[(usize, usize)],
876        x: u16,
877        y: u16,
878        max_x: u16,
879        frame: &mut Frame,
880        base_style: Style,
881    ) {
882        let hl_style = self
883            .search_highlight_style
884            .unwrap_or_else(|| Style::new().bold().reverse());
885        let mut cursor_x = x;
886        let mut pos = 0;
887
888        for &(start, end) in ranges {
889            let start = start.min(content.len());
890            let end = end.min(content.len());
891            if start > pos {
892                // Draw non-highlighted segment
893                cursor_x =
894                    draw_text_span(frame, cursor_x, y, &content[pos..start], base_style, max_x);
895            }
896            if start < end {
897                // Draw highlighted segment
898                cursor_x =
899                    draw_text_span(frame, cursor_x, y, &content[start..end], hl_style, max_x);
900            }
901            pos = end;
902        }
903        // Draw trailing non-highlighted text
904        if pos < content.len() {
905            draw_text_span(frame, cursor_x, y, &content[pos..], base_style, max_x);
906        }
907    }
908
909    #[allow(clippy::too_many_arguments)]
910    fn draw_text_line(
911        &self,
912        line: Option<&ftui_text::Line>,
913        fallback: &str,
914        x: u16,
915        y: u16,
916        max_x: u16,
917        frame: &mut Frame,
918        base_style: Style,
919    ) {
920        if let Some(line) = line {
921            let mut cursor_x = x;
922            for span in line.spans() {
923                if cursor_x >= max_x {
924                    break;
925                }
926                let span_style = span
927                    .style
928                    .map_or(base_style, |style| style.merge(&base_style));
929                cursor_x = draw_text_span_with_link(
930                    frame,
931                    cursor_x,
932                    y,
933                    span.as_str(),
934                    span_style,
935                    max_x,
936                    span.link.as_deref(),
937                );
938            }
939        } else {
940            draw_text_span(frame, x, y, fallback, base_style, max_x);
941        }
942    }
943
944    fn scroll_to_match(&mut self, idx: usize) {
945        if let Some(indices) = self.filtered_indices.as_ref() {
946            let position = indices.partition_point(|&v| v < idx);
947            self.filtered_scroll_offset = position.min(indices.len().saturating_sub(1));
948        } else {
949            self.virt.scroll_to(idx);
950        }
951    }
952
953    fn is_filtered_at_bottom(&self, total: usize, visible_count: usize) -> bool {
954        if total == 0 || visible_count == 0 {
955            return true;
956        }
957        self.filtered_scroll_offset >= total.saturating_sub(visible_count)
958    }
959}
960
961impl StatefulWidget for LogViewer {
962    type State = LogViewerState;
963
964    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
965        if area.width == 0 || area.height == 0 {
966            return;
967        }
968
969        // Update state with current viewport info
970        state.last_viewport_height = area.height;
971
972        let total_lines = self.virt.len();
973        if total_lines == 0 {
974            state.last_visible_lines = 0;
975            return;
976        }
977
978        // Use filtered indices if a filter is active
979        let render_indices: Option<&[usize]> = self.filtered_indices.as_deref();
980
981        // Calculate visible range using Virtualized's scroll state
982        let visible_count = area.height as usize;
983
984        // Determine which lines to show
985        let (start_idx, end_idx, at_bottom) = if let Some(indices) = render_indices {
986            // Filtered mode: show lines matching the filter
987            let filtered_total = indices.len();
988            if filtered_total == 0 {
989                state.last_visible_lines = 0;
990                return;
991            }
992            // Clamp scroll to filtered set
993            let max_offset = filtered_total.saturating_sub(visible_count);
994            let offset = self.filtered_scroll_offset.min(max_offset);
995            let start = offset;
996            let end = (offset + visible_count).min(filtered_total);
997            let is_bottom = offset >= max_offset;
998            (start, end, is_bottom)
999        } else {
1000            // Unfiltered mode: use Virtualized's range directly
1001            let range = self.virt.visible_range(area.height);
1002            (range.start, range.end, self.virt.is_at_bottom())
1003        };
1004
1005        let mut y = area.y;
1006        let mut lines_rendered = 0;
1007
1008        for display_idx in start_idx..end_idx {
1009            if y >= area.bottom() {
1010                break;
1011            }
1012
1013            // Resolve to actual line index
1014            let line_idx = if let Some(indices) = render_indices {
1015                indices[display_idx]
1016            } else {
1017                display_idx
1018            };
1019
1020            let Some(line) = self.virt.get(line_idx) else {
1021                continue;
1022            };
1023
1024            let is_selected = state.selected_line == Some(line_idx);
1025
1026            let lines_used = self.render_line(
1027                line,
1028                line_idx,
1029                area.x,
1030                y,
1031                area.width,
1032                area.bottom(),
1033                frame,
1034                is_selected,
1035            );
1036
1037            y = y.saturating_add(lines_used);
1038            lines_rendered += 1;
1039        }
1040
1041        state.last_visible_lines = lines_rendered;
1042
1043        // Render scroll indicator if not at bottom
1044        if !at_bottom && area.width >= 4 {
1045            let lines_below = if let Some(indices) = render_indices {
1046                indices.len().saturating_sub(end_idx)
1047            } else {
1048                total_lines.saturating_sub(end_idx)
1049            };
1050            let indicator = format!(" {} ", lines_below);
1051            let indicator_len = display_width(&indicator) as u16;
1052            if indicator_len < area.width {
1053                let indicator_x = area.right().saturating_sub(indicator_len);
1054                let indicator_y = area.bottom().saturating_sub(1);
1055                draw_text_span(
1056                    frame,
1057                    indicator_x,
1058                    indicator_y,
1059                    &indicator,
1060                    Style::new().bold(),
1061                    area.right(),
1062                );
1063            }
1064        }
1065
1066        // Render search indicator if active
1067        if let Some((current, total)) = self.search_info()
1068            && area.width >= 10
1069        {
1070            let search_indicator = format!(" {}/{} ", current, total);
1071            let ind_len = display_width(&search_indicator) as u16;
1072            if ind_len < area.width {
1073                let ind_x = area.x;
1074                let ind_y = area.bottom().saturating_sub(1);
1075                draw_text_span(
1076                    frame,
1077                    ind_x,
1078                    ind_y,
1079                    &search_indicator,
1080                    Style::new().bold(),
1081                    ind_x.saturating_add(ind_len),
1082                );
1083            }
1084        }
1085    }
1086}
1087
1088/// Find match ranges using allocation-free case-insensitive search.
1089fn search_ascii_case_insensitive_ranges(haystack: &str, needle_lower: &str) -> Vec<(usize, usize)> {
1090    let mut results = Vec::new();
1091    if needle_lower.is_empty() {
1092        return results;
1093    }
1094
1095    if !haystack.is_ascii() || !needle_lower.is_ascii() {
1096        return search_ascii_case_insensitive(haystack, needle_lower)
1097            .into_iter()
1098            .map(|r| (r.range.start, r.range.end))
1099            .collect();
1100    }
1101
1102    let haystack_bytes = haystack.as_bytes();
1103    let needle_bytes = needle_lower.as_bytes();
1104    let needle_len = needle_bytes.len();
1105
1106    if needle_len > haystack_bytes.len() {
1107        return results;
1108    }
1109
1110    const MAX_WORK: usize = 4096;
1111    if haystack_bytes.len().saturating_mul(needle_len) > MAX_WORK {
1112        return search_ascii_case_insensitive(haystack, needle_lower)
1113            .into_iter()
1114            .map(|r| (r.range.start, r.range.end))
1115            .collect();
1116    }
1117
1118    let mut i = 0;
1119    while i <= haystack_bytes.len() - needle_len {
1120        let mut match_found = true;
1121        for j in 0..needle_len {
1122            if haystack_bytes[i + j].to_ascii_lowercase() != needle_bytes[j] {
1123                match_found = false;
1124                break;
1125            }
1126        }
1127        if match_found {
1128            results.push((i, i + needle_len));
1129            i += needle_len;
1130        } else {
1131            i += 1;
1132        }
1133    }
1134    results
1135}
1136
1137/// Find match byte ranges within a single line using the given config.
1138fn find_match_ranges(
1139    plain: &str,
1140    query: &str,
1141    query_lower: Option<&str>,
1142    config: &SearchConfig,
1143    #[cfg(feature = "regex-search")] compiled_regex: Option<&regex::Regex>,
1144) -> Vec<(usize, usize)> {
1145    match config.mode {
1146        SearchMode::Literal => {
1147            if config.case_sensitive {
1148                search_exact(plain, query)
1149                    .into_iter()
1150                    .map(|r| (r.range.start, r.range.end))
1151                    .collect()
1152            } else if let Some(lower) = query_lower {
1153                search_ascii_case_insensitive_ranges(plain, lower)
1154            } else {
1155                search_ascii_case_insensitive(plain, query)
1156                    .into_iter()
1157                    .map(|r| (r.range.start, r.range.end))
1158                    .collect()
1159            }
1160        }
1161        SearchMode::Regex => {
1162            #[cfg(feature = "regex-search")]
1163            {
1164                if let Some(re) = compiled_regex {
1165                    re.find_iter(plain).map(|m| (m.start(), m.end())).collect()
1166                } else {
1167                    Vec::new()
1168                }
1169            }
1170            #[cfg(not(feature = "regex-search"))]
1171            {
1172                // Without the feature, regex mode is a no-op.
1173                Vec::new()
1174            }
1175        }
1176    }
1177}
1178
1179/// Compile a regex from the query, respecting case sensitivity.
1180#[cfg(feature = "regex-search")]
1181fn compile_regex(query: &str, config: &SearchConfig) -> Option<regex::Regex> {
1182    let pattern = if config.case_sensitive {
1183        query.to_string()
1184    } else {
1185        format!("(?i){}", query)
1186    };
1187    regex::Regex::new(&pattern).ok()
1188}
1189
1190/// Expand match indices by ±N context lines, dedup and sort.
1191fn expand_context(matches: &[usize], context_lines: usize, total_lines: usize) -> Vec<usize> {
1192    let mut expanded = Vec::new();
1193    for &idx in matches {
1194        let start = idx.saturating_sub(context_lines);
1195        let end = (idx + context_lines + 1).min(total_lines);
1196        for i in start..end {
1197            expanded.push(i);
1198        }
1199    }
1200    expanded.sort_unstable();
1201    expanded.dedup();
1202    expanded
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207    use super::*;
1208    use ftui_render::cell::StyleFlags as RenderStyleFlags;
1209    use ftui_render::grapheme_pool::GraphemePool;
1210    use ftui_style::StyleFlags as TextStyleFlags;
1211
1212    fn line_text(frame: &Frame, y: u16, width: u16) -> String {
1213        let mut out = String::with_capacity(width as usize);
1214        for x in 0..width {
1215            let ch = frame
1216                .buffer
1217                .get(x, y)
1218                .and_then(|cell| cell.content.as_char())
1219                .unwrap_or(' ');
1220            out.push(ch);
1221        }
1222        out
1223    }
1224
1225    #[test]
1226    fn test_push_appends_to_end() {
1227        let mut log = LogViewer::new(100);
1228        log.push("line 1");
1229        log.push("line 2");
1230        assert_eq!(log.line_count(), 2);
1231    }
1232
1233    #[test]
1234    fn test_circular_buffer_eviction() {
1235        let mut log = LogViewer::new(3);
1236        log.push("line 1");
1237        log.push("line 2");
1238        log.push("line 3");
1239        log.push("line 4"); // Should evict "line 1"
1240        assert_eq!(log.line_count(), 3);
1241    }
1242
1243    #[test]
1244    fn test_auto_scroll_stays_at_bottom() {
1245        let mut log = LogViewer::new(100);
1246        log.push("line 1");
1247        assert!(log.is_at_bottom());
1248        log.push("line 2");
1249        assert!(log.is_at_bottom());
1250    }
1251
1252    #[test]
1253    fn test_manual_scroll_disables_auto_scroll() {
1254        let mut log = LogViewer::new(100);
1255        log.virt.set_visible_count(10);
1256        for i in 0..50 {
1257            log.push(format!("line {}", i));
1258        }
1259        log.scroll_up(10);
1260        assert!(!log.auto_scroll_enabled());
1261        log.push("new line");
1262        assert!(!log.auto_scroll_enabled()); // Still scrolled up
1263    }
1264
1265    #[test]
1266    fn test_scroll_to_bottom_reengages_auto_scroll() {
1267        let mut log = LogViewer::new(100);
1268        log.virt.set_visible_count(10);
1269        for i in 0..50 {
1270            log.push(format!("line {}", i));
1271        }
1272        log.scroll_up(10);
1273        log.scroll_to_bottom();
1274        assert!(log.is_at_bottom());
1275        assert!(log.auto_scroll_enabled());
1276    }
1277
1278    #[test]
1279    fn test_scroll_down_reengages_at_bottom() {
1280        let mut log = LogViewer::new(100);
1281        log.virt.set_visible_count(10);
1282        for i in 0..50 {
1283            log.push(format!("line {}", i));
1284        }
1285        log.scroll_up(5);
1286        assert!(!log.auto_scroll_enabled());
1287
1288        log.scroll_down(5);
1289        if log.is_at_bottom() {
1290            assert!(log.auto_scroll_enabled());
1291        }
1292    }
1293
1294    #[test]
1295    fn test_scroll_to_top() {
1296        let mut log = LogViewer::new(100);
1297        for i in 0..50 {
1298            log.push(format!("line {}", i));
1299        }
1300        log.scroll_to_top();
1301        assert!(!log.auto_scroll_enabled());
1302    }
1303
1304    #[test]
1305    fn test_page_up_down() {
1306        let mut log = LogViewer::new(100);
1307        log.virt.set_visible_count(10);
1308        for i in 0..50 {
1309            log.push(format!("line {}", i));
1310        }
1311
1312        let state = LogViewerState {
1313            last_viewport_height: 10,
1314            ..Default::default()
1315        };
1316
1317        assert!(log.is_at_bottom());
1318
1319        log.page_up(&state);
1320        assert!(!log.is_at_bottom());
1321
1322        log.page_down(&state);
1323        // After paging down from near-bottom, should be closer to bottom
1324    }
1325
1326    #[test]
1327    fn test_clear() {
1328        let mut log = LogViewer::new(100);
1329        log.push("line 1");
1330        log.push("line 2");
1331        log.clear();
1332        assert_eq!(log.line_count(), 0);
1333    }
1334
1335    #[test]
1336    fn test_push_many() {
1337        let mut log = LogViewer::new(100);
1338        log.push_many(["line 1", "line 2", "line 3"]);
1339        assert_eq!(log.line_count(), 3);
1340    }
1341
1342    #[test]
1343    fn test_render_empty() {
1344        let mut pool = GraphemePool::new();
1345        let mut frame = Frame::new(80, 24, &mut pool);
1346        let log = LogViewer::new(100);
1347        let mut state = LogViewerState::default();
1348
1349        log.render(Rect::new(0, 0, 80, 24), &mut frame, &mut state);
1350
1351        assert_eq!(state.last_visible_lines, 0);
1352    }
1353
1354    #[test]
1355    fn test_render_some_lines() {
1356        let mut pool = GraphemePool::new();
1357        let mut frame = Frame::new(80, 10, &mut pool);
1358        let mut log = LogViewer::new(100);
1359
1360        for i in 0..5 {
1361            log.push(format!("Line {}", i));
1362        }
1363
1364        let mut state = LogViewerState::default();
1365        log.render(Rect::new(0, 0, 80, 10), &mut frame, &mut state);
1366
1367        assert_eq!(state.last_viewport_height, 10);
1368        assert_eq!(state.last_visible_lines, 5);
1369    }
1370
1371    #[test]
1372    fn test_toggle_follow() {
1373        let mut log = LogViewer::new(100);
1374        assert!(log.auto_scroll_enabled());
1375        log.toggle_follow();
1376        assert!(!log.auto_scroll_enabled());
1377        log.toggle_follow();
1378        assert!(log.auto_scroll_enabled());
1379    }
1380
1381    #[test]
1382    fn test_filter_shows_matching_lines() {
1383        let mut log = LogViewer::new(100);
1384        log.push("INFO: starting");
1385        log.push("ERROR: something failed");
1386        log.push("INFO: processing");
1387        log.push("ERROR: another failure");
1388        log.push("INFO: done");
1389
1390        log.set_filter(Some("ERROR"));
1391        assert_eq!(log.filtered_indices.as_ref().unwrap().len(), 2);
1392
1393        // Clear filter
1394        log.set_filter(None);
1395        assert!(log.filtered_indices.is_none());
1396    }
1397
1398    #[test]
1399    fn test_search_finds_matches() {
1400        let mut log = LogViewer::new(100);
1401        log.push("hello world");
1402        log.push("goodbye world");
1403        log.push("hello again");
1404
1405        let count = log.search("hello");
1406        assert_eq!(count, 2);
1407        assert_eq!(log.search_info(), Some((1, 2)));
1408    }
1409
1410    #[test]
1411    fn test_search_respects_filter() {
1412        let mut log = LogViewer::new(100);
1413        log.push("INFO: ok");
1414        log.push("ERROR: first");
1415        log.push("WARN: mid");
1416        log.push("ERROR: second");
1417
1418        log.set_filter(Some("ERROR"));
1419        assert_eq!(log.search("WARN"), 0);
1420        assert_eq!(log.search("ERROR"), 2);
1421    }
1422
1423    #[test]
1424    fn test_filter_clears_search() {
1425        let mut log = LogViewer::new(100);
1426        log.push("alpha");
1427        log.search("alpha");
1428        assert!(log.search_info().is_some());
1429
1430        log.set_filter(Some("alpha"));
1431        assert!(log.search_info().is_none());
1432    }
1433
1434    #[test]
1435    fn test_search_sets_filtered_scroll_offset() {
1436        let mut log = LogViewer::new(100);
1437        log.push("match one");
1438        log.push("line two");
1439        log.push("match three");
1440        log.push("match four");
1441
1442        log.set_filter(Some("match"));
1443        log.search("match");
1444
1445        assert_eq!(log.filtered_scroll_offset, 0);
1446        log.next_match();
1447        assert_eq!(log.filtered_scroll_offset, 1);
1448    }
1449
1450    #[test]
1451    fn test_search_next_prev() {
1452        let mut log = LogViewer::new(100);
1453        log.push("match A");
1454        log.push("nothing here");
1455        log.push("match B");
1456        log.push("match C");
1457
1458        log.search("match");
1459        assert_eq!(log.search_info(), Some((1, 3)));
1460
1461        log.next_match();
1462        assert_eq!(log.search_info(), Some((2, 3)));
1463
1464        log.next_match();
1465        assert_eq!(log.search_info(), Some((3, 3)));
1466
1467        log.next_match(); // wraps around
1468        assert_eq!(log.search_info(), Some((1, 3)));
1469
1470        log.prev_match(); // wraps back
1471        assert_eq!(log.search_info(), Some((3, 3)));
1472    }
1473
1474    #[test]
1475    fn test_clear_search() {
1476        let mut log = LogViewer::new(100);
1477        log.push("hello");
1478        log.search("hello");
1479        assert!(log.search_info().is_some());
1480
1481        log.clear_search();
1482        assert!(log.search_info().is_none());
1483    }
1484
1485    #[test]
1486    fn test_filter_with_push() {
1487        let mut log = LogViewer::new(100);
1488        log.set_filter(Some("ERROR"));
1489        log.push("INFO: ok");
1490        log.push("ERROR: bad");
1491        log.push("INFO: fine");
1492
1493        assert_eq!(log.filtered_indices.as_ref().unwrap().len(), 1);
1494        assert_eq!(log.filtered_indices.as_ref().unwrap()[0], 1);
1495    }
1496
1497    #[test]
1498    fn test_eviction_adjusts_filter_indices() {
1499        let mut log = LogViewer::new(3);
1500        log.set_filter(Some("x"));
1501        log.push("x1");
1502        log.push("y2");
1503        log.push("x3");
1504        // At capacity: indices [0, 2]
1505        assert_eq!(log.filtered_indices.as_ref().unwrap(), &[0, 2]);
1506
1507        log.push("y4"); // evicts "x1", indices should adjust
1508        // After eviction of 1 item: "x3" was at 2, now at 1
1509        assert_eq!(log.filtered_indices.as_ref().unwrap(), &[1]);
1510    }
1511
1512    #[test]
1513    fn test_filter_scroll_offset_tracks_unfiltered_position() {
1514        let mut log = LogViewer::new(100);
1515        for i in 0..20 {
1516            if i == 2 || i == 10 || i == 15 {
1517                log.push(format!("match {}", i));
1518            } else {
1519                log.push(format!("line {}", i));
1520            }
1521        }
1522
1523        log.virt.scroll_to(12);
1524        log.set_filter(Some("match"));
1525
1526        // Matches before index 12 are at 2 and 10 -> offset should be 2.
1527        assert_eq!(log.filtered_scroll_offset, 2);
1528    }
1529
1530    #[test]
1531    fn test_filtered_scroll_down_moves_within_filtered_list() {
1532        let mut log = LogViewer::new(100);
1533        log.push("match one");
1534        log.push("line two");
1535        log.push("match three");
1536        log.push("line four");
1537        log.push("match five");
1538
1539        log.set_filter(Some("match"));
1540        log.scroll_to_top();
1541        log.scroll_down(1);
1542
1543        assert_eq!(log.filtered_scroll_offset, 1);
1544    }
1545
1546    // -----------------------------------------------------------------------
1547    // Incremental filter/search tests (bd-1b5h.11)
1548    // -----------------------------------------------------------------------
1549
1550    #[test]
1551    fn test_incremental_filter_on_push_tracks_stats() {
1552        let mut log = LogViewer::new(100);
1553        log.set_filter(Some("ERROR"));
1554        // set_filter triggers one full rescan (on empty log).
1555        assert_eq!(log.filter_stats().full_rescans, 1);
1556
1557        log.push("INFO: ok");
1558        log.push("ERROR: bad");
1559        log.push("INFO: fine");
1560        log.push("ERROR: worse");
1561
1562        // 4 lines pushed with filter active → 4 incremental checks.
1563        assert_eq!(log.filter_stats().incremental_checks, 4);
1564        // 2 matched.
1565        assert_eq!(log.filter_stats().incremental_matches, 2);
1566        // No additional full rescans.
1567        assert_eq!(log.filter_stats().full_rescans, 1);
1568    }
1569
1570    #[test]
1571    fn test_incremental_search_on_push() {
1572        let mut log = LogViewer::new(100);
1573        log.push("hello world");
1574        log.push("goodbye world");
1575
1576        // Full search scan.
1577        let count = log.search("hello");
1578        assert_eq!(count, 1);
1579        assert_eq!(log.filter_stats().full_rescans, 1);
1580
1581        // Push new lines while search is active → incremental search update.
1582        log.push("hello again");
1583        log.push("nothing here");
1584
1585        // Search matches should include the new "hello again" line.
1586        assert_eq!(log.search_info(), Some((1, 2)));
1587        assert_eq!(log.filter_stats().incremental_search_matches, 1);
1588    }
1589
1590    #[test]
1591    fn test_incremental_search_respects_active_filter() {
1592        let mut log = LogViewer::new(100);
1593        log.push("ERROR: hello");
1594        log.push("INFO: hello");
1595
1596        log.set_filter(Some("ERROR"));
1597        let count = log.search("hello");
1598        assert_eq!(count, 1); // Only ERROR line passes filter.
1599
1600        // Push new lines: only those passing filter should be search-matched.
1601        log.push("ERROR: hello again");
1602        log.push("INFO: hello again"); // Doesn't pass filter.
1603
1604        assert_eq!(log.search_info(), Some((1, 2))); // Original + new ERROR.
1605        assert_eq!(log.filter_stats().incremental_search_matches, 1);
1606    }
1607
1608    #[test]
1609    fn test_incremental_search_without_filter() {
1610        let mut log = LogViewer::new(100);
1611        log.push("first");
1612        log.search("match");
1613        assert_eq!(log.search_info(), None); // No matches.
1614
1615        // Push matching line without any filter active.
1616        log.push("match found");
1617        assert_eq!(log.search_info(), Some((1, 1)));
1618        assert_eq!(log.filter_stats().incremental_search_matches, 1);
1619    }
1620
1621    #[test]
1622    fn test_filter_stats_reset_on_clear() {
1623        let mut log = LogViewer::new(100);
1624        log.set_filter(Some("x"));
1625        log.push("x1");
1626        log.push("y2");
1627
1628        assert!(log.filter_stats().incremental_checks > 0);
1629        log.clear();
1630        assert_eq!(log.filter_stats().incremental_checks, 0);
1631        assert_eq!(log.filter_stats().full_rescans, 0);
1632    }
1633
1634    #[test]
1635    fn test_filter_stats_full_rescan_on_filter_change() {
1636        let mut log = LogViewer::new(100);
1637        for i in 0..100 {
1638            log.push(format!("line {}", i));
1639        }
1640
1641        log.set_filter(Some("line 5"));
1642        assert_eq!(log.filter_stats().full_rescans, 1);
1643        assert_eq!(log.filter_stats().full_rescan_lines, 100);
1644
1645        log.set_filter(Some("line 9"));
1646        assert_eq!(log.filter_stats().full_rescans, 2);
1647        assert_eq!(log.filter_stats().full_rescan_lines, 200);
1648    }
1649
1650    #[test]
1651    fn test_filter_stats_manual_reset() {
1652        let mut log = LogViewer::new(100);
1653        log.set_filter(Some("x"));
1654        log.push("x1");
1655        assert!(log.filter_stats().incremental_checks > 0);
1656
1657        log.filter_stats_mut().reset();
1658        assert_eq!(log.filter_stats().incremental_checks, 0);
1659
1660        // Subsequent pushes still tracked after reset.
1661        log.push("x2");
1662        assert_eq!(log.filter_stats().incremental_checks, 1);
1663    }
1664
1665    #[test]
1666    fn test_incremental_eviction_adjusts_search_matches() {
1667        let mut log = LogViewer::new(3);
1668        log.push("match A");
1669        log.push("no");
1670        log.push("match B");
1671        log.search("match");
1672        assert_eq!(log.search_info(), Some((1, 2)));
1673
1674        // Push beyond capacity: evicts "match A".
1675        log.push("match C"); // Incremental search match.
1676
1677        // "match A" evicted. "match B" index adjusted. "match C" added.
1678        let search = log.search.as_ref().unwrap();
1679        assert_eq!(search.matches.len(), 2);
1680        // All search match indices should be valid (< log.line_count()).
1681        for &idx in &search.matches {
1682            assert!(idx < log.line_count(), "Search index {} out of range", idx);
1683        }
1684    }
1685
1686    #[test]
1687    fn test_no_stats_when_no_filter_or_search() {
1688        let mut log = LogViewer::new(100);
1689        log.push("line 1");
1690        log.push("line 2");
1691
1692        assert_eq!(log.filter_stats().incremental_checks, 0);
1693        assert_eq!(log.filter_stats().full_rescans, 0);
1694        assert_eq!(log.filter_stats().incremental_search_matches, 0);
1695    }
1696
1697    #[test]
1698    fn test_search_full_rescan_counts_lines() {
1699        let mut log = LogViewer::new(100);
1700        for i in 0..50 {
1701            log.push(format!("line {}", i));
1702        }
1703
1704        log.search("line 1");
1705        assert_eq!(log.filter_stats().full_rescans, 1);
1706        assert_eq!(log.filter_stats().full_rescan_lines, 50);
1707    }
1708
1709    #[test]
1710    fn test_search_full_rescan_on_filtered_counts_filtered_lines() {
1711        let mut log = LogViewer::new(100);
1712        for i in 0..50 {
1713            if i % 2 == 0 {
1714                log.push(format!("even {}", i));
1715            } else {
1716                log.push(format!("odd {}", i));
1717            }
1718        }
1719
1720        log.set_filter(Some("even"));
1721        let initial_rescans = log.filter_stats().full_rescans;
1722        let initial_lines = log.filter_stats().full_rescan_lines;
1723
1724        log.search("even 4");
1725        assert_eq!(log.filter_stats().full_rescans, initial_rescans + 1);
1726        // Search scanned only filtered lines (25 even lines).
1727        assert_eq!(log.filter_stats().full_rescan_lines, initial_lines + 25);
1728    }
1729
1730    // -----------------------------------------------------------------------
1731    // Enhanced search tests (bd-1b5h.2)
1732    // -----------------------------------------------------------------------
1733
1734    #[test]
1735    fn test_search_literal_case_sensitive() {
1736        let mut log = LogViewer::new(100);
1737        log.push("Hello World");
1738        log.push("hello world");
1739        log.push("HELLO WORLD");
1740
1741        let config = SearchConfig {
1742            mode: SearchMode::Literal,
1743            case_sensitive: true,
1744            context_lines: 0,
1745        };
1746        let count = log.search_with_config("Hello", config);
1747        assert_eq!(count, 1);
1748        assert_eq!(log.search_info(), Some((1, 1)));
1749    }
1750
1751    #[test]
1752    fn test_search_literal_case_insensitive() {
1753        let mut log = LogViewer::new(100);
1754        log.push("Hello World");
1755        log.push("hello world");
1756        log.push("HELLO WORLD");
1757        log.push("no match here");
1758
1759        let config = SearchConfig {
1760            mode: SearchMode::Literal,
1761            case_sensitive: false,
1762            context_lines: 0,
1763        };
1764        let count = log.search_with_config("hello", config);
1765        assert_eq!(count, 3);
1766    }
1767
1768    #[test]
1769    fn test_search_ascii_case_insensitive_fast_path_ranges() {
1770        let mut log = LogViewer::new(100);
1771        log.push("Alpha beta ALPHA beta alpha");
1772
1773        let config = SearchConfig {
1774            mode: SearchMode::Literal,
1775            case_sensitive: false,
1776            context_lines: 0,
1777        };
1778        let count = log.search_with_config("alpha", config);
1779        assert_eq!(count, 1);
1780
1781        let ranges = log.highlight_ranges_for_line(0).expect("match ranges");
1782        assert_eq!(ranges, &[(0, 5), (11, 16), (22, 27)]);
1783    }
1784
1785    #[test]
1786    fn test_search_unicode_fallback_ranges() {
1787        let mut log = LogViewer::new(100);
1788        let line = "café résumé café";
1789        log.push(line);
1790
1791        let config = SearchConfig {
1792            mode: SearchMode::Literal,
1793            case_sensitive: false,
1794            context_lines: 0,
1795        };
1796        let count = log.search_with_config("café", config);
1797        assert_eq!(count, 1);
1798
1799        let expected: Vec<(usize, usize)> = search_exact(line, "café")
1800            .into_iter()
1801            .map(|r| (r.range.start, r.range.end))
1802            .collect();
1803        let ranges = log.highlight_ranges_for_line(0).expect("match ranges");
1804        assert_eq!(ranges, expected.as_slice());
1805    }
1806
1807    #[test]
1808    fn test_search_highlight_ranges_stable_after_push() {
1809        let mut log = LogViewer::new(100);
1810        log.push("Alpha beta ALPHA beta alpha");
1811
1812        let config = SearchConfig {
1813            mode: SearchMode::Literal,
1814            case_sensitive: false,
1815            context_lines: 0,
1816        };
1817        log.search_with_config("alpha", config);
1818        let before = log
1819            .highlight_ranges_for_line(0)
1820            .expect("match ranges")
1821            .to_vec();
1822
1823        log.push("no match here");
1824        let after = log
1825            .highlight_ranges_for_line(0)
1826            .expect("match ranges")
1827            .to_vec();
1828
1829        assert_eq!(before, after);
1830    }
1831
1832    #[cfg(feature = "regex-search")]
1833    #[test]
1834    fn test_search_regex_basic() {
1835        let mut log = LogViewer::new(100);
1836        log.push("error: code 42");
1837        log.push("error: code 99");
1838        log.push("info: all good");
1839        log.push("error: code 7");
1840
1841        let config = SearchConfig {
1842            mode: SearchMode::Regex,
1843            case_sensitive: true,
1844            context_lines: 0,
1845        };
1846        let count = log.search_with_config(r"error: code \d+", config);
1847        assert_eq!(count, 3);
1848    }
1849
1850    #[cfg(feature = "regex-search")]
1851    #[test]
1852    fn test_search_regex_invalid_pattern() {
1853        let mut log = LogViewer::new(100);
1854        log.push("something");
1855
1856        let config = SearchConfig {
1857            mode: SearchMode::Regex,
1858            case_sensitive: true,
1859            context_lines: 0,
1860        };
1861        // Invalid regex (unmatched paren)
1862        let count = log.search_with_config(r"(unclosed", config);
1863        assert_eq!(count, 0);
1864        assert!(log.search_info().is_none());
1865    }
1866
1867    #[test]
1868    fn test_search_highlight_ranges() {
1869        let mut log = LogViewer::new(100);
1870        log.push("foo bar foo baz foo");
1871
1872        let count = log.search("foo");
1873        assert_eq!(count, 1);
1874
1875        let ranges = log.highlight_ranges_for_line(0);
1876        assert!(ranges.is_some());
1877        let ranges = ranges.unwrap();
1878        assert_eq!(ranges.len(), 3);
1879        assert_eq!(ranges[0], (0, 3));
1880        assert_eq!(ranges[1], (8, 11));
1881        assert_eq!(ranges[2], (16, 19));
1882    }
1883
1884    #[test]
1885    fn test_search_context_lines() {
1886        let mut log = LogViewer::new(100);
1887        for i in 0..10 {
1888            log.push(format!("line {}", i));
1889        }
1890
1891        let config = SearchConfig {
1892            mode: SearchMode::Literal,
1893            case_sensitive: true,
1894            context_lines: 1,
1895        };
1896        // "line 5" is at index 5
1897        let count = log.search_with_config("line 5", config);
1898        assert_eq!(count, 1);
1899
1900        let ctx = log.context_line_indices();
1901        assert!(ctx.is_some());
1902        let ctx = ctx.unwrap();
1903        // Should include lines 4, 5, 6
1904        assert!(ctx.contains(&4));
1905        assert!(ctx.contains(&5));
1906        assert!(ctx.contains(&6));
1907        assert!(!ctx.contains(&3));
1908        assert!(!ctx.contains(&7));
1909    }
1910
1911    #[test]
1912    fn test_search_incremental_with_config() {
1913        let mut log = LogViewer::new(100);
1914        log.push("Hello World");
1915
1916        let config = SearchConfig {
1917            mode: SearchMode::Literal,
1918            case_sensitive: false,
1919            context_lines: 0,
1920        };
1921        let count = log.search_with_config("hello", config);
1922        assert_eq!(count, 1);
1923
1924        // Push new line that matches case-insensitively
1925        log.push("HELLO again");
1926        assert_eq!(log.search_info(), Some((1, 2)));
1927
1928        // Push line that doesn't match
1929        log.push("goodbye");
1930        assert_eq!(log.search_info(), Some((1, 2)));
1931    }
1932
1933    #[test]
1934    fn test_search_mode_switch() {
1935        let mut log = LogViewer::new(100);
1936        log.push("error 42");
1937        log.push("error 99");
1938        log.push("info ok");
1939
1940        // First search: literal
1941        let count = log.search("error");
1942        assert_eq!(count, 2);
1943
1944        // Switch to case-insensitive
1945        let config = SearchConfig {
1946            mode: SearchMode::Literal,
1947            case_sensitive: false,
1948            context_lines: 0,
1949        };
1950        let count = log.search_with_config("ERROR", config);
1951        assert_eq!(count, 2);
1952
1953        // Switch back to case-sensitive — "ERROR" shouldn't match "error"
1954        let config = SearchConfig {
1955            mode: SearchMode::Literal,
1956            case_sensitive: true,
1957            context_lines: 0,
1958        };
1959        let count = log.search_with_config("ERROR", config);
1960        assert_eq!(count, 0);
1961    }
1962
1963    #[test]
1964    fn test_search_empty_query() {
1965        let mut log = LogViewer::new(100);
1966        log.push("something");
1967
1968        let count = log.search("");
1969        assert_eq!(count, 0);
1970        assert!(log.search_info().is_none());
1971
1972        let config = SearchConfig::default();
1973        let count = log.search_with_config("", config);
1974        assert_eq!(count, 0);
1975        assert!(log.search_info().is_none());
1976    }
1977
1978    #[test]
1979    fn test_highlight_ranges_within_bounds() {
1980        let mut log = LogViewer::new(100);
1981        let lines = [
1982            "short",
1983            "hello world hello",
1984            "café résumé café",
1985            "🌍 emoji 🌍",
1986            "",
1987        ];
1988        for line in &lines {
1989            log.push(*line);
1990        }
1991
1992        log.search("hello");
1993
1994        // Check all highlight ranges are valid byte ranges
1995        for match_idx in 0..log.line_count() {
1996            if let Some(ranges) = log.highlight_ranges_for_line(match_idx)
1997                && let Some(item) = log.virt.get(match_idx)
1998            {
1999                let plain = item.to_plain_text();
2000                for &(start, end) in ranges {
2001                    assert!(
2002                        start <= end,
2003                        "Invalid range: start={} > end={} on line {}",
2004                        start,
2005                        end,
2006                        match_idx
2007                    );
2008                    assert!(
2009                        end <= plain.len(),
2010                        "Out of bounds: end={} > len={} on line {}",
2011                        end,
2012                        plain.len(),
2013                        match_idx
2014                    );
2015                }
2016            }
2017        }
2018    }
2019
2020    #[test]
2021    fn test_search_match_rate_hint() {
2022        let mut log = LogViewer::new(100);
2023        log.set_filter(Some("x"));
2024        log.push("x match");
2025        log.search("match");
2026        log.push("x match again");
2027        log.push("x no");
2028
2029        // 3 incremental checks, 1 search match
2030        let rate = log.search_match_rate_hint();
2031        assert!(rate > 0.0);
2032        assert!(rate <= 1.0);
2033    }
2034
2035    #[test]
2036    fn test_large_scrollback_eviction_and_scroll_bounds() {
2037        let mut log = LogViewer::new(1_000);
2038        log.virt.set_visible_count(25);
2039
2040        for i in 0..5_000 {
2041            log.push(format!("line {}", i));
2042        }
2043
2044        assert_eq!(log.line_count(), 1_000);
2045
2046        let first = log.virt.get(0).expect("first line");
2047        assert_eq!(first.lines()[0].to_plain_text(), "line 4000");
2048
2049        let last = log
2050            .virt
2051            .get(log.line_count().saturating_sub(1))
2052            .expect("last line");
2053        assert_eq!(last.lines()[0].to_plain_text(), "line 4999");
2054
2055        log.scroll_to_top();
2056        assert!(!log.auto_scroll_enabled());
2057
2058        log.scroll_down(10_000);
2059        assert!(log.is_at_bottom());
2060        assert!(log.auto_scroll_enabled());
2061
2062        let max_offset = log.line_count().saturating_sub(log.virt.visible_count());
2063        assert!(log.virt.scroll_offset() <= max_offset);
2064    }
2065
2066    #[test]
2067    fn test_large_scrollback_render_top_and_bottom_lines() {
2068        let mut log = LogViewer::new(1_000);
2069        log.virt.set_visible_count(3);
2070        for i in 0..5_000 {
2071            log.push(format!("line {}", i));
2072        }
2073
2074        let mut pool = GraphemePool::new();
2075        let mut state = LogViewerState::default();
2076
2077        log.scroll_to_top();
2078        let mut frame = Frame::new(20, 3, &mut pool);
2079        log.render(Rect::new(0, 0, 20, 3), &mut frame, &mut state);
2080        let top_line = line_text(&frame, 0, 20);
2081        assert!(
2082            top_line.trim_end().starts_with("line 4000"),
2083            "expected top line to start with line 4000, got: {top_line:?}"
2084        );
2085
2086        log.scroll_to_bottom();
2087        let mut frame = Frame::new(20, 3, &mut pool);
2088        log.render(Rect::new(0, 0, 20, 3), &mut frame, &mut state);
2089        let bottom_line = line_text(&frame, 2, 20);
2090        assert!(
2091            bottom_line.trim_end().starts_with("line 4999"),
2092            "expected bottom line to start with line 4999, got: {bottom_line:?}"
2093        );
2094    }
2095
2096    #[test]
2097    fn test_filtered_autoscroll_respects_manual_position() {
2098        let mut log = LogViewer::new(200);
2099        log.virt.set_visible_count(2);
2100
2101        log.push("match 1");
2102        log.push("skip");
2103        log.push("match 2");
2104        log.push("match 3");
2105        log.push("skip again");
2106        log.push("match 4");
2107        log.push("match 5");
2108
2109        log.set_filter(Some("match"));
2110        assert!(log.is_at_bottom());
2111
2112        log.scroll_up(2);
2113        let offset_before = log.filtered_scroll_offset;
2114        assert!(!log.is_at_bottom());
2115
2116        log.push("match 6");
2117        assert_eq!(log.filtered_scroll_offset, offset_before);
2118
2119        log.scroll_to_bottom();
2120        let offset_at_bottom = log.filtered_scroll_offset;
2121        log.push("match 7");
2122        assert!(log.filtered_scroll_offset >= offset_at_bottom);
2123        assert!(log.is_at_bottom());
2124    }
2125
2126    #[test]
2127    fn test_markup_parsing_preserves_spans() {
2128        let mut log = LogViewer::new(100);
2129        let text = ftui_text::markup::parse_markup("[bold]Hello[/bold] [fg=red]world[/fg]!")
2130            .expect("markup parse failed");
2131        log.push(text);
2132
2133        let item = log.virt.get(0).expect("log line");
2134        let line = &item.lines()[0];
2135        assert_eq!(line.to_plain_text(), "Hello world!");
2136
2137        let spans = line.spans();
2138        assert!(spans.iter().any(|span| span.style.is_some()));
2139        assert!(spans.iter().any(|span| {
2140            span.style
2141                .and_then(|style| style.attrs)
2142                .is_some_and(|attrs| attrs.contains(TextStyleFlags::BOLD))
2143        }));
2144    }
2145
2146    #[test]
2147    fn test_markup_renders_bold_cells() {
2148        let mut log = LogViewer::new(10);
2149        let text = ftui_text::markup::parse_markup("[bold]Hello[/bold] world")
2150            .expect("markup parse failed");
2151        log.push(text);
2152
2153        let mut pool = GraphemePool::new();
2154        let mut frame = Frame::new(16, 1, &mut pool);
2155        let mut state = LogViewerState::default();
2156        log.render(Rect::new(0, 0, 16, 1), &mut frame, &mut state);
2157
2158        let rendered = line_text(&frame, 0, 16);
2159        assert!(rendered.trim_end().starts_with("Hello world"));
2160        for x in 0..5 {
2161            let cell = frame.buffer.get(x, 0).expect("cell");
2162            assert!(
2163                cell.attrs.has_flag(RenderStyleFlags::BOLD),
2164                "expected bold at x={x}, attrs={:?}",
2165                cell.attrs.flags()
2166            );
2167        }
2168    }
2169
2170    #[test]
2171    fn test_toggle_follow_disables_autoscroll_on_push() {
2172        let mut log = LogViewer::new(100);
2173        log.virt.set_visible_count(3);
2174        for i in 0..5 {
2175            log.push(format!("line {}", i));
2176        }
2177        assert!(log.is_at_bottom());
2178
2179        log.toggle_follow();
2180        assert!(!log.auto_scroll_enabled());
2181
2182        log.push("new line");
2183        assert!(!log.auto_scroll_enabled());
2184        assert!(!log.is_at_bottom());
2185    }
2186}