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