use egui::{
Align, Color32, CornerRadius, InnerResponse, Layout, Margin, Rect, Response, Sense, Stroke,
StrokeKind, Ui, Vec2, WidgetInfo, WidgetText, WidgetType,
};
use crate::theme::Theme;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CalloutTone {
Info,
Success,
Warning,
Danger,
Neutral,
}
impl CalloutTone {
fn stripe(self, theme: &Theme) -> Color32 {
let p = &theme.palette;
match self {
Self::Info => p.sky,
Self::Success => p.green,
Self::Warning => p.amber,
Self::Danger => p.red,
Self::Neutral => p.text_muted,
}
}
fn icon_color(self, theme: &Theme) -> Color32 {
let p = &theme.palette;
match self {
Self::Info => p.sky,
Self::Success => p.success,
Self::Warning => p.warning,
Self::Danger => p.danger,
Self::Neutral => p.text_muted,
}
}
fn default_icon(self) -> &'static str {
match self {
Self::Info => "ℹ",
Self::Success => "✓",
Self::Warning => "⚠",
Self::Danger => "×",
Self::Neutral => "•",
}
}
}
#[must_use = "Call `.show(ui, ...)` to render the callout."]
pub struct Callout<'a> {
tone: CalloutTone,
title: Option<WidgetText>,
body: Option<WidgetText>,
icon: Option<WidgetText>,
open: Option<&'a mut bool>,
}
impl std::fmt::Debug for Callout<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Callout")
.field("tone", &self.tone)
.field("title", &self.title.as_ref().map(|t| t.text()))
.field("body", &self.body.as_ref().map(|b| b.text()))
.field("icon", &self.icon.as_ref().map(|i| i.text()))
.field("dismissable", &self.open.is_some())
.finish()
}
}
impl<'a> Callout<'a> {
pub fn new(tone: CalloutTone) -> Self {
Self {
tone,
title: None,
body: None,
icon: None,
open: None,
}
}
#[inline]
pub fn title(mut self, text: impl Into<WidgetText>) -> Self {
self.title = Some(text.into());
self
}
#[inline]
pub fn body(mut self, text: impl Into<WidgetText>) -> Self {
self.body = Some(text.into());
self
}
#[inline]
pub fn icon(mut self, icon: impl Into<WidgetText>) -> Self {
self.icon = Some(icon.into());
self
}
#[inline]
pub fn dismissable(mut self, open: &'a mut bool) -> Self {
self.open = Some(open);
self
}
pub fn show<R>(self, ui: &mut Ui, add_actions: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
const STRIPE_WIDTH: f32 = 3.0;
let theme = Theme::current(ui.ctx());
let p = &theme.palette;
let body_size = theme.typography.body;
let stripe = self.tone.stripe(&theme);
let icon_color = self.tone.icon_color(&theme);
let default_icon = self.tone.default_icon();
let a11y_label = self
.title
.as_ref()
.or(self.body.as_ref())
.map(|w| w.text().to_string())
.unwrap_or_else(|| "callout".to_string());
let Self {
tone: _,
title,
body,
icon,
open,
} = self;
let frame = egui::Frame::new().fill(p.card).inner_margin(Margin {
left: (STRIPE_WIDTH as i8) + 18,
right: 16,
top: 10,
bottom: 10,
});
let frame_response: InnerResponse<R> = frame.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
let icon_str = icon
.as_ref()
.map(|w| w.text().to_string())
.unwrap_or_else(|| default_icon.to_string());
ui.add(
egui::Label::new(
egui::RichText::new(icon_str)
.color(icon_color)
.size(body_size + 1.0),
)
.wrap_mode(egui::TextWrapMode::Extend),
);
if let Some(title) = title {
ui.add(
egui::Label::new(
egui::RichText::new(title.text())
.color(p.text)
.size(body_size)
.strong(),
)
.wrap_mode(egui::TextWrapMode::Extend),
);
}
if let Some(body) = body {
ui.add(
egui::Label::new(
egui::RichText::new(body.text())
.color(p.text_muted)
.size(body_size),
)
.wrap_mode(egui::TextWrapMode::Truncate),
);
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if let Some(open) = open {
if dismiss_button(ui, &theme).clicked() {
*open = false;
}
}
add_actions(ui)
})
.inner
})
.inner
});
let rect = frame_response.response.rect;
let painter = ui.painter();
painter.rect(
Rect::from_min_max(
rect.left_top(),
egui::pos2(rect.left() + STRIPE_WIDTH, rect.bottom()),
),
CornerRadius::ZERO,
stripe,
Stroke::NONE,
StrokeKind::Inside,
);
painter.hline(rect.x_range(), rect.bottom(), Stroke::new(1.0, p.border));
frame_response
.response
.widget_info(|| WidgetInfo::labeled(WidgetType::Other, true, &a11y_label));
frame_response
}
}
fn dismiss_button(ui: &mut Ui, theme: &Theme) -> Response {
let size = Vec2::splat(theme.typography.body + 8.0);
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
if ui.is_rect_visible(rect) {
let color = if response.hovered() || response.has_focus() {
theme.palette.text
} else {
theme.palette.text_faint
};
let painter = ui.painter();
let c = rect.center();
let r = 4.5;
let stroke = Stroke::new(1.5, color);
painter.line_segment([c + Vec2::new(-r, -r), c + Vec2::new(r, r)], stroke);
painter.line_segment([c + Vec2::new(r, -r), c + Vec2::new(-r, r)], stroke);
}
response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, "Dismiss"));
response
}