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