use std::hash::Hash;
use egui::{
pos2, Align2, Color32, CornerRadius, FontId, Frame, Id, InnerResponse, Key, Margin, Pos2,
Sense, Stroke, StrokeKind, Ui, Vec2, WidgetInfo, WidgetText, WidgetType,
};
use crate::theme::{with_alpha, Theme};
use crate::Accent;
type UiFn<'a> = Box<dyn FnOnce(&mut Ui) + 'a>;
const HEADER_PAD_X: f32 = 16.0;
const HEADER_PAD_Y: f32 = 13.0;
const FLUSH_HEADER_PAD_Y: f32 = 12.0;
const CHEVRON_SIZE: f32 = 12.0;
const CHEVRON_GAP: f32 = 12.0;
const ICON_SIZE: f32 = 26.0;
const ICON_GAP: f32 = 10.0;
const META_GAP: f32 = 10.0;
#[must_use = "Call `.show(ui, |acc| ...)` to render the accordion."]
pub struct Accordion {
id_salt: Id,
exclusive: bool,
flush: bool,
}
impl std::fmt::Debug for Accordion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Accordion")
.field("id_salt", &self.id_salt)
.field("exclusive", &self.exclusive)
.field("flush", &self.flush)
.finish()
}
}
impl Accordion {
pub fn new(id_salt: impl Hash) -> Self {
Self {
id_salt: Id::new(("elegance_accordion", id_salt)),
exclusive: false,
flush: false,
}
}
#[inline]
pub fn exclusive(mut self, exclusive: bool) -> Self {
self.exclusive = exclusive;
self
}
#[inline]
pub fn flush(mut self, flush: bool) -> Self {
self.flush = flush;
self
}
pub fn show<R>(self, ui: &mut Ui, body: impl FnOnce(&mut AccordionUi<'_>) -> R) -> R {
let theme = Theme::current(ui.ctx());
let p = &theme.palette;
let radius = CornerRadius::same(theme.card_radius as u8);
let exclusive_id = self.id_salt.with("__exclusive_open");
let seeded_id = self.id_salt.with("__seeded");
let prev_open: Option<usize> = ui.ctx().data(|d| d.get_temp(exclusive_id));
let already_seeded: bool = ui
.ctx()
.data(|d| d.get_temp::<bool>(seeded_id).unwrap_or(false));
let frame = if self.flush {
Frame::new()
} else {
Frame::new()
.fill(p.card)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(radius)
};
frame
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let mut handle = AccordionUi {
ui,
id_salt: self.id_salt,
exclusive: self.exclusive,
flush: self.flush,
next_index: 0,
item_count: 0,
prev_open_exclusive: prev_open,
next_open_exclusive: prev_open,
seeded: already_seeded,
focus_chain: Vec::new(),
};
let r = body(&mut handle);
let final_open = handle.next_open_exclusive;
let any_items = handle.next_index > 0;
let chain = std::mem::take(&mut handle.focus_chain);
let ctx = handle.ui.ctx().clone();
if self.exclusive && final_open != prev_open {
ctx.data_mut(|d| match final_open {
Some(idx) => {
d.insert_temp(exclusive_id, idx);
}
None => {
d.remove::<usize>(exclusive_id);
}
});
}
if !already_seeded && any_items {
ctx.data_mut(|d| d.insert_temp(seeded_id, true));
}
if !chain.is_empty() {
handle_focus_chain_keys(&ctx, &chain);
}
r
})
.inner
}
}
pub struct AccordionUi<'u> {
ui: &'u mut Ui,
id_salt: Id,
exclusive: bool,
flush: bool,
next_index: usize,
item_count: usize,
prev_open_exclusive: Option<usize>,
next_open_exclusive: Option<usize>,
seeded: bool,
focus_chain: Vec<Id>,
}
impl<'u> std::fmt::Debug for AccordionUi<'u> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AccordionUi")
.field("id_salt", &self.id_salt)
.field("exclusive", &self.exclusive)
.field("flush", &self.flush)
.field("next_index", &self.next_index)
.field("prev_open_exclusive", &self.prev_open_exclusive)
.finish()
}
}
impl<'u> AccordionUi<'u> {
pub fn item(&mut self, title: impl Into<WidgetText>) -> AccordionItem<'_, 'u> {
let index = self.next_index;
self.next_index += 1;
AccordionItem {
owner: self,
index,
title: title.into(),
subtitle: None,
icon: None,
accent: None,
meta: None,
default_open: false,
disabled: false,
}
}
}
#[must_use = "Call `.show(|ui| ...)` to render the item."]
pub struct AccordionItem<'a, 'u> {
owner: &'a mut AccordionUi<'u>,
index: usize,
title: WidgetText,
subtitle: Option<WidgetText>,
icon: Option<WidgetText>,
accent: Option<Accent>,
meta: Option<UiFn<'a>>,
default_open: bool,
disabled: bool,
}
impl<'a, 'u> std::fmt::Debug for AccordionItem<'a, 'u> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AccordionItem")
.field("index", &self.index)
.field("title", &self.title.text())
.field("subtitle", &self.subtitle.as_ref().map(|s| s.text()))
.field("icon", &self.icon.as_ref().map(|s| s.text()))
.field("accent", &self.accent)
.field("default_open", &self.default_open)
.field("disabled", &self.disabled)
.finish()
}
}
impl<'a, 'u> AccordionItem<'a, 'u> {
#[inline]
pub fn subtitle(mut self, subtitle: impl Into<WidgetText>) -> Self {
self.subtitle = Some(subtitle.into());
self
}
#[inline]
pub fn icon(mut self, icon: impl Into<WidgetText>) -> Self {
self.icon = Some(icon.into());
self
}
#[inline]
pub fn accent(mut self, accent: Accent) -> Self {
self.accent = Some(accent);
self
}
#[inline]
pub fn meta<F: FnOnce(&mut Ui) + 'a>(mut self, add_meta: F) -> Self {
self.meta = Some(Box::new(add_meta));
self
}
#[inline]
pub fn default_open(mut self, open: bool) -> Self {
self.default_open = open;
self
}
#[inline]
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn show<R>(self, add_body: impl FnOnce(&mut Ui) -> R) -> InnerResponse<Option<R>> {
let AccordionItem {
owner,
index,
title,
subtitle,
icon,
accent,
meta,
default_open,
disabled,
} = self;
let theme = Theme::current(owner.ui.ctx());
let p = &theme.palette;
let t = &theme.typography;
let item_id = owner.id_salt.with(("item", index));
let mut is_open = if owner.exclusive {
owner.next_open_exclusive == Some(index)
} else {
owner
.ui
.ctx()
.data(|d| d.get_temp::<bool>(item_id).unwrap_or(default_open))
};
if !owner.seeded && default_open && !is_open {
if owner.exclusive {
if owner.next_open_exclusive.is_none() {
is_open = true;
owner.next_open_exclusive = Some(index);
}
} else {
is_open = true;
}
}
if owner.item_count > 0 {
let avail = owner.ui.available_rect_before_wrap();
let y = avail.min.y + 0.5;
owner.ui.painter().line_segment(
[pos2(avail.min.x, y), pos2(avail.max.x, y)],
Stroke::new(1.0, p.border),
);
}
owner.item_count += 1;
let pad_y = if owner.flush {
FLUSH_HEADER_PAD_Y
} else {
HEADER_PAD_Y
};
let pad_x = if owner.flush { 0.0 } else { HEADER_PAD_X };
let title_galley =
crate::theme::placeholder_galley(owner.ui, title.text(), t.body, true, f32::INFINITY);
let subtitle_galley = subtitle.as_ref().map(|s| {
crate::theme::placeholder_galley(owner.ui, s.text(), t.small, false, f32::INFINITY)
});
let icon_block_w = if icon.is_some() {
ICON_SIZE + ICON_GAP
} else {
0.0
};
let title_block_h = match &subtitle_galley {
Some(s) => title_galley.size().y + 2.0 + s.size().y,
None => title_galley.size().y,
};
let row_content_h = title_block_h.max(ICON_SIZE).max(CHEVRON_SIZE);
let row_h = row_content_h + pad_y * 2.0;
let avail_w = owner.ui.available_size_before_wrap().x;
let (row_rect, row_resp) = owner.ui.allocate_exact_size(
Vec2::new(avail_w, row_h),
if disabled {
Sense::hover()
} else {
Sense::click()
},
);
if !disabled {
owner.focus_chain.push(row_resp.id);
}
if owner.ui.is_rect_visible(row_rect) {
if !disabled && (row_resp.hovered() || row_resp.has_focus()) {
let alpha = if row_resp.has_focus() { 12 } else { 8 };
let lift =
Color32::from_rgba_unmultiplied(p.text.r(), p.text.g(), p.text.b(), alpha);
owner.ui.painter().rect_filled(row_rect, 0.0, lift);
}
if row_resp.has_focus() {
owner.ui.painter().rect(
row_rect.shrink(1.0),
CornerRadius::ZERO,
Color32::TRANSPARENT,
Stroke::new(2.0, with_alpha(p.sky, 180)),
StrokeKind::Inside,
);
}
let dim = if disabled { 0.55 } else { 1.0 };
let mut x = row_rect.min.x + pad_x;
let cy = row_rect.center().y;
let chev_color = if !disabled && row_resp.hovered() {
p.text_muted
} else {
p.text_faint
};
paint_chevron(
owner.ui,
pos2(x + CHEVRON_SIZE * 0.5, cy),
CHEVRON_SIZE,
fade(chev_color, dim),
is_open,
);
x += CHEVRON_SIZE + CHEVRON_GAP;
if let Some(icon_text) = icon.as_ref() {
let icon_rect = egui::Rect::from_center_size(
pos2(x + ICON_SIZE * 0.5, cy),
Vec2::splat(ICON_SIZE),
);
paint_icon_square(owner.ui, icon_rect, icon_text.text(), accent, &theme, dim);
x += icon_block_w;
}
let title_color = fade(p.text, dim);
let title_y = match &subtitle_galley {
Some(_) => cy - title_block_h * 0.5,
None => cy - title_galley.size().y * 0.5,
};
owner
.ui
.painter()
.galley(pos2(x, title_y), title_galley.clone(), title_color);
if let Some(sub) = &subtitle_galley {
let sub_y = title_y + title_galley.size().y + 2.0;
owner
.ui
.painter()
.galley(pos2(x, sub_y), sub.clone(), fade(p.text_faint, dim));
}
if let Some(add_meta) = meta {
let meta_pad_x = pad_x.max(META_GAP);
let meta_max_w = (row_rect.width() * 0.5).max(60.0);
let meta_rect = egui::Rect::from_min_max(
pos2(row_rect.max.x - meta_max_w - meta_pad_x, row_rect.min.y),
pos2(row_rect.max.x - meta_pad_x, row_rect.max.y),
);
let mut meta_ui = owner.ui.new_child(
egui::UiBuilder::new()
.max_rect(meta_rect)
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
if disabled {
meta_ui.disable();
}
add_meta(&mut meta_ui);
}
}
let mut toggle = false;
if !disabled {
if row_resp.clicked() {
toggle = true;
}
if row_resp.has_focus() {
let pressed = owner
.ui
.ctx()
.input(|i| i.key_pressed(Key::Space) || i.key_pressed(Key::Enter));
if pressed {
toggle = true;
}
}
}
if toggle {
is_open = !is_open;
}
if owner.exclusive {
owner.next_open_exclusive = if is_open {
Some(index)
} else if owner.next_open_exclusive == Some(index) {
None
} else {
owner.next_open_exclusive
};
} else {
owner.ui.ctx().data_mut(|d| d.insert_temp(item_id, is_open));
}
let title_text = title.text().to_string();
row_resp.widget_info(|| {
WidgetInfo::selected(
WidgetType::CollapsingHeader,
!disabled,
is_open,
&title_text,
)
});
let inner = if is_open {
let body_pad_left = if owner.flush {
CHEVRON_SIZE + CHEVRON_GAP
} else {
pad_x + CHEVRON_SIZE + CHEVRON_GAP
};
let body_pad_right = pad_x.max(HEADER_PAD_X);
let body_pad_top = if owner.flush { 4.0 } else { 6.0 };
let body_pad_bottom = if owner.flush { 14.0 } else { 16.0 };
let body_frame = Frame::new().inner_margin(Margin {
left: body_pad_left as i8,
right: body_pad_right as i8,
top: body_pad_top as i8,
bottom: body_pad_bottom as i8,
});
let r = body_frame.show(owner.ui, |ui| add_body(ui)).inner;
Some(r)
} else {
None
};
InnerResponse::new(inner, row_resp)
}
}
fn handle_focus_chain_keys(ctx: &egui::Context, chain: &[Id]) {
let Some(focused) = ctx.memory(|m| m.focused()) else {
return;
};
let Some(idx) = chain.iter().position(|id| *id == focused) else {
return;
};
let (down, up, home, end) = ctx.input(|i| {
(
i.key_pressed(Key::ArrowDown),
i.key_pressed(Key::ArrowUp),
i.key_pressed(Key::Home),
i.key_pressed(Key::End),
)
});
let target = if down && idx + 1 < chain.len() {
Some(chain[idx + 1])
} else if up && idx > 0 {
Some(chain[idx - 1])
} else if home {
chain.first().copied()
} else if end {
chain.last().copied()
} else {
None
};
if let Some(target) = target {
ctx.memory_mut(|m| m.request_focus(target));
}
}
fn fade(c: Color32, t: f32) -> Color32 {
let a = (c.a() as f32 * t).round().clamp(0.0, 255.0) as u8;
Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a)
}
fn paint_chevron(ui: &Ui, center: Pos2, size: f32, color: Color32, open: bool) {
let half = size * 0.32;
let points: Vec<Pos2> = if open {
vec![
pos2(center.x - half, center.y - half * 0.55),
pos2(center.x + half, center.y - half * 0.55),
pos2(center.x, center.y + half * 0.75),
]
} else {
vec![
pos2(center.x - half * 0.55, center.y - half),
pos2(center.x - half * 0.55, center.y + half),
pos2(center.x + half * 0.75, center.y),
]
};
ui.painter()
.add(egui::Shape::convex_polygon(points, color, Stroke::NONE));
}
fn paint_icon_square(
ui: &Ui,
rect: egui::Rect,
glyph: &str,
accent: Option<Accent>,
theme: &Theme,
dim: f32,
) {
let p = &theme.palette;
let (bg, stroke, fg) = match accent {
Some(a) => {
let base = p.accent_fill(a);
(
with_alpha(base, 28),
Stroke::new(1.0, with_alpha(base, 90)),
base,
)
}
None => (
p.depth_tint(p.card, 0.05),
Stroke::new(1.0, p.border),
p.text_muted,
),
};
ui.painter()
.rect(rect, CornerRadius::same(6), bg, stroke, StrokeKind::Inside);
ui.painter().text(
rect.center(),
Align2::CENTER_CENTER,
glyph,
FontId::proportional(theme.typography.label),
fade(fg, dim),
);
}