Skip to main content

duat_base/widgets/
logbook.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::{path::Path, sync::Mutex};
11
12use duat_core::{
13    Ns, cmd,
14    context::{self, Handle, Location, Record},
15    data::Pass,
16    hook::{self, FocusedOn, MsgLogged, OnMouseEvent, UnfocusedFrom},
17    mode::{self, MouseButton, TwoPointsPlace},
18    opts::PrintOpts,
19    text::{Point, Spawn, Text, TextMut, txt},
20    ui::{DynSpawnSpecs, Orientation, PushSpecs, PushTarget, Side, Widget},
21};
22
23use crate::widgets::Info;
24
25pub fn add_logbook_hooks() {
26    use duat_core::mode::MouseEventKind::*;
27    hook::add::<MsgLogged>(|pa, rec| {
28        let Some(logbook) = context::handle_of::<LogBook>(pa) else {
29            return;
30        };
31
32        let (lb, area) = logbook.write_with_area(pa);
33
34        let mut fmt_rec = |fmt: &mut dyn FnMut(Record) -> Option<Text>| {
35            if let Some(rec_text) = fmt(rec.clone()) {
36                lb.text.insert_text(lb.text.len(), &rec_text);
37                lb.location_ranges
38                    .push((lb.text.last_point(), rec.location()));
39            }
40        };
41
42        let mut global_fmt = GLOBAL_FMT.lock().unwrap();
43
44        if let Some(fmt) = lb.fmt.as_mut() {
45            fmt_rec(fmt);
46        } else if let Some(fmt) = global_fmt.as_mut() {
47            fmt_rec(fmt);
48        } else {
49            fmt_rec(&mut default_fmt);
50        }
51
52        area.scroll_ver(&lb.text, i32::MAX, lb.print_opts());
53    });
54
55    hook::add::<FocusedOn<LogBook>>(|pa, (_, logbook)| logbook.area().reveal(pa).unwrap());
56
57    hook::add::<UnfocusedFrom<LogBook>>(|pa, (logbook, _)| {
58        if logbook.read(pa).close_on_unfocus {
59            logbook.area().hide(pa).unwrap()
60        }
61    });
62
63    let location_ns = Ns::new();
64
65    hook::add::<OnMouseEvent<LogBook>>(move |pa, event| match event.kind {
66        ScrollDown | ScrollUp => {
67            let (lb, area) = event.handle.write_with_area(pa);
68            let scroll = if let ScrollDown = event.kind { 3 } else { -3 };
69            area.scroll_ver(&lb.text, scroll, lb.print_opts());
70        }
71        Moved => {
72            let Some(TwoPointsPlace::Within(points)) = event.points else {
73                return;
74            };
75
76            let lb = event.handle.write(pa);
77            let (Ok(i) | Err(i)) = lb
78                .location_ranges
79                .binary_search_by(|(end, _)| end.cmp(&points.real));
80
81            if let Some((_, location)) = lb.location_ranges.get(i) {
82                let spawn = Spawn::new(
83                    Info::new(txt!("[log_book.location]{location}")),
84                    DynSpawnSpecs {
85                        orientation: Orientation::VerLeftBelow,
86                        ..DynSpawnSpecs::default()
87                    },
88                );
89                lb.text.insert_tag(location_ns, points.real, spawn);
90            }
91        }
92        Down(MouseButton::Left) => {
93            let Some(TwoPointsPlace::Within(points)) = event.points else {
94                return;
95            };
96
97            let lb = event.handle.read(pa);
98            let (Ok(i) | Err(i)) = lb
99                .location_ranges
100                .binary_search_by(|(end, _)| end.cmp(&points.real));
101
102            if let Some((_, location)) = lb.location_ranges.get(i).cloned()
103                && cmd::call(pa, format!("edit {}", location.file())).is_ok()
104            {
105                let buffer = context::get_buffer_by_path(pa, Path::new(location.file())).unwrap();
106                mode::reset_to(pa, &buffer);
107                buffer.selections_mut(pa).remove_extras();
108                buffer.edit_main(pa, |mut c| {
109                    c.move_to_coords(location.line() - 1, location.column() - 1);
110                });
111            }
112        }
113        _ => {}
114    });
115
116    hook::add::<OnMouseEvent>(move |pa, _| {
117        for logbook in context::windows().handles_of::<LogBook>(pa) {
118            logbook.text_mut(pa).remove_tags(location_ns, ..);
119        }
120    });
121}
122
123#[allow(clippy::type_complexity)]
124static GLOBAL_FMT: Mutex<Option<Box<dyn FnMut(Record) -> Option<Text> + Send>>> = Mutex::new(None);
125
126/// A [`Widget`] to display [`Logs`] sent to Duat
127///
128/// [`Logs`]: duat_core::context::Logs
129pub struct LogBook {
130    text: Text,
131    location_ranges: Vec<(Point, Location)>,
132    fmt: Option<Box<dyn FnMut(Record) -> Option<Text> + Send>>,
133    /// Wether to close this [`Widget`] after unfocusing, `true` by
134    /// default
135    pub close_on_unfocus: bool,
136}
137
138impl LogBook {
139    /// Reformats this `LogBook`
140    pub fn fmt(&mut self, fmt: impl FnMut(Record) -> Option<Text> + Send + 'static) {
141        self.fmt = Some(Box::new(fmt))
142    }
143
144    /// Returns a [`LogBookOpts`], so you can push `LogBook`s around
145    pub fn builder() -> LogBookOpts {
146        LogBookOpts::default()
147    }
148}
149
150impl Widget for LogBook {
151    fn text(&self) -> &Text {
152        &self.text
153    }
154
155    fn text_mut(&mut self) -> TextMut<'_> {
156        self.text.as_mut()
157    }
158
159    fn print_opts(&self) -> PrintOpts {
160        let mut opts = PrintOpts::new();
161        opts.wrap_lines = true;
162        opts.wrap_on_word = true;
163        opts
164    }
165}
166
167/// Configuration for the [`LogBook`]
168#[derive(Clone, Copy)]
169pub struct LogBookOpts {
170    /// Wether to close the `LogBook` when unfocusing
171    pub close_on_unfocus: bool,
172    /// Wether to hide the `LogBook` by default
173    pub hidden: bool,
174    /// To which side to push the [`LogBook`] to
175    pub side: Side,
176    /// Requested height for the [`LogBook`], ignored if pushing
177    /// horizontally
178    pub height: f32,
179    /// Requested width for the [`LogBook`], ignored if pushing
180    /// vertically
181    pub width: f32,
182    /// Wether the source of a log should be shown
183    ///
184    /// Can be disabled for less noise. This option is ignored when
185    /// there is [custom formatting].
186    ///
187    /// [custom formatting]: Self::fmt
188    pub show_source: bool,
189}
190
191impl LogBookOpts {
192    /// Push a [`LogBook`] around the given [`PushTarget`]
193    pub fn push_on(self, pa: &mut Pass, push_target: &impl PushTarget) -> Handle<LogBook> {
194        let logs = context::logs();
195        let records = logs.get(..).unwrap();
196
197        let mut global_fmt = GLOBAL_FMT.lock().unwrap();
198        let mut text = Text::new();
199        let mut path_ranges = Vec::new();
200
201        let fmt_recs = |fmt: &mut dyn FnMut(Record) -> Option<Text>| {
202            for rec in records.into_iter() {
203                if let Some(rec_text) = fmt(rec.clone()) {
204                    text.insert_text(text.len(), &rec_text);
205                    path_ranges.push((text.last_point(), rec.location()));
206                }
207            }
208        };
209
210        if let Some(fmt) = global_fmt.as_mut() {
211            fmt_recs(fmt);
212        } else {
213            fmt_recs(&mut default_fmt);
214        }
215
216        let log_book = LogBook {
217            text,
218            location_ranges: path_ranges,
219            fmt: None,
220            close_on_unfocus: self.close_on_unfocus,
221        };
222
223        let specs = match self.side {
224            Side::Right | Side::Left => PushSpecs {
225                side: self.side,
226                width: Some(self.width),
227                hidden: self.hidden,
228                cluster: false,
229                ..Default::default()
230            },
231            Side::Above | Side::Below => PushSpecs {
232                side: self.side,
233                height: Some(self.height),
234                hidden: self.hidden,
235                cluster: false,
236                ..Default::default()
237            },
238        };
239
240        push_target.push_outer(pa, log_book, specs)
241    }
242
243    /// Changes the way [`Record`]s are formatted by the [`LogBook`]
244    ///
245    /// This function returns an [`Option<Text>`], which means you can
246    /// filter out unnecessary [`Record`]s. By default, all valid
247    /// [`Record`]s (those with level [`Debug`] or higher.
248    ///
249    /// [`Debug`]: context::Level::Debug
250    pub fn fmt(&mut self, fmt: impl FnMut(Record) -> Option<Text> + Send + 'static) {
251        *GLOBAL_FMT.lock().unwrap() = Some(Box::new(fmt));
252    }
253}
254
255impl Default for LogBookOpts {
256    fn default() -> Self {
257        Self {
258            close_on_unfocus: true,
259            hidden: true,
260            side: Side::Below,
261            height: 8.0,
262            width: 50.0,
263            show_source: true,
264        }
265    }
266}
267
268fn default_fmt(rec: Record) -> Option<Text> {
269    use duat_core::context::Level::*;
270    let mut builder = Text::builder();
271
272    match rec.level() {
273        Error => builder.push(txt!("[log_book.error][[ERROR]][log_book.colon]:  ")),
274        Warn => builder.push(txt!("[log_book.warn][[WARNING]][log_book.colon]:")),
275        Info => builder.push(txt!("[log_book.info][[INFO]][log_book.colon]:   ")),
276        Debug => builder.push(txt!("[log_book.debug][[DEBUG]][log_book.colon]:  ")),
277        Trace => unreachable!("Trace is not meant to be useable"),
278    };
279
280    builder.push(txt!(" {}", rec.text().clone(),));
281
282    builder.push('\n');
283
284    Some(builder.build())
285}