Source code for simvx.core.ui.tabs

"""TabContainer -- tabbed panel container.

Each child Control is a tab page. The tab title is taken from child.name.
Only the active tab's child is drawn and receives layout.
"""


from __future__ import annotations

import logging

from ..descriptors import Property, Signal
from ..math.types import Vec2
from .containers import Container
from .core import Control, ThemeColour, ThemeStyleBox

log = logging.getLogger(__name__)

__all__ = ["TabContainer"]


[docs] class TabContainer(Container): """Tabbed container where each child is a tab page. Tab titles are derived from each child's ``name`` attribute. Clicking a tab header switches the visible page. Example: tabs = TabContainer() page1 = Control(name="Settings") page2 = Control(name="Debug") tabs.add_child(page1) tabs.add_child(page2) tabs.tab_changed.connect(lambda idx: print(f"Tab {idx}")) """ current_tab = Property(0, range=(0, 100), hint="Active tab index") tab_height = Property(30.0, range=(16, 64), hint="Tab bar height") tab_bg_colour = ThemeColour("tab_bg") tab_active_colour = ThemeColour("tab_active") tab_hover_colour = ThemeColour("tab_hover") tab_text_colour = ThemeColour("tab_text") tab_active_text_colour = ThemeColour("tab_active_text") border_colour = ThemeColour("border_light") style_tab_normal = ThemeStyleBox("tab_style_normal") style_tab_active = ThemeStyleBox("tab_style_active") style_tab_hover = ThemeStyleBox("tab_style_hover") def __init__(self, **kwargs): super().__init__(**kwargs) self.current_tab = 0 self.tab_height = 30.0 self.font_size = 14.0 self._hovered_tab = -1 self.show_close_buttons = False self._tab_colours: dict[int, tuple] = {} self._tab_text_colours: dict[int, tuple] = {} self._flash_timers: dict[int, float] = {} self.tab_changed = Signal() self.tab_close_requested = Signal() # --------------------------------------------------------- tab colour API
[docs] def set_tab_colour(self, index: int, colour: tuple | None): """Set an override background colour for a specific tab (None to clear).""" if colour is None: self._tab_colours.pop(index, None) else: self._tab_colours[index] = colour
[docs] def clear_tab_colour(self, index: int): """Remove the override colour for a tab.""" self._tab_colours.pop(index, None)
[docs] def set_tab_text_colour(self, index: int, colour: tuple | None): """Set an override text colour for a specific tab (None to clear).""" if colour is None: self._tab_text_colours.pop(index, None) else: self._tab_text_colours[index] = colour
[docs] def clear_tab_text_colour(self, index: int): """Remove the override text colour for a tab.""" self._tab_text_colours.pop(index, None)
[docs] def flash_tab(self, index: int, colour: tuple, duration: float = 1.0): """Temporarily set a tab colour that auto-clears after *duration* seconds.""" self._tab_colours[index] = colour self._flash_timers[index] = duration
[docs] def process(self, dt: float): """Tick flash timers and update layout.""" super().process(dt) expired = [] for idx, remaining in self._flash_timers.items(): remaining -= dt if remaining <= 0: expired.append(idx) else: self._flash_timers[idx] = remaining for idx in expired: del self._flash_timers[idx] self._tab_colours.pop(idx, None)
# ------------------------------------------------------------------ sizing
[docs] def get_minimum_size(self) -> Vec2: from ..math.types import Vec2 as V kids = [c for c in self.children if isinstance(c, Control)] if not kids: return V(max(0, self.min_size.x), max(0, self.min_size.y)) max_w = max(c.get_minimum_size().x for c in kids) max_h = max(c.get_minimum_size().y for c in kids) return V(max(self.min_size.x, max_w), max(self.min_size.y, max_h + self.tab_height))
# ------------------------------------------------------------------ layout def _update_layout(self): """Position the active child below the tab bar; hide others.""" _, _, w, h = self.get_rect() content_y = self.tab_height content_h = max(0.0, h - self.tab_height) for i, child in enumerate(self.children): if not isinstance(child, Control): continue if i == self.current_tab: child.position = Vec2(0, content_y) child.size = Vec2(w, content_h) child.visible = True else: child.visible = False # ------------------------------------------------------------------- input def _update_mouse_over(self, mouse_pos): """Track which tab header the mouse is over (or -1 if outside the bar).""" super()._update_mouse_over(mouse_pos) px = mouse_pos[0] if not hasattr(mouse_pos, "x") else mouse_pos.x py = mouse_pos[1] if not hasattr(mouse_pos, "y") else mouse_pos.y x, y, w, _ = self.get_global_rect() tab_controls = [c for c in self.children if isinstance(c, Control)] if tab_controls and y <= py <= y + self.tab_height: tab_width = w / len(tab_controls) idx = int((px - x) / tab_width) self._hovered_tab = max(0, min(idx, len(tab_controls) - 1)) else: self._hovered_tab = -1 def _on_gui_input(self, event): if event.button != 1 or not event.pressed: return x, y, w, _ = self.get_global_rect() px = event.position.x if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] if py < y or py > y + self.tab_height: return tab_controls = [c for c in self.children if isinstance(c, Control)] if not tab_controls: return tab_width = w / len(tab_controls) clicked_index = int((px - x) / tab_width) clicked_index = max(0, min(clicked_index, len(tab_controls) - 1)) # Check if close button was clicked if self.show_close_buttons: tx = x + clicked_index * tab_width close_x = tx + tab_width - 20 close_y = y + (self.tab_height - 14) / 2 if close_x <= px < close_x + 14 and close_y <= py < close_y + 14: self.tab_close_requested.emit(clicked_index) return if clicked_index != self.current_tab: self.current_tab = clicked_index self._update_layout() self.tab_changed.emit(clicked_index) # -------------------------------------------------------------------- draw
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() tab_controls = [c for c in self.children if isinstance(c, Control)] num_tabs = len(tab_controls) if num_tabs == 0: return # Full-width bar background (respects alpha — transparent lets parent show through) if len(self.tab_bg_colour) < 4 or self.tab_bg_colour[3] > 0: renderer.draw_filled_rect(x, y, w, self.tab_height, self.tab_bg_colour) tab_width = w / num_tabs scale = self.font_size / 14.0 # Draw tab headers for i, child in enumerate(tab_controls): tx = round(x + i * tab_width) tw = round(x + (i + 1) * tab_width) - tx # Tab background via StyleBox if i == self.current_tab: tab_box = self.style_tab_active elif i == self._hovered_tab: tab_box = self.style_tab_hover else: tab_box = None if i in self._tab_colours: renderer.draw_filled_rect(tx, y, tw, self.tab_height, self._tab_colours[i]) elif tab_box is not None: tab_box.draw(renderer, tx, y, tw, self.tab_height) # Normal tabs use the bar background already drawn above # Vertical separator between tabs if i > 0: renderer.draw_filled_rect(tx, y + 4, 1, self.tab_height - 8, self.border_colour) # Tab title (pixel-snapped) title = child.name or f"Tab {i}" modified = title.startswith("*") text_w = renderer.text_width(title, scale) dot_w = 8.0 if modified else 0.0 text_x = round(tx + (tw - text_w - dot_w) / 2 + dot_w) text_y = round(y + (self.tab_height - self.font_size) / 2) if i in self._tab_text_colours: text_colour = self._tab_text_colours[i] elif i == self.current_tab: text_colour = self.tab_active_text_colour else: text_colour = self.tab_text_colour # Modified-file indicator dot if modified: dot_r = 3.0 dot_cx = text_x - 6.0 dot_cy = text_y + self.font_size / 2 renderer.draw_filled_rect(dot_cx - dot_r, dot_cy - dot_r, dot_r * 2, dot_r * 2, text_colour) renderer.draw_text_coloured(title, text_x, text_y, scale, text_colour) # Close button if self.show_close_buttons: close_x = round(tx + tw - 20) close_y = round(y + (self.tab_height - 14) / 2) close_colour = (0.8, 0.4, 0.4, 1.0) if i == self.current_tab else (0.5, 0.5, 0.5, 0.6) renderer.draw_text_coloured("x", close_x + 2, close_y, scale * 0.8, close_colour) # Active highlight bar at bottom of active tab active_x = round(x + self.current_tab * tab_width) active_w = round(x + (self.current_tab + 1) * tab_width) - active_x renderer.draw_filled_rect(active_x, y + self.tab_height - 2, active_w, 2, self.tab_active_text_colour)
# Active child is drawn by _draw_recursive (visible=True)