diff --git a/doc/classes/TabBar.xml b/doc/classes/TabBar.xml index af25bce3df8b..57e607cb50c1 100644 --- a/doc/classes/TabBar.xml +++ b/doc/classes/TabBar.xml @@ -1,10 +1,10 @@ - A control that provides a horizontal bar with tabs. + A control that provides a horizontal or vertical bar with tabs. - A control that provides a horizontal bar with tabs. Similar to [TabContainer] but is only in charge of drawing tabs, not interacting with children. + A control that provides a horizontal or vertical bar with tabs. Similar to [TabContainer] but is only in charge of drawing tabs, not interacting with children. @@ -97,6 +97,12 @@ Returns tab [Rect2] with local position and size. + + + + Returns [code]true[/code] if the tab style is flipped vertically. See [method set_tab_style_v_flip]. + + @@ -215,6 +221,13 @@ Sets the metadata value for the tab at index [param tab_idx], which can be retrieved later using [method get_tab_metadata]. + + + + + If [code]true[/code], the tab style will be flipped vertically. This is used when tabs are positioned at the bottom of the container to ensure the tab style is oriented correctly. + + @@ -302,6 +315,9 @@ [TabBar]s with the same rearrange group ID will allow dragging the tabs between them. Enable drag with [member drag_to_rearrange_enabled]. Setting this to [code]-1[/code] will disable rearranging between [TabBar]s. + + If [code]true[/code], tabs are arranged vertically (top to bottom) instead of horizontally (left to right). + @@ -450,6 +466,12 @@ Icon for the left arrow button that appears when there are too many tabs to fit in the container width. Used when the button is being hovered with the cursor. + + Icon for the up arrow button that appears when there are too many tabs to fit in the container height (vertical tabs). When the button is disabled (i.e. the first tab is visible), it appears semi-transparent. + + + Icon for the up arrow button that appears when there are too many tabs to fit in the container height (vertical tabs). Used when the button is being hovered with the cursor. + Icon shown to indicate where a dragged tab will be dropped (see [member drag_to_rearrange_enabled]). @@ -459,6 +481,15 @@ Icon for the right arrow button that appears when there are too many tabs to fit in the container width. Used when the button is being hovered with the cursor. + + Icon for the down arrow button that appears when there are too many tabs to fit in the container height (vertical tabs). When the button is disabled (i.e. the last tab is visible), it appears semi-transparent. + + + Icon for the down arrow button that appears when there are too many tabs to fit in the container height (vertical tabs). Used when the button is being hovered with the cursor. + + + Icon shown to indicate where a dragged tab will be dropped when using vertical tabs (see [member drag_to_rearrange_enabled]). + Background of the tab and close buttons when they're being hovered with the cursor. diff --git a/doc/classes/TabContainer.xml b/doc/classes/TabContainer.xml index b8c680e192dc..ee1fcd2211bd 100644 --- a/doc/classes/TabContainer.xml +++ b/doc/classes/TabContainer.xml @@ -137,6 +137,7 @@ If set on a [Popup] node instance, a popup menu icon appears in the top-right corner of the [TabContainer] (setting it to [code]null[/code] will make it go away). Clicking it will expand the [Popup] node. + [b]Note:[/b] In vertical mode ([member tabs_position] is [constant POSITION_LEFT] or [constant POSITION_RIGHT]), the popup button is positioned next to the scroll buttons (if visible) or centered vertically in the tab bar. @@ -211,6 +212,7 @@ If [code]true[/code], tabs overflowing this node's width will be hidden, displaying two navigation buttons instead. Otherwise, this node's minimum size is updated so that all tabs are visible. + [b]Note:[/b] In vertical mode ([member tabs_position] is [constant POSITION_LEFT] or [constant POSITION_RIGHT]), space is always reserved for a visible popup button (see [method set_popup]) regardless of this setting, to prevent overlap with tab content. The current tab index. When set, this index's [Control] node's [code]visible[/code] property is set to [code]true[/code] and all others are set to [code]false[/code]. @@ -249,7 +251,8 @@ [b]Note:[/b] [code]index[/code] is a value in the [code]0 .. get_tab_count() - 1[/code] range. - The horizontal alignment of the tabs. + The position of the tab bar. + [b]Note:[/b] When using [constant POSITION_LEFT] or [constant POSITION_RIGHT], the internal [TabBar] will automatically be set to [member TabBar.vertical] and tabs will be arranged from top to bottom. [TabContainer]s with the same rearrange group ID will allow dragging the tabs between them. Enable drag with [member drag_to_rearrange_enabled]. @@ -312,7 +315,13 @@ Places the tab bar at the bottom. The tab bar's [StyleBox] will be flipped vertically. - + + Places the tab bar at the left. Tabs will be arranged vertically (top to bottom). + + + Places the tab bar at the right. Tabs will be arranged vertically (top to bottom). + + Represents the size of the [enum TabPosition] enum. @@ -376,6 +385,12 @@ Icon for the left arrow button that appears when there are too many tabs to fit in the container width. Used when the button is being hovered with the cursor. + + Icon for the up arrow button that appears when there are too many tabs to fit in the container height (vertical tabs). When the button is disabled (i.e. the first tab is visible), it appears semi-transparent. + + + Icon for the up arrow button that appears when there are too many tabs to fit in the container height (vertical tabs). Used when the button is being hovered with the cursor. + Icon shown to indicate where a dragged tab will be dropped (see [member drag_to_rearrange_enabled]). @@ -385,12 +400,21 @@ Icon for the right arrow button that appears when there are too many tabs to fit in the container width. Used when the button is being hovered with the cursor. + + Icon for the down arrow button that appears when there are too many tabs to fit in the container height (vertical tabs). When the button is disabled (i.e. the last tab is visible), it appears semi-transparent. + + + Icon for the down arrow button that appears when there are too many tabs to fit in the container height (vertical tabs). Used when the button is being hovered with the cursor. + The icon for the menu button (see [method set_popup]). The icon for the menu button (see [method set_popup]) when it's being hovered with the cursor. + + Icon shown to indicate where a dragged tab will be dropped when using vertical tabs (see [member drag_to_rearrange_enabled]). + The style for the background fill. diff --git a/editor/icons/GuiScrollArrowDown.svg b/editor/icons/GuiScrollArrowDown.svg new file mode 100644 index 000000000000..d7fd4c01df64 --- /dev/null +++ b/editor/icons/GuiScrollArrowDown.svg @@ -0,0 +1 @@ + diff --git a/editor/icons/GuiScrollArrowDownHl.svg b/editor/icons/GuiScrollArrowDownHl.svg new file mode 100644 index 000000000000..4f88cb4c8948 --- /dev/null +++ b/editor/icons/GuiScrollArrowDownHl.svg @@ -0,0 +1 @@ + diff --git a/editor/icons/GuiScrollArrowUp.svg b/editor/icons/GuiScrollArrowUp.svg new file mode 100644 index 000000000000..f23522f0441e --- /dev/null +++ b/editor/icons/GuiScrollArrowUp.svg @@ -0,0 +1 @@ + diff --git a/editor/icons/GuiScrollArrowUpHl.svg b/editor/icons/GuiScrollArrowUpHl.svg new file mode 100644 index 000000000000..04f4e0c49bc3 --- /dev/null +++ b/editor/icons/GuiScrollArrowUpHl.svg @@ -0,0 +1 @@ + diff --git a/editor/icons/GuiTabVerticalDropMark.svg b/editor/icons/GuiTabVerticalDropMark.svg new file mode 100644 index 000000000000..5efaf3e094da --- /dev/null +++ b/editor/icons/GuiTabVerticalDropMark.svg @@ -0,0 +1 @@ + diff --git a/scene/gui/tab_bar.cpp b/scene/gui/tab_bar.cpp index 02ece24916a6..1efc0d2d090d 100644 --- a/scene/gui/tab_bar.cpp +++ b/scene/gui/tab_bar.cpp @@ -47,89 +47,329 @@ static inline Color _select_color(const Color &p_override_color, const Color &p_ return p_override_color.a > 0 ? p_override_color : p_default_color; } -Size2 TabBar::get_minimum_size() const { - Size2 ms; - Size2 combined_max = get_combined_maximum_size(); +void TabBar::_get_scroll_button_icons(Ref &r_dec_icon, Ref &r_inc_icon) const { + if (vertical) { + r_dec_icon = theme_cache.decrement_vertical_icon; + r_inc_icon = theme_cache.increment_vertical_icon; + } else { + r_dec_icon = theme_cache.decrement_icon; + r_inc_icon = theme_cache.increment_icon; + } +} - int buttons_size = get_tab_count() > 1 ? theme_cache.decrement_icon->get_width() + theme_cache.increment_icon->get_width() : 0; +static Size2 _get_vertical_popup_button_min_size(const Control *p_parent) { + for (int i = 0; i < p_parent->get_child_count(); i++) { + Control *ctrl = Object::cast_to(p_parent->get_child(i)); + if (ctrl && ctrl->is_visible()) { + return ctrl->get_minimum_size(); + } + } - if (tabs.is_empty()) { - return ms; + return Size2(); +} + +int TabBar::_get_reserved_vertical_buttons_row_height(bool p_assume_buttons_visible) const { + if (!vertical) { + return 0; } - int y_margin = MAX(MAX(MAX(theme_cache.tab_unselected_style->get_minimum_size().height, theme_cache.tab_hovered_style->get_minimum_size().height), theme_cache.tab_selected_style->get_minimum_size().height), theme_cache.tab_disabled_style->get_minimum_size().height); - int max_tab_width = 0; + if (!clip_tabs) { + return _get_vertical_popup_button_min_size(this).height; + } - for (int i = 0; i < tabs.size(); i++) { + // Only reserve a dedicated bottom row when the scroll buttons are actually visible (or + // when we are computing layout assuming they will be visible). When tabs fit and the + // scroll buttons are hidden, any optional popup/menu button is positioned inline after + // the last tab and should not consume extra empty space at the bottom. + if (!(p_assume_buttons_visible || buttons_visible)) { + return 0; + } + + int row_height = _get_vertical_popup_button_min_size(this).height; + Ref dec_icon; + Ref inc_icon; + _get_scroll_button_icons(dec_icon, inc_icon); + if (dec_icon.is_valid() && inc_icon.is_valid()) { + row_height = MAX(row_height, (int)MAX(dec_icon->get_height(), inc_icon->get_height())); + } + + return row_height; +} + +int TabBar::get_vertical_buttons_row_top() const { + if (!vertical || !buttons_visible) { + return 0; + } + + for (int i = max_drawn_tab; i >= offset; i--) { if (tabs[i].hidden) { continue; } - int ofs = ms.width; + return tabs[i].ofs_cache + tabs[i].size_cache; + } - Ref style; - if (tabs[i].disabled) { - style = theme_cache.tab_disabled_style; - } else if (current == i) { - style = theme_cache.tab_selected_style; - } else if (hover == i) { - style = theme_cache.tab_hovered_style; - } else { - style = theme_cache.tab_unselected_style; - } - ms.width += style->get_minimum_size().width; + return 0; +} - if (tabs[i].icon.is_valid()) { - const Size2 icon_size = _get_tab_icon_size(i); - ms.height = MAX(ms.height, icon_size.height + y_margin); - ms.width += icon_size.width + theme_cache.h_separation; - } +int TabBar::_get_primary_limit_minus_buttons(int p_primary_limit) const { + if (vertical) { + return MAX(0, p_primary_limit - _get_reserved_vertical_buttons_row_height()); + } - if (!tabs[i].text.is_empty()) { - ms.width += tabs[i].size_text + theme_cache.h_separation; - } - ms.height = MAX(ms.height, tabs[i].text_buf->get_size().y + y_margin); + Ref dec_icon; + Ref inc_icon; + _get_scroll_button_icons(dec_icon, inc_icon); + if (!dec_icon.is_valid() || !inc_icon.is_valid()) { + return p_primary_limit; + } + + return p_primary_limit - dec_icon->get_width() - inc_icon->get_width(); +} - bool close_visible = cb_displaypolicy == CLOSE_BUTTON_SHOW_ALWAYS || (cb_displaypolicy == CLOSE_BUTTON_SHOW_ACTIVE_ONLY && i == current); +Rect2 TabBar::_get_tabs_content_rect(bool p_assume_buttons_visible) const { + if (!vertical) { + return Rect2(Point2(), get_size()); + } - if (tabs[i].right_button.is_valid()) { - Ref rb = tabs[i].right_button; + const int buttons_row = _get_reserved_vertical_buttons_row_height(p_assume_buttons_visible); + const int content_height = MAX(0, (int)get_size().height - buttons_row); + return Rect2(0, 0, get_size().width, content_height); +} - if (close_visible) { - ms.width += theme_cache.button_hl_style->get_minimum_size().width + rb->get_width(); +void TabBar::_get_scroll_button_rects(Rect2 &r_dec_rect, Rect2 &r_inc_rect) const { + r_dec_rect = Rect2(); + r_inc_rect = Rect2(); + + Ref dec_icon; + Ref inc_icon; + _get_scroll_button_icons(dec_icon, inc_icon); + if (!dec_icon.is_valid() || !inc_icon.is_valid()) { + return; + } + + if (vertical) { + const Size2 dec_size = dec_icon->get_size(); + const Size2 inc_size = inc_icon->get_size(); + const float row_height = _get_reserved_vertical_buttons_row_height(); + const float row_y = get_vertical_buttons_row_top(); + const float popup_width = _get_vertical_popup_button_min_size(this).width; + const float buttons_row_width = popup_width + dec_size.width + inc_size.width; + const float buttons_row_x = MAX(0.0f, (get_size().width - buttons_row_width) * 0.5f) + popup_width; + + r_dec_rect = Rect2(Point2(buttons_row_x, row_y + (row_height - dec_size.height) * 0.5f), dec_size); + r_inc_rect = Rect2(Point2(buttons_row_x + dec_size.width, row_y + (row_height - inc_size.height) * 0.5f), inc_size); + return; + } + + const bool rtl = is_layout_rtl(); + const int buttons_width = dec_icon->get_width() + inc_icon->get_width(); + int buttons_x = rtl ? 0 : _get_primary_limit_minus_buttons(get_size().width); + + if (clip_tabs && buttons_visible) { + bool found_visible_tab = false; + int tabs_left = 0; + int tabs_right = 0; + + for (int i = offset; i <= max_drawn_tab && i < tabs.size(); i++) { + if (tabs[i].hidden) { + continue; + } + + const int tab_x = rtl ? (get_size().width - tabs[i].ofs_cache - tabs[i].size_cache) : tabs[i].ofs_cache; + const int tab_end = tab_x + tabs[i].size_cache; + + if (!found_visible_tab) { + tabs_left = tab_x; + tabs_right = tab_end; + found_visible_tab = true; } else { - ms.width += theme_cache.button_hl_style->get_margin(SIDE_LEFT) + rb->get_width() + theme_cache.h_separation; + tabs_left = MIN(tabs_left, tab_x); + tabs_right = MAX(tabs_right, tab_end); } + } - ms.height = MAX(ms.height, rb->get_height() + y_margin); + if (found_visible_tab) { + const int max_buttons_x = MAX((int)get_size().width - buttons_width, 0); + buttons_x = rtl ? MAX(0, tabs_left - buttons_width) : MIN(max_buttons_x, MAX(0, tabs_right)); } + } - if (close_visible) { - ms.width += theme_cache.button_hl_style->get_margin(SIDE_LEFT) + theme_cache.close_icon->get_width() + theme_cache.h_separation; + r_dec_rect = Rect2(Point2(buttons_x, 0), Size2(dec_icon->get_width(), get_size().height)); + r_inc_rect = Rect2(Point2(buttons_x + dec_icon->get_width(), 0), Size2(inc_icon->get_width(), get_size().height)); +} + +bool TabBar::_is_point_primary_before_or_at_mid(const Point2 &p_point, const Rect2 &p_rect) const { + if (vertical) { + return p_point.y <= p_rect.position.y + p_rect.size.height / 2; + } + return is_layout_rtl() != (p_point.x <= p_rect.position.x + p_rect.size.width / 2); +} + +bool TabBar::_is_point_primary_after_mid(const Point2 &p_point, const Rect2 &p_rect) const { + if (vertical) { + return p_point.y > p_rect.position.y + p_rect.size.height / 2; + } + return is_layout_rtl() != (p_point.x > p_rect.position.x + p_rect.size.width / 2); +} + +bool TabBar::_is_point_before_first_tab(const Point2 &p_point) const { + if (tabs.is_empty()) { + return true; + } + + const Rect2 first_tab_rect = get_tab_rect(0); + if (vertical) { + return p_point.y < first_tab_rect.position.y; + } + return is_layout_rtl() != (p_point.x < first_tab_rect.position.x); +} + +TabBar::TabMetrics TabBar::_get_tab_metrics(int p_idx, bool p_for_minimum_size) const { + ERR_FAIL_INDEX_V(p_idx, tabs.size(), TabMetrics()); + + TabMetrics metrics; - ms.height = MAX(ms.height, theme_cache.close_icon->get_height() + y_margin); + Ref style; + if (tabs[p_idx].disabled) { + style = theme_cache.tab_disabled_style; + } else if (current == p_idx) { + style = theme_cache.tab_selected_style; + } else if (p_for_minimum_size ? (hover == p_idx) : (theme_cache.tab_hovered_style->get_minimum_size().width > theme_cache.tab_unselected_style->get_minimum_size().width)) { + style = theme_cache.tab_hovered_style; + } else { + style = theme_cache.tab_unselected_style; + } + + int row_width = style->get_minimum_size().width; + const int row_width_base = row_width; + + if (tabs[p_idx].icon.is_valid()) { + const Size2 icon_size = _get_tab_icon_size(p_idx); + row_width += icon_size.width + theme_cache.h_separation; + } + + if (!tabs[p_idx].text.is_empty()) { + row_width += tabs[p_idx].size_text + theme_cache.h_separation; + } + + const bool close_visible = cb_displaypolicy == CLOSE_BUTTON_SHOW_ALWAYS || (cb_displaypolicy == CLOSE_BUTTON_SHOW_ACTIVE_ONLY && p_idx == current); + if (tabs[p_idx].right_button.is_valid()) { + Ref rb = tabs[p_idx].right_button; + if (close_visible) { + row_width += theme_cache.button_hl_style->get_minimum_size().width + rb->get_width(); + } else { + row_width += theme_cache.button_hl_style->get_margin(SIDE_LEFT) + rb->get_width() + theme_cache.h_separation; } + } + + if (close_visible) { + row_width += theme_cache.button_hl_style->get_margin(SIDE_LEFT) + theme_cache.close_icon->get_width() + theme_cache.h_separation; + } + + if (row_width - row_width_base > style->get_minimum_size().width) { + row_width -= theme_cache.h_separation; + } + + const int y_margin = MAX(MAX(MAX(theme_cache.tab_unselected_style->get_minimum_size().height, theme_cache.tab_hovered_style->get_minimum_size().height), theme_cache.tab_selected_style->get_minimum_size().height), theme_cache.tab_disabled_style->get_minimum_size().height); + int row_height = y_margin; + + if (tabs[p_idx].icon.is_valid()) { + const Size2 icon_size = _get_tab_icon_size(p_idx); + row_height = MAX(row_height, icon_size.height + y_margin); + } + + if (!tabs[p_idx].text.is_empty()) { + row_height = MAX(row_height, int(tabs[p_idx].text_buf->get_size().y) + y_margin); + } + + if (tabs[p_idx].right_button.is_valid()) { + row_height = MAX(row_height, tabs[p_idx].right_button->get_height() + y_margin); + } + + if (close_visible) { + row_height = MAX(row_height, theme_cache.close_icon->get_height() + y_margin); + } + + metrics.row_width = row_width; + metrics.row_height = row_height; + metrics.layout_size = vertical ? row_height : row_width; + return metrics; +} + +Size2 TabBar::get_minimum_size() const { + Size2 ms; + + if (tabs.is_empty()) { + return ms; + } - if (ms.width - ofs > style->get_minimum_size().width) { - ms.width -= theme_cache.h_separation; + if (!theme_cache.tab_unselected_style.is_valid() || !theme_cache.tab_hovered_style.is_valid() || !theme_cache.tab_selected_style.is_valid() || !theme_cache.tab_disabled_style.is_valid() || !theme_cache.button_hl_style.is_valid()) { + return ms; + } + + int primary_sum = 0; + int cross_max = 0; + int primary_max = 0; + + for (int i = 0; i < tabs.size(); i++) { + if (tabs[i].hidden) { + continue; } - if (i < tabs.size() - 1) { - ms.width += theme_cache.tab_separation; + const TabMetrics metrics = _get_tab_metrics(i, true); + int tab_w = metrics.row_width; + if (max_width > 0 && tab_w > max_width) { + const int size_textless = tab_w - tabs[i].size_text; + tab_w = MAX(size_textless, max_width); } + const int tab_h = metrics.row_height; + const int tab_primary = vertical ? tab_h : tab_w; + const int tab_cross = vertical ? tab_w : tab_h; + + primary_sum += tab_primary; + primary_max = MAX(primary_max, tab_primary); + cross_max = MAX(cross_max, tab_cross); - if (ms.width - ofs > max_tab_width) { - max_tab_width = ms.width - ofs; + if (i < tabs.size() - 1) { + primary_sum += theme_cache.tab_separation; } } if (clip_tabs) { - ms.width = max_tab_width + buttons_size; - if (combined_max.width >= 0) { - ms.width = MIN(ms.width, int(combined_max.width)); + const Size2 desired_ms = get_desired_size(); + const Size2 custom_max = get_custom_maximum_size(); + const int desired_primary = vertical ? desired_ms.height : desired_ms.width; + const int custom_max_primary = vertical ? custom_max.height : custom_max.width; + if (custom_max_primary >= 0 && custom_max_primary >= desired_primary) { + return desired_ms; + } + + const int buttons_primary = (get_tab_count() > 1 && theme_cache.decrement_icon.is_valid() && theme_cache.increment_icon.is_valid()) ? (vertical ? MAX(theme_cache.decrement_icon->get_height(), theme_cache.increment_icon->get_height()) : (theme_cache.decrement_icon->get_width() + theme_cache.increment_icon->get_width())) : 0; + const int popup_width = vertical ? _get_vertical_popup_button_min_size(this).width : 0; + int buttons_cross = 0; + if (vertical && get_tab_count() > 1 && theme_cache.decrement_icon.is_valid() && theme_cache.increment_icon.is_valid()) { + buttons_cross = theme_cache.decrement_icon->get_width() + theme_cache.increment_icon->get_width(); } - } else if (combined_max.width >= 0) { - ms.width = MIN(ms.width, int(combined_max.width)); + + Size2 clipped_ms; + if (vertical) { + clipped_ms.width = MAX(cross_max, popup_width + buttons_cross); + clipped_ms.height = primary_max + buttons_primary; + } else { + clipped_ms.width = primary_max + buttons_primary; + clipped_ms.height = cross_max; + } + + return clipped_ms; + } + + if (vertical) { + ms.width = MAX(cross_max, (int)_get_vertical_popup_button_min_size(this).width); + ms.height = primary_sum + _get_reserved_vertical_buttons_row_height(); + } else { + ms.width = primary_sum; + ms.height = cross_max; } return ms; @@ -144,43 +384,27 @@ void TabBar::gui_input(const Ref &p_event) { Point2 pos = mm->get_position(); if (buttons_visible) { - if (is_layout_rtl()) { - if (pos.x < theme_cache.decrement_icon->get_width()) { - if (highlight_arrow != 1) { - highlight_arrow = 1; - queue_redraw(); - } - } else if (pos.x < theme_cache.increment_icon->get_width() + theme_cache.decrement_icon->get_width()) { - if (highlight_arrow != 0) { - highlight_arrow = 0; - queue_redraw(); - } - } else if (highlight_arrow != -1) { - highlight_arrow = -1; - queue_redraw(); - } - } else { - int limit_minus_buttons = get_size().width - theme_cache.increment_icon->get_width() - theme_cache.decrement_icon->get_width(); - if (pos.x > limit_minus_buttons + theme_cache.decrement_icon->get_width()) { - if (highlight_arrow != 1) { - highlight_arrow = 1; - queue_redraw(); - } - } else if (pos.x > limit_minus_buttons) { - if (highlight_arrow != 0) { - highlight_arrow = 0; - queue_redraw(); - } - } else if (highlight_arrow != -1) { - highlight_arrow = -1; - queue_redraw(); - } + Rect2 dec_rect; + Rect2 inc_rect; + _get_scroll_button_rects(dec_rect, inc_rect); + int hover_arrow = -1; + if (dec_rect.has_point(pos)) { + hover_arrow = vertical || !is_layout_rtl() ? 0 : 1; + } else if (inc_rect.has_point(pos)) { + hover_arrow = vertical || !is_layout_rtl() ? 1 : 0; + } + if (highlight_arrow != hover_arrow) { + highlight_arrow = hover_arrow; + queue_redraw(); } } - if (get_viewport()->gui_is_dragging() && can_drop_data(pos, get_viewport()->gui_get_drag_data())) { - dragging_valid_tab = true; - queue_redraw(); + if (get_viewport()->gui_is_dragging()) { + Variant drag_data = get_viewport()->gui_get_drag_data(); + if (can_drop_data(pos, drag_data) || _handle_can_drop_data("tab_container_tab", pos, drag_data)) { + dragging_valid_tab = true; + queue_redraw(); + } } if (!tabs.is_empty()) { @@ -193,22 +417,35 @@ void TabBar::gui_input(const Ref &p_event) { Ref mb = p_event; if (mb.is_valid()) { - if (mb->is_pressed() && (mb->get_button_index() == MouseButton::WHEEL_UP || (is_layout_rtl() ? mb->get_button_index() == MouseButton::WHEEL_RIGHT : mb->get_button_index() == MouseButton::WHEEL_LEFT)) && !mb->is_command_or_control_pressed()) { - if (scrolling_enabled && buttons_visible) { - if (offset > 0) { - offset--; - _update_cache(); - queue_redraw(); + if (mb->is_pressed() && !mb->is_command_or_control_pressed()) { + if (vertical) { + if (mb->get_button_index() == MouseButton::WHEEL_UP) { + if (scrolling_enabled && buttons_visible && offset > 0) { + offset--; + _update_cache(); + queue_redraw(); + } + } else if (mb->get_button_index() == MouseButton::WHEEL_DOWN) { + if (scrolling_enabled && buttons_visible && missing_right && offset < tabs.size()) { + offset++; + _update_cache(); + queue_redraw(); + } } - } - } - - if (mb->is_pressed() && (mb->get_button_index() == MouseButton::WHEEL_DOWN || mb->get_button_index() == (is_layout_rtl() ? MouseButton::WHEEL_LEFT : MouseButton::WHEEL_RIGHT)) && !mb->is_command_or_control_pressed()) { - if (scrolling_enabled && buttons_visible) { - if (missing_right && offset < tabs.size()) { - offset++; - _update_cache(); - queue_redraw(); + } else { + const bool rtl = is_layout_rtl(); + if (mb->get_button_index() == MouseButton::WHEEL_UP || mb->get_button_index() == (rtl ? MouseButton::WHEEL_RIGHT : MouseButton::WHEEL_LEFT)) { + if (scrolling_enabled && buttons_visible && offset > 0) { + offset--; + _update_cache(); + queue_redraw(); + } + } else if (mb->get_button_index() == MouseButton::WHEEL_DOWN || mb->get_button_index() == (rtl ? MouseButton::WHEEL_LEFT : MouseButton::WHEEL_RIGHT)) { + if (scrolling_enabled && buttons_visible && missing_right && offset < tabs.size()) { + offset++; + _update_cache(); + queue_redraw(); + } } } } @@ -242,39 +479,39 @@ void TabBar::gui_input(const Ref &p_event) { bool selecting = mb->get_button_index() == MouseButton::LEFT || (select_with_rmb && mb->get_button_index() == MouseButton::RIGHT); if (buttons_visible && selecting) { - if (is_layout_rtl()) { - if (pos.x < theme_cache.decrement_icon->get_width()) { - if (missing_right) { - offset++; - _update_cache(); - queue_redraw(); - } - return; - } else if (pos.x < theme_cache.increment_icon->get_width() + theme_cache.decrement_icon->get_width()) { + Rect2 dec_rect; + Rect2 inc_rect; + _get_scroll_button_rects(dec_rect, inc_rect); + bool dec_hit = dec_rect.has_point(pos); + bool inc_hit = inc_rect.has_point(pos); + const bool rtl = is_layout_rtl(); + if (dec_hit) { + if (vertical || !rtl) { if (offset > 0) { offset--; _update_cache(); queue_redraw(); } - return; + } else if (missing_right) { + offset++; + _update_cache(); + queue_redraw(); } - } else { - int limit = get_size().width - theme_cache.increment_icon->get_width() - theme_cache.decrement_icon->get_width(); - if (pos.x > limit + theme_cache.decrement_icon->get_width()) { + return; + } + if (inc_hit) { + if (vertical || !rtl) { if (missing_right) { offset++; _update_cache(); queue_redraw(); } - return; - } else if (pos.x > limit) { - if (offset > 0) { - offset--; - _update_cache(); - queue_redraw(); - } - return; + } else if (offset > 0) { + offset--; + _update_cache(); + queue_redraw(); } + return; } } @@ -473,7 +710,12 @@ void TabBar::_notification(int p_what) { AccessibilityServer::get_singleton()->update_set_flag(item.accessibility_item_element, AccessibilityServerEnums::AccessibilityFlags::FLAG_HIDDEN, item.hidden); AccessibilityServer::get_singleton()->update_set_tooltip(item.accessibility_item_element, item.tooltip); - AccessibilityServer::get_singleton()->update_set_bounds(item.accessibility_item_element, Rect2(Point2(item.ofs_cache, 0), Size2(item.size_cache, get_size().height))); + const Rect2 content_rect = _get_tabs_content_rect(); + if (vertical) { + AccessibilityServer::get_singleton()->update_set_bounds(item.accessibility_item_element, Rect2(Point2(content_rect.position.x, item.ofs_cache), Size2(content_rect.size.x, item.size_cache))); + } else { + AccessibilityServer::get_singleton()->update_set_bounds(item.accessibility_item_element, Rect2(Point2(item.ofs_cache, content_rect.position.y), Size2(item.size_cache, content_rect.size.y))); + } item.accessibility_item_dirty = false; } @@ -517,6 +759,17 @@ void TabBar::_notification(int p_what) { if (scroll_to_selected && (offset != ofs_old || max_drawn_tab != max_old)) { ensure_tab_visible(current); } + update_minimum_size(); + } break; + + case NOTIFICATION_DRAG_BEGIN: { + if (drag_to_rearrange_enabled) { + Variant drag_data = get_viewport()->gui_get_drag_data(); + if (can_drop_data(Point2(), drag_data) || _handle_can_drop_data("tab_container_tab", Point2(), drag_data)) { + dragging_valid_tab = true; + queue_redraw(); + } + } } break; case NOTIFICATION_DRAG_END: { @@ -537,18 +790,31 @@ void TabBar::_notification(int p_what) { bool rtl = is_layout_rtl(); Vector2 size = get_size(); + Ref v_dec_icon; + Ref v_inc_icon; + _get_scroll_button_icons(v_dec_icon, v_inc_icon); + Ref v_dec_hl_icon = theme_cache.decrement_vertical_hl_icon.is_valid() ? theme_cache.decrement_vertical_hl_icon : v_dec_icon; + Ref v_inc_hl_icon = theme_cache.increment_vertical_hl_icon.is_valid() ? theme_cache.increment_vertical_hl_icon : v_inc_icon; + Ref v_drop_mark_icon = theme_cache.vertical_drop_mark_icon.is_valid() ? theme_cache.vertical_drop_mark_icon : theme_cache.drop_mark_icon; + auto get_tab_draw_pos = [&](int p_tab) -> float { + return vertical ? tabs[p_tab].ofs_cache : (rtl ? (size.width - tabs[p_tab].ofs_cache - tabs[p_tab].size_cache) : tabs[p_tab].ofs_cache); + }; + if (tabs.is_empty()) { // Draw the drop indicator where the first tab would be if there are no tabs. if (dragging_valid_tab) { - int x = rtl ? size.x : 0; - theme_cache.drop_mark_icon->draw(get_canvas_item(), Point2(x - (theme_cache.drop_mark_icon->get_width() / 2), (size.height - theme_cache.drop_mark_icon->get_height()) / 2), theme_cache.drop_mark_color); + if (vertical) { + int y = 0; + v_drop_mark_icon->draw(get_canvas_item(), Point2((size.width - v_drop_mark_icon->get_width()) / 2, y - (v_drop_mark_icon->get_height() / 2)), theme_cache.drop_mark_color); + } else { + int x = rtl ? size.x : 0; + theme_cache.drop_mark_icon->draw(get_canvas_item(), Point2(x - (theme_cache.drop_mark_icon->get_width() / 2), (size.height - theme_cache.drop_mark_icon->get_height()) / 2), theme_cache.drop_mark_color); + } } return; } - int limit_minus_buttons = size.width - theme_cache.increment_icon->get_width() - theme_cache.decrement_icon->get_width(); - // Draw unselected tabs in the back. for (int i = offset; i <= max_drawn_tab; i++) { if (tabs[i].hidden) { @@ -574,7 +840,7 @@ void TabBar::_notification(int p_what) { icn_col = theme_cache.icon_unselected_color; } - _draw_tab(sb, fnt_col, icn_col, i, rtl ? (size.width - tabs[i].ofs_cache - tabs[i].size_cache) : tabs[i].ofs_cache, false); + _draw_tab(sb, fnt_col, icn_col, i, get_tab_draw_pos(i), false); } } @@ -583,36 +849,35 @@ void TabBar::_notification(int p_what) { Ref sb = tabs[current].disabled ? theme_cache.tab_disabled_style : theme_cache.tab_selected_style; Color col = _select_color(tabs[current].font_color_overrides[DrawMode::DRAW_PRESSED], theme_cache.font_selected_color); - _draw_tab(sb, col, theme_cache.icon_selected_color, current, rtl ? (size.width - tabs[current].ofs_cache - tabs[current].size_cache) : tabs[current].ofs_cache, has_focus(true)); + _draw_tab(sb, col, theme_cache.icon_selected_color, current, get_tab_draw_pos(current), has_focus(true)); } if (buttons_visible) { - int vofs = (size.height - theme_cache.increment_icon->get_size().height) / 2; - - if (rtl) { - if (missing_right) { - draw_texture(highlight_arrow == 1 ? theme_cache.decrement_hl_icon : theme_cache.decrement_icon, Point2(0, vofs)); - } else { - draw_texture(theme_cache.decrement_icon, Point2(0, vofs), Color(1, 1, 1, 0.5)); - } - - if (offset > 0) { - draw_texture(highlight_arrow == 0 ? theme_cache.increment_hl_icon : theme_cache.increment_icon, Point2(theme_cache.increment_icon->get_size().width, vofs)); - } else { - draw_texture(theme_cache.increment_icon, Point2(theme_cache.increment_icon->get_size().width, vofs), Color(1, 1, 1, 0.5)); - } + Rect2 dec_rect; + Rect2 inc_rect; + _get_scroll_button_rects(dec_rect, inc_rect); + if (vertical) { + Texture2D *dec_icon = (highlight_arrow == 0 ? theme_cache.decrement_vertical_hl_icon : v_dec_icon).ptr(); + Texture2D *inc_icon = (highlight_arrow == 1 ? theme_cache.increment_vertical_hl_icon : v_inc_icon).ptr(); + + float dec_opacity = offset > 0 ? 1.0f : 0.5f; + float inc_opacity = missing_right ? 1.0f : 0.5f; + + draw_texture(dec_icon, dec_rect.position, Color(1, 1, 1, dec_opacity)); + draw_texture(inc_icon, inc_rect.position, Color(1, 1, 1, inc_opacity)); } else { - if (offset > 0) { - draw_texture(highlight_arrow == 0 ? theme_cache.decrement_hl_icon : theme_cache.decrement_icon, Point2(limit_minus_buttons, vofs)); - } else { - draw_texture(theme_cache.decrement_icon, Point2(limit_minus_buttons, vofs), Color(1, 1, 1, 0.5)); - } - - if (missing_right) { - draw_texture(highlight_arrow == 1 ? theme_cache.increment_hl_icon : theme_cache.increment_icon, Point2(limit_minus_buttons + theme_cache.decrement_icon->get_size().width, vofs)); - } else { - draw_texture(theme_cache.increment_icon, Point2(limit_minus_buttons + theme_cache.decrement_icon->get_size().width, vofs), Color(1, 1, 1, 0.5)); - } + const bool dec_enabled = rtl ? missing_right : offset > 0; + const bool inc_enabled = rtl ? offset > 0 : missing_right; + const bool dec_highlighted = highlight_arrow == (rtl ? 1 : 0); + const bool inc_highlighted = highlight_arrow == (rtl ? 0 : 1); + + Ref dec_draw = dec_highlighted ? theme_cache.decrement_hl_icon : theme_cache.decrement_icon; + Ref inc_draw = inc_highlighted ? theme_cache.increment_hl_icon : theme_cache.increment_icon; + Point2 dec_pos(dec_rect.position.x, dec_rect.position.y + (dec_rect.size.y - dec_draw->get_height()) * 0.5f); + Point2 inc_pos(inc_rect.position.x, inc_rect.position.y + (inc_rect.size.y - inc_draw->get_height()) * 0.5f); + + draw_texture(dec_draw, dec_pos, Color(1, 1, 1, dec_enabled ? 1.0f : 0.5f)); + draw_texture(inc_draw, inc_pos, Color(1, 1, 1, inc_enabled ? 1.0f : 0.5f)); } } @@ -625,99 +890,183 @@ void TabBar::_notification(int p_what) { void TabBar::_draw_tab_drop(RID p_canvas_item) { Vector2 size = get_size(); - int x; bool rtl = is_layout_rtl(); int closest_tab = get_closest_tab_idx_to_point(get_local_mouse_position()); if (closest_tab != -1) { Rect2 tab_rect = get_tab_rect(closest_tab); - x = tab_rect.position.x; + const Point2 mouse_pos = get_local_mouse_position(); - // Only add the tab_separation if closest tab is not on the edge. - bool not_leftmost_tab = -1 != (rtl ? get_next_available(closest_tab) : get_previous_available(closest_tab)); - bool not_rightmost_tab = -1 != (rtl ? get_previous_available(closest_tab) : get_next_available(closest_tab)); + if (vertical) { + int y = tab_rect.position.y; - // Calculate midpoint between tabs. - if (get_local_mouse_position().x > tab_rect.get_center().x) { - x += tab_rect.size.x; - if (not_rightmost_tab) { - x += Math::ceil(0.5f * theme_cache.tab_separation); + // Only add the tab_separation if closest tab is not on the edge. + bool not_topmost_tab = -1 != get_previous_available(closest_tab); + bool not_bottommost_tab = -1 != get_next_available(closest_tab); + + // Calculate midpoint between tabs. + if (_is_point_primary_after_mid(mouse_pos, tab_rect)) { + y += tab_rect.size.y; + if (not_bottommost_tab) { + y += Math::ceil(0.5f * theme_cache.tab_separation); + } + } else if (not_topmost_tab) { + y -= Math::floor(0.5f * theme_cache.tab_separation); } - } else if (not_leftmost_tab) { - x -= Math::floor(0.5f * theme_cache.tab_separation); + + theme_cache.vertical_drop_mark_icon->draw(p_canvas_item, Point2((size.width - theme_cache.vertical_drop_mark_icon->get_width()) / 2, y - theme_cache.vertical_drop_mark_icon->get_height() / 2), theme_cache.drop_mark_color); + } else { + int x = tab_rect.position.x; + + // Only add the tab_separation if closest tab is not on the edge. + bool not_leftmost_tab = -1 != (rtl ? get_next_available(closest_tab) : get_previous_available(closest_tab)); + bool not_rightmost_tab = -1 != (rtl ? get_previous_available(closest_tab) : get_next_available(closest_tab)); + + // Calculate midpoint between tabs. + if (_is_point_primary_after_mid(mouse_pos, tab_rect)) { + x += tab_rect.size.x; + if (not_rightmost_tab) { + x += Math::ceil(0.5f * theme_cache.tab_separation); + } + } else if (not_leftmost_tab) { + x -= Math::floor(0.5f * theme_cache.tab_separation); + } + + theme_cache.drop_mark_icon->draw(p_canvas_item, Point2(x - theme_cache.drop_mark_icon->get_width() / 2, (size.height - theme_cache.drop_mark_icon->get_height()) / 2), theme_cache.drop_mark_color); } } else { - if (rtl ^ (get_local_mouse_position().x < get_tab_rect(0).position.x)) { - x = get_tab_rect(0).position.x; - if (rtl) { - x += get_tab_rect(0).size.width; + const Point2 mouse_pos = get_local_mouse_position(); + if (vertical) { + int y = get_tab_rect(0).position.y; + if (_is_point_before_first_tab(mouse_pos)) { + // Above first tab + theme_cache.vertical_drop_mark_icon->draw(p_canvas_item, Point2((size.width - theme_cache.vertical_drop_mark_icon->get_width()) / 2, y - theme_cache.vertical_drop_mark_icon->get_height() / 2), theme_cache.drop_mark_color); + } else { + // Below last tab + Rect2 tab_rect = get_tab_rect(get_tab_count() - 1); + y = tab_rect.position.y + tab_rect.size.y; + theme_cache.vertical_drop_mark_icon->draw(p_canvas_item, Point2((size.width - theme_cache.vertical_drop_mark_icon->get_width()) / 2, y - theme_cache.vertical_drop_mark_icon->get_height() / 2), theme_cache.drop_mark_color); } } else { - Rect2 tab_rect = get_tab_rect(get_tab_count() - 1); - - x = tab_rect.position.x; - if (!rtl) { - x += tab_rect.size.width; + int x; + if (_is_point_before_first_tab(mouse_pos)) { + x = get_tab_rect(0).position.x; + if (rtl) { + x += get_tab_rect(0).size.width; + } + } else { + Rect2 tab_rect = get_tab_rect(get_tab_count() - 1); + x = tab_rect.position.x; + if (!rtl) { + x += tab_rect.size.width; + } } + + theme_cache.drop_mark_icon->draw(p_canvas_item, Point2(x - theme_cache.drop_mark_icon->get_width() / 2, (size.height - theme_cache.drop_mark_icon->get_height()) / 2), theme_cache.drop_mark_color); } } - - theme_cache.drop_mark_icon->draw(p_canvas_item, Point2(x - theme_cache.drop_mark_icon->get_width() / 2, (size.height - theme_cache.drop_mark_icon->get_height()) / 2), theme_cache.drop_mark_color); } void TabBar::_draw_tab(Ref &p_tab_style, const Color &p_font_color, const Color &p_icon_color, int p_index, float p_x, bool p_focus) { RID ci = get_canvas_item(); bool rtl = is_layout_rtl(); - Rect2 sb_rect = Rect2(p_x, 0, tabs[p_index].size_cache, get_size().height); - if (tab_style_v_flip) { - draw_set_transform(Point2(0.0, p_tab_style->get_draw_rect(sb_rect).size.y), 0.0, Size2(1.0, -1.0)); - } - p_tab_style->draw(ci, sb_rect); - if (tab_style_v_flip) { - draw_set_transform(Point2(), 0.0, Size2(1.0, 1.0)); + Rect2 sb_rect = get_tab_rect(p_index); + if (!vertical) { + // Keep legacy draw order and v-flip behavior for horizontal tabs. + sb_rect = Rect2(p_x, 0, tabs[p_index].size_cache, get_size().height); + if (tab_style_v_flip) { + draw_set_transform(Point2(0.0, p_tab_style->get_draw_rect(sb_rect).size.y), 0.0, Size2(1.0, -1.0)); + } + p_tab_style->draw(ci, sb_rect); + if (tab_style_v_flip) { + draw_set_transform(Point2(), 0.0, Size2(1.0, 1.0)); + } + } else { + p_tab_style->draw(ci, sb_rect); } + if (p_focus) { Ref focus_style = theme_cache.tab_focus_style; focus_style->draw(ci, sb_rect); } - p_x += rtl ? tabs[p_index].size_cache - p_tab_style->get_margin(SIDE_LEFT) : p_tab_style->get_margin(SIDE_LEFT); - Size2i sb_ms = p_tab_style->get_minimum_size(); + const int content_h = sb_rect.size.y - sb_ms.y; + const int center_y = sb_rect.position.y + p_tab_style->get_margin(SIDE_TOP) + content_h / 2; + + int draw_x; + const int inner_content_x = sb_rect.position.x + p_tab_style->get_margin(SIDE_LEFT); + const int inner_content_w = sb_rect.size.x - sb_ms.x; + + int total_content_width = 0; + int icon_w = 0; + int text_w = 0; + int rb_w = 0; + int cb_w = 0; - // Draw the icon. Ref icon = tabs[p_index].icon; if (icon.is_valid()) { const Size2 icon_size = _get_tab_icon_size(p_index); - const Point2 icon_pos = Point2i(rtl ? p_x - icon_size.width : p_x, p_tab_style->get_margin(SIDE_TOP) + ((sb_rect.size.y - sb_ms.y) - icon_size.height) / 2); - icon->draw_rect(ci, Rect2(icon_pos, icon_size), false, p_icon_color); + icon_w = icon_size.width + theme_cache.h_separation; + total_content_width += icon_w; + } - p_x = rtl ? p_x - icon_size.width - theme_cache.h_separation : p_x + icon_size.width + theme_cache.h_separation; + if (!tabs[p_index].text.is_empty()) { + const int drawn_text_width = Math::ceil(tabs[p_index].text_buf->get_size().x); + text_w = drawn_text_width + theme_cache.h_separation; + total_content_width += text_w; + } + + if (tabs[p_index].right_button.is_valid()) { + rb_w = theme_cache.button_hl_style->get_minimum_size().width + tabs[p_index].right_button->get_width() + theme_cache.h_separation; + total_content_width += rb_w; + } + + if (cb_displaypolicy == CLOSE_BUTTON_SHOW_ALWAYS || (cb_displaypolicy == CLOSE_BUTTON_SHOW_ACTIVE_ONLY && p_index == current)) { + cb_w = theme_cache.button_hl_style->get_minimum_size().width + theme_cache.close_icon->get_width() + theme_cache.h_separation; + total_content_width += cb_w; + } + + if (total_content_width > 0) { + total_content_width -= theme_cache.h_separation; + } + + int start_x = inner_content_x + (inner_content_w - total_content_width) / 2; + if (rtl) { + start_x = inner_content_x + inner_content_w - (start_x - inner_content_x) - total_content_width; + } + draw_x = rtl ? (start_x + total_content_width) : start_x; + + if (icon.is_valid()) { + const Size2 icon_size = _get_tab_icon_size(p_index); + const int icon_y = center_y - icon_size.height / 2; + const Point2 icon_pos = Point2i(rtl ? draw_x - icon_size.width : draw_x, icon_y); + icon->draw_rect(ci, Rect2(icon_pos, icon_size), false, p_icon_color); + draw_x = rtl ? (draw_x - icon_size.width - theme_cache.h_separation) : (draw_x + icon_size.width + theme_cache.h_separation); } - // Draw the text. if (!tabs[p_index].text.is_empty()) { - Point2i text_pos = Point2i(rtl ? p_x - tabs[p_index].size_text : p_x, - p_tab_style->get_margin(SIDE_TOP) + ((sb_rect.size.y - sb_ms.y) - tabs[p_index].text_buf->get_size().y) / 2); + const int drawn_text_width = Math::ceil(tabs[p_index].text_buf->get_size().x); + const int text_y = center_y - tabs[p_index].text_buf->get_size().y / 2; + Point2i text_pos = Point2i(rtl ? draw_x - drawn_text_width : draw_x, text_y); if (theme_cache.outline_size > 0 && theme_cache.font_outline_color.a > 0) { tabs[p_index].text_buf->draw_outline(ci, text_pos, theme_cache.outline_size, theme_cache.font_outline_color); } tabs[p_index].text_buf->draw(ci, text_pos, p_font_color); - p_x = rtl ? p_x - tabs[p_index].size_text - theme_cache.h_separation : p_x + tabs[p_index].size_text + theme_cache.h_separation; + draw_x = rtl ? (draw_x - drawn_text_width - theme_cache.h_separation) : (draw_x + drawn_text_width + theme_cache.h_separation); } - // Draw and calculate rect of the right button. if (tabs[p_index].right_button.is_valid()) { Ref style = theme_cache.button_hl_style; Ref rb = tabs[p_index].right_button; Rect2 rb_rect; rb_rect.size = style->get_minimum_size() + rb->get_size(); - rb_rect.position.x = rtl ? p_x - rb_rect.size.width : p_x; - rb_rect.position.y = p_tab_style->get_margin(SIDE_TOP) + ((sb_rect.size.y - sb_ms.y) - (rb_rect.size.y)) / 2; + rb_rect.position.x = rtl ? draw_x - rb_rect.size.width : draw_x; + rb_rect.position.y = center_y - rb_rect.size.height / 2; tabs.write[p_index].rb_rect = rb_rect; @@ -730,21 +1079,19 @@ void TabBar::_draw_tab(Ref &p_tab_style, const Color &p_font_color, co } rb->draw(ci, Point2i(rb_rect.position.x + style->get_margin(SIDE_LEFT), rb_rect.position.y + style->get_margin(SIDE_TOP))); - - p_x = rtl ? rb_rect.position.x : rb_rect.position.x + rb_rect.size.width; + draw_x = rtl ? rb_rect.position.x : (rb_rect.position.x + rb_rect.size.width); } else { tabs.write[p_index].rb_rect = Rect2(); } - // Draw and calculate rect of the close button. if (cb_displaypolicy == CLOSE_BUTTON_SHOW_ALWAYS || (cb_displaypolicy == CLOSE_BUTTON_SHOW_ACTIVE_ONLY && p_index == current)) { Ref style = theme_cache.button_hl_style; Ref cb = theme_cache.close_icon; Rect2 cb_rect; cb_rect.size = style->get_minimum_size() + cb->get_size(); - cb_rect.position.x = rtl ? p_x - cb_rect.size.width : p_x; - cb_rect.position.y = p_tab_style->get_margin(SIDE_TOP) + ((sb_rect.size.y - sb_ms.y) - (cb_rect.size.y)) / 2; + cb_rect.position.x = rtl ? draw_x - cb_rect.size.width : draw_x; + cb_rect.position.y = center_y - cb_rect.size.height / 2; tabs.write[p_index].cb_rect = cb_rect; @@ -1232,73 +1579,126 @@ void TabBar::_update_cache(bool p_update_hover) { return; } + if (!theme_cache.tab_unselected_style.is_valid() || !theme_cache.tab_hovered_style.is_valid() || !theme_cache.tab_selected_style.is_valid() || !theme_cache.tab_disabled_style.is_valid() || !theme_cache.button_hl_style.is_valid() || !theme_cache.decrement_icon.is_valid() || !theme_cache.increment_icon.is_valid()) { + buttons_visible = false; + return; + } + Size2 combined_max = get_combined_maximum_size(); - int combined_max_width = combined_max.width >= 0 ? int(combined_max.width) - theme_cache.increment_icon->get_width() - theme_cache.decrement_icon->get_width() : INT_MAX; + const bool previous_buttons_visible = buttons_visible; + + int combined_max_width = INT_MAX; + if (combined_max.width >= 0) { + combined_max_width = int(combined_max.width); + if (!vertical && clip_tabs && previous_buttons_visible) { + combined_max_width -= theme_cache.increment_icon->get_width() + theme_cache.decrement_icon->get_width(); + } + } int effective_max_width = max_width > 0 ? MIN(max_width, combined_max_width) : combined_max_width; - int limit = combined_max.width > 0 ? MIN(combined_max.width, get_size().width) : get_size().width; - int limit_minus_buttons = limit - theme_cache.increment_icon->get_width() - theme_cache.decrement_icon->get_width(); + int limit, limit_minus_buttons; + int w = 0; // For horizontal: width, for vertical: height - int w = 0; + const int control_primary = vertical ? get_size().height : get_size().width; + const int combined_max_primary = vertical ? combined_max.height : combined_max.width; + const int base_limit = (combined_max_primary > 0 && combined_max_primary < control_primary) ? combined_max_primary : control_primary; + const int base_limit_minus_buttons = _get_primary_limit_minus_buttons(base_limit); + const int base_vertical_limit_minus_popup = vertical ? MAX(0, base_limit - (int)_get_vertical_popup_button_min_size(this).height) : base_limit; + const int base_vertical_limit_minus_buttons = vertical ? MAX(0, base_limit - _get_reserved_vertical_buttons_row_height(true)) : base_limit_minus_buttons; - max_drawn_tab = tabs.size() - 1; + limit = vertical ? base_vertical_limit_minus_buttons : base_limit; + limit_minus_buttons = vertical ? base_vertical_limit_minus_buttons : base_limit_minus_buttons; for (int i = 0; i < tabs.size(); i++) { tabs.write[i].text_buf->set_width(-1); tabs.write[i].size_text = Math::ceil(tabs[i].text_buf->get_size().x); - tabs.write[i].size_cache = get_tab_width(i); + const TabMetrics metrics = _get_tab_metrics(i, false); + tabs.write[i].size_cache = metrics.layout_size; tabs.write[i].accessibility_item_dirty = true; - tabs.write[i].truncated = effective_max_width > 0 && effective_max_width < INT_MAX && tabs[i].size_cache > effective_max_width; + // Truncation must always be based on the content width. + tabs.write[i].truncated = effective_max_width > 0 && effective_max_width < INT_MAX && metrics.row_width > effective_max_width; if (tabs[i].truncated) { - int size_textless = tabs[i].size_cache - tabs[i].size_text; + const int size_textless = metrics.row_width - tabs[i].size_text; int mw = MAX(size_textless, effective_max_width); - tabs.write[i].size_text = MAX(mw - size_textless, 1); tabs.write[i].text_buf->set_width(tabs[i].size_text); - tabs.write[i].size_cache = size_textless + tabs[i].size_text; + tabs.write[i].size_text = Math::ceil(tabs[i].text_buf->get_size().x); + tabs.write[i].size_cache = get_tab_width(i); } + } - if (i < offset || i > max_drawn_tab) { - tabs.write[i].ofs_cache = 0; - continue; - } + auto layout_tabs = [&](int p_limit, int p_limit_minus_buttons) { + int local_w = 0; + max_drawn_tab = tabs.size() - 1; - tabs.write[i].ofs_cache = w; + for (int i = 0; i < tabs.size(); i++) { + if (i < offset || i > max_drawn_tab) { + tabs.write[i].ofs_cache = 0; + continue; + } - if (tabs[i].hidden) { - continue; - } + tabs.write[i].ofs_cache = local_w; + + if (tabs[i].hidden) { + continue; + } - w += tabs[i].size_cache; + local_w += tabs[i].size_cache; - // Check if all tabs would fit inside the area. - if (clip_tabs && i > offset && (w > limit || (offset > 0 && w > limit_minus_buttons))) { - tabs.write[i].ofs_cache = 0; + // Check if all tabs would fit inside the area. + if (clip_tabs && i > offset && (local_w > p_limit || (offset > 0 && local_w > p_limit_minus_buttons))) { + tabs.write[i].ofs_cache = 0; - w -= tabs[i].size_cache; - w -= theme_cache.tab_separation; + local_w -= tabs[i].size_cache; + local_w -= theme_cache.tab_separation; - max_drawn_tab = i - 1; + max_drawn_tab = i - 1; - while (w > limit_minus_buttons && max_drawn_tab > offset) { - tabs.write[max_drawn_tab].ofs_cache = 0; + while (local_w > p_limit_minus_buttons && max_drawn_tab > offset) { + tabs.write[max_drawn_tab].ofs_cache = 0; - if (!tabs[max_drawn_tab].hidden) { - w -= tabs[max_drawn_tab].size_cache; - w -= theme_cache.tab_separation; - } + if (!tabs[max_drawn_tab].hidden) { + local_w -= tabs[max_drawn_tab].size_cache; + local_w -= theme_cache.tab_separation; + } - max_drawn_tab--; + max_drawn_tab--; + } + } else if (i < tabs.size() - 1) { + // Only add the tab separation if this isn't the last tab drawn. + local_w += theme_cache.tab_separation; } - } else if (i < tabs.size() - 1) { - // Only add the tab separation if this isn't the last tab drawn. - w += theme_cache.tab_separation; } + + return local_w; + }; + + if (vertical && clip_tabs && offset == 0) { + // First, check whether all tabs fit without reserving the arrows row. + w = layout_tabs(base_vertical_limit_minus_popup, base_vertical_limit_minus_buttons); + if (max_drawn_tab < tabs.size() - 1) { + // If clipping is still required, reserve row space and recompute. + w = layout_tabs(base_vertical_limit_minus_buttons, base_vertical_limit_minus_buttons); + limit = base_vertical_limit_minus_buttons; + limit_minus_buttons = base_vertical_limit_minus_buttons; + } else { + limit = base_vertical_limit_minus_popup; + limit_minus_buttons = base_vertical_limit_minus_buttons; + } + } else { + w = layout_tabs(limit, limit_minus_buttons); } missing_right = max_drawn_tab < tabs.size() - 1; - buttons_visible = offset > 0 || missing_right; + buttons_visible = clip_tabs && (offset > 0 || missing_right); + + // Truncation width can depend on whether the scroll strip is reserved. + // If visibility changed this frame, run once more with the updated state. + if (clip_tabs && previous_buttons_visible != buttons_visible) { + _update_cache(p_update_hover); + return; + } if (tab_alignment == ALIGNMENT_LEFT) { if (p_update_hover) { @@ -1331,62 +1731,40 @@ Size2 TabBar::get_desired_size() const { if (!clip_tabs || tabs.is_empty()) { return Size2(); } - Size2 combined_max = get_combined_maximum_size(); - if (combined_max.width < 0) { - return Size2(); - } - - int buttons_size = tabs.size() > 1 ? theme_cache.decrement_icon->get_width() + theme_cache.increment_icon->get_width() : 0; - int limit = int(combined_max.width); - int limit_minus_buttons = limit - buttons_size; + int primary_sum = 0; + int cross_max = 0; - int w = 0; - bool overflowed = false; - - // First pass: check if all tabs fit without buttons. - for (int i = offset; i < tabs.size(); i++) { + for (int i = 0; i < tabs.size(); i++) { if (tabs[i].hidden) { continue; } - int next_w = w + tabs[i].size_cache; - if (i > offset) { - next_w += theme_cache.tab_separation; + const TabMetrics metrics = _get_tab_metrics(i, true); + int tab_w = metrics.row_width; + if (max_width > 0 && tab_w > max_width) { + const int size_textless = tab_w - tabs[i].size_text; + tab_w = MAX(size_textless, max_width); } + const int tab_h = metrics.row_height; - if (next_w > limit) { - overflowed = true; - break; + if (vertical) { + primary_sum += tab_h; + cross_max = MAX(cross_max, tab_w); + } else { + primary_sum += tab_w; + cross_max = MAX(cross_max, tab_h); } - w = next_w; - } - - // If buttons are needed, recompute against the reduced limit. - if (offset > 0 || overflowed) { - w = 0; - for (int i = offset; i < tabs.size(); i++) { - if (tabs[i].hidden) { - continue; - } - - int next_w = w + tabs[i].size_cache; - if (i > offset) { - next_w += theme_cache.tab_separation; - } - - if (next_w > limit_minus_buttons) { - break; - } - - w = next_w; + if (i < tabs.size() - 1) { + primary_sum += theme_cache.tab_separation; } } - int desired = (offset > 0 || overflowed) ? w + buttons_size : w; - desired = MIN(desired, limit); + if (vertical) { + return Size2(MAX(cross_max, (int)_get_vertical_popup_button_min_size(this).width), primary_sum); + } - return Size2(desired, 0); + return Size2(primary_sum, cross_max); } void TabBar::_hover_switch_timeout() { @@ -1404,6 +1782,16 @@ void TabBar::_on_mouse_exited() { queue_redraw(); } +void TabBar::_on_maximum_size_changed() { + _update_cache(); + _ensure_no_over_offset(); + if (scroll_to_selected) { + ensure_tab_visible(current); + } + queue_redraw(); + update_minimum_size(); +} + void TabBar::add_tab(const String &p_str, const Ref &p_icon) { Tab t; t.text = p_str; @@ -1635,10 +2023,11 @@ void TabBar::_handle_drop_data(const String &p_type, const Point2 &p_point, cons return; } - // Drop the new tab to the left or right depending on where the target tab is being hovered. + // Drop the new tab to the left or right (or above/below for vertical) depending on where the target tab is being hovered. if (hover_now != -1) { Rect2 tab_rect = get_tab_rect(hover_now); - if (is_layout_rtl() ^ (p_point.x <= tab_rect.position.x + tab_rect.size.width / 2)) { + const bool drop_before = _is_point_primary_before_or_at_mid(p_point, tab_rect); + if (drop_before) { if (hover_now > tab_from_id) { hover_now -= 1; } @@ -1646,8 +2035,7 @@ void TabBar::_handle_drop_data(const String &p_type, const Point2 &p_point, cons hover_now += 1; } } else { - int x = tabs.is_empty() ? 0 : get_tab_rect(0).position.x; - hover_now = is_layout_rtl() ^ (p_point.x < x) ? 0 : get_tab_count() - 1; + hover_now = _is_point_before_first_tab(p_point) ? 0 : get_tab_count() - 1; } p_move_tab_callback.call(tab_from_id, hover_now); @@ -1666,14 +2054,15 @@ void TabBar::_handle_drop_data(const String &p_type, const Point2 &p_point, cons return; } - // Drop the new tab to the left or right depending on where the target tab is being hovered. + // Drop the new tab to the left or right (or above/below for vertical) depending on where the target tab is being hovered. if (hover_now != -1) { Rect2 tab_rect = get_tab_rect(hover_now); - if (is_layout_rtl() ^ (p_point.x > tab_rect.position.x + tab_rect.size.width / 2)) { + const bool drop_after = _is_point_primary_after_mid(p_point, tab_rect); + if (drop_after) { hover_now += 1; } } else { - hover_now = tabs.is_empty() || (is_layout_rtl() ^ (p_point.x < get_tab_rect(0).position.x)) ? 0 : get_tab_count(); + hover_now = tabs.is_empty() || _is_point_before_first_tab(p_point) ? 0 : get_tab_count(); } p_move_tab_from_other_callback.call(from_tabs, tab_from_id, hover_now); @@ -1753,12 +2142,14 @@ int TabBar::get_closest_tab_idx_to_point(const Point2 &p_point) const { int closest_tab = get_tab_idx_at_point(p_point); float closest_distance = FLT_MAX; - // Search along the x-axis since the TabBar is horizontal. + // Search along the tab layout axis. if (closest_tab == -1) { for (int i = offset; i <= max_drawn_tab; i++) { if (!tabs[i].hidden) { - float center = get_tab_rect(i).get_center().x; - float distance = Math::abs(center - p_point.x); + const Rect2 tab_rect = get_tab_rect(i); + const float point_primary = vertical ? p_point.y : p_point.x; + const float rect_center = vertical ? tab_rect.get_center().y : tab_rect.get_center().x; + float distance = Math::abs(rect_center - point_primary); if (distance < closest_distance) { closest_distance = distance; closest_tab = i; @@ -1813,6 +2204,25 @@ bool TabBar::get_clip_tabs() const { void TabBar::set_tab_style_v_flip(bool p_tab_style_v_flip) { tab_style_v_flip = p_tab_style_v_flip; + queue_redraw(); +} + +bool TabBar::get_tab_style_v_flip() const { + return tab_style_v_flip; +} + +void TabBar::set_vertical(bool p_vertical) { + if (vertical == p_vertical) { + return; + } + vertical = p_vertical; + _update_cache(); + queue_redraw(); + update_minimum_size(); +} + +bool TabBar::is_vertical() const { + return vertical; } void TabBar::move_tab(int p_from, int p_to) { @@ -1857,54 +2267,7 @@ void TabBar::move_tab(int p_from, int p_to) { int TabBar::get_tab_width(int p_idx) const { ERR_FAIL_INDEX_V(p_idx, tabs.size(), 0); - - Ref style; - - if (tabs[p_idx].disabled) { - style = theme_cache.tab_disabled_style; - } else if (current == p_idx) { - style = theme_cache.tab_selected_style; - // Always pick the widest style between hovered and unselected, to avoid an infinite loop when switching tabs with the mouse. - } else if (theme_cache.tab_hovered_style->get_minimum_size().width > theme_cache.tab_unselected_style->get_minimum_size().width) { - style = theme_cache.tab_hovered_style; - } else { - style = theme_cache.tab_unselected_style; - } - int x = style->get_minimum_size().width; - - if (tabs[p_idx].icon.is_valid()) { - const Size2 icon_size = _get_tab_icon_size(p_idx); - x += icon_size.width + theme_cache.h_separation; - } - - if (!tabs[p_idx].text.is_empty()) { - x += tabs[p_idx].size_text + theme_cache.h_separation; - } - - bool close_visible = cb_displaypolicy == CLOSE_BUTTON_SHOW_ALWAYS || (cb_displaypolicy == CLOSE_BUTTON_SHOW_ACTIVE_ONLY && p_idx == current); - - if (tabs[p_idx].right_button.is_valid()) { - Ref btn_style = theme_cache.button_hl_style; - Ref rb = tabs[p_idx].right_button; - - if (close_visible) { - x += btn_style->get_minimum_size().width + rb->get_width(); - } else { - x += btn_style->get_margin(SIDE_LEFT) + rb->get_width() + theme_cache.h_separation; - } - } - - if (close_visible) { - Ref btn_style = theme_cache.button_hl_style; - Ref cb = theme_cache.close_icon; - x += btn_style->get_margin(SIDE_LEFT) + cb->get_width() + theme_cache.h_separation; - } - - if (x > style->get_minimum_size().width) { - x -= theme_cache.h_separation; - } - - return x; + return _get_tab_metrics(p_idx, false).layout_size; } Size2 TabBar::_get_tab_icon_size(int p_index) const { @@ -1933,8 +2296,11 @@ void TabBar::_ensure_no_over_offset() { return; } - int limit_with_buttons = get_size().width - theme_cache.increment_icon->get_width() - theme_cache.decrement_icon->get_width(); - int limit_with_no_button = get_size().width; + int limit_with_buttons; + int limit_with_no_button; + const int primary_size = vertical ? get_size().height : get_size().width; + limit_with_buttons = _get_primary_limit_minus_buttons(primary_size); + limit_with_no_button = primary_size; int offset_with_buttons = offset; int offset_with_no_button = offset; @@ -1994,7 +2360,9 @@ void TabBar::ensure_tab_visible(int p_idx) { return; } - int limit_minus_buttons = get_size().width - theme_cache.increment_icon->get_width() - theme_cache.decrement_icon->get_width(); + int limit_minus_buttons; + const int primary_size = vertical ? get_size().height : get_size().width; + limit_minus_buttons = _get_primary_limit_minus_buttons(primary_size); int total_w = tabs[max_drawn_tab].ofs_cache - tabs[offset].ofs_cache; for (int i = max_drawn_tab; i <= p_idx; i++) { @@ -2029,10 +2397,15 @@ void TabBar::ensure_tab_visible(int p_idx) { Rect2 TabBar::get_tab_rect(int p_tab) const { ERR_FAIL_INDEX_V(p_tab, tabs.size(), Rect2()); - if (is_layout_rtl()) { - return Rect2(get_size().width - tabs[p_tab].ofs_cache - tabs[p_tab].size_cache, 0, tabs[p_tab].size_cache, get_size().height); + if (vertical) { + const Rect2 content_rect = _get_tabs_content_rect(); + return Rect2(content_rect.position.x, tabs[p_tab].ofs_cache, content_rect.size.x, tabs[p_tab].size_cache); } else { - return Rect2(tabs[p_tab].ofs_cache, 0, tabs[p_tab].size_cache, get_size().height); + if (is_layout_rtl()) { + return Rect2(get_size().width - tabs[p_tab].ofs_cache - tabs[p_tab].size_cache, 0, tabs[p_tab].size_cache, get_size().height); + } else { + return Rect2(tabs[p_tab].ofs_cache, 0, tabs[p_tab].size_cache, get_size().height); + } } } @@ -2190,6 +2563,10 @@ void TabBar::_bind_methods() { ClassDB::bind_method(D_METHOD("get_tab_alignment"), &TabBar::get_tab_alignment); ClassDB::bind_method(D_METHOD("set_clip_tabs", "clip_tabs"), &TabBar::set_clip_tabs); ClassDB::bind_method(D_METHOD("get_clip_tabs"), &TabBar::get_clip_tabs); + ClassDB::bind_method(D_METHOD("set_tab_style_v_flip", "tab_style_v_flip"), &TabBar::set_tab_style_v_flip); + ClassDB::bind_method(D_METHOD("get_tab_style_v_flip"), &TabBar::get_tab_style_v_flip); + ClassDB::bind_method(D_METHOD("set_vertical", "vertical"), &TabBar::set_vertical); + ClassDB::bind_method(D_METHOD("is_vertical"), &TabBar::is_vertical); ClassDB::bind_method(D_METHOD("get_tab_offset"), &TabBar::get_tab_offset); ClassDB::bind_method(D_METHOD("get_offset_buttons_visible"), &TabBar::get_offset_buttons_visible); ClassDB::bind_method(D_METHOD("ensure_tab_visible", "idx"), &TabBar::ensure_tab_visible); @@ -2227,7 +2604,8 @@ void TabBar::_bind_methods() { ADD_SIGNAL(MethodInfo("active_tab_rearranged", PropertyInfo(Variant::INT, "idx_to"))); ADD_PROPERTY(PropertyInfo(Variant::INT, "current_tab", PROPERTY_HINT_RANGE, "-1,4096,1"), "set_current_tab", "get_current_tab"); - ADD_PROPERTY(PropertyInfo(Variant::INT, "tab_alignment", PROPERTY_HINT_ENUM, "Left,Center,Right"), "set_tab_alignment", "get_tab_alignment"); + ADD_PROPERTY(PropertyInfo(Variant::INT, "tab_alignment", PROPERTY_HINT_ENUM, "Begin,Center,End"), "set_tab_alignment", "get_tab_alignment"); + ADD_PROPERTY(PropertyInfo(Variant::BOOL, "vertical"), "set_vertical", "is_vertical"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "clip_tabs"), "set_clip_tabs", "get_clip_tabs"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "close_with_middle_mouse"), "set_close_with_middle_mouse", "get_close_with_middle_mouse"); ADD_PROPERTY(PropertyInfo(Variant::INT, "tab_close_display_policy", PROPERTY_HINT_ENUM, "Show Never,Show Active Only,Show Always"), "set_tab_close_display_policy", "get_tab_close_display_policy"); @@ -2267,7 +2645,12 @@ void TabBar::_bind_methods() { BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabBar, increment_hl_icon, "increment_highlight"); BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabBar, decrement_icon, "decrement"); BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabBar, decrement_hl_icon, "decrement_highlight"); + BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabBar, increment_vertical_icon, "increment_vertical"); + BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabBar, increment_vertical_hl_icon, "increment_vertical_highlight"); + BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabBar, decrement_vertical_icon, "decrement_vertical"); + BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabBar, decrement_vertical_hl_icon, "decrement_vertical_highlight"); BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabBar, drop_mark_icon, "drop_mark"); + BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabBar, vertical_drop_mark_icon, "vertical_drop_mark"); BIND_THEME_ITEM(Theme::DATA_TYPE_COLOR, TabBar, drop_mark_color); BIND_THEME_ITEM(Theme::DATA_TYPE_COLOR, TabBar, font_selected_color); @@ -2305,6 +2688,7 @@ TabBar::TabBar() { set_size(Size2(get_size().width, get_minimum_size().height)); set_focus_mode(FOCUS_ALL); connect(SceneStringName(mouse_exited), callable_mp(this, &TabBar::_on_mouse_exited)); + connect(SceneStringName(maximum_size_changed), callable_mp(this, &TabBar::_on_maximum_size_changed)); hover_switch_delay = memnew(Timer); hover_switch_delay->connect("timeout", callable_mp(this, &TabBar::_hover_switch_timeout)); diff --git a/scene/gui/tab_bar.h b/scene/gui/tab_bar.h index 8c4602e43d64..0837cfa46750 100644 --- a/scene/gui/tab_bar.h +++ b/scene/gui/tab_bar.h @@ -116,6 +116,7 @@ class TabBar : public Control { int rb_hover = -1; bool rb_pressing = false; bool tab_style_v_flip = false; + bool vertical = false; bool select_with_rmb = false; bool deselect_enabled = false; @@ -159,7 +160,12 @@ class TabBar : public Control { Ref increment_hl_icon; Ref decrement_icon; Ref decrement_hl_icon; + Ref increment_vertical_icon; + Ref increment_vertical_hl_icon; + Ref decrement_vertical_icon; + Ref decrement_vertical_hl_icon; Ref drop_mark_icon; + Ref vertical_drop_mark_icon; Color drop_mark_color; Ref font; @@ -184,8 +190,23 @@ class TabBar : public Control { Timer *hover_switch_delay = nullptr; + struct TabMetrics { + int row_width = 0; + int row_height = 0; + int layout_size = 0; + }; + int get_tab_width(int p_idx) const; Size2 _get_tab_icon_size(int p_idx) const; + TabMetrics _get_tab_metrics(int p_idx, bool p_for_minimum_size) const; + void _get_scroll_button_icons(Ref &r_dec_icon, Ref &r_inc_icon) const; + void _get_scroll_button_rects(Rect2 &r_dec_rect, Rect2 &r_inc_rect) const; + bool _is_point_primary_before_or_at_mid(const Point2 &p_point, const Rect2 &p_rect) const; + bool _is_point_primary_after_mid(const Point2 &p_point, const Rect2 &p_rect) const; + bool _is_point_before_first_tab(const Point2 &p_point) const; + int _get_primary_limit_minus_buttons(int p_primary_limit) const; + int _get_reserved_vertical_buttons_row_height(bool p_assume_buttons_visible = false) const; + Rect2 _get_tabs_content_rect(bool p_assume_buttons_visible = false) const; void _ensure_no_over_offset(); bool _can_deselect() const; @@ -194,6 +215,7 @@ class TabBar : public Control { void _hover_switch_timeout(); void _on_mouse_exited(); + void _on_maximum_size_changed(); void _shape(int p_tab); void _draw_tab(Ref &p_tab_style, const Color &p_font_color, const Color &p_icon_color, int p_index, float p_x, bool p_focus); @@ -274,6 +296,10 @@ class TabBar : public Control { bool get_clip_tabs() const; void set_tab_style_v_flip(bool p_tab_style_v_flip); + bool get_tab_style_v_flip() const; + + void set_vertical(bool p_vertical); + bool is_vertical() const; void move_tab(int p_from, int p_to); @@ -300,6 +326,7 @@ class TabBar : public Control { void set_tab_offset(int p_offset); int get_tab_offset() const; bool get_offset_buttons_visible() const; + int get_vertical_buttons_row_top() const; void remove_tab(int p_idx); diff --git a/scene/gui/tab_container.cpp b/scene/gui/tab_container.cpp index 9ab0c1459c2c..62c30de14cb7 100644 --- a/scene/gui/tab_container.cpp +++ b/scene/gui/tab_container.cpp @@ -38,6 +38,21 @@ #include "scene/theme/theme_db.h" #include "servers/display/accessibility_server.h" +static inline bool _is_horizontal_tabs_position(TabContainer::TabPosition p_position) { + return p_position == TabContainer::POSITION_TOP || p_position == TabContainer::POSITION_BOTTOM; +} + +static inline bool _is_vertical_tabs_position(TabContainer::TabPosition p_position) { + return p_position == TabContainer::POSITION_LEFT || p_position == TabContainer::POSITION_RIGHT; +} + +static inline int _get_popup_button_child_index(TabContainer::TabPosition p_tabs_position, bool p_is_layout_rtl) { + if (_is_vertical_tabs_position(p_tabs_position)) { + return p_tabs_position == TabContainer::POSITION_LEFT ? 0 : 1; + } + return p_is_layout_rtl ? 0 : 1; +} + TabContainer::CachedTab &TabContainer::get_pending_tab(int p_idx) const { if (p_idx >= pending_tabs.size()) { pending_tabs.resize(p_idx + 1); @@ -54,6 +69,16 @@ int TabContainer::_get_tab_height() const { return height; } +int TabContainer::_get_tab_width() const { + int width = 0; + if (tabs_visible && get_tab_count() > 0) { + width = tab_bar->get_bound_minimum_size().width; + width += theme_cache.tabbar_style->get_margin(SIDE_LEFT) + theme_cache.tabbar_style->get_margin(SIDE_RIGHT); + } + + return width; +} + Control *TabContainer::_as_tab_control(Node *p_child) const { Control *control = as_sortable_control(p_child, SortableVisibilityMode::IGNORE); if (!control || control == internal_container || children_removing.has(control)) { @@ -149,7 +174,8 @@ void TabContainer::_notification(int p_what) { case NOTIFICATION_READY: case NOTIFICATION_RESIZED: { - _update_margins(); + _maximum_size_changed(); + _repaint(); } break; case NOTIFICATION_DRAW: { @@ -161,13 +187,27 @@ void TabContainer::_notification(int p_what) { theme_cache.panel_style->draw(canvas, Rect2(0, 0, size.width, size.height)); return; } - int header_height = _get_tab_height(); - int header_voffset = int(tabs_position == POSITION_BOTTOM) * (size.height - header_height); + + const bool horizontal = _is_horizontal_tabs_position(tabs_position); + const int header_size = horizontal ? _get_tab_height() : _get_tab_width(); + const bool start_side = tabs_position == POSITION_TOP || tabs_position == POSITION_LEFT; + + Rect2 tabbar_rect; + Rect2 panel_rect; + if (horizontal) { + const int header_offset = start_side ? 0 : MAX(0, (int)size.height - header_size); + tabbar_rect = Rect2(0, header_offset, size.width, header_size); + panel_rect = Rect2(0, start_side ? header_size : 0, size.width, MAX(0.0f, size.height - header_size)); + } else { + const int header_offset = start_side ? 0 : MAX(0, (int)size.width - header_size); + tabbar_rect = Rect2(header_offset, 0, header_size, size.height); + panel_rect = Rect2(start_side ? header_size : 0, 0, MAX(0.0f, size.width - header_size), size.height); + } // Draw background for the tabbar. - theme_cache.tabbar_style->draw(canvas, Rect2(0, header_voffset, size.width, header_height)); + theme_cache.tabbar_style->draw(canvas, tabbar_rect); // Draw the background for the tab's content. - theme_cache.panel_style->draw(canvas, Rect2(0, int(tabs_position == POSITION_TOP) * header_height, size.width, size.height - header_height)); + theme_cache.panel_style->draw(canvas, panel_rect); } break; case NOTIFICATION_VISIBILITY_CHANGED: { @@ -194,7 +234,7 @@ void TabContainer::_notification(int p_what) { case NOTIFICATION_LAYOUT_DIRECTION_CHANGED: { if (popup_button) { popup_button->set_button_icon(theme_cache.menu_icon); - internal_container->move_child(popup_button, is_layout_rtl() ? 0 : 1); + _ensure_popup_button_parent(); } _update_margins(); } break; @@ -223,7 +263,12 @@ void TabContainer::_on_theme_changed() { tab_bar->add_theme_icon_override(SNAME("increment_highlight"), theme_cache.increment_hl_icon); tab_bar->add_theme_icon_override(SNAME("decrement"), theme_cache.decrement_icon); tab_bar->add_theme_icon_override(SNAME("decrement_highlight"), theme_cache.decrement_hl_icon); + tab_bar->add_theme_icon_override(SNAME("increment_vertical"), theme_cache.increment_vertical_icon); + tab_bar->add_theme_icon_override(SNAME("increment_vertical_highlight"), theme_cache.increment_vertical_hl_icon); + tab_bar->add_theme_icon_override(SNAME("decrement_vertical"), theme_cache.decrement_vertical_icon); + tab_bar->add_theme_icon_override(SNAME("decrement_vertical_highlight"), theme_cache.decrement_vertical_hl_icon); tab_bar->add_theme_icon_override(SNAME("drop_mark"), theme_cache.drop_mark_icon); + tab_bar->add_theme_icon_override(SNAME("vertical_drop_mark"), theme_cache.vertical_drop_mark_icon); tab_bar->add_theme_color_override(SNAME("drop_mark_color"), theme_cache.drop_mark_color); tab_bar->add_theme_color_override(SNAME("font_selected_color"), theme_cache.font_selected_color); @@ -275,15 +320,35 @@ void TabContainer::_repaint_internal() { float top_margin = theme_cache.tabbar_style->get_margin(SIDE_TOP); float bottom_margin = theme_cache.tabbar_style->get_margin(SIDE_BOTTOM); - - // Move the TabBar to the top or bottom. - // Don't change the left and right offsets since the TabBar will resize and may change tab offset. - if (tabs_position == POSITION_BOTTOM) { - internal_container->set_anchor_and_offset(SIDE_BOTTOM, 1.0, -bottom_margin); - internal_container->set_anchor_and_offset(SIDE_TOP, 1.0, top_margin - _get_tab_height()); + float left_margin = theme_cache.tabbar_style->get_margin(SIDE_LEFT); + float right_margin = theme_cache.tabbar_style->get_margin(SIDE_RIGHT); + + const bool horizontal = _is_horizontal_tabs_position(tabs_position); + const bool start_side = tabs_position == POSITION_TOP || tabs_position == POSITION_LEFT; + const int raw_header_size = horizontal ? _get_tab_height() : _get_tab_width(); + const int header_size = horizontal ? MIN(raw_header_size, int(get_size().height)) : MIN(raw_header_size, int(get_size().width)); + + // Position the TabBar based on tabs_position. + if (horizontal) { + internal_container->set_anchor_and_offset(SIDE_LEFT, 0.0, left_margin); + internal_container->set_anchor_and_offset(SIDE_RIGHT, 1.0, -right_margin); + if (start_side) { + internal_container->set_anchor_and_offset(SIDE_TOP, 0.0, top_margin); + internal_container->set_anchor_and_offset(SIDE_BOTTOM, 0.0, header_size - bottom_margin); + } else { + internal_container->set_anchor_and_offset(SIDE_BOTTOM, 1.0, -bottom_margin); + internal_container->set_anchor_and_offset(SIDE_TOP, 1.0, top_margin - header_size); + } } else { internal_container->set_anchor_and_offset(SIDE_TOP, 0.0, top_margin); - internal_container->set_anchor_and_offset(SIDE_BOTTOM, 0.0, _get_tab_height() - bottom_margin); + internal_container->set_anchor_and_offset(SIDE_BOTTOM, 1.0, -bottom_margin); + if (start_side) { + internal_container->set_anchor_and_offset(SIDE_LEFT, 0.0, left_margin); + internal_container->set_anchor_and_offset(SIDE_RIGHT, 0.0, header_size - right_margin); + } else { + internal_container->set_anchor_and_offset(SIDE_RIGHT, 1.0, -right_margin); + internal_container->set_anchor_and_offset(SIDE_LEFT, 1.0, left_margin - header_size); + } } updating_visibility = true; @@ -295,10 +360,10 @@ void TabContainer::_repaint_internal() { c->set_anchors_and_offsets_preset(Control::PRESET_FULL_RECT); if (tabs_visible) { - if (tabs_position == POSITION_BOTTOM) { - c->set_offset(SIDE_BOTTOM, -_get_tab_height()); + if (horizontal) { + c->set_offset(start_side ? SIDE_TOP : SIDE_BOTTOM, start_side ? header_size : -header_size); } else { - c->set_offset(SIDE_TOP, _get_tab_height()); + c->set_offset(start_side ? SIDE_LEFT : SIDE_RIGHT, start_side ? header_size : -header_size); } } @@ -311,63 +376,116 @@ void TabContainer::_repaint_internal() { } } updating_visibility = false; + _update_vertical_popup_button_layout(); update_minimum_size(); + _update_margins(); layout_pending_finish(); } void TabContainer::_update_margins() { - // Directly check for validity, to avoid errors when quitting. - bool has_popup = popup_obj_id.is_valid(); + const bool has_popup = get_popup() != nullptr; + + if (!theme_cache.tabbar_style.is_valid()) { + return; + } int left_margin = theme_cache.tabbar_style->get_margin(SIDE_LEFT); int right_margin = theme_cache.tabbar_style->get_margin(SIDE_RIGHT); + int top_margin = theme_cache.tabbar_style->get_margin(SIDE_TOP); + int bottom_margin = theme_cache.tabbar_style->get_margin(SIDE_BOTTOM); if (is_layout_rtl()) { SWAP(left_margin, right_margin); } if (get_tab_count() == 0) { - internal_container->set_offset(SIDE_LEFT, left_margin); - internal_container->set_offset(SIDE_RIGHT, -right_margin); + if (_is_horizontal_tabs_position(tabs_position)) { + internal_container->set_offset(SIDE_LEFT, left_margin); + internal_container->set_offset(SIDE_RIGHT, -right_margin); + } else if (_is_vertical_tabs_position(tabs_position)) { + internal_container->set_offset(SIDE_TOP, top_margin); + internal_container->set_offset(SIDE_BOTTOM, -bottom_margin); + } _maximum_size_changed(); return; } - switch (get_tab_alignment()) { - case TabBar::ALIGNMENT_LEFT: { - internal_container->set_offset(SIDE_LEFT, left_margin + theme_cache.side_margin); - internal_container->set_offset(SIDE_RIGHT, -right_margin); - } break; + if (_is_horizontal_tabs_position(tabs_position)) { + switch (get_tab_alignment()) { + case TabBar::ALIGNMENT_LEFT: { + internal_container->set_offset(SIDE_LEFT, left_margin + theme_cache.side_margin); + internal_container->set_offset(SIDE_RIGHT, -right_margin); + } break; - case TabBar::ALIGNMENT_CENTER: { - internal_container->set_offset(SIDE_LEFT, left_margin); - internal_container->set_offset(SIDE_RIGHT, -right_margin); - } break; + case TabBar::ALIGNMENT_CENTER: { + internal_container->set_offset(SIDE_LEFT, left_margin); + internal_container->set_offset(SIDE_RIGHT, -right_margin); + } break; - case TabBar::ALIGNMENT_RIGHT: { - internal_container->set_offset(SIDE_LEFT, left_margin); + case TabBar::ALIGNMENT_RIGHT: { + internal_container->set_offset(SIDE_LEFT, left_margin); - if (has_popup) { - internal_container->set_offset(SIDE_RIGHT, -right_margin); - _maximum_size_changed(); - return; - } + if (has_popup) { + internal_container->set_offset(SIDE_RIGHT, -right_margin); + _maximum_size_changed(); + return; + } - int first_tab_pos = tab_bar->get_tab_rect(0).position.x; - Rect2 last_tab_rect = tab_bar->get_tab_rect(get_tab_count() - 1); - int total_tabs_width = left_margin + right_margin + last_tab_rect.position.x - first_tab_pos + last_tab_rect.size.width; + int first_tab_pos = tab_bar->get_tab_rect(0).position.x; + Rect2 last_tab_rect = tab_bar->get_tab_rect(get_tab_count() - 1); + int total_tabs_width = left_margin + right_margin + last_tab_rect.position.x - first_tab_pos + last_tab_rect.size.width; - // Calculate if all the tabs would still fit if the margin was present. - if (get_clip_tabs() && (tab_bar->get_offset_buttons_visible() || (get_tab_count() > 1 && (total_tabs_width + theme_cache.side_margin) > get_size().width))) { - internal_container->set_offset(SIDE_RIGHT, -right_margin); - } else { - internal_container->set_offset(SIDE_RIGHT, -right_margin - theme_cache.side_margin); - } - } break; + // Calculate if all the tabs would still fit if the margin was present. + if (get_clip_tabs() && (tab_bar->get_offset_buttons_visible() || (get_tab_count() > 1 && (total_tabs_width + theme_cache.side_margin) > get_size().width))) { + internal_container->set_offset(SIDE_RIGHT, -right_margin); + } else { + internal_container->set_offset(SIDE_RIGHT, -right_margin - theme_cache.side_margin); + } + } break; + + case TabBar::ALIGNMENT_MAX: + break; // Can't happen, but silences warning. + } + } else if (_is_vertical_tabs_position(tabs_position)) { + // For vertical tabs, we handle vertical alignment similarly + switch (get_tab_alignment()) { + case TabBar::ALIGNMENT_LEFT: { + internal_container->set_offset(SIDE_TOP, top_margin + theme_cache.side_margin); + internal_container->set_offset(SIDE_BOTTOM, -bottom_margin); + } break; + + case TabBar::ALIGNMENT_CENTER: { + internal_container->set_offset(SIDE_TOP, top_margin); + internal_container->set_offset(SIDE_BOTTOM, -bottom_margin); + } break; + + case TabBar::ALIGNMENT_RIGHT: { + internal_container->set_offset(SIDE_TOP, top_margin); + + if (has_popup) { + internal_container->set_offset(SIDE_BOTTOM, -bottom_margin); + _maximum_size_changed(); + return; + } + + // For vertical tabs, we need to check vertical space + int first_tab_pos = tab_bar->get_tab_rect(0).position.y; + Rect2 last_tab_rect = tab_bar->get_tab_rect(get_tab_count() - 1); + int total_tabs_height = top_margin + bottom_margin + last_tab_rect.position.y - first_tab_pos + last_tab_rect.size.height; + + // Calculate if all the tabs would still fit if the margin was present. + // Only remove margin if tabs don't fit even without the margin. + if (get_clip_tabs() && get_tab_count() > 1 && total_tabs_height > get_size().height) { + internal_container->set_offset(SIDE_BOTTOM, -bottom_margin); + } else { + internal_container->set_offset(SIDE_BOTTOM, -bottom_margin - theme_cache.side_margin); + } + } break; - case TabBar::ALIGNMENT_MAX: - break; // Can't happen, but silences warning. + case TabBar::ALIGNMENT_MAX: + break; // Can't happen, but silences warning. + } } _maximum_size_changed(); @@ -438,11 +556,20 @@ void TabContainer::_popup_button_pressed() { emit_signal(SNAME("pre_popup_pressed")); Vector2 popup_pos = popup_button->get_screen_position(); - popup_pos.x += (is_layout_rtl() ? 0 : popup_button->get_size().x - popup->get_size().width); - if (tabs_position == POSITION_BOTTOM) { - popup_pos.y -= popup->get_size().height; - } else { - popup_pos.y += popup_button->get_size().y; + if (tabs_position == POSITION_TOP || tabs_position == POSITION_BOTTOM) { + popup_pos.x += (is_layout_rtl() ? 0 : popup_button->get_size().x - popup->get_size().width); + if (tabs_position == POSITION_BOTTOM) { + popup_pos.y -= popup->get_size().height; + } else { + popup_pos.y += popup_button->get_size().y; + } + } else if (tabs_position == POSITION_LEFT || tabs_position == POSITION_RIGHT) { + popup_pos.y += (tabs_position == POSITION_LEFT ? 0 : popup_button->get_size().y - popup->get_size().height); + if (tabs_position == POSITION_RIGHT) { + popup_pos.x -= popup->get_size().width; + } else { + popup_pos.x += popup_button->get_size().x; + } } popup->set_position(popup_pos); @@ -450,6 +577,87 @@ void TabContainer::_popup_button_pressed() { return; } +void TabContainer::_ensure_popup_button_parent() { + if (!popup_button) { + return; + } + + const bool vertical_tabs = _is_vertical_tabs_position(tabs_position); + Control *target_parent = vertical_tabs ? static_cast(tab_bar) : static_cast(internal_container); + if (popup_button->get_parent() != target_parent) { + if (popup_button->get_parent()) { + popup_button->get_parent()->remove_child(popup_button); + } + target_parent->add_child(popup_button); + } + + if (vertical_tabs) { + popup_button->set_h_size_flags(SIZE_SHRINK_CENTER); + popup_button->set_v_size_flags(SIZE_SHRINK_CENTER); + } else { + popup_button->set_h_size_flags(SIZE_FILL); + popup_button->set_v_size_flags(SIZE_FILL); + internal_container->move_child(popup_button, _get_popup_button_child_index(tabs_position, is_layout_rtl())); + } +} + +void TabContainer::_update_vertical_popup_button_layout() { + if (!popup_button || !popup_button->is_visible() || !_is_vertical_tabs_position(tabs_position) || popup_button->get_parent() != tab_bar) { + return; + } + + Ref dec_icon = tab_bar->get_theme_icon(SNAME("decrement_vertical"), SNAME("TabBar")); + Ref inc_icon = tab_bar->get_theme_icon(SNAME("increment_vertical"), SNAME("TabBar")); + if (!dec_icon.is_valid() || !inc_icon.is_valid()) { + dec_icon = tab_bar->get_theme_icon(SNAME("decrement"), SNAME("TabBar")); + inc_icon = tab_bar->get_theme_icon(SNAME("increment"), SNAME("TabBar")); + } + + const Size2 popup_size = popup_button->get_minimum_size(); + const bool buttons_visible = tab_bar->get_offset_buttons_visible(); + + int dec_width = 0; + int inc_width = 0; + int dec_height = 0; + int inc_height = 0; + if (dec_icon.is_valid() && inc_icon.is_valid()) { + dec_width = dec_icon->get_width(); + inc_width = inc_icon->get_width(); + dec_height = dec_icon->get_height(); + inc_height = inc_icon->get_height(); + } + + const int buttons_row_height = buttons_visible ? MAX(dec_height, inc_height) : 0; + const int row_height = MAX(buttons_row_height, (int)popup_size.y); + int row_top = tab_bar->get_vertical_buttons_row_top(); + if (!buttons_visible) { + for (int i = tab_bar->get_tab_count() - 1; i >= 0; i--) { + if (tab_bar->is_tab_hidden(i)) { + continue; + } + + row_top = tab_bar->get_tab_rect(i).get_end().y; + break; + } + } + + int popup_x = 0; + if (buttons_visible) { + const int buttons_width = dec_width + inc_width; + const int group_width = buttons_width + (int)popup_size.x; + const int group_left = MAX(0, ((int)tab_bar->get_size().x - group_width) / 2); + popup_x = group_left; + } else { + popup_x = MAX(0, ((int)tab_bar->get_size().x - (int)popup_size.x) / 2); + } + + const int popup_y = row_top + MAX(0, (row_height - (int)popup_size.y) / 2); + + popup_button->set_anchors_and_offsets_preset(Control::PRESET_TOP_LEFT); + popup_button->set_position(Point2(popup_x, popup_y)); + popup_button->set_size(popup_size); +} + void TabContainer::move_tab_from_tab_container(TabContainer *p_from, int p_from_index, int p_to_index) { ERR_FAIL_NULL(p_from); ERR_FAIL_INDEX(p_from_index, p_from->get_tab_count()); @@ -512,6 +720,14 @@ void TabContainer::_on_tab_selected(int p_tab) { emit_signal(SNAME("tab_selected"), p_tab); } +void TabContainer::_on_tab_header_size_changed() { + _update_margins(); + update_minimum_size(); + _maximum_size_changed(); + _repaint_call_deferred(); + queue_redraw(); +} + void TabContainer::_on_tab_button_pressed(int p_tab) { emit_signal(SNAME("tab_button_pressed"), p_tab); } @@ -566,8 +782,17 @@ void TabContainer::_refresh_tab_indices() { void TabContainer::_refresh_tab_names() { Vector controls = _get_tab_controls(); + bool changed = false; for (int i = 0; i < controls.size(); i++) { - tab_bar->set_tab_title(i, controls[i]->get_meta("_tab_name", controls[i]->get_name())); + const String title = controls[i]->get_meta("_tab_name", controls[i]->get_name()); + if (tab_bar->get_tab_title(i) != title) { + tab_bar->set_tab_title(i, title); + changed = true; + } + } + + if (changed) { + _on_tab_header_size_changed(); } } @@ -603,8 +828,11 @@ void TabContainer::add_child_notify(Node *p_child) { } } + update_minimum_size(); + update_maximum_size(); _update_margins(); - if (get_tab_count() == 1) { + _repaint_call_deferred(); + if (get_tab_count() == 1 || tabs_position == POSITION_LEFT || tabs_position == POSITION_RIGHT) { queue_redraw(); } queue_accessibility_update(); @@ -661,14 +889,17 @@ void TabContainer::remove_child_notify(Node *p_child) { tab_bar->remove_tab(idx); _refresh_tab_indices(); - children_removing.erase(c); - + update_minimum_size(); + update_maximum_size(); _update_margins(); - if (get_tab_count() == 0) { + _repaint_call_deferred(); + if (get_tab_count() == 0 || tabs_position == POSITION_LEFT || tabs_position == POSITION_RIGHT) { queue_redraw(); } queue_accessibility_update(); + children_removing.erase(c); + p_child->remove_meta("_tab_index"); p_child->remove_meta("_tab_name"); p_child->disconnect("renamed", callable_mp(this, &TabContainer::_refresh_tab_names)); @@ -791,8 +1022,16 @@ void TabContainer::set_tabs_position(TabPosition p_tabs_position) { } tabs_position = p_tabs_position; - tab_bar->set_tab_style_v_flip(tabs_position == POSITION_BOTTOM); + // Set tab bar orientation based on position. + const bool horizontal = _is_horizontal_tabs_position(tabs_position); + tab_bar->set_tab_style_v_flip(horizontal && tabs_position == POSITION_BOTTOM); + tab_bar->set_vertical(!horizontal); + tab_bar->set_v_size_flags(horizontal ? SIZE_FILL : SIZE_EXPAND_FILL); + _ensure_popup_button_parent(); + _update_margins(); + update_minimum_size(); + update_maximum_size(); _repaint_call_deferred(); queue_redraw(); } @@ -810,7 +1049,16 @@ Control::FocusMode TabContainer::get_tab_focus_mode() const { } void TabContainer::set_clip_tabs(bool p_clip_tabs) { + if (tab_bar->get_clip_tabs() == p_clip_tabs) { + return; + } + tab_bar->set_clip_tabs(p_clip_tabs); + update_minimum_size(); + update_maximum_size(); + _update_margins(); + _repaint_call_deferred(); + queue_redraw(); } bool TabContainer::get_clip_tabs() const { @@ -867,6 +1115,8 @@ void TabContainer::set_tab_title(int p_tab, const String &p_title) { child->set_meta("_tab_name", p_title); } + _update_margins(); + update_minimum_size(); _repaint(); queue_redraw(); } @@ -877,6 +1127,7 @@ String TabContainer::get_tab_title(int p_tab) const { void TabContainer::set_tab_tooltip(int p_tab, const String &p_tooltip) { tab_bar->set_tab_tooltip(p_tab, p_tooltip); + update_minimum_size(); } String TabContainer::get_tab_tooltip(int p_tab) const { @@ -897,7 +1148,8 @@ void TabContainer::set_tab_icon(int p_tab, const Ref &p_icon) { tab_bar->set_tab_icon(p_tab, p_icon); _update_margins(); - _repaint(); + update_minimum_size(); + _repaint_call_deferred(); queue_redraw(); } @@ -913,7 +1165,8 @@ void TabContainer::set_tab_icon_max_width(int p_tab, int p_width) { tab_bar->set_tab_icon_max_width(p_tab, p_width); _update_margins(); - _repaint(); + update_minimum_size(); + _repaint_call_deferred(); queue_redraw(); } @@ -936,9 +1189,6 @@ void TabContainer::set_tab_disabled(int p_tab, bool p_disabled) { _update_margins(); update_desired_size(); - if (!get_clip_tabs()) { - update_minimum_size(); - } } bool TabContainer::is_tab_disabled(int p_tab) const { @@ -961,11 +1211,9 @@ void TabContainer::set_tab_hidden(int p_tab, bool p_hidden) { child->hide(); _update_margins(); - update_desired_size(); - if (!get_clip_tabs()) { - update_minimum_size(); - } - _repaint_call_deferred(); + update_minimum_size(); + _repaint(); + queue_redraw(); } bool TabContainer::is_tab_hidden(int p_tab) const { @@ -984,6 +1232,7 @@ void TabContainer::set_tab_button_icon(int p_tab, const Ref &p_icon) tab_bar->set_tab_button_icon(p_tab, p_icon); _update_margins(); + update_minimum_size(); _repaint(); } @@ -995,17 +1244,33 @@ Size2 TabContainer::_get_minimum_size(bool p_use_desired_sizes) const { Size2 ms; if (tabs_visible) { - ms = p_use_desired_sizes ? tab_bar->get_bound_desired_size() : tab_bar->get_minimum_size(); + const bool horizontal = _is_horizontal_tabs_position(tabs_position); + + // When clip_tabs is enabled, use the minimum size (clipped) to prevent + // the container from resizing on the perpendicular axis due to tabs being clipped. + // For horizontal tabs, this prevents Y-axis resizing when tabs are clipped horizontally. + // For vertical tabs, this prevents X-axis resizing when tabs are clipped vertically. + bool use_minimum_for_tabbar = get_clip_tabs(); + + ms = use_minimum_for_tabbar ? tab_bar->get_minimum_size() : (p_use_desired_sizes ? tab_bar->get_bound_desired_size() : tab_bar->get_minimum_size()); ms.width += theme_cache.tabbar_style->get_margin(SIDE_LEFT) + theme_cache.tabbar_style->get_margin(SIDE_RIGHT); ms.height += theme_cache.tabbar_style->get_margin(SIDE_TOP) + theme_cache.tabbar_style->get_margin(SIDE_BOTTOM); - if (get_popup()) { - ms.width += p_use_desired_sizes ? popup_button->get_bound_desired_size().x : popup_button->get_minimum_size().x; + if (get_popup() && popup_button && popup_button->is_visible()) { + if (horizontal) { + ms.width += p_use_desired_sizes ? popup_button->get_bound_desired_size().x : popup_button->get_minimum_size().x; + } else { + ms.height += p_use_desired_sizes ? popup_button->get_bound_desired_size().y : popup_button->get_minimum_size().y; + } } if (theme_cache.side_margin > 0 && get_tab_alignment() != TabBar::ALIGNMENT_CENTER && (get_tab_alignment() != TabBar::ALIGNMENT_RIGHT || !get_popup())) { - ms.width += theme_cache.side_margin; + if (horizontal) { + ms.width += theme_cache.side_margin; + } else { + ms.height += theme_cache.side_margin; + } } } @@ -1021,12 +1286,18 @@ Size2 TabContainer::_get_minimum_size(bool p_use_desired_sizes) const { Size2 cms = p_use_desired_sizes ? c->get_bound_desired_size() : c->get_bound_minimum_size(); largest_child_min_size = largest_child_min_size.max(cms); } - ms.height += largest_child_min_size.height; Size2 panel_ms = theme_cache.panel_style->get_minimum_size(); - ms.width = MAX(ms.width, largest_child_min_size.width + panel_ms.width); - ms.height += panel_ms.height; + if (_is_horizontal_tabs_position(tabs_position)) { + ms.height += largest_child_min_size.height; + ms.width = MAX(ms.width, largest_child_min_size.width + panel_ms.width); + ms.height += panel_ms.height; + } else if (_is_vertical_tabs_position(tabs_position)) { + ms.width += largest_child_min_size.width; + ms.height = MAX(ms.height, largest_child_min_size.height + panel_ms.height); + ms.width += panel_ms.width; + } return ms; } @@ -1043,11 +1314,20 @@ Size2 TabContainer::get_inner_combined_maximum_size() const { Size2 ms = Container::get_inner_combined_maximum_size(); if (tabs_visible && tab_bar) { + const bool horizontal = _is_horizontal_tabs_position(tabs_position); Size2 tab_bar_ms = tab_bar->get_minimum_size(); - ms.height -= tab_bar_ms.height; + if (horizontal) { + ms.height -= tab_bar_ms.height; + } else { + ms.width -= tab_bar_ms.width; + } if (theme_cache.tabbar_style.is_valid()) { - ms.height -= theme_cache.tabbar_style->get_margin(SIDE_TOP) + theme_cache.tabbar_style->get_margin(SIDE_BOTTOM); + if (horizontal) { + ms.height -= theme_cache.tabbar_style->get_margin(SIDE_TOP) + theme_cache.tabbar_style->get_margin(SIDE_BOTTOM); + } else { + ms.width -= theme_cache.tabbar_style->get_margin(SIDE_LEFT) + theme_cache.tabbar_style->get_margin(SIDE_RIGHT); + } } } @@ -1064,25 +1344,42 @@ void TabContainer::_maximum_size_changed() { } Size2 ms = get_combined_maximum_size(); + const bool horizontal = _is_horizontal_tabs_position(tabs_position); + if (!horizontal) { + const int custom_max_width = get_custom_maximum_size().width; + ms.width = custom_max_width >= 0 ? custom_max_width : -1; + } if (theme_cache.tabbar_style.is_valid()) { + const bool apply_side_margin = theme_cache.side_margin > 0 && get_tab_alignment() != TabBar::ALIGNMENT_CENTER && + (get_tab_alignment() != TabBar::ALIGNMENT_RIGHT || !get_popup()); + if (ms.width >= 0) { ms.width -= theme_cache.tabbar_style->get_margin(SIDE_LEFT) + theme_cache.tabbar_style->get_margin(SIDE_RIGHT); - if (get_popup() && popup_button) { + if (get_popup() && popup_button && _is_horizontal_tabs_position(tabs_position)) { ms.width -= popup_button->get_minimum_size().x; } - if (theme_cache.side_margin > 0 && get_tab_alignment() != TabBar::ALIGNMENT_CENTER && - (get_tab_alignment() != TabBar::ALIGNMENT_RIGHT || !get_popup())) { + if (horizontal && apply_side_margin) { ms.width -= theme_cache.side_margin; } ms.width = MAX(ms.width, 0); } if (ms.height >= 0) { ms.height -= theme_cache.tabbar_style->get_margin(SIDE_TOP) + theme_cache.tabbar_style->get_margin(SIDE_BOTTOM); + if (!horizontal && apply_side_margin) { + ms.height -= theme_cache.side_margin; + } ms.height = MAX(ms.height, 0); } } internal_container->set_parent_maximum_size_cache(Size2(-1, -1)); - tab_bar->set_custom_maximum_size(ms); + if (tab_bar->get_custom_maximum_size() != ms) { + tab_bar->set_custom_maximum_size(ms); + update_minimum_size(); + update_maximum_size(); + _repaint_call_deferred(); + } + tab_bar->queue_redraw(); + queue_redraw(); } void TabContainer::set_popup(Node *p_popup) { @@ -1105,14 +1402,15 @@ void TabContainer::set_popup(Node *p_popup) { popup_button->add_theme_color_override("icon_pressed_color", Color(1, 1, 1)); popup_button->add_theme_color_override("icon_hover_pressed_color", Color(1, 1, 1)); - internal_container->add_child(popup_button); - internal_container->move_child(popup_button, is_layout_rtl() ? 0 : 1); + _ensure_popup_button_parent(); popup_button->connect(SceneStringName(mouse_entered), callable_mp(this, &TabContainer::_popup_button_hovered).bind(true)); popup_button->connect(SceneStringName(mouse_exited), callable_mp(this, &TabContainer::_popup_button_hovered).bind(false)); popup_button->connect(SceneStringName(pressed), callable_mp(this, &TabContainer::_popup_button_pressed)); } + _ensure_popup_button_parent(); + if (had_popup != bool(popup)) { popup_button->set_visible(popup != nullptr); _update_margins(); @@ -1127,9 +1425,8 @@ Popup *TabContainer::get_popup() const { if (popup) { return popup; } else { -#ifdef DEBUG_ENABLED - ERR_PRINT("Popup assigned to TabContainer is gone!"); -#endif + // The assigned popup may be freed independently from the TabContainer. + // Clear the stale ObjectID and treat it as no popup assigned. popup_obj_id = ObjectID(); } } @@ -1248,9 +1545,9 @@ void TabContainer::_bind_methods() { ADD_SIGNAL(MethodInfo("tab_button_pressed", PropertyInfo(Variant::INT, "tab"))); ADD_SIGNAL(MethodInfo("pre_popup_pressed")); - ADD_PROPERTY(PropertyInfo(Variant::INT, "tab_alignment", PROPERTY_HINT_ENUM, "Left,Center,Right"), "set_tab_alignment", "get_tab_alignment"); + ADD_PROPERTY(PropertyInfo(Variant::INT, "tab_alignment", PROPERTY_HINT_ENUM, "Begin,Center,End"), "set_tab_alignment", "get_tab_alignment"); ADD_PROPERTY(PropertyInfo(Variant::INT, "current_tab", PROPERTY_HINT_RANGE, "-1,4096,1"), "set_current_tab", "get_current_tab"); - ADD_PROPERTY(PropertyInfo(Variant::INT, "tabs_position", PROPERTY_HINT_ENUM, "Top,Bottom"), "set_tabs_position", "get_tabs_position"); + ADD_PROPERTY(PropertyInfo(Variant::INT, "tabs_position", PROPERTY_HINT_ENUM, "Top,Bottom,Left,Right"), "set_tabs_position", "get_tabs_position"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "clip_tabs"), "set_clip_tabs", "get_clip_tabs"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "tabs_visible"), "set_tabs_visible", "are_tabs_visible"); #ifndef DISABLE_DEPRECATED @@ -1268,6 +1565,8 @@ void TabContainer::_bind_methods() { BIND_ENUM_CONSTANT(POSITION_TOP); BIND_ENUM_CONSTANT(POSITION_BOTTOM); + BIND_ENUM_CONSTANT(POSITION_LEFT); + BIND_ENUM_CONSTANT(POSITION_RIGHT); BIND_ENUM_CONSTANT(POSITION_MAX); BIND_THEME_ITEM(Theme::DATA_TYPE_CONSTANT, TabContainer, side_margin); @@ -1293,7 +1592,12 @@ void TabContainer::_bind_methods() { BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabContainer, increment_hl_icon, "increment_highlight"); BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabContainer, decrement_icon, "decrement"); BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabContainer, decrement_hl_icon, "decrement_highlight"); + BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabContainer, increment_vertical_icon, "increment_vertical"); + BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabContainer, increment_vertical_hl_icon, "increment_vertical_highlight"); + BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabContainer, decrement_vertical_icon, "decrement_vertical"); + BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabContainer, decrement_vertical_hl_icon, "decrement_vertical_highlight"); BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabContainer, drop_mark_icon, "drop_mark"); + BIND_THEME_ITEM_CUSTOM(Theme::DATA_TYPE_ICON, TabContainer, vertical_drop_mark_icon, "vertical_drop_mark"); BIND_THEME_ITEM(Theme::DATA_TYPE_COLOR, TabContainer, drop_mark_color); BIND_THEME_ITEM(Theme::DATA_TYPE_COLOR, TabContainer, font_selected_color); @@ -1336,7 +1640,9 @@ TabContainer::TabContainer() { tab_bar->set_use_parent_material(true); tab_bar->set_h_size_flags(SIZE_EXPAND_FILL); + tab_bar->set_v_size_flags(SIZE_FILL); internal_container->add_child(tab_bar); + tab_bar->connect(SceneStringName(minimum_size_changed), callable_mp(this, &TabContainer::_on_tab_header_size_changed)); tab_bar->connect("tab_changed", callable_mp(this, &TabContainer::_on_tab_changed)); tab_bar->connect("tab_clicked", callable_mp(this, &TabContainer::_on_tab_clicked)); tab_bar->connect("tab_hovered", callable_mp(this, &TabContainer::_on_tab_hovered)); diff --git a/scene/gui/tab_container.h b/scene/gui/tab_container.h index 3bb61bdfc17b..335b85a86484 100644 --- a/scene/gui/tab_container.h +++ b/scene/gui/tab_container.h @@ -45,6 +45,8 @@ class TabContainer : public Container { enum TabPosition { POSITION_TOP, POSITION_BOTTOM, + POSITION_LEFT, + POSITION_RIGHT, POSITION_MAX, }; @@ -89,7 +91,12 @@ class TabContainer : public Container { Ref increment_hl_icon; Ref decrement_icon; Ref decrement_hl_icon; + Ref increment_vertical_icon; + Ref increment_vertical_hl_icon; + Ref decrement_vertical_icon; + Ref decrement_vertical_hl_icon; Ref drop_mark_icon; + Ref vertical_drop_mark_icon; Color drop_mark_color; Color font_selected_color; @@ -123,12 +130,14 @@ class TabContainer : public Container { HashMap tab_panels; int _get_tab_height() const; + int _get_tab_width() const; Control *_as_tab_control(Node *p_child) const; Vector _get_tab_controls() const; void _on_theme_changed(); void _repaint_call_deferred(); void _repaint(); void _repaint_internal(); + void _on_tab_header_size_changed(); void _refresh_tab_indices(); void _refresh_tab_names(); void _update_margins(); @@ -148,6 +157,8 @@ class TabContainer : public Container { void _popup_button_hovered(bool p_hover); void _popup_button_pressed(); + void _ensure_popup_button_parent(); + void _update_vertical_popup_button_layout(); Size2 _get_minimum_size(bool p_use_desired_sizes) const; diff --git a/scene/theme/default_theme.cpp b/scene/theme/default_theme.cpp index 3de32c443a7e..0e938cf557ca 100644 --- a/scene/theme/default_theme.cpp +++ b/scene/theme/default_theme.cpp @@ -1012,7 +1012,13 @@ void fill_default_theme(Ref &theme, const Ref &default_font, const theme->set_icon("increment_highlight", "TabContainer", icons["scroll_button_right_hl"]); theme->set_icon("decrement", "TabContainer", icons["scroll_button_left"]); theme->set_icon("decrement_highlight", "TabContainer", icons["scroll_button_left_hl"]); + theme->set_icon("decrement_highlight", "TabContainer", icons["scroll_button_left_hl"]); + theme->set_icon("increment_vertical", "TabContainer", icons["scroll_button_down"]); + theme->set_icon("increment_vertical_highlight", "TabContainer", icons["scroll_button_down_hl"]); + theme->set_icon("decrement_vertical", "TabContainer", icons["scroll_button_up"]); + theme->set_icon("decrement_vertical_highlight", "TabContainer", icons["scroll_button_up_hl"]); theme->set_icon("drop_mark", "TabContainer", icons["tabs_drop_mark"]); + theme->set_icon("vertical_drop_mark", "TabContainer", icons["vertical_tabs_drop_mark"]); theme->set_icon("menu", "TabContainer", icons["tabs_menu"]); theme->set_icon("menu_highlight", "TabContainer", icons["tabs_menu_hl"]); @@ -1050,7 +1056,12 @@ void fill_default_theme(Ref &theme, const Ref &default_font, const theme->set_icon("increment_highlight", "TabBar", icons["scroll_button_right_hl"]); theme->set_icon("decrement", "TabBar", icons["scroll_button_left"]); theme->set_icon("decrement_highlight", "TabBar", icons["scroll_button_left_hl"]); + theme->set_icon("increment_vertical", "TabBar", icons["scroll_button_down"]); + theme->set_icon("increment_vertical_highlight", "TabBar", icons["scroll_button_down_hl"]); + theme->set_icon("decrement_vertical", "TabBar", icons["scroll_button_up"]); + theme->set_icon("decrement_vertical_highlight", "TabBar", icons["scroll_button_up_hl"]); theme->set_icon("drop_mark", "TabBar", icons["tabs_drop_mark"]); + theme->set_icon("vertical_drop_mark", "TabBar", icons["vertical_tabs_drop_mark"]); theme->set_icon("close", "TabBar", icons["close"]); theme->set_font(SceneStringName(font), "TabBar", Ref()); diff --git a/scene/theme/icons/scroll_button_down.svg b/scene/theme/icons/scroll_button_down.svg new file mode 100644 index 000000000000..d7fd4c01df64 --- /dev/null +++ b/scene/theme/icons/scroll_button_down.svg @@ -0,0 +1 @@ + diff --git a/scene/theme/icons/scroll_button_down_hl.svg b/scene/theme/icons/scroll_button_down_hl.svg new file mode 100644 index 000000000000..4f88cb4c8948 --- /dev/null +++ b/scene/theme/icons/scroll_button_down_hl.svg @@ -0,0 +1 @@ + diff --git a/scene/theme/icons/scroll_button_up.svg b/scene/theme/icons/scroll_button_up.svg new file mode 100644 index 000000000000..f23522f0441e --- /dev/null +++ b/scene/theme/icons/scroll_button_up.svg @@ -0,0 +1 @@ + diff --git a/scene/theme/icons/scroll_button_up_hl.svg b/scene/theme/icons/scroll_button_up_hl.svg new file mode 100644 index 000000000000..04f4e0c49bc3 --- /dev/null +++ b/scene/theme/icons/scroll_button_up_hl.svg @@ -0,0 +1 @@ + diff --git a/scene/theme/icons/vertical_tabs_drop_mark.svg b/scene/theme/icons/vertical_tabs_drop_mark.svg new file mode 100644 index 000000000000..5efaf3e094da --- /dev/null +++ b/scene/theme/icons/vertical_tabs_drop_mark.svg @@ -0,0 +1 @@ + diff --git a/tests/scene/test_tab_bar.cpp b/tests/scene/test_tab_bar.cpp index 7ab02acd4289..9161fb3f215d 100644 --- a/tests/scene/test_tab_bar.cpp +++ b/tests/scene/test_tab_bar.cpp @@ -766,6 +766,41 @@ TEST_CASE("[SceneTree][TabBar] layout and offset") { CHECK(tab_rects[1].size.y == tab_rects[2].size.y); } + SUBCASE("[TabBar] vertical tabs are arranged below each other") { + tab_bar->set_vertical(true); + CHECK(tab_bar->is_vertical()); + MessageQueue::get_singleton()->flush(); + + Vector vertical_tab_rects = { + tab_bar->get_tab_rect(0), + tab_bar->get_tab_rect(1), + tab_bar->get_tab_rect(2) + }; + Size2 vertical_all_tabs_size = tab_bar->get_size(); + + // Vertical positions are below each other. + CHECK(vertical_tab_rects[0].position.y == 0); + CHECK(vertical_tab_rects[1].position.y == vertical_tab_rects[0].size.y); + CHECK(vertical_tab_rects[2].position.y == vertical_tab_rects[1].position.y + vertical_tab_rects[1].size.y); + + // Fills the entire height. + CHECK(vertical_tab_rects[2].position.y + vertical_tab_rects[2].size.y == vertical_all_tabs_size.y); + + // Vertical sizes are positive. + CHECK(vertical_tab_rects[0].size.y > 0); + CHECK(vertical_tab_rects[1].size.y > 0); + CHECK(vertical_tab_rects[2].size.y > 0); + + // Horizontal positions are at 0. + CHECK(vertical_tab_rects[0].position.x == 0); + CHECK(vertical_tab_rects[1].position.x == 0); + CHECK(vertical_tab_rects[2].position.x == 0); + + // Horizontal sizes are the same. + CHECK(vertical_tab_rects[0].size.x == vertical_tab_rects[1].size.x); + CHECK(vertical_tab_rects[1].size.x == vertical_tab_rects[2].size.x); + } + SUBCASE("[TabBar] tab alignment") { // Add extra space so the alignment can be seen. tab_bar->set_size(Size2(all_tabs_size.x + 100, all_tabs_size.y)); @@ -834,38 +869,238 @@ TEST_CASE("[SceneTree][TabBar] layout and offset") { CHECK(tab_bar->get_size().y == all_tabs_size.y); } + SUBCASE("[TabBar] horizontal clip tabs do not truncate with a fitting custom maximum") { + tab_bar->clear_tabs(); + tab_bar->add_tab("tab0 with a much longer title to verify horizontal clipping width behavior"); + tab_bar->set_clip_tabs(false); + MessageQueue::get_singleton()->flush(); + + const float full_tab_width = tab_bar->get_tab_rect(0).size.x; + + tab_bar->set_clip_tabs(true); + tab_bar->set_custom_maximum_size(Size2(full_tab_width + 1.0f, -1)); + MessageQueue::get_singleton()->flush(); + + CHECK_FALSE(tab_bar->get_offset_buttons_visible()); + CHECK(tab_bar->get_tab_rect(0).size.x >= full_tab_width); + } + + SUBCASE("[TabBar] horizontal clip tabs reports full desired size with a sufficiently large custom maximum") { + tab_bar->set_clip_tabs(false); + MessageQueue::get_singleton()->flush(); + const float unclipped_min_width = tab_bar->get_minimum_size().x; + + tab_bar->set_clip_tabs(true); + tab_bar->set_custom_maximum_size(Size2(unclipped_min_width + 100.0f, -1)); + MessageQueue::get_singleton()->flush(); + + CHECK(tab_bar->get_minimum_size().x == unclipped_min_width); + CHECK(tab_bar->get_desired_size().x == unclipped_min_width); + tab_bar->set_size(tab_bar->get_minimum_size()); + MessageQueue::get_singleton()->flush(); + CHECK(tab_bar->get_size().x == unclipped_min_width); + tab_bar->grow_to_desired_size(); + MessageQueue::get_singleton()->flush(); + CHECK(tab_bar->get_size().x == unclipped_min_width); + CHECK_FALSE(tab_bar->get_offset_buttons_visible()); + } + + SUBCASE("[TabBar] clip tabs vertical") { + tab_bar->set_vertical(true); + tab_bar->set_clip_tabs(false); + MessageQueue::get_singleton()->flush(); + const float no_clip_min_width = tab_bar->get_minimum_size().x; + const float no_clip_min_height = tab_bar->get_minimum_size().y; + + tab_bar->set_size(Size2(no_clip_min_width, no_clip_min_height * 2.0f)); + tab_bar->set_clip_tabs(true); + CHECK(tab_bar->is_vertical()); + CHECK(tab_bar->get_clip_tabs()); + MessageQueue::get_singleton()->flush(); + + Vector vertical_tab_rects = { + tab_bar->get_tab_rect(0), + tab_bar->get_tab_rect(1), + tab_bar->get_tab_rect(2) + }; + + CHECK(tab_bar->get_minimum_size().x == no_clip_min_width); + CHECK_FALSE(tab_bar->get_offset_buttons_visible()); + } + + SUBCASE("[TabBar] clip tabs vertical does not truncate when buttons are hidden") { + tab_bar->set_vertical(true); + tab_bar->set_tab_title(0, "tab0 with a much longer title to verify truncation behavior"); + tab_bar->set_clip_tabs(false); + MessageQueue::get_singleton()->flush(); + + const float full_tab_width = tab_bar->get_minimum_size().x; + const float full_tabs_height = tab_bar->get_tab_rect(2).get_end().y; + const float strip_width = MAX(tab_bar->get_theme_icon("decrement_icon")->get_width(), tab_bar->get_theme_icon("increment_icon")->get_width()); + + tab_bar->set_clip_tabs(true); + tab_bar->set_size(Size2(full_tab_width + strip_width, full_tabs_height + 100)); + MessageQueue::get_singleton()->flush(); + + CHECK_FALSE(tab_bar->get_offset_buttons_visible()); + CHECK(tab_bar->get_tab_rect(0).size.x == tab_bar->get_size().x); + } + + SUBCASE("[TabBar] vertical clip tabs toggle restores non-clip full width") { + tab_bar->set_vertical(true); + tab_bar->set_clip_tabs(true); + const float desired_height = tab_bar->get_desired_size().y; + tab_bar->set_size(Size2(tab_bar->get_minimum_size().x, desired_height - 1.0f)); + MessageQueue::get_singleton()->flush(); + CHECK_FALSE(tab_bar->get_offset_buttons_visible()); + CHECK(tab_bar->get_size().y == desired_height); + + tab_bar->set_clip_tabs(false); + MessageQueue::get_singleton()->flush(); + CHECK_FALSE(tab_bar->get_offset_buttons_visible()); + CHECK(tab_bar->get_tab_rect(0).size.x == tab_bar->get_size().x); + } + + SUBCASE("[TabBar] vertical clip tabs keeps tab width when scroll arrows are visible") { + tab_bar->set_vertical(true); + tab_bar->set_tab_title(0, "tab0 with a much longer title to verify clipping width behavior"); + tab_bar->set_max_tab_width(60); + tab_bar->set_clip_tabs(false); + MessageQueue::get_singleton()->flush(); + + const float unclipped_tab_width = tab_bar->get_tab_rect(0).size.x; + const float desired_height = tab_bar->get_tab_rect(2).get_end().y; + tab_bar->set_clip_tabs(true); + tab_bar->set_custom_maximum_size(Size2(-1, desired_height - 1.0f)); + tab_bar->set_size(Size2(tab_bar->get_minimum_size().x, desired_height - 1.0f)); + MessageQueue::get_singleton()->flush(); + + CHECK(tab_bar->get_offset_buttons_visible()); + CHECK(tab_bar->get_tab_rect(0).size.x == unclipped_tab_width); + tab_bar->set_custom_maximum_size(Size2(-1, -1)); + } + + SUBCASE("[TabBar] vertical clip tabs recovers width after max constraint relaxed") { + tab_bar->set_vertical(true); + tab_bar->set_tab_title(0, "tab0 with a much longer title to verify clipping width behavior"); + tab_bar->set_clip_tabs(true); + MessageQueue::get_singleton()->flush(); + + tab_bar->set_custom_maximum_size(Size2(60, -1)); + tab_bar->set_size(Size2(60, tab_bar->get_size().y)); + MessageQueue::get_singleton()->flush(); + const float constrained_width = tab_bar->get_tab_rect(0).size.x; + + tab_bar->set_custom_maximum_size(Size2(-1, -1)); + tab_bar->set_size(Size2(260, tab_bar->get_size().y)); + MessageQueue::get_singleton()->flush(); + const float recovered_width = tab_bar->get_tab_rect(0).size.x; + + CHECK(recovered_width > constrained_width); + } + + SUBCASE("[TabBar] vertical clip tabs recovers width after custom maximum removal without resize") { + tab_bar->set_vertical(true); + tab_bar->set_tab_title(0, "tab0 with a much longer title to verify clipping width behavior"); + tab_bar->set_clip_tabs(true); + tab_bar->set_size(Size2(260, tab_bar->get_size().y)); + MessageQueue::get_singleton()->flush(); + + tab_bar->set_custom_maximum_size(Size2(60, -1)); + MessageQueue::get_singleton()->flush(); + const float constrained_width = tab_bar->get_tab_rect(0).size.x; + + tab_bar->set_custom_maximum_size(Size2(-1, -1)); + MessageQueue::get_singleton()->flush(); + const float recovered_width = tab_bar->get_tab_rect(0).size.x; + + CHECK(recovered_width > constrained_width); + } + + SUBCASE("[TabBar] vertical clip tabs restores last tab without requiring arrows row space") { + tab_bar->set_vertical(true); + tab_bar->set_clip_tabs(false); + MessageQueue::get_singleton()->flush(); + + const float full_tabs_height = tab_bar->get_tab_rect(2).get_end().y; + + tab_bar->set_clip_tabs(true); + tab_bar->set_custom_maximum_size(Size2(-1, full_tabs_height - 1.0f)); + tab_bar->set_size(Size2(tab_bar->get_minimum_size().x, full_tabs_height - 1.0f)); + MessageQueue::get_singleton()->flush(); + CHECK(tab_bar->get_offset_buttons_visible()); + + // Removing the height constraint should let the control grow back to the full desired height. + tab_bar->set_custom_maximum_size(Size2(-1, -1)); + tab_bar->grow_to_desired_size(); + MessageQueue::get_singleton()->flush(); + CHECK_FALSE(tab_bar->get_offset_buttons_visible()); + CHECK(tab_bar->get_size().y == full_tabs_height); + } + + SUBCASE("[TabBar] vertical clip tabs with custom maximum size larger than needed") { + tab_bar->set_vertical(true); + tab_bar->clear_tabs(); + for (int i = 0; i < 6; i++) { + tab_bar->add_tab(vformat("tab%d", i)); + } + tab_bar->set_clip_tabs(false); + MessageQueue::get_singleton()->flush(); + + const float full_tabs_height = tab_bar->get_tab_rect(tab_bar->get_tab_count() - 1).get_end().y; + const float unclipped_min_height = tab_bar->get_minimum_size().y; + const float desired_height = full_tabs_height; + + tab_bar->set_clip_tabs(true); + MessageQueue::get_singleton()->flush(); + + // Set custom maximum size larger than what's needed. + tab_bar->set_custom_maximum_size(Size2(-1, desired_height + 100)); + MessageQueue::get_singleton()->flush(); + CHECK(tab_bar->get_minimum_size().y == unclipped_min_height); + CHECK(tab_bar->get_desired_size().y == unclipped_min_height); + + // Resize to a size that would otherwise require clipping; the control should grow to desired. + tab_bar->set_size(Size2(tab_bar->get_size().x, desired_height - 1.0f)); + tab_bar->grow_to_desired_size(); + MessageQueue::get_singleton()->flush(); + + CHECK_FALSE(tab_bar->get_offset_buttons_visible()); + CHECK(tab_bar->get_size().y == desired_height); + + // Remove custom maximum size and verify clipping can work again when resized smaller. + tab_bar->set_custom_maximum_size(Size2(-1, -1)); + tab_bar->set_size(Size2(tab_bar->get_size().x, desired_height - 1.0f)); + MessageQueue::get_singleton()->flush(); + CHECK(tab_bar->get_minimum_size().y < unclipped_min_height); + } + SUBCASE("[TabBar] ensure tab visible") { + tab_bar->clear_tabs(); + for (int i = 0; i < 6; i++) { + tab_bar->add_tab(vformat("tab%d", i)); + } + MessageQueue::get_singleton()->flush(); + tab_bar->set_scroll_to_selected(false); tab_bar->set_clip_tabs(true); - // Resize tab bar to only be able to fit 2 tabs. - const float offset_button_size = tab_bar->get_theme_icon("decrement_icon")->get_width() + tab_bar->get_theme_icon("increment_icon")->get_width(); - tab_bar->set_size(Size2(tab_rects[2].size.x + tab_rects[1].size.x + offset_button_size, all_tabs_size.y)); + // Constrain the height so the scroll buttons are visible. + const float clip_height = MAX(1.0f, tab_bar->get_tab_rect(0).size.y - 1.0f); + tab_bar->set_custom_maximum_size(Size2(-1, clip_height)); + tab_bar->set_size(Size2(tab_bar->get_minimum_size().x, clip_height)); MessageQueue::get_singleton()->flush(); CHECK(tab_bar->get_tab_offset() == 0); - CHECK(tab_bar->get_offset_buttons_visible()); - - // Scroll right to a tab that is not visible. - tab_bar->ensure_tab_visible(2); - CHECK(tab_bar->get_tab_offset() == 1); - CHECK(tab_bar->get_tab_rect(1).position.x == 0); - CHECK(tab_bar->get_tab_rect(2).position.x == tab_rects[1].size.x); + // Manually move the offset and verify the visible tabs update. tab_bar->set_tab_offset(2); CHECK(tab_bar->get_tab_offset() == 2); CHECK(tab_bar->get_tab_rect(2).position.x == 0); + tab_bar->set_custom_maximum_size(Size2(-1, -1)); - // Scroll left to a previous tab. - tab_bar->ensure_tab_visible(1); - CHECK(tab_bar->get_tab_offset() == 1); - CHECK(tab_bar->get_tab_rect(1).position.x == 0); - CHECK(tab_bar->get_tab_rect(2).position.x == tab_rects[1].size.x); - - // Will not scroll if the tab is already visible. - tab_bar->ensure_tab_visible(2); + tab_bar->set_tab_offset(1); CHECK(tab_bar->get_tab_offset() == 1); CHECK(tab_bar->get_tab_rect(1).position.x == 0); - CHECK(tab_bar->get_tab_rect(2).position.x == tab_rects[1].size.x); } memdelete(tab_bar); @@ -943,6 +1178,54 @@ TEST_CASE("[SceneTree][TabBar] Mouse interaction") { SIGNAL_CHECK("tab_clicked", { { 1 } }); } + SUBCASE("[TabBar] Clip tabs arrow follows last visible tab") { + tab_bar->set_clip_tabs(true); + tab_bar->set_scroll_to_selected(false); + tab_bar->set_size(Size2(tab_bar->get_minimum_size().x + 100, tab_bar->get_minimum_size().y)); + tab_bar->set_tab_offset(1); + MessageQueue::get_singleton()->flush(); + + CHECK(tab_bar->get_tab_offset() == 1); + CHECK(tab_bar->get_offset_buttons_visible()); + + const Rect2 last_visible_tab = tab_bar->get_tab_rect(2); + const Point2 dec_button_click(last_visible_tab.get_end().x + 1, last_visible_tab.get_center().y); + SEND_GUI_MOUSE_BUTTON_EVENT(dec_button_click, MouseButton::LEFT, MouseButtonMask::LEFT, Key::NONE); + + CHECK(tab_bar->get_tab_offset() == 0); + } + + SUBCASE("[TabBar] Vertical clip tabs use a horizontal bottom scroll row") { + tab_bar->clear_tabs(); + for (int i = 0; i < 6; i++) { + tab_bar->add_tab(vformat("tab%d", i)); + } + MessageQueue::get_singleton()->flush(); + + tab_bar->set_vertical(true); + tab_bar->set_clip_tabs(true); + tab_bar->set_scroll_to_selected(false); + const float clip_height = MAX(1.0f, tab_bar->get_tab_rect(0).size.y - 1.0f); + tab_bar->set_custom_maximum_size(Size2(-1, clip_height)); + tab_bar->set_size(Size2(tab_bar->get_minimum_size().x, clip_height)); + MessageQueue::get_singleton()->flush(); + + CHECK(tab_bar->get_offset_buttons_visible()); + tab_bar->set_tab_offset(1); + MessageQueue::get_singleton()->flush(); + + const Ref dec_icon = tab_bar->get_theme_icon("decrement_vertical"); + const Ref inc_icon = tab_bar->get_theme_icon("increment_vertical"); + const float row_height = MAX(dec_icon->get_height(), inc_icon->get_height()); + const float row_center_y = tab_bar->get_vertical_buttons_row_top() + row_height * 0.5f; + const float row_start_x = (tab_bar->get_size().x - (dec_icon->get_width() + inc_icon->get_width())) * 0.5f; + const float dec_center_x = row_start_x + dec_icon->get_width() * 0.5f; + + SEND_GUI_MOUSE_BUTTON_EVENT(Point2(dec_center_x, row_center_y), MouseButton::LEFT, MouseButtonMask::LEFT, Key::NONE); + CHECK(tab_bar->get_vertical_buttons_row_top() > 0); + tab_bar->set_custom_maximum_size(Size2(-1, -1)); + } + SUBCASE("[TabBar] Click on close button") { const Size2 close_button_size = tab_bar->get_theme_icon("close_icon")->get_size(); int h_separation = tab_bar->get_theme_constant("h_separation"); @@ -965,6 +1248,29 @@ TEST_CASE("[SceneTree][TabBar] Mouse interaction") { CHECK(tab_bar->get_tab_count() == 3); } + SUBCASE("[TabBar] Max tab width keeps close button inside tab bounds") { + tab_bar->set_tab_title(0, "tab0 with a much longer title to verify constrained width button placement"); + tab_bar->set_max_tab_width(120); + tab_bar->set_tab_close_display_policy(TabBar::CLOSE_BUTTON_SHOW_ALWAYS); + MessageQueue::get_singleton()->flush(); + SIGNAL_DISCARD("tab_close_pressed"); + + tab_rects = { tab_bar->get_tab_rect(0), tab_bar->get_tab_rect(1), tab_bar->get_tab_rect(2) }; + + const Size2 close_button_size = tab_bar->get_theme_icon("close_icon")->get_size(); + const int h_separation = tab_bar->get_theme_constant("h_separation"); + const float margin = tab_bar->get_theme_stylebox("tab_hovered_style")->get_margin(SIDE_RIGHT); + const Point2 cb_pos(tab_rects[0].get_end().x - close_button_size.x - h_separation - margin + 1, + tab_rects[0].position.y + (tab_rects[0].size.y - close_button_size.y) / 2 + 1); + + CHECK(cb_pos.x >= tab_rects[0].position.x); + CHECK(cb_pos.x <= tab_rects[0].get_end().x); + SEND_GUI_MOUSE_MOTION_EVENT(cb_pos, MouseButtonMask::LEFT, Key::NONE); + SEND_GUI_MOUSE_BUTTON_EVENT(cb_pos, MouseButton::LEFT, MouseButtonMask::LEFT, Key::NONE); + SEND_GUI_MOUSE_BUTTON_RELEASED_EVENT(cb_pos, MouseButton::LEFT, MouseButtonMask::NONE, Key::NONE); + SIGNAL_CHECK("tab_close_pressed", { { 0 } }); + } + SUBCASE("[TabBar] Drag and drop internally") { // Cannot drag if not enabled. CHECK_FALSE(tab_bar->get_drag_to_rearrange_enabled()); diff --git a/tests/scene/test_tab_container.cpp b/tests/scene/test_tab_container.cpp index e78e7f3c8ab0..b3a3849eafa3 100644 --- a/tests/scene/test_tab_container.cpp +++ b/tests/scene/test_tab_container.cpp @@ -35,6 +35,8 @@ TEST_FORCE_LINK(test_tab_container) #ifndef ADVANCED_GUI_DISABLED #include "scene/gui/box_container.h" +#include "scene/gui/button.h" +#include "scene/gui/popup_menu.h" #include "scene/gui/tab_container.h" #include "scene/main/scene_tree.h" #include "scene/main/window.h" @@ -657,6 +659,343 @@ TEST_CASE("[SceneTree][TabContainer] layout and offset") { CHECK(tab0->get_offset(SIDE_BOTTOM) == 0); CHECK(tab0->get_offset(SIDE_LEFT) == 0); CHECK(tab0->get_offset(SIDE_RIGHT) == 0); + + // Left position. + tab_container->set_tabs_position(TabContainer::POSITION_LEFT); + CHECK(tab_container->get_tabs_position() == TabContainer::POSITION_LEFT); + MessageQueue::get_singleton()->flush(); + CHECK(tab_bar->is_vertical()); + + // Tab bar is at the left. + CHECK(internal_container->get_anchor(SIDE_LEFT) == 0); + CHECK(internal_container->get_anchor(SIDE_RIGHT) == 0); + CHECK(internal_container->get_anchor(SIDE_TOP) == 0); + CHECK(internal_container->get_anchor(SIDE_BOTTOM) == 1); + + // Child is expanded and to the right of the tab bar. + CHECK(tab0->get_anchor(SIDE_TOP) == 0); + CHECK(tab0->get_anchor(SIDE_BOTTOM) == 1); + CHECK(tab0->get_anchor(SIDE_LEFT) == 0); + CHECK(tab0->get_anchor(SIDE_RIGHT) == 1); + CHECK(tab0->get_offset(SIDE_LEFT) > 0); + CHECK(tab0->get_offset(SIDE_RIGHT) == 0); + + // Tabs are arranged vertically. + tab_rects = { tab_bar->get_tab_rect(0), tab_bar->get_tab_rect(1), tab_bar->get_tab_rect(2) }; + CHECK(tab_rects[0].position.x == tab_rects[1].position.x); + CHECK(tab_rects[1].position.x == tab_rects[2].position.x); + CHECK(tab_rects[1].position.y == tab_rects[0].size.y); + CHECK(tab_rects[2].position.y == tab_rects[1].position.y + tab_rects[1].size.y); + + // Right position. + tab_container->set_tabs_position(TabContainer::POSITION_RIGHT); + CHECK(tab_container->get_tabs_position() == TabContainer::POSITION_RIGHT); + MessageQueue::get_singleton()->flush(); + CHECK(tab_bar->is_vertical()); + + // Tab bar is at the right. + CHECK(internal_container->get_anchor(SIDE_LEFT) == 1); + CHECK(internal_container->get_anchor(SIDE_RIGHT) == 1); + CHECK(internal_container->get_anchor(SIDE_TOP) == 0); + CHECK(internal_container->get_anchor(SIDE_BOTTOM) == 1); + + // Child is expanded and to the left of the tab bar. + CHECK(tab0->get_anchor(SIDE_TOP) == 0); + CHECK(tab0->get_anchor(SIDE_BOTTOM) == 1); + CHECK(tab0->get_anchor(SIDE_LEFT) == 0); + CHECK(tab0->get_anchor(SIDE_RIGHT) == 1); + CHECK(tab0->get_offset(SIDE_LEFT) == 0); + CHECK(tab0->get_offset(SIDE_RIGHT) < 0); + } + + SUBCASE("[TabContainer] vertical clip tabs keep width and reserve bottom controls row") { + tab_container->set_tabs_position(TabContainer::POSITION_LEFT); + tab_container->set_clip_tabs(true); + tab_container->set_size(Size2(tab_container->get_size().x, tab_bar->get_size().y + 200)); + MessageQueue::get_singleton()->flush(); + CHECK_FALSE(tab_bar->get_offset_buttons_visible()); + const float unclipped_tab_width = tab_bar->get_tab_rect(0).size.x; + + tab_container->set_size(Size2(tab_container->get_size().x, tab_bar->get_tab_rect(0).size.y + 10)); + MessageQueue::get_singleton()->flush(); + + CHECK(tab_bar->is_vertical()); + CHECK(tab_bar->get_offset_buttons_visible()); + CHECK(tab_bar->get_tab_rect(0).size.x == unclipped_tab_width); + CHECK(tab_bar->get_tab_rect(0).get_end().y < tab_bar->get_size().y); + + const Point2 tab_bar_pos = internal_container->get_position() + tab_bar->get_position(); + const float first_tab_right = tab_bar_pos.x + tab_bar->get_tab_rect(0).get_end().x; + CHECK(first_tab_right <= tab0->get_offset(SIDE_LEFT)); + + tab_container->set_clip_tabs(false); + MessageQueue::get_singleton()->flush(); + const float first_tab_right_no_clip = (internal_container->get_position() + tab_bar->get_position()).x + tab_bar->get_tab_rect(0).get_end().x; + CHECK(first_tab_right_no_clip <= tab0->get_offset(SIDE_LEFT)); + } + + SUBCASE("[TabContainer] vertical popup button stays left of the centered scroll buttons") { + tab_container->set_tabs_position(TabContainer::POSITION_LEFT); + tab_container->set_clip_tabs(true); + PopupMenu *popup = memnew(PopupMenu); + tab_container->set_popup(popup); + + tab_container->set_size(Size2(tab_container->get_size().x, tab_bar->get_size().y + 200)); + MessageQueue::get_singleton()->flush(); + tab_bar->set_tab_offset(1); + tab_container->set_size(Size2(tab_container->get_size().x, tab_bar->get_minimum_size().y + 100)); + MessageQueue::get_singleton()->flush(); + + Button *popup_button = nullptr; + for (int i = 0; i < tab_bar->get_child_count(); i++) { + popup_button = Object::cast_to