pub mod containers;
pub mod image;
pub mod inputs;
pub mod link;
pub mod list;
pub mod nav;
pub mod progress;
pub mod segmented;
pub mod select;
pub mod stepper;
pub mod window_buttons;
use std::cell::{Cell, RefCell};
use std::path::Path;
use crate::anim::{Easing, Transition};
use crate::core::{ClickFn, DropFn, EmptyWidget, EventCtx, Layout, Node, NodeId, Tree, Widget};
use crate::event::{Event, Key, PointerKind};
use crate::geometry::{Color, Insets, Rect, Size};
use crate::render::image::{Fit, Image, VisualState};
use crate::render::{Canvas, Paint};
use crate::signal::Signal;
use crate::spec::{Align, Axis, Dimension};
use crate::style::Style;
use crate::text::TextEngine;
use crate::theme::{Intent, IntentColors};
pub use image::{ImageContent, ImageView};
pub use inputs::{CheckBox, RadioButton, Slider, Switch, TextInput};
pub use link::Link;
pub use list::ListRow;
pub use nav::{AccordionHeader, CollapsibleHeader, ExpandState, NavRow};
pub use progress::ProgressBar;
pub use segmented::SegmentedControl;
pub use select::Dropdown;
pub use stepper::Stepper;
pub use window_buttons::{WindowButton, WindowButtonKind};
const ICON_GAP: i32 = 6;
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum Truncate {
#[default]
None, End, Start, Middle, }
pub struct Label {
text: String,
pub max_lines: Option<usize>,
pub truncate: Truncate,
trunc_cache: RefCell<Option<(i32, u32, String)>>,
}
impl Label {
pub fn new(text: String) -> Self {
Self {
text,
max_lines: None,
truncate: Truncate::None,
trunc_cache: RefCell::new(None),
}
}
fn compute_truncated(
&self,
canvas: &mut dyn Canvas,
family: Option<&str>,
fsize: f32,
avail_w: i32,
) -> String {
let total_w = canvas.measure_text(&self.text, family, fsize).w;
if total_w <= avail_w {
return self.text.clone();
}
let ew = canvas.measure_text("…", family, fsize).w;
let avail = (avail_w - ew).max(0);
let chars: Vec<char> = self.text.chars().collect();
let n = chars.len();
let mut widths = vec![0i32; n + 1];
let mut acc = String::new();
for (i, &c) in chars.iter().enumerate() {
acc.push(c);
widths[i + 1] = canvas.measure_text(&acc, family, fsize).w;
}
match self.truncate {
Truncate::End => {
let cut = widths
.partition_point(|&w| w <= avail)
.saturating_sub(1)
.min(n);
format!("{}…", chars[..cut].iter().collect::<String>())
}
Truncate::Start => {
let threshold = total_w - avail;
let cut = widths.partition_point(|&w| w < threshold).min(n);
format!("…{}", chars[cut..].iter().collect::<String>())
}
Truncate::Middle => {
let lcut = widths
.partition_point(|&w| w <= avail / 2)
.saturating_sub(1)
.min(n);
let right_avail = (avail - widths[lcut]).max(0);
let threshold = total_w - right_avail;
let rcut = widths.partition_point(|&w| w < threshold).min(n);
let left: String = chars[..lcut].iter().collect();
let right: String = chars[rcut..].iter().collect();
format!("{left}…{right}")
}
Truncate::None => unreachable!(),
}
}
}
impl Widget for Label {
fn measure(&self, avail: Size, style: &Style, text: &mut dyn TextEngine) -> Size {
let max_w = if avail.w > 0 {
Some(avail.w as f32)
} else {
None
};
let full = text.measure(
&self.text,
style.font_family.as_deref(),
style.font_size,
max_w,
);
if let Some(max_n) = self.max_lines {
let line_h = text
.measure("Ay", style.font_family.as_deref(), style.font_size, None)
.h
.max(1);
Size::new(full.w, full.h.min(max_n as i32 * line_h))
} else {
full
}
}
fn paint(
&self,
_bounds: Rect,
content: Rect,
_focused: bool,
_enabled: bool,
canvas: &mut dyn Canvas,
style: &Style,
) {
let family = style.font_family.as_deref();
let fsize = style.font_size;
let (paint_rect, need_clip) = if let Some(max_n) = self.max_lines {
let line_h = canvas.measure_text("Ay", family, fsize).h.max(1);
let clipped = Rect::new(
content.x,
content.y,
content.w,
content.h.min(max_n as i32 * line_h),
);
(clipped, true)
} else {
(content, false)
};
if need_clip {
canvas.save();
canvas.clip_rect(paint_rect);
}
if self.truncate != Truncate::None && self.max_lines == Some(1) && !self.text.is_empty() {
let key_w = content.w;
let key_f = fsize.to_bits();
let cached: Option<String> = {
let c = self.trunc_cache.borrow();
c.as_ref().and_then(|(cw, cf, s)| {
if *cw == key_w && *cf == key_f {
Some(s.clone())
} else {
None
}
})
};
let text_str = if let Some(s) = cached {
s
} else {
let s = self.compute_truncated(canvas, family, fsize, content.w);
*self.trunc_cache.borrow_mut() = Some((key_w, key_f, s.clone()));
s
};
canvas.draw_text(
&text_str,
paint_rect,
style.resolved_fg(&crate::theme::current()),
style.text_align,
family,
fsize,
);
} else {
canvas.draw_text(
&self.text,
paint_rect,
style.resolved_fg(&crate::theme::current()),
style.text_align,
family,
fsize,
);
}
if need_clip {
canvas.restore();
}
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
pub struct DynLabel {
text: Signal<String>,
pub max_lines: Option<usize>,
pub truncate: Truncate,
trunc_cache: RefCell<Option<(String, i32, u32, String)>>,
}
impl DynLabel {
pub fn new(text: Signal<String>) -> Self {
Self {
text,
max_lines: None,
truncate: Truncate::None,
trunc_cache: RefCell::new(None),
}
}
fn compute_truncated(
&self,
s: &str,
canvas: &mut dyn Canvas,
family: Option<&str>,
fsize: f32,
avail_w: i32,
) -> String {
let total_w = canvas.measure_text(s, family, fsize).w;
if total_w <= avail_w {
return s.to_string();
}
let ew = canvas.measure_text("…", family, fsize).w;
let avail = (avail_w - ew).max(0);
let chars: Vec<char> = s.chars().collect();
let n = chars.len();
let mut widths = vec![0i32; n + 1];
let mut acc = String::new();
for (i, &c) in chars.iter().enumerate() {
acc.push(c);
widths[i + 1] = canvas.measure_text(&acc, family, fsize).w;
}
match self.truncate {
Truncate::End => {
let cut = widths
.partition_point(|&w| w <= avail)
.saturating_sub(1)
.min(n);
format!("{}…", chars[..cut].iter().collect::<String>())
}
Truncate::Start => {
let threshold = total_w - avail;
let cut = widths.partition_point(|&w| w < threshold).min(n);
format!("…{}", chars[cut..].iter().collect::<String>())
}
Truncate::Middle => {
let lcut = widths
.partition_point(|&w| w <= avail / 2)
.saturating_sub(1)
.min(n);
let right_avail = (avail - widths[lcut]).max(0);
let threshold = total_w - right_avail;
let rcut = widths.partition_point(|&w| w < threshold).min(n);
let left: String = chars[..lcut].iter().collect();
let right: String = chars[rcut..].iter().collect();
format!("{left}…{right}")
}
Truncate::None => unreachable!(),
}
}
}
impl Widget for DynLabel {
fn measure(&self, avail: Size, style: &Style, text: &mut dyn TextEngine) -> Size {
let s = self.text.get();
let max_w = if avail.w > 0 {
Some(avail.w as f32)
} else {
None
};
let full = text.measure(&s, style.font_family.as_deref(), style.font_size, max_w);
if let Some(max_n) = self.max_lines {
let line_h = text
.measure("Ay", style.font_family.as_deref(), style.font_size, None)
.h
.max(1);
Size::new(full.w, full.h.min(max_n as i32 * line_h))
} else {
full
}
}
fn paint(
&self,
_bounds: Rect,
content: Rect,
_focused: bool,
_enabled: bool,
canvas: &mut dyn Canvas,
style: &Style,
) {
let s = self.text.get();
let family = style.font_family.as_deref();
let fsize = style.font_size;
let (paint_rect, need_clip) = if let Some(max_n) = self.max_lines {
let line_h = canvas.measure_text("Ay", family, fsize).h.max(1);
let clipped = Rect::new(
content.x,
content.y,
content.w,
content.h.min(max_n as i32 * line_h),
);
(clipped, true)
} else {
(content, false)
};
if need_clip {
canvas.save();
canvas.clip_rect(paint_rect);
}
if self.truncate != Truncate::None && self.max_lines == Some(1) && !s.is_empty() {
let key_w = content.w;
let key_f = fsize.to_bits();
let cached: Option<String> = {
let c = self.trunc_cache.borrow();
c.as_ref().and_then(|(ks, cw, cf, out)| {
if ks.as_str() == s.as_str() && *cw == key_w && *cf == key_f {
Some(out.clone())
} else {
None
}
})
};
let text_str = if let Some(out) = cached {
out
} else {
let out = self.compute_truncated(&s, canvas, family, fsize, content.w);
*self.trunc_cache.borrow_mut() = Some((s.clone(), key_w, key_f, out.clone()));
out
};
canvas.draw_text(
&text_str,
paint_rect,
style.resolved_fg(&crate::theme::current()),
style.text_align,
family,
fsize,
);
} else {
canvas.draw_text(
&s,
paint_rect,
style.resolved_fg(&crate::theme::current()),
style.text_align,
family,
fsize,
);
}
if need_clip {
canvas.restore();
}
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
#[derive(PartialEq, Eq, Clone, Copy)]
enum BtnState {
Normal,
Hover,
Press,
}
#[derive(PartialEq, Eq, Clone, Copy)]
pub enum ButtonSize {
Small,
Medium,
}
impl ButtonSize {
fn padding(self) -> (i32, i32) {
match self {
ButtonSize::Small => (20, 10),
ButtonSize::Medium => (32, 18),
}
}
}
pub struct Button {
label: String,
icon: Option<ImageContent>,
state: BtnState,
on_click: Option<ClickFn>,
bg_anim: Cell<Transition<Color>>,
primed: Cell<bool>,
intent: Intent,
size: ButtonSize,
}
impl Button {
pub fn new(label: String) -> Self {
Self {
label,
icon: None,
state: BtnState::Normal,
on_click: None,
bg_anim: Cell::new(Transition::new(Color::rgba(0, 0, 0, 0))),
primed: Cell::new(false),
intent: Intent::Primary,
size: ButtonSize::Medium,
}
}
pub fn set_icon(&mut self, icon: ImageContent) {
self.icon = Some(icon);
}
pub fn set_intent(&mut self, intent: Intent) {
self.intent = intent;
}
fn visual_state(&self, enabled: bool) -> VisualState {
if !enabled {
return VisualState::Disabled;
}
match self.state {
BtnState::Normal => VisualState::Normal,
BtnState::Hover => VisualState::Hover,
BtnState::Press => VisualState::Pressed,
}
}
}
impl Widget for Button {
fn measure(&self, _avail: Size, style: &Style, text: &mut dyn TextEngine) -> Size {
let s = text.measure(
&self.label,
style.font_family.as_deref(),
style.font_size,
None,
);
let icon_extra = if self.icon.is_some() {
s.h + ICON_GAP
} else {
0
};
let (pad_w, pad_h) = self.size.padding();
Size::new(s.w + pad_w + icon_extra, s.h + pad_h)
}
fn paint(
&self,
bounds: Rect,
_content: Rect,
_focused: bool,
enabled: bool,
canvas: &mut dyn Canvas,
style: &Style,
) {
let t = crate::theme::current();
let (pal, bt) = (&t.palette, &t.button);
let vstate = self.visual_state(enabled);
let is_primary = matches!(self.intent, Intent::Primary);
let ic = if is_primary {
IntentColors {
bg: bt.bg(pal),
hover: bt.hover(pal),
active: bt.active(pal),
fg: bt.fg(pal),
}
} else {
self.intent.colors(pal)
};
let target = match vstate {
VisualState::Disabled => bt.disabled(pal),
_ => match &style.bg {
Some(bc) if is_primary => bc.solid_color(&t),
_ => match self.state {
BtnState::Normal => ic.bg,
BtnState::Hover => ic.hover,
BtnState::Press => ic.active,
},
},
};
let mut anim = self.bg_anim.get();
if !self.primed.get() {
anim = Transition::new(target);
self.primed.set(true);
} else if anim.target() != target {
anim.retarget(target, t.anim.fast(), Easing::EaseOut);
}
let color = anim.animate();
self.bg_anim.set(anim);
let fg = if vstate == VisualState::Disabled {
pal.text_disabled
} else if style.fg_role.is_some() {
style.resolved_fg(&t)
} else if is_primary && style.bg.is_some() {
style.fg
} else {
ic.fg
};
let r = if style.corner_radius > 0.0 {
style.corner_radius
} else {
bt.corner(&t.metrics)
};
canvas.fill_round_rect(
bounds.x as f32,
bounds.y as f32,
bounds.w as f32,
bounds.h as f32,
r,
&Paint::fill(color),
);
let Some(icon) = self.icon.as_ref() else {
canvas.draw_text(
&self.label,
bounds,
fg,
Align::Center,
style.font_family.as_deref(),
style.font_size,
);
return;
};
let ts = canvas.measure_text(&self.label, style.font_family.as_deref(), style.font_size);
let ih = ts.h; let total_w = ih + ICON_GAP + ts.w;
let start_x = bounds.x + ((bounds.w - total_w) / 2).max(0);
let icon_y = bounds.y + ((bounds.h - ih) / 2).max(0);
let icon_style = Style {
corner_radius: 0.0,
..style.clone()
};
icon.paint_into(
Rect::new(start_x, icon_y, ih, ih),
canvas,
&icon_style,
vstate,
);
let text_rect = Rect::new(start_x + ih + ICON_GAP, bounds.y, ts.w + 2, bounds.h);
canvas.draw_text(
&self.label,
text_rect,
fg,
Align::Start,
style.font_family.as_deref(),
style.font_size,
);
}
fn on_event(&mut self, ctx: &mut EventCtx, ev: &Event) -> bool {
match ev {
Event::Pointer(p) => match p.kind {
PointerKind::Enter => {
if self.state == BtnState::Normal {
self.state = BtnState::Hover;
ctx.mark_dirty();
}
true
}
PointerKind::Leave => {
if self.state != BtnState::Press {
self.state = BtnState::Normal;
ctx.mark_dirty();
}
true
}
PointerKind::Down => {
self.state = BtnState::Press;
ctx.capture();
ctx.request_focus();
ctx.mark_dirty();
true
}
PointerKind::Up => {
let was_press = self.state == BtnState::Press;
let inside = ctx.bounds().contains(p.pos);
self.state = if inside {
BtnState::Hover
} else {
BtnState::Normal
};
ctx.release_capture();
ctx.mark_dirty();
if was_press && inside {
if let Some(cb) = self.on_click.as_mut() {
cb(ctx);
}
}
true
}
_ => false,
},
Event::Key(k) => {
if k.pressed && (k.key == Key::Enter || k.key == Key::Space) {
if let Some(cb) = self.on_click.as_mut() {
cb(ctx);
}
ctx.mark_dirty();
true
} else {
false
}
}
}
}
fn focusable(&self) -> bool {
true
}
fn take_click(&mut self, f: ClickFn) {
self.on_click = Some(f);
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
pub struct Element {
width: Dimension,
height: Dimension,
padding: Insets,
margin: Insets,
align: Option<Align>,
weight: Option<f32>,
layout: Layout,
style: Style,
widget: Box<dyn Widget>,
children: Vec<Element>,
visible: bool,
vis_cond: Option<Box<dyn Fn() -> bool>>,
clip_children: bool,
click: Option<ClickFn>,
on_drop: Option<DropFn>,
context_menu: Option<crate::core::MenuFn>,
window_drag: bool,
enabled: Option<Signal<bool>>,
tooltip: Option<String>,
}
impl Element {
fn base(layout: Layout) -> Self {
Self {
width: Dimension::Wrap,
height: Dimension::Wrap,
padding: Insets::default(),
margin: Insets::default(),
align: None,
weight: None,
layout,
style: Style::default(),
widget: Box::new(EmptyWidget),
children: Vec::new(),
visible: true,
vis_cond: None,
clip_children: false,
click: None,
on_drop: None,
context_menu: None,
window_drag: false,
enabled: None,
tooltip: None,
}
}
pub fn col() -> Self {
Self::base(Layout::Linear {
axis: Axis::Vertical,
spacing: 0,
cross: Align::Start,
})
}
pub fn row() -> Self {
Self::base(Layout::Linear {
axis: Axis::Horizontal,
spacing: 0,
cross: Align::Start,
})
}
pub fn stack() -> Self {
Self::base(Layout::Frame)
}
pub fn leaf() -> Self {
Self::base(Layout::None)
}
pub fn label(text: impl Into<String>) -> Self {
Self::base(Layout::None).widget(Label::new(text.into()))
}
pub fn label_rc(text: Signal<String>) -> Self {
Self::base(Layout::None).widget(DynLabel::new(text))
}
fn config_label(mut self, f: impl FnOnce(&mut Label)) -> Self {
if let Some(a) = self.widget.as_any_mut() {
if let Some(l) = a.downcast_mut::<Label>() {
f(l);
return self;
}
}
debug_assert!(false, "max_lines()/truncate() 只能用于 Element::label(..)");
self
}
fn config_dynlabel(mut self, f: impl FnOnce(&mut DynLabel)) -> Self {
if let Some(a) = self.widget.as_any_mut() {
if let Some(l) = a.downcast_mut::<DynLabel>() {
f(l);
return self;
}
}
debug_assert!(
false,
"max_lines()/truncate() 只能用于 Element::label_rc(..)"
);
self
}
pub fn max_lines(mut self, n: usize) -> Self {
if self
.widget
.as_any_mut()
.and_then(|a| a.downcast_mut::<Label>())
.is_some()
{
return self.config_label(|l| l.max_lines = Some(n));
}
self.config_dynlabel(|l| l.max_lines = Some(n))
}
pub fn truncate(mut self, mode: Truncate) -> Self {
if self
.widget
.as_any_mut()
.and_then(|a| a.downcast_mut::<Label>())
.is_some()
{
return self.config_label(|l| l.truncate = mode);
}
self.config_dynlabel(|l| l.truncate = mode)
}
pub fn button(label: impl Into<String>) -> Self {
Self::base(Layout::None).widget(Button::new(label.into()))
}
pub fn on_click(mut self, f: impl FnMut(&mut EventCtx) + 'static) -> Self {
self.click = Some(Box::new(f));
self
}
pub fn on_toggle(mut self, f: impl FnMut(&mut EventCtx) + 'static) -> Self {
self.click = Some(Box::new(f));
self
}
pub fn on_drop_files(
mut self,
f: impl FnMut(&mut EventCtx, &[std::path::PathBuf]) + 'static,
) -> Self {
self.on_drop = Some(Box::new(f));
self
}
pub fn on_context_menu(
mut self,
build: impl FnMut() -> Vec<crate::event::MenuItem> + 'static,
) -> Self {
self.context_menu = Some(Box::new(build));
self
}
pub fn window_drag(mut self) -> Self {
self.window_drag = true;
self
}
pub fn window_button(kind: window_buttons::WindowButtonKind) -> Self {
Self::base(Layout::None).widget(window_buttons::WindowButton::new(kind))
}
pub fn link(text: impl Into<String>) -> Self {
Self::base(Layout::None).widget(link::Link::new(text.into()))
}
fn config_link(mut self, f: impl FnOnce(&mut link::Link)) -> Self {
match self
.widget
.as_any_mut()
.and_then(|a| a.downcast_mut::<link::Link>())
{
Some(l) => f(l),
None => debug_assert!(false, "url()/underline() 只能用于 Element::link(..)"),
}
self
}
pub fn url(self, url: impl Into<String>) -> Self {
let url = url.into();
self.config_link(move |l| l.set_url(url))
}
pub fn underline(self, on: bool) -> Self {
self.config_link(move |l| l.set_underline(on))
}
pub fn image(path: impl AsRef<Path>) -> Self {
Self::base(Layout::None).widget(ImageView::new(Image::from_file(path).ok()))
}
pub fn image_bytes(bytes: &[u8]) -> Self {
Self::base(Layout::None).widget(ImageView::new(Image::from_bytes(bytes).ok()))
}
#[cfg(feature = "svg")]
pub fn image_svg(bytes: &[u8], target_width: Option<u32>) -> Self {
Self::base(Layout::None).widget(ImageView::new(
Image::from_svg_bytes(bytes, target_width).ok(),
))
}
pub fn image_rgba(w: u32, h: u32, rgba: &[u8]) -> Self {
Self::base(Layout::None).widget(ImageView::new(Image::from_rgba(w, h, rgba).ok()))
}
pub fn image_content(content: ImageContent) -> Self {
Self::base(Layout::None).widget(ImageView::from_content(content))
}
fn config_image(mut self, f: impl FnOnce(&mut ImageView)) -> Self {
match self
.widget
.as_any_mut()
.and_then(|a| a.downcast_mut::<ImageView>())
{
Some(iv) => f(iv),
None => debug_assert!(false, "fit()/tint() 只能用于 Element::image*(..)"),
}
self
}
pub fn fit(self, fit: Fit) -> Self {
self.config_image(|iv| iv.set_fit(fit))
}
pub fn tint(self, color: Color) -> Self {
self.config_image(|iv| iv.set_tint(color))
}
pub fn icon_bytes(self, bytes: &[u8]) -> Self {
self.config_button_icon(ImageContent::from_bytes(bytes))
}
pub fn icon(self, path: impl AsRef<Path>) -> Self {
self.config_button_icon(ImageContent::from_file(path))
}
pub fn icon_rgba(self, w: u32, h: u32, rgba: &[u8]) -> Self {
self.config_button_icon(ImageContent::from_rgba(w, h, rgba))
}
#[cfg(feature = "svg")]
pub fn icon_svg(self, bytes: &[u8], target_width: Option<u32>) -> Self {
self.config_button_icon(ImageContent::from_svg_bytes(bytes, target_width))
}
pub fn icon_content(self, icon: ImageContent) -> Self {
self.config_button_icon(icon)
}
fn config_button_icon(self, icon: ImageContent) -> Self {
self.config_button(|b| b.set_icon(icon), "icon()/icon_bytes()")
}
pub fn small(self) -> Self {
self.config_button(|b| b.size = ButtonSize::Small, "small()")
}
pub fn enabled(mut self, flag: Signal<bool>) -> Self {
self.enabled = Some(flag);
self
}
pub fn tooltip(mut self, text: impl Into<String>) -> Self {
let text = text.into();
debug_assert!(!text.contains('\n'), "tooltip 仅支持单行文本");
self.tooltip = Some(text);
self
}
pub fn disabled(mut self, on: bool) -> Self {
if on {
self.enabled = Some(crate::signal::signal(false));
}
self
}
fn config_button(mut self, f: impl FnOnce(&mut Button), who: &str) -> Self {
match self
.widget
.as_any_mut()
.and_then(|a| a.downcast_mut::<Button>())
{
Some(b) => f(b),
None => debug_assert!(false, "{who} 只能用于 Element::button(..)"),
}
self
}
pub fn checkbox(label: impl Into<String>, state: Signal<bool>) -> Self {
Self::base(Layout::None).widget(CheckBox::new(label.into(), state))
}
pub fn intent(self, i: Intent) -> Self {
self.config_intent("intent()", i)
}
pub fn danger(self) -> Self {
self.config_intent("danger()", Intent::Danger)
}
pub fn neutral(self) -> Self {
self.config_intent("neutral()", Intent::Neutral)
}
pub fn accent(self, color: Color) -> Self {
self.config_intent("accent()", Intent::Custom(color))
}
fn config_intent(mut self, who: &str, i: Intent) -> Self {
if let Some(a) = self.widget.as_any_mut() {
if let Some(b) = a.downcast_mut::<Button>() {
b.set_intent(i);
return self;
}
if let Some(c) = a.downcast_mut::<CheckBox>() {
c.set_intent(i);
return self;
}
}
debug_assert!(false, "{who} 只能用于 Button / CheckBox");
self
}
pub fn switch(state: Signal<bool>) -> Self {
Self::base(Layout::None).widget(Switch::new(state))
}
pub fn radio(label: impl Into<String>, group: Signal<usize>, index: usize) -> Self {
Self::base(Layout::None).widget(RadioButton::new(label.into(), group, index))
}
pub fn slider(value: Signal<f32>) -> Self {
Self::base(Layout::None).widget(Slider::new(value))
}
pub fn text_input(text: Signal<String>, placeholder: impl Into<String>) -> Self {
Self::base(Layout::None).widget(TextInput::new(text, placeholder.into()))
}
fn config_text_input(mut self, f: impl FnOnce(&mut inputs::TextConfig)) -> Self {
match self
.widget
.as_any_mut()
.and_then(|a| a.downcast_mut::<TextInput>())
{
Some(ti) => f(ti.config_mut()),
None => debug_assert!(
false,
"password()/multiline()/wrap() 只能用于 Element::text_input(..)"
),
}
self
}
pub fn password(self) -> Self {
self.config_text_input(|c| {
c.password = true;
c.multiline = false;
})
}
pub fn multiline(self) -> Self {
self.config_text_input(|c| c.multiline = true)
}
pub fn wrap(self, on: bool) -> Self {
self.config_text_input(|c| c.wrap = on)
}
pub fn visible_when(mut self, f: impl Fn() -> bool + 'static) -> Self {
self.vis_cond = Some(Box::new(f));
self
}
pub fn segmented(options: Vec<impl Into<String>>, selected: Signal<usize>) -> Self {
let opts: Vec<String> = options.into_iter().map(|o| o.into()).collect();
debug_assert!(!opts.is_empty(), "Element::segmented 至少需要一段");
Self::base(Layout::None).widget(segmented::SegmentedControl::new(opts, selected))
}
pub fn dropdown(options: Vec<impl Into<String>>, selected: Signal<usize>) -> Self {
let opts: Vec<String> = options.into_iter().map(|o| o.into()).collect();
Self::base(Layout::None).widget(select::Dropdown::new(opts, selected))
}
pub fn stepper(value: Signal<f64>, min: f64, max: f64, step: f64) -> Self {
Self::base(Layout::None).widget(stepper::Stepper::new(value, min, max, step))
}
pub fn list(items: Vec<impl Into<String>>, selected: Signal<usize>) -> Self {
let mut scroll = Self::scroll().fill();
for (i, it) in items.into_iter().enumerate() {
let row = list::ListRow::new(it.into(), selected, i);
scroll = scroll.child(
Self::base(Layout::None)
.widget(row)
.width_match()
.height(list::ROW_H),
);
}
scroll
}
pub fn list_icons(
items: Vec<(impl Into<String>, ImageContent)>,
selected: Signal<usize>,
) -> Self {
let mut scroll = Self::scroll().fill();
for (i, (label, icon)) in items.into_iter().enumerate() {
let row = list::ListRow::new(label.into(), selected, i).with_icon(icon);
scroll = scroll.child(
Self::base(Layout::None)
.widget(row)
.width_match()
.height(list::ROW_H),
);
}
scroll
}
pub fn nav_row(label: impl Into<String>) -> Self {
Self::base(Layout::None)
.widget(nav::NavRow::new(label.into()))
.width_match()
.height(nav::NAV_ROW_H)
}
pub fn collapsible(title: impl Into<String>, expanded: Signal<bool>, body: Element) -> Self {
let header = Self::base(Layout::None)
.widget(nav::CollapsibleHeader::new(title.into(), expanded))
.width_match()
.height(nav::NAV_ROW_H);
let show = expanded;
Element::col()
.width_match()
.child(header)
.child(body.visible_when(move || show.get()))
}
pub fn accordion(selected: Signal<i32>, panels: Vec<(impl Into<String>, Element)>) -> Self {
Self::accordion_impl(panels, |i| nav::ExpandState::Single {
sel: selected,
index: i,
})
}
pub fn accordion_multi(panels: Vec<(impl Into<String>, Element)>) -> Self {
Self::accordion_impl(panels, |_| {
nav::ExpandState::Multi(crate::signal::signal(false))
})
}
fn accordion_impl(
panels: Vec<(impl Into<String>, Element)>,
make_state: impl Fn(usize) -> nav::ExpandState,
) -> Self {
use crate::style::Role;
let corner = {
let th = crate::theme::current();
th.accordion.corner(&th.metrics)
};
let mut card = Element::col()
.width_match()
.bg_role(Role::Surface)
.border_role(Role::AccordionBorder, 1)
.corner(corner);
for (i, (title, body)) in panels.into_iter().enumerate() {
if i > 0 {
card = card.child(
Element::base(Layout::None)
.width_match()
.height(1)
.bg_role(Role::Divider),
);
}
let state = make_state(i);
let header = Self::base(Layout::None)
.widget(nav::AccordionHeader::new(title.into(), state.clone()))
.width_match()
.height(nav::NAV_ROW_H)
.bg_role(Role::AccordionHeaderBg);
let show = state.clone();
card = card
.child(header)
.child(body.visible_when(move || show.is_expanded()));
}
card
}
pub fn progress(value: Signal<f32>) -> Self {
Self::base(Layout::None).widget(progress::ProgressBar::determinate(value))
}
pub fn progress_indeterminate() -> Self {
Self::base(Layout::None).widget(progress::ProgressBar::indeterminate())
}
pub fn scroll() -> Self {
let mut e = Self::base(Layout::Scroll).widget(containers::ScrollWidget::default());
e.clip_children = true;
e
}
pub fn divider() -> Self {
Self::base(Layout::None)
.width_match()
.height(1)
.bg_role(crate::style::Role::Divider)
}
pub fn tabs(selected: Signal<usize>, pages: Vec<(impl Into<String>, Element)>) -> Self {
let mut bar = Element::row()
.width_match()
.height(40)
.spacing(6)
.cross(Align::Stretch);
let mut content = Element::stack().fill().weight(1.0);
for (i, (title, page)) in pages.into_iter().enumerate() {
let tab = containers::TabButton::new(title.into(), selected, i);
bar = bar.child(Element::base(Layout::None).widget(tab));
let sel2 = selected;
content = content.child(page.fill().visible_when(move || sel2.get() == i));
}
Element::col().fill().spacing(10).child(bar).child(content)
}
pub fn tabs_icons(
selected: Signal<usize>,
pages: Vec<(impl Into<String>, ImageContent, Element)>,
) -> Self {
let mut bar = Element::row()
.width_match()
.height(40)
.spacing(6)
.cross(Align::Stretch);
let mut content = Element::stack().fill().weight(1.0);
for (i, (title, icon, page)) in pages.into_iter().enumerate() {
let tab = containers::TabButton::new(title.into(), selected, i).with_icon(icon);
bar = bar.child(Element::base(Layout::None).widget(tab));
let sel2 = selected;
content = content.child(page.fill().visible_when(move || sel2.get() == i));
}
Element::col().fill().spacing(10).child(bar).child(content)
}
pub fn dialog(show: Signal<bool>, content: Element) -> Self {
Element::stack()
.fill()
.widget(containers::ModalScrim)
.bg(Color::rgba(0, 0, 0, 120))
.visible_when(move || show.get())
.child(content.align(Align::Center))
}
pub fn widget(mut self, w: impl Widget + 'static) -> Self {
self.widget = Box::new(w);
self
}
pub fn width(mut self, px: i32) -> Self {
self.width = Dimension::Px(px);
self
}
pub fn height(mut self, px: i32) -> Self {
self.height = Dimension::Px(px);
self
}
pub fn size(self, w: i32, h: i32) -> Self {
self.width(w).height(h)
}
pub fn width_match(mut self) -> Self {
self.width = Dimension::Match;
self
}
pub fn height_match(mut self) -> Self {
self.height = Dimension::Match;
self
}
pub fn fill(self) -> Self {
self.width_match().height_match()
}
pub fn weight(mut self, w: f32) -> Self {
self.weight = Some(w);
self
}
pub fn padding(mut self, p: i32) -> Self {
self.padding = Insets::all(p);
self
}
pub fn padding_xy(mut self, h: i32, v: i32) -> Self {
self.padding = Insets::symmetric(h, v);
self
}
pub fn margin(mut self, m: i32) -> Self {
self.margin = Insets::all(m);
self
}
pub fn margin_xy(mut self, h: i32, v: i32) -> Self {
self.margin = Insets::symmetric(h, v);
self
}
pub fn align(mut self, a: Align) -> Self {
self.align = Some(a);
self
}
pub fn spacing(mut self, s: i32) -> Self {
if let Layout::Linear { spacing, .. } = &mut self.layout {
*spacing = s;
}
self
}
pub fn cross(mut self, a: Align) -> Self {
if let Layout::Linear { cross, .. } = &mut self.layout {
*cross = a;
}
self
}
pub fn bg(mut self, c: Color) -> Self {
self.style.bg = Some(crate::style::Brush::Solid(c));
self
}
pub fn bg_gradient(mut self, g: crate::render::Gradient) -> Self {
self.style.bg = Some(crate::style::Brush::Gradient(g));
self
}
pub fn bg_role(mut self, role: crate::style::Role) -> Self {
self.style.bg = Some(crate::style::Brush::Role(role));
self
}
pub fn border(mut self, c: Color, w: i32) -> Self {
self.style.border = Some((crate::style::Brush::Solid(c), w));
self
}
pub fn border_role(mut self, role: crate::style::Role, w: i32) -> Self {
self.style.border = Some((crate::style::Brush::Role(role), w));
self
}
pub fn corner(mut self, r: f32) -> Self {
self.style.corner_radius = r;
self
}
pub fn fg(mut self, c: Color) -> Self {
self.style.fg = c;
self.style.fg_role = None;
self
}
pub fn fg_role(mut self, role: crate::style::Role) -> Self {
self.style.fg_role = Some(role);
self
}
pub fn shadow(mut self, s: crate::style::Shadow) -> Self {
self.style.shadow = Some(s);
self
}
pub fn opacity(mut self, o: f32) -> Self {
self.style.opacity = o.clamp(0.0, 1.0);
self
}
pub fn font_size(mut self, s: f32) -> Self {
self.style.font_size = s;
self
}
pub fn font_weight(mut self, w: u16) -> Self {
self.style.font_weight = w;
self
}
pub fn text_align(mut self, a: Align) -> Self {
self.style.text_align = a;
self
}
pub fn child(mut self, c: Element) -> Self {
self.children.push(c);
self
}
pub fn children(mut self, cs: impl IntoIterator<Item = Element>) -> Self {
self.children.extend(cs);
self
}
pub fn visible(mut self, v: bool) -> Self {
self.visible = v;
self
}
pub fn build(mut self, tree: &mut Tree) -> NodeId {
let my_axis = match self.layout {
Layout::Linear { axis, .. } => Some(axis),
_ => None,
};
let children = std::mem::take(&mut self.children);
let mut widget = self.widget;
if let Some(f) = self.click {
widget.take_click(f);
}
let node = Node {
parent: None,
children: Vec::new(),
bounds: Default::default(),
measured: Default::default(),
width: self.width,
height: self.height,
padding: self.padding,
margin: self.margin,
align: self.align,
layout: self.layout,
widget,
style: self.style,
visible: self.visible,
vis_cond: self.vis_cond,
enabled: self.enabled,
on_drop: self.on_drop,
context_menu: self.context_menu,
window_drag: self.window_drag,
tooltip: self.tooltip,
focused: false,
clip_children: self.clip_children,
scroll_y: 0,
content_h: 0,
over_scroll: 0,
};
let id = tree.insert(node);
for mut ce in children {
if let (Some(axis), Some(w)) = (my_axis, ce.weight) {
match axis {
Axis::Horizontal => ce.width = Dimension::Weight(w),
Axis::Vertical => ce.height = Dimension::Weight(w),
}
}
let cid = ce.build(tree);
tree.add_child(id, cid);
}
id
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::Point;
use crate::signal::signal;
use std::path::PathBuf;
use std::rc::Rc;
fn layout(el: Element) -> Tree {
let mut tree = Tree::new();
let root = el.build(&mut tree);
tree.root = Some(root);
tree.layout_root(Size::new(200, 200), &mut crate::text::NullTextEngine);
tree
}
#[test]
fn drop_routes_to_widget_under_point() {
let got: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
let sink = got.clone();
let tree = layout(Element::col().fill().on_drop_files(move |_ctx, paths| {
sink.borrow_mut().extend_from_slice(paths);
}));
let mut tree = tree;
let res = tree.dispatch_files(
Point::new(50, 50),
vec![PathBuf::from("a.txt"), PathBuf::from("b.png")],
);
assert!(res.consumed, "落点命中带回调的容器应消费");
assert_eq!(got.borrow().len(), 2, "回调应收到 2 个文件");
assert_eq!(got.borrow()[0], PathBuf::from("a.txt"));
}
#[test]
fn drop_ignored_when_no_handler() {
let mut tree = layout(Element::col().fill());
let res = tree.dispatch_files(Point::new(50, 50), vec![PathBuf::from("a.txt")]);
assert!(!res.consumed, "无回调时拖放不消费");
}
#[test]
fn window_drag_hits_caption_not_button() {
let tree = layout(
Element::row()
.width_match()
.height(40)
.window_drag()
.child(Element::label("标题").width(120).height(40))
.child(
Element::window_button(WindowButtonKind::Close)
.width(46)
.height(40),
),
);
assert!(tree.drag_hit_at(Point::new(40, 20)), "标题文字区应为拖动区");
assert!(!tree.drag_hit_at(Point::new(130, 20)), "按钮区不应拖动窗口");
assert!(
tree.interactive_hit_at(Point::new(130, 20)),
"按钮区应判为交互控件"
);
assert!(
!tree.interactive_hit_at(Point::new(40, 20)),
"标题文字区不应判为交互控件"
);
}
#[test]
fn window_button_click_requests_op() {
let mut tree = layout(
Element::window_button(WindowButtonKind::Minimize)
.width(46)
.height(40),
);
let mut hover = None;
let mut capture = None;
let at = Point::new(20, 20);
tree.dispatch_pointer(
crate::event::PointerEvent::single(
PointerKind::Down,
at,
crate::event::MouseButton::Left,
),
&mut hover,
&mut capture,
);
let res = tree.dispatch_pointer(
crate::event::PointerEvent::single(
PointerKind::Up,
at,
crate::event::MouseButton::Left,
),
&mut hover,
&mut capture,
);
assert_eq!(
res.window_op,
Some(crate::event::WindowOp::Minimize),
"最小化按钮点击应请求 Minimize"
);
}
#[test]
fn tooltip_attaches_to_node_and_resolves_by_hit() {
let tree = layout(
Element::col().fill().child(
Element::label("帮助")
.width(100)
.height(30)
.tooltip("说明文本"),
),
);
let hit = tree.hit_test(Point::new(20, 15)).expect("应命中标签");
assert_eq!(
tree.node_tooltip(hit).as_deref(),
Some("说明文本"),
"命中节点应取到 tooltip"
);
assert_eq!(
tree.node_tooltip(tree.root.unwrap()),
None,
"未设 tooltip 的节点应为 None"
);
}
#[test]
fn drop_skips_disabled_subtree() {
let got = signal(0u32);
let sink = got;
let mut tree = layout(
Element::col()
.fill()
.disabled(true)
.on_drop_files(move |_ctx, _paths| sink.set(sink.get() + 1)),
);
let res = tree.dispatch_files(Point::new(50, 50), vec![PathBuf::from("a.txt")]);
assert!(!res.consumed, "禁用子树不接收拖放");
assert_eq!(got.get(), 0);
}
}