Skip to main content

rich_interact/components/
log.rs

1//! A live log view (#724): records in a list that follows the end as they
2//! arrive, a filter line, a pause, and each record's fields on Enter.
3//!
4//! Records are [`rich_ext::log_stream::Record`]s, given up front
5//! ([`LogView::records`]), pushed ([`LogView::push`]) or sent from another
6//! thread as they are read ([`LogView::source`]). Each is one row: its
7//! time, level, message and fields, cut to the width. A run of identical
8//! records (the time aside) is one row with its count, `×12`. Enter shows
9//! the cursor's record's fields and the lines that followed it (a stack
10//! trace) under its row.
11//!
12//! `/` types a filter, applied as it is typed: words that parse as a
13//! [`Predicate`] (`service=api`, `latency_ms>500`, `msg~timeout`) test a
14//! field, and any other word must appear in the row, in any case. Every
15//! word must match. Enter keeps the filter and Escape clears it.
16//!
17//! The view follows the end until the cursor moves up; End (or Down on the
18//! last row) follows again. Space pauses it, counting what arrives, and
19//! Space again jumps to the end. `q` or Escape closes it.
20//!
21//! ```
22//! use rich_interact::components::LogView;
23//! use rich_interact::{headless, Outcome};
24//! use rich_ext::event::Value;
25//! use rich_ext::log_stream::Record;
26//!
27//! let record = |level: &str, message: &str| {
28//!     Record::new("json")
29//!         .field("level", Value::String(level.into()))
30//!         .field("msg", Value::String(message.into()))
31//! };
32//! let view = LogView::new().records([record("info", "started"), record("error", "failed")]);
33//! let (outcome, frames) = headless::run(view, headless::Script::new().keys("q"), 60, 6);
34//! assert_eq!(outcome.unwrap(), Outcome::Done(()));
35//! assert!(frames.last_frame().contains("ERROR failed"));
36//! ```
37
38use std::collections::{HashSet, VecDeque};
39use std::sync::mpsc::{Receiver, TryRecvError};
40use std::time::Duration;
41
42use rich::cells::cell_len;
43use rich::{Segment, Style};
44use rich_ext::event::{Severity, Value};
45use rich_ext::log_stream::{
46    Assembler, LEVEL_KEYS, MESSAGE_KEYS, Predicate, Record, TIME_KEYS, TimeMode, format_offset,
47};
48
49use crate::component::{Component, Context, Flow, View};
50use crate::components::{Theme, fit, plain, text};
51use crate::event::{Event, KeyCode};
52use crate::keymap::{Keymap, keys};
53use crate::kit::TextBuffer;
54use crate::policy::{LineIo, NotInteractive};
55
56/// What a [`LogView`]'s source sends.
57#[derive(Clone, Debug, PartialEq)]
58pub enum LogEvent {
59    /// A record.
60    Record(Record),
61    /// A line that belongs under the last record (see
62    /// [`Assembled::Continuation`](rich_ext::log_stream::Assembled)).
63    Continuation(String),
64}
65
66/// A record as an event, so rs-rich-ext's
67/// [`ChannelSink`](rich_ext::log_stream::ChannelSink) can feed a
68/// [`LogView::source`] straight from `tracing` or `log`:
69///
70/// ```
71/// use rich_interact::components::{LogEvent, LogView};
72/// use rich_ext::log_stream::{ChannelSink, Record};
73///
74/// let (sink, records) = ChannelSink::<LogEvent>::bounded(1_000);
75/// sink.send(Record::text("started"));
76/// let view = LogView::new().source(records);
77/// // `EventLayer::new(Arc::new(sink))` would send `tracing`'s events.
78/// # drop(view);
79/// ```
80impl From<Record> for LogEvent {
81    fn from(record: Record) -> LogEvent {
82        LogEvent::Record(record)
83    }
84}
85
86/// The keys of a [`LogView`], in context `log-view`.
87pub fn log_view_keymap() -> Keymap {
88    Keymap::new("log-view")
89        .bind("quit", keys("q escape"), "quit")
90        .bind("filter", keys("/"), "filter")
91        .bind("pause", keys("space"), "pause or follow again")
92        .bind("expand", keys("enter"), "show the record's fields")
93        .bind("up", keys("up k"), "previous record")
94        .bind("down", keys("down j"), "next record")
95        .bind("page-up", keys("pageup"), "page up")
96        .bind("page-down", keys("pagedown"), "page down")
97        .bind("top", keys("home g"), "first record")
98        .bind("bottom", keys("end G"), "last record, and follow")
99}
100
101/// The keys of a [`LogView`] while its filter is typed, in context
102/// `log-filter`.
103pub fn log_filter_keymap() -> Keymap {
104    Keymap::new("log-filter")
105        .bind("apply", keys("enter"), "keep the filter")
106        .bind("clear", keys("escape"), "clear the filter")
107        .bind("left", keys("left"), "move left")
108        .bind("right", keys("right"), "move right")
109        .bind("home", keys("home ctrl+a"), "go to the start")
110        .bind("end", keys("end ctrl+e"), "go to the end")
111        .bind("delete-to-start", keys("ctrl+u"), "delete to the start")
112        .bind("delete-word", keys("ctrl+w"), "delete a word")
113        .bind("backspace", keys("backspace"), "delete back")
114        .bind("delete", keys("delete"), "delete forward")
115}
116
117struct Entry {
118    record: Record,
119    /// The row's text, lower-cased, and the continuation lines: what a
120    /// filter word is looked for in.
121    search: String,
122    /// Continuation lines past [`Assembler::CONTINUATION_LIMIT`], counted
123    /// rather than kept.
124    more: usize,
125}
126
127/// One row: a record, and how many identical ones it stands for.
128#[derive(Clone, Copy, Debug)]
129struct Row {
130    entry: usize,
131    count: usize,
132}
133
134/// One word of the filter.
135enum Term {
136    Field(Predicate),
137    Text(String),
138}
139
140/// A live view of log records. See the [module docs](self).
141#[must_use = "a component does nothing until it is run or mounted"]
142pub struct LogView {
143    /// The records kept; `first` is the number of the first, as older ones
144    /// are dropped past the limit.
145    entries: VecDeque<Entry>,
146    first: usize,
147    rows: Vec<Row>,
148    source: Option<Receiver<LogEvent>>,
149    filter: TextBuffer,
150    typing: bool,
151    terms: Vec<Term>,
152    cursor: usize,
153    offset: usize,
154    following: bool,
155    paused: bool,
156    /// Rows that arrived while the view was not following.
157    unseen: usize,
158    expanded: HashSet<usize>,
159    expand_all: bool,
160    collapse: bool,
161    time: TimeMode,
162    origin: Option<i64>,
163    limit: usize,
164    dropped: usize,
165    keymap: Keymap,
166    filter_keymap: Keymap,
167    theme: Theme,
168    tick: Duration,
169}
170
171impl Default for LogView {
172    fn default() -> LogView {
173        LogView::new()
174    }
175}
176
177/// The most records drained from the source on one tick, so a flood
178/// still lets keys through.
179const DRAIN: usize = 20_000;
180
181impl LogView {
182    /// The records a view keeps by default before dropping the oldest.
183    pub const LIMIT: usize = 200_000;
184
185    /// An empty view.
186    pub fn new() -> LogView {
187        LogView {
188            entries: VecDeque::new(),
189            first: 0,
190            rows: Vec::new(),
191            source: None,
192            filter: TextBuffer::new(),
193            typing: false,
194            terms: Vec::new(),
195            cursor: 0,
196            offset: 0,
197            following: true,
198            paused: false,
199            unseen: 0,
200            expanded: HashSet::new(),
201            expand_all: false,
202            collapse: true,
203            time: TimeMode::Absolute,
204            origin: None,
205            limit: LogView::LIMIT,
206            dropped: 0,
207            keymap: log_view_keymap(),
208            filter_keymap: log_filter_keymap(),
209            theme: Theme::default(),
210            tick: Duration::from_millis(100),
211        }
212    }
213
214    /// Start with `records`.
215    pub fn records(mut self, records: impl IntoIterator<Item = Record>) -> LogView {
216        for record in records {
217            self.push(record);
218        }
219        self
220    }
221
222    /// Take records from `source` as they arrive, checking it every 100 ms
223    /// until its sender is dropped.
224    pub fn source(mut self, source: Receiver<LogEvent>) -> LogView {
225        self.source = Some(source);
226        self
227    }
228
229    /// Fold runs of identical records into one row (default on).
230    pub fn collapse(mut self, on: bool) -> LogView {
231        self.collapse = on;
232        self.rebuild();
233        self
234    }
235
236    /// How times show (default as written).
237    pub fn time(mut self, mode: TimeMode) -> LogView {
238        self.time = mode;
239        self
240    }
241
242    /// Show every record's fields, as if Enter had been pressed on each.
243    pub fn expand_all(mut self, on: bool) -> LogView {
244        self.expand_all = on;
245        self
246    }
247
248    /// Start with this filter (see the [module docs](self)).
249    pub fn filter(mut self, filter: impl Into<String>) -> LogView {
250        self.filter.set_text(filter);
251        self.refilter();
252        self
253    }
254
255    /// Keep at most `records`, dropping the oldest (default
256    /// [`LIMIT`](Self::LIMIT)).
257    pub fn limit(mut self, records: usize) -> LogView {
258        self.limit = records.max(1);
259        self
260    }
261
262    pub fn theme(mut self, theme: Theme) -> LogView {
263        self.theme = theme;
264        self
265    }
266
267    /// The records received and kept.
268    pub fn len(&self) -> usize {
269        self.entries.len()
270    }
271
272    pub fn is_empty(&self) -> bool {
273        self.entries.is_empty()
274    }
275
276    /// The rows shown with the filter, a folded run counting once.
277    pub fn rows(&self) -> usize {
278        self.rows.len()
279    }
280
281    /// The records dropped past the limit.
282    pub fn dropped(&self) -> usize {
283        self.dropped
284    }
285
286    /// Whether the view follows the end.
287    pub fn is_following(&self) -> bool {
288        self.following
289    }
290
291    pub fn is_paused(&self) -> bool {
292        self.paused
293    }
294
295    /// The filter typed.
296    pub fn filter_text(&self) -> &str {
297        self.filter.text()
298    }
299
300    /// Add a record.
301    pub fn push(&mut self, record: Record) {
302        if self.origin.is_none() {
303            self.origin = record.instant();
304        }
305        let search = search_text(&record);
306        let entry = self.first + self.entries.len();
307        self.entries.push_back(Entry {
308            record,
309            search,
310            more: 0,
311        });
312        if self.matches(entry) {
313            self.add_row(entry);
314        }
315        if self.entries.len() > self.limit + self.limit / 10 {
316            self.trim();
317        }
318    }
319
320    /// Add a line under the last record. Past
321    /// [`Assembler::CONTINUATION_LIMIT`] lines it is only counted, and shown
322    /// as `… N more lines`.
323    pub fn push_continuation(&mut self, line: String) {
324        let Some(last) = self.entries.back_mut() else {
325            return;
326        };
327        if last.record.continuation.len() >= Assembler::CONTINUATION_LIMIT {
328            last.more += 1;
329            return;
330        }
331        last.search.push('\n');
332        last.search.push_str(&line.to_lowercase());
333        last.record.continuation.push(line);
334    }
335
336    /// Drop the oldest records down to the limit.
337    fn trim(&mut self) {
338        let excess = self.entries.len().saturating_sub(self.limit);
339        self.entries.drain(..excess);
340        self.first += excess;
341        self.dropped += excess;
342        self.expanded.retain(|entry| *entry >= self.first);
343        let cursor_entry = self.rows.get(self.cursor).map(|row| row.entry);
344        self.rebuild();
345        self.cursor = cursor_entry
346            .and_then(|entry| self.rows.iter().position(|row| row.entry >= entry))
347            .unwrap_or(self.rows.len().saturating_sub(1));
348        self.offset = self.offset.min(self.cursor);
349    }
350
351    fn entry(&self, entry: usize) -> &Entry {
352        &self.entries[entry - self.first]
353    }
354
355    fn add_row(&mut self, entry: usize) {
356        if self.collapse
357            && let Some(last) = self.rows.last().map(|row| row.entry)
358        {
359            let repeats = self.entry(last).record.same_as(&self.entry(entry).record);
360            if repeats {
361                if let Some(row) = self.rows.last_mut() {
362                    row.count += 1;
363                }
364                return;
365            }
366        }
367        self.rows.push(Row { entry, count: 1 });
368        if self.following {
369            self.cursor = self.rows.len() - 1;
370        } else {
371            self.unseen += 1;
372        }
373    }
374
375    fn matches(&self, entry: usize) -> bool {
376        let entry = self.entry(entry);
377        self.terms.iter().all(|term| match term {
378            Term::Field(predicate) => predicate.matches(&entry.record),
379            Term::Text(word) => entry.search.contains(word.as_str()),
380        })
381    }
382
383    /// The rows again, for a new filter or collapsing.
384    fn rebuild(&mut self) {
385        let following = self.following;
386        self.rows.clear();
387        let unseen = self.unseen;
388        self.following = true;
389        for entry in self.first..self.first + self.entries.len() {
390            if self.matches(entry) {
391                self.add_row(entry);
392            }
393        }
394        self.following = following;
395        self.unseen = unseen;
396    }
397
398    /// Parse the filter and show what it keeps.
399    fn refilter(&mut self) {
400        let cursor_entry = self.rows.get(self.cursor).map(|row| row.entry);
401        self.terms = words(self.filter.text())
402            .into_iter()
403            .map(|word| {
404                let field = word.contains(['=', '<', '>', '~']) || word.starts_with('!');
405                match Predicate::parse(&word) {
406                    Ok(predicate) if field => Term::Field(predicate),
407                    _ => Term::Text(word.to_lowercase()),
408                }
409            })
410            .collect();
411        self.rebuild();
412        self.cursor = if self.following {
413            self.rows.len().saturating_sub(1)
414        } else {
415            cursor_entry
416                .and_then(|entry| self.rows.iter().position(|row| row.entry >= entry))
417                .unwrap_or(self.rows.len().saturating_sub(1))
418        };
419        self.offset = self.offset.min(self.cursor);
420    }
421
422    /// Take what the source sent; drop it once it is done.
423    fn drain(&mut self) {
424        let Some(source) = &self.source else {
425            return;
426        };
427        let mut events = Vec::new();
428        let mut done = false;
429        while events.len() < DRAIN {
430            match source.try_recv() {
431                Ok(event) => events.push(event),
432                Err(TryRecvError::Empty) => break,
433                Err(TryRecvError::Disconnected) => {
434                    done = true;
435                    break;
436                }
437            }
438        }
439        if done {
440            self.source = None;
441        }
442        for event in events {
443            match event {
444                LogEvent::Record(record) => self.push(record),
445                LogEvent::Continuation(line) => self.push_continuation(line),
446            }
447        }
448    }
449
450    fn is_expanded(&self, entry: usize) -> bool {
451        self.expand_all != self.expanded.contains(&entry)
452    }
453
454    /// The lines a row takes.
455    fn height(&self, row: &Row) -> usize {
456        1 + if self.is_expanded(row.entry) {
457            detail_count(self.entry(row.entry))
458        } else {
459            0
460        }
461    }
462
463    /// The first row shown when the last row is at the bottom.
464    fn bottom_offset(&self, page: usize) -> usize {
465        let mut used = 0;
466        let mut offset = self.rows.len();
467        while offset > 0 {
468            let height = self.height(&self.rows[offset - 1]);
469            if used + height > page && used > 0 {
470                break;
471            }
472            used += height;
473            offset -= 1;
474        }
475        offset
476    }
477
478    /// The first row shown now.
479    fn top(&self, page: usize) -> usize {
480        if self.following {
481            self.bottom_offset(page)
482        } else {
483            self.offset.min(self.rows.len().saturating_sub(1))
484        }
485    }
486
487    /// Scroll just enough to show the cursor's row.
488    fn show_cursor(&mut self, page: usize) {
489        if self.cursor < self.offset {
490            self.offset = self.cursor;
491        }
492        loop {
493            let used: usize = self.rows[self.offset..=self.cursor.min(self.rows.len() - 1)]
494                .iter()
495                .map(|row| self.height(row))
496                .sum();
497            if used <= page || self.offset >= self.cursor {
498                break;
499            }
500            self.offset += 1;
501        }
502    }
503
504    /// Stop following, keeping what is shown where it is.
505    fn hold(&mut self, page: usize) {
506        if self.following {
507            self.offset = self.bottom_offset(page);
508            self.following = false;
509            self.unseen = 0;
510        }
511    }
512
513    fn follow(&mut self) {
514        self.following = true;
515        self.paused = false;
516        self.unseen = 0;
517        self.cursor = self.rows.len().saturating_sub(1);
518    }
519
520    fn page(context: &Context<'_>) -> usize {
521        context.height.saturating_sub(1).max(1)
522    }
523
524    fn move_cursor(&mut self, delta: isize, page: usize) {
525        if self.rows.is_empty() {
526            return;
527        }
528        let last = self.rows.len() - 1;
529        let target = self.cursor.saturating_add_signed(delta).min(last);
530        if delta > 0 && target == last && !self.paused {
531            self.follow();
532            return;
533        }
534        self.hold(page);
535        self.cursor = target;
536        self.show_cursor(page);
537    }
538
539    fn handle_filter(&mut self, event: &Event) -> Flow<()> {
540        if let Event::Paste(text) = event {
541            self.filter.insert(&crate::kit::pasted(text, " "));
542            self.refilter();
543            return Flow::Continue;
544        }
545        let Some(key) = event.key() else {
546            return Flow::Continue;
547        };
548        let action = self.filter_keymap.action(key).map(str::to_string);
549        let changed = match action.as_deref() {
550            Some("apply") => {
551                self.typing = false;
552                false
553            }
554            Some("clear") => {
555                self.typing = false;
556                self.filter.set_text("");
557                true
558            }
559            Some("left") => {
560                self.filter.left();
561                false
562            }
563            Some("right") => {
564                self.filter.right();
565                false
566            }
567            Some("home") => {
568                self.filter.home();
569                false
570            }
571            Some("end") => {
572                self.filter.end();
573                false
574            }
575            Some("delete-to-start") => {
576                self.filter.delete_to_start();
577                true
578            }
579            Some("delete-word") => {
580                self.filter.delete_word();
581                true
582            }
583            Some("backspace") => self.filter.backspace(),
584            Some("delete") => self.filter.delete(),
585            _ => match key.code {
586                KeyCode::Char(c) if !key.modifiers.ctrl && !key.modifiers.alt => {
587                    let mut buffer = [0; 4];
588                    self.filter.insert(c.encode_utf8(&mut buffer));
589                    true
590                }
591                _ => false,
592            },
593        };
594        if changed {
595            self.refilter();
596        }
597        Flow::Continue
598    }
599
600    /// A row's line.
601    fn row_line(&self, row: &Row, selected: bool, width: usize) -> Vec<Segment> {
602        let record = &self.entry(row.entry).record;
603        let theme = &self.theme;
604        let mut line = Vec::new();
605        if selected {
606            line.push(text(format!("{} ", theme.pointer), &theme.pointer_style));
607        } else {
608            line.push(plain(" ".repeat(cell_len(&theme.pointer) + 1)));
609        }
610        if let Some(time) = self.time_of(record) {
611            line.push(text(format!("{time} "), &theme.hint));
612        }
613        match record.severity() {
614            Some(severity) => line.push(text(
615                format!("{:<5} ", severity.as_str().to_uppercase()),
616                &severity_style(severity),
617            )),
618            None => {
619                if let Some(level) = record.level() {
620                    line.push(plain(format!("{} ", one_line(&value_text(level)))));
621                }
622            }
623        }
624        if row.count > 1 {
625            line.push(text(format!("×{} ", row.count), &style("bold")));
626        }
627        let message = record.message().unwrap_or_default();
628        let message_style = if selected {
629            theme.focused.clone()
630        } else {
631            Style::default()
632        };
633        line.push(text(one_line(&message), &message_style));
634        let field = style("cyan");
635        for (key, value) in shown_fields(record) {
636            line.push(plain(" "));
637            line.push(text(format!("{key}="), &field));
638            line.push(plain(one_line(&value_text(value))));
639        }
640        if !record.continuation.is_empty() && !self.is_expanded(row.entry) {
641            let lines = record.continuation.len() + self.entry(row.entry).more;
642            let label = if lines == 1 { "line" } else { "lines" };
643            line.push(text(format!(" (+{lines} {label})"), &theme.hint));
644        }
645        fit(line, width)
646    }
647
648    /// The lines under an expanded row, at most `room` of them: its
649    /// fields, then its continuation lines.
650    fn details(&self, row: &Row, width: usize, room: usize) -> Vec<Vec<Segment>> {
651        let entry = self.entry(row.entry);
652        let record = &entry.record;
653        let theme = &self.theme;
654        let indent = cell_len(&theme.pointer) + 3;
655        let mut lines = Vec::new();
656        let field = style("cyan");
657        for (key, value) in record.fields.iter().take(room) {
658            lines.push(fit(
659                vec![
660                    plain(" ".repeat(indent)),
661                    text(format!("{key}: "), &field),
662                    plain(one_line(&value_text(value))),
663                ],
664                width,
665            ));
666        }
667        let more = (entry.more > 0).then(|| format!("… {} more lines", entry.more));
668        let continuations = record.continuation.iter().map(String::as_str);
669        for continuation in continuations
670            .chain(more.as_deref())
671            .take(room.saturating_sub(lines.len()))
672        {
673            lines.push(fit(
674                vec![
675                    plain(" ".repeat(indent)),
676                    text("│ ", &theme.hint),
677                    plain(continuation.replace('\t', "    ")),
678                ],
679                width,
680            ));
681        }
682        lines
683    }
684
685    fn time_of(&self, record: &Record) -> Option<String> {
686        match self.time {
687            TimeMode::Hidden => None,
688            TimeMode::Absolute => record.time().map(|time| one_line(&time)),
689            TimeMode::Relative => match (record.instant(), self.origin) {
690                (Some(at), Some(origin)) => Some(format_offset(at.saturating_sub(origin))),
691                _ => record.time().map(|time| one_line(&time)),
692            },
693        }
694    }
695
696    fn status(&self, page: usize, width: usize) -> Vec<Segment> {
697        let theme = &self.theme;
698        if self.typing {
699            let (shown, _) = self.filter.window(width.saturating_sub(3), None);
700            let hint = format!("  {} matching · enter keep · esc clear", self.rows.len());
701            return fit(
702                vec![
703                    text("/ ", &theme.prompt),
704                    plain(shown),
705                    text(hint, &theme.hint),
706                ],
707                width,
708            );
709        }
710        let records = self.entries.len() + self.dropped;
711        let mut status = plural(records, "record");
712        if !self.filter.is_empty() {
713            status.push_str(&format!(
714                " · {} matching {:?}",
715                self.rows.len(),
716                self.filter.text()
717            ));
718        }
719        let top = self.top(page);
720        let state = if self.paused {
721            format!("paused · {} new", self.unseen)
722        } else if self.following {
723            if self.source.is_some() {
724                "following".to_string()
725            } else {
726                "at the end".to_string()
727            }
728        } else if self.unseen > 0 {
729            format!("row {} · {} new · end follows", top + 1, self.unseen)
730        } else {
731            format!("row {} · end follows", top + 1)
732        };
733        status.push_str(&format!(" · {state}"));
734        status.push_str(" · / filter · space pause · enter fields · q quit");
735        fit(vec![text(status, &theme.hint)], width)
736    }
737}
738
739impl Component for LogView {
740    type Output = ();
741
742    fn start(&mut self, _context: &Context<'_>) -> Flow<()> {
743        self.drain();
744        Flow::Continue
745    }
746
747    fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<()> {
748        let page = Self::page(context);
749        if let Event::Tick = event {
750            self.drain();
751            return Flow::Continue;
752        }
753        if self.typing {
754            return self.handle_filter(event);
755        }
756        let Some(key) = event.key() else {
757            return Flow::Continue;
758        };
759        let action = self.keymap.action(key).map(str::to_string);
760        match action.as_deref() {
761            Some("quit") => return Flow::Done(()),
762            Some("filter") => self.typing = true,
763            Some("pause") => {
764                if self.paused {
765                    self.follow();
766                } else {
767                    self.hold(page);
768                    self.paused = true;
769                }
770            }
771            Some("expand") => {
772                if let Some(row) = self.rows.get(self.cursor) {
773                    let entry = row.entry;
774                    if !self.expanded.remove(&entry) {
775                        self.expanded.insert(entry);
776                    }
777                    if !self.following {
778                        self.show_cursor(page);
779                    }
780                }
781            }
782            Some("up") => self.move_cursor(-1, page),
783            Some("down") => self.move_cursor(1, page),
784            Some("page-up") => self.move_cursor(-(page as isize), page),
785            Some("page-down") => self.move_cursor(page as isize, page),
786            Some("top") => {
787                self.hold(page);
788                self.cursor = 0;
789                self.offset = 0;
790            }
791            Some("bottom") => self.follow(),
792            _ => return Flow::Ignored,
793        }
794        Flow::Continue
795    }
796
797    fn render(&self, context: &Context<'_>) -> View {
798        let width = context.width;
799        let page = Self::page(context);
800        let mut lines = Vec::new();
801        if self.rows.is_empty() {
802            let message = if self.entries.is_empty() {
803                "no records yet"
804            } else {
805                "no record matches the filter"
806            };
807            lines.push(fit(vec![text(message, &self.theme.hint)], width));
808        }
809        let top = self.top(page);
810        for (index, row) in self.rows.iter().enumerate().skip(top) {
811            if lines.len() >= page {
812                break;
813            }
814            lines.push(self.row_line(row, index == self.cursor, width));
815            if self.is_expanded(row.entry) {
816                let room = page.saturating_sub(lines.len());
817                lines.extend(self.details(row, width, room));
818            }
819        }
820        lines.truncate(page);
821        lines.push(self.status(page, width));
822        let view = View::new(lines);
823        if self.typing {
824            let (_, caret) = self.filter.window(width.saturating_sub(3), None);
825            let row = view.lines.len() - 1;
826            return view.with_cursor(row, 2 + caret);
827        }
828        view
829    }
830
831    fn tick(&self) -> Option<Duration> {
832        self.source.as_ref().map(|_| self.tick)
833    }
834
835    fn keymap(&self) -> Keymap {
836        if self.typing {
837            self.filter_keymap.clone()
838        } else {
839            self.keymap.clone()
840        }
841    }
842
843    fn default_value(&self) -> Option<()> {
844        Some(())
845    }
846
847    /// Without a terminal: every row, as text.
848    fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<()>, NotInteractive> {
849        while self.source.is_some() {
850            self.drain();
851            if self.source.is_some() {
852                std::thread::sleep(self.tick);
853            }
854        }
855        for row in &self.rows {
856            let line: String = self
857                .row_line(row, false, usize::MAX / 2)
858                .iter()
859                .map(|segment| segment.text.as_str())
860                .collect();
861            io.write(line.trim_start());
862            io.write("\n");
863        }
864        Ok(Some(()))
865    }
866}
867
868fn style(definition: &str) -> Style {
869    Style::parse(definition).expect("a built-in style")
870}
871
872fn severity_style(severity: Severity) -> Style {
873    style(match severity {
874        Severity::Fatal | Severity::Error => "bold red",
875        Severity::Warn => "yellow",
876        Severity::Info => "green",
877        Severity::Debug | Severity::Trace => "dim",
878    })
879}
880
881fn plural(count: usize, one: &str) -> String {
882    if count == 1 {
883        format!("1 {one}")
884    } else {
885        format!("{count} {one}s")
886    }
887}
888
889/// `text` on one line: line breaks as `↵`, tabs as spaces.
890fn one_line(text: &str) -> String {
891    if !text.contains(['\n', '\r', '\t']) {
892        return text.to_string();
893    }
894    text.replace("\r\n", "↵")
895        .replace(['\n', '\r'], "↵")
896        .replace('\t', " ")
897}
898
899/// A value as a row shows it: a string as it is, `[a, b]`, `{k: v}`.
900fn value_text(value: &Value) -> String {
901    match value {
902        Value::String(text) => text.clone(),
903        Value::Null => "null".into(),
904        Value::Bool(v) => v.to_string(),
905        Value::Integer(v) => v.to_string(),
906        Value::Unsigned(v) => v.to_string(),
907        Value::Integer128(v) => v.to_string(),
908        Value::Unsigned128(v) => v.to_string(),
909        Value::Float(v) => v.to_string(),
910        Value::List(items) => {
911            let items: Vec<String> = items.iter().map(value_text).collect();
912            format!("[{}]", items.join(", "))
913        }
914        Value::Map(entries) => {
915            let entries: Vec<String> = entries
916                .iter()
917                .map(|(key, value)| format!("{key}: {}", value_text(value)))
918                .collect();
919            format!("{{{}}}", entries.join(", "))
920        }
921    }
922}
923
924/// The fields a row shows after the message: all but the message, level
925/// and time it already shows.
926fn shown_fields(record: &Record) -> impl Iterator<Item = (&str, &Value)> {
927    let message = record.first_of(MESSAGE_KEYS).map(|(key, _)| key);
928    let level = record
929        .severity()
930        .and(record.first_of(LEVEL_KEYS).map(|(key, _)| key));
931    let time = record.first_of(TIME_KEYS).map(|(key, _)| key);
932    record.fields.iter().filter_map(move |(key, value)| {
933        let key = key.as_str();
934        (Some(key) != message && Some(key) != level && Some(key) != time).then_some((key, value))
935    })
936}
937
938/// The lines under an expanded record.
939fn detail_count(entry: &Entry) -> usize {
940    entry.record.fields.len() + entry.record.continuation.len() + usize::from(entry.more > 0)
941}
942
943/// What a filter word is looked for in: the row's text and the
944/// continuation lines, lower-cased.
945fn search_text(record: &Record) -> String {
946    let mut search = String::new();
947    if let Some(time) = record.time() {
948        search.push_str(&time);
949        search.push(' ');
950    }
951    if let Some(level) = record.level() {
952        search.push_str(&value_text(level));
953        search.push(' ');
954    }
955    search.push_str(&record.message().unwrap_or_default());
956    for (key, value) in shown_fields(record) {
957        search.push(' ');
958        search.push_str(key);
959        search.push('=');
960        search.push_str(&value_text(value));
961    }
962    for line in &record.continuation {
963        search.push('\n');
964        search.push_str(line);
965    }
966    search.to_lowercase()
967}
968
969/// The filter's words: split at spaces, except inside quotes.
970fn words(filter: &str) -> Vec<String> {
971    let mut words = Vec::new();
972    let mut word = String::new();
973    let mut quote = None;
974    for c in filter.chars() {
975        match (quote, c) {
976            (None, '"' | '\'') => {
977                quote = Some(c);
978                word.push(c);
979            }
980            (Some(open), c) if c == open => {
981                quote = None;
982                word.push(c);
983            }
984            (None, c) if c.is_whitespace() => {
985                if !word.is_empty() {
986                    words.push(std::mem::take(&mut word));
987                }
988            }
989            (_, c) => word.push(c),
990        }
991    }
992    if !word.is_empty() {
993        words.push(word);
994    }
995    words
996        .into_iter()
997        .map(|word| {
998            // A bare quoted word is the text inside its quotes.
999            let bare = word.len() >= 2
1000                && (word.starts_with('"') && word.ends_with('"')
1001                    || word.starts_with('\'') && word.ends_with('\''));
1002            if bare {
1003                word[1..word.len() - 1].to_string()
1004            } else {
1005                word
1006            }
1007        })
1008        .collect()
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use super::*;
1014    use crate::Outcome;
1015    use crate::headless::{self, Script};
1016    use std::sync::mpsc;
1017
1018    fn record(level: &str, message: &str, second: u32) -> Record {
1019        Record::new("json")
1020            .field(
1021                "time",
1022                Value::String(format!("2026-10-18T10:00:{second:02}Z")),
1023            )
1024            .field("level", Value::String(level.into()))
1025            .field("msg", Value::String(message.into()))
1026    }
1027
1028    fn run(view: LogView, script: Script, rows: u16) -> headless::Record {
1029        let (outcome, record) = headless::run(view, script, 80, rows);
1030        assert_eq!(outcome.unwrap(), Outcome::Done(()));
1031        record
1032    }
1033
1034    #[test]
1035    fn rows_show_time_level_message_and_fields() {
1036        let view = LogView::new().records([record("warn", "slow", 0)
1037            .field("service", Value::String("api".into()))
1038            .field("latency_ms", Value::Integer(812))]);
1039        let frames = run(view, Script::new().keys("q"), 5);
1040        let frame = frames.last_frame();
1041        assert!(
1042            frame.contains("❯ 2026-10-18T10:00:00Z WARN  slow service=api latency_ms=812"),
1043            "{frame}"
1044        );
1045        assert!(frame.contains("1 record · at the end"), "{frame}");
1046    }
1047
1048    #[test]
1049    fn repeats_fold_into_one_row_with_a_count() {
1050        let view = LogView::new().records([
1051            record("error", "refused", 0),
1052            record("error", "refused", 1),
1053            record("error", "refused", 2),
1054            record("info", "ok", 3),
1055        ]);
1056        assert_eq!(view.rows(), 2);
1057        let frames = run(view, Script::new().keys("q"), 6);
1058        assert!(
1059            frames.last_frame().contains("ERROR ×3 refused"),
1060            "{}",
1061            frames.last_frame()
1062        );
1063        let flat = LogView::new()
1064            .collapse(false)
1065            .records([record("error", "refused", 0), record("error", "refused", 1)]);
1066        assert_eq!(flat.rows(), 2);
1067    }
1068
1069    #[test]
1070    fn the_filter_line_takes_words_and_predicates() {
1071        let records = [
1072            record("info", "GET /a", 0).field("status", Value::Integer(200)),
1073            record("warn", "GET /b", 1).field("status", Value::Integer(404)),
1074            record("error", "POST /c", 2).field("status", Value::Integer(503)),
1075        ];
1076        let view = LogView::new().records(records.clone());
1077        let frames = run(
1078            view,
1079            Script::new()
1080                .keys("/")
1081                .text("status>=400 get")
1082                .keys("enter q"),
1083            8,
1084        );
1085        let frame = frames.last_frame();
1086        assert!(frame.contains("GET /b"), "{frame}");
1087        assert!(!frame.contains("POST /c"), "{frame}");
1088        assert!(!frame.contains("GET /a"), "{frame}");
1089        assert!(frame.contains("1 matching"), "{frame}");
1090        // Escape clears it.
1091        let view = LogView::new().records(records);
1092        let frames = run(
1093            view,
1094            Script::new().keys("/").text("post").keys("escape q"),
1095            8,
1096        );
1097        let typed = frames
1098            .frames
1099            .iter()
1100            .find(|frame| frame.contains("/ post"))
1101            .expect("the filter line shows what is typed");
1102        assert!(!typed.contains("GET /a"), "{typed}");
1103        assert!(frames.last_frame().contains("GET /a"));
1104        assert!(frames.last_frame().contains("3 records"));
1105    }
1106
1107    #[test]
1108    fn enter_shows_the_fields_and_continuation_lines() {
1109        let mut failed = record("error", "failed", 0).field("trace_id", Value::String("t1".into()));
1110        failed
1111            .continuation
1112            .push("Traceback (most recent call last):".into());
1113        let view = LogView::new().records([failed]);
1114        let frames = run(view, Script::new().keys("enter q"), 10);
1115        let frame = frames.last_frame();
1116        assert!(frame.contains("trace_id: t1"), "{frame}");
1117        assert!(
1118            frame.contains("│ Traceback (most recent call last):"),
1119            "{frame}"
1120        );
1121        let first = &frames.frames[0];
1122        assert!(first.contains("(+1 line)"), "{first}");
1123    }
1124
1125    #[test]
1126    fn continuation_lines_are_capped_and_counted() {
1127        let mut view = LogView::new().records([record("error", "failed", 0)]);
1128        let extra = 5;
1129        for n in 0..Assembler::CONTINUATION_LIMIT + extra {
1130            view.push_continuation(format!("line {n}"));
1131        }
1132        let entry = view.entries.back().unwrap();
1133        assert_eq!(
1134            entry.record.continuation.len(),
1135            Assembler::CONTINUATION_LIMIT
1136        );
1137        assert_eq!(entry.more, extra);
1138        let row = view.rows[0];
1139        let details = view.details(&row, 80, usize::MAX);
1140        let last: String = details
1141            .last()
1142            .unwrap()
1143            .iter()
1144            .map(|s| s.text.as_str())
1145            .collect();
1146        assert!(last.contains("… 5 more lines"), "{last}");
1147        // Only what fits the page is built.
1148        assert_eq!(view.details(&row, 80, 4).len(), 4);
1149        let frames = run(view, Script::new().keys("enter q"), 10);
1150        let frame = frames.last_frame();
1151        assert!(frame.contains("line 0"), "{frame}");
1152        let first = &frames.frames[0];
1153        assert!(first.contains("(+1005 lines)"), "{first}");
1154    }
1155
1156    #[test]
1157    fn relative_time_before_the_origin_does_not_overflow() {
1158        let mut view = LogView::new();
1159        // The first record's time (the origin) is i64::MAX microseconds,
1160        // the second's i64::MIN.
1161        for micros in [i64::MAX, i64::MIN] {
1162            let time = Value::Integer128(i128::from(micros) * 1_000);
1163            view.push(Record::new("json").field("time", time));
1164        }
1165        let record = view.entries.back().unwrap().record.clone();
1166        assert_eq!(record.instant(), Some(i64::MIN));
1167        view.time = TimeMode::Relative;
1168        assert!(view.time_of(&record).is_some());
1169    }
1170
1171    #[test]
1172    fn a_channel_sink_feeds_the_view_with_events() {
1173        use rich_ext::adapters::EventSink;
1174        use rich_ext::event::{EventContext, Message, Severity, StructuredEvent};
1175        use rich_ext::log_stream::ChannelSink;
1176
1177        let (sink, receiver) = ChannelSink::<LogEvent>::bounded(8);
1178        let event =
1179            StructuredEvent::new(Message::Literal("disk full".into())).context(EventContext {
1180                severity: Some(Severity::Error),
1181                target: Some("store".into()),
1182                ..Default::default()
1183            });
1184        sink.emit(event).unwrap();
1185        let view = LogView::new().source(receiver);
1186        let frames = run(
1187            view,
1188            Script::new().wait(Duration::from_millis(150)).keys("q"),
1189            4,
1190        );
1191        drop(sink);
1192        let frame = frames.last_frame();
1193        assert!(frame.contains("ERROR disk full"), "{frame}");
1194        assert!(frame.contains("target=store"), "{frame}");
1195    }
1196
1197    #[test]
1198    fn it_follows_a_source_and_pauses_on_space() {
1199        let (sender, receiver) = mpsc::channel();
1200        for second in 0..3 {
1201            sender
1202                .send(LogEvent::Record(record(
1203                    "info",
1204                    &format!("n{second}"),
1205                    second,
1206                )))
1207                .unwrap();
1208        }
1209        let view = LogView::new().source(receiver);
1210        // The script cannot send, so the records are queued up front; the
1211        // view takes them when it starts, and the sender stays open, so it
1212        // goes on following.
1213        let script = Script::new()
1214            .wait(Duration::from_millis(150))
1215            .keys("space")
1216            .wait(Duration::from_millis(150))
1217            .keys("space q");
1218        let frames = run(view, script, 3);
1219        drop(sender);
1220        let frame = frames.last_frame();
1221        assert!(
1222            frame.contains("n2") && frame.contains("following"),
1223            "{frame}"
1224        );
1225        assert!(
1226            frames.frames.iter().any(|f| f.contains("paused · 0 new")),
1227            "{:#?}",
1228            frames.frames
1229        );
1230    }
1231
1232    #[test]
1233    fn a_live_source_keeps_the_end_in_view_until_the_cursor_moves_up() {
1234        let mut view = LogView::new();
1235        for second in 0..20 {
1236            view.push(record("info", &format!("n{second}"), second));
1237        }
1238        assert!(view.is_following());
1239        let frames = run(view, Script::new().keys("up up q"), 6);
1240        let followed = &frames.frames[0];
1241        assert!(followed.contains("n19"), "{followed}");
1242        let held = frames.last_frame();
1243        assert!(held.contains("❯") && held.contains("n17"), "{held}");
1244        assert!(held.contains("end follows"), "{held}");
1245        // Pushed while held: counted as new, not scrolled to.
1246        let mut view = LogView::new();
1247        for second in 0..5 {
1248            view.push(record("info", &format!("n{second}"), second));
1249        }
1250        view.hold(3);
1251        view.push(record("info", "later", 9));
1252        assert_eq!(view.unseen, 1);
1253        view.follow();
1254        assert_eq!(view.cursor, view.rows() - 1);
1255    }
1256
1257    #[test]
1258    fn relative_times_count_from_the_first_record() {
1259        let view = LogView::new()
1260            .time(TimeMode::Relative)
1261            .records([record("info", "a", 0), record("info", "b", 5)]);
1262        let frames = run(view, Script::new().keys("q"), 5);
1263        assert!(
1264            frames.last_frame().contains("+5.000s INFO  b"),
1265            "{}",
1266            frames.last_frame()
1267        );
1268    }
1269
1270    #[test]
1271    fn the_oldest_records_drop_past_the_limit() {
1272        let mut view = LogView::new().limit(10).collapse(false);
1273        for n in 0..30 {
1274            view.push(record("info", &format!("n{n}"), n % 60));
1275        }
1276        assert!(view.len() <= 11, "{}", view.len());
1277        assert_eq!(view.len() + view.dropped(), 30);
1278        assert_eq!(view.rows(), view.len());
1279    }
1280
1281    #[test]
1282    fn filter_words_keep_quotes_together() {
1283        assert_eq!(
1284            words(r#"msg~"timed out" api 'two words'"#),
1285            [r#"msg~"timed out""#, "api", "two words"]
1286        );
1287        assert!(words("   ").is_empty());
1288    }
1289
1290    #[test]
1291    fn without_a_terminal_it_writes_the_rows() {
1292        struct Lines(String);
1293        impl LineIo for Lines {
1294            fn write(&mut self, text: &str) {
1295                self.0.push_str(text);
1296            }
1297            fn read_line(&mut self) -> Option<String> {
1298                None
1299            }
1300        }
1301        let mut view = LogView::new().records([record("info", "a", 0), record("warn", "b", 1)]);
1302        let mut io = Lines(String::new());
1303        assert_eq!(view.prompt(&mut io), Ok(Some(())));
1304        assert_eq!(
1305            io.0,
1306            "2026-10-18T10:00:00Z INFO  a\n2026-10-18T10:00:01Z WARN  b\n"
1307        );
1308    }
1309}