use std::cell::RefCell;
use std::rc::Rc;
use crate::layout::{Dimension, Style as LayoutStyle};
use crate::reactive::Scope;
use crate::render::{Attrs, Style};
use crate::theme::{TokenId, TokenSet};
use crate::ui::{dyn_view, Element, Key, MouseButton, MouseKind, Phase, UiEvent};
#[derive(Copy, Clone, Debug)]
pub struct ButtonStyle {
pub fg: TokenId,
pub bg: TokenId,
pub hover_ink: TokenId,
pub focus_fg: TokenId,
pub focus_bg: TokenId,
pub disabled_fg: TokenId,
}
impl Default for ButtonStyle {
fn default() -> Self {
ButtonStyle {
fg: TokenId::Text,
bg: TokenId::SurfaceRaised,
hover_ink: TokenId::Accent,
focus_fg: TokenId::SelectionFg,
focus_bg: TokenId::SelectionBg,
disabled_fg: TokenId::TextFaint,
}
}
}
pub struct Button {
label: String,
style: ButtonStyle,
layout: Option<LayoutStyle>,
disabled: bool,
on_click: Option<Box<dyn FnMut()>>,
}
impl Button {
pub fn new(label: impl Into<String>) -> Button {
Button {
label: label.into(),
style: ButtonStyle::default(),
layout: None,
disabled: false,
on_click: None,
}
}
pub fn style(mut self, style: ButtonStyle) -> Button {
self.style = style;
self
}
pub fn layout(mut self, layout: LayoutStyle) -> Button {
self.layout = Some(layout);
self
}
pub fn disabled(mut self, disabled: bool) -> Button {
self.disabled = disabled;
self
}
pub fn on_click(mut self, f: impl FnMut() + 'static) -> Button {
self.on_click = Some(Box::new(f));
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 fg = t.get(self.style.fg);
let bg = t.get(self.style.bg);
let hover_ink = t.get(self.style.hover_ink);
let focus_fg = t.get(self.style.focus_fg);
let focus_bg = t.get(self.style.focus_bg);
let disabled_fg = t.get(self.style.disabled_fg);
let label = self.label;
let disabled = self.disabled;
let width = crate::text::width(&label) + 2; let layout = self.layout.unwrap_or_else(|| {
LayoutStyle::default()
.width(Dimension::Cells(width))
.height(Dimension::Cells(1))
.shrink(0.0)
});
let hovered = cx.signal(false);
let pressed = cx.signal(false);
let focused = cx.signal(false);
let on_click: crate::widgets::SharedCallback<()> = Rc::new(RefCell::new(
self.on_click
.map(|mut f| Box::new(move |()| f()) as Box<dyn FnMut(())>),
));
let fire = {
let on_click = on_click.clone();
move || {
if let Some(f) = on_click.borrow_mut().as_mut() {
f(());
}
}
};
let mut el = Element::new()
.style(layout)
.role(crate::ui::Role::Button)
.access_label(label.clone())
.hover_signal(hovered)
.focus_signal(focused);
if disabled {
el = el.access_value(|| "disabled".into());
}
if !disabled {
el = el.focusable().on(Phase::Bubble, move |ctx, ev| match ev {
UiEvent::Key(k) if k.key == Key::Enter || k.key == Key::Char(' ') => {
if focused.get_untracked() {
fire();
ctx.stop_propagation();
}
}
UiEvent::Mouse(m) => match m.kind {
MouseKind::Down(MouseButton::Left) => {
pressed.set(true);
ctx.stop_propagation();
}
MouseKind::Up(MouseButton::Left) => {
let inside = ctx.current_rect().contains(m.pos);
if pressed.get_untracked() && inside {
fire();
}
pressed.set(false);
ctx.stop_propagation();
}
_ => {}
},
_ => {}
});
}
el.child(dyn_view(
LayoutStyle::default()
.width(Dimension::Percent(1.0))
.height(Dimension::Cells(1)),
move || {
let hovered = hovered.get();
let pressed = pressed.get();
let focused = focused.get();
let label = label.clone();
let (fg, bg, bold) = if disabled {
(disabled_fg, bg, false)
} else if pressed {
(focus_fg, focus_bg, true)
} else if focused {
(focus_fg, focus_bg, false)
} else if hovered {
(hover_ink, bg, false)
} else {
(fg, bg, false)
};
Element::new()
.style(LayoutStyle::default().width(Dimension::Percent(1.0)))
.draw(move |canvas, rect| {
if rect.is_empty() {
return;
}
let mut style = Style::new().fg(fg).bg(bg);
if bold {
style = style.attrs(Attrs::BOLD);
}
canvas.fill_styled(rect, ' ', &style);
let x = rect.x + ((rect.w - crate::text::width(&label)).max(0)) / 2;
canvas.print_styled(crate::base::Point::new(x, rect.y), &label, &style);
})
.build()
},
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::base::{Point, Size};
use crate::theme::default_theme;
use crate::ui::{Key, MouseButton, MouseKind};
use crate::widgets::itest_util::{click, key, mount_widget, mouse, render};
fn clicks() -> (Rc<RefCell<u32>>, impl FnMut() + 'static) {
let n = Rc::new(RefCell::new(0u32));
let n2 = n.clone();
(n, move || *n2.borrow_mut() += 1)
}
#[test]
fn mouse_click_fires_once_and_only_inside() {
let t = &default_theme().tokens;
let (count, on) = clicks();
let (_root, mut tree) = mount_widget(Size::new(12, 1), |cx| {
Button::new("Go").on_click(on).element(cx, t).build()
});
click(&mut tree, 2, 0);
assert_eq!(*count.borrow(), 1, "down+up inside = one click");
mouse(&mut tree, MouseKind::Down(MouseButton::Left), 2, 0);
mouse(&mut tree, MouseKind::Drag(MouseButton::Left), 40, 0);
mouse(&mut tree, MouseKind::Up(MouseButton::Left), 40, 0);
assert_eq!(*count.borrow(), 1, "release outside cancels");
}
#[test]
fn keyboard_activates_when_focused() {
let t = &default_theme().tokens;
let (count, on) = clicks();
let (_root, mut tree) = mount_widget(Size::new(12, 1), |cx| {
Button::new("Go").on_click(on).element(cx, t).build()
});
key(&mut tree, Key::Enter);
assert_eq!(*count.borrow(), 0, "unfocused button ignores Enter");
key(&mut tree, Key::Tab); key(&mut tree, Key::Enter);
key(&mut tree, Key::Char(' '));
assert_eq!(*count.borrow(), 2, "Enter and Space both click");
}
#[test]
fn hover_focus_press_follow_the_style_guide() {
let theme = default_theme();
let t = &theme.tokens;
let size = Size::new(12, 1);
let (_root, mut tree) = mount_widget(size, |cx| Button::new("Go").element(cx, t).build());
let base = render(&mut tree, size);
assert_eq!(base.cell(Point::new(3, 0)).unwrap().2, t.surface_raised);
mouse(&mut tree, MouseKind::Move, 3, 0); let hovered = render(&mut tree, size);
let label_x = (12 - 2) / 2; assert_eq!(
hovered.cell(Point::new(label_x, 0)).unwrap().1,
t.accent,
"hover shifts ink to accent"
);
assert_eq!(
hovered.cell(Point::new(label_x, 0)).unwrap().2,
t.surface_raised,
"hover never changes the ground (garnish only)"
);
mouse(&mut tree, MouseKind::Move, 40, 0); key(&mut tree, Key::Tab); let focused = render(&mut tree, size);
assert_eq!(
focused.cell(Point::new(3, 0)).unwrap().2,
t.selection_bg,
"borderless focus wears the selection pair"
);
assert_eq!(
focused.cell(Point::new(label_x, 0)).unwrap().1,
t.selection_fg
);
}
#[test]
fn disabled_button_neither_fires_nor_focuses() {
let t = &default_theme().tokens;
let (count, on) = clicks();
let (_root, mut tree) = mount_widget(Size::new(12, 1), |cx| {
Button::new("Go")
.disabled(true)
.on_click(on)
.element(cx, t)
.build()
});
click(&mut tree, 2, 0);
key(&mut tree, Key::Tab);
key(&mut tree, Key::Enter);
assert_eq!(*count.borrow(), 0);
assert_eq!(tree.focused(), None, "disabled = not in tab order");
}
#[test]
fn pressed_state_wears_selection_pair_bold() {
let theme = default_theme();
let t = &theme.tokens;
let size = Size::new(12, 1);
let (_root, mut tree) = mount_widget(size, |cx| Button::new("Go").element(cx, t).build());
mouse(&mut tree, MouseKind::Move, 3, 0);
mouse(&mut tree, MouseKind::Down(MouseButton::Left), 3, 0);
let pressed = render(&mut tree, size);
assert_eq!(pressed.cell(Point::new(3, 0)).unwrap().2, t.selection_bg);
assert!(
pressed.attrs_at(Point::new(3, 0)).contains(Attrs::BOLD),
"press = selection pair + BOLD (§3.2)"
);
}
}