From 07c344464253b28c4dc92bff8b2449a2cab3dd10 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Thu, 20 Aug 2026 23:27:42 +0000 Subject: [PATCH] Replace Python layout prototype with editable playground --- python/{ => old}/layout_item.py | 0 python/{ => old}/positions.py | 0 python/{ => old}/render.py | 0 python/playground.py | 186 ++++++++++++++ python/statusbar_lab.py | 428 ++++++++++++++++++++++++++++++++ python/statusbar_renderer.py | 139 +++++++++++ 6 files changed, 753 insertions(+) rename python/{ => old}/layout_item.py (100%) rename python/{ => old}/positions.py (100%) rename python/{ => old}/render.py (100%) create mode 100644 python/playground.py create mode 100644 python/statusbar_lab.py create mode 100644 python/statusbar_renderer.py diff --git a/python/layout_item.py b/python/old/layout_item.py similarity index 100% rename from python/layout_item.py rename to python/old/layout_item.py diff --git a/python/positions.py b/python/old/positions.py similarity index 100% rename from python/positions.py rename to python/old/positions.py diff --git a/python/render.py b/python/old/render.py similarity index 100% rename from python/render.py rename to python/old/render.py diff --git a/python/playground.py b/python/playground.py new file mode 100644 index 0000000..b9555f3 --- /dev/null +++ b/python/playground.py @@ -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) diff --git a/python/statusbar_lab.py b/python/statusbar_lab.py new file mode 100644 index 0000000..6c5761b --- /dev/null +++ b/python/statusbar_lab.py @@ -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 diff --git a/python/statusbar_renderer.py b/python/statusbar_renderer.py new file mode 100644 index 0000000..2629984 --- /dev/null +++ b/python/statusbar_renderer.py @@ -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 ""