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::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
18const LEVEL_WIDTH: u16 = 5;
20
21type CopyMessage<Msg> = Box<dyn Fn(usize) -> Msg>;
23
24pub 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#[derive(Debug, Default)]
59struct Filter {
60 key: Option<(u64, LogLevel, String)>,
61 scanned: u64,
63 lines: VecDeque<u64>,
65}
66
67#[derive(Debug, Default)]
68struct LogMemory {
69 filter: Filter,
70 detached: bool,
72 cursor: Option<u64>,
73 anchor: Option<u64>,
74 selecting: bool,
75 note: Option<Rect>,
77}
78
79impl<Msg: 'static> LogView<Msg> {
80 #[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 #[must_use]
94 pub fn min_level(mut self, level: LogLevel) -> Self {
95 self.min_level = level;
96 self
97 }
98
99 #[must_use]
101 pub fn search(mut self, query: impl Into<String>) -> Self {
102 self.query = query.into();
103 self
104 }
105
106 #[must_use]
108 pub fn empty_text(mut self, text: impl Into<String>) -> Self {
109 self.empty = text.into();
110 self
111 }
112
113 #[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 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 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 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 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 (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 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 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 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
349fn 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 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 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 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}