Skip to main content

material_ui_rs/widget/component/
log_viewer.rs

1//! Selectable, copyable log viewer building blocks.
2
3use iced_widget::button::{Status as ButtonStatus, Style as ButtonStyle};
4use iced_widget::checkbox::{Status as CheckboxStatus, Style as CheckboxStyle};
5use iced_widget::core::svg as core_svg;
6use iced_widget::core::text as core_text;
7use iced_widget::core::time::Instant;
8use iced_widget::core::widget;
9use iced_widget::core::{
10    Background, Border, Element, Font, Length, Padding, Shadow, alignment, border,
11};
12use iced_widget::graphics::geometry;
13use iced_widget::renderer::wgpu::primitive;
14use iced_widget::scrollable;
15use iced_widget::text::{self, LineHeight};
16use iced_widget::{Column, Container, Row, Scrollable, Stack, Text, opaque};
17
18use super::app_bar;
19use super::button::Button;
20use super::reveal::{RevealAnimation, RevealFrame};
21use super::support::alpha_color;
22use super::viewport::Viewport;
23use crate::style::{button as button_style, checkbox as checkbox_style};
24use crate::{Theme, fonts, text as text_style, tokens};
25
26const LOG_SCROLL_ANCHOR: scrollable::Anchor = scrollable::Anchor::Start;
27
28/// Severity attached to a structured log entry.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum LogLevel {
31    Trace,
32    Debug,
33    Info,
34    Warn,
35    Error,
36}
37
38impl LogLevel {
39    /// Returns the uppercase label rendered and copied for this level.
40    pub const fn label(self) -> &'static str {
41        match self {
42            Self::Trace => "TRACE",
43            Self::Debug => "DEBUG",
44            Self::Info => "INFO",
45            Self::Warn => "WARN",
46            Self::Error => "ERROR",
47        }
48    }
49}
50
51impl std::fmt::Display for LogLevel {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.write_str(self.label())
54    }
55}
56
57/// A structured item displayed by the log viewer.
58///
59/// `message` is appended directly to the level label so callers can preserve
60/// source formatting such as `INFO[0005] ...`.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct LogEntry<Id> {
63    id: Id,
64    level: LogLevel,
65    line: String,
66}
67
68impl<Id> LogEntry<Id> {
69    /// Creates a log entry with a stable caller-provided identifier.
70    pub fn new(id: Id, level: LogLevel, message: impl Into<String>) -> Self {
71        let message = message.into();
72
73        Self {
74            id,
75            level,
76            line: format!("{level}{message}"),
77        }
78    }
79
80    /// Returns the stable identifier used for selection.
81    pub const fn id(&self) -> &Id {
82        &self.id
83    }
84
85    /// Returns the log severity.
86    pub const fn level(&self) -> LogLevel {
87        self.level
88    }
89
90    /// Returns the source-formatted text following the severity label.
91    pub fn message(&self) -> &str {
92        &self.line[self.level.label().len()..]
93    }
94
95    /// Returns the complete line shown by the viewer and written on copy.
96    pub fn line(&self) -> &str {
97        &self.line
98    }
99}
100
101/// User actions emitted by [`view`].
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum Action<Id> {
104    /// Toggles one entry's selected state.
105    Toggle(Id),
106    /// Leaves selection mode and clears all selected entries.
107    CloseSelection,
108    /// Copies all selected entries in their visible order.
109    CopySelection,
110}
111
112/// Selection and scroll identity for a log viewer.
113#[derive(Debug)]
114pub struct State<Id> {
115    selected: Vec<Id>,
116    scrollable_id: widget::Id,
117    selection_bar: RevealAnimation,
118    selection_bar_count: usize,
119}
120
121impl<Id> Default for State<Id> {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127impl<Id> State<Id> {
128    /// Creates an empty viewer state with an independent scroll identity.
129    pub fn new() -> Self {
130        Self {
131            selected: Vec::new(),
132            scrollable_id: widget::Id::unique(),
133            selection_bar: RevealAnimation::closed(),
134            selection_bar_count: 0,
135        }
136    }
137
138    /// Returns the selected identifiers in selection order.
139    pub fn selected_ids(&self) -> &[Id] {
140        &self.selected
141    }
142
143    /// Clears the current selection.
144    pub fn clear_selection(&mut self) {
145        self.clear_selection_at(Instant::now());
146    }
147
148    /// Advances the contextual selection bar animation.
149    ///
150    /// Call this from window frame events while [`Self::is_animating`] is true.
151    pub fn advance(&mut self, now: Instant) -> bool {
152        let animating = self.selection_bar.advance(now);
153
154        if !self.selection_bar.is_visible() && self.selected.is_empty() {
155            self.selection_bar_count = 0;
156        }
157
158        animating
159    }
160
161    /// Returns whether the contextual selection bar is animating.
162    pub fn is_animating(&self) -> bool {
163        self.selection_bar.is_animating()
164    }
165
166    /// Returns the contextual selection bar visibility progress.
167    pub fn selection_bar_progress(&self) -> f32 {
168        self.selection_bar.frame().reveal
169    }
170
171    fn clear_selection_at(&mut self, now: Instant) {
172        self.selected.clear();
173        self.sync_selection_bar(now);
174    }
175
176    fn sync_selection_bar(&mut self, now: Instant) {
177        let count = self.selected.len();
178
179        if count > 0 {
180            self.selection_bar_count = count;
181            self.selection_bar.open(now);
182        } else {
183            self.selection_bar.close(now);
184        }
185    }
186}
187
188impl<Id: Eq> State<Id> {
189    /// Returns whether an identifier is currently selected.
190    pub fn is_selected(&self, id: &Id) -> bool {
191        self.selected.iter().any(|selected| selected == id)
192    }
193
194    /// Toggles an identifier and returns its new selected state.
195    pub fn toggle(&mut self, id: Id) -> bool {
196        self.toggle_at(id, Instant::now())
197    }
198
199    fn toggle_at(&mut self, id: Id, now: Instant) -> bool {
200        if let Some(index) = self.selected.iter().position(|selected| selected == &id) {
201            let _ = self.selected.remove(index);
202            self.sync_selection_bar(now);
203            false
204        } else {
205            self.selected.push(id);
206            self.sync_selection_bar(now);
207            true
208        }
209    }
210
211    /// Drops selections whose entries are no longer present.
212    pub fn retain_entries(&mut self, entries: &[LogEntry<Id>]) {
213        self.selected
214            .retain(|selected| entries.iter().any(|entry| entry.id() == selected));
215        self.sync_selection_bar(Instant::now());
216    }
217
218    /// Counts selected entries that are still present in the supplied list.
219    pub fn selected_count(&self, entries: &[LogEntry<Id>]) -> usize {
220        entries
221            .iter()
222            .filter(|entry| self.is_selected(entry.id()))
223            .count()
224    }
225
226    /// Builds clipboard text in the current visible entry order.
227    pub fn selected_text(&self, entries: &[LogEntry<Id>]) -> String {
228        entries
229            .iter()
230            .filter(|entry| self.is_selected(entry.id()))
231            .map(LogEntry::line)
232            .collect::<Vec<_>>()
233            .join("\n")
234    }
235
236    /// Applies a viewer action, including clipboard writes for copy actions.
237    pub fn update<Message>(
238        &mut self,
239        action: Action<Id>,
240        entries: &[LogEntry<Id>],
241    ) -> iced::Task<Message> {
242        match action {
243            Action::Toggle(id) => {
244                let _ = self.toggle(id);
245                iced::Task::none()
246            }
247            Action::CloseSelection => {
248                self.clear_selection();
249                iced::Task::none()
250            }
251            Action::CopySelection => {
252                let selected = self.selected_text(entries);
253
254                if selected.is_empty() {
255                    iced::Task::none()
256                } else {
257                    iced::clipboard::write(selected)
258                }
259            }
260        }
261    }
262}
263
264/// Builds a selectable log list and its contextual selection app bar.
265///
266/// Page titles are intentionally outside this component. Compose this viewer
267/// below an existing top app bar such as [`super::app_bar::large`].
268/// Advance [`State`] from frame events to animate the contextual bar.
269pub fn view<'a, Id, Message, Renderer>(
270    entries: &'a [LogEntry<Id>],
271    state: &'a State<Id>,
272    on_action: impl Fn(Action<Id>) -> Message,
273) -> Column<'a, Message, Theme, Renderer>
274where
275    Id: Clone + Eq + 'a,
276    Message: Clone + 'a,
277    Renderer: iced_widget::core::Renderer
278        + geometry::Renderer
279        + primitive::Renderer
280        + core_text::Renderer
281        + core_svg::Renderer
282        + 'a,
283    Font: Into<Renderer::Font>,
284{
285    let selected_count = state.selected_count(entries);
286
287    let items = Container::new(
288        Column::with_children(
289            entries
290                .iter()
291                .map(|entry| item(entry, state, on_action(Action::Toggle(entry.id().clone())))),
292        )
293        .spacing(tokens::component::log_viewer::ITEM_SPACING)
294        .width(Length::Fill),
295    )
296    .padding(Padding {
297        top: 0.0,
298        right: tokens::component::log_viewer::LIST_HORIZONTAL_SPACE,
299        bottom: 0.0,
300        left: tokens::component::log_viewer::LIST_HORIZONTAL_SPACE,
301    })
302    .width(Length::Fill);
303
304    let logs: Element<'a, Message, Theme, Renderer> = Scrollable::new(items)
305        .id(state.scrollable_id.clone())
306        .anchor_y(LOG_SCROLL_ANCHOR)
307        .width(Length::Fill)
308        .height(Length::Fill)
309        .into();
310    let frame = state.selection_bar.frame();
311    let mut layers = Stack::new()
312        .push(logs)
313        .width(Length::Fill)
314        .height(Length::Fill);
315
316    if selected_count > 0 || state.selection_bar.is_visible() {
317        let count = if selected_count > 0 {
318            selected_count
319        } else {
320            state.selection_bar_count
321        };
322        let bar = selection_bar(
323            count,
324            frame.alpha,
325            on_action(Action::CloseSelection),
326            on_action(Action::CopySelection),
327        );
328        let bar = Viewport::fixed_height(
329            bar,
330            selection_bar_visible_height(frame),
331            tokens::component::log_viewer::SELECTION_BAR_HEIGHT,
332        )
333        .width(Length::Fill);
334        layers = layers.push(opaque(bar));
335    }
336
337    Column::new()
338        .push(layers)
339        .width(Length::Fill)
340        .height(Length::Fill)
341}
342
343fn selection_bar<'a, Message, Renderer>(
344    selected_count: usize,
345    alpha: f32,
346    close: Message,
347    copy: Message,
348) -> Container<'a, Message, Theme, Renderer>
349where
350    Message: Clone + 'a,
351    Renderer: iced_widget::core::Renderer
352        + geometry::Renderer
353        + primitive::Renderer
354        + core_text::Renderer
355        + 'a,
356    Font: Into<Renderer::Font>,
357{
358    let alpha = alpha.clamp(0.0, 1.0);
359    let title_text = tokens::component::app_bar::SMALL_TITLE_TEXT;
360    let close = selection_icon_button("close", close, alpha);
361    let copy = selection_icon_button("content_copy", copy, alpha);
362    let content = Row::new()
363        .push(close)
364        .push(
365            Text::new(format!("{selected_count} selected"))
366                .size(title_text.size)
367                .line_height(LineHeight::Absolute(title_text.line_height.into()))
368                .width(Length::Fill)
369                .style(move |theme: &Theme| iced_widget::text::Style {
370                    color: Some(alpha_color(theme.colors().surface.text, alpha)),
371                }),
372        )
373        .push(copy)
374        .spacing(tokens::component::app_bar::ICON_BUTTON_SPACE)
375        .padding(Padding {
376            top: 0.0,
377            right: tokens::component::app_bar::TRAILING_SPACE,
378            bottom: 0.0,
379            left: tokens::component::app_bar::LEADING_SPACE,
380        })
381        .align_y(alignment::Vertical::Center)
382        .width(Length::Fill);
383
384    Container::new(content)
385        .width(Length::Fill)
386        .height(Length::Fixed(
387            tokens::component::log_viewer::SELECTION_BAR_HEIGHT,
388        ))
389        .align_y(alignment::Vertical::Center)
390        .style(move |theme| selection_bar_style(theme, alpha))
391}
392
393fn selection_bar_visible_height(frame: RevealFrame) -> f32 {
394    tokens::component::log_viewer::SELECTION_BAR_HEIGHT * frame.reveal.clamp(0.0, 1.0)
395}
396
397fn selection_icon_button<'a, Message, Renderer>(
398    icon: &'static str,
399    on_press: Message,
400    progress: f32,
401) -> Element<'a, Message, Theme, Renderer>
402where
403    Message: Clone + 'a,
404    Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
405    Font: Into<Renderer::Font>,
406{
407    app_bar::icon_button(icon)
408        .style(move |theme, status| {
409            let mut style = button_style::icon(theme, status);
410            style.text_color = alpha_color(style.text_color, progress);
411            style.background = style.background.map(|background| match background {
412                Background::Color(color) => Background::Color(alpha_color(color, progress)),
413                Background::Gradient(gradient) => Background::Gradient(gradient),
414            });
415            style
416        })
417        .on_press(on_press)
418        .into()
419}
420
421fn selection_bar_style(theme: &Theme, progress: f32) -> iced_widget::container::Style {
422    let colors = theme.colors();
423
424    iced_widget::container::Style {
425        background: Some(Background::Color(alpha_color(
426            colors.surface.container.base,
427            progress,
428        ))),
429        text_color: Some(alpha_color(colors.surface.text, progress)),
430        border: border::rounded(tokens::component::app_bar::CONTAINER_SHAPE),
431        snap: cfg!(feature = "crisp"),
432        ..iced_widget::container::Style::default()
433    }
434}
435
436fn item<'a, Id, Message, Renderer>(
437    entry: &'a LogEntry<Id>,
438    state: &State<Id>,
439    toggle: Message,
440) -> Element<'a, Message, Theme, Renderer>
441where
442    Id: Eq,
443    Message: Clone + 'a,
444    Renderer: iced_widget::core::Renderer
445        + geometry::Renderer
446        + primitive::Renderer
447        + core_text::Renderer
448        + core_svg::Renderer
449        + 'a,
450    Font: Into<Renderer::Font>,
451{
452    let selected = state.is_selected(entry.id());
453    let checkbox = super::checkbox::control(selected).style(checkbox_visual_style);
454    let checkbox_slot = Container::new(checkbox)
455        .width(Length::Fixed(
456            tokens::component::log_viewer::CHECKBOX_SLOT_WIDTH,
457        ))
458        .height(Length::Fixed(
459            tokens::component::log_viewer::ITEM_MIN_HEIGHT,
460        ))
461        .align_x(alignment::Horizontal::Center)
462        .align_y(alignment::Vertical::Center);
463
464    let scale = tokens::component::log_viewer::LOG_TEXT;
465    let line = Text::new(entry.line())
466        .size(scale.size)
467        .line_height(LineHeight::Absolute(scale.line_height.into()))
468        .font(log_text_font())
469        .wrapping(text::Wrapping::WordOrGlyph)
470        .width(Length::Fill)
471        .style(text_style::surface);
472    let level = entry.level();
473    let colored_level = Text::new(level.label())
474        .size(scale.size)
475        .line_height(LineHeight::Absolute(scale.line_height.into()))
476        .font(log_text_font())
477        .style(move |theme| level_text_style(theme, level));
478    let log_text = Stack::new()
479        .push(line)
480        .push(colored_level)
481        .width(Length::Fill);
482    let text = Container::new(log_text)
483        .width(Length::Fill)
484        .padding(Padding {
485            top: tokens::component::log_viewer::ITEM_VERTICAL_SPACE,
486            right: tokens::component::log_viewer::ITEM_TRAILING_SPACE,
487            bottom: tokens::component::log_viewer::ITEM_VERTICAL_SPACE,
488            left: 0.0,
489        });
490    let row_button = Button::new(
491        Row::new()
492            .push(checkbox_slot)
493            .push(text)
494            .align_y(alignment::Vertical::Center)
495            .width(Length::Fill),
496    )
497    .width(Length::Fill)
498    .padding(Padding::ZERO)
499    .style(item_button_style)
500    .on_press(toggle);
501
502    Container::new(row_button)
503        .width(Length::Fill)
504        .style(move |theme| item_container_style(theme, selected))
505        .into()
506}
507
508fn log_text_font() -> Font {
509    fonts::roboto_for_type_scale(tokens::component::log_viewer::LOG_TEXT)
510}
511
512fn checkbox_visual_style(theme: &Theme, status: CheckboxStatus) -> CheckboxStyle {
513    let is_checked = match status {
514        CheckboxStatus::Active { is_checked }
515        | CheckboxStatus::Hovered { is_checked }
516        | CheckboxStatus::Disabled { is_checked } => is_checked,
517    };
518
519    checkbox_style::default(theme, CheckboxStatus::Active { is_checked })
520}
521
522fn item_button_style(theme: &Theme, _status: ButtonStatus) -> ButtonStyle {
523    ButtonStyle {
524        background: None,
525        text_color: theme.colors().surface.text,
526        border: border::rounded(tokens::component::log_viewer::ITEM_SHAPE),
527        shadow: Shadow::default(),
528        snap: cfg!(feature = "crisp"),
529    }
530}
531
532fn item_container_style(theme: &Theme, selected: bool) -> iced_widget::container::Style {
533    let colors = theme.colors();
534
535    iced_widget::container::Style {
536        background: Some(Background::Color(colors.surface.container.low)),
537        text_color: Some(colors.surface.text),
538        border: if selected {
539            Border {
540                color: colors.outline.color,
541                width: tokens::component::log_viewer::SELECTED_OUTLINE_WIDTH,
542                radius: tokens::component::log_viewer::ITEM_SHAPE.into(),
543            }
544        } else {
545            border::rounded(tokens::component::log_viewer::ITEM_SHAPE)
546        },
547        snap: cfg!(feature = "crisp"),
548        ..iced_widget::container::Style::default()
549    }
550}
551
552fn level_text_style(theme: &Theme, level: LogLevel) -> iced_widget::text::Style {
553    let colors = theme.colors();
554    let color = match level {
555        LogLevel::Trace => colors.outline.color,
556        LogLevel::Debug => colors.secondary.color,
557        LogLevel::Info => colors.primary.color,
558        LogLevel::Warn => colors.tertiary.color,
559        LogLevel::Error => colors.error.color,
560    };
561
562    iced_widget::text::Style { color: Some(color) }
563}
564
565#[cfg(test)]
566#[path = "../../../tests/widget/component/log_viewer.rs"]
567mod tests;