Skip to main content

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