use std::time::Instant;
use crate::icon::Icon;
use crate::notification_sign::NotificationSign;
use crate::tag::Tag;
use crate::theme::ActiveTheme as _;
use crate::tooltip::Tooltip;
use fluidattacks_designs_domain::button::{
colors, padding, spacing, Justify, State, Variant, DOT_LEFT, DOT_LEFT_WITH_ICON, DOT_TOP,
ICON_SIZE, TEXT_SIZE,
};
use fluidattacks_designs_domain::icon::{Size as IconSize, Style as IconStyle};
use fluidattacks_designs_domain::tooltip::Place;
use fluidattacks_designs_domain::typography::FONT_PRIMARY;
use fluidattacks_designs_domain::{button, motion};
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, px, relative, rgb, App, ClickEvent, ElementId, Entity, InteractiveElement as _,
IntoElement, ParentElement as _, RenderOnce, Rgba, SharedString,
StatefulInteractiveElement as _, Styled as _, Window,
};
type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
#[derive(Default)]
struct LinkTarget {
url: Option<SharedString>,
external: bool,
}
#[derive(Clone, Copy)]
struct Paint {
bg: Option<Rgba>,
fg: Rgba,
border: Option<Rgba>,
}
impl From<button::Colors> for Paint {
fn from(palette: button::Colors) -> Self {
Self {
bg: palette.bg.map(opaque),
fg: opaque(palette.fg),
border: palette.border.map(opaque),
}
}
}
fn opaque(color: u32) -> Rgba {
rgb(color)
}
const fn faded(color: Rgba) -> Rgba {
Rgba {
alpha: 0.0,
..color
}
}
fn mix(from: Rgba, to: Rgba, amount: f32) -> Rgba {
let channel = |a: f32, b: f32| (b - a).mul_add(amount, a);
Rgba::new(
channel(from.color.red, to.color.red),
channel(from.color.green, to.color.green),
channel(from.color.blue, to.color.blue),
channel(from.alpha, to.alpha),
)
}
fn mix_surface(from: Option<Rgba>, to: Option<Rgba>, amount: f32) -> Option<Rgba> {
match (from, to) {
(None, None) => None,
(Some(from_color), Some(to_color)) => Some(mix(from_color, to_color, amount)),
(None, Some(to_color)) => Some(mix(faded(to_color), to_color, amount)),
(Some(from_color), None) => Some(mix(from_color, faded(from_color), amount)),
}
}
fn mix_paint(from: Paint, to: Paint, amount: f32) -> Paint {
Paint {
bg: mix_surface(from.bg, to.bg, amount),
fg: mix(from.fg, to.fg, amount),
border: mix_surface(from.border, to.border, amount),
}
}
#[derive(Clone, Copy)]
struct HoverAnimation {
hovered: bool,
shown: State,
transition: Option<(Instant, Paint)>,
}
impl Default for HoverAnimation {
fn default() -> Self {
Self {
hovered: false,
shown: State::Idle,
transition: None,
}
}
}
fn retarget(
animation_state: &Entity<HoverAnimation>,
variant: Variant,
state: State,
cx: &mut App,
) {
let theme = cx.theme().colors;
animation_state.update(cx, |animation, _cx| {
let previous_target = Paint::from(colors(variant, animation.shown, &theme));
let (current, _) = displayed(animation, previous_target);
animation.transition = Some((Instant::now(), current));
animation.shown = state;
});
}
fn displayed(animation: &HoverAnimation, target: Paint) -> (Paint, bool) {
match animation.transition {
None => (target, false),
Some((started, from)) => {
let progress = started.elapsed().as_secs_f32() * 1000.0 / motion::DURATION_MS;
if progress >= 1.0 {
(target, false)
} else {
(mix_paint(from, target, motion::ease(progress)), true)
}
}
}
}
#[derive(IntoElement)]
pub struct Button {
id: ElementId,
label: SharedString,
variant: Variant,
disabled: bool,
icon: Option<SharedString>,
icon_color: Option<u32>,
icon_style: IconStyle,
icon_size: IconSize,
right_icon: Option<SharedString>,
tag: Option<SharedString>,
notification: Option<u32>,
dot_notification: bool,
underline: bool,
link_target: LinkTarget,
width: Option<f32>,
justify: Justify,
tooltip: Option<(ElementId, SharedString)>,
tooltip_place: Place,
on_click: Option<ClickHandler>,
}
impl Button {
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
variant: Variant::default(),
disabled: false,
icon: None,
icon_color: None,
icon_style: IconStyle::Light,
icon_size: ICON_SIZE,
right_icon: None,
tag: None,
notification: None,
dot_notification: false,
underline: false,
link_target: LinkTarget::default(),
width: None,
justify: Justify::Center,
tooltip: None,
tooltip_place: Place::Bottom,
on_click: None,
}
}
#[must_use]
pub const fn variant(mut self, variant: Variant) -> Self {
self.variant = variant;
self
}
#[must_use]
pub const fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub fn icon(mut self, name: impl Into<SharedString>) -> Self {
self.icon = Some(name.into());
self
}
#[must_use]
pub const fn icon_color(mut self, color: u32) -> Self {
self.icon_color = Some(color);
self
}
#[must_use]
pub const fn icon_style(mut self, style: IconStyle) -> Self {
self.icon_style = style;
self
}
#[must_use]
pub const fn icon_size(mut self, size: IconSize) -> Self {
self.icon_size = size;
self
}
#[must_use]
pub fn right_icon(mut self, name: impl Into<SharedString>) -> Self {
self.right_icon = Some(name.into());
self
}
#[must_use]
pub fn tag(mut self, tag: impl Into<SharedString>) -> Self {
self.tag = Some(tag.into());
self
}
#[must_use]
pub const fn notification(mut self, count: u32) -> Self {
self.notification = Some(count);
self
}
#[must_use]
pub const fn dot_notification(mut self, dot: bool) -> Self {
self.dot_notification = dot;
self
}
#[must_use]
pub const fn underline(mut self, underline: bool) -> Self {
self.underline = underline;
self
}
#[must_use]
pub fn href(mut self, href: impl Into<SharedString>) -> Self {
self.link_target.url = Some(href.into());
self
}
#[must_use]
pub const fn external(mut self, external: bool) -> Self {
self.link_target.external = external;
self
}
#[must_use]
pub const fn width(mut self, width: f32) -> Self {
self.width = Some(width);
self
}
#[must_use]
pub const fn justify(mut self, justify: Justify) -> Self {
self.justify = justify;
self
}
#[must_use]
pub fn tooltip(mut self, id: impl Into<ElementId>, tip: impl Into<SharedString>) -> Self {
self.tooltip = Some((id.into(), tip.into()));
self
}
#[must_use]
pub const fn tooltip_place(mut self, place: Place) -> Self {
self.tooltip_place = place;
self
}
#[must_use]
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
fn effective_handler(&mut self) -> Option<ClickHandler> {
if !matches!(self.variant, Variant::Link) {
return self.on_click.take();
}
self.link_target.url.take().map(|href| {
let opener: ClickHandler =
Box::new(move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
cx.open_url(&href);
});
opener
})
}
}
fn transition_frame(
animation_state: &Entity<HoverAnimation>,
variant: Variant,
disabled: bool,
window: &Window,
cx: &mut App,
) -> Paint {
let animation = *animation_state.read(cx);
let state = if disabled {
State::Disabled
} else if animation.hovered {
State::Hover
} else {
State::Idle
};
if animation.shown != state {
retarget(animation_state, variant, state, cx);
}
let refreshed = *animation_state.read(cx);
let theme = cx.theme().colors;
let target = Paint::from(colors(variant, state, &theme));
let (painted, animating) = displayed(&refreshed, target);
if animating {
window.request_animation_frame();
}
painted
}
fn set_hovered(animation_state: &Entity<HoverAnimation>, hovered: bool, cx: &mut App) {
animation_state.update(cx, |animation, _| animation.hovered = hovered);
}
#[must_use]
fn with_tooltip(
button: gpui::Stateful<gpui::Div>,
tooltip: Option<(ElementId, SharedString)>,
place: Place,
) -> gpui::AnyElement {
match tooltip {
Some((tooltip_id, tip)) => Tooltip::new(tooltip_id, tip)
.place(place)
.child(button)
.into_any_element(),
None => button.into_any_element(),
}
}
#[must_use]
fn justified<E: gpui::Styled>(el: E, justify: Justify) -> E {
match justify {
Justify::Center => el.justify_center(),
Justify::Start => el.justify_start(),
Justify::End => el.justify_end(),
Justify::Between => el.justify_between(),
}
}
fn content(button: Button, painted: Paint) -> Vec<gpui::AnyElement> {
let mut children: Vec<gpui::AnyElement> = Vec::new();
let is_ghost = matches!(button.variant, Variant::Ghost);
if button.dot_notification && is_ghost && !button.disabled {
let left = if button.icon.is_some() {
DOT_LEFT_WITH_ICON
} else {
DOT_LEFT
};
children.push(
div()
.absolute()
.top(relative(DOT_TOP))
.left(relative(left))
.child(NotificationSign::new())
.into_any_element(),
);
}
if let Some(name) = button.icon {
let icon = Icon::new(name)
.style(button.icon_style)
.size(button.icon_size);
children.push(
match button.icon_color {
Some(color) => icon.color(color),
None => icon.color_rgba(painted.fg),
}
.into_any_element(),
);
}
children.push(button.label.into_any_element());
let arrow = matches!(button.variant, Variant::Link);
if button.right_icon.is_some() || arrow {
let name = button
.right_icon
.unwrap_or_else(|| SharedString::from("arrow-right"));
children.push(
Icon::new(name)
.style(IconStyle::Light)
.size(button.icon_size)
.color_rgba(painted.fg)
.into_any_element(),
);
}
if let Some(tag) = button.tag.filter(|_| !button.disabled) {
children.push(Tag::new(tag).into_any_element());
}
if let Some(count) = button.notification.filter(|_| is_ghost && !button.disabled) {
children.push(NotificationSign::new().count(count).into_any_element());
}
children
}
impl RenderOnce for Button {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let animation_state =
window.use_keyed_state(self.id.clone(), cx, |_, _| HoverAnimation::default());
let variant = self.variant;
let painted = transition_frame(&animation_state, variant, self.disabled, window, cx);
let (pad_y, pad_x) = padding(variant);
let box_spacing = spacing(variant);
let handler = self.effective_handler().filter(|_| !self.disabled);
let tooltip = self.tooltip.take();
let tooltip_place = self.tooltip_place;
let id = self.id.clone();
let disabled = self.disabled;
let underline = self.underline;
let width = self.width;
let justify = self.justify;
let has_dot = self.dot_notification;
let children = content(self, painted);
let button = div()
.id(id)
.flex()
.items_center()
.map(|el| justified(el, justify))
.when(has_dot, gpui::Styled::relative)
.gap(px(box_spacing))
.rounded(px(box_spacing))
.py(px(pad_y))
.px(px(pad_x))
.when_some(width, |el, width| el.w(px(width)))
.font_family(FONT_PRIMARY)
.text_size(px(TEXT_SIZE.px()))
.line_height(px(TEXT_SIZE.line_height_px()))
.when(underline, gpui::Styled::underline)
.whitespace_nowrap()
.text_color(painted.fg)
.when_some(painted.bg, gpui::Styled::bg)
.when_some(painted.border, |el, border| {
el.border_1().border_color(border)
})
.when(disabled, gpui::Styled::cursor_not_allowed)
.when(!disabled, |el| {
el.cursor_pointer()
.on_hover(move |is_hovered, hover_window, hover_cx| {
set_hovered(&animation_state, *is_hovered, hover_cx);
hover_window.refresh();
})
})
.when_some(handler, |el, handler| el.on_click(handler))
.children(children);
with_tooltip(button, tooltip, tooltip_place)
}
}