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