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