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