pub mod containers;
pub mod dyn_list;
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 sortable_table;
pub mod stepper;
pub mod window_buttons;
use std::cell::{Cell, RefCell};
use std::path::Path;
use std::rc::Rc;
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::{Role, Style};
use crate::text::TextEngine;
use crate::theme::{Intent, IntentColors};
pub use image::{ImageContent, ImageView};
pub use inputs::{CheckBox, CheckBoxSize, 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 sortable_table::SortStyle;
pub use stepper::Stepper;
pub use window_buttons::{WindowButton, WindowButtonKind};
const ICON_GAP: i32 = 6;
const TABLE_CELL_PAD_X: i32 = 14;
const TABLE_CELL_PAD_Y: i32 = 9;
const TABLE_HEADER_PAD_Y: i32 = 10;
const TABLE_CELL_CORNER: f32 = 4.0;
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum Truncate {
#[default]
None, End, Start, Middle, }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortOrder {
Asc,
Desc,
}
fn text_fg(enabled: bool, style: &Style, theme: &crate::theme::Theme) -> Color {
if enabled {
style.resolved_fg(theme)
} else {
theme.palette.text_disabled
}
}
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 fg = text_fg(enabled, style, &crate::theme::current());
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, fg, style.text_align, family, fsize);
} else {
canvas.draw_text(&self.text, paint_rect, fg, 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 fg = text_fg(enabled, style, &crate::theme::current());
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, fg, style.text_align, family, fsize);
} else {
canvas.draw_text(&s, paint_rect, fg, 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,
variant: ButtonVariant,
}
#[derive(PartialEq, Eq, Clone, Copy)]
pub enum ButtonVariant {
Solid,
Outline,
}
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,
variant: ButtonVariant::Solid,
}
}
pub fn set_icon(&mut self, icon: ImageContent) {
self.icon = Some(icon);
}
pub fn set_intent(&mut self, intent: Intent) {
self.intent = intent;
}
pub fn set_variant(&mut self, variant: ButtonVariant) {
self.variant = variant;
}
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 is_outline = self.variant == ButtonVariant::Outline;
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 = if is_outline {
match vstate {
VisualState::Disabled => Color::TRANSPARENT,
_ => match self.state {
BtnState::Normal => Color::TRANSPARENT,
BtnState::Hover => ic.bg.scale_alpha(0.10),
BtnState::Press => ic.bg.scale_alpha(0.18),
},
}
} else {
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 outline_col = if matches!(self.intent, Intent::Neutral) {
pal.text_muted
} else {
ic.bg
};
let fg = if vstate == VisualState::Disabled {
pal.text_disabled
} else if is_outline {
outline_col
} else if style.fg_role.is_none() {
style.fg
} else if style.fg_role != Some(Role::Text) {
style.resolved_fg(&t)
} 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),
);
if is_outline {
let border = if vstate == VisualState::Disabled {
pal.text_disabled
} else {
outline_col
};
let bw = t.metrics.border_width.to_logical(canvas.dpi_scale());
canvas.stroke_round_rect(
bounds.x as f32,
bounds.y as f32,
bounds.w as f32,
bounds.h as f32,
r,
bw,
&Paint::fill(border),
);
}
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 reset_interaction(&mut self) {
self.state = BtnState::Normal;
self.primed.set(false); }
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>>,
en_cond: Option<Box<dyn Fn() -> bool>>,
tooltip: Option<String>,
reactive: bool,
}
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,
en_cond: None,
tooltip: None,
reactive: false,
}
}
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))
}
pub fn badge(text: impl Into<String>) -> Self {
Self::badge_intent(text, Intent::Primary)
}
pub fn badge_intent(text: impl Into<String>, intent: Intent) -> Self {
let th = crate::theme::current();
let base = match intent {
Intent::Primary => th.palette.accent,
other => other.colors(&th.palette).bg,
};
Element::row()
.cross(Align::Center)
.padding_xy(9, 3)
.corner(999.0)
.bg(base.scale_alpha(0.15))
.child(
Element::label(text.into())
.font_size(12.0)
.font_weight(600)
.fg(base),
)
}
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 icon_button(glyph: impl Into<String>) -> Self {
Self::base(Layout::None).widget(containers::IconButton::glyph(glyph))
}
pub fn icon_button_content(content: ImageContent) -> Self {
Self::base(Layout::None).widget(containers::IconButton::image(content))
}
pub fn on_click(mut self, f: impl FnMut(&mut EventCtx) + 'static) -> Self {
self.click = Some(Box::new(f));
self
}
pub fn clickable(mut self) -> Self {
debug_assert!(
self.widget.as_any_mut().is_none(),
"clickable() 仅用于容器(col/row/stack),不能叠加在叶子控件上"
);
self.widget = Box::new(containers::Clickable::new());
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(mut self) -> Self {
if let Some(a) = self.widget.as_any_mut() {
if let Some(c) = a.downcast_mut::<CheckBox>() {
c.set_size(CheckBoxSize::Small);
return self;
}
}
self.config_button(|b| b.size = ButtonSize::Small, "small()")
}
pub fn outline(self) -> Self {
self.config_button(|b| b.set_variant(ButtonVariant::Outline), "outline()")
}
pub fn enabled(mut self, flag: Signal<bool>) -> Self {
self.enabled = Some(flag);
self
}
pub fn enabled_when(mut self, f: impl Fn() -> bool + 'static) -> Self {
self.en_cond = Some(Box::new(f));
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))
}
fn config_slider(mut self, f: impl FnOnce(&mut Slider)) -> Self {
match self
.widget
.as_any_mut()
.and_then(|a| a.downcast_mut::<Slider>())
{
Some(s) => f(s),
None => debug_assert!(false, "show_value() 只能用于 Element::slider(..)"),
}
self
}
pub fn show_value(self, on: bool) -> Self {
self.config_slider(|s| s.set_show_value(on))
}
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 leading_icon(self, glyph: char) -> Self {
self.config_text_input(|c| c.leading = Some(glyph))
}
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 dropdown_reactive(options: Signal<Vec<String>>, selected: Signal<usize>) -> Self {
Self::base(Layout::None).widget(select::Dropdown::new_reactive(options, 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_pill(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).pill();
scroll = scroll.child(
Self::base(Layout::None)
.widget(row)
.width_match()
.height(list::ROW_H),
);
}
scroll
}
pub fn reactive(mut self) -> Self {
self.reactive = true;
self
}
pub fn list_signal<T, K>(
data: Signal<Vec<T>>,
_key_fn: impl Fn(&T) -> K + 'static,
row_fn: impl Fn(T) -> Self + 'static,
) -> Self
where
T: Clone + 'static,
K: Eq + std::hash::Hash,
{
let row_fn = std::rc::Rc::new(row_fn);
let initial: Vec<Self> = data.get().into_iter().map(|item| row_fn(item)).collect();
let row_fn_clone = row_fn.clone();
let widget = dyn_list::DynList::new(data, move |item: T| row_fn_clone(item));
let mut container = Self::scroll().fill();
container.widget = Box::new(widget);
container.reactive = true;
for el in initial {
container.children.push(el);
}
container
}
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 {
crate::app::register_modal(show);
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 dialog_panel(
show: Signal<bool>,
title: impl Into<String>,
width: i32,
on_close: impl FnMut(&mut EventCtx) + 'static,
body: Element,
footer: Element,
) -> Self {
let th = crate::theme::current();
let header = Element::row()
.width_match()
.cross(Align::Center)
.child(
Element::label(title.into())
.font_size(18.0)
.font_weight(700)
.fg(th.palette.text)
.weight(1.0)
.height(26),
)
.child(
Element::icon_button("\u{2715}")
.size(28, 28)
.fg(th.palette.text_muted)
.on_click(on_close),
);
let panel = Element::col()
.width(width)
.bg(th.palette.surface)
.corner(th.metrics.corner_lg)
.padding(20)
.spacing(16)
.child(header)
.child(body)
.child(footer);
Element::dialog(show, panel)
}
pub fn flex_spacer() -> Self {
Element::stack().weight(1.0)
}
pub fn grid(cols: usize, gap: i32, items: Vec<Element>) -> Self {
debug_assert!(cols >= 1, "grid 至少需要 1 列");
let cols = cols.max(1);
let mut container = Element::col().width_match().spacing(gap);
let mut iter = items.into_iter();
loop {
let mut cells: Vec<Element> = Vec::with_capacity(cols);
for _ in 0..cols {
match iter.next() {
Some(e) => cells.push(e),
None => break,
}
}
if cells.is_empty() {
break;
}
let n = cells.len();
let mut r = Element::row()
.width_match()
.spacing(gap)
.cross(Align::Stretch);
for e in cells {
r = r.child(e.weight(1.0));
}
for _ in n..cols {
r = r.child(Element::stack().weight(1.0)); }
container = container.child(r);
}
container
}
pub fn chip(text: impl Into<String>, on_remove: impl FnMut(&mut EventCtx) + 'static) -> Self {
let th = crate::theme::current();
let base = th.palette.accent;
Element::row()
.cross(Align::Center)
.spacing(4)
.padding_xy(9, 3)
.corner(999.0)
.bg(base.scale_alpha(0.14))
.child(
Element::label(text.into())
.font_size(12.5)
.fg(base)
.height(18),
)
.child(
Element::icon_button("\u{2715}")
.size(16, 16)
.font_size(11.0)
.fg(base)
.on_click(on_remove),
)
}
pub fn tag_field(placeholder: impl Into<String>, chips: Vec<Element>) -> Self {
let th = crate::theme::current();
let mut row = Element::row()
.width_match()
.cross(Align::Center)
.spacing(6)
.padding_xy(8, 6)
.corner(th.input.corner(&th.metrics))
.bg(th.input.bg(&th.palette))
.border(th.input.border(&th.palette), 1);
if chips.is_empty() {
row = row.child(
Element::label(placeholder.into())
.font_size(13.0)
.fg(th.palette.placeholder)
.weight(1.0)
.height(20),
);
} else {
for c in chips {
row = row.child(c);
}
}
row
}
pub fn table(
columns: Vec<(impl Into<String>, f32)>,
rows: Vec<Vec<impl Into<String>>>,
) -> Self {
let cols: Vec<(String, f32)> = columns.into_iter().map(|(t, w)| (t.into(), w)).collect();
let body: Vec<Vec<Element>> = rows
.into_iter()
.map(|r| {
r.into_iter()
.map(|c| Self::table_cell_pad(Element::label(c.into()).font_size(13.0)))
.collect()
})
.collect();
Self::table_custom(cols, body)
}
fn table_cell_pad(content: Element) -> Self {
Element::stack()
.padding_xy(TABLE_CELL_PAD_X, TABLE_CELL_PAD_Y)
.child(content.width_match().height(20))
}
pub fn table_custom(columns: Vec<(String, f32)>, rows: Vec<Vec<Element>>) -> Self {
let mut header = Element::row()
.width_match()
.cross(Align::Stretch)
.bg_role(Role::SurfaceAlt);
for (title, w) in &columns {
header = header.child(
Element::stack()
.weight(*w)
.padding_xy(TABLE_CELL_PAD_X, TABLE_HEADER_PAD_Y)
.child(
Element::label(title.clone())
.font_size(13.0)
.font_weight(600)
.fg_role(Role::TextMuted)
.width_match()
.height(18),
),
);
}
let mut scroll = Element::scroll().fill();
for (ri, cells) in rows.into_iter().enumerate() {
let mut tr = Element::row().width_match().cross(Align::Stretch);
if ri % 2 == 1 {
tr = tr.bg_role(Role::SurfaceAlt);
}
for (ci, cell) in cells.into_iter().enumerate() {
let w = columns.get(ci).map(|c| c.1).unwrap_or(1.0);
tr = tr.child(cell.weight(w));
}
tr.widget = Box::new(sortable_table::HoverRow::new());
scroll = scroll.child(
Element::col()
.width_match()
.child(tr)
.child(Element::divider()),
);
}
Element::col()
.width_match()
.child(header)
.child(Element::divider())
.child(scroll.weight(1.0))
}
pub fn table_editable(
columns: Vec<(impl Into<String>, f32)>,
cells: Vec<Vec<Signal<String>>>,
on_edit: impl FnMut(&mut EventCtx, usize, usize) + 'static,
) -> Self {
let cols: Vec<(String, f32)> = columns.into_iter().map(|(t, w)| (t.into(), w)).collect();
let cb = Rc::new(RefCell::new(on_edit));
let fg = crate::theme::current().palette.text;
let rows: Vec<Vec<Element>> = cells
.into_iter()
.enumerate()
.map(|(r, row)| {
row.into_iter()
.enumerate()
.map(|(c, sig)| {
let cb = cb.clone();
Element::stack()
.clickable()
.on_click(move |ctx| (cb.borrow_mut())(ctx, r, c))
.corner(TABLE_CELL_CORNER)
.padding_xy(TABLE_CELL_PAD_X, TABLE_CELL_PAD_Y)
.child(
Element::label_rc(sig)
.font_size(13.0)
.fg(fg)
.width_match()
.height(20),
)
})
.collect()
})
.collect();
Self::table_custom(cols, rows)
}
pub fn table_sortable(
columns: Vec<(impl Into<String>, f32)>,
rows: Vec<Vec<impl Into<String>>>,
sort: Signal<Option<(usize, SortOrder)>>,
) -> Self {
let cols: Vec<(String, f32)> = columns.into_iter().map(|(t, w)| (t.into(), w)).collect();
let data: Vec<Vec<String>> = rows
.into_iter()
.map(|r| r.into_iter().map(Into::into).collect())
.collect();
let weights: Vec<f32> = cols.iter().map(|c| c.1).collect();
let mut header = Element::row()
.width_match()
.cross(Align::Stretch)
.bg_role(Role::SurfaceAlt);
header.widget = Box::new(sortable_table::SortableHeader::new(cols, sort, None));
header.reactive = true;
let order = sortable_table::sorted_order(&data, sort.get());
let mut body = Element::col().width_match();
for (disp, &ri) in order.iter().enumerate() {
body = body.child(sortable_table::body_row(
disp, ri, &data[ri], &weights, None,
));
}
body.widget = Box::new(sortable_table::SortableBody::new(data, weights, sort));
body.reactive = true;
let scroll = Element::scroll().fill().child(body);
Element::col()
.width_match()
.child(header)
.child(Element::divider())
.child(scroll.weight(1.0))
}
pub fn table_sortable_server(
columns: Vec<(impl Into<String>, f32)>,
rows: Signal<Vec<Vec<String>>>,
sort: Signal<Option<(usize, SortOrder)>>,
on_sort: impl FnMut(&mut EventCtx, Option<(usize, SortOrder)>) + 'static,
) -> Self {
let cols: Vec<(String, f32)> = columns.into_iter().map(|(t, w)| (t.into(), w)).collect();
let weights: Vec<f32> = cols.iter().map(|c| c.1).collect();
let on_sort: sortable_table::OnSort = Rc::new(RefCell::new(on_sort));
let mut header = Element::row()
.width_match()
.cross(Align::Stretch)
.bg_role(Role::SurfaceAlt);
header.widget = Box::new(sortable_table::SortableHeader::new(
cols,
sort,
Some(on_sort),
));
header.reactive = true;
let initial = rows.get();
let mut body = Element::col().width_match();
for (disp, row) in initial.iter().enumerate() {
body = body.child(sortable_table::body_row(disp, disp, row, &weights, None));
}
body.widget = Box::new(sortable_table::PagedBody::new(rows, weights));
body.reactive = true;
let scroll = Element::scroll().fill().child(body);
Element::col()
.width_match()
.child(header)
.child(Element::divider())
.child(scroll.weight(1.0))
}
pub fn table_selectable(
columns: Vec<(impl Into<String>, f32)>,
rows: Vec<Vec<impl Into<String>>>,
selected: Vec<Signal<bool>>,
sort: Signal<Option<(usize, SortOrder)>>,
) -> Self {
let cols: Vec<(String, f32)> = columns.into_iter().map(|(t, w)| (t.into(), w)).collect();
let data: Vec<Vec<String>> = rows
.into_iter()
.map(|r| r.into_iter().map(Into::into).collect())
.collect();
let weights: Vec<f32> = cols.iter().map(|c| c.1).collect();
let scw = sortable_table::select_col_w();
let selectall = Element::label("")
.width(scw)
.widget(sortable_table::SelectAllCheck::new(selected.clone()));
let mut subrow = Element::row().weight(1.0).cross(Align::Stretch);
subrow.widget = Box::new(sortable_table::SortableHeader::new(cols, sort, None));
subrow.reactive = true;
let header = Element::row()
.width_match()
.cross(Align::Stretch)
.bg_role(Role::SurfaceAlt)
.child(selectall)
.child(subrow);
let mut body = Element::col().width_match();
body.widget = Box::new(sortable_table::SelectableBody::new(
data, weights, selected, sort,
));
body.reactive = true;
let scroll = Element::scroll().fill().child(body);
Element::col()
.width_match()
.child(header)
.child(Element::divider())
.child(scroll.weight(1.0))
}
pub fn sort_indicator(mut self, style: SortStyle) -> Self {
if let Some(header) = self.children.get_mut(0) {
if let Some(a) = header.widget.as_any_mut() {
if let Some(h) = a.downcast_mut::<sortable_table::SortableHeader>() {
h.set_style(style);
}
}
}
self
}
pub fn actions(
mut self,
title: impl Into<String>,
weight: f32,
build: impl Fn(usize) -> Element + 'static,
) -> Self {
let ac = sortable_table::action_col(title.into(), weight, build);
if let Some(header) = self.children.get_mut(0) {
if !sortable_table::set_header_actions(header, &ac) {
if let Some(sub) = header.children.get_mut(1) {
sortable_table::set_header_actions(sub, &ac);
}
}
}
if let Some(scroll) = self.children.last_mut() {
if let Some(body) = scroll.children.get_mut(0) {
sortable_table::set_body_actions(body, &ac);
}
}
self
}
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 is_reactive = self.reactive;
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,
en_cond: self.en_cond,
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,
prev_visible: Cell::new(true),
};
let id = tree.insert(node);
if is_reactive {
tree.register_reactive(id);
}
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 disabled_text_uses_text_disabled_color() {
let theme = crate::theme::Theme::default();
let style = Style {
fg: Color::hex(0x123456),
fg_role: None, ..Style::default()
};
assert_eq!(text_fg(true, &style, &theme), Color::hex(0x123456));
assert_eq!(text_fg(false, &style, &theme), theme.palette.text_disabled);
let muted = Style {
fg_role: Some(crate::style::Role::TextMuted),
..Style::default()
};
assert_eq!(text_fg(true, &muted, &theme), theme.palette.text_muted);
assert_eq!(text_fg(false, &muted, &theme), theme.palette.text_disabled);
}
struct CaptureCanvas {
last_text_color: std::cell::Cell<Option<Color>>,
}
impl crate::render::Canvas for CaptureCanvas {
fn dpi_scale(&self) -> f32 {
1.0
}
fn fill_rect(&mut self, _: f32, _: f32, _: f32, _: f32, _: &crate::render::Paint) {}
fn fill_round_rect(
&mut self,
_: f32,
_: f32,
_: f32,
_: f32,
_: f32,
_: &crate::render::Paint,
) {
}
fn stroke_round_rect(
&mut self,
_: f32,
_: f32,
_: f32,
_: f32,
_: f32,
_: f32,
_: &crate::render::Paint,
) {
}
fn draw_line(&mut self, _: f32, _: f32, _: f32, _: f32, _: f32, _: &crate::render::Paint) {}
fn fill_circle(&mut self, _: f32, _: f32, _: f32, _: &crate::render::Paint) {}
fn draw_shadow(&mut self, _: f32, _: f32, _: f32, _: f32, _: f32, _: f32, _: Color) {}
fn draw_image(
&mut self,
_: &crate::render::image::Image,
_: Rect,
_: crate::render::image::Fit,
_: f32,
_: f32,
) {
}
fn draw_text(
&mut self,
_text: &str,
_rect: Rect,
color: Color,
_align: crate::spec::Align,
_family: Option<&str>,
_size: f32,
) {
self.last_text_color.set(Some(color));
}
fn measure_text(&mut self, _: &str, _: Option<&str>, _: f32) -> Size {
Size::ZERO
}
fn push_layer(&mut self, _: f32) {}
fn pop_layer(&mut self) {}
fn save(&mut self) {}
fn restore(&mut self) {}
fn clip_rect(&mut self, _: Rect) {}
}
#[test]
fn label_paint_wires_enabled_to_text_color() {
use crate::core::Widget;
let style = Style {
fg: Color::hex(0x123456),
fg_role: None, ..Style::default()
};
let r = Rect::new(0, 0, 100, 20);
let disabled_col = crate::theme::current().palette.text_disabled;
let paint_color = |draw: &dyn Fn(&mut CaptureCanvas)| {
let mut cv = CaptureCanvas {
last_text_color: std::cell::Cell::new(None),
};
draw(&mut cv);
cv.last_text_color.get()
};
let label = Label::new("x".into());
assert_eq!(
paint_color(&|cv| label.paint(r, r, false, true, cv, &style)),
Some(Color::hex(0x123456))
);
assert_eq!(
paint_color(&|cv| label.paint(r, r, false, false, cv, &style)),
Some(disabled_col)
);
let dl = DynLabel::new(crate::signal::signal(String::from("y")));
assert_eq!(
paint_color(&|cv| dl.paint(r, r, false, true, cv, &style)),
Some(Color::hex(0x123456))
);
assert_eq!(
paint_color(&|cv| dl.paint(r, r, false, false, cv, &style)),
Some(disabled_col)
);
}
#[test]
fn hover_leave_reaches_interactive_container_with_child() {
use crate::core::{EventCtx, Widget};
use crate::event::{Event, MouseButton, PointerEvent, PointerKind};
use crate::geometry::Point;
use std::cell::Cell as StdCell;
use std::rc::Rc;
struct LeaveProbe(Rc<StdCell<u32>>);
impl Widget for LeaveProbe {
fn on_event(&mut self, _ctx: &mut EventCtx, ev: &Event) -> bool {
if let Event::Pointer(p) = ev {
if p.kind == PointerKind::Leave {
self.0.set(self.0.get() + 1);
}
}
false
}
}
let leaves = Rc::new(StdCell::new(0u32));
let ui = Element::row()
.fill()
.child(
Element::stack()
.width(50)
.height(50)
.widget(LeaveProbe(leaves.clone()))
.child(Element::label("x").fill()),
)
.child(Element::leaf().width(50).height(50));
let mut tree = Tree::new();
let root = ui.build(&mut tree);
tree.root = Some(root);
tree.layout_root(Size::new(100, 50), &mut crate::text::NullTextEngine);
let (mut hover, mut capture) = (None, None);
tree.dispatch_pointer(
PointerEvent::single(PointerKind::Move, Point::new(25, 25), MouseButton::Left),
&mut hover,
&mut capture,
);
tree.dispatch_pointer(
PointerEvent::single(PointerKind::Move, Point::new(75, 25), MouseButton::Left),
&mut hover,
&mut capture,
);
assert!(
leaves.get() >= 1,
"hover 移开容器后容器应收到 Leave,实得 {}",
leaves.get()
);
}
#[test]
fn grid_chunks_items_into_rows_and_pads_last() {
let items: Vec<Element> = (0..5).map(|_| Element::label("x")).collect();
let tree = layout(Element::grid(2, 8, items));
let root = tree.root.unwrap();
let rows = tree.get(root).unwrap().children.clone();
assert_eq!(rows.len(), 3, "5 项 2 列应分 3 行");
assert_eq!(
tree.get(rows[0]).unwrap().children.len(),
2,
"整行应有 2 个单元格"
);
assert_eq!(
tree.get(rows[2]).unwrap().children.len(),
2,
"末行应补空占位到 2 列"
);
}
#[test]
fn table_builds_header_divider_and_scroll_body() {
let tree = layout(Element::table(
vec![("A", 1.0), ("B", 1.0)],
vec![vec!["1", "2"], vec!["3", "4"]],
));
let root = tree.root.unwrap();
let top = tree.get(root).unwrap().children.clone();
assert_eq!(top.len(), 3, "表格 = 表头 + 分隔线 + 滚动正文");
let scroll = top[2];
assert_eq!(tree.get(scroll).unwrap().children.len(), 2, "正文应有 2 行");
}
#[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);
}
}