use iced_widget::button::{Status as ButtonStatus, Style as ButtonStyle};
use iced_widget::checkbox::{Status as CheckboxStatus, Style as CheckboxStyle};
use iced_widget::core::svg as core_svg;
use iced_widget::core::text as core_text;
use iced_widget::core::time::Instant;
use iced_widget::core::widget;
use iced_widget::core::{
Background, Border, Element, Font, Length, Padding, Shadow, alignment, border,
};
use iced_widget::graphics::geometry;
use iced_widget::renderer::wgpu::primitive;
use iced_widget::scrollable;
use iced_widget::text::{self, LineHeight};
use iced_widget::{Column, Container, Row, Scrollable, Stack, Text, opaque};
use super::app_bar;
use super::button::Button;
use super::reveal::{RevealAnimation, RevealFrame};
use super::support::alpha_color;
use super::viewport::Viewport;
use crate::style::{button as button_style, checkbox as checkbox_style};
use crate::{Theme, fonts, text as text_style, tokens};
const LOG_SCROLL_ANCHOR: scrollable::Anchor = scrollable::Anchor::Start;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
impl LogLevel {
pub const fn label(self) -> &'static str {
match self {
Self::Trace => "TRACE",
Self::Debug => "DEBUG",
Self::Info => "INFO",
Self::Warn => "WARN",
Self::Error => "ERROR",
}
}
}
impl std::fmt::Display for LogLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogEntry<Id> {
id: Id,
level: LogLevel,
line: String,
}
impl<Id> LogEntry<Id> {
pub fn new(id: Id, level: LogLevel, message: impl Into<String>) -> Self {
let message = message.into();
Self {
id,
level,
line: format!("{level}{message}"),
}
}
pub const fn id(&self) -> &Id {
&self.id
}
pub const fn level(&self) -> LogLevel {
self.level
}
pub fn message(&self) -> &str {
&self.line[self.level.label().len()..]
}
pub fn line(&self) -> &str {
&self.line
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action<Id> {
Toggle(Id),
CloseSelection,
CopySelection,
}
#[derive(Debug)]
pub struct State<Id> {
selected: Vec<Id>,
scrollable_id: widget::Id,
selection_bar: RevealAnimation,
selection_bar_count: usize,
}
impl<Id> Default for State<Id> {
fn default() -> Self {
Self::new()
}
}
impl<Id> State<Id> {
pub fn new() -> Self {
Self {
selected: Vec::new(),
scrollable_id: widget::Id::unique(),
selection_bar: RevealAnimation::closed(),
selection_bar_count: 0,
}
}
pub fn selected_ids(&self) -> &[Id] {
&self.selected
}
pub fn clear_selection(&mut self) {
self.clear_selection_at(Instant::now());
}
pub fn advance(&mut self, now: Instant) -> bool {
let animating = self.selection_bar.advance(now);
if !self.selection_bar.is_visible() && self.selected.is_empty() {
self.selection_bar_count = 0;
}
animating
}
pub fn is_animating(&self) -> bool {
self.selection_bar.is_animating()
}
pub fn selection_bar_progress(&self) -> f32 {
self.selection_bar.frame().reveal
}
fn clear_selection_at(&mut self, now: Instant) {
self.selected.clear();
self.sync_selection_bar(now);
}
fn sync_selection_bar(&mut self, now: Instant) {
let count = self.selected.len();
if count > 0 {
self.selection_bar_count = count;
self.selection_bar.open(now);
} else {
self.selection_bar.close(now);
}
}
}
impl<Id: Eq> State<Id> {
pub fn is_selected(&self, id: &Id) -> bool {
self.selected.iter().any(|selected| selected == id)
}
pub fn toggle(&mut self, id: Id) -> bool {
self.toggle_at(id, Instant::now())
}
fn toggle_at(&mut self, id: Id, now: Instant) -> bool {
if let Some(index) = self.selected.iter().position(|selected| selected == &id) {
let _ = self.selected.remove(index);
self.sync_selection_bar(now);
false
} else {
self.selected.push(id);
self.sync_selection_bar(now);
true
}
}
pub fn retain_entries(&mut self, entries: &[LogEntry<Id>]) {
self.selected
.retain(|selected| entries.iter().any(|entry| entry.id() == selected));
self.sync_selection_bar(Instant::now());
}
pub fn selected_count(&self, entries: &[LogEntry<Id>]) -> usize {
entries
.iter()
.filter(|entry| self.is_selected(entry.id()))
.count()
}
pub fn selected_text(&self, entries: &[LogEntry<Id>]) -> String {
entries
.iter()
.filter(|entry| self.is_selected(entry.id()))
.map(LogEntry::line)
.collect::<Vec<_>>()
.join("\n")
}
pub fn update<Message>(
&mut self,
action: Action<Id>,
entries: &[LogEntry<Id>],
) -> iced::Task<Message> {
match action {
Action::Toggle(id) => {
let _ = self.toggle(id);
iced::Task::none()
}
Action::CloseSelection => {
self.clear_selection();
iced::Task::none()
}
Action::CopySelection => {
let selected = self.selected_text(entries);
if selected.is_empty() {
iced::Task::none()
} else {
iced::clipboard::write(selected)
}
}
}
}
}
pub fn view<'a, Id, Message, Renderer>(
entries: &'a [LogEntry<Id>],
state: &'a State<Id>,
on_action: impl Fn(Action<Id>) -> Message,
) -> Column<'a, Message, Theme, Renderer>
where
Id: Clone + Eq + 'a,
Message: Clone + 'a,
Renderer: iced_widget::core::Renderer
+ geometry::Renderer
+ primitive::Renderer
+ core_text::Renderer
+ core_svg::Renderer
+ 'a,
Font: Into<Renderer::Font>,
{
let selected_count = state.selected_count(entries);
let items = Container::new(
Column::with_children(
entries
.iter()
.map(|entry| item(entry, state, on_action(Action::Toggle(entry.id().clone())))),
)
.spacing(tokens::component::log_viewer::ITEM_SPACING)
.width(Length::Fill),
)
.padding(Padding {
top: 0.0,
right: tokens::component::log_viewer::LIST_HORIZONTAL_SPACE,
bottom: 0.0,
left: tokens::component::log_viewer::LIST_HORIZONTAL_SPACE,
})
.width(Length::Fill);
let logs: Element<'a, Message, Theme, Renderer> = Scrollable::new(items)
.id(state.scrollable_id.clone())
.anchor_y(LOG_SCROLL_ANCHOR)
.width(Length::Fill)
.height(Length::Fill)
.into();
let frame = state.selection_bar.frame();
let mut layers = Stack::new()
.push(logs)
.width(Length::Fill)
.height(Length::Fill);
if selected_count > 0 || state.selection_bar.is_visible() {
let count = if selected_count > 0 {
selected_count
} else {
state.selection_bar_count
};
let bar = selection_bar(
count,
frame.alpha,
on_action(Action::CloseSelection),
on_action(Action::CopySelection),
);
let bar = Viewport::fixed_height(
bar,
selection_bar_visible_height(frame),
tokens::component::log_viewer::SELECTION_BAR_HEIGHT,
)
.width(Length::Fill);
layers = layers.push(opaque(bar));
}
Column::new()
.push(layers)
.width(Length::Fill)
.height(Length::Fill)
}
fn selection_bar<'a, Message, Renderer>(
selected_count: usize,
alpha: f32,
close: Message,
copy: Message,
) -> Container<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Renderer: iced_widget::core::Renderer
+ geometry::Renderer
+ primitive::Renderer
+ core_text::Renderer
+ 'a,
Font: Into<Renderer::Font>,
{
let alpha = alpha.clamp(0.0, 1.0);
let title_text = tokens::component::app_bar::SMALL_TITLE_TEXT;
let close = selection_icon_button("close", close, alpha);
let copy = selection_icon_button("content_copy", copy, alpha);
let content = Row::new()
.push(close)
.push(
Text::new(format!("{selected_count} selected"))
.size(title_text.size)
.line_height(LineHeight::Absolute(title_text.line_height.into()))
.width(Length::Fill)
.style(move |theme: &Theme| iced_widget::text::Style {
color: Some(alpha_color(theme.colors().surface.text, alpha)),
}),
)
.push(copy)
.spacing(tokens::component::app_bar::ICON_BUTTON_SPACE)
.padding(Padding {
top: 0.0,
right: tokens::component::app_bar::TRAILING_SPACE,
bottom: 0.0,
left: tokens::component::app_bar::LEADING_SPACE,
})
.align_y(alignment::Vertical::Center)
.width(Length::Fill);
Container::new(content)
.width(Length::Fill)
.height(Length::Fixed(
tokens::component::log_viewer::SELECTION_BAR_HEIGHT,
))
.align_y(alignment::Vertical::Center)
.style(move |theme| selection_bar_style(theme, alpha))
}
fn selection_bar_visible_height(frame: RevealFrame) -> f32 {
tokens::component::log_viewer::SELECTION_BAR_HEIGHT * frame.reveal.clamp(0.0, 1.0)
}
fn selection_icon_button<'a, Message, Renderer>(
icon: &'static str,
on_press: Message,
progress: f32,
) -> Element<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
Font: Into<Renderer::Font>,
{
app_bar::icon_button(icon)
.style(move |theme, status| {
let mut style = button_style::icon(theme, status);
style.text_color = alpha_color(style.text_color, progress);
style.background = style.background.map(|background| match background {
Background::Color(color) => Background::Color(alpha_color(color, progress)),
Background::Gradient(gradient) => Background::Gradient(gradient),
});
style
})
.on_press(on_press)
.into()
}
fn selection_bar_style(theme: &Theme, progress: f32) -> iced_widget::container::Style {
let colors = theme.colors();
iced_widget::container::Style {
background: Some(Background::Color(alpha_color(
colors.surface.container.base,
progress,
))),
text_color: Some(alpha_color(colors.surface.text, progress)),
border: border::rounded(tokens::component::app_bar::CONTAINER_SHAPE),
snap: cfg!(feature = "crisp"),
..iced_widget::container::Style::default()
}
}
fn item<'a, Id, Message, Renderer>(
entry: &'a LogEntry<Id>,
state: &State<Id>,
toggle: Message,
) -> Element<'a, Message, Theme, Renderer>
where
Id: Eq,
Message: Clone + 'a,
Renderer: iced_widget::core::Renderer
+ geometry::Renderer
+ primitive::Renderer
+ core_text::Renderer
+ core_svg::Renderer
+ 'a,
Font: Into<Renderer::Font>,
{
let selected = state.is_selected(entry.id());
let checkbox = super::checkbox::control(selected).style(checkbox_visual_style);
let checkbox_slot = Container::new(checkbox)
.width(Length::Fixed(
tokens::component::log_viewer::CHECKBOX_SLOT_WIDTH,
))
.height(Length::Fixed(
tokens::component::log_viewer::ITEM_MIN_HEIGHT,
))
.align_x(alignment::Horizontal::Center)
.align_y(alignment::Vertical::Center);
let scale = tokens::component::log_viewer::LOG_TEXT;
let line = Text::new(entry.line())
.size(scale.size)
.line_height(LineHeight::Absolute(scale.line_height.into()))
.font(log_text_font())
.wrapping(text::Wrapping::WordOrGlyph)
.width(Length::Fill)
.style(text_style::surface);
let level = entry.level();
let colored_level = Text::new(level.label())
.size(scale.size)
.line_height(LineHeight::Absolute(scale.line_height.into()))
.font(log_text_font())
.style(move |theme| level_text_style(theme, level));
let log_text = Stack::new()
.push(line)
.push(colored_level)
.width(Length::Fill);
let text = Container::new(log_text)
.width(Length::Fill)
.padding(Padding {
top: tokens::component::log_viewer::ITEM_VERTICAL_SPACE,
right: tokens::component::log_viewer::ITEM_TRAILING_SPACE,
bottom: tokens::component::log_viewer::ITEM_VERTICAL_SPACE,
left: 0.0,
});
let row_button = Button::new(
Row::new()
.push(checkbox_slot)
.push(text)
.align_y(alignment::Vertical::Center)
.width(Length::Fill),
)
.width(Length::Fill)
.padding(Padding::ZERO)
.style(item_button_style)
.on_press(toggle);
Container::new(row_button)
.width(Length::Fill)
.style(move |theme| item_container_style(theme, selected))
.into()
}
fn log_text_font() -> Font {
fonts::roboto_for_type_scale(tokens::component::log_viewer::LOG_TEXT)
}
fn checkbox_visual_style(theme: &Theme, status: CheckboxStatus) -> CheckboxStyle {
let is_checked = match status {
CheckboxStatus::Active { is_checked }
| CheckboxStatus::Hovered { is_checked }
| CheckboxStatus::Disabled { is_checked } => is_checked,
};
checkbox_style::default(theme, CheckboxStatus::Active { is_checked })
}
fn item_button_style(theme: &Theme, _status: ButtonStatus) -> ButtonStyle {
ButtonStyle {
background: None,
text_color: theme.colors().surface.text,
border: border::rounded(tokens::component::log_viewer::ITEM_SHAPE),
shadow: Shadow::default(),
snap: cfg!(feature = "crisp"),
}
}
fn item_container_style(theme: &Theme, selected: bool) -> iced_widget::container::Style {
let colors = theme.colors();
iced_widget::container::Style {
background: Some(Background::Color(colors.surface.container.low)),
text_color: Some(colors.surface.text),
border: if selected {
Border {
color: colors.outline.color,
width: tokens::component::log_viewer::SELECTED_OUTLINE_WIDTH,
radius: tokens::component::log_viewer::ITEM_SHAPE.into(),
}
} else {
border::rounded(tokens::component::log_viewer::ITEM_SHAPE)
},
snap: cfg!(feature = "crisp"),
..iced_widget::container::Style::default()
}
}
fn level_text_style(theme: &Theme, level: LogLevel) -> iced_widget::text::Style {
let colors = theme.colors();
let color = match level {
LogLevel::Trace => colors.outline.color,
LogLevel::Debug => colors.secondary.color,
LogLevel::Info => colors.primary.color,
LogLevel::Warn => colors.tertiary.color,
LogLevel::Error => colors.error.color,
};
iced_widget::text::Style { color: Some(color) }
}
#[cfg(test)]
#[path = "../../../tests/widget/component/log_viewer.rs"]
mod tests;