duat_base/widgets/
log_book.rs

1//! Widget to display the history of notifications sent to Duat
2//!
3//! This widget is kind of an extended version of the
4//! [`Notifications`] widget. It is normally placed at the bottom of
5//! the screen, but you can also place it somewhere else. The messages
6//! can be formatted differently, and you can also filter the things
7//! that you don't care about.
8//!
9//! [`Notifications`]: super::Notifications
10use std::sync::Mutex;
11
12use duat_core::{
13    context::{self, Handle, Logs, Record},
14    data::Pass,
15    mode::{MouseEvent, MouseEventKind},
16    opts::PrintOpts,
17    text::{Spacer, Text, TextMut, txt},
18    ui::{PushSpecs, PushTarget, Side, Widget},
19};
20
21#[allow(clippy::type_complexity)]
22static GLOBAL_FMT: Mutex<Option<Box<dyn FnMut(Record) -> Option<Text> + Send>>> = Mutex::new(None);
23
24/// A [`Widget`] to display [`Logs`] sent to Duat
25pub struct LogBook {
26    logs: Logs,
27    len_of_taken: usize,
28    text: Text,
29    fmt: Option<Box<dyn FnMut(Record) -> Option<Text> + Send>>,
30    has_updated_once: bool,
31    /// Wether to close this [`Widget`] after unfocusing, `true` by
32    /// default
33    pub close_on_unfocus: bool,
34    /// Wether the source of a log should be shown
35    ///
36    /// Can be disabled for less noise. This option is ignored when
37    /// there is [custom formatting].
38    ///
39    /// [custom formatting]: LogBookOpts::fmt
40    pub show_source: bool,
41}
42
43impl LogBook {
44    /// Reformats this `LogBook`
45    pub fn fmt(&mut self, fmt: impl FnMut(Record) -> Option<Text> + Send + 'static) {
46        self.fmt = Some(Box::new(fmt))
47    }
48
49    /// Returns a [`LogBookOpts`], so you can push `LogBook`s around
50    pub fn builder() -> LogBookOpts {
51        LogBookOpts::default()
52    }
53}
54
55impl Widget for LogBook {
56    fn update(pa: &mut Pass, handle: &Handle<Self>) {
57        let (lb, area) = handle.write_with_area(pa);
58
59        let Some(new_records) = lb.logs.get(lb.len_of_taken..) else {
60            return;
61        };
62
63        let records_were_added = !new_records.is_empty();
64        lb.len_of_taken += new_records.len();
65
66        let fmt_recs = |fmt: &mut dyn FnMut(Record) -> Option<Text>| {
67            for rec_text in new_records.into_iter().filter_map(fmt) {
68                lb.text.insert_text(lb.text.len(), &rec_text);
69            }
70        };
71
72        let mut global_fmt = GLOBAL_FMT.lock().unwrap();
73
74        if let Some(fmt) = lb.fmt.as_mut() {
75            fmt_recs(fmt);
76        } else if let Some(fmt) = global_fmt.as_mut() {
77            fmt_recs(fmt);
78        } else {
79            fmt_recs(&mut |rec| default_fmt(lb.show_source, rec));
80        }
81
82        if !lb.has_updated_once {
83            area.scroll_ver(&lb.text, i32::MAX, lb.get_print_opts());
84            lb.has_updated_once = true;
85        } else if records_were_added {
86            area.scroll_ver(&lb.text, i32::MAX, lb.get_print_opts());
87        }
88    }
89
90    fn needs_update(&self, _: &Pass) -> bool {
91        self.logs.has_changed()
92    }
93
94    fn text(&self) -> &Text {
95        &self.text
96    }
97
98    fn text_mut(&mut self) -> TextMut<'_> {
99        self.text.as_mut()
100    }
101
102    fn get_print_opts(&self) -> PrintOpts {
103        let mut opts = PrintOpts::new();
104        opts.wrap_lines = true;
105        opts.wrap_on_word = true;
106        opts
107    }
108
109    fn on_focus(pa: &mut Pass, handle: &Handle<Self>) {
110        handle.area().reveal(pa).unwrap();
111    }
112
113    fn on_unfocus(pa: &mut Pass, handle: &Handle<Self>) {
114        if handle.read(pa).close_on_unfocus {
115            handle.area().hide(pa).unwrap()
116        }
117    }
118
119    fn on_mouse_event(pa: &mut Pass, handle: &Handle<Self>, event: MouseEvent) {
120        match event.kind {
121            MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => {
122                let (lb, area) = handle.write_with_area(pa);
123                let scroll = if let MouseEventKind::ScrollDown = event.kind {
124                    3
125                } else {
126                    -3
127                };
128                area.scroll_ver(&lb.text, scroll, lb.get_print_opts());
129            }
130            _ => {}
131        }
132    }
133}
134
135/// Configuration for the [`LogBook`]
136#[derive(Clone, Copy)]
137pub struct LogBookOpts {
138    /// Wether to close the `LogBook` when unfocusing
139    pub close_on_unfocus: bool,
140    /// Wether to hide the `LogBook` by default
141    pub hidden: bool,
142    /// To which side to push the [`LogBook`] to
143    pub side: Side,
144    /// Requested height for the [`LogBook`], ignored if pushing
145    /// horizontally
146    pub height: f32,
147    /// Requested width for the [`LogBook`], ignored if pushing
148    /// vertically
149    pub width: f32,
150    /// Wether the source of a log should be shown
151    ///
152    /// Can be disabled for less noise. This option is ignored when
153    /// there is [custom formatting].
154    ///
155    /// [custom formatting]: Self::fmt
156    pub show_source: bool,
157}
158
159impl LogBookOpts {
160    /// Push a [`LogBook`] around the given [`PushTarget`]
161    pub fn push_on(self, pa: &mut Pass, push_target: &impl PushTarget) -> Handle<LogBook> {
162        let logs = context::logs();
163
164        let mut text = Text::new();
165
166        let records = logs.get(..).unwrap();
167        let len_of_taken = records.len();
168
169        let fmt_recs = |fmt: &mut dyn FnMut(Record) -> Option<Text>| {
170            for rec_text in records.into_iter().filter_map(fmt) {
171                text.insert_text(text.len(), &rec_text);
172            }
173        };
174
175        let mut global_fmt = GLOBAL_FMT.lock().unwrap();
176
177        if let Some(fmt) = global_fmt.as_mut() {
178            fmt_recs(fmt);
179        } else {
180            fmt_recs(&mut |rec| default_fmt(self.show_source, rec));
181        }
182
183        let log_book = LogBook {
184            logs,
185            len_of_taken,
186            text,
187            has_updated_once: false,
188            fmt: None,
189            show_source: self.show_source,
190            close_on_unfocus: self.close_on_unfocus,
191        };
192        let specs = match self.side {
193            Side::Right | Side::Left => PushSpecs {
194                side: self.side,
195                width: Some(self.width),
196                hidden: self.hidden,
197                ..Default::default()
198            },
199            Side::Above | Side::Below => PushSpecs {
200                side: self.side,
201                height: Some(self.height),
202                hidden: self.hidden,
203                ..Default::default()
204            },
205        };
206
207        push_target.push_outer(pa, log_book, specs)
208    }
209
210    /// Changes the way [`Record`]s are formatted by the [`LogBook`]
211    ///
212    /// This function returns an [`Option<Text>`], which means you can
213    /// filter out unnecessary [`Record`]s. By default, all valid
214    /// [`Record`]s (those with level [`Debug`] or higher.
215    ///
216    /// [`Debug`]: context::Level::Debug
217    pub fn fmt(&mut self, fmt: impl FnMut(Record) -> Option<Text> + Send + 'static) {
218        *GLOBAL_FMT.lock().unwrap() = Some(Box::new(fmt));
219    }
220}
221
222impl Default for LogBookOpts {
223    fn default() -> Self {
224        Self {
225            close_on_unfocus: true,
226            hidden: true,
227            side: Side::Below,
228            height: 8.0,
229            width: 50.0,
230            show_source: true,
231        }
232    }
233}
234
235fn default_fmt(show_source: bool, rec: Record) -> Option<Text> {
236    use duat_core::context::Level::*;
237    let mut builder = Text::builder();
238
239    match rec.level() {
240        Error => builder.push(txt!("[log_book.error][[ERROR]][log_book.colon]:  ")),
241        Warn => builder.push(txt!("[log_book.warn][[WARNING]][log_book.colon]:")),
242        Info => builder.push(txt!("[log_book.info][[INFO]][log_book.colon]:   ")),
243        Debug => builder.push(txt!("[log_book.debug][[DEBUG]][log_book.colon]:  ")),
244        Trace => unreachable!("Trace is not meant to be useable"),
245    };
246
247    builder.push(txt!("[log_book.bracket][] {}", rec.text().clone(),));
248
249    if show_source {
250        builder.push(txt!(
251            "{Spacer}([log_book.location]{}[log_book.bracket])",
252            rec.location()
253        ));
254    }
255
256    builder.push('\n');
257
258    Some(builder.build())
259}