use std::any::Any;
use astrelis_core::math::Vec2;
use astrelis_render::Color;
use astrelis_text::FontRenderer;
use crate::style::Style;
use crate::tree::{LayoutRect, NodeId};
use crate::widgets::{ScrollbarTheme, Widget};
pub const DEFAULT_TAB_BAR_HEIGHT: f32 = 22.0;
pub const DEFAULT_TAB_PADDING: f32 = 8.0;
pub const DEFAULT_CLOSE_BUTTON_SIZE: f32 = 12.0;
pub(crate) const CHAR_WIDTH_FACTOR: f32 = 0.6;
pub(crate) const DROP_INDICATOR_WIDTH: f32 = 2.0;
pub(crate) const CLOSE_BUTTON_MARGIN: f32 = 4.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TabScrollIndicator {
#[default]
Arrows,
Scrollbar,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TabScrollbarPosition {
Top,
#[default]
Bottom,
}
pub fn default_tab_bar_color() -> Color {
Color::from_rgb_u8(14, 14, 19)
}
pub fn default_active_tab_color() -> Color {
Color::from_rgb_u8(24, 24, 32)
}
pub fn default_inactive_tab_color() -> Color {
Color::from_rgb_u8(14, 14, 19)
}
pub fn default_tab_text_color() -> Color {
Color::from_rgb_u8(200, 200, 215)
}
pub fn default_tab_hover_color() -> Color {
Color::from_rgb_u8(30, 30, 40)
}
#[derive(Debug, Clone)]
pub struct DockTabsTheme {
pub tab_bar_height: f32,
pub tab_bar_color: Color,
pub active_tab_color: Color,
pub inactive_tab_color: Color,
pub tab_text_color: Color,
pub tab_hover_color: Color,
pub tab_font_size: f32,
pub closable: bool,
pub scroll_indicator: TabScrollIndicator,
pub scrollbar_position: TabScrollbarPosition,
pub scrollbar_theme: ScrollbarTheme,
}
impl Default for DockTabsTheme {
fn default() -> Self {
Self {
tab_bar_height: DEFAULT_TAB_BAR_HEIGHT,
tab_bar_color: default_tab_bar_color(),
active_tab_color: default_active_tab_color(),
inactive_tab_color: default_inactive_tab_color(),
tab_text_color: default_tab_text_color(),
tab_hover_color: default_tab_hover_color(),
tab_font_size: 11.0,
closable: false,
scroll_indicator: TabScrollIndicator::default(),
scrollbar_position: TabScrollbarPosition::default(),
scrollbar_theme: ScrollbarTheme::default(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TabDragState {
pub dragging_tab_index: Option<usize>,
pub drag_drop_target: Option<usize>,
pub drag_cursor_pos: Option<Vec2>,
}
impl TabDragState {
pub fn is_active(&self) -> bool {
self.dragging_tab_index.is_some()
}
pub fn clear(&mut self) {
*self = Self::default();
}
}
#[derive(Clone)]
pub struct DockTabs {
pub style: Style,
pub children: Vec<NodeId>,
pub tab_labels: Vec<String>,
pub active_tab: usize,
pub theme: DockTabsTheme,
pub hovered_tab: Option<usize>,
pub(crate) tab_widths: Vec<f32>,
pub(crate) tab_widths_dirty: bool,
pub drag: TabDragState,
pub tab_scroll_offset: f32,
pub(crate) tab_bar_scrollable: bool,
pub(crate) scrollbar_thumb_dragging: bool,
pub(crate) scrollbar_drag_anchor: f32,
pub(crate) scrollbar_thumb_hovered: bool,
pub content_padding: Option<f32>,
}
impl DockTabs {
pub fn new() -> Self {
Self {
style: Style::new().display(taffy::Display::Flex),
children: Vec::new(),
tab_labels: Vec::new(),
active_tab: 0,
theme: DockTabsTheme::default(),
hovered_tab: None,
tab_widths: Vec::new(),
tab_widths_dirty: true,
drag: TabDragState::default(),
tab_scroll_offset: 0.0,
tab_bar_scrollable: false,
scrollbar_thumb_dragging: false,
scrollbar_drag_anchor: 0.0,
scrollbar_thumb_hovered: false,
content_padding: None,
}
}
pub fn add_tab(&mut self, label: impl Into<String>, content: NodeId) {
self.tab_labels.push(label.into());
self.children.push(content);
self.tab_widths_dirty = true;
}
pub fn set_active_tab(&mut self, index: usize) -> Option<usize> {
if index < self.children.len() && index != self.active_tab {
let old_active = self.active_tab;
self.active_tab = index;
Some(old_active)
} else {
None
}
}
pub fn set_hovered_tab(&mut self, index: Option<usize>) {
self.hovered_tab = index;
}
pub fn insert_tab_at(&mut self, index: usize, label: impl Into<String>, content: NodeId) {
let index = index.min(self.children.len());
self.tab_labels.insert(index, label.into());
self.children.insert(index, content);
self.tab_widths_dirty = true;
if index <= self.active_tab
&& !self.children.is_empty()
&& self.active_tab + 1 < self.children.len()
{
self.active_tab += 1;
}
}
pub fn remove_tab(&mut self, index: usize) -> Option<(String, NodeId)> {
if index >= self.children.len() {
return None;
}
let label = self.tab_labels.remove(index);
let content = self.children.remove(index);
self.tab_widths_dirty = true;
if self.active_tab >= self.children.len() && !self.children.is_empty() {
self.active_tab = self.children.len() - 1;
} else if self.active_tab > index {
self.active_tab -= 1;
}
Some((label, content))
}
pub fn reorder_tab(&mut self, from_index: usize, to_insertion: usize) -> Option<usize> {
if from_index >= self.children.len() {
return None;
}
let is_moving_left = to_insertion < from_index;
let is_moving_right = to_insertion > from_index + 1;
if !is_moving_left && !is_moving_right {
return None;
}
let label = self.tab_labels.remove(from_index);
let child = self.children.remove(from_index);
let insert_index = if to_insertion > from_index {
to_insertion - 1
} else {
to_insertion
};
self.tab_labels.insert(insert_index, label);
self.children.insert(insert_index, child);
self.tab_widths_dirty = true;
if self.active_tab == from_index {
self.active_tab = insert_index;
} else if self.active_tab > from_index && self.active_tab <= insert_index {
self.active_tab -= 1;
} else if self.active_tab < from_index && self.active_tab >= insert_index {
self.active_tab += 1;
}
Some(insert_index)
}
pub fn close_tab(&mut self, index: usize) -> Option<NodeId> {
self.remove_tab(index).map(|(_, content)| content)
}
pub fn tab_count(&self) -> usize {
self.children.len()
}
pub fn tab_label(&self, index: usize) -> Option<&str> {
self.tab_labels.get(index).map(|s| s.as_str())
}
pub fn tab_bar_height(mut self, height: f32) -> Self {
self.theme.tab_bar_height = height.max(16.0);
self
}
pub fn tab_colors(mut self, bar: Color, active: Color, inactive: Color) -> Self {
self.theme.tab_bar_color = bar;
self.theme.active_tab_color = active;
self.theme.inactive_tab_color = inactive;
self
}
pub fn text_color(mut self, color: Color) -> Self {
self.theme.tab_text_color = color;
self
}
pub fn hover_color(mut self, color: Color) -> Self {
self.theme.tab_hover_color = color;
self
}
pub fn closable(mut self, closable: bool) -> Self {
self.theme.closable = closable;
self
}
pub fn tab_font_size(mut self, size: f32) -> Self {
self.theme.tab_font_size = size;
self
}
pub fn scroll_indicator(mut self, mode: TabScrollIndicator) -> Self {
self.theme.scroll_indicator = mode;
self
}
pub fn scrollbar_position(mut self, position: TabScrollbarPosition) -> Self {
self.theme.scrollbar_position = position;
self
}
pub fn scrollbar_theme(mut self, theme: ScrollbarTheme) -> Self {
self.theme.scrollbar_theme = theme;
self
}
pub fn content_padding(mut self, padding: f32) -> Self {
self.content_padding = Some(padding);
self
}
pub fn should_show_scrollbar(&self) -> bool {
self.tab_bar_scrollable
&& matches!(
self.theme.scroll_indicator,
TabScrollIndicator::Scrollbar | TabScrollIndicator::Both
)
}
pub fn should_show_arrows(&self) -> bool {
self.tab_bar_scrollable
&& matches!(
self.theme.scroll_indicator,
TabScrollIndicator::Arrows | TabScrollIndicator::Both
)
}
pub fn scrollbar_thickness(&self) -> f32 {
if self.should_show_scrollbar() {
self.theme.scrollbar_theme.thickness
} else {
0.0
}
}
pub fn scrollbar_track_bounds(&self, layout: &LayoutRect) -> LayoutRect {
let thickness = self.scrollbar_thickness();
let y = match self.theme.scrollbar_position {
TabScrollbarPosition::Top => layout.y,
TabScrollbarPosition::Bottom => layout.y + self.theme.tab_bar_height - thickness,
};
LayoutRect {
x: layout.x,
y,
width: layout.width,
height: thickness,
}
}
pub fn scrollbar_thumb_bounds(&self, layout: &LayoutRect) -> LayoutRect {
let track = self.scrollbar_track_bounds(layout);
let total_width = self.total_tabs_width();
if total_width <= 0.0 {
return track;
}
let visible_ratio = (track.width / total_width).min(1.0);
let thumb_width = (visible_ratio * track.width)
.max(self.theme.scrollbar_theme.min_thumb_length)
.min(track.width);
let max_scroll = self.max_tab_scroll_offset(layout.width);
let scroll_ratio = if max_scroll > 0.0 {
self.tab_scroll_offset / max_scroll
} else {
0.0
};
let available_travel = track.width - thumb_width;
let thumb_x = track.x + scroll_ratio * available_travel;
LayoutRect {
x: thumb_x,
y: track.y,
width: thumb_width,
height: track.height,
}
}
pub fn tab_row_bounds(&self, layout: &LayoutRect) -> LayoutRect {
let thickness = self.scrollbar_thickness();
let y = match self.theme.scrollbar_position {
TabScrollbarPosition::Top => layout.y + thickness,
TabScrollbarPosition::Bottom => layout.y,
};
LayoutRect {
x: layout.x,
y,
width: layout.width,
height: self.theme.tab_bar_height - thickness,
}
}
pub fn hit_test_scrollbar_thumb(&self, pos: Vec2, layout: &LayoutRect) -> bool {
if !self.should_show_scrollbar() {
return false;
}
let thumb = self.scrollbar_thumb_bounds(layout);
pos.x >= thumb.x
&& pos.x <= thumb.x + thumb.width
&& pos.y >= thumb.y
&& pos.y <= thumb.y + thumb.height
}
pub fn hit_test_scrollbar_track(&self, pos: Vec2, layout: &LayoutRect) -> bool {
if !self.should_show_scrollbar() {
return false;
}
let track = self.scrollbar_track_bounds(layout);
pos.x >= track.x
&& pos.x <= track.x + track.width
&& pos.y >= track.y
&& pos.y <= track.y + track.height
}
pub fn start_scrollbar_drag(&mut self, mouse_x: f32, layout: &LayoutRect) {
let thumb = self.scrollbar_thumb_bounds(layout);
self.scrollbar_drag_anchor = mouse_x - thumb.x;
self.scrollbar_thumb_dragging = true;
}
pub fn update_scrollbar_drag(&mut self, mouse_x: f32, layout: &LayoutRect) {
if !self.scrollbar_thumb_dragging {
return;
}
let track = self.scrollbar_track_bounds(layout);
let total_width = self.total_tabs_width();
if total_width <= 0.0 {
return;
}
let visible_ratio = (track.width / total_width).min(1.0);
let thumb_width = (visible_ratio * track.width)
.max(self.theme.scrollbar_theme.min_thumb_length)
.min(track.width);
let available_travel = track.width - thumb_width;
if available_travel <= 0.0 {
return;
}
let thumb_left = mouse_x - self.scrollbar_drag_anchor;
let scroll_ratio = ((thumb_left - track.x) / available_travel).clamp(0.0, 1.0);
let max_scroll = self.max_tab_scroll_offset(layout.width);
self.tab_scroll_offset = scroll_ratio * max_scroll;
}
pub fn end_scrollbar_drag(&mut self) {
self.scrollbar_thumb_dragging = false;
}
pub fn scrollbar_thumb_color(&self) -> Color {
if self.scrollbar_thumb_dragging {
self.theme.scrollbar_theme.thumb_active_color
} else if self.scrollbar_thumb_hovered {
self.theme.scrollbar_theme.thumb_hover_color
} else {
self.theme.scrollbar_theme.thumb_color
}
}
pub fn tab_bar_bounds(&self, layout: &LayoutRect) -> LayoutRect {
LayoutRect {
x: layout.x,
y: layout.y,
width: layout.width,
height: self.theme.tab_bar_height,
}
}
pub fn content_bounds(&self, layout: &LayoutRect) -> LayoutRect {
LayoutRect {
x: layout.x,
y: layout.y + self.theme.tab_bar_height,
width: layout.width,
height: (layout.height - self.theme.tab_bar_height).max(0.0),
}
}
pub fn tab_bounds(&self, index: usize, layout: &LayoutRect) -> Option<LayoutRect> {
if index >= self.tab_labels.len() {
return None;
}
let row = self.tab_row_bounds(layout);
let mut x = row.x - self.tab_scroll_offset;
for i in 0..index {
x += self.get_tab_width(i);
}
Some(LayoutRect {
x,
y: row.y,
width: self.get_tab_width(index),
height: row.height,
})
}
pub fn compute_tab_widths(&mut self, font_renderer: &FontRenderer) {
if !self.tab_widths_dirty {
return;
}
self.tab_widths.clear();
self.tab_widths.reserve(self.tab_labels.len());
let close_width = if self.theme.closable {
DEFAULT_CLOSE_BUTTON_SIZE + CLOSE_BUTTON_MARGIN
} else {
0.0
};
for label in &self.tab_labels {
let text = astrelis_text::Text::new(label.as_str()).size(self.theme.tab_font_size);
let (text_width, _) = font_renderer.measure_text(&text);
let tab_width = text_width + DEFAULT_TAB_PADDING * 2.0 + close_width;
self.tab_widths.push(tab_width);
}
self.tab_widths_dirty = false;
}
pub fn get_tab_width(&self, index: usize) -> f32 {
if let Some(&width) = self.tab_widths.get(index) {
width
} else {
self.estimate_tab_width(index)
}
}
fn estimate_tab_width(&self, index: usize) -> f32 {
let label = self.tab_labels.get(index).map(|s| s.as_str()).unwrap_or("");
let char_width = self.theme.tab_font_size * CHAR_WIDTH_FACTOR;
let text_width = label.len() as f32 * char_width;
let close_width = if self.theme.closable {
DEFAULT_CLOSE_BUTTON_SIZE + CLOSE_BUTTON_MARGIN
} else {
0.0
};
text_width + DEFAULT_TAB_PADDING * 2.0 + close_width
}
pub fn close_button_bounds(&self, index: usize, layout: &LayoutRect) -> Option<LayoutRect> {
if !self.theme.closable || index >= self.tab_labels.len() {
return None;
}
let tab_bounds = self.tab_bounds(index, layout)?;
let button_size = DEFAULT_CLOSE_BUTTON_SIZE;
let row_height = self.tab_row_bounds(layout).height;
let margin = (row_height - button_size) / 2.0;
Some(LayoutRect {
x: tab_bounds.x + tab_bounds.width - button_size - margin,
y: tab_bounds.y + margin,
width: button_size,
height: button_size,
})
}
pub fn hit_test_tab(&self, pos: Vec2, layout: &LayoutRect) -> Option<usize> {
let row = self.tab_row_bounds(layout);
if pos.y < row.y || pos.y > row.y + row.height {
return None;
}
for i in 0..self.tab_labels.len() {
if let Some(tab_rect) = self.tab_bounds(i, layout)
&& pos.x >= tab_rect.x
&& pos.x <= tab_rect.x + tab_rect.width
{
return Some(i);
}
}
None
}
pub fn hit_test_close_button(&self, pos: Vec2, layout: &LayoutRect) -> Option<usize> {
if !self.theme.closable {
return None;
}
for i in 0..self.tab_labels.len() {
if let Some(close_rect) = self.close_button_bounds(i, layout)
&& pos.x >= close_rect.x
&& pos.x <= close_rect.x + close_rect.width
&& pos.y >= close_rect.y
&& pos.y <= close_rect.y + close_rect.height
{
return Some(i);
}
}
None
}
pub fn tab_background_color(&self, index: usize) -> Color {
if index == self.active_tab {
self.theme.active_tab_color
} else if self.hovered_tab == Some(index) {
self.theme.tab_hover_color
} else {
self.theme.inactive_tab_color
}
}
pub fn active_content(&self) -> Option<NodeId> {
self.children.get(self.active_tab).copied()
}
pub fn start_tab_drag(&mut self, tab_index: usize) {
if tab_index < self.tab_labels.len() {
self.drag.dragging_tab_index = Some(tab_index);
}
}
pub fn update_drop_target(&mut self, cursor_pos: Vec2, layout: &LayoutRect) {
if self.drag.dragging_tab_index.is_none() {
self.drag.drag_drop_target = None;
self.drag.drag_cursor_pos = None;
return;
}
self.drag.drag_cursor_pos = Some(cursor_pos);
let mut closest_index = 0;
let mut closest_dist = f32::MAX;
for i in 0..=self.tab_labels.len() {
let insertion_x = if i == 0 {
layout.x
} else if let Some(prev_bounds) = self.tab_bounds(i - 1, layout) {
prev_bounds.x + prev_bounds.width
} else {
layout.x
};
let dist = (cursor_pos.x - insertion_x).abs();
if dist < closest_dist {
closest_dist = dist;
closest_index = i;
}
}
self.drag.drag_drop_target = Some(closest_index);
}
pub fn finish_tab_drag(&mut self) {
if let (Some(from_index), Some(to_index)) =
(self.drag.dragging_tab_index, self.drag.drag_drop_target)
{
self.reorder_tab(from_index, to_index);
}
self.drag.clear();
}
pub fn cancel_tab_drag(&mut self) {
self.drag.clear();
}
pub fn hit_test_tab_bar_background(&self, pos: Vec2, layout: &LayoutRect) -> bool {
let bar = self.tab_bar_bounds(layout);
if pos.y < bar.y || pos.y > bar.y + bar.height || pos.x < bar.x || pos.x > bar.x + bar.width
{
return false;
}
self.hit_test_tab(pos, layout).is_none()
}
pub fn remove_all_tabs(&mut self) -> Vec<(String, NodeId)> {
let labels = std::mem::take(&mut self.tab_labels);
let children = std::mem::take(&mut self.children);
self.active_tab = 0;
self.tab_widths_dirty = true;
self.tab_widths.clear();
self.tab_scroll_offset = 0.0;
self.tab_bar_scrollable = false;
labels.into_iter().zip(children).collect()
}
pub fn insert_tabs_at(&mut self, start_index: usize, tabs: &[(String, NodeId)]) {
let start = start_index.min(self.children.len());
for (offset, (label, content)) in tabs.iter().enumerate() {
let idx = start + offset;
self.tab_labels.insert(idx, label.clone());
self.children.insert(idx, *content);
}
self.tab_widths_dirty = true;
if start <= self.active_tab && !self.children.is_empty() {
self.active_tab = (self.active_tab + tabs.len()).min(self.children.len() - 1);
}
}
pub fn total_tabs_width(&self) -> f32 {
(0..self.tab_labels.len())
.map(|i| self.get_tab_width(i))
.sum()
}
pub fn tabs_overflow(&self, available_width: f32) -> bool {
self.total_tabs_width() > available_width
}
pub fn max_tab_scroll_offset(&self, available_width: f32) -> f32 {
(self.total_tabs_width() - available_width).max(0.0)
}
pub fn clamp_tab_scroll(&mut self, available_width: f32) {
let max = self.max_tab_scroll_offset(available_width);
self.tab_scroll_offset = self.tab_scroll_offset.clamp(0.0, max);
}
pub fn scroll_to_tab(&mut self, index: usize, available_width: f32) {
if index >= self.tab_labels.len() {
return;
}
let mut tab_start: f32 = 0.0;
for i in 0..index {
tab_start += self.get_tab_width(i);
}
let tab_end = tab_start + self.get_tab_width(index);
if tab_start < self.tab_scroll_offset {
self.tab_scroll_offset = tab_start;
}
if tab_end > self.tab_scroll_offset + available_width {
self.tab_scroll_offset = tab_end - available_width;
}
self.clamp_tab_scroll(available_width);
}
pub fn scroll_tab_bar_by(&mut self, delta: f32, available_width: f32) {
self.tab_scroll_offset += delta;
self.clamp_tab_scroll(available_width);
}
pub fn drop_indicator_bounds(&self, layout: &LayoutRect) -> Option<LayoutRect> {
let drop_index = self.drag.drag_drop_target?;
let row = self.tab_row_bounds(layout);
let x = if drop_index == 0 {
row.x
} else if let Some(prev_tab) = self.tab_bounds(drop_index - 1, layout) {
prev_tab.x + prev_tab.width
} else {
row.x
};
Some(LayoutRect {
x,
y: row.y,
width: DROP_INDICATOR_WIDTH,
height: row.height,
})
}
}
impl Default for DockTabs {
fn default() -> Self {
Self::new()
}
}
impl Widget for DockTabs {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn style(&self) -> &Style {
&self.style
}
fn style_mut(&mut self) -> &mut Style {
&mut self.style
}
fn children(&self) -> &[NodeId] {
&self.children
}
fn children_mut(&mut self) -> Option<&mut Vec<NodeId>> {
Some(&mut self.children)
}
fn measure(&self, _available_space: Vec2, _font_renderer: Option<&FontRenderer>) -> Vec2 {
Vec2::ZERO
}
fn clone_box(&self) -> Box<dyn Widget> {
Box::new(self.clone())
}
}
pub fn compute_all_tab_widths(tree: &mut crate::tree::UiTree, font_renderer: &FontRenderer) {
let all_ids = tree.node_ids();
let tab_node_infos: Vec<(NodeId, f32)> = all_ids
.into_iter()
.filter_map(|id| {
let is_tabs = tree
.get_widget(id)
.map(|w| w.as_any().downcast_ref::<DockTabs>().is_some())
.unwrap_or(false);
if is_tabs {
let width = tree.get_layout(id).map(|l| l.width).unwrap_or(0.0);
Some((id, width))
} else {
None
}
})
.collect();
for (node_id, layout_width) in tab_node_infos {
if let Some(widget) = tree.get_widget_mut(node_id)
&& let Some(tabs) = widget.as_any_mut().downcast_mut::<DockTabs>()
{
tabs.compute_tab_widths(font_renderer);
let new_scrollable = tabs.tabs_overflow(layout_width);
let scrollable_changed = new_scrollable != tabs.tab_bar_scrollable;
tabs.tab_bar_scrollable = new_scrollable;
if tabs.tab_bar_scrollable {
tabs.clamp_tab_scroll(layout_width);
} else {
tabs.tab_scroll_offset = 0.0;
}
if scrollable_changed {
tree.mark_dirty_flags(node_id, crate::dirty::DirtyFlags::GEOMETRY);
}
}
}
}