414 lines
14 KiB
Python
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)
|