"""Container model for the status-bar layout playground. This module deliberately contains no placement policy. ``playground.py`` owns the scenario and is the only file intended for interactive editing. """ from __future__ import annotations from dataclasses import dataclass from typing import Iterable, Optional def text_width(text: str, height: float, horizontal_padding: float) -> float: """Use stable approximate glyph widths so geometry is available before Tk starts.""" narrow = set(" ilI.,:;!|'`") wide = set("MW@#%&") units = sum(0.35 if char in narrow else 0.9 if char in wide else 0.62 for char in text) return max(0.0, units * height + horizontal_padding * 2) def _bounds_share_vertical_space(first: "Bounds", second: "Bounds") -> bool: return first.bottom < second.top and second.bottom < first.top def rectangles_overlap(first: "Bounds", second: "Bounds") -> bool: return ( _bounds_share_vertical_space(first, second) and first.left < second.right and second.left < first.right ) @dataclass(frozen=True) class Bounds: left: float bottom: float right: float top: float def vertically_overlaps( first: "Bounds | Container | ClockGroup", second: "Bounds | Container | ClockGroup", ) -> bool: """Return whether two containers can collide through horizontal movement. A composite clock is expanded to its individual lines, so protruding clock text outside the tested vertical band cannot produce a false collision. """ first_items = _collision_items(first) second_items = _collision_items(second) return any( _bounds_share_vertical_space(first_item.bounds, second_item.bounds) for first_item in first_items for second_item in second_items ) def shares_vertical_space( first: "Bounds | Container | ClockGroup", second: "Bounds | Container | ClockGroup", ) -> bool: return vertically_overlaps(first, second) class Container: """A mutable, bottom-origin status-bar rectangle.""" def __init__( self, lab: "StatusBarLab", kind: str, height: float, vertical_offset: float = 0, position: str = "left", cutout_side: str = "left", ) -> None: self.lab = lab self.kind = kind self.height = float(height) self.y = float(vertical_offset) self.position = position self.cutout_side = cutout_side self.x = 0.0 @property def width(self) -> float: raise NotImplementedError @property def left(self) -> float: return self.x @property def right(self) -> float: return self.x + self.width @property def bottom(self) -> float: return self.y @property def top(self) -> float: return self.y + self.height @property def bounds(self) -> Bounds: return Bounds(self.left, self.bottom, self.right, self.top) def set_left(self, left: float) -> None: self.x = float(left) def set_right(self, right: float) -> None: self.x = float(right) - self.width def shift_x(self, amount: float) -> None: self.x += float(amount) def vertically_overlaps(self, other: "Bounds") -> bool: return vertically_overlaps(self.bounds, other) def overlaps(self, other: "Container | ClockGroup") -> bool: return containers_overlap(self, other) class TextContainer(Container): def __init__(self, text: str, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.text = text self._width_override: Optional[float] = None @property def natural_width(self) -> float: return text_width(self.text, self.height, self.lab.text_horizontal_padding) @property def width(self) -> float: return self.natural_width if self._width_override is None else self._width_override def set_width(self, width: float) -> None: self._width_override = max(0.0, float(width)) def restore_natural_width(self) -> None: self._width_override = None class ClockLine(TextContainer): """A clock line whose width always follows its text.""" def __init__(self, horizontal_offset: float, text: str, *args, **kwargs) -> None: super().__init__(text, *args, **kwargs) self.raw_horizontal_offset = float(horizontal_offset) self.relative_x = 0.0 def set_width(self, width: float) -> None: raise ValueError("Clock line widths are derived from their text and cannot be changed.") class ClockGroup: """One movable clock made from one or more independently-sized text lines.""" def __init__(self, lab: "StatusBarLab") -> None: self.lab = lab self.lines: list[ClockLine] = [] self.x = 0.0 def add_line( self, text: str, height: float, vertical_offset: float = 0, horizontal_offset: float = 0, position: str = "left", cutout_side: str = "left", ) -> ClockLine: line = ClockLine( horizontal_offset, text, self.lab, "clock", height, vertical_offset, position, cutout_side, ) self.lines.append(line) self._apply_relative_offsets() return line def _apply_relative_offsets(self) -> None: if not self.lines: return origin = min(line.raw_horizontal_offset for line in self.lines) for line in self.lines: line.relative_x = line.raw_horizontal_offset - origin line.x = self.x + line.relative_x @property def left(self) -> float: return min((line.left for line in self.lines), default=self.x) @property def right(self) -> float: return max((line.right for line in self.lines), default=self.x) @property def width(self) -> float: return self.right - self.left def set_left(self, left: float) -> None: self.x += float(left) - self.left self._apply_relative_offsets() def set_right(self, right: float) -> None: self.x += float(right) - self.right self._apply_relative_offsets() def shift_x(self, amount: float) -> None: self.x += float(amount) self._apply_relative_offsets() def bounds_between(self, bottom: float, top: float) -> Optional[Bounds]: """Return clock bounds limited to lines that occupy ``bottom..top``.""" band = Bounds(float("-inf"), bottom, float("inf"), top) lines = [line for line in self.lines if vertically_overlaps(line.bounds, band)] if not lines: return None return Bounds( min(line.left for line in lines), min(line.bottom for line in lines), max(line.right for line in lines), max(line.top for line in lines), ) def left_edge_between(self, bottom: float, top: float) -> Optional[float]: bounds = self.bounds_between(bottom, top) return bounds.left if bounds is not None else None def right_edge_between(self, bottom: float, top: float) -> Optional[float]: bounds = self.bounds_between(bottom, top) return bounds.right if bounds is not None else None def overlaps(self, other: Container | "ClockGroup") -> bool: return containers_overlap(self, other) class IconPool: def __init__(self, kind: str) -> None: self.kind = kind self.widths: list[Optional[float]] = [] self.available_ids: list[int] = [] def configure(self, count: int, widths: Iterable[float]) -> None: count = max(0, int(count)) supplied: list[Optional[float]] = [float(width) for width in widths][:count] supplied.extend([None] * (count - len(supplied))) self.widths = supplied self.available_ids = list(range(count)) def take(self, count: int) -> list[int]: count = max(0, int(count)) assigned = self.available_ids[:count] del self.available_ids[:len(assigned)] return assigned def release(self, ids: Iterable[int]) -> None: self.available_ids = sorted(set(self.available_ids).union(ids)) def width_for(self, icon_id: int, fallback_width: float) -> float: width = self.widths[icon_id] return fallback_width if width is None else width class IconContainer(Container): def __init__(self, pool: IconPool, direction: str = "right", *args, **kwargs) -> None: super().__init__(*args, **kwargs) if direction not in ("left", "right"): raise ValueError("Icon direction must be 'left' or 'right'.") self.pool = pool self.direction = direction self.icon_ids: list[int] = [] self.dot = False @property def icon_count(self) -> int: return len(self.icon_ids) @property def start_number(self) -> Optional[int]: return self.icon_ids[0] if self.icon_ids else None @property def dot_width(self) -> float: return self.height * self.lab.dot_width_factor @property def width(self) -> float: icon_width = sum(self.icon_width(icon_id) for icon_id in self.icon_ids) if not self.dot: return icon_width gap = self.lab.icon_dot_gap if self.icon_ids else 0.0 return icon_width + gap + self.dot_width def add_icons(self, count: int) -> int: assigned = self.pool.take(count) self.icon_ids.extend(assigned) return len(assigned) def return_icons(self, count: int) -> int: count = min(max(0, int(count)), len(self.icon_ids)) returned = self.icon_ids[-count:] if count else [] if count: del self.icon_ids[-count:] self.pool.release(returned) return len(returned) def clear_icons(self) -> None: self.return_icons(len(self.icon_ids)) def set_dot(self, enabled: bool) -> None: self.dot = bool(enabled) def toggle_dot(self) -> bool: self.dot = not self.dot return self.dot def segments(self) -> list[tuple[str, Optional[int], float]]: icons = [("icon", icon_id, self.icon_width(icon_id)) for icon_id in self.icon_ids] dot = [("dot", None, self.dot_width)] if self.dot else [] if self.dot and self.icon_ids and self.lab.icon_dot_gap > 0: dot.insert(0, ("gap", None, self.lab.icon_dot_gap)) return icons + dot if self.direction == "right" else dot + list(reversed(icons)) def icon_width(self, icon_id: int) -> float: return self.pool.width_for(icon_id, self.height) class CameraCutout(Container): def __init__(self, lab: "StatusBarLab", width: float, height: float, horizontal_position: float, vertical_position: float) -> None: super().__init__(lab, "camera", height, vertical_position) self._width = float(width) self.x = float(horizontal_position) @property def width(self) -> float: return self._width def set_width(self, width: float) -> None: self._width = max(0.0, float(width)) class StatusBarLab: def __init__(self) -> None: self.text_horizontal_padding = 6.0 self.icon_dot_gap = 0.0 self.dot_width_factor = 0.75 self.clock = ClockGroup(self) self.notifications: list[IconContainer] = [] self.statuses: list[IconContainer] = [] self.chips: list[TextContainer] = [] self.carriers: list[TextContainer] = [] self.cameras: list[CameraCutout] = [] self.notification_pool = IconPool("notification") self.status_pool = IconPool("status") def add_notification(self, height: float, vertical_offset: float = 0, position: str = "left", cutout_side: str = "left", direction: str = "right") -> IconContainer: item = IconContainer(self.notification_pool, direction, self, "notification", height, vertical_offset, position, cutout_side) self.notifications.append(item) return item def add_status(self, height: float, vertical_offset: float = 0, position: str = "right", cutout_side: str = "right", direction: str = "left") -> IconContainer: item = IconContainer(self.status_pool, direction, self, "status", height, vertical_offset, position, cutout_side) self.statuses.append(item) return item def add_chip(self, text: str, height: float, vertical_offset: float = 0, position: str = "left", cutout_side: str = "left") -> TextContainer: item = TextContainer(text, self, "chip", height, vertical_offset, position, cutout_side) self.chips.append(item) return item def add_carrier(self, text: str, height: float, vertical_offset: float = 0, position: str = "left", cutout_side: str = "left") -> TextContainer: item = TextContainer(text, self, "carrier", height, vertical_offset, position, cutout_side) self.carriers.append(item) return item def add_camera(self, width: float, height: float, horizontal_position: float, vertical_position: float) -> CameraCutout: item = CameraCutout(self, width, height, horizontal_position, vertical_position) self.cameras.append(item) return item def configure_notification_icons(self, count: int) -> None: self.notification_pool.configure(count, []) for item in self.notifications: item.icon_ids.clear() def configure_status_icons(self, count: int, widths: Iterable[float]) -> None: self.status_pool.configure(count, widths) for item in self.statuses: item.icon_ids.clear() def render_containers(self) -> list[Container]: return [*self.clock.lines, *self.notifications, *self.statuses, *self.chips, *self.carriers] def containers_overlap(first: Container | ClockGroup, second: Container | ClockGroup) -> bool: """Check horizontal and vertical overlap, expanding a clock into its lines.""" first_items = _collision_items(first) second_items = _collision_items(second) for first_item in first_items: for second_item in second_items: if first_item is second_item: continue if rectangles_overlap(first_item.bounds, second_item.bounds): return True return False def _collision_items(item: Bounds | Container | ClockGroup) -> list[Container]: if isinstance(item, Bounds): return [_BoundsContainer(item)] return item.lines if isinstance(item, ClockGroup) else [item] class _BoundsContainer: """Adapter so the public helpers can accept a raw Bounds instance too.""" def __init__(self, bounds: Bounds) -> None: self.bounds = bounds