mirror of
https://github.com/Felitendo/spotifast.git
synced 2026-11-01 17:33:26 +01:00
The pinned playlists wore a bookmark because the icon set had no pin; Lucide's pin and pin-off join it, and the sidebar's menu and marker use them.
778 lines
25 KiB
Rust
778 lines
25 KiB
Rust
//! Fastpotify's visual language: palette, typography, icons, base widgets.
|
|
//!
|
|
//! Inter carries the interface with real weights (egui's `strong()` only
|
|
//! brightens), IBM-free monospace is unnecessary here, and one Lucide icon
|
|
//! vocabulary replaces Unicode lookalikes. Everything colour-related goes
|
|
//! through a [`Palette`] so light and dark stay coherent and album-art tints
|
|
//! can be blended in without hunting for hard-coded colours.
|
|
|
|
use egui::{Color32, CornerRadius, Response, Sense, Stroke, Vec2};
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Palette {
|
|
pub dark: bool,
|
|
pub window: Color32,
|
|
pub panel: Color32,
|
|
pub surface: Color32,
|
|
pub surface_hover: Color32,
|
|
pub surface_active: Color32,
|
|
pub outline: Color32,
|
|
pub text: Color32,
|
|
pub secondary: Color32,
|
|
pub dim: Color32,
|
|
pub accent: Color32,
|
|
pub accent_hover: Color32,
|
|
pub on_accent: Color32,
|
|
pub danger: Color32,
|
|
pub warning: Color32,
|
|
pub overlay: Color32,
|
|
pub shadow: Color32,
|
|
}
|
|
|
|
impl Palette {
|
|
pub fn dark() -> Self {
|
|
Self {
|
|
dark: true,
|
|
window: Color32::from_rgb(0x0f, 0x11, 0x14),
|
|
panel: Color32::from_rgb(0x15, 0x18, 0x1c),
|
|
surface: Color32::from_rgb(0x1d, 0x21, 0x27),
|
|
surface_hover: Color32::from_rgb(0x26, 0x2b, 0x33),
|
|
surface_active: Color32::from_rgb(0x2f, 0x35, 0x3f),
|
|
outline: Color32::from_rgb(0x2a, 0x30, 0x38),
|
|
text: Color32::from_rgb(0xf2, 0xf4, 0xf6),
|
|
secondary: Color32::from_rgb(0xa9, 0xb1, 0xbc),
|
|
dim: Color32::from_rgb(0x6e, 0x77, 0x84),
|
|
accent: Color32::from_rgb(0x1e, 0xd7, 0x60),
|
|
accent_hover: Color32::from_rgb(0x3c, 0xe8, 0x7a),
|
|
on_accent: Color32::from_rgb(0x0a, 0x14, 0x0e),
|
|
danger: Color32::from_rgb(0xf5, 0x71, 0x7f),
|
|
warning: Color32::from_rgb(0xf2, 0xb8, 0x5c),
|
|
overlay: Color32::from_rgb(0x22, 0x27, 0x2e),
|
|
shadow: Color32::from_black_alpha(140),
|
|
}
|
|
}
|
|
|
|
pub fn light() -> Self {
|
|
Self {
|
|
dark: false,
|
|
window: Color32::from_rgb(0xf8, 0xf9, 0xfb),
|
|
panel: Color32::from_rgb(0xff, 0xff, 0xff),
|
|
surface: Color32::from_rgb(0xee, 0xf0, 0xf3),
|
|
surface_hover: Color32::from_rgb(0xe3, 0xe6, 0xeb),
|
|
surface_active: Color32::from_rgb(0xd7, 0xdb, 0xe1),
|
|
outline: Color32::from_rgb(0xdd, 0xe1, 0xe6),
|
|
text: Color32::from_rgb(0x14, 0x17, 0x1a),
|
|
secondary: Color32::from_rgb(0x53, 0x5b, 0x66),
|
|
dim: Color32::from_rgb(0x8b, 0x93, 0x9e),
|
|
accent: Color32::from_rgb(0x15, 0xa6, 0x4a),
|
|
accent_hover: Color32::from_rgb(0x12, 0x8f, 0x40),
|
|
on_accent: Color32::WHITE,
|
|
danger: Color32::from_rgb(0xd6, 0x3b, 0x4c),
|
|
warning: Color32::from_rgb(0xb8, 0x7a, 0x14),
|
|
overlay: Color32::from_rgb(0xff, 0xff, 0xff),
|
|
shadow: Color32::from_black_alpha(50),
|
|
}
|
|
}
|
|
|
|
/// A colour derived from album art, softened so it can sit behind text.
|
|
pub fn tint_from_art(&self, rgb: [u8; 3]) -> Color32 {
|
|
let [r, g, b] = rgb.map(|c| c as f32 / 255.0);
|
|
let max = r.max(g).max(b);
|
|
let min = r.min(g).min(b);
|
|
let lightness = (max + min) / 2.0;
|
|
let target = if self.dark { 0.30 } else { 0.72 };
|
|
let (r, g, b) = if lightness < 0.01 {
|
|
(target, target, target)
|
|
} else {
|
|
let scale = target / lightness;
|
|
(
|
|
(r * scale).min(1.0),
|
|
(g * scale).min(1.0),
|
|
(b * scale).min(1.0),
|
|
)
|
|
};
|
|
Color32::from_rgb((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
|
|
}
|
|
}
|
|
|
|
pub const RADIUS: u8 = 8;
|
|
pub const RADIUS_SMALL: u8 = 4;
|
|
pub const ROW_HEIGHT: f32 = 56.0;
|
|
pub const COMPACT_ROW_HEIGHT: f32 = 48.0;
|
|
pub const PLAYER_BAR_HEIGHT: f32 = 88.0;
|
|
pub const TOP_BAR_HEIGHT: f32 = 56.0;
|
|
|
|
const INTER_MEDIUM: &str = "inter-medium";
|
|
const INTER_SEMIBOLD: &str = "inter-semibold";
|
|
const INTER_BOLD: &str = "inter-bold";
|
|
|
|
pub fn regular(size: f32) -> egui::FontId {
|
|
egui::FontId::new(size, egui::FontFamily::Proportional)
|
|
}
|
|
|
|
pub fn medium(size: f32) -> egui::FontId {
|
|
egui::FontId::new(size, egui::FontFamily::Name(INTER_MEDIUM.into()))
|
|
}
|
|
|
|
pub fn semibold(size: f32) -> egui::FontId {
|
|
egui::FontId::new(size, egui::FontFamily::Name(INTER_SEMIBOLD.into()))
|
|
}
|
|
|
|
pub fn bold(size: f32) -> egui::FontId {
|
|
egui::FontId::new(size, egui::FontFamily::Name(INTER_BOLD.into()))
|
|
}
|
|
|
|
/// Install fonts, icons, and the base style once.
|
|
pub fn install(ctx: &egui::Context) {
|
|
install_fonts(ctx);
|
|
register_icons(ctx);
|
|
egui_extras::install_image_loaders(ctx);
|
|
}
|
|
|
|
/// Applies the palette to egui's own widgets so dialogs, menus, and text
|
|
/// fields agree with the custom views.
|
|
pub fn apply(ctx: &egui::Context, palette: &Palette) {
|
|
let mut style = (*ctx.global_style()).clone();
|
|
let visuals = &mut style.visuals;
|
|
*visuals = if palette.dark {
|
|
egui::Visuals::dark()
|
|
} else {
|
|
egui::Visuals::light()
|
|
};
|
|
visuals.dark_mode = palette.dark;
|
|
visuals.panel_fill = palette.panel;
|
|
visuals.window_fill = palette.overlay;
|
|
visuals.extreme_bg_color = palette.surface;
|
|
visuals.faint_bg_color = palette.surface;
|
|
visuals.code_bg_color = palette.surface;
|
|
visuals.override_text_color = Some(palette.text);
|
|
visuals.weak_text_color = Some(palette.secondary);
|
|
visuals.hyperlink_color = palette.text;
|
|
visuals.selection.bg_fill = palette.accent.gamma_multiply(0.35);
|
|
visuals.selection.stroke = Stroke::new(1.0, palette.accent);
|
|
visuals.window_stroke = Stroke::new(1.0, palette.outline);
|
|
visuals.window_corner_radius = CornerRadius::same(RADIUS + 2);
|
|
visuals.menu_corner_radius = CornerRadius::same(RADIUS);
|
|
visuals.window_shadow = egui::epaint::Shadow {
|
|
offset: [0, 6],
|
|
blur: 24,
|
|
spread: 0,
|
|
color: palette.shadow,
|
|
};
|
|
visuals.popup_shadow = egui::epaint::Shadow {
|
|
offset: [0, 4],
|
|
blur: 16,
|
|
spread: 0,
|
|
color: palette.shadow,
|
|
};
|
|
let corner = CornerRadius::same(RADIUS_SMALL + 2);
|
|
for widget in [
|
|
&mut visuals.widgets.inactive,
|
|
&mut visuals.widgets.hovered,
|
|
&mut visuals.widgets.active,
|
|
&mut visuals.widgets.open,
|
|
] {
|
|
widget.corner_radius = corner;
|
|
widget.bg_stroke = Stroke::NONE;
|
|
widget.fg_stroke = Stroke::new(1.0, palette.text);
|
|
widget.expansion = 0.0;
|
|
}
|
|
visuals.widgets.noninteractive.corner_radius = corner;
|
|
visuals.widgets.noninteractive.bg_fill = palette.panel;
|
|
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.outline);
|
|
visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, palette.text);
|
|
visuals.widgets.inactive.bg_fill = palette.surface;
|
|
visuals.widgets.inactive.weak_bg_fill = palette.surface;
|
|
visuals.widgets.hovered.bg_fill = palette.surface_hover;
|
|
visuals.widgets.hovered.weak_bg_fill = palette.surface_hover;
|
|
visuals.widgets.active.bg_fill = palette.surface_active;
|
|
visuals.widgets.active.weak_bg_fill = palette.surface_active;
|
|
visuals.widgets.open.bg_fill = palette.surface_hover;
|
|
visuals.widgets.open.weak_bg_fill = palette.surface_hover;
|
|
visuals.text_cursor.stroke = Stroke::new(2.0, palette.accent);
|
|
visuals.striped = false;
|
|
visuals.slider_trailing_fill = true;
|
|
visuals.handle_shape = egui::style::HandleShape::Circle;
|
|
|
|
use egui::FontFamily::{Monospace, Proportional};
|
|
use egui::{FontId, TextStyle};
|
|
style.text_styles = [
|
|
(TextStyle::Small, FontId::new(11.5, Proportional)),
|
|
(TextStyle::Body, FontId::new(14.0, Proportional)),
|
|
(TextStyle::Button, FontId::new(14.0, Proportional)),
|
|
(TextStyle::Heading, FontId::new(22.0, Proportional)),
|
|
(TextStyle::Monospace, FontId::new(13.0, Monospace)),
|
|
]
|
|
.into();
|
|
style.spacing.item_spacing = Vec2::new(8.0, 6.0);
|
|
style.spacing.button_padding = Vec2::new(12.0, 6.0);
|
|
style.spacing.interact_size = Vec2::new(40.0, 28.0);
|
|
style.spacing.menu_margin = egui::Margin::same(6);
|
|
style.spacing.window_margin = egui::Margin::same(16);
|
|
style.spacing.scroll = egui::style::ScrollStyle {
|
|
bar_width: 8.0,
|
|
floating_width: 6.0,
|
|
floating_allocated_width: 0.0,
|
|
handle_min_length: 28.0,
|
|
bar_inner_margin: 3.0,
|
|
bar_outer_margin: 2.0,
|
|
dormant_background_opacity: 0.0,
|
|
dormant_handle_opacity: 0.0,
|
|
active_background_opacity: 0.0,
|
|
active_handle_opacity: 0.55,
|
|
interact_handle_opacity: 0.85,
|
|
foreground_color: true,
|
|
..egui::style::ScrollStyle::floating()
|
|
};
|
|
style.interaction.selectable_labels = false;
|
|
style.interaction.tooltip_delay = 0.4;
|
|
style.animation_time = 0.12;
|
|
style.url_in_tooltip = false;
|
|
ctx.set_global_style(style);
|
|
}
|
|
|
|
fn install_fonts(ctx: &egui::Context) {
|
|
use egui::epaint::text::VariationCoords;
|
|
use egui::{FontData, FontDefinitions, FontFamily};
|
|
use std::sync::Arc;
|
|
|
|
let mut fonts = FontDefinitions::default();
|
|
let inter = include_bytes!("../assets/fonts/InterVariable.ttf");
|
|
let weighted = |weight: f32| {
|
|
let mut data = FontData::from_static(inter);
|
|
data.tweak.coords = VariationCoords::new([(b"wght", weight)]);
|
|
Arc::new(data)
|
|
};
|
|
fonts.font_data.insert("inter".to_owned(), weighted(400.0));
|
|
fonts
|
|
.font_data
|
|
.insert(INTER_MEDIUM.to_owned(), weighted(500.0));
|
|
fonts
|
|
.font_data
|
|
.insert(INTER_SEMIBOLD.to_owned(), weighted(600.0));
|
|
fonts
|
|
.font_data
|
|
.insert(INTER_BOLD.to_owned(), weighted(700.0));
|
|
|
|
fonts
|
|
.families
|
|
.entry(FontFamily::Proportional)
|
|
.or_default()
|
|
.insert(0, "inter".to_owned());
|
|
let fallbacks: Vec<String> = fonts.families[&FontFamily::Proportional]
|
|
.iter()
|
|
.skip(1)
|
|
.cloned()
|
|
.collect();
|
|
for name in [INTER_MEDIUM, INTER_SEMIBOLD, INTER_BOLD] {
|
|
let mut family = vec![name.to_owned()];
|
|
family.extend(fallbacks.iter().cloned());
|
|
fonts.families.insert(FontFamily::Name(name.into()), family);
|
|
}
|
|
|
|
// Inter draws Latin, Greek, and Cyrillic and nothing else, and the faces
|
|
// egui bundles add no more, so a title in any other script arrives as a
|
|
// row of tofu boxes. Shipping the fonts that would cover them is not an
|
|
// option -- Noto CJK alone is ten times this binary -- but a desktop that
|
|
// displays a script already carries a face for it. Borrow those and append
|
|
// them to each family, after Inter so Latin text keeps its shape and after
|
|
// the emoji faces so emoji keep their colour.
|
|
for font in crate::system_fonts::fallbacks() {
|
|
// Lending epaint the cached bytes rather than handing it owned ones
|
|
// saves copying them into its own blob, paid again every time it
|
|
// rebuilds the glyph atlas -- which a twenty-megabyte CJK collection
|
|
// makes expensive and CJK text, filling the atlas fast, provokes.
|
|
let mut data = FontData::from_static(&font.bytes);
|
|
data.index = font.index;
|
|
fonts.font_data.insert(font.name.clone(), Arc::new(data));
|
|
for family in fonts.families.values_mut() {
|
|
family.push(font.name.clone());
|
|
}
|
|
}
|
|
|
|
ctx.set_fonts(fonts);
|
|
}
|
|
|
|
macro_rules! icons {
|
|
($($variant:ident => $file:literal),* $(,)?) => {
|
|
&[$((
|
|
Icon::$variant,
|
|
concat!("bytes://fastpotify-icon-", $file, ".svg"),
|
|
include_bytes!(concat!("../assets/icons/", $file, ".svg")).as_slice(),
|
|
)),*]
|
|
};
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
|
pub enum Icon {
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
AudioLines,
|
|
BadgeCheck,
|
|
Bookmark,
|
|
BookmarkFilled,
|
|
Car,
|
|
Cast,
|
|
Check,
|
|
ChevronDown,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ChevronUp,
|
|
CircleAlert,
|
|
CircleCheck,
|
|
CirclePlay,
|
|
CirclePlus,
|
|
CircleX,
|
|
Clock,
|
|
Compass,
|
|
Copy,
|
|
Disc,
|
|
Ellipsis,
|
|
ExternalLink,
|
|
Gamepad,
|
|
Globe,
|
|
GripVertical,
|
|
Headphones,
|
|
Heart,
|
|
HeartFilled,
|
|
House,
|
|
Info,
|
|
Laptop,
|
|
Library,
|
|
ListEnd,
|
|
ListMusic,
|
|
ListPlus,
|
|
ListVideo,
|
|
Loader,
|
|
Lock,
|
|
LogOut,
|
|
Mic,
|
|
Minus,
|
|
Monitor,
|
|
Moon,
|
|
Music,
|
|
Pause,
|
|
PauseFilled,
|
|
PanelLeft,
|
|
Pin,
|
|
PinOff,
|
|
Pencil,
|
|
Play,
|
|
PlayFilled,
|
|
Plus,
|
|
Radio,
|
|
Refresh,
|
|
Repeat,
|
|
Repeat1,
|
|
Search,
|
|
Settings,
|
|
Shuffle,
|
|
SkipBack,
|
|
SkipBackFilled,
|
|
SkipForward,
|
|
SkipForwardFilled,
|
|
Smartphone,
|
|
Sparkles,
|
|
Speaker,
|
|
Square,
|
|
SquarePen,
|
|
Sun,
|
|
Tablet,
|
|
Trash,
|
|
TrendingUp,
|
|
Tv,
|
|
User,
|
|
Users,
|
|
Volume,
|
|
Volume1,
|
|
Volume2,
|
|
VolumeX,
|
|
Watch,
|
|
X,
|
|
Zap,
|
|
}
|
|
|
|
const ICONS: &[(Icon, &str, &[u8])] = icons! {
|
|
ArrowLeft => "arrow-left",
|
|
ArrowRight => "arrow-right",
|
|
AudioLines => "audio-lines",
|
|
BadgeCheck => "badge-check",
|
|
Bookmark => "bookmark",
|
|
BookmarkFilled => "bookmark-filled",
|
|
Car => "car",
|
|
Cast => "cast",
|
|
Check => "check",
|
|
ChevronDown => "chevron-down",
|
|
ChevronLeft => "chevron-left",
|
|
ChevronRight => "chevron-right",
|
|
ChevronUp => "chevron-up",
|
|
CircleAlert => "circle-alert",
|
|
CircleCheck => "circle-check",
|
|
CirclePlay => "circle-play",
|
|
CirclePlus => "circle-plus",
|
|
CircleX => "circle-x",
|
|
Clock => "clock",
|
|
Compass => "compass",
|
|
Copy => "copy",
|
|
Disc => "disc-3",
|
|
Ellipsis => "ellipsis",
|
|
ExternalLink => "external-link",
|
|
Gamepad => "gamepad-2",
|
|
Globe => "globe",
|
|
GripVertical => "grip-vertical",
|
|
Headphones => "headphones",
|
|
Heart => "heart",
|
|
HeartFilled => "heart-filled",
|
|
House => "house",
|
|
Info => "info",
|
|
Laptop => "laptop",
|
|
Library => "library",
|
|
ListEnd => "list-end",
|
|
ListMusic => "list-music",
|
|
ListPlus => "list-plus",
|
|
ListVideo => "list-video",
|
|
Loader => "loader-circle",
|
|
Lock => "lock",
|
|
LogOut => "log-out",
|
|
Mic => "mic",
|
|
Minus => "minus",
|
|
Monitor => "monitor",
|
|
Moon => "moon",
|
|
Music => "music",
|
|
Pause => "pause",
|
|
PauseFilled => "pause-filled",
|
|
PanelLeft => "panel-left",
|
|
Pin => "pin",
|
|
PinOff => "pin-off",
|
|
Pencil => "pencil",
|
|
Play => "play",
|
|
PlayFilled => "play-filled",
|
|
Plus => "plus",
|
|
Radio => "radio",
|
|
Refresh => "refresh-cw",
|
|
Repeat => "repeat",
|
|
Repeat1 => "repeat-1",
|
|
Search => "search",
|
|
Settings => "settings",
|
|
Shuffle => "shuffle",
|
|
SkipBack => "skip-back",
|
|
SkipBackFilled => "skip-back-filled",
|
|
SkipForward => "skip-forward",
|
|
SkipForwardFilled => "skip-forward-filled",
|
|
Smartphone => "smartphone",
|
|
Sparkles => "sparkles",
|
|
Speaker => "speaker",
|
|
Square => "square",
|
|
SquarePen => "square-pen",
|
|
Sun => "sun",
|
|
Tablet => "tablet",
|
|
Trash => "trash-2",
|
|
TrendingUp => "trending-up",
|
|
Tv => "tv",
|
|
User => "user",
|
|
Users => "users",
|
|
Volume => "volume",
|
|
Volume1 => "volume-1",
|
|
Volume2 => "volume-2",
|
|
VolumeX => "volume-x",
|
|
Watch => "watch",
|
|
X => "x",
|
|
Zap => "zap",
|
|
};
|
|
|
|
impl Icon {
|
|
pub fn uri(self) -> &'static str {
|
|
ICONS
|
|
.iter()
|
|
.find(|(icon, _, _)| *icon == self)
|
|
.map_or("", |(_, uri, _)| *uri)
|
|
}
|
|
|
|
pub fn image(self, color: Color32, size: f32) -> egui::Image<'static> {
|
|
egui::Image::new(self.uri())
|
|
.tint(color)
|
|
.fit_to_exact_size(Vec2::splat(size))
|
|
}
|
|
}
|
|
|
|
fn register_icons(ctx: &egui::Context) {
|
|
for (_, uri, bytes) in ICONS {
|
|
ctx.include_bytes(*uri, *bytes);
|
|
}
|
|
}
|
|
|
|
/// A static icon.
|
|
pub fn icon(ui: &mut egui::Ui, icon: Icon, size: f32, color: Color32) -> Response {
|
|
ui.add(icon.image(color, size))
|
|
}
|
|
|
|
/// Paints an icon centred in `rect` without allocating space.
|
|
pub fn paint_icon(ui: &egui::Ui, icon: Icon, rect: egui::Rect, size: f32, color: Color32) {
|
|
let icon_rect = egui::Rect::from_center_size(
|
|
rect.center() + play_glyph_offset(icon, size),
|
|
Vec2::splat(size),
|
|
);
|
|
icon.image(color, size).paint_at(ui, icon_rect);
|
|
}
|
|
|
|
/// A frameless icon control whose colour lifts on hover.
|
|
pub fn icon_button(
|
|
ui: &mut egui::Ui,
|
|
icon: Icon,
|
|
size: f32,
|
|
color: Color32,
|
|
hover: Color32,
|
|
tooltip: &str,
|
|
) -> Response {
|
|
let edge = size + 12.0;
|
|
let (rect, response) = ui.allocate_exact_size(Vec2::splat(edge), Sense::click());
|
|
if ui.is_rect_visible(rect) {
|
|
let tint = if response.hovered() || response.has_focus() {
|
|
hover
|
|
} else {
|
|
color
|
|
};
|
|
let scale = if response.is_pointer_button_down_on() {
|
|
0.92
|
|
} else {
|
|
1.0
|
|
};
|
|
paint_icon(ui, icon, rect, size * scale, tint);
|
|
}
|
|
let response = response.on_hover_cursor(egui::CursorIcon::PointingHand);
|
|
if tooltip.is_empty() {
|
|
response
|
|
} else {
|
|
response.on_hover_text(tooltip)
|
|
}
|
|
}
|
|
|
|
/// A round, filled control such as the main play button.
|
|
/// The horizontal nudge that visually centres a play triangle. A
|
|
/// right-pointing triangle's mass sits left of its bounding box, so a
|
|
/// geometrically centred glyph reads as pushed left and a full optical
|
|
/// shift reads as pushed right. Lucide bakes about one viewBox unit
|
|
/// (1/24) of right shift into the artwork; replacing it with a measured
|
|
/// 3% of the icon size lands the glyph centred at every size used here.
|
|
/// Every place that paints the glyph must use this, or the login-logo
|
|
/// bug returns: hand-tuned nudges drifted apart per call site.
|
|
pub fn play_glyph_offset(icon: Icon, icon_size: f32) -> Vec2 {
|
|
if matches!(icon, Icon::PlayFilled | Icon::Play) {
|
|
Vec2::new(icon_size * (0.03 - 1.0 / 24.0), 0.0)
|
|
} else {
|
|
Vec2::ZERO
|
|
}
|
|
}
|
|
|
|
/// The app's mark, the accent disc with the play triangle, drawn the same
|
|
/// wherever it appears.
|
|
pub fn logo(ui: &egui::Ui, center: egui::Pos2, diameter: f32, disc: Color32, glyph: Color32) {
|
|
ui.painter().circle_filled(center, diameter / 2.0, disc);
|
|
let icon_size = diameter * 0.45;
|
|
let icon_rect = egui::Rect::from_center_size(
|
|
center + play_glyph_offset(Icon::PlayFilled, icon_size),
|
|
Vec2::splat(icon_size),
|
|
);
|
|
Icon::PlayFilled
|
|
.image(glyph, icon_size)
|
|
.paint_at(ui, icon_rect);
|
|
}
|
|
|
|
pub fn circle_button(
|
|
ui: &mut egui::Ui,
|
|
icon: Icon,
|
|
diameter: f32,
|
|
fill: Color32,
|
|
fill_hover: Color32,
|
|
icon_color: Color32,
|
|
tooltip: &str,
|
|
) -> Response {
|
|
let (rect, response) = ui.allocate_exact_size(Vec2::splat(diameter), Sense::click());
|
|
if ui.is_rect_visible(rect) {
|
|
let hovered = response.hovered();
|
|
let grow = if hovered { 1.05 } else { 1.0 };
|
|
let radius = diameter / 2.0 * grow;
|
|
let fill = if hovered { fill_hover } else { fill };
|
|
ui.painter().circle_filled(rect.center(), radius, fill);
|
|
let icon_size = diameter * 0.46;
|
|
let offset = play_glyph_offset(icon, icon_size);
|
|
let icon_rect =
|
|
egui::Rect::from_center_size(rect.center() + offset, Vec2::splat(icon_size));
|
|
icon.image(icon_color, icon_size).paint_at(ui, icon_rect);
|
|
}
|
|
let response = response.on_hover_cursor(egui::CursorIcon::PointingHand);
|
|
if tooltip.is_empty() {
|
|
response
|
|
} else {
|
|
response.on_hover_text(tooltip)
|
|
}
|
|
}
|
|
|
|
/// A disc the size of a [`circle_button`] whose icon is replaced by a
|
|
/// spinner: the pressed play button itself shows that Spotify is reacting.
|
|
pub fn circle_spinner(
|
|
ui: &mut egui::Ui,
|
|
diameter: f32,
|
|
fill: Color32,
|
|
spin: Color32,
|
|
tooltip: &str,
|
|
) -> Response {
|
|
let (rect, response) = ui.allocate_exact_size(Vec2::splat(diameter), Sense::hover());
|
|
if ui.is_rect_visible(rect) {
|
|
ui.painter()
|
|
.circle_filled(rect.center(), diameter / 2.0, fill);
|
|
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(
|
|
egui::Layout::centered_and_justified(egui::Direction::LeftToRight),
|
|
));
|
|
spinner(&mut child, diameter * 0.55, spin);
|
|
}
|
|
if tooltip.is_empty() {
|
|
response
|
|
} else {
|
|
response.on_hover_text(tooltip)
|
|
}
|
|
}
|
|
|
|
/// A pill-shaped text button: filled for the primary action, outlined otherwise.
|
|
pub fn pill_button(ui: &mut egui::Ui, palette: &Palette, label: &str, primary: bool) -> Response {
|
|
let font = semibold(13.0);
|
|
let color = if primary {
|
|
palette.on_accent
|
|
} else {
|
|
palette.text
|
|
};
|
|
let galley = ui.painter().layout_no_wrap(label.to_string(), font, color);
|
|
let padding = Vec2::new(18.0, 8.0);
|
|
let size = galley.size() + padding * 2.0;
|
|
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
|
|
if ui.is_rect_visible(rect) {
|
|
let hovered = response.hovered();
|
|
let radius = rect.height() / 2.0;
|
|
if primary {
|
|
let fill = if hovered {
|
|
palette.accent_hover
|
|
} else {
|
|
palette.accent
|
|
};
|
|
ui.painter().rect_filled(rect, radius, fill);
|
|
} else {
|
|
let stroke_color = if hovered { palette.text } else { palette.dim };
|
|
ui.painter().rect_stroke(
|
|
rect,
|
|
radius,
|
|
Stroke::new(1.0, stroke_color),
|
|
egui::StrokeKind::Inside,
|
|
);
|
|
}
|
|
let pos = rect.center() - galley.size() / 2.0;
|
|
ui.painter().galley(pos, galley, color);
|
|
}
|
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
|
}
|
|
|
|
/// A muted button with an icon and label, for row and header actions.
|
|
pub fn soft_button(
|
|
ui: &mut egui::Ui,
|
|
palette: &Palette,
|
|
icon: Option<Icon>,
|
|
label: &str,
|
|
active: bool,
|
|
) -> Response {
|
|
let font = medium(13.0);
|
|
let color = if active { palette.window } else { palette.text };
|
|
let galley = ui.painter().layout_no_wrap(label.to_string(), font, color);
|
|
let icon_size = 15.0;
|
|
let icon_width = if icon.is_some() { icon_size + 6.0 } else { 0.0 };
|
|
let padding = Vec2::new(12.0, 7.0);
|
|
let size = Vec2::new(galley.size().x + icon_width, galley.size().y) + padding * 2.0;
|
|
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
|
|
if ui.is_rect_visible(rect) {
|
|
let hovered = response.hovered();
|
|
let fill = if active {
|
|
palette.text
|
|
} else if hovered {
|
|
palette.surface_hover
|
|
} else {
|
|
palette.surface
|
|
};
|
|
ui.painter().rect_filled(rect, rect.height() / 2.0, fill);
|
|
let mut x = rect.left() + padding.x;
|
|
if let Some(icon) = icon {
|
|
let icon_rect = egui::Rect::from_center_size(
|
|
egui::pos2(x + icon_size / 2.0, rect.center().y),
|
|
Vec2::splat(icon_size),
|
|
);
|
|
icon.image(color, icon_size).paint_at(ui, icon_rect);
|
|
x += icon_width;
|
|
}
|
|
let pos = egui::pos2(x, rect.center().y - galley.size().y / 2.0);
|
|
ui.painter().galley(pos, galley, color);
|
|
}
|
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
|
}
|
|
|
|
/// An animated busy indicator paced independently of the graphics driver.
|
|
pub fn spinner(ui: &mut egui::Ui, size: f32, color: Color32) -> Response {
|
|
let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
|
|
if ui.is_rect_visible(rect) {
|
|
ui.ctx()
|
|
.request_repaint_after(std::time::Duration::from_millis(33));
|
|
let radius = size / 2.0 - 2.0;
|
|
let start = ui.input(|input| input.time) * std::f64::consts::TAU * 1.2;
|
|
let sweep = 250_f64.to_radians();
|
|
let points = (0..20)
|
|
.map(|index| {
|
|
let angle = start + sweep * f64::from(index) / 19.0;
|
|
let (sin, cos) = angle.sin_cos();
|
|
rect.center() + radius * egui::vec2(cos as f32, sin as f32)
|
|
})
|
|
.collect();
|
|
ui.painter()
|
|
.add(egui::Shape::line(points, Stroke::new(2.0, color)));
|
|
}
|
|
response
|
|
}
|
|
|
|
/// Truncated single-line text in a given font and colour.
|
|
pub fn text(
|
|
ui: &mut egui::Ui,
|
|
text: impl Into<String>,
|
|
font: egui::FontId,
|
|
color: Color32,
|
|
) -> Response {
|
|
ui.add(
|
|
egui::Label::new(egui::RichText::new(text).font(font).color(color))
|
|
.truncate()
|
|
.selectable(false),
|
|
)
|
|
}
|
|
|
|
/// Single-line text that acts like a link: underlines on hover, clickable.
|
|
pub fn link(
|
|
ui: &mut egui::Ui,
|
|
text: impl Into<String>,
|
|
font: egui::FontId,
|
|
color: Color32,
|
|
) -> Response {
|
|
let response = ui.add(
|
|
egui::Label::new(egui::RichText::new(text).font(font).color(color))
|
|
.truncate()
|
|
.selectable(false)
|
|
.sense(Sense::click()),
|
|
);
|
|
if response.hovered() {
|
|
let rect = response.rect;
|
|
ui.painter()
|
|
.hline(rect.x_range(), rect.bottom() - 1.0, Stroke::new(1.0, color));
|
|
}
|
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
|
}
|
|
|
|
pub fn section_title(ui: &mut egui::Ui, palette: &Palette, label: &str) -> Response {
|
|
text(ui, label, bold(17.0), palette.text)
|
|
}
|
|
|
|
pub fn subtle(ui: &mut egui::Ui, palette: &Palette, label: &str) -> Response {
|
|
text(ui, label, regular(13.0), palette.secondary)
|
|
}
|