1use 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#[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 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#[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 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 pub const fn id(&self) -> &Id {
82 &self.id
83 }
84
85 pub const fn level(&self) -> LogLevel {
87 self.level
88 }
89
90 pub fn message(&self) -> &str {
92 &self.line[self.level.label().len()..]
93 }
94
95 pub fn line(&self) -> &str {
97 &self.line
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum Action<Id> {
104 Toggle(Id),
106 CloseSelection,
108 CopySelection,
110}
111
112#[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 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 pub fn selected_ids(&self) -> &[Id] {
140 &self.selected
141 }
142
143 pub fn clear_selection(&mut self) {
145 self.clear_selection_at(Instant::now());
146 }
147
148 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 pub fn is_animating(&self) -> bool {
163 self.selection_bar.is_animating()
164 }
165
166 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 pub fn is_selected(&self, id: &Id) -> bool {
191 self.selected.iter().any(|selected| selected == id)
192 }
193
194 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 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 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 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 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
264pub 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;