Skip to main content

pito_tui/
log.rs

1use std::borrow::Cow;
2use std::sync::Arc;
3
4use crossterm::event::KeyEvent;
5use pito_footer::{Input, InputBar};
6use pito_list::{Cell, Column, Key, Keys, List, ListView, Row, Source};
7use ratatui::{
8    Frame,
9    buffer::Buffer,
10    layout::{Alignment, Rect},
11    text::Line,
12    widgets::Paragraph,
13};
14
15use crate::filter::matches;
16use crate::palette::Palette;
17
18pub type Lines = Arc<Vec<Line<'static>>>;
19
20const SEARCH: &[Key] = &[Key::Char('/')];
21const NEXT: &[Key] = &[Key::Char('n')];
22const PREVIOUS: &[Key] = &[Key::Char('N')];
23const COPY: &[Key] = &[Key::Char('y')];
24const ALL: &[Key] = &[Key::Char('Y')];
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum Turn {
29    Pass,
30    Taken,
31    Copy(String),
32}
33
34#[derive(Debug, Clone)]
35pub struct Log {
36    lines: Lines,
37    top: usize,
38    page: usize,
39    input: Input,
40    typing: bool,
41    hits: Vec<usize>,
42    hit: Option<usize>,
43    follow: bool,
44    label: Cow<'static, str>,
45    placeholder: Cow<'static, str>,
46    hint: Cow<'static, str>,
47    keys: Keys,
48    search: &'static [Key],
49    next: &'static [Key],
50    previous: &'static [Key],
51    copy: &'static [Key],
52    all: &'static [Key],
53}
54
55fn plain(line: &Line) -> String {
56    line.spans
57        .iter()
58        .map(|span| span.content.as_ref())
59        .collect()
60}
61
62impl Log {
63    pub fn new(label: impl Into<Cow<'static, str>>) -> Self {
64        Log {
65            lines: Arc::new(Vec::new()),
66            top: 0,
67            page: 1,
68            input: Input::new(),
69            typing: false,
70            hits: Vec::new(),
71            hit: None,
72            follow: true,
73            label: label.into(),
74            placeholder: Cow::Borrowed(""),
75            hint: Cow::Borrowed(""),
76            keys: Keys::VIM,
77            search: SEARCH,
78            next: NEXT,
79            previous: PREVIOUS,
80            copy: COPY,
81            all: ALL,
82        }
83    }
84
85    pub fn placeholder(mut self, text: impl Into<Cow<'static, str>>) -> Self {
86        self.placeholder = text.into();
87        self
88    }
89
90    pub fn hint(mut self, text: impl Into<Cow<'static, str>>) -> Self {
91        self.hint = text.into();
92        self
93    }
94
95    pub fn follow(mut self, follow: bool) -> Self {
96        self.follow = follow;
97        self
98    }
99
100    pub fn keys(mut self, keys: Keys) -> Self {
101        self.keys = keys;
102        self
103    }
104
105    pub fn search_keys(mut self, keys: &'static [Key]) -> Self {
106        self.search = keys;
107        self
108    }
109
110    pub fn hit_keys(mut self, next: &'static [Key], previous: &'static [Key]) -> Self {
111        self.next = next;
112        self.previous = previous;
113        self
114    }
115
116    pub fn copy_keys(mut self, page: &'static [Key], all: &'static [Key]) -> Self {
117        self.copy = page;
118        self.all = all;
119        self
120    }
121
122    pub fn lines(&self) -> &Lines {
123        &self.lines
124    }
125
126    pub fn set_lines(&mut self, lines: Lines) {
127        if Arc::ptr_eq(&self.lines, &lines) {
128            return;
129        }
130        let following = self.follow && self.top >= self.last();
131        self.lines = lines;
132        if following && self.top > 0 {
133            self.top = self.last();
134        }
135        self.top = self.top.min(self.last());
136        self.find(false);
137    }
138
139    pub fn len(&self) -> usize {
140        self.lines.len()
141    }
142
143    pub fn is_empty(&self) -> bool {
144        self.lines.is_empty()
145    }
146
147    pub fn top(&self) -> usize {
148        self.top
149    }
150
151    pub fn hits(&self) -> usize {
152        self.hits.len()
153    }
154
155    pub fn hit(&self) -> Option<usize> {
156        self.hit
157    }
158
159    pub fn query(&self) -> &str {
160        self.input.value()
161    }
162
163    pub fn typing(&self) -> bool {
164        self.typing
165    }
166
167    pub fn bar(&self) -> Option<InputBar<'_>> {
168        self.typing.then(|| {
169            InputBar::new(&self.label, &self.input)
170                .placeholder(&self.placeholder)
171                .hint(&self.hint)
172        })
173    }
174
175    fn last(&self) -> usize {
176        self.lines.len().saturating_sub(self.page)
177    }
178
179    fn show(&mut self, line: usize) {
180        if line < self.top || line >= self.top + self.page {
181            self.top = line.saturating_sub(self.page / 3).min(self.last());
182        }
183    }
184
185    fn find(&mut self, jump: bool) {
186        let query = self.input.value().trim().to_string();
187        self.hits = if query.is_empty() {
188            Vec::new()
189        } else {
190            self.lines
191                .iter()
192                .enumerate()
193                .filter(|(_, line)| matches(&plain(line), &query))
194                .map(|(index, _)| index)
195                .collect()
196        };
197        self.hit = None;
198        if jump && let Some(at) = self.hits.iter().position(|line| *line >= self.top) {
199            self.hit = Some(at);
200            self.show(self.hits[at]);
201        }
202    }
203
204    fn step(&mut self, by: isize) {
205        if self.hits.is_empty() {
206            return;
207        }
208        let count = self.hits.len() as isize;
209        let at = match self.hit {
210            Some(at) => (at as isize + by).rem_euclid(count),
211            None if by > 0 => 0,
212            None => count - 1,
213        } as usize;
214        self.hit = Some(at);
215        self.show(self.hits[at]);
216    }
217
218    fn text(&self, lines: &[Line]) -> String {
219        let mut text: Vec<String> = lines.iter().map(|line| plain(line)).collect();
220        for line in &mut text {
221            line.truncate(line.trim_end().len());
222        }
223        text.join("\n")
224    }
225
226    pub fn key(&mut self, event: KeyEvent) -> Turn {
227        let key: Key = event.into();
228        if self.typing {
229            match key {
230                Key::Enter => self.typing = false,
231                Key::Esc => {
232                    self.typing = false;
233                    self.input.clear();
234                    self.find(false);
235                }
236                _ => {
237                    let before = self.input.value().to_string();
238                    self.input.key(event.into());
239                    if self.input.value() != before {
240                        self.find(true);
241                    }
242                }
243            }
244            return Turn::Taken;
245        }
246        let page = self.page.max(1);
247        if self.keys.up.contains(&key) {
248            self.top = self.top.saturating_sub(1);
249        } else if self.keys.down.contains(&key) {
250            self.top = (self.top + 1).min(self.last());
251        } else if self.keys.page_up.contains(&key) {
252            self.top = self.top.saturating_sub(page);
253        } else if self.keys.page_down.contains(&key) {
254            self.top = (self.top + page).min(self.last());
255        } else if self.keys.first.contains(&key) {
256            self.top = 0;
257        } else if self.keys.last.contains(&key) {
258            self.top = self.last();
259        } else if self.search.contains(&key) {
260            self.typing = true;
261        } else if self.next.contains(&key) {
262            self.step(1);
263        } else if self.previous.contains(&key) {
264            self.step(-1);
265        } else if self.copy.contains(&key) {
266            let end = (self.top + page).min(self.lines.len());
267            return Turn::Copy(self.text(&self.lines[self.top.min(end)..end]));
268        } else if self.all.contains(&key) {
269            return Turn::Copy(self.text(&self.lines));
270        } else {
271            return Turn::Pass;
272        }
273        Turn::Taken
274    }
275
276    pub fn paste(&mut self, text: &str) -> Turn {
277        if !self.typing {
278            return Turn::Pass;
279        }
280        self.input.paste(text);
281        self.find(true);
282        Turn::Taken
283    }
284
285    pub fn draw(&mut self, frame: &mut Frame, area: Rect, palette: &Palette) {
286        let total = self.lines.len();
287        let over = area.height > 0 && total > usize::from(area.height);
288        let cut = over && palette.count.is_some() && area.height > 1;
289        let bar = over && palette.bar.is_some() && area.width > 1;
290        let area = Rect {
291            width: area.width - u16::from(bar),
292            height: area.height - u16::from(cut),
293            ..area
294        };
295        self.page = usize::from(area.height).max(1);
296        self.top = self.top.min(self.last());
297        if bar {
298            let column = Rect::new(area.right(), area.y, 1, area.height);
299            scrollbar(frame.buffer_mut(), column, self.top, total, palette);
300        }
301        if let Some(count) = palette.count.filter(|_| cut) {
302            let last = (self.top + self.page).min(total);
303            let label = count.label(self.top + 1, last, total).to_string();
304            let line = Rect::new(area.x, area.bottom(), area.width + u16::from(bar), 1);
305            let label = Line::styled(label, palette.muted).alignment(Alignment::Right);
306            frame.render_widget(Paragraph::new(label), line);
307        }
308        let current = self.hit.map(|at| self.hits[at]);
309        let end = (self.top + self.page).min(self.lines.len());
310        let shown: Vec<Line> = (self.top..end)
311            .map(|index| {
312                let line = self.lines[index].clone();
313                if Some(index) == current {
314                    line.patch_style(palette.selected)
315                } else {
316                    line
317                }
318            })
319            .collect();
320        frame.render_widget(Paragraph::new(shown), area);
321    }
322}
323
324struct Blank(usize);
325
326impl Source for Blank {
327    fn len(&self) -> usize {
328        self.0
329    }
330
331    fn row(&self, _index: usize) -> Cow<'_, Row> {
332        Cow::Owned(Row::new(Vec::<Cell>::new()))
333    }
334
335    fn selectable(&self, _index: usize) -> bool {
336        true
337    }
338}
339
340pub(crate) fn scrollbar(
341    buf: &mut Buffer,
342    column: Rect,
343    top: usize,
344    total: usize,
345    palette: &Palette,
346) {
347    if column.is_empty() {
348        return;
349    }
350    let mut list = List::from_source(Blank(total));
351    list.select(top);
352    list.scroll_to(top);
353    let columns = [Column::new("", 0, 0)];
354    let view = ListView::new(&mut list, &columns)
355        .styles(palette.list())
356        .scrollbar(palette.bar)
357        .cursor("");
358    let mut scratch = Buffer::empty(Rect::new(0, 0, 2, column.height));
359    ratatui::widgets::Widget::render(view, scratch.area, &mut scratch);
360    for y in 0..column.height {
361        let at = (column.x, column.y + y);
362        if buf.area.contains(at.into()) {
363            buf[at] = scratch[(1, y)].clone();
364        }
365    }
366}