use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::color_prop::ColorProp;
use teksilo_core::signal::{Prop, Signal};
use teksilo_core::styles::{RadioTileStyle, RadioTileStyleConfig, RadioTileVariant};
use teksilo_core::widget::{
LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
};
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::{BorderRole, CornerRadius, SurfaceRole};
pub const RADIO_TILE_CORNER_RADIUS: f32 = 4.0;
pub const RADIO_TILE_PADDING: f32 = 14.0;
pub const RADIO_TILE_BORDER_WIDTH: f32 = 1.0;
pub const RADIO_TILE_SELECTED_BORDER_WIDTH: f32 = 1.5;
pub const RADIO_TILE_FOCUS_RING_WIDTH: f32 = 2.0;
pub const RADIO_TILE_SHADOW_DENSITY: f32 = 0.5;
pub const RADIO_TILE_VERTICAL_ROW_HEIGHT: f32 = 44.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RadioTileRecipe {
pub corner_radius: f32,
pub padding: f32,
pub border_width: f32,
pub selected_border_width: f32,
pub focus_ring_width: f32,
pub shadow_density: f32,
pub vertical_row_height: f32,
}
impl Default for RadioTileRecipe {
fn default() -> Self {
Self {
corner_radius: RADIO_TILE_CORNER_RADIUS,
padding: RADIO_TILE_PADDING,
border_width: RADIO_TILE_BORDER_WIDTH,
selected_border_width: RADIO_TILE_SELECTED_BORDER_WIDTH,
focus_ring_width: RADIO_TILE_FOCUS_RING_WIDTH,
shadow_density: RADIO_TILE_SHADOW_DENSITY,
vertical_row_height: RADIO_TILE_VERTICAL_ROW_HEIGHT,
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RecipeRadioTileStyle {
pub recipe: RadioTileRecipe,
}
impl RecipeRadioTileStyle {
pub fn new(recipe: RadioTileRecipe) -> Self {
Self { recipe }
}
}
impl RadioTileStyle for RecipeRadioTileStyle {
fn vertical_row_height(&self) -> f32 {
self.recipe.vertical_row_height
}
fn make_body(&self, cfg: &RadioTileStyleConfig, ctx: &mut BuildContext) -> WidgetId {
let variant = cfg.variant;
let bg_role = tile_bg_signal(
&cfg.is_selected,
&cfg.is_pressed,
&cfg.is_hovered,
&cfg.is_window_active,
variant,
);
let border_width_base = self.recipe.border_width;
let border_state = cfg.is_hovered.map(move |hov| {
if *hov {
(BorderRole::Strong, border_width_base)
} else {
match variant {
RadioTileVariant::Filled => (BorderRole::Transparent, border_width_base),
_ => (BorderRole::Default, border_width_base),
}
}
});
let border_role = border_state.map(|(r, _)| *r);
let border_width = border_state.map(|(_, w)| *w);
let frame = RadioTileFrame {
content_id: None,
pending_content: Some(PendingChild::Id(cfg.content)),
bg: ColorProp::DynamicSurfaceRole(bg_role),
border: ColorProp::DynamicBorderRole(border_role),
border_width: Prop::Bound(border_width),
corner_radius: self.recipe.corner_radius,
padding: self.recipe.padding,
variant,
shadow_density: self.recipe.shadow_density,
is_compact: cfg.is_compact,
};
ctx.add(frame)
}
}
fn tile_bg_signal(
is_selected: &Signal<bool>,
is_pressed: &Signal<bool>,
is_hovered: &Signal<bool>,
is_window_active: &Signal<bool>,
variant: RadioTileVariant,
) -> Signal<SurfaceRole> {
let combined = is_selected.zip3(is_pressed, is_hovered);
combined
.zip(is_window_active)
.map(move |((selected, pressed, hovered), window_active)| {
let resting = match variant {
RadioTileVariant::Filled => SurfaceRole::Container,
_ => SurfaceRole::Transparent,
};
if *selected {
if *window_active {
SurfaceRole::AccentSubtle
} else {
SurfaceRole::SelectedInactive
}
} else if *pressed {
SurfaceRole::Pressed
} else if *hovered {
SurfaceRole::Hover
} else {
resting
}
})
}
struct RadioTileFrame {
content_id: Option<WidgetId>,
pending_content: Option<PendingChild>,
bg: ColorProp,
border: ColorProp,
border_width: Prop<f32>,
corner_radius: f32,
padding: f32,
variant: RadioTileVariant,
shadow_density: f32,
is_compact: bool,
}
impl std::fmt::Debug for RadioTileFrame {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RadioTileFrame")
.field("variant", &self.variant)
.finish()
}
}
impl Widget for RadioTileFrame {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
if let Some(pending) = self.pending_content.take() {
self.content_id = Some(match pending {
PendingChild::Id(id) => id,
PendingChild::Deferred(w) => ctx.add_boxed(w),
});
}
let id = ctx.self_id();
let registry = ctx.binding_registry();
self.bg
.register_if_bound(id, registry, BindingLevel::RepaintOnly);
self.border
.register_if_bound(id, registry, BindingLevel::RepaintOnly);
self.border_width
.register_if_bound(id, registry, BindingLevel::RepaintOnly);
self.content_id.into_iter().collect()
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
let inset = self.padding * 2.0;
if let Some(child_id) = self.content_id {
let inner_proposal = SizeProposal {
width: proposal.width.map(|w| (w - inset).max(0.0)),
height: proposal.height.map(|h| (h - inset).max(0.0)),
};
if let Some(child_size) = ctx.child_size(child_id, inner_proposal) {
return (Size::new(child_size.width + inset, child_size.height + inset)).into();
}
}
proposal.resolve(inset, inset).into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
ctx: &LayoutContext,
) {
let pad = self.padding;
let inner_w = (bounds.width - pad * 2.0).max(0.0);
for child in children.iter_mut() {
if self.is_compact {
let content_h = ctx
.child_size(
child.id,
SizeProposal {
width: Some(inner_w),
height: None,
},
)
.map(|s| s.height)
.unwrap_or(bounds.height)
.min(bounds.height);
let y = bounds.y + ((bounds.height - content_h) * 0.5).max(0.0);
child.origin = teksilo_canvas::Point::new(bounds.x + pad, y);
child.size = Size::new(inner_w, content_h);
} else {
child.origin = teksilo_canvas::Point::new(bounds.x + pad, bounds.y + pad);
child.size = Size::new(inner_w, (bounds.height - pad * 2.0).max(0.0));
}
}
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
let cr = CornerRadius::uniform(self.corner_radius);
if matches!(self.variant, RadioTileVariant::Elevated) {
crate::shadow::paint_layered_shadow(
canvas,
bounds,
cr,
&ctx.theme.shape.shadow_md,
&ctx.theme.shape.shadow_inner_md,
self.shadow_density,
None,
);
}
let bg = self.bg.resolve(ctx.theme, ctx.effective_enabled);
canvas.fill_rounded_rect(bounds, cr, bg);
let bc = self.border.resolve(ctx.theme, ctx.effective_enabled);
let bw = self.border_width.get();
if bw > 0.0 && bc.a() > 0.0 {
canvas.stroke_rounded_rect(bounds, cr, bc, bw);
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_hidden();
}
fn children(&self) -> Vec<WidgetId> {
self.content_id.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selected_surface_tracks_window_active_resting_is_transparent() {
let selected = Signal::new(true);
let pressed = Signal::new(false);
let hovered = Signal::new(false);
let window_active = Signal::new(true);
let role = tile_bg_signal(
&selected,
&pressed,
&hovered,
&window_active,
RadioTileVariant::Outlined,
);
assert_eq!(role.get(), SurfaceRole::AccentSubtle);
window_active.set(false);
assert_eq!(role.get(), SurfaceRole::SelectedInactive);
selected.set(false);
window_active.set(true);
assert_eq!(role.get(), SurfaceRole::Transparent);
}
#[test]
fn filled_variant_rests_on_container_surface() {
let selected = Signal::new(false);
let pressed = Signal::new(false);
let hovered = Signal::new(false);
let window_active = Signal::new(true);
let role = tile_bg_signal(
&selected,
&pressed,
&hovered,
&window_active,
RadioTileVariant::Filled,
);
assert_eq!(role.get(), SurfaceRole::Container);
}
}