1use std::collections::{BTreeSet, VecDeque};
61use std::fmt;
62use std::rc::Rc;
63
64use iced::futures::channel::mpsc;
65use iced::futures::{Stream, StreamExt, stream};
66use iced::keyboard::Modifiers;
67use iced::keyboard::key::Named;
68use iced::widget::text::{LineHeight, Wrapping};
69use iced::widget::{self, Space, column, container, responsive, row, text};
70use iced::{Alignment, Border, Color, Element, Length, Padding, Pixels, Subscription, Task, Theme};
71
72use crate::data::ellipsis::ellipsis;
73use crate::data::interaction::{
74 Look, RowMenu, match_count_label, pressable, ringed, row_menu, scope, search_count, search_keys,
75};
76use crate::data::marked::{find, marked};
77use crate::data::rows::rows;
78use crate::data::virtual_list::{self, Step, target};
79use crate::keys::{self, Chord, Keymap};
80use crate::overlay::context_menu;
81use crate::overlay::dropdown_menu::{item, separator};
82use crate::overlay::tooltip::Position;
83use crate::primitives::button::{self, button};
84use crate::primitives::icon_button::icon_button;
85use crate::primitives::input::{self, input};
86use crate::primitives::toggle;
87use crate::primitives::toggle_group::{self, toggle_group};
88use crate::theme::{self, Tokens, fade, mix, radius, space, text_size};
89
90pub const ROW_HEIGHT: f32 = 24.0;
92pub const STACKED_ROW_HEIGHT: f32 = 42.0;
95pub const DEFAULT_LIMIT: usize = 10_000;
97pub const CHANNEL_CAPACITY: usize = 1024;
99pub const BATCH_SIZE: usize = 512;
101pub const SEARCH_ID: widget::Id = widget::Id::new("iced-cube-log-viewer-search");
103pub const BREAKPOINT: f32 = 560.0;
105const LINE_HEIGHT: f32 = 18.0;
106const TIMESTAMP_WIDTH: f32 = 84.0;
107const LEVEL_WIDTH: f32 = 44.0;
108const TARGET_WIDTH: f32 = 120.0;
109const FRAME_PADDING: f32 = 4.0;
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
113pub enum Level {
114 Trace,
115 Debug,
116 #[default]
117 Info,
118 Warn,
119 Error,
120}
121
122impl Level {
123 pub const ALL: [Level; 5] = [
124 Level::Trace,
125 Level::Debug,
126 Level::Info,
127 Level::Warn,
128 Level::Error,
129 ];
130
131 pub const fn tag(self) -> &'static str {
133 match self {
134 Level::Trace => "TRACE",
135 Level::Debug => "DEBUG",
136 Level::Info => "INFO",
137 Level::Warn => "WARN",
138 Level::Error => "ERROR",
139 }
140 }
141
142 pub const fn name(self) -> &'static str {
144 match self {
145 Level::Trace => "Trace",
146 Level::Debug => "Debug",
147 Level::Info => "Info",
148 Level::Warn => "Warn",
149 Level::Error => "Error",
150 }
151 }
152
153 const fn index(self) -> usize {
154 self as usize
155 }
156}
157
158impl fmt::Display for Level {
159 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 f.write_str(self.tag())
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Default)]
166pub struct Entry {
167 pub level: Level,
168 pub timestamp: Option<String>,
170 pub target: Option<String>,
172 pub message: String,
173}
174
175pub fn entry(level: Level, message: impl Into<String>) -> Entry {
177 Entry {
178 level,
179 timestamp: None,
180 target: None,
181 message: message.into(),
182 }
183}
184
185impl Entry {
186 pub fn timestamp(mut self, timestamp: impl Into<String>) -> Self {
187 self.timestamp = Some(timestamp.into());
188 self
189 }
190
191 pub fn target(mut self, target: impl Into<String>) -> Self {
192 self.target = Some(target.into());
193 self
194 }
195
196 pub fn line(&self) -> String {
198 let mut line = String::new();
199 if let Some(timestamp) = &self.timestamp {
200 line.push_str(timestamp);
201 line.push(' ');
202 }
203 line.push_str(&format!("{:<5} ", self.level.tag()));
204 if let Some(target) = &self.target {
205 line.push_str(target);
206 line.push_str(": ");
207 }
208 line.push_str(&self.message);
209 line
210 }
211}
212
213pub type LineId = u64;
216
217pub type Sender = mpsc::Sender<Entry>;
220
221#[derive(Debug, Clone)]
223pub enum Event {
224 Ready(Sender),
226 Received(Vec<Entry>),
228 Press(LineId, Modifiers),
230 Search(String),
232 NextMatch,
233 PreviousMatch,
234 ClearSearch,
235 FocusSearch,
237 ToggleLevel(Level),
239 Levels(toggle_group::Event<Level>),
241 FilterToLevel(LineId),
243 ToggleWrap,
244 TogglePause,
246 Clear,
248 CopySelection,
250 CopyLine(LineId),
252 CopyMessage(LineId),
254 ToggleHighlighted,
256 Follow(bool),
260 Next,
261 Previous,
262 First,
263 Last,
265 NextPage,
266 PreviousPage,
267 ExtendNext,
268 ExtendPrevious,
269 SelectAll,
270 Page(usize),
272 Focus(bool),
274}
275
276#[derive(Debug, Clone)]
278pub enum Output {
279 Ready(Sender),
280 Copy(String),
282 FocusSearch,
284}
285
286#[derive(Debug, Clone)]
289pub struct State {
290 entries: VecDeque<Entry>,
291 first: LineId,
293 limit: usize,
294 counts: [usize; 5],
295 shown: [bool; 5],
296 levels: toggle_group::State<Level>,
298 visible: VecDeque<LineId>,
300 visible_dropped: u64,
303 query: String,
304 matches: VecDeque<LineId>,
306 current: Option<LineId>,
307 selected: BTreeSet<LineId>,
308 anchor: Option<LineId>,
309 highlighted: Option<LineId>,
310 focused: bool,
311 following: bool,
312 paused: bool,
313 pending: VecDeque<Entry>,
315 unseen: usize,
317 wrap: bool,
318 page: usize,
319 reveal: Option<(LineId, u64)>,
320 requests: u64,
321 search_id: widget::Id,
322}
323
324impl Default for State {
325 fn default() -> Self {
326 Self {
327 entries: VecDeque::new(),
328 first: 0,
329 limit: DEFAULT_LIMIT,
330 counts: [0; 5],
331 shown: [true; 5],
332 levels: level_filters(&[0; 5], &[true; 5]),
333 visible: VecDeque::new(),
334 visible_dropped: 0,
335 query: String::new(),
336 matches: VecDeque::new(),
337 current: None,
338 selected: BTreeSet::new(),
339 anchor: None,
340 highlighted: None,
341 focused: false,
342 following: true,
343 paused: false,
344 pending: VecDeque::new(),
345 unseen: 0,
346 wrap: false,
347 page: virtual_list::DEFAULT_PAGE,
348 reveal: None,
349 requests: 0,
350 search_id: SEARCH_ID,
351 }
352 }
353}
354
355impl State {
356 pub fn new() -> Self {
359 Self::default()
360 }
361
362 pub fn with_limit(mut self, limit: usize) -> Self {
365 self.limit = limit.max(1);
366 self.trim();
367 self.sync_levels();
368 self
369 }
370
371 pub fn with_levels(mut self, levels: impl IntoIterator<Item = Level>) -> Self {
373 self.shown = [false; 5];
374 for level in levels {
375 self.shown[level.index()] = true;
376 }
377 self.refilter();
378 self
379 }
380
381 pub fn with_wrap(mut self, wrap: bool) -> Self {
383 self.wrap = wrap;
384 self
385 }
386
387 pub fn with_search_id(mut self, id: impl Into<widget::Id>) -> Self {
390 self.search_id = id.into();
391 self
392 }
393
394 pub fn extend(&mut self, entries: impl IntoIterator<Item = Entry>) {
396 if self.paused {
397 self.pending.extend(entries);
398 let over = self.pending.len().saturating_sub(self.limit);
399 self.pending.drain(..over);
400 return;
401 }
402 for entry in entries {
403 self.append(entry);
404 }
405 self.trim();
406 self.sync_levels();
407 }
408
409 pub fn entries(&self) -> impl Iterator<Item = &Entry> {
411 self.entries.iter()
412 }
413
414 pub fn len(&self) -> usize {
416 self.entries.len()
417 }
418
419 pub fn is_empty(&self) -> bool {
420 self.entries.is_empty()
421 }
422
423 pub fn limit(&self) -> usize {
424 self.limit
425 }
426
427 pub fn entry(&self, id: LineId) -> Option<&Entry> {
429 let index = usize::try_from(id.checked_sub(self.first)?).ok()?;
430 self.entries.get(index)
431 }
432
433 pub fn visible(&self) -> impl Iterator<Item = LineId> + '_ {
435 self.visible.iter().copied()
436 }
437
438 pub fn visible_len(&self) -> usize {
440 self.visible.len()
441 }
442
443 pub fn count(&self, level: Level) -> usize {
445 self.counts[level.index()]
446 }
447
448 pub fn is_shown(&self, level: Level) -> bool {
449 self.shown[level.index()]
450 }
451
452 pub fn levels(&self) -> &toggle_group::State<Level> {
455 &self.levels
456 }
457
458 pub fn query(&self) -> &str {
459 &self.query
460 }
461
462 pub fn matches(&self) -> impl Iterator<Item = LineId> + '_ {
464 self.matches.iter().copied()
465 }
466
467 pub fn match_count(&self) -> usize {
468 self.matches.len()
469 }
470
471 pub fn current_match(&self) -> Option<(LineId, usize)> {
474 let id = self.current?;
475 let position = self.matches.binary_search(&id).ok()?;
476 Some((id, position + 1))
477 }
478
479 pub fn is_selected(&self, id: LineId) -> bool {
480 self.selected.contains(&id)
481 }
482
483 pub fn selected(&self) -> Vec<LineId> {
485 self.selected
486 .iter()
487 .copied()
488 .filter(|id| self.position(*id).is_some())
489 .collect()
490 }
491
492 pub fn highlighted(&self) -> Option<LineId> {
494 self.highlighted.filter(|id| self.position(*id).is_some())
495 }
496
497 pub fn is_focused(&self) -> bool {
498 self.focused
499 }
500
501 pub fn is_following(&self) -> bool {
502 self.following
503 }
504
505 pub fn is_paused(&self) -> bool {
506 self.paused
507 }
508
509 pub fn pending(&self) -> usize {
511 self.pending.len()
512 }
513
514 pub fn unseen(&self) -> usize {
516 self.unseen
517 }
518
519 pub fn is_wrapped(&self) -> bool {
520 self.wrap
521 }
522
523 pub fn search_id(&self) -> &widget::Id {
524 &self.search_id
525 }
526
527 pub fn can_copy(&self) -> bool {
530 self.highlighted().is_some() || self.selected.iter().any(|&id| self.position(id).is_some())
531 }
532
533 pub fn copy_text(&self) -> Option<String> {
536 let mut ids = self.selected();
537 if ids.is_empty() {
538 ids.extend(self.highlighted());
539 }
540 if ids.is_empty() {
541 return None;
542 }
543 let lines: Vec<String> = ids
544 .into_iter()
545 .filter_map(|id| self.entry(id).map(Entry::line))
546 .collect();
547 Some(lines.join("\n"))
548 }
549
550 pub fn update(&mut self, event: Event) -> Option<Output> {
552 match event {
553 Event::Ready(sender) => return Some(Output::Ready(sender)),
554 Event::Received(entries) => self.extend(entries),
555 Event::Press(id, modifiers) => self.press(id, modifiers),
556 Event::Search(query) => {
557 self.query = query;
558 self.current = None;
559 self.rematch();
560 self.jump_nearest();
561 }
562 Event::NextMatch => self.jump(true),
563 Event::PreviousMatch => self.jump(false),
564 Event::ClearSearch => {
565 self.query.clear();
566 self.current = None;
567 self.matches.clear();
568 }
569 Event::FocusSearch => return Some(Output::FocusSearch),
570 Event::ToggleLevel(level) => {
571 self.shown[level.index()] = !self.shown[level.index()];
572 self.refilter();
573 }
574 Event::Levels(event) => {
575 if self.levels.update(event).is_some() {
576 for level in Level::ALL {
577 self.shown[level.index()] = self.levels.is_selected(level);
578 }
579 self.refilter();
580 }
581 }
582 Event::FilterToLevel(id) => {
583 let level = self.entry(id)?.level;
584 self.shown = [false; 5];
585 self.shown[level.index()] = true;
586 self.refilter();
587 }
588 Event::ToggleWrap => self.wrap = !self.wrap,
589 Event::TogglePause => self.toggle_pause(),
590 Event::Clear => self.clear(),
591 Event::CopySelection => return self.copy_text().map(Output::Copy),
592 Event::CopyLine(id) => return self.entry(id).map(|entry| Output::Copy(entry.line())),
593 Event::CopyMessage(id) => {
594 return self
595 .entry(id)
596 .map(|entry| Output::Copy(entry.message.clone()));
597 }
598 Event::ToggleHighlighted => {
599 let id = self.highlighted()?;
600 self.press(id, Modifiers::COMMAND);
601 }
602 Event::Follow(follow) => self.follow(follow),
603 Event::Next => self.step(Step::Next),
604 Event::Previous => self.step(Step::Previous),
605 Event::First => self.step(Step::First),
606 Event::Last => {
607 self.step(Step::Last);
608 self.follow(true);
609 }
610 Event::NextPage => self.step(Step::NextPage),
611 Event::PreviousPage => self.step(Step::PreviousPage),
612 Event::ExtendNext => self.extend_selection(Step::Next),
613 Event::ExtendPrevious => self.extend_selection(Step::Previous),
614 Event::SelectAll => self.selected = self.visible.iter().copied().collect(),
615 Event::Page(lines) => self.page = lines.max(1),
616 Event::Focus(focused) => {
617 self.focused = focused;
618 if focused && self.highlighted().is_none() {
621 let start = if self.following {
622 self.visible.back()
623 } else {
624 self.visible.front()
625 };
626 self.highlighted = self.selected().first().or(start).copied();
627 }
628 }
629 }
630 None
631 }
632
633 pub fn key_event(&self, keymap: &Keymap<Action>, key: &keys::Event) -> Option<Event> {
635 keymap.resolve_event(key)?.event(self)
636 }
637
638 fn position(&self, id: LineId) -> Option<usize> {
640 self.visible.binary_search(&id).ok()
641 }
642
643 fn matches_query(&self, entry: &Entry) -> bool {
644 !self.query.is_empty() && !find(&entry.message, &self.query).is_empty()
645 }
646
647 fn append(&mut self, entry: Entry) {
648 let id = self.first + self.entries.len() as u64;
649 self.counts[entry.level.index()] += 1;
650 if self.shown[entry.level.index()] {
651 self.visible.push_back(id);
652 if self.matches_query(&entry) {
653 self.matches.push_back(id);
654 }
655 if !self.following {
656 self.unseen += 1;
657 }
658 }
659 self.entries.push_back(entry);
660 }
661
662 fn trim(&mut self) {
664 while self.entries.len() > self.limit {
665 let Some(entry) = self.entries.pop_front() else {
666 break;
667 };
668 let id = self.first;
669 self.first += 1;
670 self.counts[entry.level.index()] -= 1;
671 if self.visible.front() == Some(&id) {
672 let _ = self.visible.pop_front();
673 self.visible_dropped += 1;
674 }
675 if self.matches.front() == Some(&id) {
676 let _ = self.matches.pop_front();
677 }
678 }
679 let first = self.first;
680 self.selected = self.selected.split_off(&first);
681 let gone = |id: &LineId| *id < first;
682 if self.highlighted.as_ref().is_some_and(gone) {
683 self.highlighted = None;
684 }
685 if self.anchor.as_ref().is_some_and(gone) {
686 self.anchor = None;
687 }
688 if self.current.as_ref().is_some_and(gone) {
689 self.current = None;
690 }
691 if self.reveal.as_ref().is_some_and(|(id, _)| gone(id)) {
692 self.reveal = None;
693 }
694 }
695
696 fn refilter(&mut self) {
699 let first = self.first;
700 self.visible = self
701 .entries
702 .iter()
703 .enumerate()
704 .filter(|(_, entry)| self.shown[entry.level.index()])
705 .map(|(index, _)| first + index as u64)
706 .collect();
707 let visible: BTreeSet<LineId> = self.visible.iter().copied().collect();
708 self.selected.retain(|id| visible.contains(id));
709 self.sync_levels();
710 self.rematch();
711 if let Some(id) = self.highlighted() {
712 self.reveal_line(id);
713 }
714 }
715
716 fn rematch(&mut self) {
718 self.matches = self
719 .visible
720 .iter()
721 .copied()
722 .filter(|&id| {
723 self.entry(id)
724 .is_some_and(|entry| self.matches_query(entry))
725 })
726 .collect();
727 }
728
729 fn clear(&mut self) {
730 self.first += self.entries.len() as u64;
731 self.entries.clear();
732 self.counts = [0; 5];
733 self.visible.clear();
734 self.matches.clear();
735 self.current = None;
736 self.selected.clear();
737 self.anchor = None;
738 self.highlighted = None;
739 self.reveal = None;
740 self.pending.clear();
741 self.unseen = 0;
742 self.following = true;
743 self.sync_levels();
744 }
745
746 fn sync_levels(&mut self) {
748 self.levels = level_filters(&self.counts, &self.shown);
749 }
750
751 fn follow(&mut self, follow: bool) {
752 self.following = follow;
753 if follow {
754 self.unseen = 0;
755 }
756 }
757
758 fn toggle_pause(&mut self) {
759 if !self.paused {
760 self.paused = true;
761 return;
762 }
763 self.paused = false;
764 let pending = std::mem::take(&mut self.pending);
765 self.extend(pending);
766 }
767
768 fn reveal_line(&mut self, id: LineId) {
769 self.requests += 1;
770 self.reveal = Some((id, self.requests));
771 }
772
773 fn reveal_target(&self) -> Option<(usize, u64)> {
775 let (id, request) = self.reveal?;
776 Some((self.position(id)?, request))
777 }
778
779 fn move_to(&mut self, id: LineId) {
782 self.highlighted = Some(id);
783 self.reveal_line(id);
784 self.follow(self.visible.back() == Some(&id));
785 }
786
787 fn step(&mut self, step: Step) {
788 let current = self.highlighted().and_then(|id| self.position(id));
789 let Some(index) = target(step, current, self.visible.len(), self.page) else {
790 return;
791 };
792 if let Some(&id) = self.visible.get(index) {
793 self.move_to(id);
794 }
795 }
796
797 fn jump(&mut self, forward: bool) {
800 let from = self
801 .current_match()
802 .map(|(id, _)| id)
803 .or(self.highlighted());
804 let next = match (forward, from) {
805 (true, Some(from)) => {
806 let after = self.matches.partition_point(|&id| id <= from);
807 self.matches.get(after).or(self.matches.front())
808 }
809 (true, None) => self.matches.front(),
810 (false, Some(from)) => {
811 let before = self.matches.partition_point(|&id| id < from);
812 before
813 .checked_sub(1)
814 .and_then(|index| self.matches.get(index))
815 .or(self.matches.back())
816 }
817 (false, None) => self.matches.back(),
818 };
819 let Some(&id) = next else {
820 return;
821 };
822 self.current = Some(id);
823 self.move_to(id);
824 }
825
826 fn jump_nearest(&mut self) {
831 let nearest = match self.highlighted() {
832 Some(from) => {
833 let at = self.matches.partition_point(|&id| id < from);
834 self.matches.get(at).or(self.matches.back())
835 }
836 None if self.following => self.matches.back(),
837 None => self.matches.front(),
838 };
839 let Some(&id) = nearest else {
840 return;
841 };
842 self.current = Some(id);
843 self.move_to(id);
844 }
845
846 fn press(&mut self, id: LineId, modifiers: Modifiers) {
847 if self.position(id).is_none() {
848 return;
849 }
850 self.highlighted = Some(id);
851 if self.visible.back() != Some(&id) {
852 self.follow(false);
853 }
854 match (modifiers.shift(), modifiers.command(), self.anchor) {
855 (true, additive, Some(anchor)) => {
856 if !additive {
857 self.selected.clear();
858 }
859 self.selected.extend(self.range(anchor, id));
860 }
861 (_, true, _) => {
862 if !self.selected.remove(&id) {
863 let _ = self.selected.insert(id);
864 }
865 self.anchor = Some(id);
866 }
867 _ => {
868 self.selected = BTreeSet::from([id]);
869 self.anchor = Some(id);
870 }
871 }
872 }
873
874 fn range(&self, a: LineId, b: LineId) -> Vec<LineId> {
876 let (Some(start), Some(end)) = (self.position(a), self.position(b)) else {
877 return vec![b];
878 };
879 let (start, end) = (start.min(end), start.max(end));
880 self.visible.range(start..=end).copied().collect()
881 }
882
883 fn extend_selection(&mut self, step: Step) {
884 let anchor = self.anchor.or(self.highlighted());
885 self.step(step);
886 let (Some(anchor), Some(highlighted)) = (anchor, self.highlighted()) else {
887 return;
888 };
889 self.anchor = Some(anchor);
890 self.selected = self.range(anchor, highlighted).into_iter().collect();
891 }
892}
893
894fn level_filters(counts: &[usize; 5], shown: &[bool; 5]) -> toggle_group::State<Level> {
896 let items = Level::ALL.map(|level| {
897 let label = format!("{} {}", level.name(), counts[level.index()]);
898 toggle_group::item(level, label)
899 });
900 toggle_group::State::multiple(items)
901 .with_selected(Level::ALL.into_iter().filter(|level| shown[level.index()]))
902}
903
904#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
906pub enum Action {
907 Next,
908 Previous,
909 First,
910 Last,
911 NextPage,
912 PreviousPage,
913 ExtendNext,
914 ExtendPrevious,
915 Select,
916 SelectAll,
917 CopySelection,
918 FocusSearch,
919 NextMatch,
920 PreviousMatch,
921 ClearSearch,
922 TogglePause,
923 ToggleWrap,
924 Clear,
925}
926
927impl Action {
928 pub fn event(self, state: &State) -> Option<Event> {
931 let lines = state.visible_len() > 0;
932 match self {
933 Action::Select => state.highlighted().map(|_| Event::ToggleHighlighted),
934 Action::Next => lines.then_some(Event::Next),
935 Action::Previous => lines.then_some(Event::Previous),
936 Action::First => lines.then_some(Event::First),
937 Action::Last => Some(Event::Last),
938 Action::NextPage => lines.then_some(Event::NextPage),
939 Action::PreviousPage => lines.then_some(Event::PreviousPage),
940 Action::ExtendNext => lines.then_some(Event::ExtendNext),
941 Action::ExtendPrevious => lines.then_some(Event::ExtendPrevious),
942 Action::SelectAll => lines.then_some(Event::SelectAll),
943 Action::CopySelection => state.can_copy().then_some(Event::CopySelection),
944 Action::FocusSearch => Some(Event::FocusSearch),
945 Action::NextMatch => (state.match_count() > 0).then_some(Event::NextMatch),
946 Action::PreviousMatch => (state.match_count() > 0).then_some(Event::PreviousMatch),
947 Action::ClearSearch => (!state.query().is_empty()).then_some(Event::ClearSearch),
948 Action::TogglePause => Some(Event::TogglePause),
949 Action::ToggleWrap => Some(Event::ToggleWrap),
950 Action::Clear => (!state.is_empty()).then_some(Event::Clear),
951 }
952 }
953}
954
955impl keys::Action for Action {
956 const ALL: &'static [Self] = &[
957 Action::Next,
958 Action::Previous,
959 Action::First,
960 Action::Last,
961 Action::NextPage,
962 Action::PreviousPage,
963 Action::ExtendNext,
964 Action::ExtendPrevious,
965 Action::Select,
966 Action::SelectAll,
967 Action::CopySelection,
968 Action::FocusSearch,
969 Action::NextMatch,
970 Action::PreviousMatch,
971 Action::ClearSearch,
972 Action::TogglePause,
973 Action::ToggleWrap,
974 Action::Clear,
975 ];
976
977 fn defaults() -> Keymap<Self> {
978 default_keymap()
979 }
980
981 fn name(self) -> &'static str {
982 match self {
983 Action::Next => "Next",
984 Action::Previous => "Previous",
985 Action::First => "First",
986 Action::Last => "Last",
987 Action::NextPage => "NextPage",
988 Action::PreviousPage => "PreviousPage",
989 Action::ExtendNext => "ExtendNext",
990 Action::ExtendPrevious => "ExtendPrevious",
991 Action::Select => "Select",
992 Action::SelectAll => "SelectAll",
993 Action::CopySelection => "CopySelection",
994 Action::FocusSearch => "FocusSearch",
995 Action::NextMatch => "NextMatch",
996 Action::PreviousMatch => "PreviousMatch",
997 Action::ClearSearch => "ClearSearch",
998 Action::TogglePause => "TogglePause",
999 Action::ToggleWrap => "ToggleWrap",
1000 Action::Clear => "Clear",
1001 }
1002 }
1003
1004 fn description(self) -> &'static str {
1005 match self {
1006 Action::Next => "Highlights the next line.",
1007 Action::Previous => "Highlights the previous line.",
1008 Action::First => "Highlights the oldest line.",
1009 Action::Last => "Jumps to the newest line and follows new ones.",
1010 Action::NextPage => "Moves the highlight down by a page of lines.",
1011 Action::PreviousPage => "Moves the highlight up by a page of lines.",
1012 Action::ExtendNext => "Moves down and extends the selection.",
1013 Action::ExtendPrevious => "Moves up and extends the selection.",
1014 Action::Select => "Selects or deselects the highlighted line.",
1015 Action::SelectAll => "Selects every shown line.",
1016 Action::CopySelection => "Copies the selected lines, or the highlighted one.",
1017 Action::FocusSearch => "Focuses the search field.",
1018 Action::NextMatch => "Moves to the next line matching the search.",
1019 Action::PreviousMatch => "Moves to the previous line matching the search.",
1020 Action::ClearSearch => "Clears the search.",
1021 Action::TogglePause => "Pauses the stream, or resumes it with the held lines.",
1022 Action::ToggleWrap => "Wraps long lines, or ends them in an ellipsis.",
1023 Action::Clear => "Removes every line.",
1024 }
1025 }
1026}
1027
1028pub fn default_keymap() -> Keymap<Action> {
1058 Keymap::new()
1059 .bind(Chord::named(Named::ArrowDown), Action::Next)
1060 .bind(Chord::named(Named::ArrowUp), Action::Previous)
1061 .bind(Chord::named(Named::Home), Action::First)
1062 .bind(Chord::named(Named::End), Action::Last)
1063 .bind(Chord::named(Named::PageDown), Action::NextPage)
1064 .bind(Chord::named(Named::PageUp), Action::PreviousPage)
1065 .bind(Chord::named(Named::ArrowDown).shift(), Action::ExtendNext)
1066 .bind(Chord::named(Named::ArrowUp).shift(), Action::ExtendPrevious)
1067 .bind(Chord::named(Named::Space), Action::Select)
1068 .bind(Chord::character('a').command(), Action::SelectAll)
1069 .bind(Chord::character('c').command(), Action::CopySelection)
1070 .bind(Chord::character('f').command(), Action::FocusSearch)
1071 .bind(Chord::named(Named::F3), Action::NextMatch)
1072 .bind(Chord::named(Named::F3).shift(), Action::PreviousMatch)
1073 .bind(Chord::named(Named::Escape), Action::ClearSearch)
1074 .bind(Chord::character('l').ctrl(), Action::Clear)
1075}
1076
1077pub fn subscription() -> Subscription<Event> {
1080 Subscription::run(stream)
1081}
1082
1083pub fn stream() -> impl Stream<Item = Event> {
1085 let (sender, receiver) = mpsc::channel(CHANNEL_CAPACITY);
1086 stream::once(async move { Event::Ready(sender) })
1087 .chain(receiver.ready_chunks(BATCH_SIZE).map(Event::Received))
1088}
1089
1090pub fn focus_search<T>(state: &State) -> Task<T> {
1092 widget::operation::focus(state.search_id.clone())
1093}
1094
1095pub fn match_label(state: &State) -> String {
1099 let current = state.current_match().map(|(_, position)| position);
1100 match_count_label(state.query(), state.match_count(), current)
1101}
1102
1103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1105pub enum MenuItem {
1106 CopyLine,
1107 CopyMessage,
1108 FilterToLevel,
1109}
1110
1111impl MenuItem {
1112 pub const ALL: [MenuItem; 3] = [
1113 MenuItem::CopyLine,
1114 MenuItem::CopyMessage,
1115 MenuItem::FilterToLevel,
1116 ];
1117
1118 pub fn event(self, id: LineId) -> Event {
1120 match self {
1121 MenuItem::CopyLine => Event::CopyLine(id),
1122 MenuItem::CopyMessage => Event::CopyMessage(id),
1123 MenuItem::FilterToLevel => Event::FilterToLevel(id),
1124 }
1125 }
1126}
1127
1128pub fn menu() -> context_menu::State<MenuItem, LineId> {
1132 context_menu::State::new([
1133 item(MenuItem::CopyLine, "Copy line").icon(crate::lucide!(Copy)),
1134 item(MenuItem::CopyMessage, "Copy message").icon(crate::lucide!(TextQuote)),
1135 separator(),
1136 item(MenuItem::FilterToLevel, "Show only this level").icon(crate::lucide!(ListFilter)),
1137 ])
1138}
1139
1140pub fn level_colour(tokens: &Tokens, level: Level) -> Color {
1142 let legible = |colour: Color| {
1145 if tokens.is_dark {
1146 colour
1147 } else {
1148 mix(colour, tokens.foreground, 0.25)
1149 }
1150 };
1151 match level {
1152 Level::Trace => fade(tokens.muted_foreground, 0.7),
1153 Level::Debug => tokens.muted_foreground,
1154 Level::Info => legible(tokens.success),
1155 Level::Warn => legible(tokens.warning),
1156 Level::Error => legible(tokens.destructive),
1157 }
1158}
1159
1160pub type Status = virtual_list::Status;
1162
1163#[derive(Debug, Clone, Copy, PartialEq)]
1165pub struct LineStyle {
1166 pub background: Option<Color>,
1167 pub ring: Option<Color>,
1169 pub text: Color,
1170 pub mark: Color,
1172}
1173
1174pub fn line_style(tokens: &Tokens, level: Level, current: bool, status: Status) -> LineStyle {
1179 let row = virtual_list::row_style(tokens, status);
1180 let tint = (level == Level::Error)
1181 .then(|| fade(tokens.destructive, if tokens.is_dark { 0.14 } else { 0.07 }));
1182 let selected = status.selected && !status.disabled;
1183 LineStyle {
1184 background: row.background.or(tint),
1185 ring: row.ring,
1186 text: if selected {
1187 tokens.accent_foreground
1188 } else {
1189 tokens.foreground
1190 },
1191 mark: fade(tokens.warning, if current { 0.7 } else { 0.35 }),
1192 }
1193}
1194
1195type OnEvent<'a, Message> = dyn Fn(Event) -> Message + 'a;
1196type BuildMenu<'a, Message> =
1197 dyn FnOnce(Keymap<context_menu::Action>) -> RowMenu<'a, LineId, Message> + 'a;
1198
1199pub struct LogViewer<'a, Message> {
1201 state: &'a State,
1202 width: Length,
1203 height: Length,
1204 toolbar: bool,
1205 placeholder: String,
1206 empty: String,
1207 keymap: Keymap<Action>,
1208 menu: Option<Box<BuildMenu<'a, Message>>>,
1209 menu_keymap: Keymap<context_menu::Action>,
1210 on_event: Option<Box<OnEvent<'a, Message>>>,
1211}
1212
1213pub fn log_viewer<'a, Message>(state: &'a State) -> LogViewer<'a, Message> {
1217 LogViewer {
1218 state,
1219 width: Length::Fill,
1220 height: Length::Fill,
1221 toolbar: true,
1222 placeholder: "Search messages".to_owned(),
1223 empty: "No log lines yet".to_owned(),
1224 keymap: default_keymap(),
1225 menu: None,
1226 menu_keymap: context_menu::default_keymap(),
1227 on_event: None,
1228 }
1229}
1230
1231impl<Message> fmt::Debug for LogViewer<'_, Message> {
1232 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1233 f.debug_struct("LogViewer")
1234 .field("lines", &self.state.visible_len())
1235 .field("width", &self.width)
1236 .field("height", &self.height)
1237 .field("toolbar", &self.toolbar)
1238 .field("placeholder", &self.placeholder)
1239 .field("empty", &self.empty)
1240 .field("keymap", &self.keymap)
1241 .finish_non_exhaustive()
1242 }
1243}
1244
1245impl<'a, Message> LogViewer<'a, Message> {
1246 pub fn width(mut self, width: impl Into<Length>) -> Self {
1247 self.width = width.into();
1248 self
1249 }
1250
1251 pub fn height(mut self, height: impl Into<Length>) -> Self {
1253 self.height = height.into();
1254 self
1255 }
1256
1257 pub fn toolbar(mut self, toolbar: bool) -> Self {
1259 self.toolbar = toolbar;
1260 self
1261 }
1262
1263 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
1265 self.placeholder = placeholder.into();
1266 self
1267 }
1268
1269 pub fn empty(mut self, message: impl Into<String>) -> Self {
1271 self.empty = message.into();
1272 self
1273 }
1274
1275 pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
1277 self.keymap = keymap;
1278 self
1279 }
1280
1281 pub fn on_event(mut self, on_event: impl Fn(Event) -> Message + 'a) -> Self {
1282 self.on_event = Some(Box::new(on_event));
1283 self
1284 }
1285
1286 pub fn context_menu<MenuId>(
1291 mut self,
1292 menu: &'a context_menu::State<MenuId, LineId>,
1293 on_event: impl Fn(context_menu::Event<MenuId, LineId>) -> Message + 'a,
1294 ) -> Self
1295 where
1296 MenuId: Copy + PartialEq + 'a,
1297 Message: Clone + 'a,
1298 {
1299 let on_event: Rc<dyn Fn(context_menu::Event<MenuId, LineId>) -> Message + 'a> =
1300 Rc::new(on_event);
1301 self.menu = Some(Box::new(move |keymap| row_menu(menu, keymap, on_event)));
1302 self
1303 }
1304
1305 pub fn context_menu_keymap(mut self, keymap: Keymap<context_menu::Action>) -> Self {
1307 self.menu_keymap = keymap;
1308 self
1309 }
1310
1311 pub fn is_enabled(&self) -> bool {
1312 self.on_event.is_some()
1313 }
1314}
1315
1316impl<'a, Message> From<LogViewer<'a, Message>> for Element<'a, Message>
1317where
1318 Message: Clone + 'a,
1319{
1320 fn from(viewer: LogViewer<'a, Message>) -> Self {
1321 let LogViewer {
1322 state,
1323 width,
1324 height,
1325 toolbar,
1326 placeholder,
1327 empty,
1328 keymap,
1329 menu,
1330 menu_keymap,
1331 on_event,
1332 } = viewer;
1333 let on_event: Option<Rc<OnEvent<'a, Message>>> = on_event.map(Rc::from);
1334 let menu = menu
1335 .filter(|_| on_event.is_some())
1336 .map(|build| Rc::new(build(menu_keymap)));
1337 let view = Rc::new(View {
1338 state,
1339 toolbar,
1340 placeholder,
1341 empty,
1342 menu: menu.clone(),
1343 on_event: on_event.clone(),
1344 });
1345 let body = responsive(move |size| view.body(size.width < BREAKPOINT))
1346 .width(width)
1347 .height(height);
1348
1349 let Some(on_event) = on_event else {
1350 return body.into();
1351 };
1352 let on_focus = on_event.clone();
1353 scope(body)
1354 .on_focus(move |focused| on_focus(Event::Focus(focused)))
1355 .on_key(move |key| {
1356 let opened = menu
1357 .as_ref()
1358 .zip(state.highlighted())
1359 .and_then(|(menu, target)| menu.key(key, target));
1360 opened.or_else(|| state.key_event(&keymap, key).map(&*on_event))
1361 })
1362 .into()
1363 }
1364}
1365
1366struct View<'a, Message> {
1368 state: &'a State,
1369 toolbar: bool,
1370 placeholder: String,
1371 empty: String,
1372 menu: Option<Rc<RowMenu<'a, LineId, Message>>>,
1373 on_event: Option<Rc<OnEvent<'a, Message>>>,
1374}
1375
1376fn muted(theme: &Theme) -> Color {
1377 Tokens::of(theme).muted_foreground
1378}
1379
1380impl<'a, Message: Clone + 'a> View<'a, Message> {
1381 fn send(&self, event: Event) -> Option<Message> {
1382 self.on_event.as_ref().map(|on_event| on_event(event))
1383 }
1384
1385 fn body(&self, narrow: bool) -> Element<'a, Message> {
1386 let mut parts = column![].spacing(space::SM).height(Length::Fill);
1387 if self.toolbar {
1388 parts = parts.push(self.toolbar(narrow));
1389 }
1390 parts.push(self.lines(narrow)).into()
1391 }
1392
1393 fn toolbar(&self, narrow: bool) -> Element<'a, Message> {
1394 let state = self.state;
1395 let has_matches = state.match_count() > 0;
1396 let field = input(&self.placeholder, state.query())
1397 .id(state.search_id.clone())
1398 .icon(crate::lucide!(Search))
1399 .size(input::Size::Sm)
1400 .width(if narrow {
1401 Length::Fill
1402 } else {
1403 Length::Fixed(240.0)
1404 })
1405 .on_input_maybe(
1406 self.on_event
1407 .clone()
1408 .map(|on_event| move |query| on_event(Event::Search(query))),
1409 );
1410 let search = search_keys(field, state.search_id.clone())
1411 .on_clear(
1412 self.send(Event::ClearSearch)
1413 .filter(|_| !state.query().is_empty()),
1414 )
1415 .on_next(self.send(Event::NextMatch).filter(|_| has_matches))
1416 .on_previous(self.send(Event::PreviousMatch).filter(|_| has_matches));
1417 let found = search_count(match_label(state));
1418 let previous = icon_button(crate::lucide!(ChevronUp))
1419 .label("Previous match")
1420 .tooltip(Some(Position::Bottom))
1421 .size(button::Size::Sm)
1422 .on_press_maybe(self.send(Event::PreviousMatch).filter(|_| has_matches));
1423 let next = icon_button(crate::lucide!(ChevronDown))
1424 .label("Next match")
1425 .tooltip(Some(Position::Bottom))
1426 .size(button::Size::Sm)
1427 .on_press_maybe(self.send(Event::NextMatch).filter(|_| has_matches));
1428 let stepper = row![found, previous, next]
1429 .spacing(space::XS)
1430 .align_y(Alignment::Center);
1431
1432 let mut levels = toggle_group(&state.levels).size(toggle::Size::Sm);
1433 if let Some(on_event) = self.on_event.clone() {
1434 levels = levels.on_event(move |event| on_event(Event::Levels(event)));
1435 }
1436
1437 let can_copy = state.can_copy();
1438 let actions: [Element<'a, Message>; 4] = [
1439 icon_button(crate::lucide!(TextWrap))
1440 .label("Wrap lines")
1441 .tooltip(Some(Position::Bottom))
1442 .size(button::Size::Sm)
1443 .pressed(state.is_wrapped())
1444 .on_press_maybe(self.send(Event::ToggleWrap))
1445 .into(),
1446 icon_button(if state.is_paused() {
1447 crate::lucide!(Play)
1448 } else {
1449 crate::lucide!(Pause)
1450 })
1451 .label(if state.is_paused() { "Resume" } else { "Pause" })
1452 .tooltip(Some(Position::Bottom))
1453 .size(button::Size::Sm)
1454 .pressed(state.is_paused())
1455 .on_press_maybe(self.send(Event::TogglePause))
1456 .into(),
1457 icon_button(crate::lucide!(Copy))
1458 .label("Copy selected lines")
1459 .tooltip(Some(Position::Bottom))
1460 .size(button::Size::Sm)
1461 .on_press_maybe(self.send(Event::CopySelection).filter(|_| can_copy))
1462 .into(),
1463 icon_button(crate::lucide!(Trash))
1464 .label("Clear")
1465 .tooltip(Some(Position::Bottom))
1466 .size(button::Size::Sm)
1467 .on_press_maybe(self.send(Event::Clear).filter(|_| !state.is_empty()))
1468 .into(),
1469 ];
1470
1471 if narrow {
1475 let tools = row![
1476 row(actions).spacing(space::XS),
1477 Space::new().width(Length::Fill),
1478 stepper,
1479 ]
1480 .align_y(Alignment::Center);
1481 return column![search, levels, tools].spacing(space::SM).into();
1482 }
1483 column![
1484 row![
1485 search,
1486 stepper,
1487 Space::new().width(Length::Fill),
1488 row(actions).spacing(space::XS),
1489 ]
1490 .spacing(space::SM)
1491 .align_y(Alignment::Center),
1492 levels,
1493 ]
1494 .spacing(space::SM)
1495 .into()
1496 }
1497
1498 fn lines(&self, narrow: bool) -> Element<'a, Message> {
1499 let state = self.state;
1500 let frame = |content: Element<'a, Message>| {
1501 container(content)
1502 .padding(FRAME_PADDING)
1503 .width(Length::Fill)
1504 .height(Length::Fill)
1505 .style(|theme| virtual_list::frame_style(&Tokens::of(theme)))
1506 };
1507 if state.visible_len() == 0 {
1508 let message = text(self.empty.clone())
1509 .size(text_size::SM)
1510 .style(|theme| text::Style {
1511 color: Some(muted(theme)),
1512 });
1513 return frame(container(message).center(Length::Fill).into()).into();
1514 }
1515
1516 let on_event = self.on_event.clone();
1517 let menu = self.menu.clone();
1518 let menu_target = menu.as_ref().and_then(|menu| menu.target());
1519 let wrap = state.is_wrapped();
1520 let build = move |range: std::ops::Range<usize>| -> Element<'a, Message> {
1521 column(range.filter_map(|index| {
1522 let id = *state.visible.get(index)?;
1523 let entry = state.entry(id)?;
1524 let ringed = ringed(
1525 menu_target.as_ref(),
1526 &id,
1527 state.focused && state.highlighted == Some(id),
1528 );
1529 let line = line_view(state, id, entry, narrow, ringed, on_event.as_ref());
1530 Some(match &menu {
1531 Some(menu) => menu.wrap(id, line),
1532 None => line,
1533 })
1534 }))
1535 .into()
1536 };
1537 let row_height = if narrow {
1538 STACKED_ROW_HEIGHT
1539 } else {
1540 ROW_HEIGHT
1541 };
1542 let mut list = rows(state.visible_len(), row_height, build)
1543 .reveal(state.reveal_target())
1544 .follow(state.is_following())
1545 .origin(state.visible_dropped)
1546 .height(Length::Fill);
1547 if wrap {
1548 list =
1549 list.measured(move |index| state.visible.get(index).copied().unwrap_or_default());
1550 }
1551 if let Some(on_event) = self.on_event.clone() {
1552 let on_page = on_event.clone();
1553 list = list
1554 .on_page(move |lines| on_page(Event::Page(lines)))
1555 .on_follow(move |follow| on_event(Event::Follow(follow)));
1556 }
1557
1558 let pill = self.pill().map(|pill| -> Element<'a, Message> {
1562 container(pill)
1563 .align_right(Length::Fill)
1564 .padding(Padding::ZERO.top(FRAME_PADDING).right(space::XS))
1565 .into()
1566 });
1567 frame(column(std::iter::once(list.into()).chain(pill)).into()).into()
1568 }
1569
1570 fn pill(&self) -> Option<Element<'a, Message>> {
1573 let state = self.state;
1574 if state.is_paused() {
1575 let label = match state.pending() {
1576 0 => "Resume".to_owned(),
1577 waiting => format!("Resume ({waiting} new)"),
1578 };
1579 let resume = button(label)
1580 .icon(crate::lucide!(Play))
1581 .size(button::Size::Sm)
1582 .variant(button::Variant::Secondary)
1583 .on_press_maybe(self.send(Event::TogglePause));
1584 return Some(resume.into());
1585 }
1586 if state.is_following() {
1587 return None;
1588 }
1589 let label = match state.unseen() {
1590 0 => "Jump to latest".to_owned(),
1591 unseen => format!("Jump to latest ({unseen} new)"),
1592 };
1593 let jump = button(label)
1594 .icon(crate::lucide!(ArrowDown))
1595 .size(button::Size::Sm)
1596 .variant(button::Variant::Secondary)
1597 .on_press_maybe(self.send(Event::Last));
1598 Some(jump.into())
1599 }
1600}
1601
1602fn line_view<'a, Message: Clone + 'a>(
1607 state: &'a State,
1608 id: LineId,
1609 entry: &'a Entry,
1610 stacked: bool,
1611 ringed: bool,
1612 on_event: Option<&Rc<OnEvent<'a, Message>>>,
1613) -> Element<'a, Message> {
1614 let status = Status {
1615 selected: state.selected.contains(&id),
1616 hovered: false,
1617 highlighted: ringed,
1618 disabled: on_event.is_none(),
1619 };
1620 let level = entry.level;
1621 let current = state.current == Some(id);
1622 let small = |content: &'a str, width: f32| {
1623 container(
1624 ellipsis(content)
1625 .size(text_size::XS, LINE_HEIGHT)
1626 .width(Length::Shrink)
1627 .colour(muted),
1628 )
1629 .max_width(width)
1630 };
1631
1632 let mut meta = row![].spacing(space::SM).align_y(Alignment::Start);
1633 if let Some(timestamp) = &entry.timestamp {
1634 meta = meta.push(small(timestamp, TIMESTAMP_WIDTH).width(TIMESTAMP_WIDTH));
1635 }
1636 meta = meta.push(
1637 text(level.tag())
1638 .size(text_size::XS)
1639 .line_height(LineHeight::Absolute(Pixels(LINE_HEIGHT)))
1640 .font(theme::semibold())
1641 .wrapping(Wrapping::None)
1642 .width(LEVEL_WIDTH)
1643 .style(move |theme| text::Style {
1644 color: Some(level_colour(&Tokens::of(theme), level)),
1645 }),
1646 );
1647 if let Some(target) = &entry.target {
1648 let width = if stacked { f32::INFINITY } else { TARGET_WIDTH };
1649 meta = meta.push(small(target, width));
1650 }
1651 let wrapping = if state.wrap {
1652 Wrapping::WordOrGlyph
1653 } else {
1654 Wrapping::None
1655 };
1656 let message = marked(
1657 &entry.message,
1658 find(&entry.message, &state.query),
1659 move |theme| line_style(&Tokens::of(theme), level, current, status).text,
1660 move |theme| line_style(&Tokens::of(theme), level, current, status).mark,
1661 )
1662 .size(text_size::XS)
1663 .line_height(LineHeight::Absolute(Pixels(LINE_HEIGHT)))
1664 .wrapping(wrapping);
1665 let (line, height): (Element<'a, Message>, f32) = if stacked {
1666 (column![meta, message].into(), STACKED_ROW_HEIGHT)
1667 } else {
1668 (meta.push(message).into(), ROW_HEIGHT)
1669 };
1670
1671 let padding = (ROW_HEIGHT - LINE_HEIGHT) / 2.0;
1672 let body = container(line)
1673 .padding(Padding::from([padding, space::SM]))
1674 .width(Length::Fill)
1675 .height(if state.wrap {
1676 Length::Shrink
1677 } else {
1678 Length::Fixed(height)
1679 })
1680 .clip(true);
1681 let row = pressable(body, move |theme, hovered| {
1682 let style = line_style(
1683 &Tokens::of(theme),
1684 level,
1685 current,
1686 Status { hovered, ..status },
1687 );
1688 Look {
1689 background: style.background,
1690 border: Border {
1691 color: style.ring.unwrap_or(Color::TRANSPARENT),
1692 width: if style.ring.is_some() { 1.0 } else { 0.0 },
1693 radius: radius::SM.into(),
1694 },
1695 divider: None,
1696 }
1697 });
1698 let Some(on_event) = on_event.cloned() else {
1699 return row.into();
1700 };
1701 row.on_press(move |modifiers| on_event(Event::Press(id, modifiers)))
1702 .into()
1703}
1704
1705#[cfg(test)]
1706mod tests {
1707 use super::*;
1708 use crate::keys::Action as _;
1709 use crate::theme::{dark, light};
1710 use iced::futures::SinkExt;
1711 use iced::futures::executor::block_on;
1712 use iced::keyboard::Key;
1713
1714 fn log(lines: &[(Level, &str)]) -> State {
1715 let mut state = State::new();
1716 state.extend(lines.iter().map(|&(level, message)| entry(level, message)));
1717 state
1718 }
1719
1720 fn sample() -> State {
1721 log(&[
1722 (Level::Info, "server started"),
1723 (Level::Debug, "loaded config"),
1724 (Level::Warn, "disk almost full"),
1725 (Level::Error, "request failed: timeout"),
1726 (Level::Info, "request served"),
1727 ])
1728 }
1729
1730 fn key(key: Key, modifiers: Modifiers) -> keys::Event {
1731 keys::Event { key, modifiers }
1732 }
1733
1734 #[test]
1735 fn entries_format_as_one_line() {
1736 let full = entry(Level::Warn, "slow query")
1737 .timestamp("12:00:01.250")
1738 .target("db::pool");
1739 assert_eq!(full.line(), "12:00:01.250 WARN db::pool: slow query");
1740 assert_eq!(entry(Level::Error, "boom").line(), "ERROR boom");
1741 assert_eq!(Entry::default().level, Level::Info);
1742 }
1743
1744 #[test]
1745 fn levels_have_tags_names_and_an_order() {
1746 assert_eq!(Level::ALL.len(), 5);
1747 assert_eq!(Level::Warn.tag(), "WARN");
1748 assert_eq!(Level::Warn.name(), "Warn");
1749 assert_eq!(Level::Error.to_string(), "ERROR");
1750 assert!(Level::Trace < Level::Error);
1751 for (index, level) in Level::ALL.iter().enumerate() {
1752 assert_eq!(level.index(), index);
1753 }
1754 }
1755
1756 #[test]
1757 fn a_new_log_follows_and_shows_every_level() {
1758 let state = State::new();
1759 assert!(state.is_empty());
1760 assert!(state.is_following());
1761 assert!(!state.is_paused());
1762 assert!(!state.is_wrapped());
1763 assert_eq!(state.limit(), DEFAULT_LIMIT);
1764 assert_eq!(state.search_id(), &SEARCH_ID);
1765 assert!(Level::ALL.iter().all(|&level| state.is_shown(level)));
1766 assert!(State::new().with_wrap(true).is_wrapped());
1767 let custom = State::new().with_search_id(widget::Id::new("logs"));
1768 assert_eq!(custom.search_id(), &widget::Id::new("logs"));
1769 }
1770
1771 #[test]
1772 fn received_entries_are_counted_by_level() {
1773 let mut state = sample();
1774 assert_eq!(state.len(), 5);
1775 assert_eq!(state.visible_len(), 5);
1776 assert_eq!(state.count(Level::Info), 2);
1777 assert_eq!(state.count(Level::Trace), 0);
1778 let _ = state.update(Event::Received(vec![entry(Level::Error, "again")]));
1779 assert_eq!(state.count(Level::Error), 2);
1780 assert_eq!(
1781 state.entry(5).map(|entry| entry.message.as_str()),
1782 Some("again")
1783 );
1784 assert_eq!(state.entries().count(), 6);
1785 }
1786
1787 #[test]
1788 fn the_limit_keeps_the_newest_entries() {
1789 let mut state = sample().with_limit(3);
1790 assert_eq!(state.len(), 3);
1791 assert_eq!(state.entry(0), None, "dropped");
1792 assert_eq!(state.visible().collect::<Vec<_>>(), [2, 3, 4]);
1793 assert_eq!(state.count(Level::Info), 1);
1794 assert_eq!(state.count(Level::Debug), 0);
1795 assert_eq!(state.visible_dropped, 2);
1796 let _ = state.update(Event::Press(2, Modifiers::empty()));
1797 let _ = state.update(Event::Received(vec![
1798 entry(Level::Info, "a"),
1799 entry(Level::Info, "b"),
1800 ]));
1801 assert_eq!(state.visible().collect::<Vec<_>>(), [4, 5, 6]);
1802 assert!(state.selected().is_empty(), "the selected line was dropped");
1803 assert_eq!(state.highlighted(), None);
1804 assert_eq!(State::new().with_limit(0).limit(), 1);
1805 }
1806
1807 #[test]
1808 fn hiding_a_level_filters_lines_and_their_selection() {
1809 let mut state = sample();
1810 let _ = state.update(Event::Press(1, Modifiers::empty()));
1811 let _ = state.update(Event::ToggleLevel(Level::Debug));
1812 assert!(!state.is_shown(Level::Debug));
1813 assert_eq!(state.visible().collect::<Vec<_>>(), [0, 2, 3, 4]);
1814 assert!(state.selected().is_empty());
1815 let _ = state.update(Event::Received(vec![entry(Level::Debug, "hidden")]));
1816 assert_eq!(state.visible_len(), 4, "new hidden lines stay hidden");
1817 assert_eq!(state.count(Level::Debug), 2, "but are still counted");
1818 let _ = state.update(Event::ToggleLevel(Level::Debug));
1819 assert_eq!(state.visible_len(), 6);
1820
1821 let errors = sample().with_levels([Level::Warn, Level::Error]);
1822 assert_eq!(errors.visible().collect::<Vec<_>>(), [2, 3]);
1823 }
1824
1825 #[test]
1826 fn search_finds_messages_and_moves_between_matches() {
1827 let mut state = sample();
1828 let _ = state.update(Event::Search("REQUEST".into()));
1829 assert_eq!(state.query(), "REQUEST");
1830 assert_eq!(state.matches().collect::<Vec<_>>(), [3, 4]);
1831 assert_eq!(
1832 state.current_match(),
1833 Some((4, 2)),
1834 "typing jumps to the newest match while following"
1835 );
1836 assert!(state.is_following(), "the newest line keeps following");
1837 let _ = state.update(Event::NextMatch);
1838 assert_eq!(state.current_match(), Some((3, 1)), "wraps to the first");
1839 assert_eq!(state.highlighted(), Some(3));
1840 assert!(
1841 !state.is_following(),
1842 "a match above the end stops following"
1843 );
1844 let _ = state.update(Event::NextMatch);
1845 assert_eq!(state.current_match(), Some((4, 2)));
1846 assert!(state.is_following(), "the newest line follows again");
1847 let _ = state.update(Event::PreviousMatch);
1848 assert_eq!(state.current_match(), Some((3, 1)));
1849 let _ = state.update(Event::PreviousMatch);
1850 assert_eq!(state.current_match(), Some((4, 2)), "wraps to the last");
1851 let _ = state.update(Event::Received(vec![entry(Level::Info, "request queued")]));
1852 assert_eq!(
1853 state.match_count(),
1854 3,
1855 "new lines are searched as they arrive"
1856 );
1857 let _ = state.update(Event::ClearSearch);
1858 assert_eq!(state.query(), "");
1859 assert_eq!(state.match_count(), 0);
1860 assert_eq!(state.current_match(), None);
1861 }
1862
1863 #[test]
1864 fn typing_jumps_to_the_nearest_match_at_or_after_the_highlight() {
1865 let mut state = sample();
1866 let _ = state.update(Event::Press(1, Modifiers::empty()));
1867 let _ = state.update(Event::Search("request".into()));
1868 assert_eq!(state.current_match(), Some((3, 1)));
1869 assert_eq!(state.reveal_target().map(|(index, _)| index), Some(3));
1870 let _ = state.update(Event::Search("request s".into()));
1871 assert_eq!(state.current_match(), Some((4, 1)), "narrows onwards");
1872 let _ = state.update(Event::Search("server".into()));
1873 assert_eq!(
1874 state.current_match(),
1875 Some((0, 1)),
1876 "nothing after the highlight takes the last before it"
1877 );
1878
1879 let mut scrolled = sample();
1880 let _ = scrolled.update(Event::Follow(false));
1881 let _ = scrolled.update(Event::Search("request".into()));
1882 assert_eq!(
1883 scrolled.current_match(),
1884 Some((3, 1)),
1885 "the oldest while not following"
1886 );
1887 }
1888
1889 #[test]
1890 fn stepping_without_matches_does_nothing() {
1891 let mut none = sample();
1892 let _ = none.update(Event::Search("nothing like this".into()));
1893 let _ = none.update(Event::NextMatch);
1894 assert_eq!(none.current_match(), None);
1895 assert_eq!(none.highlighted(), None);
1896 }
1897
1898 #[test]
1899 fn matches_follow_the_level_filter() {
1900 let mut state = sample();
1901 let _ = state.update(Event::Search("request".into()));
1902 let _ = state.update(Event::ToggleLevel(Level::Error));
1903 assert_eq!(state.matches().collect::<Vec<_>>(), [4]);
1904 }
1905
1906 #[test]
1907 fn pausing_holds_entries_back_until_resumed() {
1908 let mut state = sample().with_limit(6);
1909 let _ = state.update(Event::TogglePause);
1910 assert!(state.is_paused());
1911 let _ = state.update(Event::Received(
1912 (0..10)
1913 .map(|n| entry(Level::Info, format!("held {n}")))
1914 .collect(),
1915 ));
1916 assert_eq!(state.len(), 5, "nothing shown while paused");
1917 assert_eq!(state.pending(), 6, "held entries are bounded by the limit");
1918 let _ = state.update(Event::TogglePause);
1919 assert!(!state.is_paused());
1920 assert_eq!(state.pending(), 0);
1921 assert_eq!(state.len(), 6);
1922 let last = state.entries().last().map(|entry| entry.message.clone());
1923 assert_eq!(last.as_deref(), Some("held 9"));
1924 }
1925
1926 #[test]
1927 fn clearing_removes_everything_and_follows() {
1928 let mut state = sample();
1929 let _ = state.update(Event::Press(1, Modifiers::empty()));
1930 let _ = state.update(Event::Clear);
1931 assert!(state.is_empty());
1932 assert_eq!(state.visible_len(), 0);
1933 assert_eq!(state.count(Level::Info), 0);
1934 assert!(state.selected().is_empty());
1935 assert!(state.is_following());
1936 let _ = state.update(Event::Received(vec![entry(Level::Info, "fresh")]));
1937 assert_eq!(state.visible().collect::<Vec<_>>(), [5], "ids carry on");
1938 }
1939
1940 #[test]
1941 fn copying_returns_the_selected_lines_in_order() {
1942 let mut state = sample();
1943 assert!(
1944 state.update(Event::CopySelection).is_none(),
1945 "nothing to copy"
1946 );
1947 assert!(!state.can_copy());
1948 let _ = state.update(Event::Press(3, Modifiers::empty()));
1949 assert!(state.can_copy());
1950 let _ = state.update(Event::Press(0, Modifiers::COMMAND));
1951 let Some(Output::Copy(text)) = state.update(Event::CopySelection) else {
1952 panic!("copies the selection");
1953 };
1954 assert_eq!(text, "INFO server started\nERROR request failed: timeout");
1955 let _ = state.update(Event::Press(0, Modifiers::COMMAND));
1956 let _ = state.update(Event::Press(3, Modifiers::COMMAND));
1957 assert!(state.selected().is_empty());
1958 assert_eq!(
1959 state.copy_text().as_deref(),
1960 Some("ERROR request failed: timeout"),
1961 "falls back to the highlighted line"
1962 );
1963 }
1964
1965 #[test]
1966 fn shift_presses_and_keys_select_ranges_of_shown_lines() {
1967 let mut state = sample();
1968 let _ = state.update(Event::ToggleLevel(Level::Debug));
1969 let _ = state.update(Event::Press(0, Modifiers::empty()));
1970 let _ = state.update(Event::Press(3, Modifiers::SHIFT));
1971 assert_eq!(state.selected(), [0, 2, 3], "the hidden line is skipped");
1972 let _ = state.update(Event::ExtendNext);
1973 assert_eq!(state.selected(), [0, 2, 3, 4]);
1974 let _ = state.update(Event::Press(2, Modifiers::empty()));
1975 let _ = state.update(Event::ExtendPrevious);
1976 assert_eq!(state.selected(), [0, 2]);
1977 let _ = state.update(Event::SelectAll);
1978 assert_eq!(state.selected(), [0, 2, 3, 4]);
1979 let _ = state.update(Event::Press(1, Modifiers::empty()));
1980 assert_eq!(
1981 state.selected(),
1982 [0, 2, 3, 4],
1983 "a hidden line cannot be pressed"
1984 );
1985 }
1986
1987 #[test]
1988 fn navigation_follows_only_at_the_newest_line() {
1989 let mut state = sample();
1990 let _ = state.update(Event::Previous);
1991 assert_eq!(state.highlighted(), Some(4));
1992 assert!(state.is_following());
1993 let _ = state.update(Event::Previous);
1994 assert!(!state.is_following());
1995 let _ = state.update(Event::Received(vec![entry(Level::Info, "new")]));
1996 assert_eq!(state.unseen(), 1);
1997 let _ = state.update(Event::First);
1998 assert_eq!(state.highlighted(), Some(0));
1999 let _ = state.update(Event::Page(2));
2000 let _ = state.update(Event::NextPage);
2001 assert_eq!(state.highlighted(), Some(2));
2002 let _ = state.update(Event::PreviousPage);
2003 assert_eq!(state.highlighted(), Some(0));
2004 let _ = state.update(Event::Next);
2005 assert_eq!(state.highlighted(), Some(1));
2006 let _ = state.update(Event::Last);
2007 assert_eq!(state.highlighted(), Some(5));
2008 assert!(state.is_following());
2009 assert_eq!(state.unseen(), 0);
2010 assert_eq!(state.reveal_target().map(|(index, _)| index), Some(5));
2011 }
2012
2013 #[test]
2014 fn scrolling_away_stops_following_and_counts_new_lines() {
2015 let mut state = sample();
2016 let _ = state.update(Event::Follow(false));
2017 assert!(!state.is_following());
2018 let _ = state.update(Event::Received(vec![
2019 entry(Level::Info, "one"),
2020 entry(Level::Info, "two"),
2021 ]));
2022 assert_eq!(state.unseen(), 2);
2023 let _ = state.update(Event::Follow(true));
2024 assert!(state.is_following());
2025 assert_eq!(state.unseen(), 0);
2026 }
2027
2028 #[test]
2029 fn focus_lands_on_the_newest_line_without_scrolling() {
2030 let mut state = sample();
2031 let _ = state.update(Event::Focus(true));
2032 assert!(state.is_focused());
2033 assert_eq!(state.highlighted(), Some(4));
2034 assert_eq!(state.reveal_target(), None);
2035 let mut paused = sample();
2036 let _ = paused.update(Event::Follow(false));
2037 let _ = paused.update(Event::Focus(true));
2038 assert_eq!(paused.highlighted(), Some(0));
2039 let _ = paused.update(Event::Focus(false));
2040 assert!(!paused.is_focused());
2041 }
2042
2043 #[test]
2044 fn wrap_and_page_events_update_the_state() {
2045 let mut state = sample();
2046 let _ = state.update(Event::ToggleWrap);
2047 assert!(state.is_wrapped());
2048 let _ = state.update(Event::Page(0));
2049 assert_eq!(state.page, 1);
2050 assert!(matches!(
2051 state.update(Event::FocusSearch),
2052 Some(Output::FocusSearch)
2053 ));
2054 let (sender, _receiver) = mpsc::channel(1);
2055 assert!(matches!(
2056 state.update(Event::Ready(sender)),
2057 Some(Output::Ready(_))
2058 ));
2059 }
2060
2061 #[test]
2062 fn the_default_keymap_maps_to_events() {
2063 let mut state = sample();
2064 let keymap = default_keymap();
2065 let resolve = |state: &State, pressed: keys::Event| state.key_event(&keymap, &pressed);
2066 let named = |name: Named, modifiers| key(Key::Named(name), modifiers);
2067 let character = |c: &str, modifiers| key(Key::Character(c.into()), modifiers);
2068
2069 assert!(matches!(
2070 resolve(&state, character("f", Modifiers::COMMAND)),
2071 Some(Event::FocusSearch)
2072 ));
2073 assert!(matches!(
2074 resolve(&state, named(Named::End, Modifiers::empty())),
2075 Some(Event::Last)
2076 ));
2077 assert!(
2078 resolve(&state, named(Named::Space, Modifiers::empty())).is_none(),
2079 "nothing highlighted to select"
2080 );
2081 assert!(
2082 resolve(&state, character("w", Modifiers::empty())).is_none(),
2083 "no bare letters"
2084 );
2085 assert!(
2086 resolve(&state, named(Named::Enter, Modifiers::empty())).is_none(),
2087 "lines have no activation"
2088 );
2089 assert!(matches!(
2090 resolve(&state, character("l", Modifiers::CTRL)),
2091 Some(Event::Clear)
2092 ));
2093 assert!(
2094 resolve(&state, named(Named::F3, Modifiers::empty())).is_none(),
2095 "no search, so F3 is left for others"
2096 );
2097 assert!(resolve(&state, named(Named::Escape, Modifiers::empty())).is_none());
2098 assert!(resolve(&state, character("c", Modifiers::COMMAND)).is_none());
2099
2100 let _ = state.update(Event::Search("request".into()));
2101 assert!(matches!(
2102 resolve(&state, named(Named::F3, Modifiers::empty())),
2103 Some(Event::NextMatch)
2104 ));
2105 assert!(
2106 resolve(&state, named(Named::Enter, Modifiers::SHIFT)).is_none(),
2107 "Enter steps through matches only in the search field"
2108 );
2109 assert!(matches!(
2110 resolve(&state, named(Named::F3, Modifiers::SHIFT)),
2111 Some(Event::PreviousMatch)
2112 ));
2113 assert!(matches!(
2114 resolve(&state, named(Named::Escape, Modifiers::empty())),
2115 Some(Event::ClearSearch)
2116 ));
2117 let _ = state.update(Event::Press(0, Modifiers::empty()));
2118 assert!(matches!(
2119 resolve(&state, character("c", Modifiers::COMMAND)),
2120 Some(Event::CopySelection)
2121 ));
2122 assert!(matches!(
2123 resolve(&state, named(Named::ArrowDown, Modifiers::SHIFT)),
2124 Some(Event::ExtendNext)
2125 ));
2126 assert!(matches!(
2127 resolve(&state, named(Named::Space, Modifiers::empty())),
2128 Some(Event::ToggleHighlighted)
2129 ));
2130
2131 let empty = State::new();
2132 assert!(resolve(&empty, named(Named::ArrowDown, Modifiers::empty())).is_none());
2133 assert!(resolve(&empty, character("l", Modifiers::CTRL)).is_none());
2134 assert!(
2135 matches!(
2136 resolve(&empty, named(Named::End, Modifiers::empty())),
2137 Some(Event::Last)
2138 ),
2139 "End always resumes following"
2140 );
2141 }
2142
2143 #[test]
2144 fn keymaps_can_be_overridden_and_unbound() {
2145 let keymap = default_keymap()
2146 .bind(Chord::character('p'), Action::TogglePause)
2147 .unbind(&Chord::named(Named::Space));
2148 let state = sample();
2149 let p = key(Key::Character("p".into()), Modifiers::empty());
2150 assert!(matches!(
2151 state.key_event(&keymap, &p),
2152 Some(Event::TogglePause)
2153 ));
2154 let space = key(Key::Named(Named::Space), Modifiers::empty());
2155 assert!(state.key_event(&keymap, &space).is_none());
2156 }
2157
2158 #[test]
2159 fn every_action_is_named_described_and_bound_but_the_toolbar_toggles() {
2160 let toolbar_only = [Action::TogglePause, Action::ToggleWrap];
2161 for action in Action::ALL {
2162 assert!(!action.name().is_empty());
2163 assert!(action.description().ends_with('.'));
2164 let bound = !default_keymap().chords(action).is_empty();
2165 assert_eq!(bound, !toolbar_only.contains(action), "{action:?}");
2166 }
2167 let state = sample();
2168 assert!(matches!(
2169 Action::TogglePause.event(&state),
2170 Some(Event::TogglePause)
2171 ));
2172 assert!(matches!(
2173 Action::ToggleWrap.event(&state),
2174 Some(Event::ToggleWrap)
2175 ));
2176 }
2177
2178 #[test]
2179 fn no_default_chord_is_a_bare_letter_or_alt() {
2180 for (chord, _) in default_keymap().bindings() {
2181 let label = chord.to_string();
2182 assert!(!label.contains("Alt"), "{label}");
2183 let bare = label.chars().count() == 1;
2184 assert!(!bare, "{label}");
2185 }
2186 }
2187
2188 #[test]
2189 fn space_selects_the_highlighted_line_and_keeps_the_others() {
2190 let mut state = sample();
2191 let _ = state.update(Event::Press(1, Modifiers::empty()));
2192 let _ = state.update(Event::Next);
2193 let _ = state.update(Event::ToggleHighlighted);
2194 assert_eq!(state.selected(), [1, 2]);
2195 let _ = state.update(Event::ToggleHighlighted);
2196 assert_eq!(state.selected(), [1]);
2197 assert!(State::new().update(Event::ToggleHighlighted).is_none());
2198 }
2199
2200 #[test]
2201 fn the_level_filters_are_a_multiple_choice_group_with_counts() {
2202 let mut state = sample();
2203 let levels = state.levels();
2204 assert_eq!(levels.mode(), toggle_group::Mode::Multiple);
2205 assert_eq!(levels.selected(), Level::ALL);
2206 let labels: Vec<&str> = levels
2207 .items()
2208 .iter()
2209 .map(|item| item.label.as_str())
2210 .collect();
2211 assert_eq!(
2212 labels,
2213 ["Trace 0", "Debug 1", "Info 2", "Warn 1", "Error 1"]
2214 );
2215
2216 let _ = state.update(Event::Levels(toggle_group::Event::Toggle(Level::Info)));
2217 assert!(!state.is_shown(Level::Info));
2218 assert!(!state.levels().is_selected(Level::Info));
2219 assert_eq!(state.visible().collect::<Vec<_>>(), [1, 2, 3]);
2220
2221 let _ = state.update(Event::Received(vec![entry(Level::Info, "more")]));
2222 assert_eq!(state.levels().items()[2].label, "Info 3", "counts follow");
2223 assert!(!state.levels().is_selected(Level::Info), "and stay off");
2224 let _ = state.update(Event::ToggleLevel(Level::Info));
2225 assert!(state.levels().is_selected(Level::Info));
2226 let _ = state.update(Event::Clear);
2227 assert_eq!(state.levels().items()[2].label, "Info 0");
2228 }
2229
2230 #[test]
2231 fn the_menu_copies_and_filters_one_line() {
2232 let mut state = sample();
2233 let Some(Output::Copy(line)) = state.update(MenuItem::CopyLine.event(2)) else {
2234 panic!("copies the line");
2235 };
2236 assert_eq!(line, "WARN disk almost full");
2237 let Some(Output::Copy(message)) = state.update(MenuItem::CopyMessage.event(3)) else {
2238 panic!("copies the message");
2239 };
2240 assert_eq!(message, "request failed: timeout");
2241 assert!(state.update(Event::CopyLine(99)).is_none());
2242
2243 let _ = state.update(MenuItem::FilterToLevel.event(0));
2244 assert_eq!(state.visible().collect::<Vec<_>>(), [0, 4]);
2245 assert_eq!(state.levels().selected(), [Level::Info]);
2246 let _ = state.update(Event::FilterToLevel(99));
2247 assert_eq!(state.visible_len(), 2, "a missing line changes nothing");
2248
2249 let menu = menu();
2250 assert_eq!(MenuItem::ALL.len(), 3);
2251 for item in MenuItem::ALL {
2252 assert!(menu.menu().item(item).is_some(), "{item:?}");
2253 }
2254 }
2255
2256 #[test]
2257 fn the_match_label_counts_matches() {
2258 let mut state = sample();
2259 assert_eq!(match_label(&state), "");
2260 let _ = state.update(Event::Search("request".into()));
2261 assert_eq!(match_label(&state), "2 of 2");
2262 let _ = state.update(Event::NextMatch);
2263 assert_eq!(match_label(&state), "1 of 2");
2264 let _ = state.update(Event::ToggleLevel(Level::Error));
2265 assert_eq!(
2266 match_label(&state),
2267 "1 found",
2268 "the current match was hidden"
2269 );
2270 let _ = state.update(Event::Search("nothing like this".into()));
2271 assert_eq!(match_label(&state), "None");
2272 }
2273
2274 #[test]
2275 fn level_colours_come_from_the_tokens() {
2276 for theme in [light(), dark()] {
2277 let tokens = Tokens::of(&theme);
2278 assert_eq!(level_colour(&tokens, Level::Debug), tokens.muted_foreground);
2279 let colours: Vec<Color> = Level::ALL
2280 .iter()
2281 .map(|&level| level_colour(&tokens, level))
2282 .collect();
2283 for (index, colour) in colours.iter().enumerate() {
2284 assert!(
2285 !colours[index + 1..].contains(colour),
2286 "levels look different: {colours:?}"
2287 );
2288 }
2289 }
2290 let dark = Tokens::of(&dark());
2291 assert_eq!(level_colour(&dark, Level::Error), dark.destructive);
2292 }
2293
2294 #[test]
2295 fn line_styles_cover_levels_matches_and_statuses() {
2296 for theme in [light(), dark()] {
2297 let tokens = Tokens::of(&theme);
2298 let idle = line_style(&tokens, Level::Info, false, Status::default());
2299 assert_eq!(idle.background, None);
2300 assert_eq!(idle.ring, None);
2301 assert_eq!(idle.text, tokens.foreground);
2302 let error = line_style(&tokens, Level::Error, false, Status::default());
2303 assert!(error.background.is_some(), "error lines are tinted");
2304 let selected = line_style(
2305 &tokens,
2306 Level::Error,
2307 false,
2308 Status {
2309 selected: true,
2310 ..Status::default()
2311 },
2312 );
2313 assert_eq!(selected.background, Some(tokens.accent), "selection wins");
2314 assert_eq!(selected.text, tokens.accent_foreground);
2315 let hovered = line_style(
2316 &tokens,
2317 Level::Warn,
2318 false,
2319 Status {
2320 hovered: true,
2321 ..Status::default()
2322 },
2323 );
2324 assert!(hovered.background.is_some());
2325 let highlighted = line_style(
2326 &tokens,
2327 Level::Info,
2328 false,
2329 Status {
2330 highlighted: true,
2331 ..Status::default()
2332 },
2333 );
2334 assert_eq!(highlighted.ring, Some(tokens.ring));
2335 let disabled = line_style(
2336 &tokens,
2337 Level::Info,
2338 false,
2339 Status {
2340 disabled: true,
2341 highlighted: true,
2342 ..Status::default()
2343 },
2344 );
2345 assert_eq!(disabled.ring, None);
2346 let current = line_style(&tokens, Level::Info, true, Status::default());
2347 assert!(current.mark.a > idle.mark.a, "the current match stands out");
2348 }
2349 }
2350
2351 #[test]
2352 fn the_builder_has_sensible_defaults() {
2353 let state = sample();
2354 let viewer: LogViewer<'_, ()> = log_viewer(&state);
2355 assert!(!viewer.is_enabled());
2356 assert!(viewer.toolbar);
2357 assert_eq!(viewer.height, Length::Fill);
2358 assert!(viewer.menu.is_none());
2359 assert_eq!(viewer.menu_keymap, context_menu::default_keymap());
2360 let menu = menu();
2361 let viewer = viewer
2362 .toolbar(false)
2363 .height(300)
2364 .width(400)
2365 .placeholder("Filter")
2366 .empty("Quiet")
2367 .keymap(Keymap::new())
2368 .context_menu(&menu, |_| ())
2369 .context_menu_keymap(Keymap::new())
2370 .on_event(|_| ());
2371 assert!(viewer.menu.is_some());
2372 assert!(viewer.menu_keymap.is_empty());
2373 assert!(viewer.is_enabled());
2374 assert!(!viewer.toolbar);
2375 assert_eq!(viewer.height, Length::Fixed(300.0));
2376 assert_eq!(viewer.placeholder, "Filter");
2377 assert_eq!(viewer.empty, "Quiet");
2378 assert!(viewer.keymap.is_empty());
2379 assert!(format!("{viewer:?}").contains("LogViewer"));
2380 }
2381
2382 #[test]
2383 fn stream_emits_ready_first_then_batches() {
2384 let mut events = Box::pin(stream());
2385 let Some(Event::Ready(mut sender)) = block_on(events.next()) else {
2386 panic!("first event must be Ready");
2387 };
2388 block_on(async {
2389 for n in 0..3 {
2390 sender
2391 .send(entry(Level::Info, format!("line {n}")))
2392 .await
2393 .expect("channel open");
2394 }
2395 });
2396 let Some(Event::Received(batch)) = block_on(events.next()) else {
2397 panic!("then a batch");
2398 };
2399 assert_eq!(batch.len(), 3);
2400 assert_eq!(batch[2].message, "line 2");
2401 }
2402
2403 #[test]
2404 fn bursts_arrive_in_batches_of_the_batch_size() {
2405 let items = stream::iter(0..BATCH_SIZE * 2 + 1);
2406 let sizes: Vec<usize> = block_on(
2407 items
2408 .ready_chunks(BATCH_SIZE)
2409 .map(|batch| batch.len())
2410 .collect(),
2411 );
2412 assert_eq!(sizes, [BATCH_SIZE, BATCH_SIZE, 1]);
2413 }
2414}