1#![forbid(unsafe_code)]
2
3use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
60pub enum LogWrapMode {
61 #[default]
63 NoWrap,
64 CharWrap,
66 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
82pub enum SearchMode {
83 #[default]
85 Literal,
86 Regex,
88}
89
90#[derive(Clone, Debug)]
92pub struct SearchConfig {
93 pub mode: SearchMode,
95 pub case_sensitive: bool,
97 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#[derive(Debug, Clone)]
113struct SearchState {
114 query: String,
116 query_lower: Option<String>,
118 config: SearchConfig,
120 matches: Vec<usize>,
122 current: usize,
124 highlight_ranges: Vec<Vec<(usize, usize)>>,
126 #[cfg(feature = "regex-search")]
128 compiled_regex: Option<regex::Regex>,
129 context_expanded: Option<Vec<usize>>,
132}
133
134#[derive(Debug, Clone, Default)]
139pub struct FilterStats {
140 pub incremental_checks: u64,
142 pub incremental_matches: u64,
144 pub full_rescans: u64,
146 pub full_rescan_lines: u64,
148 pub incremental_search_matches: u64,
150 pub incremental_search_checks: u64,
152}
153
154impl FilterStats {
155 pub fn reset(&mut self) {
157 *self = Self::default();
158 }
159}
160
161#[derive(Debug, Clone)]
174pub struct LogViewer {
175 virt: Virtualized<Text>,
177 max_lines: usize,
179 wrap_mode: LogWrapMode,
181 style: Style,
183 highlight_style: Option<Style>,
185 search_highlight_style: Option<Style>,
187 filter: Option<String>,
189 filtered_indices: Option<Vec<usize>>,
191 filtered_scroll_offset: usize,
193 search: Option<SearchState>,
195 filter_stats: FilterStats,
197}
198
199#[derive(Debug, Clone, Default)]
201pub struct LogViewerState {
202 pub last_viewport_height: u16,
204 pub last_visible_lines: usize,
206 pub selected_line: Option<usize>,
208}
209
210impl LogViewer {
211 #[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 #[must_use]
235 pub fn wrap_mode(mut self, mode: LogWrapMode) -> Self {
236 self.wrap_mode = mode;
237 self
238 }
239
240 #[must_use]
242 pub fn style(mut self, style: Style) -> Self {
243 self.style = style;
244 self
245 }
246
247 #[must_use]
249 pub fn highlight_style(mut self, style: Style) -> Self {
250 self.highlight_style = Some(style);
251 self
252 }
253
254 #[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 #[inline]
263 #[must_use]
264 pub fn len(&self) -> usize {
265 self.virt.len()
266 }
267
268 #[inline]
270 #[must_use]
271 pub fn is_empty(&self) -> bool {
272 self.virt.is_empty()
273 }
274
275 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 for line in text.into_iter() {
291 let item = Text::from_line(line);
292 let plain = item.to_plain_text();
293
294 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 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 if self.virt.len() > self.max_lines {
338 let removed = self.virt.trim_front(self.max_lines);
339
340 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 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 if !search.matches.is_empty() {
383 search.current = search.current.min(search.matches.len() - 1);
384 } else {
385 search.current = 0;
386 }
387 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 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 pub fn scroll_up(&mut self, lines: usize) {
416 if self.filtered_indices.is_some() {
417 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 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 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 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 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 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 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 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 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 #[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 #[must_use]
541 pub fn line_count(&self) -> usize {
542 self.virt.len()
543 }
544
545 #[must_use]
547 pub fn auto_scroll_enabled(&self) -> bool {
548 self.virt.follow_mode()
549 }
550
551 pub fn set_auto_scroll(&mut self, enabled: bool) {
553 self.virt.set_follow(enabled);
554 }
555
556 pub fn toggle_follow(&mut self) {
558 let current = self.virt.follow_mode();
559 self.virt.set_follow(!current);
560 }
561
562 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 #[must_use]
576 pub fn filter_stats(&self) -> &FilterStats {
577 &self.filter_stats
578 }
579
580 pub fn filter_stats_mut(&mut self) -> &mut FilterStats {
582 &mut self.filter_stats
583 }
584
585 pub fn set_filter(&mut self, pattern: Option<&str>) {
589 match pattern {
590 Some(pat) if !pat.is_empty() => {
591 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 pub fn search(&mut self, query: &str) -> usize {
633 self.search_with_config(query, SearchConfig::default())
634 }
635
636 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 #[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 self.search = None;
653 return 0;
654 }
655 }
656 } else {
657 None
658 };
659
660 let query_lower = if !config.case_sensitive {
662 Some(query.to_ascii_lowercase())
663 } else {
664 None
665 };
666
667 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 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 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 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 pub fn clear_search(&mut self) {
761 self.search = None;
762 }
763
764 #[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 #[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 #[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 #[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 #[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 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 #[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 cursor_x =
939 draw_text_span(frame, cursor_x, y, &content[pos..start], base_style, max_x);
940 }
941 if start < end {
942 cursor_x =
944 draw_text_span(frame, cursor_x, y, &content[start..end], hl_style, max_x);
945 }
946 pos = end;
947 }
948 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 let _ = self.virt.visible_range(area.height);
1021
1022 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 let render_indices: Option<&[usize]> = self.filtered_indices.as_deref();
1033
1034 let visible_count = area.height as usize;
1036
1037 let (start_idx, end_idx, _at_bottom_ignored) = if let Some(indices) = render_indices {
1039 let filtered_total = indices.len();
1041 if filtered_total == 0 {
1042 state.last_visible_lines = 0;
1043 return;
1044 }
1045 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 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 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 self.virt.set_visible_count(lines_rendered);
1100
1101 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 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 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
1159fn 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
1208fn 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<®ex::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 Vec::new()
1245 }
1246 }
1247 }
1248}
1249
1250#[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
1261fn 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"); 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()); }
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 }
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 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(); assert_eq!(log.search_info(), Some((1, 3)));
1582
1583 log.prev_match(); 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 assert_eq!(log.filtered_indices.as_ref().unwrap(), &[0, 2]);
1619
1620 log.push("y4"); 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 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 #[test]
1664 fn test_incremental_filter_on_push_tracks_stats() {
1665 let mut log = LogViewer::new(100);
1666 log.set_filter(Some("ERROR"));
1667 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 assert_eq!(log.filter_stats().incremental_checks, 4);
1677 assert_eq!(log.filter_stats().incremental_matches, 2);
1679 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 let count = log.search("hello");
1691 assert_eq!(count, 1);
1692 assert_eq!(log.filter_stats().full_rescans, 1);
1693
1694 log.push("hello again");
1696 log.push("nothing here");
1697
1698 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); log.push("ERROR: hello again");
1715 log.push("INFO: hello again"); assert_eq!(log.search_info(), Some((1, 2))); 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); 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 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 log.push("match C"); let search = log.search.as_ref().unwrap();
1792 assert_eq!(search.matches.len(), 2);
1793 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 assert_eq!(log.filter_stats().full_rescan_lines, initial_lines + 25);
1841 }
1842
1843 #[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 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 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 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 log.push("HELLO again");
2039 assert_eq!(log.search_info(), Some((1, 2)));
2040
2041 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 let count = log.search("error");
2055 assert_eq!(count, 2);
2056
2057 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 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 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 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}