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