use crate::{ layout::Direction, paint::{Outline, PaintCmd, Path, TextStyle}, widgets::Label, *, }; #[derive(Clone, Copy, Debug, serde_derive::Deserialize, serde_derive::Serialize)] #[serde(default)] pub(crate) struct State { open: bool, #[serde(skip)] // Times are relative, and we don't want to continue animations anyway toggle_time: f64, /// Height of the region when open. Used for animations open_height: Option, } impl Default for State { fn default() -> Self { Self { open: false, toggle_time: -f64::INFINITY, open_height: None, } } } impl State { pub fn from_memory_with_default_open(ui: &Ui, id: Id, default_open: bool) -> Self { ui.memory() .collapsing_headers .entry(id) .or_insert(State { open: default_open, ..Default::default() }) .clone() } // Helper pub fn is_open(ctx: &Context, id: Id) -> Option { ctx.memory() .collapsing_headers .get(&id) .map(|state| state.open) } pub fn toggle(&mut self, ui: &Ui) { self.open = !self.open; self.toggle_time = ui.input().time; } /// 0 for closed, 1 for open, with tweening pub fn openness(&self, ui: &Ui) -> f32 { let animation_time = ui.style().animation_time; let time_since_toggle = (ui.input().time - self.toggle_time) as f32; let time_since_toggle = time_since_toggle + ui.input().dt; // Instant feedback if self.open { remap_clamp(time_since_toggle, 0.0..=animation_time, 0.0..=1.0) } else { remap_clamp(time_since_toggle, 0.0..=animation_time, 1.0..=0.0) } } /// Paint the arrow icon that indicated if the region is open or not pub fn paint_icon(&self, ui: &mut Ui, interact: &InteractInfo) { let stroke_color = ui.style().interact(interact).stroke_color; let stroke_width = ui.style().interact(interact).stroke_width; let rect = interact.rect; let openness = self.openness(ui); // Draw a pointy triangle arrow: let rect = Rect::from_center_size(rect.center(), vec2(rect.width(), rect.height()) * 0.75); let mut points = [rect.left_top(), rect.right_top(), rect.center_bottom()]; let rotation = Vec2::angled(remap(openness, 0.0..=1.0, -TAU / 4.0..=0.0)); for p in &mut points { let v = *p - rect.center(); let v = rotation.rotate_other(v); *p = rect.center() + v; } ui.add_paint_cmd(PaintCmd::Path { path: Path::from_point_loop(&points), closed: true, fill_color: None, outline: Some(Outline::new(stroke_width, stroke_color)), }); } /// Show contents if we are open, with a nice animation between closed and open pub fn add_contents( &mut self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R, ) -> Option<(R, Rect)> { let openness = self.openness(ui); let animate = 0.0 < openness && openness < 1.0; if animate { Some(ui.add_custom(|child_ui| { let max_height = if self.open { if let Some(full_height) = self.open_height { remap_clamp(openness, 0.0..=1.0, 0.0..=full_height) } else { // First frame of expansion. // We don't know full height yet, but we will next frame. // Just use a placehodler value that shows some movement: 10.0 } } else { let full_height = self.open_height.unwrap_or_default(); remap_clamp(openness, 0.0..=1.0, 0.0..=full_height) }; let mut clip_rect = child_ui.clip_rect(); clip_rect.max.y = clip_rect.max.y.min(child_ui.rect().top() + max_height); child_ui.set_clip_rect(clip_rect); let top_left = child_ui.top_left(); let r = add_contents(child_ui); self.open_height = Some(child_ui.bounding_size().y); // Pretend children took up less space: let mut child_bounds = child_ui.child_bounds(); child_bounds.max.y = child_bounds.max.y.min(top_left.y + max_height); child_ui.force_set_child_bounds(child_bounds); r })) } else if self.open { let r_interact = ui.add_custom(add_contents); let full_size = r_interact.1.size(); self.open_height = Some(full_size.y); Some(r_interact) } else { None } } } pub struct CollapsingHeader { label: Label, default_open: bool, } impl CollapsingHeader { pub fn new(label: impl Into) -> Self { Self { label: Label::new(label) .text_style(TextStyle::Button) .multiline(false), default_open: false, } } pub fn default_open(mut self, open: bool) -> Self { self.default_open = open; self } } struct Prepared { id: Id, state: State, } impl CollapsingHeader { fn begin(self, ui: &mut Ui) -> Prepared { assert!( ui.layout().dir() == Direction::Vertical, "Horizontal collapsing is unimplemented" ); let Self { label, default_open, } = self; // TODO: horizontal layout, with icon and text as labels. Insert background behind using Frame. let title = label.text(); let id = ui.make_unique_id(title); let available = ui.available_finite(); let text_pos = available.min + vec2(ui.style().indent, 0.0); let galley = label.layout_width(ui, available.width() - ui.style().indent); let text_max_x = text_pos.x + galley.size.x; let desired_width = text_max_x - available.left(); let desired_width = desired_width.max(available.width()); let size = vec2( desired_width, galley.size.y + 2.0 * ui.style().button_padding.y, ); let rect = ui.allocate_space(size); let interact = ui.interact(rect, id, Sense::click()); let text_pos = pos2(text_pos.x, interact.rect.center().y - galley.size.y / 2.0); let mut state = State::from_memory_with_default_open(ui, id, default_open); if interact.clicked { state.toggle(ui); } let where_to_put_background = ui.paint_list_len(); { let (mut icon_rect, _) = ui.style().icon_rectangles(interact.rect); icon_rect.set_center(pos2( interact.rect.left() + ui.style().indent / 2.0, interact.rect.center().y, )); let icon_interact = InteractInfo { rect: icon_rect, ..interact }; state.paint_icon(ui, &icon_interact); } ui.add_galley( text_pos, galley, label.text_style, Some(ui.style().interact(&interact).stroke_color), ); ui.insert_paint_cmd( where_to_put_background, PaintCmd::Rect { corner_radius: ui.style().interact(&interact).corner_radius, fill_color: ui.style().interact(&interact).bg_fill_color, outline: None, rect: interact.rect, }, ); Prepared { id, state } } pub fn show(self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> Option { let Prepared { id, mut state } = self.begin(ui); let r_interact = state.add_contents(ui, |ui| ui.indent(id, add_contents).0); let ret = r_interact.map(|ri| ri.0); ui.memory().collapsing_headers.insert(id, state); ret } }