use std::cell::RefCell;
use std::rc::Rc;
use teksilo_i18n::lit;
use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
use teksilo_core::DropFeedback;
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::drag_payload::{DragPayload, DropOutcome};
use teksilo_core::event::{EventResponse, ScrollDelta, WidgetEvent};
use teksilo_core::overlay::OverlayPlacement;
use teksilo_core::signal::Signal;
use teksilo_core::widget::{
EventContext, LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget,
WidgetPlacement,
};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use teksilo_data::{ListDataSource, ListModel};
use teksilo_i18n::LocalizedString;
use teksilo_tokens::Easing;
use crate::list_source::ListSource;
use crate::primitives::FixedSize;
use crate::scroll_area::{ScrollArea, ScrollBarMode, ScrollBarPolicy};
use crate::tab_widget::delegate::{
TabBarOrientation, TabDelegate, TabDisplayMode, TabOverflowButton, TabSizing,
};
use crate::tab_widget::header::{HeaderShared, TabHeader, TabHeaderConfig};
use crate::tab_widget::id::TabId;
use crate::{
Button, ButtonVariant, Expand, HStack, IconButton, IconButtonSize, IconWidget, ListView, Panel,
PopoverIconButton,
};
use teksilo_core::accesskit::HasPopup;
use teksilo_tokens::{BorderRole, SurfaceRole, TextRole};
use std::collections::HashMap;
pub const DEFAULT_MIN_TAB_WIDTH: f32 = 96.0;
pub const DEFAULT_MAX_TAB_WIDTH: f32 = 240.0;
pub const DEFAULT_TAB_SPACING: f32 = 0.0;
pub const DEFAULT_BAR_SLOT_SPACING: f32 = 8.0;
pub const DEFAULT_PINNED_TAB_WIDTH: f32 = 32.0;
const SCROLL_ARROW_STEP: f32 = 120.0;
const WHEEL_LINE_PIXELS: f32 = 64.0;
const DRAG_EDGE_ZONE: f32 = 32.0;
const DRAG_MAX_VELOCITY: f32 = 12.0;
pub struct TabBarDragData<T: 'static> {
pub source_index: usize,
pub source_bar_id: WidgetId,
pub source_id: TabId,
pub item: Option<T>,
}
pub struct TabBar<T: 'static> {
source: ListSource<T>,
delegate: TabDelegate<T>,
selected_id: Signal<Option<TabId>>,
id_of: Rc<dyn Fn(usize, &T) -> TabId>,
selected: Signal<usize>,
orientation: TabBarOrientation,
sizing: TabSizing,
tab_display: TabDisplayMode,
min_tab_width: f32,
max_tab_width: f32,
pinned_tab_width: f32,
spacing: f32,
tab_height: Option<f32>,
tab_background: Option<teksilo_core::color_prop::ColorProp>,
selected_tab_background: Option<teksilo_core::color_prop::ColorProp>,
hover_tab_background: Option<teksilo_core::color_prop::ColorProp>,
idle_tab_background: Option<teksilo_core::color_prop::ColorProp>,
bar_background: Option<teksilo_core::color_prop::ColorProp>,
selected_text_role: TextRole,
idle_text_role: TextRole,
tab_dividers: bool,
tab_divider_color: Option<teksilo_core::color_prop::ColorProp>,
active_indicator: teksilo_core::styles::TabIndicatorPosition,
style_override: Option<teksilo_core::styles::SharedTabStyle>,
bar_leading_slot: Option<PendingChild>,
bar_trailing_slot: Option<PendingChild>,
show_separator: bool,
show_scroll_arrows: bool,
overflow_button: TabOverflowButton,
vertical_wheel_scrolls_horizontally: bool,
shift_wheel_scrolls_horizontally: bool,
on_close: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
reorderable: bool,
on_reorder: Option<Rc<dyn Fn(usize, usize, &mut EventContext)>>,
on_pin_toggle: Option<Rc<dyn Fn(usize, bool, &mut EventContext)>>,
accept_external_tabs: bool,
clone_item: Option<Rc<dyn Fn(&T) -> T>>,
on_tab_received: Option<Rc<dyn Fn(T, usize, &mut EventContext)>>,
on_transfer_out: Option<Rc<dyn Fn(TabId, &mut EventContext)>>,
on_external_drop: Option<Rc<dyn Fn(&DragPayload, usize, &mut EventContext) -> bool>>,
transferable_fn: Option<Rc<dyn Fn(usize, &T) -> bool>>,
self_reorder_flag: Rc<std::cell::Cell<bool>>,
panel_ids_buffer: Option<Rc<RefCell<Vec<WidgetId>>>>,
header_ids_buffer: Option<Rc<RefCell<Vec<WidgetId>>>>,
paint_state: PaintState,
reveal: RevealState,
root_child_id: Option<WidgetId>,
header_row_id: Option<WidgetId>,
pinned_strip_id: Option<WidgetId>,
bar_leading_slot_id: Option<WidgetId>,
bar_trailing_slot_id: Option<WidgetId>,
outer_stack_id: Option<WidgetId>,
}
#[derive(Clone)]
struct PaintState {
drop_indicator_x: Signal<Option<f32>>,
last_bar_bounds: Rc<std::cell::Cell<Rect>>,
}
impl Default for PaintState {
fn default() -> Self {
Self {
drop_indicator_x: Signal::new(None),
last_bar_bounds: Rc::new(std::cell::Cell::new(Rect::new(0.0, 0.0, 0.0, 0.0))),
}
}
}
impl std::fmt::Debug for PaintState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PaintState")
.field("drop_indicator_x", &self.drop_indicator_x.get())
.field("last_bar_bounds", &self.last_bar_bounds.get())
.finish()
}
}
const REVEAL_EPSILON: f32 = 0.5;
#[derive(Clone)]
struct RevealArea {
scroll_main: Signal<f32>,
viewport: Rc<std::cell::Cell<Size>>,
}
#[derive(Clone)]
struct RevealState {
pending: Rc<std::cell::Cell<Option<usize>>>,
generation: Signal<u64>,
revealed: Rc<std::cell::Cell<Option<TabId>>>,
area: Rc<RefCell<Option<RevealArea>>>,
}
impl Default for RevealState {
fn default() -> Self {
Self {
pending: Rc::new(std::cell::Cell::new(None)),
generation: Signal::new(0),
revealed: Rc::new(std::cell::Cell::new(None)),
area: Rc::new(RefCell::new(None)),
}
}
}
impl RevealState {
fn arm(&self, position: usize) {
self.pending.set(Some(position));
self.generation.set(self.generation.get().wrapping_add(1));
}
}
impl std::fmt::Debug for RevealState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RevealState")
.field("pending", &self.pending.get())
.field("generation", &self.generation.get())
.field("revealed", &self.revealed.get())
.field("area", &self.area.borrow().is_some())
.finish()
}
}
impl<T: 'static> std::fmt::Debug for TabBar<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TabBar")
.field("len", &self.source.len())
.field("selected", &self.selected.get())
.field("sizing", &self.sizing)
.field("min_tab_width", &self.min_tab_width)
.field("max_tab_width", &self.max_tab_width)
.finish()
}
}
impl<T: 'static> TabBar<T> {
pub fn horizontal(
model: ListModel<T>,
delegate: TabDelegate<T>,
selected_id: Signal<Option<TabId>>,
id_of: impl Fn(usize, &T) -> TabId + 'static,
) -> Self {
Self::from_list_source(
ListSource::from_model(model),
delegate,
selected_id,
Rc::new(id_of),
TabBarOrientation::Horizontal,
)
}
pub fn horizontal_from_source<S: ListDataSource<Item = T>>(
source: S,
delegate: TabDelegate<T>,
selected_id: Signal<Option<TabId>>,
id_of: impl Fn(usize, &T) -> TabId + 'static,
) -> Self {
Self::from_list_source(
ListSource::from_data_source(source),
delegate,
selected_id,
Rc::new(id_of),
TabBarOrientation::Horizontal,
)
}
pub fn vertical(
model: ListModel<T>,
delegate: TabDelegate<T>,
selected_id: Signal<Option<TabId>>,
id_of: impl Fn(usize, &T) -> TabId + 'static,
) -> Self {
Self::from_list_source(
ListSource::from_model(model),
delegate,
selected_id,
Rc::new(id_of),
TabBarOrientation::Vertical,
)
}
pub fn vertical_from_source<S: ListDataSource<Item = T>>(
source: S,
delegate: TabDelegate<T>,
selected_id: Signal<Option<TabId>>,
id_of: impl Fn(usize, &T) -> TabId + 'static,
) -> Self {
Self::from_list_source(
ListSource::from_data_source(source),
delegate,
selected_id,
Rc::new(id_of),
TabBarOrientation::Vertical,
)
}
pub(crate) fn from_list_source(
source: ListSource<T>,
delegate: TabDelegate<T>,
selected_id: Signal<Option<TabId>>,
id_of: Rc<dyn Fn(usize, &T) -> TabId>,
orientation: TabBarOrientation,
) -> Self {
Self {
source,
delegate,
selected_id,
id_of,
selected: Signal::new(0_usize),
orientation,
sizing: TabSizing::Shared,
tab_display: TabDisplayMode::Auto,
min_tab_width: DEFAULT_MIN_TAB_WIDTH,
max_tab_width: DEFAULT_MAX_TAB_WIDTH,
pinned_tab_width: DEFAULT_PINNED_TAB_WIDTH,
spacing: DEFAULT_TAB_SPACING,
tab_height: None,
tab_background: None,
selected_tab_background: None,
hover_tab_background: None,
idle_tab_background: None,
bar_background: None,
selected_text_role: TextRole::Primary,
idle_text_role: TextRole::Secondary,
tab_dividers: false,
tab_divider_color: None,
active_indicator: teksilo_core::styles::TabIndicatorPosition::OuterEdge,
style_override: None,
bar_leading_slot: None,
bar_trailing_slot: None,
show_separator: true,
show_scroll_arrows: true,
overflow_button: TabOverflowButton::Auto,
vertical_wheel_scrolls_horizontally: true,
shift_wheel_scrolls_horizontally: true,
on_close: None,
reorderable: false,
on_reorder: None,
on_pin_toggle: None,
accept_external_tabs: false,
clone_item: None,
on_tab_received: None,
on_transfer_out: None,
on_external_drop: None,
transferable_fn: None,
self_reorder_flag: Rc::new(std::cell::Cell::new(false)),
panel_ids_buffer: None,
header_ids_buffer: None,
paint_state: PaintState::default(),
reveal: RevealState::default(),
root_child_id: None,
header_row_id: None,
pinned_strip_id: None,
bar_leading_slot_id: None,
bar_trailing_slot_id: None,
outer_stack_id: None,
}
}
pub fn tab_sizing(mut self, mode: TabSizing) -> Self {
self.sizing = mode;
self
}
fn natural_height_vertical(&self, width: Option<f32>, ctx: &LayoutContext) -> f32 {
let probe = SizeProposal {
width,
height: None,
};
let slots_h = self
.outer_stack_id
.and_then(|id| ctx.child_size(id, probe))
.map(|s| s.height)
.unwrap_or(0.0);
let headers_h = self
.header_row_id
.and_then(|id| ctx.child_size(id, probe))
.map(|s| s.height)
.unwrap_or(0.0);
slots_h + headers_h
}
pub fn tab_display(mut self, mode: TabDisplayMode) -> Self {
self.tab_display = mode;
self
}
pub fn min_tab_width(mut self, dp: f32) -> Self {
self.min_tab_width = dp.max(0.0);
self
}
pub fn tab_bar_height(mut self, dp: f32) -> Self {
self.tab_height = Some(dp.max(0.0));
self
}
pub fn max_tab_width(mut self, dp: f32) -> Self {
self.max_tab_width = dp.max(0.0);
self
}
pub fn tab_spacing(mut self, dp: f32) -> Self {
self.spacing = dp.max(0.0);
self
}
pub fn pinned_tab_width(mut self, dp: f32) -> Self {
self.pinned_tab_width = dp.max(0.0);
self
}
pub fn tab_background(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
self.tab_background = Some(color.into());
self
}
pub fn selected_tab_background(
mut self,
color: impl Into<teksilo_core::color_prop::ColorProp>,
) -> Self {
self.selected_tab_background = Some(color.into());
self
}
pub fn hover_tab_background(
mut self,
color: impl Into<teksilo_core::color_prop::ColorProp>,
) -> Self {
self.hover_tab_background = Some(color.into());
self
}
pub fn idle_tab_background(
mut self,
color: impl Into<teksilo_core::color_prop::ColorProp>,
) -> Self {
self.idle_tab_background = Some(color.into());
self
}
pub fn bar_background(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
self.bar_background = Some(color.into());
self
}
pub fn tab_dividers(mut self) -> Self {
self.tab_dividers = true;
self
}
pub fn tab_divider_color(
mut self,
color: impl Into<teksilo_core::color_prop::ColorProp>,
) -> Self {
self.tab_dividers = true;
self.tab_divider_color = Some(color.into());
self
}
pub fn active_indicator(
mut self,
position: teksilo_core::styles::TabIndicatorPosition,
) -> Self {
self.active_indicator = position;
self
}
pub fn selected_text_role(mut self, role: TextRole) -> Self {
self.selected_text_role = role;
self
}
pub fn idle_text_role(mut self, role: TextRole) -> Self {
self.idle_text_role = role;
self
}
pub fn style(mut self, style: impl teksilo_core::styles::TabStyle) -> Self {
self.style_override = Some(std::rc::Rc::new(style));
self
}
pub fn on_pin_toggle(mut self, f: impl Fn(usize, bool, &mut EventContext) + 'static) -> Self {
self.on_pin_toggle = Some(Rc::new(f));
self
}
pub fn bar_leading_slot(mut self, w: impl Widget + 'static) -> Self {
self.bar_leading_slot = Some(PendingChild::Deferred(Box::new(w)));
self
}
pub fn bar_leading_slot_id(mut self, id: WidgetId) -> Self {
self.bar_leading_slot = Some(PendingChild::Id(id));
self
}
pub fn bar_trailing_slot(mut self, w: impl Widget + 'static) -> Self {
self.bar_trailing_slot = Some(PendingChild::Deferred(Box::new(w)));
self
}
pub fn bar_trailing_slot_id(mut self, id: WidgetId) -> Self {
self.bar_trailing_slot = Some(PendingChild::Id(id));
self
}
pub fn separator(mut self, on: bool) -> Self {
self.show_separator = on;
self
}
pub fn show_scroll_arrows(mut self, on: bool) -> Self {
self.show_scroll_arrows = on;
self
}
pub fn overflow_button(mut self, mode: TabOverflowButton) -> Self {
self.overflow_button = mode;
self
}
pub fn show_overflow_dropdown(mut self, on: bool) -> Self {
self.overflow_button = if on {
TabOverflowButton::Always
} else {
TabOverflowButton::Never
};
self
}
pub fn vertical_wheel_scrolls_horizontally(mut self, on: bool) -> Self {
self.vertical_wheel_scrolls_horizontally = on;
self
}
pub fn shift_wheel_scrolls_horizontally(mut self, on: bool) -> Self {
self.shift_wheel_scrolls_horizontally = on;
self
}
pub fn on_close(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self {
self.on_close = Some(Rc::new(f));
self
}
pub fn reorderable(mut self, on: bool) -> Self {
self.reorderable = on;
self
}
pub fn on_reorder(mut self, f: impl Fn(usize, usize, &mut EventContext) + 'static) -> Self {
self.on_reorder = Some(Rc::new(f));
self.reorderable = true;
self
}
pub fn accept_external_tabs(mut self, on: bool) -> Self
where
T: Clone,
{
self.accept_external_tabs = on;
self.clone_item = if on {
Some(Rc::new(|t: &T| t.clone()))
} else {
None
};
self
}
pub fn on_tab_received(mut self, f: impl Fn(T, usize, &mut EventContext) + 'static) -> Self
where
T: Clone,
{
self.on_tab_received = Some(Rc::new(f));
if !self.accept_external_tabs {
self = self.accept_external_tabs(true);
}
self
}
pub fn on_transfer_out(mut self, f: impl Fn(TabId, &mut EventContext) + 'static) -> Self
where
T: Clone,
{
self.on_transfer_out = Some(Rc::new(f));
if !self.accept_external_tabs {
self = self.accept_external_tabs(true);
}
self
}
pub fn on_external_drop(
mut self,
f: impl Fn(&DragPayload, usize, &mut EventContext) -> bool + 'static,
) -> Self {
self.on_external_drop = Some(Rc::new(f));
self
}
pub(crate) fn on_external_drop_rc(
mut self,
f: Rc<dyn Fn(&DragPayload, usize, &mut EventContext) -> bool>,
) -> Self {
self.on_external_drop = Some(f);
self
}
pub(crate) fn with_transferable_predicate(
mut self,
f: impl Fn(usize, &T) -> bool + 'static,
) -> Self {
self.transferable_fn = Some(Rc::new(f));
self
}
pub(crate) fn on_transfer_out_rc(mut self, f: Rc<dyn Fn(TabId, &mut EventContext)>) -> Self {
self.on_transfer_out = Some(f);
self
}
pub(crate) fn on_tab_received_rc(mut self, f: Rc<dyn Fn(T, usize, &mut EventContext)>) -> Self {
self.on_tab_received = Some(f);
self
}
pub(crate) fn with_panel_ids(mut self, buffer: Rc<RefCell<Vec<WidgetId>>>) -> Self {
self.panel_ids_buffer = Some(buffer);
self
}
pub(crate) fn with_header_ids(mut self, buffer: Rc<RefCell<Vec<WidgetId>>>) -> Self {
self.header_ids_buffer = Some(buffer);
self
}
}
impl<T: 'static> Widget for TabBar<T> {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let self_id = ctx.self_id();
let version = ctx.signal(0u64);
version.bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
let data_ver = Rc::new(std::cell::Cell::new(0_u64));
let observer_handle = (self.source.observe_fn)(Box::new({
let version = version.clone();
let dv = data_ver.clone();
move |_change| {
let next = dv.get().wrapping_add(1);
dv.set(next);
version.set(next);
}
}));
ctx.own_handle(observer_handle);
let n = self.source.len();
let mut enabled_tabs = Vec::with_capacity(n);
let mut pinned_tabs: Vec<bool> = Vec::with_capacity(n);
for i in 0..n {
let cell = std::cell::Cell::new((true, false));
(self.source.with_item_fn)(i, &|item| {
cell.set((
self.delegate.resolve_enabled(i, item),
self.delegate.resolve_pinned(i, item),
));
Box::new(EnabledProbe) as Box<dyn Widget>
});
let (e, p) = cell.get();
enabled_tabs.push(e);
pinned_tabs.push(p);
}
let enabled_tabs = Rc::new(enabled_tabs);
let mut id_to_index: HashMap<TabId, usize> = HashMap::with_capacity(n);
let mut index_to_id: Vec<TabId> = Vec::with_capacity(n);
for i in 0..n {
let cell: std::cell::Cell<Option<TabId>> = std::cell::Cell::new(None);
(self.source.with_item_fn)(i, &|item| {
cell.set(Some((self.id_of)(i, item)));
Box::new(EnabledProbe) as Box<dyn Widget>
});
if let Some(id) = cell.get() {
id_to_index.insert(id, i);
index_to_id.push(id);
}
}
let id_to_index = Rc::new(id_to_index);
let index_to_id = Rc::new(index_to_id);
if n > 0 {
let valid = self
.selected_id
.get()
.and_then(|id| id_to_index.get(&id).copied());
if let Some(target_idx) = valid {
if self.selected.get() != target_idx {
self.selected.set(target_idx);
}
} else {
let clamped = self.selected.get().min(n - 1);
if self.selected.get() != clamped {
self.selected.set(clamped);
}
let new_id = index_to_id[clamped];
if self.selected_id.get() != Some(new_id) {
self.selected_id.set(Some(new_id));
}
}
} else if self.selected_id.get().is_some() {
self.selected_id.set(None);
}
let id_to_idx_for_eff = id_to_index.clone();
let idx_for_id_eff = self.selected.clone();
ctx.effect(&self.selected_id, move |maybe_id| {
if let Some(id) = maybe_id
&& let Some(&i) = id_to_idx_for_eff.get(id)
&& idx_for_id_eff.get() != i
{
idx_for_id_eff.set(i);
}
});
let idx_to_id_for_eff = index_to_id.clone();
let id_for_idx_eff = self.selected_id.clone();
ctx.effect(&self.selected, move |i| {
let new_id = idx_to_id_for_eff.get(*i).copied();
if id_for_idx_eff.get() != new_id {
id_for_idx_eff.set(new_id);
}
});
let header_ids_buf = self
.header_ids_buffer
.clone()
.unwrap_or_else(|| Rc::new(RefCell::new(Vec::with_capacity(n))));
header_ids_buf.borrow_mut().clear();
let panel_ids_buf = self
.panel_ids_buffer
.clone()
.unwrap_or_else(|| Rc::new(RefCell::new(Vec::new())));
let shared = Rc::new(HeaderShared {
header_ids: header_ids_buf.clone(),
panel_ids: panel_ids_buf,
enabled_tabs: enabled_tabs.clone(),
});
let mut pinned_header_ids: Vec<WidgetId> = Vec::new();
let mut unpinned_header_ids: Vec<WidgetId> = Vec::with_capacity(n);
let mut unpinned_to_model: Vec<usize> = Vec::with_capacity(n);
let mut header_labels: Vec<LocalizedString> = Vec::with_capacity(n);
let reorder_handler: Option<Rc<dyn Fn(usize, usize, &mut EventContext)>> =
if self.reorderable {
if let Some(explicit) = self.on_reorder.clone() {
Some(explicit)
} else {
self.source.move_item_fn.clone().map(|move_fn| {
Rc::new(move |from: usize, to: usize, _ctx: &mut EventContext| {
(move_fn)(from, to);
}) as Rc<dyn Fn(usize, usize, &mut EventContext)>
})
}
} else {
None
};
let close_handler: Option<Rc<dyn Fn(usize, &mut EventContext)>> =
if let Some(explicit) = self.on_close.clone() {
Some(explicit)
} else {
self.source.remove_item_fn.clone().map(|remove| {
Rc::new(move |i: usize, _ctx: &mut EventContext| {
(remove)(i);
}) as Rc<dyn Fn(usize, &mut EventContext)>
})
};
for i in 0..n {
let is_pinned = pinned_tabs[i];
let selected = self.selected.clone();
let shared_for_header = shared.clone();
let (min_w, max_w) = if is_pinned {
(self.pinned_tab_width, self.pinned_tab_width)
} else {
(self.min_tab_width, self.max_tab_width)
};
let label_capture: Rc<RefCell<Option<LocalizedString>>> = Rc::new(RefCell::new(None));
let label_capture_clone = label_capture.clone();
let close_handler_for_tab = close_handler.clone();
let header = (self.source.with_item_fn)(i, &|item| -> Box<dyn Widget> {
let label = self.delegate.resolve_label(i, item);
*label_capture_clone.borrow_mut() = Some(label.clone());
let icon = self.delegate.resolve_icon(i, item);
let leading_slot = self.delegate.resolve_leading(i, item);
let trailing_slot = self.delegate.resolve_trailing(i, item);
let tooltip = self.delegate.resolve_tooltip(i, item);
let at_name = label.clone();
let (label, icon, tooltip) =
apply_tab_display(self.tab_display, label, icon, tooltip);
let rich_tooltip = self.delegate.resolve_rich_tooltip(i, item);
let composite_tooltip = self.delegate.resolve_composite_tooltip(i, item);
let context_menu_factory = self.delegate.resolve_context_menu(i, item);
let enabled = self.delegate.resolve_enabled(i, item);
let closable = self.delegate.resolve_closable(i, item);
let on_close: Option<Rc<dyn Fn(&mut EventContext)>> = if closable {
close_handler_for_tab.clone().map(|f| {
Rc::new(move |ctx: &mut EventContext| (f)(i, ctx))
as Rc<dyn Fn(&mut EventContext)>
})
} else {
None
};
let on_reorder_to: Option<Rc<dyn Fn(usize, &mut EventContext)>> = if !is_pinned {
reorder_handler.clone().map(|reorder| {
Rc::new(move |to: usize, ctx: &mut EventContext| (reorder)(i, to, ctx))
as Rc<dyn Fn(usize, &mut EventContext)>
})
} else {
None
};
let is_drag_source = reorder_handler.is_some() || self.accept_external_tabs;
let make_drag_payload: Option<Rc<dyn Fn() -> DragPayload>> = if is_drag_source {
let tab_id = (self.id_of)(i, item);
let transferable = self.transferable_fn.as_ref().is_none_or(|f| f(i, item));
let item_payload: Option<(T, Rc<dyn Fn(&T) -> T>)> = if transferable {
self.clone_item.as_ref().map(|cf| ((cf)(item), cf.clone()))
} else {
None
};
let src_index = i;
let bar_id = self_id;
Some(Rc::new(move || {
let item = item_payload.as_ref().map(|(it, cf)| (cf)(it));
DragPayload::typed(TabBarDragData {
source_index: src_index,
source_bar_id: bar_id,
source_id: tab_id,
item,
})
}) as Rc<dyn Fn() -> DragPayload>)
} else {
None
};
let on_drag_ended: Option<Rc<dyn Fn(DropOutcome, &mut EventContext)>> =
match (self.accept_external_tabs, self.on_transfer_out.clone()) {
(true, Some(transfer_out)) => {
let tab_id = (self.id_of)(i, item);
let self_reorder = self.self_reorder_flag.clone();
Some(
Rc::new(move |outcome: DropOutcome, ctx: &mut EventContext| {
if matches!(outcome, DropOutcome::InApp { accepted: true })
&& !self_reorder.replace(false)
{
(transfer_out)(tab_id, ctx);
}
})
as Rc<dyn Fn(DropOutcome, &mut EventContext)>,
)
}
_ => None,
};
Box::new(TabHeader::new(TabHeaderConfig {
label,
at_name,
icon,
leading_slot,
trailing_slot,
tooltip,
rich_tooltip,
composite_tooltip,
context_menu_factory,
on_close: if is_pinned { None } else { on_close },
on_reorder_to,
make_drag_payload,
on_drag_ended,
index: i,
initial_enabled: enabled,
selected: selected.clone(),
shared: shared_for_header.clone(),
min_width: min_w,
max_width: max_w,
pinned: is_pinned,
orientation: self.orientation,
tab_background: self.tab_background.clone(),
selected_tab_background: self.selected_tab_background.clone(),
hover_tab_background: self.hover_tab_background.clone(),
idle_tab_background: self.idle_tab_background.clone(),
selected_text_role: self.selected_text_role,
idle_text_role: self.idle_text_role,
active_indicator: self.active_indicator,
style_override: self.style_override.clone(),
}))
});
if let Some(header) = header {
let id = ctx.add_boxed(header);
if is_pinned {
pinned_header_ids.push(id);
} else {
unpinned_header_ids.push(id);
unpinned_to_model.push(i);
}
header_ids_buf.borrow_mut().push(id);
if let Some(lbl) = label_capture.borrow_mut().take() {
header_labels.push(lbl);
} else {
header_labels.push(lit!(String::new()));
}
}
}
let unpinned_to_model = Rc::new(unpinned_to_model);
let model_len = n;
let mut model_to_unpinned: Vec<Option<usize>> = vec![None; n];
for (position, &model_index) in unpinned_to_model.iter().enumerate() {
model_to_unpinned[model_index] = Some(position);
}
let model_to_unpinned = Rc::new(model_to_unpinned);
let selected_target = self.selected_id.get();
if selected_target.is_some()
&& (self.reveal.revealed.get() != selected_target
|| self.reveal.pending.get().is_some())
{
self.reveal.revealed.set(selected_target);
match model_to_unpinned
.get(self.selected.get())
.copied()
.flatten()
{
Some(position) => self.reveal.arm(position),
None => self.reveal.pending.set(None),
}
}
{
let reveal = self.reveal.clone();
let positions = model_to_unpinned.clone();
let ids = index_to_id.clone();
ctx.effect(&self.selected, move |index| {
let target = ids.get(*index).copied();
if target.is_none() || reveal.revealed.get() == target {
return;
}
reveal.revealed.set(target);
match positions.get(*index).copied().flatten() {
Some(position) => reveal.arm(position),
None => reveal.pending.set(None),
}
});
}
let (header_min_height, motion_duration_normal, motion_easing_standard) = {
let theme = ctx.theme();
(
self.tab_height
.unwrap_or(crate::styles::recipe_tab_style::TAB_EDITOR_HEIGHT),
theme.motion.duration_normal,
theme.motion.easing_standard,
)
};
let header_bounds_buf: Rc<RefCell<Vec<Rect>>> =
Rc::new(RefCell::new(Vec::with_capacity(unpinned_header_ids.len())));
let row_bounds_buf: Rc<std::cell::Cell<Rect>> =
Rc::new(std::cell::Cell::new(Rect::new(0.0, 0.0, 0.0, 0.0)));
let divider_prop: Option<teksilo_core::color_prop::ColorProp> =
self.tab_dividers.then(|| {
self.tab_divider_color
.clone()
.unwrap_or_else(|| BorderRole::Divider.into())
});
let row = TabHeaderRow {
header_ids: unpinned_header_ids.clone(),
axis: self.orientation,
sizing: self.sizing,
min_extent: self.min_tab_width,
max_extent: self.max_tab_width,
spacing: self.spacing,
tab_height: self.tab_height,
header_bounds_buf: header_bounds_buf.clone(),
row_bounds_buf: row_bounds_buf.clone(),
divider: divider_prop.clone().map(|c| (c, self.spacing)),
overlay_id: None,
reveal: self.reveal.clone(),
};
let row_id = ctx.add(row);
self.header_row_id = Some(row_id);
let scroll = match self.orientation {
TabBarOrientation::Horizontal => ScrollArea::from_id(row_id)
.scroll_bar_style(ScrollBarMode::Thin)
.vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
.horizontal_scroll_bar_policy(ScrollBarPolicy::AsNeeded)
.widget_resizable(true)
.preferred_size(0.0, header_min_height),
TabBarOrientation::Vertical => ScrollArea::from_id(row_id)
.scroll_bar_style(ScrollBarMode::Overlay)
.horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
.vertical_scroll_bar_policy(ScrollBarPolicy::AsNeeded)
.widget_resizable(true),
};
let scroll_x = scroll.scroll_x_signal().clone();
let max_scroll_x = scroll.max_scroll_x_signal().clone();
let scroll_y = scroll.scroll_y_signal().clone();
let max_scroll_y = scroll.max_scroll_y_signal().clone();
let scroll_viewport = scroll.viewport_size_cell();
let scroll_id = ctx.add(scroll);
let scroll_main = match self.orientation {
TabBarOrientation::Horizontal => scroll_x.clone(),
TabBarOrientation::Vertical => scroll_y.clone(),
};
let max_scroll_main = match self.orientation {
TabBarOrientation::Horizontal => max_scroll_x.clone(),
TabBarOrientation::Vertical => max_scroll_y.clone(),
};
*self.reveal.area.borrow_mut() = Some(RevealArea {
scroll_main: scroll_main.clone(),
viewport: scroll_viewport,
});
let mut outer_children: Vec<WidgetId> = Vec::new();
if let Some(slot) = self.bar_leading_slot.take() {
let id = match slot {
PendingChild::Id(id) => id,
PendingChild::Deferred(w) => ctx.add_boxed(w),
};
self.bar_leading_slot_id = Some(id);
outer_children.push(id);
}
if !pinned_header_ids.is_empty() {
let make_divider = |ctx: &mut BuildContext| -> Option<WidgetId> {
divider_prop.clone().map(|c| {
let d = match self.orientation {
TabBarOrientation::Horizontal => crate::primitives::Divider::vertical(),
TabBarOrientation::Vertical => crate::primitives::Divider::horizontal(),
};
ctx.add(d.color(c))
})
};
let pinned_id = match self.orientation {
TabBarOrientation::Horizontal => {
let mut pinned = HStack::new().spacing(self.spacing);
for (i, id) in pinned_header_ids.iter().enumerate() {
if i > 0
&& let Some(div) = make_divider(ctx)
{
pinned = pinned.add_child(div);
}
pinned = pinned.add_child(*id);
}
ctx.add(pinned)
}
TabBarOrientation::Vertical => {
let mut pinned = crate::VStack::new().spacing(self.spacing);
for (i, id) in pinned_header_ids.iter().enumerate() {
if i > 0
&& let Some(div) = make_divider(ctx)
{
pinned = pinned.add_child(div);
}
pinned = pinned.add_child(*id);
}
ctx.add(pinned)
}
};
self.pinned_strip_id = Some(pinned_id);
outer_children.push(pinned_id);
}
if self.show_scroll_arrows {
let arrow_id = build_scroll_arrow(
ctx,
ScrollArrowKind::Leading,
self.orientation,
scroll_main.clone(),
max_scroll_main.clone(),
motion_duration_normal,
motion_easing_standard,
self.idle_text_role,
);
let visible = scroll_main.clone().map(|x| *x > 0.5);
ctx.visible_when(arrow_id, visible);
outer_children.push(arrow_id);
}
let scroll_slot = match self.orientation {
TabBarOrientation::Horizontal => ctx.add(Expand::horizontal().child_id(scroll_id)),
TabBarOrientation::Vertical => ctx.add(Expand::vertical().child_id(scroll_id)),
};
outer_children.push(scroll_slot);
if self.show_scroll_arrows {
let arrow_id = build_scroll_arrow(
ctx,
ScrollArrowKind::Trailing,
self.orientation,
scroll_main.clone(),
max_scroll_main.clone(),
motion_duration_normal,
motion_easing_standard,
self.idle_text_role,
);
let visible = scroll_main
.clone()
.zip(&max_scroll_main)
.map(|(x, max)| *x + 0.5 < *max);
ctx.visible_when(arrow_id, visible);
outer_children.push(arrow_id);
}
if self.overflow_button != TabOverflowButton::Never && !header_labels.is_empty() {
let entries: Vec<DropdownEntry> = header_labels
.iter()
.zip(index_to_id.iter().copied())
.zip(enabled_tabs.iter().copied())
.map(|((label, id), enabled)| DropdownEntry {
id,
label: label.clone(),
enabled,
})
.collect();
let dropdown_id = build_overflow_dropdown(
ctx,
self.selected_id.clone(),
entries,
self.idle_text_role,
);
if self.overflow_button == TabOverflowButton::Auto {
let overflowing = max_scroll_main.clone().map(|m| *m > 0.5);
ctx.visible_when(dropdown_id, overflowing);
}
outer_children.push(dropdown_id);
}
if let Some(slot) = self.bar_trailing_slot.take() {
let id = match slot {
PendingChild::Id(id) => id,
PendingChild::Deferred(w) => ctx.add_boxed(w),
};
self.bar_trailing_slot_id = Some(id);
outer_children.push(id);
}
let root_id = match self.orientation {
TabBarOrientation::Horizontal => {
let mut row = HStack::new().spacing(DEFAULT_BAR_SLOT_SPACING);
for id in &outer_children {
row = row.add_child(*id);
}
ctx.add(row)
}
TabBarOrientation::Vertical => {
let mut col = crate::VStack::new().spacing(DEFAULT_BAR_SLOT_SPACING);
for id in &outer_children {
col = col.add_child(*id);
}
ctx.add(col)
}
};
self.outer_stack_id = Some(root_id);
let style: teksilo_core::styles::SharedTabStyle = self
.style_override
.clone()
.or_else(|| ctx.theme().style_slots.tab.clone())
.unwrap_or_else(|| Rc::new(crate::styles::RecipeTabStyle::default()));
let chrome_cfg = teksilo_core::styles::TabBarChromeConfig {
content: root_id,
orientation: self.orientation.into(),
show_separator: self.show_separator,
surface_role: self.bar_background.clone(),
drop_indicator: self.paint_state.drop_indicator_x.clone(),
};
let bar_root = style.make_bar(&chrome_cfg, ctx);
self.root_child_id = Some(bar_root);
let vert_to_horiz = self.vertical_wheel_scrolls_horizontally;
let shift_to_horiz = self.shift_wheel_scrolls_horizontally;
let scroll_x_for_wheel = scroll_x.clone();
let max_scroll_x_for_wheel = max_scroll_x.clone();
let orientation_for_wheel = self.orientation;
let handler = HandlerSet::new().on_pointer_event(
move |event: &WidgetEvent, _ctx: &mut EventContext| -> EventResponse {
if orientation_for_wheel == TabBarOrientation::Vertical {
return EventResponse::Ignored;
}
let WidgetEvent::Scroll { delta, modifiers } = event else {
return EventResponse::Ignored;
};
let (dx, dy) = match delta {
ScrollDelta::Lines { x, y } => (x * WHEEL_LINE_PIXELS, y * WHEEL_LINE_PIXELS),
ScrollDelta::Pixels { x, y } => (*x, *y),
};
let shift = modifiers.shift();
let should_remap = if shift && shift_to_horiz {
true
} else {
vert_to_horiz && dx.abs() < f32::EPSILON && dy.abs() > 0.0
};
if !should_remap {
return EventResponse::Ignored;
}
let mapped_dx = if dx.abs() > 0.0 { dx } else { dy };
if mapped_dx.abs() < f32::EPSILON {
return EventResponse::Ignored;
}
let new_x =
(scroll_x_for_wheel.get() + mapped_dx).clamp(0.0, max_scroll_x_for_wheel.get());
scroll_x_for_wheel.set(new_x);
EventResponse::Handled
},
);
ctx.apply_self_handlers(handler);
if reorder_handler.is_some() || self.accept_external_tabs || self.on_external_drop.is_some()
{
let bar_id_for_drop = self_id;
let axis = self.orientation;
let accept_external = self.accept_external_tabs;
let has_external_drop = self.on_external_drop.is_some();
let drop_handler = HandlerSet::new()
.on_drag_hover({
let header_bounds = header_bounds_buf.clone();
let drop_indicator = self.paint_state.drop_indicator_x.clone();
let bar_bounds = self.paint_state.last_bar_bounds.clone();
move |payload: &DragPayload,
position: Point,
_ctx: &mut EventContext|
-> DropFeedback {
match payload.get_typed::<TabBarDragData<T>>() {
Some(data) => {
let is_intra = data.source_bar_id == bar_id_for_drop;
let is_foreign_ok = accept_external && data.item.is_some();
if !is_intra && !is_foreign_ok {
drop_indicator.set(None);
return DropFeedback::NoFeedback;
}
}
None => {
if !has_external_drop {
drop_indicator.set(None);
return DropFeedback::NoFeedback;
}
}
}
let bar = bar_bounds.get();
let bounds = header_bounds.borrow();
if bounds.is_empty() {
let cross = match axis {
TabBarOrientation::Horizontal => bar.height,
TabBarOrientation::Vertical => bar.width,
};
drop_indicator.set(Some(0.0));
return DropFeedback::InsertionLine {
y: 0.0,
width: cross,
};
}
let (pointer_world_main, bar_origin_main) = match axis {
TabBarOrientation::Horizontal => (position.x + bar.x, bar.x),
TabBarOrientation::Vertical => (position.y + bar.y, bar.y),
};
let insertion_world_main =
insertion_world_main_for(&bounds, pointer_world_main, axis);
let insertion_local_main = insertion_world_main - bar_origin_main;
drop_indicator.set(Some(insertion_local_main));
DropFeedback::InsertionLine {
y: 0.0,
width: bounds[0].height,
}
}
})
.on_drag_leave({
let drop_indicator = self.paint_state.drop_indicator_x.clone();
move |_ctx: &mut EventContext| {
drop_indicator.set(None);
}
})
.on_drop({
let header_bounds = header_bounds_buf.clone();
let bar_bounds = self.paint_state.last_bar_bounds.clone();
let drop_indicator = self.paint_state.drop_indicator_x.clone();
let reorder = reorder_handler.clone();
let on_received = self.on_tab_received.clone();
let on_external_drop = self.on_external_drop.clone();
let self_reorder = self.self_reorder_flag.clone();
let unpinned_to_model = unpinned_to_model.clone();
let bar_id = bar_id_for_drop;
move |mut payload: DragPayload,
position: Point,
ctx: &mut EventContext|
-> bool {
drop_indicator.set(None);
let mut data = payload.take_typed::<TabBarDragData<T>>();
let bar = bar_bounds.get();
let bounds = header_bounds.borrow();
let to_model = if bounds.is_empty() {
0
} else {
let pointer_world_main = match axis {
TabBarOrientation::Horizontal => position.x + bar.x,
TabBarOrientation::Vertical => position.y + bar.y,
};
let to_unpinned =
insertion_index_for(&bounds, pointer_world_main, axis);
if to_unpinned < unpinned_to_model.len() {
unpinned_to_model[to_unpinned]
} else {
unpinned_to_model
.last()
.map(|&last| last + 1)
.unwrap_or(model_len)
}
};
let Some(data) = data.as_mut() else {
drop(bounds);
return match on_external_drop.as_ref() {
Some(cb) => (cb)(&payload, to_model, ctx),
None => false,
};
};
if data.source_bar_id == bar_id {
self_reorder.set(true);
let Some(reorder) = reorder.as_ref() else {
return true;
};
let from = data.source_index;
let adjusted_to = if from < to_model {
to_model.saturating_sub(1)
} else {
to_model
};
if from != adjusted_to {
(reorder)(from, adjusted_to, ctx);
}
true
} else if accept_external {
let Some(item) = data.item.take() else {
return false;
};
if let Some(cb) = on_received.as_ref() {
(cb)(item, to_model, ctx);
}
true
} else {
false
}
}
})
.on_drag_tick({
let scroll_main = scroll_main.clone();
let max_scroll_main = max_scroll_main.clone();
let bar_bounds = self.paint_state.last_bar_bounds.clone();
move |position: Point, _ctx: &mut EventContext| {
let bar = bar_bounds.get();
let (pointer_main, bar_extent) = match axis {
TabBarOrientation::Horizontal => (position.x, bar.width),
TabBarOrientation::Vertical => (position.y, bar.height),
};
let max = max_scroll_main.get();
let cur = scroll_main.get();
let leading_in = (DRAG_EDGE_ZONE - pointer_main).max(0.0);
let trailing_in = (pointer_main - (bar_extent - DRAG_EDGE_ZONE)).max(0.0);
let delta = if leading_in > 0.0 {
-(leading_in / DRAG_EDGE_ZONE) * DRAG_MAX_VELOCITY
} else if trailing_in > 0.0 {
(trailing_in / DRAG_EDGE_ZONE) * DRAG_MAX_VELOCITY
} else {
0.0
};
if delta.abs() > 0.001 {
scroll_main.set((cur + delta).clamp(0.0, max));
}
}
});
ctx.apply_self_handlers(drop_handler);
}
vec![bar_root]
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
let Some(root_id) = self.root_child_id else {
return proposal.resolve(0.0, 0.0).into();
};
let final_proposal = match self.orientation {
TabBarOrientation::Vertical => {
let target = match (self.sizing, proposal.width) {
(TabSizing::Fill, Some(p)) => p.max(0.0),
_ => {
let mut intrinsic_w = 0.0_f32;
for opt in [
self.header_row_id,
self.pinned_strip_id,
self.bar_leading_slot_id,
self.bar_trailing_slot_id,
] {
if let Some(id) = opt
&& let Some(s) = ctx.child_size(id, SizeProposal::unspecified())
{
intrinsic_w = intrinsic_w.max(s.width);
}
}
let mut t = intrinsic_w.clamp(self.min_tab_width, self.max_tab_width);
if let Some(p) = proposal.width {
t = t.min(p).max(self.min_tab_width);
}
t
}
};
SizeProposal {
width: Some(target),
height: proposal.height,
}
}
TabBarOrientation::Horizontal => proposal,
};
let mut size = ctx
.child_size(root_id, final_proposal)
.unwrap_or_else(|| final_proposal.resolve(0.0, 0.0));
if self.orientation == TabBarOrientation::Vertical && proposal.height.is_none() {
size.height = self.natural_height_vertical(final_proposal.width, ctx);
}
size.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
self.paint_state.last_bar_bounds.set(bounds);
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::TabList);
builder.set_orientation(match self.orientation {
TabBarOrientation::Horizontal => teksilo_core::accesskit::Orientation::Horizontal,
TabBarOrientation::Vertical => teksilo_core::accesskit::Orientation::Vertical,
});
}
fn children(&self) -> Vec<WidgetId> {
self.root_child_id.into_iter().collect()
}
}
#[derive(Debug)]
struct TabHeaderRow {
header_ids: Vec<WidgetId>,
axis: TabBarOrientation,
sizing: TabSizing,
min_extent: f32,
max_extent: f32,
spacing: f32,
tab_height: Option<f32>,
header_bounds_buf: Rc<RefCell<Vec<Rect>>>,
row_bounds_buf: Rc<std::cell::Cell<Rect>>,
divider: Option<(teksilo_core::color_prop::ColorProp, f32)>,
overlay_id: Option<WidgetId>,
reveal: RevealState,
}
impl TabHeaderRow {
fn tab_extent(&self, ctx: &LayoutContext) -> f32 {
self.tab_height
.unwrap_or_else(|| TabHeader::intrinsic_height(ctx))
}
fn compute_extents(&self, viewport_main: Option<f32>, ctx: &LayoutContext) -> Vec<f32> {
let n = self.header_ids.len();
if n == 0 {
return Vec::new();
}
match self.sizing {
TabSizing::Shared | TabSizing::Fill => {
let target = match self.axis {
TabBarOrientation::Horizontal => {
let total_spacing = self.spacing * (n.saturating_sub(1)) as f32;
let avail = viewport_main.unwrap_or(0.0).max(0.0);
let ideal = ((avail - total_spacing).max(0.0) / n as f32).max(0.0);
if self.sizing == TabSizing::Fill {
ideal.max(self.min_extent)
} else {
ideal.clamp(self.min_extent, self.max_extent)
}
}
TabBarOrientation::Vertical => {
self.tab_extent(ctx)
}
};
vec![target; n]
}
TabSizing::Independent => self
.header_ids
.iter()
.map(|&id| {
let s = ctx.child_size(id, SizeProposal::unspecified());
let raw = match self.axis {
TabBarOrientation::Horizontal => s.map(|s| s.width),
TabBarOrientation::Vertical => s.map(|s| s.height),
};
let fallback = match self.axis {
TabBarOrientation::Horizontal => self.min_extent,
TabBarOrientation::Vertical => self.tab_extent(ctx),
};
let raw = raw.unwrap_or(fallback);
match self.axis {
TabBarOrientation::Horizontal => {
raw.clamp(self.min_extent, self.max_extent)
}
TabBarOrientation::Vertical => raw,
}
})
.collect(),
}
}
}
impl TabHeaderRow {
fn apply_pending_reveal(&self, extents: &[f32], viewport_main: f32) {
if viewport_main <= 0.0 {
return;
}
let Some(target) = self.reveal.pending.get() else {
return;
};
let Some(&extent) = extents.get(target) else {
self.reveal.pending.set(None);
return;
};
let area_guard = self.reveal.area.borrow();
let Some(area) = area_guard.as_ref() else {
return;
};
self.reveal.pending.set(None);
let content =
extents.iter().sum::<f32>() + self.spacing * extents.len().saturating_sub(1) as f32;
let max_scroll = (content - viewport_main).max(0.0);
if max_scroll <= 0.0 {
return;
}
let lead = extents[..target].iter().sum::<f32>() + self.spacing * target as f32;
let current = area.scroll_main.get();
let next = if lead < current {
lead
} else if lead + extent > current + viewport_main {
lead + extent - viewport_main
} else {
current
}
.clamp(0.0, max_scroll);
if (next - current).abs() > REVEAL_EPSILON {
area.scroll_main.set(next);
}
}
fn child_ids(&self) -> Vec<WidgetId> {
let mut ids = self.header_ids.clone();
ids.extend(self.overlay_id);
ids
}
}
impl Widget for TabHeaderRow {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
self.reveal.generation.bind_to(
ctx.self_id(),
ctx.binding_registry(),
BindingLevel::Relayout,
);
if let Some((color, spacing)) = self.divider.clone() {
let overlay = ctx.add_boxed(Box::new(TabRowDividers {
header_bounds_buf: self.header_bounds_buf.clone(),
axis: self.axis,
color,
spacing,
}));
self.overlay_id = Some(overlay);
}
self.child_ids()
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
let n = self.header_ids.len();
if n == 0 {
return Size::new(0.0, 0.0).into();
}
let total_spacing = self.spacing * (n - 1) as f32;
match self.axis {
TabBarOrientation::Horizontal => {
let intrinsic = self.tab_extent(ctx);
let height = proposal
.height
.map(|h| h.min(intrinsic))
.unwrap_or(intrinsic);
let extents = self.compute_extents(proposal.width, ctx);
let total = extents.iter().sum::<f32>() + total_spacing;
if let Some(viewport_main) = proposal.width {
self.apply_pending_reveal(&extents, viewport_main);
}
Size::new(total, height).into()
}
TabBarOrientation::Vertical => {
let width = match (self.sizing, proposal.width) {
(TabSizing::Fill, Some(proposed)) => proposed.max(0.0),
_ => {
let intrinsic = self
.header_ids
.iter()
.filter_map(|&id| ctx.child_size(id, SizeProposal::unspecified()))
.map(|s| s.width)
.fold(0.0_f32, f32::max);
let mut w = intrinsic.clamp(self.min_extent, self.max_extent);
if let Some(proposed) = proposal.width {
w = w.min(proposed).max(self.min_extent);
}
w
}
};
let extents = self.compute_extents(proposal.height, ctx);
let total = extents.iter().sum::<f32>() + total_spacing;
let viewport_main = self
.reveal
.area
.borrow()
.as_ref()
.map_or(0.0, |a| a.viewport.get().height);
self.apply_pending_reveal(&extents, viewport_main);
Size::new(width, total).into()
}
}
}
fn place_children(
&self,
bounds: Rect,
proposal: SizeProposal,
children: &mut [WidgetPlacement],
ctx: &LayoutContext,
) {
let viewport_main = match self.axis {
TabBarOrientation::Horizontal => proposal.width,
TabBarOrientation::Vertical => proposal.height,
};
let extents = self.compute_extents(viewport_main, ctx);
let mut buf = self.header_bounds_buf.borrow_mut();
buf.clear();
match self.axis {
TabBarOrientation::Horizontal => {
let mut x = bounds.x;
for (i, child) in children.iter_mut().enumerate() {
if i >= extents.len() {
break;
}
child.origin = Point::new(x, bounds.y);
child.size = Size::new(extents[i], bounds.height);
buf.push(Rect::new(x, bounds.y, extents[i], bounds.height));
x += extents[i] + self.spacing;
}
}
TabBarOrientation::Vertical => {
let mut y = bounds.y;
for (i, child) in children.iter_mut().enumerate() {
if i >= extents.len() {
break;
}
child.origin = Point::new(bounds.x, y);
child.size = Size::new(bounds.width, extents[i]);
buf.push(Rect::new(bounds.x, y, bounds.width, extents[i]));
y += extents[i] + self.spacing;
}
}
}
drop(buf);
if self.overlay_id.is_some()
&& let Some(last) = children.last_mut()
{
last.origin = bounds.origin();
last.size = bounds.size();
}
self.row_bounds_buf.set(bounds);
}
fn children(&self) -> Vec<WidgetId> {
self.child_ids()
}
}
struct TabRowDividers {
header_bounds_buf: Rc<RefCell<Vec<Rect>>>,
axis: TabBarOrientation,
color: teksilo_core::color_prop::ColorProp,
spacing: f32,
}
impl std::fmt::Debug for TabRowDividers {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TabRowDividers")
.field("axis", &self.axis)
.finish()
}
}
impl Widget for TabRowDividers {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
self.color.register_if_bound(
ctx.self_id(),
ctx.binding_registry(),
BindingLevel::RepaintOnly,
);
ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
vec![]
}
fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
proposal.resolve(0.0, 0.0).into()
}
fn paint(&self, _bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
let headers = self.header_bounds_buf.borrow();
if headers.len() < 2 {
return;
}
let color = self.color.resolve(ctx.theme, true);
let t = ctx.theme.shape.border_width.max(1.0);
for pair in headers.windows(2) {
let (a, b) = (pair[0], pair[1]);
let line = match self.axis {
TabBarOrientation::Horizontal => {
let mid = (a.right() + b.x) * 0.5;
Rect::new(mid - t * 0.5, a.y, t, a.height)
}
TabBarOrientation::Vertical => {
let mid = (a.bottom() + b.y) * 0.5;
Rect::new(a.x, mid - t * 0.5, a.width, t)
}
};
canvas.fill_rect(line, color);
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_hidden();
}
}
fn apply_tab_display(
mode: TabDisplayMode,
label: LocalizedString,
icon: Option<IconWidget>,
tooltip: Option<LocalizedString>,
) -> (LocalizedString, Option<IconWidget>, Option<LocalizedString>) {
match mode {
TabDisplayMode::Auto | TabDisplayMode::IconText => (label, icon, tooltip),
TabDisplayMode::Text => (label, None, tooltip),
TabDisplayMode::Icon => {
let resolved = label.clone().resolve_now();
let tip = tooltip.or_else(|| (!resolved.trim().is_empty()).then(|| label.clone()));
if icon.is_some() {
(lit!(""), icon, tip)
} else {
let initial: String = resolved.chars().take(1).collect();
(lit!(initial), None, tip)
}
}
}
}
#[derive(Debug, Clone, Copy)]
enum ScrollArrowKind {
Leading,
Trailing,
}
fn build_scroll_arrow(
ctx: &mut BuildContext,
kind: ScrollArrowKind,
orientation: TabBarOrientation,
scroll_main: Signal<f32>,
max_scroll_main: Signal<f32>,
duration: std::time::Duration,
easing: Easing,
icon_role: TextRole,
) -> WidgetId {
let _ = ctx;
let icon_size = crate::styles::recipe_button_style::BUTTON_ICON_SIZE;
let icon = match (orientation, kind) {
(TabBarOrientation::Horizontal, ScrollArrowKind::Leading) => {
IconWidget::chevron_left(icon_size)
}
(TabBarOrientation::Horizontal, ScrollArrowKind::Trailing) => {
IconWidget::chevron_right(icon_size)
}
(TabBarOrientation::Vertical, ScrollArrowKind::Leading) => {
IconWidget::chevron_up(icon_size)
}
(TabBarOrientation::Vertical, ScrollArrowKind::Trailing) => {
IconWidget::chevron_down(icon_size)
}
};
let tooltip = match (orientation, kind) {
(TabBarOrientation::Horizontal, ScrollArrowKind::Leading) => {
lit!("Scroll tabs left")
}
(TabBarOrientation::Horizontal, ScrollArrowKind::Trailing) => {
lit!("Scroll tabs right")
}
(TabBarOrientation::Vertical, ScrollArrowKind::Leading) => {
lit!("Scroll tabs up")
}
(TabBarOrientation::Vertical, ScrollArrowKind::Trailing) => {
lit!("Scroll tabs down")
}
};
let button = IconButton::new(icon)
.embedded()
.size(IconButtonSize::Compact)
.icon_role(icon_role)
.tooltip(tooltip)
.on_activate_fn(move |_ctx| {
let cur = scroll_main.get();
let target = match kind {
ScrollArrowKind::Leading => (cur - SCROLL_ARROW_STEP).max(0.0),
ScrollArrowKind::Trailing => (cur + SCROLL_ARROW_STEP).min(max_scroll_main.get()),
};
scroll_main.animate_to(target, duration, easing);
});
ctx.add(button)
}
#[derive(Clone)]
struct DropdownEntry {
id: TabId,
label: LocalizedString,
enabled: bool,
}
impl std::fmt::Debug for DropdownEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DropdownEntry")
.field("id", &self.id)
.field("enabled", &self.enabled)
.finish()
}
}
const DROPDOWN_WIDTH: f32 = 240.0;
const DROPDOWN_MAX_HEIGHT: f32 = 320.0;
const DROPDOWN_ROW_HEIGHT: f32 = 28.0;
const DROPDOWN_PADDING: f32 = 4.0;
fn build_overflow_dropdown(
ctx: &mut BuildContext,
selected_id: Signal<Option<TabId>>,
entries: Vec<DropdownEntry>,
icon_role: TextRole,
) -> WidgetId {
let _ = ctx;
let icon_size = crate::styles::recipe_button_style::BUTTON_ICON_SIZE;
let trigger = IconButton::new(IconWidget::chevron_down(icon_size))
.embedded()
.size(IconButtonSize::Compact)
.icon_role(icon_role)
.tooltip(lit!("Show all tabs"));
let row_count = entries.len();
let model = ListModel::from_vec(entries);
let selected_for_delegate = selected_id.clone();
let list = ListView::new(model, move |_i, entry: &DropdownEntry, _selected| {
let entry_id = entry.id;
let label = entry.label.clone();
let enabled = entry.enabled;
let sel = selected_for_delegate.clone();
Box::new(
Button::new(label)
.variant(ButtonVariant::Ghost)
.enabled(enabled)
.on_activate_fn(move |ctx: &mut EventContext| {
sel.set(Some(entry_id));
ctx.dismiss_self_overlay_chain();
}),
) as Box<dyn Widget>
})
.item_height(DROPDOWN_ROW_HEIGHT);
let natural_h = (row_count as f32 * DROPDOWN_ROW_HEIGHT) + (DROPDOWN_PADDING * 2.0);
let content_h = natural_h.min(DROPDOWN_MAX_HEIGHT);
let sized = FixedSize::new()
.width(DROPDOWN_WIDTH - DROPDOWN_PADDING * 2.0)
.height(content_h - DROPDOWN_PADDING * 2.0)
.child(list);
let surface = Panel::new()
.background(SurfaceRole::Raised)
.border_color(BorderRole::Default)
.border_width(1.0)
.padding(DROPDOWN_PADDING)
.child(sized);
ctx.add(
PopoverIconButton::new(trigger)
.content(surface)
.bare()
.placement(OverlayPlacement::BelowPreferred)
.has_popup_kind(HasPopup::Menu),
)
}
fn axis_range(rect: &Rect, axis: TabBarOrientation) -> (f32, f32) {
match axis {
TabBarOrientation::Horizontal => (rect.x, rect.right()),
TabBarOrientation::Vertical => (rect.y, rect.bottom()),
}
}
fn insertion_world_main_for(bounds: &[Rect], pointer_main: f32, axis: TabBarOrientation) -> f32 {
let n = bounds.len();
debug_assert!(n > 0);
let (_, last_end) = axis_range(&bounds[n - 1], axis);
if pointer_main >= last_end {
return last_end;
}
let (first_start, _) = axis_range(&bounds[0], axis);
if pointer_main <= first_start {
return first_start;
}
for header in bounds {
let (start, end) = axis_range(header, axis);
let mid = (start + end) * 0.5;
if pointer_main < mid {
return start;
}
}
last_end
}
fn insertion_index_for(bounds: &[Rect], pointer_main: f32, axis: TabBarOrientation) -> usize {
let n = bounds.len();
if n == 0 {
return 0;
}
let (_, last_end) = axis_range(&bounds[n - 1], axis);
if pointer_main >= last_end {
return n;
}
let (first_start, _) = axis_range(&bounds[0], axis);
if pointer_main <= first_start {
return 0;
}
for (i, header) in bounds.iter().enumerate() {
let (start, end) = axis_range(header, axis);
let mid = (start + end) * 0.5;
if pointer_main < mid {
return i;
}
}
n
}
#[cfg(test)]
mod drop_math_tests {
use super::*;
fn three_tabs() -> Vec<Rect> {
vec![
Rect::new(0.0, 0.0, 100.0, 30.0), Rect::new(100.0, 0.0, 100.0, 30.0), Rect::new(200.0, 0.0, 100.0, 30.0), ]
}
fn three_tabs_vertical() -> Vec<Rect> {
vec![
Rect::new(0.0, 0.0, 200.0, 50.0), Rect::new(0.0, 50.0, 200.0, 50.0), Rect::new(0.0, 100.0, 200.0, 50.0), ]
}
#[test]
fn pointer_before_first_tab_inserts_at_zero() {
let bounds = three_tabs();
let axis = TabBarOrientation::Horizontal;
assert_eq!(insertion_index_for(&bounds, -10.0, axis), 0);
assert_eq!(insertion_world_main_for(&bounds, -10.0, axis), 0.0);
}
#[test]
fn pointer_past_last_tab_appends() {
let bounds = three_tabs();
let axis = TabBarOrientation::Horizontal;
assert_eq!(insertion_index_for(&bounds, 999.0, axis), 3);
assert_eq!(insertion_world_main_for(&bounds, 999.0, axis), 300.0);
}
#[test]
fn pointer_in_left_half_of_a_tab_inserts_before_it() {
let bounds = three_tabs();
let axis = TabBarOrientation::Horizontal;
assert_eq!(insertion_index_for(&bounds, 120.0, axis), 1);
assert_eq!(insertion_world_main_for(&bounds, 120.0, axis), 100.0);
}
#[test]
fn pointer_in_right_half_of_a_tab_inserts_after_it() {
let bounds = three_tabs();
let axis = TabBarOrientation::Horizontal;
assert_eq!(insertion_index_for(&bounds, 175.0, axis), 2);
assert_eq!(insertion_world_main_for(&bounds, 175.0, axis), 200.0);
}
#[test]
fn vertical_pointer_above_first_tab_inserts_at_zero() {
let bounds = three_tabs_vertical();
let axis = TabBarOrientation::Vertical;
assert_eq!(insertion_index_for(&bounds, -10.0, axis), 0);
assert_eq!(insertion_world_main_for(&bounds, -10.0, axis), 0.0);
}
#[test]
fn vertical_pointer_past_last_tab_appends() {
let bounds = three_tabs_vertical();
let axis = TabBarOrientation::Vertical;
assert_eq!(insertion_index_for(&bounds, 999.0, axis), 3);
assert_eq!(insertion_world_main_for(&bounds, 999.0, axis), 150.0);
}
#[test]
fn vertical_pointer_in_top_half_of_a_tab_inserts_before_it() {
let bounds = three_tabs_vertical();
let axis = TabBarOrientation::Vertical;
assert_eq!(insertion_index_for(&bounds, 60.0, axis), 1);
assert_eq!(insertion_world_main_for(&bounds, 60.0, axis), 50.0);
}
#[test]
fn vertical_pointer_in_bottom_half_of_a_tab_inserts_after_it() {
let bounds = three_tabs_vertical();
let axis = TabBarOrientation::Vertical;
assert_eq!(insertion_index_for(&bounds, 88.0, axis), 2);
assert_eq!(insertion_world_main_for(&bounds, 88.0, axis), 100.0);
}
}
#[derive(Debug)]
struct EnabledProbe;
impl Widget for EnabledProbe {
fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
Size::new(0.0, 0.0).into()
}
}