from dataclasses import dataclass, field @dataclass(eq=False) class Box: rel_x: float rel_y: float width: float height: float item: "LayoutItem | None" = field(default=None, repr=False) collision_left: list["Box"] = field(default_factory=list, repr=False) collision_right: list["Box"] = field(default_factory=list, repr=False) def get_left(self): return self.item.x + self.rel_x def get_right(self): return self.get_left() + self.width def get_top(self): return self.item.y + self.rel_y def get_bottom(self): return self.get_top() + self.height def is_in_collision_left_tree(self, box): if box in self.collision_left: return True return any(collision.is_in_collision_left_tree(box) for collision in self.collision_left) def __repr__(self): if len(self.item.boxes) == 1: return self.item.name return f"{self.item.name}:{self.item.boxes.index(self)}" class LayoutItem: def __init__( self, name, kind, color, position, boxes, fallback=None, icon_group=None, icon_count=0, icon_width=40, icon_widths=None, icon_height=None, dot_width=40, allow_zero=False, allow_dot=False, direction=None, spacing=0, ): self.name = name self.kind = kind self.color = color self.position = position if isinstance(position, str) else position[0] self.fallback = fallback if isinstance(position, str) else position[1] self.icon_group = icon_group self.icon_count = icon_count self.icon_width = icon_width self.icon_height = icon_height if icon_height is not None else icon_width self.icon_widths = self._expanded_icon_widths(icon_widths, icon_count, icon_width) self.dot_width = dot_width self.allow_zero = allow_zero self.allow_dot = allow_dot self.direction = direction self.spacing = spacing self.visible_icons = icon_count self.visible_icon_widths = self.icon_widths self.dot = False self.pool_remaining = icon_count self.pool_icon_widths = self.icon_widths self.width_limit = None self.x = 0 self.y = 0 self.boxes = boxes for box in self.boxes: box.item = self if self.is_icon_item: self.width_limit = sum(self.icon_widths) self._apply_icon_width(self.width_limit) @classmethod def fixed(cls, name, kind, color, position, width, height, xy=(0, 0), fallback=None): item = cls(name, kind, color, position, [Box(0, 0, width, height)], fallback=fallback) item.x, item.y = xy return item @classmethod def multi_box(cls, name, kind, color, position, boxes, xy=(0, 0), fallback=None): item = cls(name, kind, color, position, boxes, fallback=fallback) item.x, item.y = xy return item @classmethod def icon_strip( cls, name, kind, color, position, icon_group, icon_count, height, xy=(0, 0), icon_width=40, icon_widths=None, allow_zero=False, allow_dot=True, direction=None, spacing=0, ): item = cls( name, kind, color, position, [Box(0, 0, 0, height)], icon_group=icon_group, icon_count=icon_count, icon_width=icon_width, icon_widths=icon_widths, icon_height=height, dot_width=height * 0.75, allow_zero=allow_zero, allow_dot=allow_dot, direction=direction, spacing=spacing, ) item.x, item.y = xy return item @property def is_icon_item(self): return self.icon_group is not None def get_width(self): return self.get_right() - self.get_left() def get_left(self): return min(box.get_left() for box in self.boxes) def get_right(self): return max(box.get_right() for box in self.boxes) def get_top(self): return min(box.get_top() for box in self.boxes) def get_bottom(self): return max(box.get_bottom() for box in self.boxes) def set_left(self, x): self.x += x - self.get_left() def set_right(self, x): self.x += x - self.get_right() def set_top(self, y): self.y += y - self.get_top() def shift_x(self, offset): self.x += offset def collision_amount_left(self, items=None): boxes = self._boxes_for_items(items) amount = 0 for box in self.boxes: for collision in box.collision_left: if boxes is not None and collision not in boxes: continue for blocker, blocker_item in self._visible_blockers(collision, "left", boxes): amount = max( amount, blocker + blocker_item.spacing_to(self) - box.get_left(), ) return amount def collision_amount_right(self, items=None): boxes = self._boxes_for_items(items) amount = 0 for box in self.boxes: for collision in box.collision_right: if boxes is not None and collision not in boxes: continue for blocker, blocker_item in self._visible_blockers(collision, "right", boxes): amount = max( amount, box.get_right() + self.spacing_to(blocker_item) - blocker, ) return amount def spacing_to(self, other): if self.get_width() <= 0 or other.get_width() <= 0: return 0 if self.icon_group is not None and self.icon_group == other.icon_group: return 0 return self.spacing def get_collisionleft_items(self): return self._collision_items("left") def get_collisionright_items(self): return self._collision_items("right") def min_width(self): if not self.is_icon_item: if self.kind == "Chip": return 0 return self.get_width() if self.icon_count <= 0 or self.pool_remaining <= 0: return 0 if self.allow_zero: return 0 return self._dot_width(visible_icons=0) def can_shrink_by(self): return max(0, self.get_width() - self.min_width()) def truncate_at_least(self, amount): amount = max(0, min(amount, self.can_shrink_by())) old_width = self.get_width() if self.is_icon_item: self.width_limit = max(0, self.width_limit - amount) self.recompute_icon_state() self.width_limit = self.get_width() else: self._set_primary_width(old_width - amount) return old_width - self.get_width() def set_pool_remaining(self, remaining): self.pool_remaining = max(0, remaining) self.recompute_icon_state() def recompute_icon_state(self): if not self.is_icon_item: return max_width = min(self.width_limit, sum(self.pool_icon_widths)) width, visible, dot = self._best_icon_state(max_width) self.visible_icons = visible self.visible_icon_widths = self.pool_icon_widths[:visible] self.dot = dot self._apply_icon_width(width) def set_pool_icons(self, widths): self.width_limit = min(self.width_limit, self.get_width()) self.pool_icon_widths = list(widths) self.pool_remaining = len(self.pool_icon_widths) self.recompute_icon_state() self.width_limit = self.get_width() def rendername(self): text = f"{self.name}" if self.is_icon_item: text += f"\n{self.visible_icons}/{self.icon_count} icons" + (" *" if self.dot else "") text += f"\n{self.get_width()}" return text def render_segments(self): if not self.is_icon_item: return [] segments = [("icon", width) for width in self.visible_icon_widths] if self.dot: dot = ("dot", self._dot_width()) if self._dot_side() == "left": segments.insert(0, dot) else: segments.append(dot) return segments def _best_icon_state(self, max_width): if self.pool_remaining <= 0: return 0, 0, False max_icons = len(self.pool_icon_widths) prefix_widths = [0] for width in self.pool_icon_widths: prefix_widths.append(prefix_widths[-1] + width) best = None for visible in range(max_icons, -1, -1): hidden = self.pool_remaining - visible dot = self.allow_dot and hidden > 0 icon_width = prefix_widths[visible] if visible == 0 and not dot and not self.allow_zero: continue width = None if visible == 0 and dot and self.allow_zero: width = self._dot_width() elif visible > 0 or dot: width = icon_width + (self._dot_width() if dot else 0) elif self.allow_zero: width = 0 if width is not None and width <= max_width: if best is None or width > best[0]: best = (width, visible, dot) if best is not None: return best return 0, 0, False def _dot_width(self, visible_icons=None): visible_icons = self.visible_icons if visible_icons is None else visible_icons crop = 0 dot_side = self._dot_side() if dot_side == "left" and all(not box.collision_left for box in self.boxes): crop += self.dot_width / 3 if dot_side == "right" and all(not box.collision_right for box in self.boxes): crop += self.dot_width / 3 if visible_icons == 0: if dot_side != "left" and all(not box.collision_left for box in self.boxes): crop += self.dot_width / 3 if dot_side != "right" and all(not box.collision_right for box in self.boxes): crop += self.dot_width / 3 return max(0, self.dot_width - crop) def _dot_side(self): if self.direction in ("left", "right"): return self.direction if self.position == "right": return "left" return "right" def _apply_icon_width(self, width): self._set_primary_width(width) def _set_primary_width(self, width): self.boxes[0].width = max(0, width) @staticmethod def _expanded_icon_widths(widths, count, fallback): if count <= 0: return [] if widths is None: return [fallback] * count source = list(widths) if not source: source = [fallback] if len(source) < count: source.extend([source[-1]] * (count - len(source))) return source[:count] def _boxes_for_items(self, items): if items is None: return None return {box for item in items for box in item.boxes} def _visible_blockers(self, box, side, allowed_boxes=None): seen = set() def visit(current): if current in seen: return seen.add(current) if allowed_boxes is not None and current not in allowed_boxes: return if current.item.get_width() > 0: if side == "left": yield current.get_right(), current.item else: yield current.get_left(), current.item return collisions = current.collision_left if side == "left" else current.collision_right found = False for collision in collisions: for blocker in visit(collision): found = True yield blocker if not found: if side == "left": yield current.get_right(), current.item else: yield current.get_left(), current.item yield from visit(box) def _collision_items(self, side): seen = set() result = [] def visit(box): if box in seen: return seen.add(box) if box.item not in result: result.append(box.item) collisions = box.collision_left if side == "left" else box.collision_right for collision in collisions: visit(collision) for box in self.boxes: visit(box) return result def __repr__(self): return self.name