use std::cell::RefCell;
use std::rc::Rc;
use crate::layout::{Dimension, Edges, Style as LayoutStyle};
use crate::reactive::{Scope, Signal};
use crate::render::Style;
use crate::theme::TokenSet;
use crate::ui::{
dyn_view, dyn_view_scoped, Element, EventCtx, Key, MouseButton, MouseKind, Phase, UiEvent, View,
};
use super::feed::{Feed, FeedItem, FeedState};
use super::scroll::Scroll;
type BodyBuilder = Box<dyn FnMut(Scope) -> View>;
enum Body {
None,
Text(String),
Markdown(String),
Build(BodyBuilder),
}
pub struct Disclosure {
title: String,
detail: Option<String>,
body: Body,
folded: Option<Signal<bool>>,
initially_folded: bool,
max_body_rows: i32,
layout: Option<LayoutStyle>,
on_toggle: Option<Box<dyn FnMut(bool)>>,
}
impl Disclosure {
pub fn new(title: impl Into<String>) -> Disclosure {
Disclosure {
title: title.into(),
detail: None,
body: Body::None,
folded: None,
initially_folded: true,
max_body_rows: 8,
layout: None,
on_toggle: None,
}
}
pub fn text(title: impl Into<String>, body: impl Into<String>) -> Disclosure {
let mut d = Disclosure::new(title);
d.body = Body::Text(body.into());
d
}
pub fn markdown(title: impl Into<String>, src: impl Into<String>) -> Disclosure {
let mut d = Disclosure::new(title);
d.body = Body::Markdown(src.into());
d
}
pub fn body(mut self, build: impl FnMut(Scope) -> View + 'static) -> Disclosure {
self.body = Body::Build(Box::new(build));
self
}
pub fn detail(mut self, detail: impl Into<String>) -> Disclosure {
self.detail = Some(detail.into());
self
}
pub fn initially_folded(mut self, folded: bool) -> Disclosure {
self.initially_folded = folded;
self
}
pub fn folded(mut self, folded: Signal<bool>) -> Disclosure {
self.folded = Some(folded);
self
}
pub fn max_body_rows(mut self, rows: i32) -> Disclosure {
self.max_body_rows = rows;
self
}
pub fn on_toggle(mut self, f: impl FnMut(bool) + 'static) -> Disclosure {
self.on_toggle = Some(Box::new(f));
self
}
pub fn layout(mut self, layout: LayoutStyle) -> Disclosure {
self.layout = Some(layout);
self
}
pub fn view(self, cx: Scope) -> crate::ui::View {
let t = crate::widgets::theme_tokens(cx);
self.element(cx, &t).build()
}
pub fn element(self, cx: Scope, t: &TokenSet) -> Element {
let tokens = *t;
let text_fg = t.text;
let muted = t.text_muted;
let accent = t.accent;
let raised = t.surface_raised;
let ground = t.surface;
let sel_fg = t.selection_fg;
let sel_bg = t.selection_bg;
let title = self.title;
let detail = self.detail;
let folded = self
.folded
.unwrap_or_else(|| cx.signal(self.initially_folded));
let hovered = cx.signal(false);
let focused = cx.signal(false);
let on_toggle: crate::widgets::SharedCallback<bool> = Rc::new(RefCell::new(self.on_toggle));
let toggle = move || {
let now = !folded.get_untracked();
folded.set(now);
if let Some(f) = on_toggle.borrow_mut().as_mut() {
f(now);
}
};
let handler = move |ctx: &mut EventCtx, ev: &UiEvent| match ev {
UiEvent::Key(k) if k.key == Key::Enter || k.key == Key::Char(' ') => {
if focused.get_untracked() {
toggle();
ctx.stop_propagation();
}
}
UiEvent::Mouse(m) if matches!(m.kind, MouseKind::Down(MouseButton::Left)) => {
toggle();
ctx.stop_propagation();
}
_ => {}
};
let title_for_dyn = title.clone();
let header = Element::new()
.style(
LayoutStyle::default()
.width(Dimension::Percent(1.0))
.height(Dimension::Cells(1))
.shrink(0.0),
)
.role(crate::ui::Role::Button)
.access_label(title.clone())
.access_value(move || {
if folded.get_untracked() {
"collapsed"
} else {
"expanded"
}
.into()
})
.focusable()
.hover_signal(hovered)
.focus_signal(focused)
.on(Phase::Bubble, handler)
.child(dyn_view(
LayoutStyle::default()
.width(Dimension::Percent(1.0))
.height(Dimension::Cells(1)),
move || {
let is_folded = folded.get();
let hover = hovered.get();
let focus = focused.get();
let title = title_for_dyn.clone();
let detail = detail.clone();
Element::new()
.style(
LayoutStyle::default()
.width(Dimension::Percent(1.0))
.height(Dimension::Cells(1)),
)
.draw(move |canvas, rect| {
if rect.is_empty() {
return;
}
draw_header(
canvas,
rect,
&title,
detail.as_deref(),
is_folded,
hover,
focus,
HeaderInks {
text_fg,
muted,
accent,
raised,
sel_fg,
sel_bg,
},
);
})
.build()
},
));
let mut make_body: Option<BodyBuilder> = match self.body {
Body::None => None,
Body::Text(s) => {
let fs = FeedState::new(cx);
fs.push("body", FeedItem::text(s));
Some(Box::new(move |gcx: Scope| {
Feed::new(&fs).gap(0).element(gcx, &tokens).build()
}))
}
Body::Markdown(s) => {
let fs = FeedState::new(cx);
fs.push("body", FeedItem::markdown(s));
Some(Box::new(move |gcx: Scope| {
Feed::new(&fs).gap(0).element(gcx, &tokens).build()
}))
}
Body::Build(f) => Some(f),
};
let cap = self.max_body_rows;
let extent: Signal<(i32, i32)> = cx.signal((0, 0));
let body_region = make_body.take().map(|mut build| {
dyn_view_scoped(
LayoutStyle::default()
.width(Dimension::Percent(1.0))
.shrink(0.0),
move |gcx| {
if folded.get() {
return Element::new().build();
}
let body = build(gcx);
let fill = Style::new().fg(text_fg).bg(ground);
if cap <= 0 {
return Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.padding(Edges::hv(1, 0))
.shrink(0.0),
)
.draw(move |canvas, rect| {
canvas.fill_styled(rect, ' ', &fill);
})
.child(body)
.build();
}
let scroll = Scroll::new(body)
.extent_signal(extent)
.scrollbar_auto_hide(true)
.element(gcx, &tokens)
.build();
Element::new()
.style_signal(move || {
let (mw, mh) = extent.get();
let shown = if mw == 0 && mh == 0 { cap } else { mh.min(cap) };
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Cells(shown.max(1)))
.padding(Edges::hv(1, 0))
.shrink(0.0)
})
.draw(move |canvas, rect| {
canvas.fill_styled(rect, ' ', &fill);
})
.child(scroll)
.build()
},
)
});
let layout = self.layout.unwrap_or_else(|| {
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.shrink(0.0)
});
let mut root = Element::new()
.style(layout)
.role(crate::ui::Role::Region)
.access_label(title)
.child(header.build());
if let Some(region) = body_region {
root = root.child(region);
}
root
}
}
#[derive(Copy, Clone)]
struct HeaderInks {
text_fg: crate::base::Rgba,
muted: crate::base::Rgba,
accent: crate::base::Rgba,
raised: crate::base::Rgba,
sel_fg: crate::base::Rgba,
sel_bg: crate::base::Rgba,
}
#[allow(clippy::too_many_arguments)]
fn draw_header(
canvas: &mut dyn crate::ui::StyledCanvas,
rect: crate::base::Rect,
title: &str,
detail: Option<&str>,
folded: bool,
hover: bool,
focus: bool,
inks: HeaderInks,
) {
let bg = if focus { inks.sel_bg } else { inks.raised };
let base_fg = if focus { inks.sel_fg } else { inks.text_fg };
let base = Style::new().fg(base_fg).bg(bg);
canvas.fill_styled(rect, ' ', &base);
let glyph = if folded { "▸" } else { "▾" };
let glyph_ink = if focus { inks.sel_fg } else { inks.accent };
if rect.w >= 2 {
canvas.print_styled(
crate::base::Point::new(rect.x + 1, rect.y),
glyph,
&Style::new().fg(glyph_ink).bg(bg),
);
}
let title_x = rect.x + 1 + crate::text::width(glyph) + 1;
let right = rect.right() - 1; let mut title_max = right - title_x;
let mut detail_at: Option<(i32, &str)> = None;
if let Some(d) = detail {
let dw = crate::text::width(d);
let remaining = right - title_x - dw - 1; if remaining >= 4 {
title_max = remaining;
detail_at = Some((right - dw, d));
}
}
let title_ink = if focus {
inks.sel_fg
} else if hover {
inks.accent
} else {
inks.text_fg
};
let shown = crate::text::truncate_ellipsis(title, title_max.max(0));
canvas.print_styled(
crate::base::Point::new(title_x, rect.y),
&shown,
&Style::new().fg(title_ink).bg(bg),
);
if let Some((dx, d)) = detail_at {
let detail_ink = if focus { inks.sel_fg } else { inks.muted };
canvas.print_styled(
crate::base::Point::new(dx, rect.y),
d,
&Style::new().fg(detail_ink).bg(bg),
);
}
}
#[cfg(test)]
#[path = "disclosure_tests.rs"]
mod tests;