Skip to main content

qframe/widgets/
log_view.rs

1//! Streaming log lines: follow the tail, filter by level, search with highlights, copy lines.
2
3use std::collections::VecDeque;
4
5use crate::event::{Event, MouseButton, MouseKind};
6use crate::geometry::{Rect, Size, clamp_u16};
7use crate::keymap::{Key, Modifiers};
8use crate::text;
9use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
10
11use super::edit_menu::{self, TextMenu};
12use super::log_buffer::{LogBuffer, LogLevel, LogLine};
13use super::row::LEAD;
14use super::rows::{self, RowScroll};
15use crate::runtime::CopyKind;
16
17/// Width of the level column.
18const LEVEL_WIDTH: u16 = 5;
19
20/// Builds a message from a number of copied lines.
21type CopyMessage<Msg> = Box<dyn Fn(usize) -> Msg>;
22
23/// A view of a [`LogBuffer`] that follows new lines as they arrive.
24///
25/// While the view is at the bottom it follows the tail; scrolling up (wheel, keys, scrollbar)
26/// stops following and a faint note at the bottom counts the lines below; reaching the bottom
27/// again, End or a click on the note resumes. Each line shows its faint timestamp, its level as
28/// a word in the level's status colour and the message. [`LogView::min_level`] hides less
29/// important lines and [`LogView::search`] keeps only lines containing the query, with the
30/// matches highlighted; a lowercase query ignores case. Only the lines on screen are drawn and
31/// filtering is incremental, so large buffers stay fast.
32///
33/// Keys while focused: ↑/↓ or k/j move a line cursor (shift extends a selection), PgUp/PgDn
34/// page, Home jumps to the oldest line, End follows the tail again, Esc clears the cursor, `c`
35/// copies the selected lines or the cursor line. A click places the cursor, shift-click or
36/// dragging extends the selection.
37///
38/// A right click on the selected lines (elsewhere it first places the cursor on that line), or
39/// Shift+F10 and the menu key, opens a menu with Copy and Raw copy. Copy writes each line as
40/// `time level message` with single spaces, like `c`; Raw copy keeps the columns lined up as
41/// they are shown.
42///
43/// Style keys: `list-item` (`hover`, `selected`, `focus`) for the rows like
44/// [`List`](super::List); `log-time`; `log-level.<level>` for `trace`, `debug`, `info`, `warn`,
45/// `error`; `log-match` for search matches; `log-more` for the lines-below note; `list-header`
46/// for the empty text; `scrollbar`. Framework strings: `quvyta.log.below`,
47/// `quvyta.log.no-match`.
48pub struct LogView<Msg> {
49    buffer: LogBuffer,
50    min_level: LogLevel,
51    query: String,
52    empty: String,
53    on_copy: Option<CopyMessage<Msg>>,
54}
55
56/// The lines that pass the filters, kept up to date as lines arrive.
57#[derive(Debug, Default)]
58struct Filter {
59    key: Option<(u64, LogLevel, String)>,
60    /// Permanent number of the next line to look at.
61    scanned: u64,
62    /// Permanent numbers of passing lines, oldest first.
63    lines: VecDeque<u64>,
64}
65
66#[derive(Debug, Default)]
67struct LogMemory {
68    filter: Filter,
69    /// Whether the user scrolled away from the tail.
70    detached: bool,
71    cursor: Option<u64>,
72    anchor: Option<u64>,
73    selecting: bool,
74    /// Where the lines-below note was painted in the last frame; a click on it follows the tail.
75    note: Option<Rect>,
76}
77
78impl<Msg: 'static> LogView<Msg> {
79    /// A view of `buffer`; cloning a buffer is cheap.
80    #[must_use]
81    pub fn new(buffer: &LogBuffer) -> Self {
82        Self {
83            buffer: buffer.clone(),
84            min_level: LogLevel::Trace,
85            query: String::new(),
86            empty: String::new(),
87            on_copy: None,
88        }
89    }
90
91    /// Hides lines less important than `level`.
92    #[must_use]
93    pub fn min_level(mut self, level: LogLevel) -> Self {
94        self.min_level = level;
95        self
96    }
97
98    /// Shows only lines containing `query` and highlights it; empty shows everything.
99    #[must_use]
100    pub fn search(mut self, query: impl Into<String>) -> Self {
101        self.query = query.into();
102        self
103    }
104
105    /// Text shown while the buffer is empty.
106    #[must_use]
107    pub fn empty_text(mut self, text: impl Into<String>) -> Self {
108        self.empty = text.into();
109        self
110    }
111
112    /// Message sent after `c` copied lines, with how many.
113    #[must_use]
114    pub fn on_copy(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
115        self.on_copy = Some(Box::new(message));
116        self
117    }
118
119    fn filtering(&self) -> bool {
120        self.min_level > LogLevel::Trace || !self.query.is_empty()
121    }
122
123    fn passes(&self, line: &LogLine) -> bool {
124        line.level() >= self.min_level && (self.query.is_empty() || !matches(line.text(), &self.query).is_empty())
125    }
126
127    /// Brings the filter up to date with the buffer.
128    fn refresh(&self, filter: &mut Filter) {
129        if !self.filtering() {
130            return;
131        }
132        let key = (self.buffer.id(), self.min_level, self.query.clone());
133        let first = self.buffer.first_number();
134        if filter.key.as_ref() != Some(&key) {
135            *filter = Filter { key: Some(key), scanned: first, lines: VecDeque::new() };
136        }
137        while filter.lines.front().is_some_and(|number| *number < first) {
138            filter.lines.pop_front();
139        }
140        let end = first + self.buffer.len() as u64;
141        for number in filter.scanned.max(first)..end {
142            if self.buffer.by_number(number).is_some_and(|line| self.passes(line)) {
143                filter.lines.push_back(number);
144            }
145        }
146        filter.scanned = end;
147    }
148
149    fn total(&self, filter: &Filter) -> usize {
150        if self.filtering() { filter.lines.len() } else { self.buffer.len() }
151    }
152
153    /// The permanent number of the `row`-th visible line.
154    fn number_at(&self, filter: &Filter, row: usize) -> Option<u64> {
155        if self.filtering() {
156            filter.lines.get(row).copied()
157        } else {
158            (row < self.buffer.len()).then(|| self.buffer.first_number() + row as u64)
159        }
160    }
161
162    /// The row of line `number`, or the row of the nearest passing line after it.
163    fn row_of(&self, filter: &Filter, number: u64) -> usize {
164        if self.filtering() {
165            filter.lines.partition_point(|n| *n < number)
166        } else {
167            usize::try_from(number.saturating_sub(self.buffer.first_number())).unwrap_or(usize::MAX)
168        }
169    }
170
171    fn selection(memory: &LogMemory) -> Option<(u64, u64)> {
172        let cursor = memory.cursor?;
173        let anchor = memory.anchor.unwrap_or(cursor);
174        Some((cursor.min(anchor), cursor.max(anchor)))
175    }
176
177    fn copy(&self, cx: &mut EventCx<'_, Msg>, kind: CopyKind) -> bool {
178        let memory = cx.memory::<LogMemory>();
179        let Some((from, to)) = Self::selection(memory) else {
180            return false;
181        };
182        self.refresh(&mut memory.filter);
183        let start = self.row_of(&memory.filter, from);
184        let mut lines = Vec::new();
185        let mut row = start;
186        while let Some(number) = self.number_at(&memory.filter, row).filter(|n| *n <= to) {
187            if let Some(line) = self.buffer.by_number(number) {
188                let (gap, width) = match kind {
189                    CopyKind::Clean => (" ", 0),
190                    CopyKind::Raw => ("  ", usize::from(LEVEL_WIDTH)),
191                };
192                let mut out = String::new();
193                if let Some(time) = line.timestamp() {
194                    out.push_str(time);
195                    out.push_str(gap);
196                }
197                out.push_str(&format!("{:<width$}", line.level().name()));
198                out.push_str(gap);
199                out.push_str(line.text());
200                lines.push(out);
201            }
202            row += 1;
203        }
204        if lines.is_empty() {
205            return false;
206        }
207        let count = lines.len();
208        cx.copy(lines.join("\n"));
209        cx.flash();
210        if let Some(message) = &self.on_copy {
211            cx.emit(message(count));
212        }
213        true
214    }
215
216    /// Moves the cursor `delta` rows (or to a row), keeps it in view and updates following.
217    fn move_cursor(&self, cx: &mut EventCx<'_, Msg>, target: CursorTarget, extend: bool) -> bool {
218        let area = cx.area();
219        let visible = usize::from(area.height);
220        let offset = cx.memory::<RowScroll>().offset;
221        let memory = cx.memory::<LogMemory>();
222        self.refresh(&mut memory.filter);
223        let total = self.total(&memory.filter);
224        if total == 0 {
225            return false;
226        }
227        let current = memory.cursor.map(|number| self.row_of(&memory.filter, number).min(total - 1));
228        let row = match (target, current) {
229            (CursorTarget::By(delta), Some(row)) => row.saturating_add_signed(delta).min(total - 1),
230            // The first move starts from the bottom line on screen.
231            (CursorTarget::By(_), None) => (offset + visible).min(total).saturating_sub(1),
232            (CursorTarget::First, _) => 0,
233        };
234        let number = self.number_at(&memory.filter, row);
235        if !extend || memory.anchor.is_none() {
236            memory.anchor = if extend { memory.cursor.or(number) } else { number };
237        }
238        memory.cursor = number;
239        let scroll = cx.memory::<RowScroll>();
240        if row < scroll.offset {
241            scroll.offset = row;
242        } else if visible > 0 && row >= scroll.offset + visible {
243            scroll.offset = row + 1 - visible;
244        }
245        let at_bottom = scroll.offset + visible >= total;
246        cx.memory::<LogMemory>().detached = !at_bottom;
247        true
248    }
249
250    /// Offers `event` to the Copy and Raw copy menu, which opens on a right press on a line or
251    /// its keys while lines are selected, and takes every event while open. Returns `None` when
252    /// the menu did not use the event.
253    fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> Option<bool> {
254        let open = edit_menu::is_open(cx);
255        if !open && !edit_menu::asks(event) {
256            return None;
257        }
258        if let Event::Mouse(mouse) = event
259            && !open
260        {
261            let area = cx.area();
262            let offset = cx.memory::<RowScroll>().offset;
263            let memory = cx.memory::<LogMemory>();
264            self.refresh(&mut memory.filter);
265            let row = usize::try_from(mouse.y - area.y).ok().map(|row| offset + row);
266            let number = row.and_then(|row| self.number_at(&memory.filter, row))?;
267            // A right press on the selected lines keeps them; elsewhere it selects that line.
268            if !Self::selection(memory).is_some_and(|(from, to)| (from..=to).contains(&number)) {
269                memory.cursor = Some(number);
270                memory.anchor = Some(number);
271                memory.detached = true;
272            }
273        }
274        if !open && Self::selection(cx.memory::<LogMemory>()).is_none() {
275            return None;
276        }
277        let (used, chosen) = TextMenu::copy(cx.env()).event(cx, event);
278        if let Some(kind) = chosen {
279            self.copy(cx, kind);
280        }
281        used.then_some(true)
282    }
283
284    fn follow_tail(cx: &mut EventCx<'_, Msg>) {
285        let memory = cx.memory::<LogMemory>();
286        memory.detached = false;
287        memory.cursor = None;
288        memory.anchor = None;
289    }
290
291    fn paint_line(&self, cx: &mut PaintCx<'_>, rect: Rect, line: &LogLine, selected: bool, focused: bool) {
292        let hovered = cx.pointer().is_some_and(|(x, y)| rect.contains(x, y));
293        let states = rows::row_states(hovered, selected, focused, false);
294        let style = cx.style("list-item", None, &states);
295        let row_style = rows::paint_row(cx, rect, &style);
296        let mut x = rect.x + i32::from(LEAD + rows::slide(cx, &states));
297        // One spare cell keeps the sliding text inside the row.
298        let right = rect.right() - 1;
299        let room = |x: i32| clamp_u16(right - x);
300        if let Some(time) = line.timestamp() {
301            let time_style = cx.style("log-time", None, &states).text();
302            x += i32::from(cx.text(x, rect.y, time, time_style, room(x))) + 2;
303        }
304        let level_style = cx.style("log-level", Some(line.level().name()), &states).text();
305        cx.text(x, rect.y, line.level().name(), level_style, room(x).min(LEVEL_WIDTH));
306        x += i32::from(LEVEL_WIDTH) + 2;
307        let budget = room(x);
308        let shown = text::truncate(line.text(), budget);
309        cx.text(x, rect.y, &shown, row_style, budget);
310        if self.query.is_empty() {
311            return;
312        }
313        let mut match_style = cx.style("log-match", None, &states).text();
314        if match_style.fg.is_none() {
315            match_style.fg = row_style.fg;
316        }
317        let limit = shown.len().saturating_sub(if shown.len() < line.text().len() { text::ELLIPSIS.len() } else { 0 });
318        for (start, end) in matches(line.text(), &self.query) {
319            if end > limit {
320                break;
321            }
322            let dx = i32::from(text::width(&line.text()[..start]));
323            cx.text(x + dx, rect.y, &line.text()[start..end], match_style, budget);
324        }
325    }
326}
327
328#[derive(Debug, Clone, Copy)]
329enum CursorTarget {
330    By(isize),
331    First,
332}
333
334/// Byte ranges of `query` in `text`; a query without uppercase letters ignores case.
335fn matches(text: &str, query: &str) -> Vec<(usize, usize)> {
336    if query.is_empty() {
337        return Vec::new();
338    }
339    let fold = !query.chars().any(char::is_uppercase);
340    let needle: Vec<char> = query.chars().collect();
341    let same = |a: char, b: char| if fold { a.to_lowercase().eq(b.to_lowercase()) } else { a == b };
342    let chars: Vec<(usize, char)> = text.char_indices().collect();
343    let mut out = Vec::new();
344    let mut i = 0;
345    while i + needle.len() <= chars.len() {
346        if needle.iter().enumerate().all(|(k, c)| same(chars[i + k].1, *c)) {
347            let end = chars.get(i + needle.len()).map_or(text.len(), |(byte, _)| *byte);
348            out.push((chars[i].0, end));
349            i += needle.len();
350        } else {
351            i += 1;
352        }
353    }
354    out
355}
356
357impl<Msg: 'static> Widget<Msg> for LogView<Msg> {
358    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
359        let rows = clamp_u16(i32::try_from(self.buffer.len().max(1)).unwrap_or(i32::MAX));
360        Size::new(available.width, rows).min(available)
361    }
362
363    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
364        cx.register_hit(area);
365        edit_menu::request_overlay(cx, area);
366        let visible = usize::from(area.height);
367        let focused = cx.is_focused();
368        let (total, detached, selection) = {
369            let memory = cx.memory::<LogMemory>();
370            self.refresh(&mut memory.filter);
371            (self.total(&memory.filter), memory.detached, Self::selection(memory))
372        };
373        if total == 0 {
374            cx.memory::<LogMemory>().note = None;
375            let faint = cx.style("list-header", None, &[]).text();
376            let text = if self.buffer.is_empty() {
377                self.empty.clone()
378            } else {
379                crate::i18n::translate_active("quvyta.log.no-match", &[])
380            };
381            cx.text(area.x + i32::from(LEAD), area.y, &text, faint, area.width.saturating_sub(LEAD));
382            return;
383        }
384        let max_offset = total.saturating_sub(visible);
385        let offset = {
386            let scroll = cx.memory::<RowScroll>();
387            scroll.offset = if detached { scroll.offset.min(max_offset) } else { max_offset };
388            scroll.offset
389        };
390        let width = area.width.saturating_sub(u16::from(total > visible));
391        for row in 0..visible.min(total - offset) {
392            let memory = cx.memory::<LogMemory>();
393            let Some(number) = self.number_at(&memory.filter, offset + row) else { break };
394            let Some(line) = self.buffer.by_number(number) else { continue };
395            let selected = selection.is_some_and(|(from, to)| (from..=to).contains(&number));
396            let rect = Rect::new(area.x, area.y + i32::try_from(row).unwrap_or(0), width, 1);
397            self.paint_line(cx, rect, line, selected, focused);
398        }
399        rows::paint_scrollbar(cx, area, total, offset, None);
400        let below = total - (offset + visible).min(total);
401        let note = (detached && below > 0).then(|| {
402            let note = rows::paint_below_note(cx, Rect::new(area.x, area.y, width, area.height), below);
403            cx.register_hit(note);
404            note
405        });
406        cx.memory::<LogMemory>().note = note;
407    }
408
409    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
410        TextMenu::copy(cx.env()).paint_overlay(cx, anchor);
411    }
412
413    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
414        if let Some(used) = self.menu_event(cx, event) {
415            return used;
416        }
417        let area = cx.area();
418        let page = isize::try_from(area.height.max(1)).unwrap_or(1);
419        match event {
420            Event::Key(key) => {
421                let shift = Modifiers { shift: true, ..Modifiers::default() };
422                let extend = key.chord.mods == shift;
423                let plain_or_shift = key.chord.mods == Modifiers::default() || extend;
424                let delta = match key.chord.key {
425                    Key::Up | Key::Char('k') if plain_or_shift => Some(-1),
426                    Key::Down | Key::Char('j') if plain_or_shift => Some(1),
427                    Key::PageUp if plain_or_shift => Some(-page),
428                    Key::PageDown if plain_or_shift => Some(page),
429                    _ => None,
430                };
431                if let Some(delta) = delta {
432                    return self.move_cursor(cx, CursorTarget::By(delta), extend);
433                }
434                if key.is_plain(Key::Home) {
435                    return self.move_cursor(cx, CursorTarget::First, false);
436                }
437                if key.is_plain(Key::End) {
438                    Self::follow_tail(cx);
439                    return true;
440                }
441                if key.is_plain(Key::Esc) {
442                    let memory = cx.memory::<LogMemory>();
443                    let had = memory.cursor.is_some();
444                    memory.cursor = None;
445                    memory.anchor = None;
446                    return had;
447                }
448                if key.is_plain(Key::Char('c')) {
449                    return self.copy(cx, CopyKind::Clean);
450                }
451                false
452            }
453            Event::Mouse(mouse) => {
454                let total = {
455                    let memory = cx.memory::<LogMemory>();
456                    self.refresh(&mut memory.filter);
457                    self.total(&memory.filter)
458                };
459                if rows::scroll_mouse(cx, mouse, area, total) {
460                    let offset = cx.memory::<RowScroll>().offset;
461                    cx.memory::<LogMemory>().detached = offset + usize::from(area.height) < total;
462                    return true;
463                }
464                let offset = cx.memory::<RowScroll>().offset;
465                let row = usize::try_from(mouse.y - area.y).ok().map(|r| offset + r).filter(|r| *r < total);
466                match mouse.kind {
467                    MouseKind::Down(MouseButton::Left) => {
468                        let visible = usize::from(area.height);
469                        if cx.memory::<LogMemory>().note.is_some_and(|note| note.contains(mouse.x, mouse.y)) {
470                            Self::follow_tail(cx);
471                            return true;
472                        }
473                        let Some(row) = row else { return false };
474                        let memory = cx.memory::<LogMemory>();
475                        let number = self.number_at(&memory.filter, row);
476                        if !(mouse.mods.shift && memory.cursor.is_some()) {
477                            memory.anchor = number;
478                        }
479                        memory.cursor = number;
480                        memory.selecting = true;
481                        memory.detached = offset + visible < total;
482                        cx.capture_pointer();
483                        true
484                    }
485                    MouseKind::Drag(MouseButton::Left) if cx.memory::<LogMemory>().selecting => {
486                        // The view can lose its rows while a drag is on; the top row stands in.
487                        let clamped = (mouse.y - area.y).clamp(0, i32::from(area.height.saturating_sub(1)));
488                        let row = (offset + usize::try_from(clamped).unwrap_or(0)).min(total.saturating_sub(1));
489                        let memory = cx.memory::<LogMemory>();
490                        memory.cursor = self.number_at(&memory.filter, row).or(memory.cursor);
491                        memory.detached = true;
492                        true
493                    }
494                    MouseKind::Up(MouseButton::Left) if cx.memory::<LogMemory>().selecting => {
495                        cx.memory::<LogMemory>().selecting = false;
496                        true
497                    }
498                    _ => false,
499                }
500            }
501            _ => false,
502        }
503    }
504
505    fn focusable(&self) -> bool {
506        true
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::icons::GlyphMode;
514    use crate::runtime::{App, Command, Harness};
515    use crate::widget::View;
516
517    struct Demo {
518        buffer: LogBuffer,
519        level: LogLevel,
520        query: String,
521        copies: Vec<usize>,
522    }
523
524    #[derive(Clone)]
525    enum Msg {
526        Push(LogLine),
527        Copied(usize),
528    }
529
530    impl App for Demo {
531        type Msg = Msg;
532        fn update(&mut self, msg: Msg) -> Command<Msg> {
533            match msg {
534                Msg::Push(line) => self.buffer.push(line),
535                Msg::Copied(n) => self.copies.push(n),
536            }
537            Command::none()
538        }
539        fn view(&self, ui: &mut View<'_, Msg>) {
540            let view = LogView::new(&self.buffer)
541                .min_level(self.level)
542                .search(self.query.clone())
543                .empty_text("Waiting for logs")
544                .on_copy(Msg::Copied);
545            ui.add(view).fill().id("log");
546        }
547    }
548
549    fn line(n: usize) -> LogLine {
550        let level = if n % 5 == 4 { LogLevel::Warn } else { LogLevel::Info };
551        LogLine::new(level, format!("request {n} served")).time(format!("12:00:{:02}", n % 60))
552    }
553
554    fn demo(count: usize) -> Harness<Demo> {
555        let mut buffer = LogBuffer::new(1000);
556        for n in 0..count {
557            buffer.push(line(n));
558        }
559        let mut h =
560            Harness::new(Demo { buffer, level: LogLevel::Trace, query: String::new(), copies: Vec::new() }, 44, 4);
561        h.set_glyph_mode(GlyphMode::Unicode);
562        h
563    }
564
565    #[test]
566    fn follows_the_tail_until_scrolled_up() {
567        let mut h = demo(10);
568        assert_eq!(
569            h.screen(),
570            "  12:00:06  info   request 6 served\n  12:00:07  info   request 7 served\n  12:00:08  info   request 8 served\n  12:00:09  warn   request 9 served\n"
571        );
572        assert_eq!(super::super::scrollbar::column(&h, 43), "---#", "the thumb follows the tail");
573        h.send(Msg::Push(line(10)));
574        assert!(h.screen().contains("request 10 served"));
575        h.mouse(MouseKind::ScrollUp, 5, 1);
576        h.send(Msg::Push(line(11)));
577        let screen = h.screen();
578        assert!(!screen.contains("request 11"), "{screen}");
579        assert!(screen.contains("↓ 4 lines below"), "{screen}");
580        h.click_text("lines below");
581        assert!(h.screen().contains("request 11 served"), "{}", h.screen());
582    }
583
584    #[test]
585    fn only_a_click_on_the_note_resumes_following() {
586        let mut h = demo(10);
587        h.mouse(MouseKind::ScrollUp, 5, 1);
588        // The note reads ` ↓ 3 lines below `: two cells before its first cell is a line.
589        let (words, y) = h.find("lines below").expect("the note");
590        let note = words - 5;
591        h.click(note - 2, y);
592        assert!(!h.screen().contains("request 9 served"), "a click beside the note places the cursor:\n{}", h.screen());
593        h.click(note, y);
594        assert!(h.screen().contains("request 9 served"), "a click on the note follows the tail:\n{}", h.screen());
595    }
596
597    #[test]
598    fn level_colour_marks_and_faint_time() {
599        let h = demo(10);
600        let theme = h.env().theme();
601        assert_eq!(h.fg(12, 3), theme.color("warning"), "warn is a word in the warning colour");
602        assert_eq!(h.fg(12, 2), theme.color("info"));
603        assert_eq!(h.fg(2, 3), theme.color("muted"), "timestamps are faint");
604    }
605
606    #[test]
607    fn filters_by_level_and_highlights_search() {
608        let mut h = demo(30);
609        h.set_glyph_mode(GlyphMode::Unicode);
610        let app_level = |h: &mut Harness<Demo>, level, query: &str| {
611            let buffer = h.app().buffer.clone();
612            let mut next = Harness::new(Demo { buffer, level, query: query.to_owned(), copies: Vec::new() }, 44, 4);
613            next.set_glyph_mode(GlyphMode::Unicode);
614            next
615        };
616        let warn = app_level(&mut h, LogLevel::Warn, "");
617        let screen = warn.screen();
618        assert!(screen.lines().all(|l| l.contains("warn")), "{screen}");
619        assert!(screen.contains("request 29 served"), "{screen}");
620        let search = app_level(&mut h, LogLevel::Trace, "REQUEST 2");
621        assert!(search.screen().contains("No lines match"), "{}", search.screen());
622        let search = app_level(&mut h, LogLevel::Trace, "request 2");
623        let screen = search.screen();
624        assert!(screen.contains("request 29") && !screen.contains("request 19"), "{screen}");
625        let highlight = search.env().theme().color("warning");
626        let x = u16::try_from(search.find("request 29").map_or(0, |(x, _)| x)).unwrap_or(0);
627        assert_ne!(search.bg(x, 3), search.bg(x - 2, 3), "matches are highlighted");
628        assert!(highlight.is_some());
629    }
630
631    #[test]
632    fn cursor_selection_and_copy() {
633        let mut h = demo(10);
634        h.press("tab").press("up").press("shift+up").press("c");
635        assert_eq!(
636            h.copied().last().map(String::as_str),
637            Some("12:00:08 info request 8 served\n12:00:09 warn request 9 served")
638        );
639        assert_eq!(h.app().copies, vec![2]);
640        assert!(h.screen().starts_with("  12:00:06"), "{}", h.screen());
641        h.press("end");
642        h.send(Msg::Push(line(10)));
643        assert!(h.screen().contains("request 10"));
644        h.press("home");
645        assert!(h.screen().contains("request 0 served"));
646    }
647
648    #[test]
649    fn right_click_copies_the_selected_lines_clean_or_raw() {
650        let mut h = demo(10);
651        h.set_reduced_motion(true);
652        h.mouse(MouseKind::Down(MouseButton::Left), 5, 2).mouse(MouseKind::Drag(MouseButton::Left), 5, 3);
653        h.mouse(MouseKind::Up(MouseButton::Left), 5, 3);
654        assert!(h.copied().is_empty(), "selecting lines copies nothing");
655        h.mouse(MouseKind::Down(MouseButton::Right), 20, 3).mouse(MouseKind::Up(MouseButton::Right), 20, 3);
656        h.set_glyph_mode(GlyphMode::Unicode);
657        let screen = h.screen();
658        assert!(screen.contains("Copy") && screen.contains("Raw copy"), "{screen}");
659        h.click_text("Raw copy");
660        assert_eq!(
661            h.copied().last().map(String::as_str),
662            Some("12:00:08  info   request 8 served\n12:00:09  warn   request 9 served"),
663            "the columns as shown"
664        );
665        h.mouse(MouseKind::Down(MouseButton::Right), 20, 0).mouse(MouseKind::Up(MouseButton::Right), 20, 0);
666        h.click_text("Copy");
667        assert_eq!(h.copied().last().map(String::as_str), Some("12:00:06 info request 6 served"));
668        assert_eq!(h.app().copies, vec![2, 1], "a right click off the selection took that line");
669    }
670
671    /// A log whose height the application sets, to shrink it while lines are being selected.
672    struct Shrinking {
673        buffer: LogBuffer,
674        rows: u16,
675    }
676
677    impl App for Shrinking {
678        type Msg = u16;
679        fn update(&mut self, rows: u16) -> Command<u16> {
680            self.rows = rows;
681            Command::none()
682        }
683        fn view(&self, ui: &mut View<'_, u16>) {
684            ui.add(LogView::new(&self.buffer)).fill_width().height(crate::widget::Length::Cells(self.rows));
685        }
686    }
687
688    #[test]
689    fn a_selection_drag_survives_the_view_losing_its_height() {
690        let mut buffer = LogBuffer::new(100);
691        for n in 0..10 {
692            buffer.push(line(n));
693        }
694        let mut h = Harness::new(Shrinking { buffer, rows: 4 }, 44, 4);
695        h.mouse(MouseKind::Down(MouseButton::Left), 5, 1);
696        h.send(0);
697        h.mouse(MouseKind::Drag(MouseButton::Left), 5, 3);
698        h.mouse(MouseKind::Up(MouseButton::Left), 5, 3);
699        assert_eq!(h.screen(), "\n\n\n\n", "a log with no rows paints nothing");
700    }
701
702    #[test]
703    fn keeps_capacity_and_shows_empty_text() {
704        let mut h = demo(0);
705        assert_eq!(h.screen(), "  Waiting for logs\n\n\n\n");
706        for n in 0..1500 {
707            h.send(Msg::Push(line(n)));
708        }
709        assert_eq!(h.app().buffer.len(), 1000);
710        assert!(h.screen().contains("request 1499 served"));
711    }
712
713    #[test]
714    fn a_line_meant_for_a_terminal_shows_what_a_terminal_would_leave() {
715        let buffer = LogBuffer::new(10);
716        let app = Demo { buffer, level: LogLevel::Trace, query: String::new(), copies: Vec::new() };
717        let mut h = Harness::new(app, 60, 6);
718        for text in [
719            "Sending build context to Docker daemon  2.048kB\r\r",
720            "10%\r50%\r100%",
721            "\u{1b}[1;32mok\u{1b}[0m done",
722            "half \u{1b}[3",
723            "lone \u{1b}",
724            "a\tb\u{7}c\u{0}d",
725        ] {
726            h.send(Msg::Push(LogLine::new(LogLevel::Info, text)));
727        }
728        let screen = h.screen();
729        let shown: Vec<&str> = screen.lines().map(str::trim_end).collect();
730        assert_eq!(
731            shown,
732            [
733                "  info   Sending build context to Docker daemon  2.048kB",
734                "  info   100%",
735                "  info   ok done",
736                "  info   half",
737                "  info   lone",
738                "  info   a       bcd",
739            ],
740            "{screen}"
741        );
742        h.press("tab").press("up").press("c");
743        assert_eq!(h.copied().last().map(String::as_str), Some("info a       bcd"), "a copy holds what is shown");
744    }
745}