Skip to main content

iced_cube/data/
log_viewer.rs

1//! A live, searchable log: entries with a level, an optional timestamp and
2//! target, and a message, streamed in from threads or tasks.
3//!
4//! [`State`] keeps the newest entries up to a limit, dropping the oldest,
5//! so memory stays bounded however long the log runs. It filters by
6//! level, searches messages with every match marked, follows the newest
7//! line, pauses and resumes the stream, and selects lines to copy. Only
8//! the lines in view are built, as in a
9//! [virtual list](mod@crate::data::virtual_list).
10//!
11//! [`subscription`] creates a bounded channel and emits [`Event::Ready`]
12//! with its [`Sender`] once. Producers send [`Entry`] values through clones
13//! of the sender, and entries arrive in batches as [`Event::Received`].
14//!
15//! Copying returns [`Output::Copy`] with the text, for the app to write to
16//! the clipboard, and the search shortcut returns [`Output::FocusSearch`],
17//! for the app to answer with [`focus_search`]. Lines can carry a context
18//! menu, such as the one from [`menu()`], through
19//! [`LogViewer::context_menu`].
20//!
21//! ```no_run
22//! use iced::{Element, Subscription, Task};
23//! use iced_cube::data::log_viewer::{self, Event, Level, Output, State, entry, log_viewer};
24//!
25//! #[derive(Debug, Clone)]
26//! enum Message {
27//!     Logs(Event),
28//! }
29//!
30//! struct App {
31//!     logs: State,
32//!     sender: Option<log_viewer::Sender>,
33//! }
34//!
35//! impl App {
36//!     fn update(&mut self, message: Message) -> Task<Message> {
37//!         let Message::Logs(event) = message;
38//!         match self.logs.update(event) {
39//!             Some(Output::Ready(mut sender)) => {
40//!                 let _ = sender.try_send(entry(Level::Info, "Listening on :8080"));
41//!                 self.sender = Some(sender);
42//!                 Task::none()
43//!             }
44//!             Some(Output::Copy(text)) => iced::clipboard::write(text),
45//!             Some(Output::FocusSearch) => log_viewer::focus_search(&self.logs),
46//!             None => Task::none(),
47//!         }
48//!     }
49//!
50//!     fn view(&self) -> Element<'_, Message> {
51//!         log_viewer(&self.logs).height(400).on_event(Message::Logs).into()
52//!     }
53//!
54//!     fn subscription(&self) -> Subscription<Message> {
55//!         log_viewer::subscription().map(Message::Logs)
56//!     }
57//! }
58//! ```
59
60use 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
90/// Height of a line that does not wrap.
91pub const ROW_HEIGHT: f32 = 24.0;
92/// Height of a line that does not wrap below the [`BREAKPOINT`], where the
93/// message sits under the timestamp, level and target.
94pub const STACKED_ROW_HEIGHT: f32 = 42.0;
95/// The most entries kept unless the state is given another limit.
96pub const DEFAULT_LIMIT: usize = 10_000;
97/// How many entries a producer can send before it has to wait.
98pub const CHANNEL_CAPACITY: usize = 1024;
99/// The most entries delivered in one [`Event::Received`].
100pub const BATCH_SIZE: usize = 512;
101/// The default widget id of the search field.
102pub const SEARCH_ID: widget::Id = widget::Id::new("iced-cube-log-viewer-search");
103/// Below this width the toolbar stacks its parts.
104pub 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/// How severe an entry is.
112#[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    /// The short upper-case tag shown on each line, such as `WARN`.
132    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    /// The name shown on the level filter, such as `Warn`.
143    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/// One log line.
165#[derive(Debug, Clone, PartialEq, Eq, Default)]
166pub struct Entry {
167    pub level: Level,
168    /// Shown as given, so the app picks the format.
169    pub timestamp: Option<String>,
170    /// Where the entry came from, such as a module path.
171    pub target: Option<String>,
172    pub message: String,
173}
174
175/// Creates an entry. Add a timestamp and a target with the builder methods.
176pub 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    /// The entry as one line of plain text, as it is copied.
197    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
213/// Names a line for as long as it is kept: its position counted from the
214/// first entry the log ever received.
215pub type LineId = u64;
216
217/// The sending half of the channel entries arrive on. Clone it for each
218/// producer.
219pub type Sender = mpsc::Sender<Entry>;
220
221/// Everything that changes a log.
222#[derive(Debug, Clone)]
223pub enum Event {
224    /// The channel is open. Keep the sender and hand clones to producers.
225    Ready(Sender),
226    /// Entries from the channel, oldest first.
227    Received(Vec<Entry>),
228    /// The pointer pressed a line with these modifiers held.
229    Press(LineId, Modifiers),
230    /// The search text changed.
231    Search(String),
232    NextMatch,
233    PreviousMatch,
234    ClearSearch,
235    /// Asks the app to focus the search field.
236    FocusSearch,
237    /// Shows or hides a level.
238    ToggleLevel(Level),
239    /// The level filters were pressed.
240    Levels(toggle_group::Event<Level>),
241    /// Shows only the level of this line.
242    FilterToLevel(LineId),
243    ToggleWrap,
244    /// Pauses the stream, holding new entries back, or resumes it.
245    TogglePause,
246    /// Removes every entry.
247    Clear,
248    /// Copies the selected lines, or the highlighted one.
249    CopySelection,
250    /// Copies one line as plain text, as [`Entry::line`] formats it.
251    CopyLine(LineId),
252    /// Copies only the message of one line.
253    CopyMessage(LineId),
254    /// Selects or deselects the highlighted line, keeping the others.
255    ToggleHighlighted,
256    /// Starts or stops following the newest line. The log sends `false`
257    /// when the user scrolls away from the end, and `true` when they
258    /// scroll back.
259    Follow(bool),
260    Next,
261    Previous,
262    First,
263    /// Highlights the newest line and follows it.
264    Last,
265    NextPage,
266    PreviousPage,
267    ExtendNext,
268    ExtendPrevious,
269    SelectAll,
270    /// How many lines fit in the view. The log sends this itself.
271    Page(usize),
272    /// The log gained or lost focus. The log sends this itself.
273    Focus(bool),
274}
275
276/// What the app may need to act on after an [`Event`].
277#[derive(Debug, Clone)]
278pub enum Output {
279    Ready(Sender),
280    /// Write this text to the clipboard.
281    Copy(String),
282    /// Focus the search field, with [`focus_search`].
283    FocusSearch,
284}
285
286/// The kept entries, the filters, the search, the selection and whether the
287/// log follows its newest line.
288#[derive(Debug, Clone)]
289pub struct State {
290    entries: VecDeque<Entry>,
291    /// The id of the oldest kept entry.
292    first: LineId,
293    limit: usize,
294    counts: [usize; 5],
295    shown: [bool; 5],
296    /// The level filters as drawn, labelled with their counts.
297    levels: toggle_group::State<Level>,
298    /// Ids of the entries that pass the level filter, oldest first.
299    visible: VecDeque<LineId>,
300    /// Visible lines dropped from the front so far, so the lines in view
301    /// stay put as old ones go.
302    visible_dropped: u64,
303    query: String,
304    /// Visible lines whose message matches the search, oldest first.
305    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    /// Entries held back while paused.
314    pending: VecDeque<Entry>,
315    /// Lines that arrived while not following.
316    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    /// An empty log that keeps [`DEFAULT_LIMIT`] entries, shows every
357    /// level and follows the newest line.
358    pub fn new() -> Self {
359        Self::default()
360    }
361
362    /// Keeps at most `limit` entries, dropping the oldest. A limit of zero
363    /// is treated as one.
364    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    /// Shows only the given levels.
372    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    /// Starts with long lines wrapped.
382    pub fn with_wrap(mut self, wrap: bool) -> Self {
383        self.wrap = wrap;
384        self
385    }
386
387    /// Replaces [`SEARCH_ID`], so several logs on one screen each focus
388    /// their own field.
389    pub fn with_search_id(mut self, id: impl Into<widget::Id>) -> Self {
390        self.search_id = id.into();
391        self
392    }
393
394    /// Appends entries straight away, as the channel does.
395    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    /// Every kept entry, oldest first, whether shown or not.
410    pub fn entries(&self) -> impl Iterator<Item = &Entry> {
411        self.entries.iter()
412    }
413
414    /// How many entries are kept.
415    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    /// The entry with `id`, if it is still kept.
428    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    /// The ids of the lines shown, oldest first.
434    pub fn visible(&self) -> impl Iterator<Item = LineId> + '_ {
435        self.visible.iter().copied()
436    }
437
438    /// How many lines are shown.
439    pub fn visible_len(&self) -> usize {
440        self.visible.len()
441    }
442
443    /// How many kept entries have this level.
444    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    /// The level filters as a multiple-choice toggle group, each labelled
453    /// with its level's count.
454    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    /// The shown lines whose message matches the search, oldest first.
463    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    /// The match the search last moved to, and its position counted from
472    /// one, if it is still shown.
473    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    /// The selected lines that are shown, oldest first.
484    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    /// The line the keyboard acts on.
493    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    /// Entries held back while paused.
510    pub fn pending(&self) -> usize {
511        self.pending.len()
512    }
513
514    /// Lines that arrived since the log stopped following.
515    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    /// Whether there is anything to copy: a shown selected line or a
528    /// highlight.
529    pub fn can_copy(&self) -> bool {
530        self.highlighted().is_some() || self.selected.iter().any(|&id| self.position(id).is_some())
531    }
532
533    /// The selected lines as text, one per line, or the highlighted line
534    /// when nothing is selected.
535    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    /// Applies an event and returns what the app may need to act on.
551    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                // Focus lands on the newest line while following, or the
619                // first selected. It does not scroll.
620                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    /// Turns a key press into an event through `keymap`.
634    pub fn key_event(&self, keymap: &Keymap<Action>, key: &keys::Event) -> Option<Event> {
635        keymap.resolve_event(key)?.event(self)
636    }
637
638    /// Where a line sits among the shown lines.
639    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    /// Drops the oldest entries over the limit, and forgets them.
663    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    /// Rebuilds the shown lines after the level filter changed, dropping
697    /// hidden lines from the selection.
698    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    /// Rebuilds the matches after the search or the filter changed.
717    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    /// Relabels the level filters with the current counts.
747    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    /// The row to keep in view and its request count, for the view.
774    fn reveal_target(&self) -> Option<(usize, u64)> {
775        let (id, request) = self.reveal?;
776        Some((self.position(id)?, request))
777    }
778
779    /// Highlights a line, scrolls it into view and follows only when it is
780    /// the newest.
781    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    /// Moves to the next or previous match after the current one, or the
798    /// highlighted line, wrapping around at the ends.
799    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    /// Moves to the match nearest the highlight: the first at or after it,
827    /// or else the last before it. Without a highlight it takes the newest
828    /// match while following, as the newest lines are in view, and the
829    /// oldest otherwise.
830    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    /// The shown lines from `a` to `b`, in either order.
875    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
894/// The level filters for these counts, with the shown levels on.
895fn 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/// What a log keyboard shortcut does.
905#[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    /// The [`Event`] this action sends to `state`, or `None` when it does
929    /// nothing, so the key is left for others.
930    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
1028/// The default log shortcuts:
1029///
1030/// | Keys | Action |
1031/// | --- | --- |
1032/// | `ArrowDown` | [`Action::Next`] |
1033/// | `ArrowUp` | [`Action::Previous`] |
1034/// | `Home` | [`Action::First`] |
1035/// | `End` | [`Action::Last`] |
1036/// | `PageDown` | [`Action::NextPage`] |
1037/// | `PageUp` | [`Action::PreviousPage`] |
1038/// | `Shift+ArrowDown` | [`Action::ExtendNext`] |
1039/// | `Shift+ArrowUp` | [`Action::ExtendPrevious`] |
1040/// | `Space` | [`Action::Select`] |
1041/// | `Mod+A` (Ctrl, or Cmd on macOS) | [`Action::SelectAll`] |
1042/// | `Mod+C` | [`Action::CopySelection`] |
1043/// | `Mod+F` | [`Action::FocusSearch`] |
1044/// | `F3` | [`Action::NextMatch`] |
1045/// | `Shift+F3` | [`Action::PreviousMatch`] |
1046/// | `Escape` | [`Action::ClearSearch`] |
1047/// | `Ctrl+L` | [`Action::Clear`] |
1048///
1049/// The log resolves these itself, only while it has focus, which it takes
1050/// when any part of it is pressed. Keys the search field uses, such as
1051/// typing, Home and End, stay with the field. While the field has focus,
1052/// Enter and F3 step to the next match and Shift+Enter and Shift+F3 to the
1053/// previous one, Mod+F selects its text, and Escape clears the text, then
1054/// a second Escape leaves the field. [`Action::TogglePause`]
1055/// and [`Action::ToggleWrap`] are on the toolbar and have no chord until
1056/// one is bound. Pass a changed keymap with [`LogViewer::keymap`].
1057pub 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
1077/// Owns the channel entries arrive on. Emits [`Event::Ready`] first, then
1078/// batches of up to [`BATCH_SIZE`] entries.
1079pub fn subscription() -> Subscription<Event> {
1080    Subscription::run(stream)
1081}
1082
1083/// The stream behind [`subscription`], for driving it without a runtime.
1084pub 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
1090/// Focuses the search field of `state`'s log.
1091pub fn focus_search<T>(state: &State) -> Task<T> {
1092    widget::operation::focus(state.search_id.clone())
1093}
1094
1095/// The text beside the search field: "3 of 12" for the current match of
1096/// all matches, "12 found" before one is current, "None" when nothing
1097/// matches, and empty without a search.
1098pub 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/// An entry of the context menu from [`menu`].
1104#[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    /// The event the entry sends for the line the menu opened on.
1119    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
1128/// A context menu to pass to [`LogViewer::context_menu`]: copy the line or
1129/// only its message, or show only lines of its level. Turn the chosen entry
1130/// into an event with [`MenuItem::event`].
1131pub 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
1140/// The colour of a level's tag.
1141pub fn level_colour(tokens: &Tokens, level: Level) -> Color {
1142    // Bright status colours are darkened on light backgrounds so the small
1143    // tag stays readable.
1144    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
1160/// How a line is shown.
1161pub type Status = virtual_list::Status;
1162
1163/// The colours of a line.
1164#[derive(Debug, Clone, Copy, PartialEq)]
1165pub struct LineStyle {
1166    pub background: Option<Color>,
1167    /// The ring around the line carrying the keyboard highlight.
1168    pub ring: Option<Color>,
1169    pub text: Color,
1170    /// Behind the parts of the message that match the search.
1171    pub mark: Color,
1172}
1173
1174/// Resolves a line's colours. Lines take the list rows' colours, with the
1175/// accent's text on a selected line, and an error line is tinted when
1176/// nothing else colours it. The current match is marked more strongly than
1177/// the others.
1178pub 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
1199/// A log viewer builder. Convert it into an [`Element`] to render.
1200pub 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
1213/// Renders `state`: a toolbar with the search, level filters and actions,
1214/// above the lines. Without [`on_event`](LogViewer::on_event) everything
1215/// renders disabled.
1216pub 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    /// The height of the whole log, toolbar included. Fills by default.
1252    pub fn height(mut self, height: impl Into<Length>) -> Self {
1253        self.height = height.into();
1254        self
1255    }
1256
1257    /// Shows or hides the toolbar. Its actions stay on the keyboard.
1258    pub fn toolbar(mut self, toolbar: bool) -> Self {
1259        self.toolbar = toolbar;
1260        self
1261    }
1262
1263    /// The search field's placeholder.
1264    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
1265        self.placeholder = placeholder.into();
1266        self
1267    }
1268
1269    /// What shows while no lines are shown.
1270    pub fn empty(mut self, message: impl Into<String>) -> Self {
1271        self.empty = message.into();
1272        self
1273    }
1274
1275    /// Replaces the [`default_keymap`] the focused log resolves keys with.
1276    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    /// Gives every line the context menu of `menu`, keyed by line, such as
1287    /// the one from [`menu()`]. It opens on a right-click or a long press,
1288    /// and on the highlighted line with its opening chord while the log has
1289    /// focus.
1290    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    /// Replaces the context menu's default keymap.
1306    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
1366/// Everything the log's parts are built from.
1367struct 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        // Narrow, the search field, the filters and the actions each take a
1472        // row, the filters wrap, and the match stepper joins the actions so
1473        // the field keeps the full width.
1474        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        // Below the lines, inside the frame, so the newest line stays in
1559        // view above it. The lines stay the column's first child whether
1560        // the pill shows or not, so they keep their scroll position.
1561        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    /// A button under the lines: resume while paused, or jump to the newest
1571    /// line while not following.
1572    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
1602/// One line: timestamp, level, target and the message with its matches
1603/// marked.
1604/// Below the breakpoint the message sits under the other parts, so it keeps
1605/// the full width.
1606fn 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}