1use 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
17const LEVEL_WIDTH: u16 = 5;
19
20type CopyMessage<Msg> = Box<dyn Fn(usize) -> Msg>;
22
23pub 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#[derive(Debug, Default)]
58struct Filter {
59 key: Option<(u64, LogLevel, String)>,
60 scanned: u64,
62 lines: VecDeque<u64>,
64}
65
66#[derive(Debug, Default)]
67struct LogMemory {
68 filter: Filter,
69 detached: bool,
71 cursor: Option<u64>,
72 anchor: Option<u64>,
73 selecting: bool,
74 note: Option<Rect>,
76}
77
78impl<Msg: 'static> LogView<Msg> {
79 #[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 #[must_use]
93 pub fn min_level(mut self, level: LogLevel) -> Self {
94 self.min_level = level;
95 self
96 }
97
98 #[must_use]
100 pub fn search(mut self, query: impl Into<String>) -> Self {
101 self.query = query.into();
102 self
103 }
104
105 #[must_use]
107 pub fn empty_text(mut self, text: impl Into<String>) -> Self {
108 self.empty = text.into();
109 self
110 }
111
112 #[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 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 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 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 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 (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 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 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 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
334fn 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 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 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 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}