use std::f32::consts::FRAC_PI_2;
use std::rc::Rc;
use gpui::{
AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Transformation, Window, div, prelude::FluentBuilder, px,
radians,
};
use gpui_kit_assets::{Icon, icon};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{
ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, TextTone, TypeScale,
};
use crate::display::icon::flips;
use crate::foundation::direction::{ActiveDirection, DirectionalExt};
use crate::foundation::{FocusRing, Ident, Pressable, Sizable, StyledExt, text as foundation_text};
use crate::layout::measure;
use crate::motion;
type ToggleHandler = Rc<dyn Fn(SharedString, bool, &mut Window, &mut App)>;
pub struct AccordionSection {
id: SharedString,
title: SharedString,
description: Option<SharedString>,
disabled: bool,
body: Option<AnyElement>,
}
impl std::fmt::Debug for AccordionSection {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AccordionSection")
.field("id", &self.id)
.field("title", &self.title)
.field("disabled", &self.disabled)
.field("has_body", &self.body.is_some())
.finish()
}
}
impl AccordionSection {
pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
title: title.into(),
description: None,
disabled: false,
body: None,
}
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn body(mut self, body: impl IntoElement) -> Self {
self.body = Some(body.into_any_element());
self
}
}
#[derive(IntoElement)]
pub struct Accordion {
ident: Ident,
sections: Vec<AccordionSection>,
expanded: Vec<SharedString>,
exclusive: bool,
size: ControlSize,
on_toggle: Option<ToggleHandler>,
}
impl std::fmt::Debug for Accordion {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Accordion")
.field("ident", &self.ident)
.field("sections", &self.sections.len())
.field("expanded", &self.expanded)
.field("exclusive", &self.exclusive)
.field("has_handler", &self.on_toggle.is_some())
.finish()
}
}
impl Accordion {
pub fn new(ident: impl Into<Ident>) -> Self {
Self {
ident: ident.into(),
sections: Vec::new(),
expanded: Vec::new(),
exclusive: false,
size: ControlSize::Md,
on_toggle: None,
}
}
pub fn section(mut self, section: AccordionSection) -> Self {
self.sections.push(section);
self
}
pub fn sections(mut self, sections: impl IntoIterator<Item = AccordionSection>) -> Self {
self.sections.extend(sections);
self
}
pub fn expanded(mut self, ids: impl IntoIterator<Item = SharedString>) -> Self {
self.expanded = ids.into_iter().collect();
self
}
pub fn expanded_ids<S: AsRef<str>>(mut self, ids: &[S]) -> Self {
self.expanded = ids
.iter()
.map(|id| SharedString::from(id.as_ref().to_string()))
.collect();
self
}
pub fn exclusive(mut self, exclusive: bool) -> Self {
self.exclusive = exclusive;
self
}
pub fn on_toggle(
mut self,
handler: impl Fn(SharedString, bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
}
impl Sizable for Accordion {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl RenderOnce for Accordion {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let metrics = theme.control.get(self.size);
let expanded_ids = self.expanded.clone();
let direction = cx.layout_direction();
let mut stack = div()
.column()
.radius(&theme, Radius::Card)
.frame(&theme, Surface::Panel, Elevation::Raised)
.overflow_hidden();
for section in self.sections.into_iter() {
let open = expanded_ids.contains(§ion.id);
let actionable = !section.disabled && self.on_toggle.is_some();
let ident = self.ident.child(section.id.as_ref());
let color = if section.disabled {
theme.colors.text_faint
} else {
theme.colors.text
};
let mut header = div()
.id(ident.element_id())
.row_reading(direction)
.w_full()
.gap(px(theme.space(Space::Sm)))
.px(px(metrics.padding_x))
.py(px(theme.space(Space::Sm)))
.child(
icon(Icon::AltArrowRight)
.size(px(metrics.icon_size))
.text_color(theme.colors.text_muted)
.when(open, |glyph| {
glyph.with_transformation(Transformation::rotate(radians(FRAC_PI_2)))
})
.when(!open && flips(Icon::AltArrowRight, direction), |glyph| {
glyph.with_transformation(Transformation::scale(gpui::size(-1.0, 1.0)))
}),
)
.child(
div()
.column()
.flex_1()
.gap(px(2.0))
.child(
foundation_text(&theme, TypeScale::Label, section.title.clone())
.text_size(px(metrics.font_size))
.text_start(direction)
.text_color(color),
)
.children(section.description.clone().map(|description| {
foundation_text(&theme, TypeScale::Caption, description)
.text_start(direction)
.text_tone(&theme, TextTone::Muted)
})),
)
.when(section.disabled, |element| {
element.opacity(theme.opacity.disabled)
})
.when(actionable, |element| {
element
.cursor_pointer()
.tab_index(0)
.pressable(cx)
.hover(|style| style.bg(theme.colors.hover))
.focus_ring(&theme)
});
if let (true, Some(handler)) = (actionable, self.on_toggle.clone()) {
let reports = reports(&expanded_ids, §ion.id, open, self.exclusive);
let key_reports = reports.clone();
let click = Rc::clone(&handler);
header = header
.on_click(move |_, window, cx| {
for (id, next) in &reports {
click(id.clone(), *next, window, cx);
}
})
.on_key_down(move |event, window, cx| {
if matches!(event.keystroke.key.as_str(), "enter" | "space") {
for (id, next) in &key_reports {
handler(id.clone(), *next, window, cx);
}
cx.stop_propagation();
}
});
}
let header = header.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::Button)
.parent(self.ident.semantic_id())
.expanded(open)
.disabled(section.disabled)
.text(section.title.clone()),
);
let body_id = ident.child("body").semantic_id();
let disclosed = motion::tracked(
&body_id,
f32::from(u8::from(open)),
motion::resize(&theme),
window,
cx,
);
let body = (disclosed > 0.0).then_some(section.body).flatten();
let measured = measure::cell(&body_id, cx);
let height = px(f32::from(measured.get().size.height) * disclosed);
stack = stack.child(div().column().child(header).children(body.map(|body| {
let content = div()
.pl(px(metrics.padding_x
+ metrics.icon_size
+ theme.space(Space::Sm)))
.pr(px(metrics.padding_x))
.pb(px(theme.space(Space::Sm)))
.child(body);
let record = {
let measured = Rc::clone(&measured);
move |bounds: Vec<gpui::Bounds<gpui::Pixels>>,
window: &mut Window,
_: &mut App| {
if let Some(first) = bounds.first() {
measure::record(&measured, *first, window);
}
}
};
if disclosed >= 1.0 {
div().w_full().on_children_prepainted(record).child(content)
} else {
div()
.relative()
.w_full()
.h(height)
.overflow_hidden()
.on_children_prepainted(record)
.child(content.absolute().top_0().left_0().right_0())
}
})));
}
stack.semantic_in(cx, NodeSpec::new(self.ident.semantic_id(), Role::Group))
}
}
fn reports(
expanded: &[SharedString],
id: &SharedString,
open: bool,
exclusive: bool,
) -> Vec<(SharedString, bool)> {
let mut reports = vec![(id.clone(), !open)];
if exclusive && !open {
reports.extend(
expanded
.iter()
.filter(|other| *other != id)
.map(|other| (other.clone(), false)),
);
}
reports
}