use std::time::{Duration, Instant};
use figures::units::{Px, UPx};
use figures::{IntoSigned, Point, Rect, Round, ScreenScale, Size, Zero};
use kludgine::app::winit::event::{Modifiers, MouseButton};
use kludgine::app::winit::window::CursorIcon;
use kludgine::shapes::{Shape, StrokeOptions};
use kludgine::Color;
use crate::animation::{
AnimationHandle, AnimationTarget, IntoAnimate, LinearInterpolate, Spawn, ZeroToOne,
};
use crate::context::{AsEventContext, EventContext, GraphicsContext, LayoutContext, WidgetContext};
use crate::styles::components::{
AutoFocusableControls, CornerRadius, DefaultActiveBackgroundColor,
DefaultActiveForegroundColor, DefaultBackgroundColor, DefaultDisabledBackgroundColor,
DefaultDisabledForegroundColor, DefaultForegroundColor, DefaultHoveredBackgroundColor,
DefaultHoveredForegroundColor, Easing, HighlightColor, IntrinsicPadding, OpaqueWidgetColor,
OutlineColor, OutlineWidth, SurfaceColor, TextColor,
};
use crate::styles::{ColorExt, Styles};
use crate::value::{Destination, Dynamic, IntoValue, Source, Value};
use crate::widget::{
Callback, EventHandling, MakeWidget, SharedCallback, Widget, WidgetRef, HANDLED,
};
use crate::window::{DeviceId, WindowLocal};
use crate::FitMeasuredSize;
#[derive(Debug)]
pub struct Button {
pub content: WidgetRef,
pub on_click: Option<Callback<Option<ButtonClick>>>,
pub kind: Value<ButtonKind>,
focusable: bool,
per_window: WindowLocal<PerWindow>,
}
#[derive(Debug, Default)]
struct PerWindow {
buttons_pressed: usize,
modifiers: Modifiers,
cached_state: CacheState,
active_colors: Option<Dynamic<ButtonColors>>,
color_animation: AnimationHandle,
}
#[derive(Default, Debug, Eq, PartialEq, Clone, Copy)]
struct CacheState {
style: Option<ButtonColors>,
}
#[derive(Debug, Default, Eq, PartialEq, Clone, Copy)]
pub enum ButtonKind {
#[default]
Solid,
Outline,
Transparent,
}
impl ButtonKind {
#[must_use]
pub fn colors_for_default(
self,
visual_state: VisualState,
context: &WidgetContext<'_>,
) -> ButtonColors {
match self {
ButtonKind::Solid => match visual_state {
VisualState::Normal => ButtonColors {
background: context.get(&DefaultBackgroundColor),
foreground: context.get(&DefaultForegroundColor),
outline: context.get(&ButtonOutline),
},
VisualState::Hovered => ButtonColors {
background: context.get(&DefaultHoveredBackgroundColor),
foreground: context.get(&DefaultHoveredForegroundColor),
outline: context.get(&ButtonHoverOutline),
},
VisualState::Active => ButtonColors {
background: context.get(&DefaultActiveBackgroundColor),
foreground: context.get(&DefaultActiveForegroundColor),
outline: context.get(&ButtonActiveOutline),
},
VisualState::Disabled => ButtonColors {
background: context.get(&DefaultDisabledBackgroundColor),
foreground: context.get(&DefaultDisabledForegroundColor),
outline: context.get(&ButtonDisabledOutline),
},
},
ButtonKind::Outline | ButtonKind::Transparent => match visual_state {
VisualState::Normal => ButtonColors {
background: context.get(&ButtonOutline),
foreground: context.get(&DefaultBackgroundColor),
outline: context.get(&DefaultBackgroundColor),
},
VisualState::Hovered => ButtonColors {
background: context.get(&ButtonHoverOutline),
foreground: context.get(&DefaultHoveredBackgroundColor),
outline: context.get(&DefaultHoveredBackgroundColor),
},
VisualState::Active => ButtonColors {
background: context.get(&ButtonActiveOutline),
foreground: context.get(&DefaultActiveBackgroundColor),
outline: context.get(&DefaultActiveBackgroundColor),
},
VisualState::Disabled => ButtonColors {
background: context.get(&ButtonDisabledOutline),
foreground: context.get(&DefaultDisabledBackgroundColor),
outline: context.get(&DefaultDisabledBackgroundColor),
},
},
}
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, LinearInterpolate)]
pub struct ButtonColors {
pub background: Color,
pub foreground: Color,
pub outline: Color,
}
impl Button {
pub fn new(content: impl MakeWidget) -> Self {
Self {
content: content.into_ref(),
on_click: None,
per_window: WindowLocal::default(),
kind: Value::Constant(ButtonKind::default()),
focusable: true,
}
}
#[must_use]
pub fn kind(mut self, kind: impl IntoValue<ButtonKind>) -> Self {
self.kind = kind.into_value();
self
}
#[must_use]
pub fn on_click<F>(mut self, callback: F) -> Self
where
F: FnMut(Option<ButtonClick>) + Send + 'static,
{
self.on_click = Some(Callback::new(callback));
self
}
#[must_use]
pub fn prevent_focus(mut self) -> Self {
self.focusable = false;
self
}
fn invoke_on_click(&mut self, button: Option<ButtonClick>, context: &WidgetContext<'_>) {
if context.enabled() {
if let Some(on_click) = self.on_click.as_mut() {
on_click.invoke(button);
}
}
}
fn visual_style(context: &WidgetContext<'_>) -> VisualState {
if !context.enabled() {
VisualState::Disabled
} else if context.active() {
VisualState::Active
} else if context.hovered() {
VisualState::Hovered
} else {
VisualState::Normal
}
}
#[must_use]
pub fn colors_for_transparent(
visual_state: VisualState,
context: &WidgetContext<'_>,
) -> ButtonColors {
match visual_state {
VisualState::Normal => ButtonColors {
background: context
.try_get(&ButtonBackground)
.unwrap_or(Color::CLEAR_BLACK),
foreground: context.get(&TextColor),
outline: context.get(&ButtonOutline),
},
VisualState::Hovered => ButtonColors {
background: context.get(&OpaqueWidgetColor),
foreground: context.get(&TextColor),
outline: context.get(&ButtonHoverOutline),
},
VisualState::Active => ButtonColors {
background: context.get(&ButtonActiveBackground),
foreground: context.get(&ButtonActiveForeground),
outline: context.get(&ButtonActiveOutline),
},
VisualState::Disabled => ButtonColors {
background: context
.try_get(&ButtonDisabledBackground)
.unwrap_or(Color::CLEAR_BLACK),
foreground: context.theme().surface.on_color_variant,
outline: context.get(&ButtonDisabledOutline),
},
}
}
fn determine_stateful_colors(&mut self, context: &mut WidgetContext<'_>) -> ButtonColors {
let kind = self.kind.get_tracking_redraw(context);
let visual_state = Self::visual_style(context);
if context.is_default() {
kind.colors_for_default(visual_state, context)
} else {
match kind {
ButtonKind::Transparent => Self::colors_for_transparent(visual_state, context),
ButtonKind::Solid => visual_state.solid_colors(context),
ButtonKind::Outline => visual_state.outline_colors(context),
}
}
}
fn update_colors(&mut self, context: &mut WidgetContext<'_>, immediate: bool) {
let new_style = self.determine_stateful_colors(context);
let window_local = self.per_window.entry(context).or_default();
if window_local.cached_state.style.as_ref() == Some(&new_style) {
return;
}
window_local.cached_state.style = Some(new_style);
match (immediate, &window_local.active_colors) {
(false, Some(style)) => {
window_local.color_animation = (style.transition_to(new_style))
.over(Duration::from_millis(150))
.with_easing(context.get(&Easing))
.spawn();
}
(true, Some(style)) => {
style.set(new_style);
window_local.color_animation.clear();
}
_ => {
let new_style = Dynamic::new(new_style);
let foreground = new_style.map_each(|s| s.foreground);
window_local.active_colors = Some(new_style);
context.attach_styles(Styles::new().with(&TextColor, foreground));
}
}
}
fn current_style(&mut self, context: &mut WidgetContext<'_>) -> ButtonColors {
if self
.per_window
.entry(context)
.or_default()
.active_colors
.is_none()
{
self.update_colors(context, false);
}
let style = self
.per_window
.entry(context)
.or_default()
.active_colors
.as_ref()
.expect("always initialized");
context.redraw_when_changed(style);
style.get()
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum VisualState {
Normal,
Hovered,
Active,
Disabled,
}
impl VisualState {
#[must_use]
pub fn solid_colors(self, context: &WidgetContext<'_>) -> ButtonColors {
match self {
VisualState::Normal => ButtonColors {
background: context.get(&ButtonBackground),
foreground: context.get(&ButtonForeground),
outline: context.get(&ButtonOutline),
},
VisualState::Hovered => ButtonColors {
background: context.get(&ButtonHoverBackground),
foreground: context.get(&ButtonHoverForeground),
outline: context.get(&ButtonHoverOutline),
},
VisualState::Active => ButtonColors {
background: context.get(&ButtonActiveBackground),
foreground: context.get(&ButtonActiveForeground),
outline: context.get(&ButtonActiveOutline),
},
VisualState::Disabled => ButtonColors {
background: context.get(&ButtonDisabledBackground),
foreground: context.get(&ButtonDisabledForeground),
outline: context.get(&ButtonDisabledOutline),
},
}
}
#[must_use]
pub fn outline_colors(self, context: &WidgetContext<'_>) -> ButtonColors {
let solid = self.solid_colors(context);
ButtonColors {
background: solid.outline,
foreground: solid.foreground,
outline: solid.background,
}
}
}
impl Widget for Button {
fn summarize(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
fmt.debug_struct("Button")
.field("content", &self.content)
.field("kind", &self.kind)
.finish()
}
fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_>) {
#![allow(clippy::similar_names)]
let current_style = self.kind.get_tracking_redraw(context);
self.update_colors(context, false);
let style = self.current_style(context);
context.fill(style.background);
let outline_options = StrokeOptions::px_wide(
context
.get(&OutlineWidth)
.into_px(context.gfx.scale())
.ceil(),
);
context.stroke_outline(style.outline, outline_options);
if context.focused(true) {
if current_style == ButtonKind::Transparent {
let focus_color = context.get(&HighlightColor);
let color = if style.background.alpha() > 128 {
style
.background
.most_contrasting(&[focus_color, context.get(&TextColor)])
} else {
focus_color
}
.with_alpha(128);
let inset = context
.get(&IntrinsicPadding)
.into_px(context.gfx.scale())
.min(outline_options.line_width)
/ 2;
let options = outline_options.colored(color);
let radii = context.get(&CornerRadius);
let radii = radii.map(|r| r.into_px(context.gfx.scale()));
let ring_rect =
Rect::new(Point::squared(inset), context.gfx.region().size - inset * 2);
let focus_ring = if radii.is_zero() {
Shape::stroked_rect(ring_rect, options.into_px(context.gfx.scale()))
} else {
Shape::stroked_round_rect(ring_rect, radii, options)
};
context.gfx.draw_shape(&focus_ring);
} else if context.is_default() {
context.stroke_outline(context.get(&OutlineColor), outline_options);
} else {
context.draw_focus_ring();
}
}
let content = self.content.mounted(&mut context.as_event_context());
context.for_other(&content).redraw();
}
fn hit_test(&mut self, _location: Point<Px>, _context: &mut EventContext<'_>) -> bool {
true
}
fn accept_focus(&mut self, context: &mut EventContext<'_>) -> bool {
self.focusable && context.enabled() && context.get(&AutoFocusableControls).is_all()
}
fn mouse_down(
&mut self,
_location: Point<Px>,
_device_id: DeviceId,
_button: MouseButton,
context: &mut EventContext<'_>,
) -> EventHandling {
let per_window = self.per_window.entry(context).or_default();
per_window.buttons_pressed += 1;
per_window.modifiers = context.modifiers();
context.activate();
HANDLED
}
fn mouse_drag(
&mut self,
location: Point<Px>,
_device_id: DeviceId,
_button: MouseButton,
context: &mut EventContext<'_>,
) {
let changed = if Rect::from(context.last_layout().expect("must have been rendered").size)
.contains(location)
{
context.activate()
} else {
context.deactivate()
};
if changed {
context.set_needs_redraw();
}
}
fn mouse_up(
&mut self,
location: Option<Point<Px>>,
_device_id: DeviceId,
button: MouseButton,
context: &mut EventContext<'_>,
) {
let window_local = self.per_window.entry(context).or_default();
window_local.buttons_pressed -= 1;
if window_local.buttons_pressed == 0 {
context.deactivate();
if let (true, Some(location)) = (self.focusable, location) {
let last_layout = context.last_layout().expect("must have been rendered");
if Rect::from(last_layout.size).contains(location) {
context.focus();
let modifiers = window_local.modifiers;
self.invoke_on_click(
Some(ButtonClick {
mouse_button: button,
location,
window_location: location + last_layout.origin,
modifiers,
}),
context,
);
}
}
}
}
fn layout(
&mut self,
available_space: Size<crate::ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_>,
) -> Size<UPx> {
let outline_width = context
.get(&OutlineWidth)
.into_upx(context.gfx.scale())
.ceil();
let padding = context
.get(&IntrinsicPadding)
.into_upx(context.gfx.scale())
.round()
.max(outline_width);
let double_padding = padding * 2;
let mounted = self.content.mounted(context);
let available_space = available_space.map(|space| space - double_padding);
let size = context.for_other(&mounted).layout(available_space);
let size = available_space.fit_measured(size);
context.set_child_layout(
&mounted,
Rect::new(Point::squared(padding), size).into_signed(),
);
size + double_padding
}
fn unhover(&mut self, context: &mut EventContext<'_>) {
self.update_colors(context, false);
}
fn hover(
&mut self,
_location: Point<Px>,
context: &mut EventContext<'_>,
) -> Option<CursorIcon> {
self.update_colors(context, false);
if context.enabled() {
Some(CursorIcon::Pointer)
} else {
Some(CursorIcon::NotAllowed)
}
}
fn focus(&mut self, context: &mut EventContext<'_>) {
context.set_needs_redraw();
}
fn blur(&mut self, context: &mut EventContext<'_>) {
context.set_needs_redraw();
}
fn activate(&mut self, context: &mut EventContext<'_>) {
let window_local = self.per_window.entry(context).or_default();
if window_local.buttons_pressed == 0 {
self.invoke_on_click(None, context);
}
self.update_colors(context, true);
}
fn deactivate(&mut self, context: &mut EventContext<'_>) {
self.update_colors(context, false);
}
fn unmounted(&mut self, context: &mut EventContext<'_>) {
self.content.unmount_in(context);
}
}
define_components! {
Button {
ButtonBackground(Color, "background_color", @OpaqueWidgetColor)
ButtonActiveBackground(Color, "active_background_color", .surface.color)
ButtonHoverBackground(Color, "hover_background_color", |context| context.get(&ButtonBackground).darken_by(ZeroToOne::new(0.8)))
ButtonDisabledBackground(Color, "disabled_background_color", .surface.dim_color)
ButtonForeground(Color, "foreground_color", contrasting!(ButtonBackground, TextColor, SurfaceColor))
ButtonActiveForeground(Color, "active_foreground_color", contrasting!(ButtonActiveBackground, ButtonForeground, TextColor, SurfaceColor))
ButtonHoverForeground(Color, "hover_foreground_color", contrasting!(ButtonHoverBackground, ButtonForeground, TextColor, SurfaceColor))
ButtonDisabledForeground(Color, "disabled_foreground_color", contrasting!(ButtonDisabledBackground, ButtonForeground, TextColor, SurfaceColor))
ButtonOutline(Color, "outline_color", Color::CLEAR_BLACK)
ButtonActiveOutline(Color, "active_outline_color", Color::CLEAR_BLACK)
ButtonHoverOutline(Color, "hover_outline_color", Color::CLEAR_BLACK)
ButtonDisabledOutline(Color, "disabled_outline_color", Color::CLEAR_BLACK)
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct ButtonClick {
pub mouse_button: MouseButton,
pub location: Point<Px>,
pub window_location: Point<Px>,
pub modifiers: Modifiers,
}
pub struct ClickCounter {
threshold: Value<Duration>,
maximum: usize,
last_click: Option<Instant>,
count: usize,
on_click: SharedCallback<(usize, Option<ButtonClick>)>,
delay_fire: AnimationHandle,
}
impl ClickCounter {
#[must_use]
pub fn new<F>(threshold: impl IntoValue<Duration>, mut on_click: F) -> Self
where
F: FnMut(usize, Option<ButtonClick>) + Send + 'static,
{
Self {
threshold: threshold.into_value(),
maximum: usize::MAX,
last_click: None,
count: 0,
on_click: SharedCallback::new(move |(count, click)| on_click(count, click)),
delay_fire: AnimationHandle::new(),
}
}
#[must_use]
pub fn with_maximum(mut self, maximum: usize) -> Self {
self.maximum = maximum;
self
}
pub fn click(&mut self, click: Option<ButtonClick>) {
let now = Instant::now();
let threshold = self.threshold.get();
if let Some(last_click) = self.last_click {
let elapsed = now.saturating_duration_since(last_click);
if elapsed < threshold {
self.count += 1;
} else {
self.count = 1;
}
} else {
self.count = 1;
}
self.last_click = Some(now);
if self.count == self.maximum {
self.delay_fire.clear();
self.on_click.invoke((self.count, click));
self.count = 0;
} else {
let on_activation = self.on_click.clone();
let count = self.count;
self.delay_fire = threshold
.on_complete(move || {
on_activation.invoke((count, click));
})
.spawn();
}
}
}