laatikoita/boxes.py
2025-08-07 20:56:12 +03:00

414 lines
14 KiB
Python

from __future__ import annotations
import dataclasses
class DimensionsError(Exception): pass
@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
class Box:
"""Rectangle containing text.
'contents' is a 'height'-member list, where each string represents one
line. All the strings are conceptually the same width."""
width: int
height: int
contents: list[str]
def __str__(self) -> str:
return '\n'.join(self.contents)
def heighten(self, above: int, below: int, fill: str=' ') -> Box:
"""Add vertical padding before/after the contents of the box.
'fill' is a single-wide string used for the padding."""
padding = [fill * self.width]
return Box(
width=self.width,
height=above + self.height + below,
contents=padding * above + self.contents + padding * below
)
def widen(self, before: int, after: int, fill: str=' ') -> Box:
"""Add horizontal padding before/after the contents of the box.
'fill' is a single-wide string used for the padding."""
return Box(
width=before + self.width + after,
height=self.height,
contents=[fill * before + i + fill * after for i in self.contents]
)
def vrepeat(self, times: int) -> Box:
"""Create a new box that contains the contents of this box repeated
'times' vertically."""
return Box(
width=self.width,
height=self.height * times,
contents = self.contents * times
)
def hrepeat(self, times: int) -> Box:
"""Create a new box that contains the contents of this box repeated
'times' times on side-by-side."""
return Box(
width=self.width * times,
height=self.height,
contents = [i * times for i in self.contents]
)
def above(self, other: Box) -> Box:
"""Create a new Box with self above other."""
assert isinstance(other, Box)
if self.width != other.width:
raise DimensionsError(f'Incompatible widths: {self.width} vs {other.width}')
return Box(
width=self.width,
height=self.height + other.height,
contents=self.contents + other.contents
)
def beside(self, other: Box) -> Box:
"""Create a new Box with self and other side by side, with self
placed first."""
assert isinstance(other, Box)
if self.height != other.height:
raise DimensionsError(f'Incompatible heights: {self.height} vs {other.height}')
return Box(
width=self.width + other.width,
height=self.height,
contents=[a+b for a, b in zip(self.contents, other.contents)]
)
def frame(self, borders = [True, True]) -> BorderBox:
"""Create a BorderBox containing this box surrounded by a frame.
'borders' is a list of booleans, which control which sides of the
BorderBox have borders. It can have one of two forms:
1. [horizontal, vertical]
2. [above, below, before, after]"""
assert(len(borders) in (2, 4))
if len(borders) == 2:
above, before = borders
below, after = borders
else:
above, below, before, after = borders
border_above = [BEFORE | AFTER if above else 0] * self.width
border_below = [BEFORE | AFTER if below else 0] * self.width
border_before = [UP | DOWN if before else 0] * self.height
border_after = [UP | DOWN if after else 0] * self.height
before_above = (DOWN if before else 0) | (AFTER if above else 0)
after_above = (BEFORE if above else 0) | (DOWN if after else 0)
before_below = (UP if before else 0) | (AFTER if below else 0)
after_below = (BEFORE if below else 0) | (UP if after else 0)
return BorderBox(
border_above=[before_above, *border_above, after_above],
border_below=[before_below, *border_below, after_below],
border_before=border_before,
border_after=border_after,
box=self
)
def margins(self) -> BorderBox:
"""Create a BorderBox containing this box surrounded by margins."""
return BorderBox(
border_above=[0] * (self.width + 2),
border_below=[0] * (self.width + 2),
border_before=[0] * self.height,
border_after=[0] * self.height,
box=self
)
def print(self, *args, **kwargs):
print(self, *args, **kwargs)
def from_string(string: str, width=len) -> Box:
"""Creates a Box from a string.
The text will be laid out as a single long line.
The 'width' parameter is a function that returns the width of a string."""
return Box(
width=width(string),
height=1,
contents=[string]
)
def box(width: int, height: int, fill: str=' ') -> Box:
"""Creates a Box with given dimensions.
'fill' is a single-wide string that the Box is filled with."""
return Box(
width=width,
height=height,
contents=[fill * width] * height
)
UP = 1
DOWN = 2
BEFORE = 4
AFTER = 8
@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
class BorderBox:
"""Box surrounded by a border that can merge with its neighbours'.
Each piece of border is represented as a bitset of the directions it
connects to.
'border_above' and 'border_below' contain the corners, so they are two
longer than 'box.width', while 'border_before' and 'border_after' are
'box.height' long."""
border_above: list[int]
border_below: list[int]
border_before: list[int]
border_after: list[int]
box: Box
def heighten(self, above: int, below: int) -> BorderBox:
"""Add vertical padding outside the borders of the box."""
# TODO: add border_parts support
# Materialize and clear the top border, if needed
if above > 0:
# The border is materialized when you set two BorderBoxes by
# each other, and the top border is the border_above of the
# upper box
# By placing an empty box with margins above us, we get our
# border materialized, and the new top border is now all empty
self = box(self.box.width, 0).margins().above(self)
# The height of two BorderBoxes set above each other is the sum
# of their heights, plus one for the materialized border
# between the two
# Since we used a zero-height box to materialize the top
# border, it added one to the height → account for that
above -= 1
# Materialize the bottom border similarly
if below > 0:
self = self.above(box(self.box.width, 0).margins())
below -= 1
return BorderBox(
border_above=self.border_above,
border_below=self.border_below,
border_before=[0] * above + self.border_before + [0] * below,
border_after=[0] * above + self.border_after + [0] * below,
# Since we can now for certain say that the top and the bottom
# borders are fully empty, adding padding outside the borders
# is equivalent to adding it inside the borders
# Thus, delegate to the box's .heighten
box=self.box.heighten(above, below)
)
def widen(self, before: int, after: int) -> BorderBox:
"""Add horizontal padding outside the borders of the box."""
# TODO: add border_parts support
# Materialize and clear the borders, similarly to heighten above
if before > 0:
self = box(0, self.box.height).margins().beside(self)
before -= 1
if after > 0:
self = self.beside(box(0, self.box.height).margins())
after -= 1
return BorderBox(
border_above=[0] * before + self.border_above + [0] * after,
border_below=[0] * before + self.border_below + [0] * after,
border_before=self.border_before,
border_after=self.border_after,
box=self.box.widen(before, after)
)
def vstretch(self, above: int, below: int, border_parts = ' ╵╷│╴┘┐┤╶└┌├─┴┬┼') -> BorderBox:
"""Add vertical padding inside the borders of the box.
'border_parts' is a map from integer representing a bitset of
directions to a box-drawing character. The default works for LTR
languages."""
# If the top border connects downwards, add a horizontal line to
# maintain the connectivity
# .┌───┬─┐
# .x...x.x ← like so
down = [UP | DOWN if i & DOWN else 0 for i in self.border_above]
# The corners become part of the border before/after, while the middle
# section is materialized
above_before = [down[0]] * above
above_after = [down[-1]] * above
above_box = from_string(''.join(border_parts[i] for i in down[1:-1])).vrepeat(above)
# Similar for the bottom border
up = [UP | DOWN if i & UP else 0 for i in self.border_below]
below_before = [up[0]] * below
below_after = [up[-1]] * below
below_box = from_string(''.join(border_parts[i] for i in up[1:-1])).vrepeat(below)
return BorderBox(
border_above=self.border_above,
border_below=self.border_below,
border_before=above_before + self.border_before + below_before,
border_after=above_after + self.border_after + below_after,
box=above_box.above(self.box).above(below_box)
)
def hstretch(self, before: int, after: int, border_parts = ' ╵╷│╴┘┐┤╶└┌├─┴┬┼') -> BorderBox:
"""Add horizontal padding inside the borders of the box.
'border_parts' is a map from integer representing a bitset of
directions to a box-drawing character. The default works for LTR
languages."""
# Continue the inwards-facing connectivity of the vertical edges
# similarly to vstretch
# ┌T ← this is part of the border_above
# │. \
# ├x |
# ├x | ← this is what we handle at this point
# │. |
# │. |
# └x /
# .B ← this is part of the border_below
leading = [BEFORE | AFTER if i & AFTER else 0 for i in self.border_before]
before_box = box(before, 0)
for i in leading:
before_box = before_box.above(from_string(border_parts[i] * before))
trailing = [BEFORE | AFTER if i & BEFORE else 0 for i in self.border_after]
after_box = box(after, 0)
for i in trailing:
after_box = after_box.above(from_string(border_parts[i] * after))
# Handle the corner bits' extension separately
first_above = self.border_above[0]
last_above = self.border_above[-1]
first_below = self.border_below[0]
last_below = self.border_below[-1]
before_above = [BEFORE | AFTER if first_above & AFTER else 0] * before
after_above = [BEFORE | AFTER if last_above & BEFORE else 0] * after
before_below = [BEFORE | AFTER if first_below & AFTER else 0] * before
after_below = [BEFORE | AFTER if last_below & BEFORE else 0] * after
border_above = [first_above, *before_above, *self.border_above[1:-1], *after_above, last_above]
border_below = [first_below, *before_below, *self.border_below[1:-1], *after_below, last_below]
return BorderBox(
border_above=border_above,
border_below=border_below,
border_before=self.border_before,
border_after=self.border_after,
box=before_box.beside(self.box).beside(after_box)
)
def to_box(self, border_parts = ' ╵╷│╴┘┐┤╶└┌├─┴┬┼') -> Box:
"""Materializes the border and returns a normal Box.
'border_parts' is a map from integer representing a bitset of
directions to a box-drawing character. The default works for LTR
languages."""
above = from_string(''.join(border_parts[i] for i in self.border_above))
below = from_string(''.join(border_parts[i] for i in self.border_below))
before = Box(
width=1,
height=len(self.border_before),
contents=[border_parts[i] for i in self.border_before]
)
after = Box(
width=1,
height=len(self.border_after),
contents=[border_parts[i] for i in self.border_after]
)
return above.above(before.beside(self.box).beside(after)).above(below)
def above(self, other: BorderBox, border_parts = ' ╵╷│╴┘┐┤╶└┌├─┴┬┼'):
"""Create a new BorderBox with self above other.
'border_parts' is a map from integer representing a bitset of
directions to a box-drawing character. The default works for LTR
languages."""
assert isinstance(other, BorderBox)
if self.box.width != other.box.width:
raise DimensionsError(f'Incompatible widths: {self.box.width} vs {other.box.width}')
# We are merging our bottom border and other's top border
# ......
# .1111.
# xxxxxx ← here
# .2222.
# ......
# Where the corners overlapped becomes part of our vertical
# borders, as they can still join to other borders
# border_above and border_below are always at least 2 long, since
# they contain the corners, so we can subscript freely here
corners_before = self.border_below[0] | other.border_above[0]
border_before = [*self.border_before, corners_before, *other.border_before]
corners_after = self.border_below[-1] | other.border_above[-1]
border_after = [*self.border_after, corners_after, *other.border_after]
# Other than the corners, the merged border is materialized
our_border = self.border_below[1:-1]
their_border = other.border_above[1:-1]
merged = [a | b for a, b in zip(our_border, their_border)]
merged = from_string(''.join(border_parts[i] for i in merged))
return BorderBox(
border_above=self.border_above,
border_below=other.border_below,
border_before=border_before,
border_after=border_after,
box=self.box.above(merged).above(other.box)
)
def beside(self, other: BorderBox, border_parts = ' ╵╷│╴┘┐┤╶└┌├─┴┬┼'):
"""Create a new BorderBox with self and other side by side, with
self placed first.
'border_parts' is a map from integer representing a bitset of
directions to a box-drawing character. The default works for LTR
languages."""
assert isinstance(other, BorderBox)
if self.box.height != other.box.height:
raise DimensionsError(f'Incompatible heights: {self.box.height} vs {other.box.height}')
# We are merging our 'after' border and other's 'before' border
# ...x.....
# .11x2222.
# .11x2222.
# ...x.....
# ↑
# here
# Similarly to the vertical case, the corners become part of the
# border
# As they are stored as part of the horizontal border, we need to
# slice those off of the borders before joining
corners_above = self.border_above[-1] | other.border_above[0]
border_above = [*self.border_above[:-1], corners_above, *other.border_above[1:]]
corners_below = self.border_below[-1] | other.border_below[0]
border_below = [*self.border_below[:-1], corners_below, *other.border_below[1:]]
# Since the corners are part of the horizontal border, we don't
# need to handle them here when materializing the vertical border
merged = [a | b for a, b in zip(self.border_after, other.border_before)]
merged = Box(
width=1,
height=len(merged),
contents=[border_parts[i] for i in merged]
)
return BorderBox(
border_above=border_above,
border_below=border_below,
border_before=self.border_before,
border_after=other.border_after,
box=self.box.beside(merged).beside(other.box)
)
def print(self, *args, border_parts = ' ╵╷│╴┘┐┤╶└┌├─┴┬┼', **kwargs):
print(self.to_box(border_parts), *args, **kwargs)