use ribir_core::{
class_names,
prelude::{anchor::Anchor, *},
};
use crate::prelude::*;
#[derive(Clone)]
pub struct BadgeColor(pub Color);
#[derive(Clone, Debug, Default, PartialEq)]
pub enum BadgeContent {
#[default]
Hidden,
Dot,
Text(CowArc<str>),
}
impl BadgeContent {
fn into_widget(self) -> Widget<'static> {
match self {
Self::Hidden => void! {}.into_widget(),
Self::Dot => text! { text: "", class: BADGE_SMALL }.into_widget(),
Self::Text(text) => text! { text, class: BADGE_LARGE }.into_widget(),
}
}
}
impl<T> From<T> for BadgeContent
where
CowArc<str>: From<T>,
{
fn from(value: T) -> Self { Self::Text(CowArc::from(value)) }
}
#[derive(Clone, Declare, PartialEq)]
pub struct Badge {
#[declare(default)]
pub content: BadgeContent,
#[declare(default = Anchor::right_top(0., 0.))]
pub offset: Anchor,
}
#[derive(Clone, Declare, PartialEq)]
pub struct NumBadge {
#[declare(default)]
pub count: Option<u32>,
#[declare(default = 999u32)]
pub max_count: u32,
#[declare(default = Anchor::right_top(0., 0.))]
pub offset: Anchor,
}
class_names! {
BADGE_SMALL,
BADGE_LARGE,
}
impl<'a> ComposeChild<'a> for Badge {
type Child = Widget<'a>;
fn compose_child(this: impl StateWriter<Value = Self>, child: Self::Child) -> Widget<'a> {
stack! {
@ { child }
@InParentLayout {
@CustomAnchor {
data: pipe!($read(this).offset.clone()),
anchor: badge_layout_anchor,
@ { pipe!($read(this).content.clone()).map(BadgeContent::into_widget) }
}
}
}
.into_widget()
}
}
impl<'a> ComposeChild<'a> for NumBadge {
type Child = Widget<'a>;
fn compose_child(this: impl StateWriter<Value = Self>, child: Self::Child) -> Widget<'a> {
badge! {
content: pipe!($read(this).count).map(move |v| {
v.map_or(BadgeContent::Hidden, |count| {
let max = $read(this).max_count;
if count > max {
BadgeContent::Text(format!("{}+", max).into())
} else {
BadgeContent::Text(count.to_string().into())
}
})
}),
offset: pipe!($read(this).offset.clone()),
@ { child }
}
.into_widget()
}
}
fn badge_layout_anchor(
offset: &Anchor, badge_size: Size, clamp: BoxClamp, _ctx: &mut PlaceCtx,
) -> Anchor {
let x = offset.x.clone().unwrap_or_else(AnchorX::right);
let y = offset.y.clone().unwrap_or_else(AnchorY::top);
let host_size = clamp.max;
let attachment = Point::new(x.calculate(host_size.width, 0.), y.calculate(host_size.height, 0.));
Anchor::from_point(Point::new(
attachment.x - badge_size.width / 2.,
attachment.y - badge_size.height / 2.,
))
}
#[cfg(test)]
mod tests {
use ribir_core::test_helper::*;
use ribir_dev_helper::*;
use ribir_material as material;
use super::*;
#[allow(unused_imports)]
use crate::prelude::*;
#[test]
fn badge_partial_offset_keeps_default_top_right_axes() {
reset_test_env!();
let (top_badge_id, w_top_badge_id) = split_value(None::<WidgetId>);
let (right_badge_id, w_right_badge_id) = split_value(None::<WidgetId>);
let wnd = TestWindow::from_widget(fn_widget! {
@Column {
@Stack {
@MockBox { size: Size::new(24., 24.) }
@InParentLayout {
@CustomAnchor {
data: Anchor::top(4.),
anchor: badge_layout_anchor,
@MockBox {
size: Size::new(24., 16.),
on_mounted: move |e| *$write(w_top_badge_id) = Some(e.current_target()),
}
}
}
}
@Stack {
@MockBox { size: Size::new(24., 24.) }
@InParentLayout {
@CustomAnchor {
data: Anchor::right(-4.),
anchor: badge_layout_anchor,
@MockBox {
size: Size::new(24., 16.),
on_mounted: move |e| *$write(w_right_badge_id) = Some(e.current_target()),
}
}
}
}
}
});
wnd.draw_frame();
let top_badge_id = top_badge_id
.read()
.expect("top-only badge should mount for layout assertions");
let right_badge_id = right_badge_id
.read()
.expect("right-only badge should mount for layout assertions");
assert_eq!(wnd.widget_pos(top_badge_id), Some(Point::new(12., -4.)));
assert_eq!(wnd.widget_pos(right_badge_id), Some(Point::new(16., -8.)));
}
#[test]
fn badge_layout_can_place_badge_outside_host() {
reset_test_env!();
let (badge_id, w_badge_id) = split_value(None::<WidgetId>);
let wnd = TestWindow::from_widget(fn_widget! {
@Stack {
@MockBox { size: Size::new(24., 24.) }
@InParentLayout {
@CustomAnchor {
data: Anchor::right_top(0., 0.),
anchor: badge_layout_anchor,
@MockBox {
size: Size::new(24., 16.),
on_mounted: move |e| *$write(w_badge_id) = Some(e.current_target()),
}
}
}
}
});
wnd.draw_frame();
let id = badge_id
.read()
.expect("badge widget should mount for layout assertions");
assert_eq!(wnd.widget_pos(id), Some(Point::new(12., -8.)));
assert_eq!(
wnd
.layout_info_by_path(&[0])
.unwrap()
.size
.unwrap(),
Size::new(24., 24.)
);
}
#[test]
fn badge_content_shorthand_wraps_some() {
reset_test_env!();
let mut badge = Badge::declarer();
badge.with_content("error!");
let badge = badge.finish();
assert_eq!(badge.read().content, BadgeContent::Text("error!".into()));
}
#[test]
fn badge_content_hidden_keeps_hidden() {
reset_test_env!();
let mut badge = Badge::declarer();
badge.with_content(BadgeContent::Hidden);
let badge = badge.finish();
assert_eq!(badge.read().content, BadgeContent::Hidden);
}
#[test]
fn badge_dot_attaches_to_host_top_right() {
reset_test_env!();
AppCtx::set_app_theme(material::purple::light());
let wnd = TestWindow::new_with_size(
badge! {
content: BadgeContent::Dot,
@Container { size: Size::new(40., 40.), background: Color::GRAY }
},
Size::new(200., 200.),
);
wnd.draw_frame();
assert_eq!(wnd.layout_info_by_path(&[0, 1, 0]).unwrap().pos, Point::new(37., -3.));
}
widget_image_tests!(
badge,
WidgetTester::new(self::column! {
x: AnchorX::center(),
y: AnchorY::center(),
align_items: Align::Center,
@Badge {
content: BadgeContent::Dot,
@Container { size: Size::new(40., 40.), background: Color::GRAY }
}
@Badge {
content: "error!",
offset: Anchor::right(-14.),
@Container { size: Size::new(40., 40.)}
}
@NumBadge {
count: 1000,
max_count: 99_u32,
providers: [Provider::new(BadgeColor(Color::GREEN))],
@Container { size: Size::new(40., 40.), background: Color::GRAY }
}
})
.with_wnd_size(Size::new(200., 200.))
.with_comparison(0.0001),
);
}