Replace Python layout prototype with editable playground
This commit is contained in:
@@ -0,0 +1,186 @@
|
|||||||
|
"""Editable status-bar layout playground.
|
||||||
|
|
||||||
|
Run from this directory with ``python3 playground.py``. Everything below is
|
||||||
|
intended to be edited freely; the model and renderer live in separate files.
|
||||||
|
|
||||||
|
Coordinates use the same convention as the module's layout solver:
|
||||||
|
horizontal zero is the status bar's left edge and vertical zero is its bottom.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from statusbar_lab import StatusBarLab, containers_overlap, vertically_overlaps
|
||||||
|
from statusbar_renderer import render
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Virtual status-bar settings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
STATUSBAR_WIDTH = 1200
|
||||||
|
STATUSBAR_HEIGHT = 100
|
||||||
|
|
||||||
|
# These are inputs for the algorithm you will write. The playground itself
|
||||||
|
# does not apply them automatically.
|
||||||
|
EDGE_PADDING = 0
|
||||||
|
CONTAINER_PADDING = 8
|
||||||
|
CAMERA_PADDING = 0
|
||||||
|
TEXT_HORIZONTAL_PADDING = 6
|
||||||
|
ICON_DOT_GAP = 0
|
||||||
|
DOT_WIDTH_FACTOR = 0.75
|
||||||
|
|
||||||
|
# Keep these in the same terms as the module settings. They are intentionally
|
||||||
|
# passive until the positioning/truncation algorithm below uses them.
|
||||||
|
CONTAINER_ORDER = ["clock", "chip", "carrier", "notification", "status"]
|
||||||
|
SHRINK_ORDER = ["notification", "status", "chip", "carrier"]
|
||||||
|
|
||||||
|
# Rendering scale only affects the visualizer, never the simulated geometry.
|
||||||
|
RENDER_SCALE = 1.0
|
||||||
|
VERTICAL_RENDER_SCALE = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scenario API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
LAB = StatusBarLab()
|
||||||
|
|
||||||
|
# These lists remain valid when items are added. Clock text lines belong to
|
||||||
|
# one CLOCK group because the group moves them by their relative x offsets.
|
||||||
|
CLOCK = LAB.clock
|
||||||
|
CLOCK_LINES = CLOCK.lines
|
||||||
|
NOTIFICATIONS = LAB.notifications
|
||||||
|
STATUSES = LAB.statuses
|
||||||
|
CHIPS = LAB.chips
|
||||||
|
CARRIERS = LAB.carriers
|
||||||
|
CAMERAS = LAB.cameras
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_settings() -> None:
|
||||||
|
LAB.text_horizontal_padding = TEXT_HORIZONTAL_PADDING
|
||||||
|
LAB.icon_dot_gap = ICON_DOT_GAP
|
||||||
|
LAB.dot_width_factor = DOT_WIDTH_FACTOR
|
||||||
|
|
||||||
|
|
||||||
|
def AddClock(
|
||||||
|
text: str,
|
||||||
|
height: float,
|
||||||
|
vertical_offset: float = 0,
|
||||||
|
horizontal_offset: float = 0,
|
||||||
|
position: str = "left",
|
||||||
|
cutout_side: str = "left",
|
||||||
|
):
|
||||||
|
"""Add one text line to the single movable CLOCK group.
|
||||||
|
|
||||||
|
Horizontal offsets are normalized relative to all clock lines. Therefore
|
||||||
|
offsets ``5, 10, -20`` produce the same clock geometry as ``105, 110, 80``.
|
||||||
|
``position`` is ``left``, ``middle`` or ``right``; ``cutout_side`` is the
|
||||||
|
fallback side for a middle clock when a camera splits the status bar.
|
||||||
|
"""
|
||||||
|
_sync_settings()
|
||||||
|
return CLOCK.add_line(text, height, vertical_offset, horizontal_offset, position, cutout_side)
|
||||||
|
|
||||||
|
|
||||||
|
def AddNotification(
|
||||||
|
height: float,
|
||||||
|
vertical_offset: float = 0,
|
||||||
|
position: str = "left",
|
||||||
|
cutout_side: str = "left",
|
||||||
|
direction: str = "right",
|
||||||
|
):
|
||||||
|
_sync_settings()
|
||||||
|
return LAB.add_notification(height, vertical_offset, position, cutout_side, direction)
|
||||||
|
|
||||||
|
|
||||||
|
def SetNotificationIcons(number_of_notifications: int) -> None:
|
||||||
|
"""Replace the notification-icon pool and empty every notification container."""
|
||||||
|
LAB.configure_notification_icons(number_of_notifications)
|
||||||
|
|
||||||
|
|
||||||
|
def AddStatus(
|
||||||
|
height: float,
|
||||||
|
vertical_offset: float = 0,
|
||||||
|
position: str = "right",
|
||||||
|
cutout_side: str = "right",
|
||||||
|
direction: str = "left",
|
||||||
|
):
|
||||||
|
_sync_settings()
|
||||||
|
return LAB.add_status(height, vertical_offset, position, cutout_side, direction)
|
||||||
|
|
||||||
|
|
||||||
|
def SetStatusIcons(number_of_icons: int, widths: list[float] | None = None) -> None:
|
||||||
|
"""Replace the status-icon pool and empty every status container.
|
||||||
|
|
||||||
|
Missing widths are padded using the first status container's height. Add
|
||||||
|
status containers before calling this function when relying on that default.
|
||||||
|
"""
|
||||||
|
LAB.configure_status_icons(number_of_icons, widths or [])
|
||||||
|
|
||||||
|
|
||||||
|
def AddChip(
|
||||||
|
text: str,
|
||||||
|
height: float,
|
||||||
|
vertical_offset: float = 0,
|
||||||
|
position: str = "left",
|
||||||
|
cutout_side: str = "left",
|
||||||
|
):
|
||||||
|
_sync_settings()
|
||||||
|
return LAB.add_chip(text, height, vertical_offset, position, cutout_side)
|
||||||
|
|
||||||
|
|
||||||
|
def AddCarrier(
|
||||||
|
text: str,
|
||||||
|
height: float,
|
||||||
|
vertical_offset: float = 0,
|
||||||
|
position: str = "left",
|
||||||
|
cutout_side: str = "left",
|
||||||
|
):
|
||||||
|
_sync_settings()
|
||||||
|
return LAB.add_carrier(text, height, vertical_offset, position, cutout_side)
|
||||||
|
|
||||||
|
|
||||||
|
def AddCamera(width: float, height: float, horizontal_position: float, vertical_position: float):
|
||||||
|
return LAB.add_camera(width, height, horizontal_position, vertical_position)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Simulated app settings: add or remove as many containers as you need.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
AddClock("12:34:56", height=40, vertical_offset=0, horizontal_offset=0)
|
||||||
|
AddClock("Mon, 1 Jan", height=24, vertical_offset=42, horizontal_offset=10)
|
||||||
|
|
||||||
|
AddNotification(height=32, vertical_offset=0)
|
||||||
|
AddNotification(height=32, vertical_offset=40)
|
||||||
|
SetNotificationIcons(8)
|
||||||
|
|
||||||
|
AddStatus(height=30, vertical_offset=0)
|
||||||
|
AddStatus(height=30, vertical_offset=38)
|
||||||
|
SetStatusIcons(6, [20, 30, 30, 45])
|
||||||
|
|
||||||
|
AddChip("Navigation 12:34", height=38, vertical_offset=0)
|
||||||
|
AddCarrier("Example carrier", height=30, vertical_offset=45)
|
||||||
|
|
||||||
|
AddCamera(width=90, height=100, horizontal_position=555, vertical_position=0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Positioning and truncation algorithm
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# All containers currently start at horizontal position zero. Add your layout
|
||||||
|
# algorithm here. Useful operations include:
|
||||||
|
#
|
||||||
|
# CLOCK.set_left(EDGE_PADDING)
|
||||||
|
# NOTIFICATIONS[0].set_right(STATUSBAR_WIDTH - EDGE_PADDING)
|
||||||
|
# CHIPS[0].set_width(120) # clock widths cannot be overridden
|
||||||
|
# NOTIFICATIONS[0].add_icons(3) # takes IDs from the shared pool
|
||||||
|
# NOTIFICATIONS[0].return_icons(1) # returns the newest assigned ID
|
||||||
|
# NOTIFICATIONS[0].set_dot(True)
|
||||||
|
# CLOCK.right_edge_between(10, 50) # ignores clock lines outside this band
|
||||||
|
# containers_overlap(CLOCK, CHIPS[0])
|
||||||
|
# vertically_overlaps(NOTIFICATIONS[0].bounds, CHIPS[0].bounds)
|
||||||
|
|
||||||
|
|
||||||
|
# Keep the renderer call at the end so the editable scenario and algorithm stay
|
||||||
|
# together above it.
|
||||||
|
_sync_settings()
|
||||||
|
render(LAB, STATUSBAR_WIDTH, STATUSBAR_HEIGHT, RENDER_SCALE, VERTICAL_RENDER_SCALE)
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Tk renderer for the status-bar layout playground."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import font as tkfont
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from statusbar_lab import CameraCutout, Container, IconContainer, StatusBarLab, rectangles_overlap
|
||||||
|
|
||||||
|
|
||||||
|
TYPE_COLOURS = {
|
||||||
|
"clock": "#9fd7ff",
|
||||||
|
"notification": "#a7efaa",
|
||||||
|
"status": "#ffd596",
|
||||||
|
"chip": "#e2b0ff",
|
||||||
|
"carrier": "#ffb4c4",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render(
|
||||||
|
lab: StatusBarLab,
|
||||||
|
statusbar_width: float,
|
||||||
|
statusbar_height: float,
|
||||||
|
render_scale: float = 1.0,
|
||||||
|
vertical_scale: float = 2.0,
|
||||||
|
margin: int = 40,
|
||||||
|
) -> None:
|
||||||
|
root = tk.Tk()
|
||||||
|
root.title("StatusBarTweak layout playground")
|
||||||
|
width = int(statusbar_width * render_scale + margin * 2)
|
||||||
|
height = int(statusbar_height * vertical_scale + margin * 2)
|
||||||
|
canvas = tk.Canvas(root, width=width, height=height, bg="#242424", highlightthickness=0)
|
||||||
|
canvas.pack()
|
||||||
|
|
||||||
|
def point(x: float, y: float) -> tuple[float, float]:
|
||||||
|
return margin + x * render_scale, margin + (statusbar_height - y) * vertical_scale
|
||||||
|
|
||||||
|
left, bottom = point(0, 0)
|
||||||
|
right, top = point(statusbar_width, statusbar_height)
|
||||||
|
canvas.create_rectangle(left, top, right, bottom, fill="white", outline="#4a4a4a", width=5)
|
||||||
|
|
||||||
|
containers = lab.render_containers()
|
||||||
|
for container in containers:
|
||||||
|
_draw_container(canvas, point, container, render_scale, vertical_scale)
|
||||||
|
|
||||||
|
_draw_overlaps(canvas, point, containers)
|
||||||
|
for camera in lab.cameras:
|
||||||
|
_draw_camera(canvas, point, camera)
|
||||||
|
root.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_container(canvas: tk.Canvas, point, container: Container, render_scale: float, vertical_scale: float) -> None:
|
||||||
|
if isinstance(container, IconContainer):
|
||||||
|
_draw_icons(canvas, point, container)
|
||||||
|
return
|
||||||
|
|
||||||
|
x1, y1 = point(container.left, container.bottom)
|
||||||
|
x2, y2 = point(container.right, container.top)
|
||||||
|
canvas.create_rectangle(x1, y2, x2, y1, fill=TYPE_COLOURS[container.kind], outline="black", width=1)
|
||||||
|
_draw_clipped_text(canvas, container.text, x1 + 3, (y1 + y2) / 2, max(0, x2 - x1 - 6), container.height * vertical_scale)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_icons(canvas: tk.Canvas, point, container: IconContainer) -> None:
|
||||||
|
colour = TYPE_COLOURS[container.kind]
|
||||||
|
if not container.segments():
|
||||||
|
x, y1 = point(container.left, container.bottom)
|
||||||
|
_, y2 = point(container.left, container.top)
|
||||||
|
canvas.create_line(x, y1, x, y2, fill="black", width=1)
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor = container.left
|
||||||
|
for segment_kind, icon_id, segment_width in container.segments():
|
||||||
|
x1, y1 = point(cursor, container.bottom)
|
||||||
|
x2, y2 = point(cursor + segment_width, container.top)
|
||||||
|
if segment_kind == "gap":
|
||||||
|
cursor += segment_width
|
||||||
|
continue
|
||||||
|
if segment_kind == "dot":
|
||||||
|
canvas.create_rectangle(x1, y2, x2, y1, fill=colour, outline="black", width=1)
|
||||||
|
radius = min(abs(x2 - x1), abs(y1 - y2)) * 0.24
|
||||||
|
cx, cy = (x1 + x2) / 2, (y1 + y2) / 2
|
||||||
|
canvas.create_oval(cx - radius, cy - radius, cx + radius, cy + radius, fill="black", outline="black")
|
||||||
|
else:
|
||||||
|
canvas.create_rectangle(x1, y2, x2, y1, fill=colour, outline="black", width=1)
|
||||||
|
_draw_clipped_text(canvas, str(icon_id), (x1 + x2) / 2, (y1 + y2) / 2, abs(x2 - x1) - 2, abs(y1 - y2), anchor="center")
|
||||||
|
cursor += segment_width
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_camera(canvas: tk.Canvas, point, camera: CameraCutout) -> None:
|
||||||
|
x1, y1 = point(camera.left, camera.bottom)
|
||||||
|
x2, y2 = point(camera.right, camera.top)
|
||||||
|
canvas.create_rectangle(x1, y2, x2, y1, fill="black", outline="black")
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_overlaps(canvas: tk.Canvas, point, containers: Iterable[Container]) -> None:
|
||||||
|
items = list(containers)
|
||||||
|
for index, first in enumerate(items):
|
||||||
|
for second in items[index + 1:]:
|
||||||
|
if first.kind == "clock" and second.kind == "clock":
|
||||||
|
continue
|
||||||
|
if not rectangles_overlap(first.bounds, second.bounds):
|
||||||
|
continue
|
||||||
|
left = max(first.left, second.left)
|
||||||
|
right = min(first.right, second.right)
|
||||||
|
bottom = max(first.bottom, second.bottom)
|
||||||
|
top = min(first.top, second.top)
|
||||||
|
x1, y1 = point(left, bottom)
|
||||||
|
x2, y2 = point(right, top)
|
||||||
|
canvas.create_rectangle(x1, y2, x2, y1, fill="red", outline="red")
|
||||||
|
radius = max(abs(x2 - x1), abs(y1 - y2)) / 2 + 12
|
||||||
|
cx, cy = (x1 + x2) / 2, (y1 + y2) / 2
|
||||||
|
canvas.create_oval(cx - radius, cy - radius, cx + radius, cy + radius, outline="red", width=3)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_clipped_text(
|
||||||
|
canvas: tk.Canvas,
|
||||||
|
text: str,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
width: float,
|
||||||
|
height: float,
|
||||||
|
anchor: str = "w",
|
||||||
|
) -> None:
|
||||||
|
if width <= 0:
|
||||||
|
return
|
||||||
|
size = max(7, int(height * 0.42))
|
||||||
|
text_font = tkfont.Font(family="TkDefaultFont", size=size)
|
||||||
|
clipped = _clip_text(text_font, text, width)
|
||||||
|
canvas.create_text(x, y, text=clipped, font=text_font, fill="black", anchor=anchor)
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_text(text_font: tkfont.Font, text: str, width: float) -> str:
|
||||||
|
if text_font.measure(text) <= width:
|
||||||
|
return text
|
||||||
|
ellipsis = "..."
|
||||||
|
while text and text_font.measure(text + ellipsis) > width:
|
||||||
|
text = text[:-1]
|
||||||
|
return text + ellipsis if text else ""
|
||||||
Reference in New Issue
Block a user