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.
"""

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._hovered_new_tab = False self.show_close_buttons = False self.show_new_tab_button = 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() self.new_tab_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)] plus_w = 28.0 if self.show_new_tab_button else 0.0 self._hovered_new_tab = False if tab_controls and y <= py <= y + self.tab_height: usable_w = w - plus_w tab_width = usable_w / len(tab_controls) rel_x = px - x if self.show_new_tab_button and rel_x >= usable_w: self._hovered_tab = -1 self._hovered_new_tab = True else: idx = int(rel_x / tab_width) self._hovered_tab = max(0, min(idx, len(tab_controls) - 1)) elif self.show_new_tab_button and not tab_controls and y <= py <= y + self.tab_height: self._hovered_new_tab = True self._hovered_tab = -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 plus_w = 28.0 if self.show_new_tab_button else 0.0 usable_w = w - plus_w rel_x = px - x # Click on "+" button if self.show_new_tab_button and rel_x >= usable_w: self.new_tab_requested.emit() return tab_controls = [c for c in self.children if isinstance(c, Control)] if not tab_controls: return tab_width = usable_w / len(tab_controls) clicked_index = int(rel_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) # 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_rect((x, y), (w, self.tab_height), colour=self.tab_bg_colour, filled=True) plus_w = 28.0 if self.show_new_tab_button else 0.0 usable_w = w - plus_w scale = self.font_size / 14.0 if num_tabs == 0: # Draw "+" button even with no tabs if self.show_new_tab_button: self._draw_new_tab_button(renderer, x + usable_w, y, plus_w, scale) return tab_width = usable_w / num_tabs # 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_rect((tx, y), (tw, self.tab_height), colour=self._tab_colours[i], filled=True) 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_rect((tx, y + 4), (1, self.tab_height - 8), colour=self.border_colour, filled=True) # 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_rect( (dot_cx - dot_r, dot_cy - dot_r), (dot_r * 2, dot_r * 2), colour=text_colour, filled=True ) renderer.draw_text(title, (text_x, text_y), colour=text_colour, scale=scale) # 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("x", (close_x + 2, close_y), colour=close_colour, scale=scale * 0.8) # 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_rect( (active_x, y + self.tab_height - 2), (active_w, 2), colour=self.tab_active_text_colour, filled=True ) # "+" new tab button if self.show_new_tab_button: self._draw_new_tab_button(renderer, x + usable_w, y, plus_w, scale)
# Active child is drawn by _draw_recursive (visible=True) def _draw_new_tab_button(self, renderer, bx, by, bw, scale): """Render the "+" new-tab button at (bx, by) with width bw.""" if self._hovered_new_tab: renderer.draw_rect((bx, by), (bw, self.tab_height), colour=self.tab_hover_colour, filled=True) text_x = round(bx + (bw - renderer.text_width("+", scale)) / 2) text_y = round(by + (self.tab_height - self.font_size) / 2) colour = self.tab_active_text_colour if self._hovered_new_tab else self.tab_text_colour renderer.draw_text("+", (text_x, text_y), colour=colour, scale=scale)