duat_base/widgets/
notifications.rs

1//! A [`Widget`] that shows notifications
2//!
3//! This is a very simple [`Widget`], and will usually be placed right
4//! under a [`PromptLine`], which, when the `"HidePromptLine"` [hook]
5//! group exists, will be hidden when the [`PromptLine`] is not in
6//! focus, allowing for the [`Notifications`] widget to pop up.
7//!
8//! [`PromptLine`]: super::PromptLine
9//! [hook]: hooks
10use std::sync::{
11    Mutex, Once,
12    atomic::{AtomicBool, Ordering},
13};
14
15use duat_core::{
16    context::{self, Handle, Level, Record},
17    data::Pass,
18    hook::{self, KeySent},
19    text::{Text, TextMut},
20    ui::{PushSpecs, PushTarget, Side, Widget},
21};
22
23/// A [`Widget`] to show notifications
24///
25/// You can style modify it using the [`opts::set_notifs`] function in
26/// Duat:
27///
28/// ```rust
29/// # duat_core::doc_duat!(duat);
30/// # use duat_base::widgets::{Notifications, FooterWidgets};
31/// # mod opts {
32/// #     pub fn set_notifs(set_fn: impl FnMut(&mut duat_base::widgets::NotificationsOpts)) {}
33/// # }
34/// setup_duat!(setup);
35/// use duat::prelude::*;
36///
37/// fn setup() {
38///     opts::set_notifs(|opts| {
39///         opts.fmt(|rec| {
40///             txt!(
41///                 "[notifs.bracket]([log_book.location]{}[notifs.bracket]) {}",
42///                 rec.location(),
43///                 rec.text().clone()
44///             )
45///         })
46///     });
47/// }
48/// ```
49///
50/// [`FooterWidgets`]: super::FooterWidgets
51/// [`PromptLine`]: super::PromptLine
52/// [`StatusLine`]: super::StatusLine
53/// [hook]: duat_core::hook
54/// [`opts::set_notifs`]: https://docs.rs/duat/latest/duat/opts/fn.set_notifs.html
55pub struct Notifications {
56    logs: context::Logs,
57    text: Text,
58    fmt: Option<Box<dyn FnMut(Record) -> Text + Send>>,
59    levels: Vec<Level>,
60    last_rec: Option<usize>,
61    get_mask: Option<Box<dyn FnMut(Record) -> &'static str + Send>>,
62    request_width: bool,
63}
64
65static CLEAR_NOTIFS: AtomicBool = AtomicBool::new(false);
66#[allow(clippy::type_complexity)]
67static GLOBAL_FMT: Mutex<Option<Box<dyn FnMut(Record) -> Text + Send>>> = Mutex::new(None);
68#[allow(clippy::type_complexity)]
69static GLOBAL_GET_MASK: Mutex<Option<Box<dyn FnMut(Record) -> &'static str + Send>>> =
70    Mutex::new(None);
71
72impl Notifications {
73    /// Returns a [`NotificationsOpts`], which can be used to push
74    /// `Notifications` around
75    pub fn builder() -> NotificationsOpts {
76        static ONCE: Once = Once::new();
77        ONCE.call_once(|| {
78            hook::add::<KeySent>(|_, _| CLEAR_NOTIFS.store(true, Ordering::Relaxed));
79        });
80        NotificationsOpts::default()
81    }
82}
83
84impl Widget for Notifications {
85    fn update(pa: &mut Pass, handle: &Handle<Self>) {
86        let clear_notifs = CLEAR_NOTIFS.swap(false, Ordering::Relaxed);
87        let notifs = handle.write(pa);
88
89        if notifs.logs.has_changed()
90            && let Some((i, rec)) = notifs.logs.last_with_levels(&notifs.levels)
91            && notifs.last_rec.is_none_or(|last_i| last_i < i)
92        {
93            let mut global_fmt = GLOBAL_FMT.lock().unwrap();
94            let mut global_get_mask = GLOBAL_GET_MASK.lock().unwrap();
95
96            handle.set_mask(if let Some(get_mask) = notifs.get_mask.as_mut() {
97                get_mask(rec.clone())
98            } else if let Some(get_mask) = global_get_mask.as_mut() {
99                get_mask(rec.clone())
100            } else {
101                default_get_mask(rec.clone())
102            });
103
104            notifs.text = if let Some(fmt) = notifs.fmt.as_mut() {
105                fmt(rec)
106            } else if let Some(fmt) = global_fmt.as_mut() {
107                fmt(rec)
108            } else {
109                default_fmt(rec)
110            };
111            notifs.last_rec = Some(i);
112
113            if notifs.request_width {
114                let notifs = handle.read(pa);
115                let size = handle
116                    .area()
117                    .size_of_text(pa, notifs.get_print_opts(), &notifs.text)
118                    .unwrap();
119                handle.area().set_width(pa, size.x).unwrap();
120                handle.area().set_height(pa, size.y).unwrap();
121            }
122        } else if clear_notifs {
123            handle.set_mask("");
124            if notifs.text != Text::new() {
125                notifs.text = Text::new();
126
127                if notifs.request_width {
128                    let notifs = handle.read(pa);
129                    let size = handle
130                        .area()
131                        .size_of_text(pa, notifs.get_print_opts(), &notifs.text)
132                        .unwrap();
133                    handle.area().set_width(pa, size.x).unwrap();
134                    handle.area().set_height(pa, size.y).unwrap();
135                }
136            }
137        }
138    }
139
140    fn text(&self) -> &Text {
141        &self.text
142    }
143
144    fn text_mut(&mut self) -> TextMut<'_> {
145        self.text.as_mut()
146    }
147
148    fn needs_update(&self, _: &Pass) -> bool {
149        self.logs.has_changed() || CLEAR_NOTIFS.load(Ordering::Relaxed)
150    }
151}
152
153/// A builder for the [`Notifications`] [`Widget`]
154///
155/// Normally, this `Widget` is placed alongside others in the
156/// [`FooterWidgets`] `Widget` group.
157///
158/// You can create it separately with [`Notifications::builder`],
159/// which will return this struct.
160///
161/// [`PromptLine`]: super::PromptLine
162/// [hook]: hook
163/// [`FooterWidgets`]: super::FooterWidgets
164#[doc(hidden)]
165#[derive(Clone)]
166pub struct NotificationsOpts {
167    allowed_levels: Vec<Level>,
168    request_width: bool,
169}
170
171impl NotificationsOpts {
172    /// Pushes the [`Notifications`] to another [`Widget`]
173    pub fn push_on(self, pa: &mut Pass, push_target: &impl PushTarget) -> Handle<Notifications> {
174        let notifications = Notifications {
175            logs: context::logs(),
176            text: Text::new(),
177            fmt: None,
178            get_mask: None,
179            levels: self.allowed_levels,
180            last_rec: None,
181            request_width: self.request_width,
182        };
183        let specs = PushSpecs {
184            side: Side::Below,
185            height: Some(1.0),
186            ..Default::default()
187        };
188
189        push_target.push_inner(pa, notifications, specs)
190    }
191
192    /// Changes the way [`Record`]s are formatted by [`Notifications`]
193    ///
194    /// This will be applied to every single [`Level`] of a
195    /// [`Record`]. If you wish to limit which levels will get shown,
196    /// see [`set_allowed_levels`]
197    ///
198    /// [`set_allowed_levels`]: Self::set_allowed_levels
199    pub fn fmt(&mut self, fmt: impl FnMut(Record) -> Text + Send + 'static) {
200        *GLOBAL_FMT.lock().unwrap() = Some(Box::new(fmt));
201    }
202
203    /// Changes how [`Notifications`] decides which [mask] to use
204    ///
205    /// [mask]: duat_core::context::Handle::set_mask
206    pub fn set_mask(&mut self, get_mask: impl FnMut(Record) -> &'static str + Send + 'static) {
207        *GLOBAL_GET_MASK.lock().unwrap() = Some(Box::new(get_mask));
208    }
209
210    /// Filters which [`Level`]s willl show notifications
211    ///
212    /// Is [`Level::Info`], [`Level::Warn`] and [`Level::Error`] by
213    /// default.
214    pub fn set_allowed_levels(&mut self, levels: impl IntoIterator<Item = Level>) {
215        self.allowed_levels = levels.into_iter().collect();
216    }
217
218    /// Requests the width when printing to the screen
219    pub(crate) fn request_width(&mut self) {
220        self.request_width = true;
221    }
222}
223
224impl Default for NotificationsOpts {
225    fn default() -> Self {
226        Self {
227            allowed_levels: vec![Level::Error, Level::Warn, Level::Info],
228            request_width: false,
229        }
230    }
231}
232
233fn default_fmt(rec: Record) -> Text {
234    match rec.level() {
235        Level::Error | Level::Warn | Level::Debug => rec.text().clone(),
236        Level::Info => rec.text().clone(),
237        Level::Trace => unreachable!(),
238    }
239}
240fn default_get_mask(rec: Record) -> &'static str {
241    match rec.level() {
242        context::Level::Error => "error",
243        context::Level::Warn => "warn",
244        context::Level::Info => "info",
245        context::Level::Debug => "debug",
246        context::Level::Trace => unreachable!(),
247    }
248}