use teksilo_canvas::{Canvas, Rect, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::Signal;
use teksilo_core::styles::{
TabBarChromeConfig, TabBarOrientation, TabIndicatorPosition, TabStyle, TabStyleConfig,
};
use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::CornerRadius;
use crate::primitives::{HStack, RectWidget, ZStack};
pub const TAB_EDITOR_HEIGHT: f32 = 50.0;
pub const TAB_TOOL_WINDOW_HEIGHT: f32 = 28.0;
pub const TAB_PADDING_HORIZONTAL: f32 = 12.0;
pub const TAB_UNDERLINE_ACTIVE: f32 = 2.0;
pub const TAB_UNDERLINE_HOVER: f32 = 2.0;
pub const TAB_CLOSE_BUTTON_SIZE: f32 = 16.0;
const DROP_INDICATOR_WIDTH: f32 = 2.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TabRecipe {
pub editor_height: f32,
pub tool_window_height: f32,
pub padding_horizontal: f32,
pub underline_active: f32,
pub underline_hover: f32,
pub close_button_size: f32,
}
impl Default for TabRecipe {
fn default() -> Self {
Self {
editor_height: TAB_EDITOR_HEIGHT,
tool_window_height: TAB_TOOL_WINDOW_HEIGHT,
padding_horizontal: TAB_PADDING_HORIZONTAL,
underline_active: TAB_UNDERLINE_ACTIVE,
underline_hover: TAB_UNDERLINE_HOVER,
close_button_size: TAB_CLOSE_BUTTON_SIZE,
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RecipeTabStyle {
pub recipe: TabRecipe,
}
impl RecipeTabStyle {
pub fn new(recipe: TabRecipe) -> Self {
Self { recipe }
}
}
impl TabStyle for RecipeTabStyle {
fn make_body(&self, cfg: &TabStyleConfig, ctx: &mut BuildContext) -> WidgetId {
let painter = ctx.add(TabBodyPainter {
is_active: cfg.is_active.clone(),
is_focused: cfg.is_focused.clone(),
is_disabled: cfg.is_disabled.clone(),
orientation: cfg.orientation,
indicator_position: cfg.indicator_position,
recipe: self.recipe,
});
let mut row = HStack::new();
if let Some(id) = cfg.leading {
row = row.add_child(id);
}
row = row.add_child(cfg.label);
if let Some(id) = cfg.trailing {
row = row.add_child(id);
}
let row_id = ctx.add(row);
ctx.add(ZStack::new().add_child(painter).add_child(row_id))
}
fn make_bar(&self, cfg: &TabBarChromeConfig, ctx: &mut BuildContext) -> WidgetId {
let painter = ctx.add(TabBarChromePainter {
orientation: cfg.orientation,
show_separator: cfg.show_separator,
drop_indicator: cfg.drop_indicator.clone(),
});
let mut layers = Vec::with_capacity(3);
if let Some(role) = &cfg.surface_role {
let backdrop = ctx.add(RectWidget::new().background(role.clone()));
layers.push(backdrop);
}
layers.push(painter);
layers.push(cfg.content);
ctx.add(TabBarChrome {
layers,
content: cfg.content,
})
}
}
#[derive(Debug)]
struct TabBarChrome {
layers: Vec<WidgetId>,
content: WidgetId,
}
impl Widget for TabBarChrome {
fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
self.layers.clone()
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
ctx.child_size(self.content, proposal)
.unwrap_or_else(|| proposal.resolve(0.0, 0.0))
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
}
fn children(&self) -> Vec<WidgetId> {
self.layers.clone()
}
}
struct TabBarChromePainter {
orientation: TabBarOrientation,
show_separator: bool,
drop_indicator: Signal<Option<f32>>,
}
impl std::fmt::Debug for TabBarChromePainter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TabBarChromePainter")
.field("orientation", &self.orientation)
.field("show_separator", &self.show_separator)
.finish()
}
}
impl Widget for TabBarChromePainter {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
self.drop_indicator.bind_to(
ctx.self_id(),
ctx.binding_registry(),
BindingLevel::RepaintOnly,
);
vec![]
}
fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
proposal.resolve(0.0, 0.0).into()
}
fn place_children(
&self,
_bounds: Rect,
_proposal: SizeProposal,
_children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
if self.show_separator {
let border_width = ctx.theme.shape.border_width;
let envelope = ctx.theme.shape.focus_ring_offset + ctx.theme.shape.focus_ring_width;
let separator = match self.orientation {
TabBarOrientation::Horizontal => Rect::new(
bounds.x,
(bounds.bottom() - envelope - border_width).max(bounds.y),
bounds.width,
border_width,
),
TabBarOrientation::Vertical => Rect::new(
(bounds.right() - envelope - border_width).max(bounds.x),
bounds.y,
border_width,
bounds.height,
),
};
canvas.fill_rect(separator, ctx.theme.colors.border);
}
if let Some(local_pos) = self.drop_indicator.get() {
let indicator = match self.orientation {
TabBarOrientation::Horizontal => {
let world_x = bounds.x + local_pos;
Rect::new(
(world_x - DROP_INDICATOR_WIDTH * 0.5).max(bounds.x),
bounds.y,
DROP_INDICATOR_WIDTH,
bounds.height,
)
}
TabBarOrientation::Vertical => {
let world_y = bounds.y + local_pos;
Rect::new(
bounds.x,
(world_y - DROP_INDICATOR_WIDTH * 0.5).max(bounds.y),
bounds.width,
DROP_INDICATOR_WIDTH,
)
}
};
canvas.fill_rect(indicator, ctx.theme.colors.accent);
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_hidden();
}
}
struct TabBodyPainter {
is_active: Signal<bool>,
is_focused: Signal<bool>,
is_disabled: Signal<bool>,
orientation: TabBarOrientation,
indicator_position: TabIndicatorPosition,
recipe: TabRecipe,
}
impl std::fmt::Debug for TabBodyPainter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TabBodyPainter")
.field("orientation", &self.orientation)
.field("indicator_position", &self.indicator_position)
.finish()
}
}
impl TabBodyPainter {
fn indicator_rect(&self, bounds: Rect, thickness: f32, rtl: bool) -> Rect {
match self.orientation {
TabBarOrientation::Horizontal => match self.indicator_position {
TabIndicatorPosition::OuterEdge => {
Rect::new(bounds.x, bounds.y, bounds.width, thickness)
}
TabIndicatorPosition::InnerEdge => Rect::new(
bounds.x,
bounds.bottom() - thickness,
bounds.width,
thickness,
),
},
TabBarOrientation::Vertical => {
let on_left = match self.indicator_position {
TabIndicatorPosition::OuterEdge => !rtl,
TabIndicatorPosition::InnerEdge => rtl,
};
let x = if on_left {
bounds.x
} else {
bounds.right() - thickness
};
Rect::new(x, bounds.y, thickness, bounds.height)
}
}
}
}
impl Widget for TabBodyPainter {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let id = ctx.self_id();
let registry = ctx.binding_registry();
self.is_active
.bind_to(id, registry, BindingLevel::RepaintOnly);
self.is_focused
.bind_to(id, registry, BindingLevel::RepaintOnly);
self.is_disabled
.bind_to(id, registry, BindingLevel::RepaintOnly);
vec![]
}
fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
proposal.resolve(0.0, 0.0).into()
}
fn place_children(
&self,
_bounds: Rect,
_proposal: SizeProposal,
_children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
let colors = &ctx.theme.colors;
let shape = &ctx.theme.shape;
let active = self.is_active.get();
let focused = self.is_focused.get();
let disabled = self.is_disabled.get();
let indicator_thickness = self.recipe.underline_active;
if active && !disabled {
let rtl = matches!(
ctx.layout_direction,
teksilo_core::environment::LayoutDirection::RightToLeft
);
let indicator = self.indicator_rect(bounds, indicator_thickness, rtl);
canvas.fill_rect(indicator, colors.accent);
}
if focused {
let half_stroke = shape.focus_ring_width * 0.5;
let inset = half_stroke + shape.focus_ring_offset;
let ring_rect = Rect::new(
bounds.x + inset,
bounds.y + inset,
(bounds.width - inset * 2.0).max(0.0),
(bounds.height - inset * 2.0).max(0.0),
);
canvas.stroke_rounded_rect(
ring_rect,
CornerRadius::uniform(shape.radius_control),
colors.focus_ring,
shape.focus_ring_width,
);
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_hidden();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn painter(
orientation: TabBarOrientation,
indicator_position: TabIndicatorPosition,
) -> TabBodyPainter {
TabBodyPainter {
is_active: Signal::new(true),
is_focused: Signal::new(false),
is_disabled: Signal::new(false),
orientation,
indicator_position,
recipe: TabRecipe::default(),
}
}
const B: Rect = Rect {
x: 10.0,
y: 20.0,
width: 100.0,
height: 40.0,
};
const T: f32 = TAB_UNDERLINE_ACTIVE;
#[test]
fn horizontal_outer_edge_is_top_and_rtl_invariant() {
let p = painter(
TabBarOrientation::Horizontal,
TabIndicatorPosition::OuterEdge,
);
let expected = Rect::new(B.x, B.y, B.width, T);
assert_eq!(p.indicator_rect(B, T, false), expected);
assert_eq!(
p.indicator_rect(B, T, true),
expected,
"top edge is RTL-invariant"
);
}
#[test]
fn horizontal_inner_edge_is_bottom() {
let p = painter(
TabBarOrientation::Horizontal,
TabIndicatorPosition::InnerEdge,
);
let expected = Rect::new(B.x, B.bottom() - T, B.width, T);
assert_eq!(p.indicator_rect(B, T, false), expected);
assert_eq!(p.indicator_rect(B, T, true), expected);
}
#[test]
fn vertical_outer_edge_is_leading() {
let p = painter(TabBarOrientation::Vertical, TabIndicatorPosition::OuterEdge);
assert_eq!(
p.indicator_rect(B, T, false),
Rect::new(B.x, B.y, T, B.height)
);
assert_eq!(
p.indicator_rect(B, T, true),
Rect::new(B.right() - T, B.y, T, B.height)
);
}
#[test]
fn vertical_inner_edge_is_trailing() {
let p = painter(TabBarOrientation::Vertical, TabIndicatorPosition::InnerEdge);
assert_eq!(
p.indicator_rect(B, T, false),
Rect::new(B.right() - T, B.y, T, B.height)
);
assert_eq!(
p.indicator_rect(B, T, true),
Rect::new(B.x, B.y, T, B.height)
);
}
}