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