Files
statusbartweak/python/statusbar_lab.py
T

429 lines
14 KiB
Python

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