YrXtals/src/ui/player.rs

843 lines
27 KiB
Rust

use std::ops::RangeInclusive;
use std::path::PathBuf;
use iced_wgpu::core::widget::Id as WidgetId;
use iced_wgpu::core::{Background, Border, Color, Element, Length, Padding, Theme};
use iced_widget::{
button::{self, Status as ButtonStatus},
checkbox, column, container, image, lazy, mouse_area, progress_bar, row, scrollable, shader,
slider, stack, svg, text, Space,
};
use crate::visualizer::{VisualizerProgram, VizParams};
const PLAY_SVG: &[u8] = include_bytes!("../../assets/Play.svg");
const PAUSE_SVG: &[u8] = include_bytes!("../../assets/Pause.svg");
const BSKIP_SVG: &[u8] = include_bytes!("../../assets/BSkip.svg");
const FSKIP_SVG: &[u8] = include_bytes!("../../assets/FSkip.svg");
const SETTINGS_SVG: &[u8] = include_bytes!("../../assets/Settings.svg");
const LOADING_SVG: &str = include_str!("../../assets/Loading.svg");
const LOADING_DEG_PER_SEC: f32 = 120.0;
use crate::library::Track;
use super::app::{App, Message};
use super::theme::palette;
pub const SIDEBAR_W: f32 = 280.0;
pub const TOP_BAR_H: f32 = 44.0;
pub const TRANSPORT_H: f32 = 72.0;
const ROW_H: f32 = 56.0;
const THUMB: f32 = 40.0;
/// stable id of the sidebar scrollable.
pub fn sidebar_scroll_id() -> WidgetId {
WidgetId::new("yrx-sidebar-scroll")
}
/// stable id of the settings-panel scrollable.
pub fn settings_scroll_id() -> WidgetId {
WidgetId::new("yrx-settings-scroll")
}
/// assembles the top bar, sidebar, transport, and visualizer into the active layout.
pub fn view(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let body: Element<'_, Message, Theme, iced_wgpu::Renderer> = if app.immersive {
visualiser(app)
} else {
column![
top_bar(app),
library_progress_strip(app),
row![sidebar(app), visualiser(app)].height(Length::Fill),
transport(app),
]
.into()
};
let body = if app.show_settings {
stack![body, settings_overlay(app)].into()
} else {
body
};
if app.coordinating_message.is_some() {
stack![body, coordinating_overlay(app)].into()
} else {
body
}
}
/// dimmed full-screen card showing the file-coordinator wait message while iOS caches the picked URL.
fn coordinating_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let msg = app.coordinating_message.clone().unwrap_or_default();
let card = container(
container(text(msg).size(16).color(palette::text()))
.padding(24)
.style(panel_style)
.max_width(420.0),
)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(scrim_style);
card.into()
}
fn scrim_style(_t: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.55 })),
..Default::default()
}
}
/// renders a thin progress bar under the top bar during an active library import.
fn library_progress_strip(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
match app.library_progress {
Some((current, total)) if total > 0 => {
let fraction = (current as f32 / total as f32).clamp(0.0, 1.0);
progress_bar(0.0..=1.0, fraction)
.length(Length::Fill)
.girth(Length::Fixed(3.0))
.into()
}
_ => Space::new().height(Length::Fixed(0.0)).into(),
}
}
/// builds the title row plus folder, file, and settings chip buttons.
fn top_bar(_app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let title = text("Yr Xtals").size(16).color(palette::text());
let folder_btn = chip_button("Folder", Message::OpenFolder);
#[cfg(target_os = "ios")]
let file_btn = chip_button("Library", Message::OpenFile);
#[cfg(not(target_os = "ios"))]
let file_btn = chip_button("File", Message::OpenFile);
let settings_btn = icon_chip_button(SETTINGS_SVG, Message::ToggleSettings);
let bar = row![
title,
Space::new().width(Length::Fixed(20.0)),
folder_btn,
Space::new().width(Length::Fixed(8.0)),
file_btn,
Space::new().width(Length::Fill),
settings_btn,
]
.padding(Padding::from([0, 16]))
.spacing(0)
.align_y(iced_wgpu::core::Alignment::Center)
.height(Length::Fill);
container(bar)
.width(Length::Fill)
.height(Length::Fixed(TOP_BAR_H))
.style(panel_style)
.into()
}
/// hash key feeding iced's lazy cache, redrawing only after a meaningful sidebar change.
#[derive(Hash)]
struct SidebarKey {
selected: Option<usize>,
rows: Vec<TrackRowKey>,
}
/// minimal per-row identity used inside the sidebar lazy hash.
#[derive(Hash)]
struct TrackRowKey {
path: PathBuf,
title: String,
artist: Option<String>,
has_art: bool,
}
/// builds the lazy-cached scrollable list of tracks down the left edge.
fn sidebar(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
if app.library.tracks.is_empty() {
return container(empty_library_hint())
.width(Length::Fixed(SIDEBAR_W))
.height(Length::Fill)
.style(sidebar_style)
.into();
}
let key = SidebarKey {
selected: app.selected_track,
rows: app
.library
.tracks
.iter()
.map(|t| TrackRowKey {
path: t.path.clone(),
title: t.title.clone(),
artist: t.artist.clone(),
has_art: t.art.is_some(),
})
.collect(),
};
let tracks: Vec<Track> = app.library.tracks.clone();
let selected = app.selected_track;
let inner = lazy(key, move |_| {
let mut col = column![].spacing(2).padding(Padding::from([8, 8]));
for (i, t) in tracks.iter().enumerate() {
col = col.push(track_row_owned(i, t.clone(), selected == Some(i)));
}
scrollable(col)
.id(sidebar_scroll_id())
.on_scroll(|vp| Message::SidebarScrolled(vp.absolute_offset()))
.height(Length::Fill)
});
container(inner)
.width(Length::Fixed(SIDEBAR_W))
.height(Length::Fill)
.style(sidebar_style)
.into()
}
/// placeholder shown inside the sidebar before any tracks load.
fn empty_library_hint<'a>() -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
let copy = text("No tracks loaded.\n\nUse Folder or File above\nto load some music.")
.size(13)
.color(palette::text_dim());
container(copy)
.width(Length::Fill)
.height(Length::Fill)
.padding(Padding::from(24))
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
}
/// renders a single sidebar row over a cloned, owned Track.
fn track_row_owned(
idx: usize,
track: Track,
active: bool,
) -> Element<'static, Message, Theme, iced_wgpu::Renderer> {
let thumb: Element<'static, Message, Theme, iced_wgpu::Renderer> = match track.art {
Some(handle) => image(handle)
.width(Length::Fixed(THUMB))
.height(Length::Fixed(THUMB))
.into(),
None => container(Space::new())
.width(Length::Fixed(THUMB))
.height(Length::Fixed(THUMB))
.style(art_placeholder_style)
.into(),
};
let title_color = if active { palette::text() } else { palette::text_dim() };
let title_widget = text(track.title)
.size(13)
.color(title_color)
.width(Length::Fill);
let artist_line = match track.artist {
Some(a) => text(a).size(11).color(palette::text_dim()),
None => text(String::new()).size(11).color(palette::text_dim()),
};
let info = column![title_widget, artist_line]
.spacing(2)
.width(Length::Fill);
let inner = row![thumb, info]
.spacing(10)
.align_y(iced_wgpu::core::Alignment::Center)
.padding(Padding::from([6, 8]))
.width(Length::Fill)
.height(Length::Fixed(ROW_H));
let btn = iced_widget::button(inner)
.padding(0)
.on_press(Message::SelectTrack(idx))
.width(Length::Fill)
.style(move |_t: &Theme, status: ButtonStatus| {
let bg = match (active, status) {
(true, _) => Some(Background::Color(Color {
a: 0.20,
..palette::accent()
})),
(false, ButtonStatus::Hovered) => Some(Background::Color(Color {
a: 0.06,
..palette::accent()
})),
_ => None,
};
button::Style {
background: bg,
text_color: palette::text(),
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 6.0.into(),
},
..Default::default()
}
});
btn.into()
}
/// shader-backed visualizer surface stacked under loading and empty-state overlays.
fn visualiser(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let no_track = app.selected_track.is_none();
let viz: Element<'_, Message, Theme, iced_wgpu::Renderer> = shader(
VisualizerProgram::new(
app.frame_data.clone(),
params_from(&app.settings),
app.current_palette.clone(),
),
)
.width(Length::Fill)
.height(Length::Fill)
.into();
let overlay: Element<'_, Message, Theme, iced_wgpu::Renderer> = if app.track_loading {
loading_overlay()
} else if no_track {
centered_overlay_text("Pick a track from the sidebar", palette::text())
} else {
Space::new().width(Length::Fill).height(Length::Fill).into()
};
let layered = stack![viz, overlay];
let bordered = container(layered)
.width(Length::Fill)
.height(Length::Fill)
.style(|_t: &Theme| container::Style {
background: Some(Background::Color(palette::bg())),
border: Border {
color: palette::border(),
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
});
mouse_area(bordered).on_press(Message::ToggleChrome).into()
}
/// animated cog and label overlay shown during track decode.
fn loading_overlay<'a>() -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
let elapsed = START.get_or_init(std::time::Instant::now).elapsed().as_secs_f32();
let angle = elapsed * LOADING_DEG_PER_SEC;
let bytes = loading_svg_at_angle(angle);
let handle = svg::Handle::from_memory(bytes);
let spinner = svg::Svg::new(handle)
.width(Length::Fixed(160.0))
.height(Length::Fixed(160.0));
let label = text("Loading.").size(36).color(palette::text());
let stack = column![spinner, label]
.spacing(12)
.align_x(iced_wgpu::core::Alignment::Center);
container(stack)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
}
/// rewrites the cog and nautilus rotate transforms inside the loading SVG.
fn loading_svg_at_angle(angle_deg: f32) -> Vec<u8> {
let mut s = LOADING_SVG.to_string();
rewrite_rotate_angle(&mut s, "Cog", angle_deg);
rewrite_rotate_angle(&mut s, "Nautilus", -angle_deg);
s.into_bytes()
}
/// patches the angle inside an SVG element's rotate transform without touching surrounding markup.
fn rewrite_rotate_angle(s: &mut String, id: &str, angle_deg: f32) {
let id_marker = format!(r#"id="{}""#, id);
let Some(id_pos) = s.find(&id_marker) else { return };
let Some(elem_start) = s[..id_pos].rfind('<') else { return };
let Some(elem_end_rel) = s[id_pos..].find('>') else { return };
let elem_end = id_pos + elem_end_rel;
let key = r#"transform="rotate("#;
let Some(off) = s[elem_start..elem_end].find(key) else { return };
let inner_start = elem_start + off + key.len();
let Some(close_rel) = s[inner_start..elem_end].find(')') else { return };
let inner_end = inner_start + close_rel;
let parts: Vec<&str> = s[inner_start..inner_end].split_whitespace().collect();
let new_inner = match parts.len() {
1 => format!("{:.2}", angle_deg),
2 => format!("{:.2} {}", angle_deg, parts[1]),
_ => format!("{:.2} {} {}", angle_deg, parts[1], parts[2]),
};
s.replace_range(inner_start..inner_end, &new_inner);
}
/// fills the visualizer area with a single centered string.
fn centered_overlay_text<'a>(
label: &'a str,
color: Color,
) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
container(text(label).size(15).color(color))
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
}
/// projects the App settings struct down to the visualizer parameter subset.
fn params_from(s: &super::app::Settings) -> VizParams {
VizParams {
glass: s.glass,
entropy_on: s.entropy_on,
entropy_strength: s.entropy_strength,
album_colors: s.album_colors,
mirrored: s.mirrored,
inverted: s.inverted,
hue: s.hue,
contrast: s.contrast,
brightness: s.brightness,
}
}
pub const SETTINGS_W: f32 = 340.0;
/// right-aligned settings panel built from grouped slider and toggle rows.
fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let s = &app.settings;
let body = column![
Space::new().height(Length::Fixed(TOP_BAR_H + 4.0)),
text("Settings").size(15).color(palette::text()),
Space::new().height(Length::Fixed(10.0)),
section_label("style"),
toggle_row("glass", s.glass, Message::SetGlass),
toggle_row("album colors", s.album_colors, Message::SetAlbumColors),
toggle_row("mirrored", s.mirrored, Message::SetMirrored),
toggle_row("inverted", s.inverted, Message::SetInverted),
Space::new().height(Length::Fixed(8.0)),
section_label("color"),
slider_row(
"hue",
s.hue,
0.0..=1.0,
0.01,
format!("{:.2}", s.hue),
Message::SetHue,
),
slider_row(
"contrast",
s.contrast,
0.0..=2.0,
0.01,
format!("{:.2}", s.contrast),
Message::SetContrast,
),
slider_row(
"brightness",
s.brightness,
0.1..=2.0,
0.01,
format!("{:.2}", s.brightness),
Message::SetBrightness,
),
Space::new().height(Length::Fixed(8.0)),
section_label("entropy filter"),
toggle_row("enabled", s.entropy_on, Message::SetEntropy),
slider_row(
"strength",
s.entropy_strength,
-1.5..=1.5,
0.05,
format!("{:+.2}", s.entropy_strength),
Message::SetEntropyStrength,
),
Space::new().height(Length::Fixed(8.0)),
section_label("dsp"),
slider_row(
"bins",
s.num_bins as f32,
8.0..=128.0,
1.0,
format!("{}", s.num_bins),
|v| Message::SetNumBins(v as u32),
),
pow2_slider_row("fft", s.fft, 9, 16, Message::SetFft),
pow2_slider_row(
"hop",
s.hop,
6,
(s.fft / 2).max(64).trailing_zeros(),
Message::SetHop,
),
Space::new().height(Length::Fixed(8.0)),
section_label("cepstral smoothing"),
slider_row(
"granularity",
s.granularity as f32,
1.0..=100.0,
1.0,
format!("{}", s.granularity),
|v| Message::SetGranularity(v as i32),
),
slider_row(
"detail",
s.detail as f32,
1.0..=100.0,
1.0,
format!("{}", s.detail),
|v| Message::SetDetail(v as i32),
),
slider_row(
"strength",
s.strength,
0.0..=1.0,
0.01,
format!("{:.2}", s.strength),
Message::SetStrength,
),
Space::new().height(Length::Fixed(8.0)),
section_label("fft engine blend"),
slider_row(
"cpu ↔ gpu",
s.gpu_blend,
0.0..=1.0,
0.01,
format!("{:.2}", s.gpu_blend),
Message::SetGpuBlend,
),
]
.spacing(8)
.padding(Padding::from(16))
.width(Length::Fixed(SETTINGS_W));
let scroll = scrollable(body)
.id(settings_scroll_id())
.on_scroll(|vp| Message::SettingsScrolled(vp.absolute_offset()))
.height(Length::Fill);
let close = container(icon_chip_button(SETTINGS_SVG, Message::ToggleSettings))
.width(Length::Fill)
.height(Length::Fixed(TOP_BAR_H))
.padding(Padding::from([0, 16]))
.align_x(iced_wgpu::core::alignment::Horizontal::Right)
.align_y(iced_wgpu::core::alignment::Vertical::Center);
let panel = container(stack![scroll, close])
.width(Length::Fixed(SETTINGS_W))
.height(Length::Fill)
.style(settings_panel_style);
container(panel)
.width(Length::Fill)
.height(Length::Fill)
.align_right(Length::Fill)
.into()
}
/// label + slider + value-text trio used by every numeric setting.
fn slider_row<'a, F>(
label: &'a str,
value: f32,
range: RangeInclusive<f32>,
step: f32,
value_text: String,
on_change: F,
) -> Element<'a, Message, Theme, iced_wgpu::Renderer>
where
F: 'a + Fn(f32) -> Message,
{
let label_w = 110.0;
let value_w = 60.0;
container(
row![
container(text(label).size(13).color(palette::text_dim()))
.width(Length::Fixed(label_w)),
slider(range, value, on_change).step(step).width(Length::Fill).height(28.0),
container(
text(value_text)
.size(13)
.color(palette::text())
.align_x(iced_wgpu::core::alignment::Horizontal::Right)
)
.width(Length::Fixed(value_w))
.align_right(Length::Fixed(value_w)),
]
.spacing(12)
.align_y(iced_wgpu::core::Alignment::Center)
)
.height(Length::Fixed(44.0))
.into()
}
/// slider that snaps to powers of two, exposed as a log2 axis underneath.
fn pow2_slider_row<'a, F>(
label: &'a str,
current: u32,
min_log2: u32,
max_log2: u32,
on_change: F,
) -> Element<'a, Message, Theme, iced_wgpu::Renderer>
where
F: 'a + Fn(u32) -> Message,
{
let max_log2 = max_log2.max(min_log2);
let resolved = current.max(1).next_power_of_two();
let cur_log2 = resolved.trailing_zeros().clamp(min_log2, max_log2);
let value_text = format!("{}", 1u32 << cur_log2);
slider_row(
label,
cur_log2 as f32,
min_log2 as f32..=max_log2 as f32,
1.0,
value_text,
move |lv| on_change(1u32 << (lv as u32)),
)
}
/// label + checkbox pair used by every boolean setting.
fn toggle_row<'a, F>(
label: &'a str,
value: bool,
on_change: F,
) -> Element<'a, Message, Theme, iced_wgpu::Renderer>
where
F: 'a + Fn(bool) -> Message,
{
container(
row![
container(text(label).size(13).color(palette::text_dim()))
.width(Length::Fixed(110.0)),
checkbox(value).on_toggle(on_change).size(26),
]
.spacing(12)
.align_y(iced_wgpu::core::Alignment::Center)
)
.height(Length::Fixed(40.0))
.into()
}
/// dim small-caps heading separating groups of settings rows.
fn section_label<'a>(label: &'a str) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
container(text(label).size(10).color(palette::text_dim()))
.padding(Padding::from([0, 0]))
.into()
}
/// translucent backdrop styling for the settings overlay.
fn settings_panel_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color {
a: 0.96,
..palette::sidebar()
})),
border: Border {
color: palette::border(),
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
}
}
/// bottom transport bar with skip, play/pause, scrub slider, and position readout.
fn transport(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let prev = transport_button(BSKIP_SVG, Message::Prev, app.selected_track.is_some());
let engine_playing = app
.engine
.as_ref()
.map(|e| e.is_playing())
.unwrap_or(app.playing);
let play_glyph = if engine_playing { PAUSE_SVG } else { PLAY_SVG };
let play = transport_button(play_glyph, Message::TogglePlayPause, app.selected_track.is_some());
let next = transport_button(FSKIP_SVG, Message::Next, app.selected_track.is_some());
let pos = app.position();
let scrub = slider(0.0..=1.0, pos, Message::Seek)
.step(0.001_f32)
.width(Length::Fill);
let pos_label = text(format!("{:>5.1}%", pos * 100.0))
.size(11)
.color(palette::text_dim());
let bar = row![
prev,
Space::new().width(Length::Fixed(6.0)),
play,
Space::new().width(Length::Fixed(6.0)),
next,
Space::new().width(Length::Fixed(20.0)),
scrub,
Space::new().width(Length::Fixed(12.0)),
pos_label,
]
.padding(Padding::from([0, 16]))
.align_y(iced_wgpu::core::Alignment::Center)
.height(Length::Fill);
container(bar)
.width(Length::Fill)
.height(Length::Fixed(TRANSPORT_H))
.style(panel_style)
.into()
}
/// small soft accent-tinted text-label button.
fn chip_button<'a>(
label: &'a str,
msg: Message,
) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
iced_widget::button(text(label).size(13).color(palette::text()))
.padding(Padding::from([6, 12]))
.on_press(msg)
.style(|_t: &Theme, status: ButtonStatus| {
let hovered = matches!(status, ButtonStatus::Hovered | ButtonStatus::Pressed);
button::Style {
background: Some(Background::Color(if hovered {
Color {
a: 0.12,
..palette::accent()
}
} else {
Color {
a: 0.04,
..palette::accent()
}
})),
text_color: palette::text(),
border: Border {
color: palette::border(),
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
}
})
.into()
}
/// chip button variant carrying an inline SVG glyph.
fn icon_chip_button<'a>(
glyph: &'static [u8],
msg: Message,
) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
let handle = svg::Handle::from_memory(glyph);
let icon = svg::Svg::new(handle)
.width(Length::Fixed(16.0))
.height(Length::Fixed(16.0))
.style(|_t: &Theme, _status| svg::Style {
color: Some(palette::text()),
});
iced_widget::button(icon)
.padding(Padding::from([4, 10]))
.on_press(msg)
.style(|_t: &Theme, status: ButtonStatus| {
let hovered = matches!(status, ButtonStatus::Hovered | ButtonStatus::Pressed);
button::Style {
background: Some(Background::Color(if hovered {
Color {
a: 0.12,
..palette::accent()
}
} else {
Color {
a: 0.04,
..palette::accent()
}
})),
text_color: palette::text(),
border: Border {
color: palette::border(),
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
}
})
.into()
}
/// large icon button used by the transport bar, dimmed and disabled without a track.
fn transport_button<'a>(
glyph: &'static [u8],
msg: Message,
enabled: bool,
) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
let handle = svg::Handle::from_memory(glyph);
let tint = if enabled { palette::text() } else { palette::text_dim() };
let icon = svg::Svg::new(handle)
.width(Length::Fixed(22.0))
.height(Length::Fixed(22.0))
.style(move |_t: &Theme, _status| svg::Style { color: Some(tint) });
let mut btn = iced_widget::button(icon).padding(Padding::from([6, 12]));
if enabled {
btn = btn.on_press(msg);
}
btn.style(|_t: &Theme, status: ButtonStatus| {
let hovered = matches!(status, ButtonStatus::Hovered | ButtonStatus::Pressed);
button::Style {
background: if hovered {
Some(Background::Color(Color {
a: 0.10,
..palette::accent()
}))
} else {
None
},
text_color: palette::text(),
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 8.0.into(),
},
..Default::default()
}
})
.into()
}
/// flat sidebar-tinted background with a single hairline border.
fn panel_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(palette::sidebar())),
border: Border {
color: palette::border(),
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
}
}
/// track-list sidebar background and border styling.
fn sidebar_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(palette::sidebar())),
border: Border {
color: palette::border(),
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
}
}
/// faded square shown in place of cover art before the art worker resolves a track.
fn art_placeholder_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color {
a: 0.20,
..palette::text_dim()
})),
border: Border {
color: palette::border(),
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
}
}