Source code for sextile.viewdata.attributes

"""The SAA5050 attribute model, and the plan of attributes for one row.

A style is every attribute the hardware has; a run or a panel is a thing placed
on a row in some style. Given the runs and panels of a row already positioned,
`_row_events` walks them in order and `_attributes_for` works out the shortest
run of attributes that carries one style into the next -- an attribute occupies
a cell, so the fewer the better, and some transitions the hardware cannot make
at all. `sextile.viewdata.composition` decides *where* things go and drives this
to decide *what colour* they come out.
"""

from collections.abc import Sequence
from dataclasses import dataclass, replace
from typing import Final

from sextile.viewdata.controls import Attribute, Colour, alpha_colour, graphics_colour
from sextile.viewdata.frame import COLUMNS
from sextile.viewdata.measure import cell_count


[docs] class DoesNotFit(ValueError): """A composition that cannot be drawn, and why."""
[docs] @dataclass(frozen=True) class Style: """How a run is to be displayed: every attribute the SAA5050 has. Not every combination is reachable from every other in one cell, which is why this is a value handed to a compositor rather than a sequence of attributes written by hand. A background is the worst of them: the hardware has no "set background" attribute, only "make the current foreground the background", so white on blue costs three cells -- choose blue, make it the background, choose white again. """ colour: Colour = Colour.WHITE background: Colour = Colour.BLACK separated: bool = False flashing: bool = False double_height: bool = False hold_graphics: bool = False concealed: bool = False
#: What every row starts as, whatever the row above ended in. OPENING: Final = Style() @dataclass(frozen=True) class Run: """One thing to place: text, or a run of mosaic blocks. ``patterns`` is six-bit block patterns; ``words`` is characters. Exactly one of them says what this is. """ column: int style: Style = Style() words: str = "" patterns: tuple[int, ...] = () @property def graphics(self) -> bool: return bool(self.patterns) @property def cells(self) -> int: return len(self.patterns) if self.graphics else cell_count(self.words) @property def end(self) -> int: return self.column + self.cells
[docs] @dataclass(frozen=True) class Panel: """A coloured rectangle, cell-aligned, with things drawn on top of it. `column` is its first coloured cell, which is the one carrying NEW_BACKGROUND: the hardware sets a background *at* the attribute cell rather than after it, so that cell is already the colour it asks for. The cell before it is not -- it is where the colour is chosen, and a colour attribute cannot colour itself -- so a panel always costs one black cell to its left, and content inside it starts at least one cell in from the left-hand edge. """ column: int width: int colour: Colour rows: tuple[int, ...] = () @property def end(self) -> int: """One past the last coloured cell.""" return self.column + self.width def covers(self, run: Run) -> bool: return self.column <= run.column and run.end <= self.end
@dataclass(frozen=True) class _Plan: runs: list[Run] attributes: list[tuple[int, Attribute]] def _opening_style(within: "Panel | None") -> Style: """The style in force where a run is going, before the run says anything. Inside a panel, the colour is the panel's and so is the background -- which is why a run drawn on a panel pays for its own colour and nothing else. """ if within is None: return OPENING return Style(colour=within.colour, background=within.colour) def _with_panel_background(style: Style, within: "Panel | None") -> Style: """A style with the background of the panel it is going on, if it wants one. A run that says nothing about a background takes the panel's. It has to be known where a run is placed as well as where the row is planned, because what a run costs in attributes is what decides where it can start. """ if within is None or style.background is not Colour.BLACK: return style return replace(style, background=within.colour) def _row_events( runs: Sequence[Run], panels: Sequence["Panel"] ) -> list[tuple[int, str, "Run | Panel | None"]]: """Everything that happens along a row, in the order it happens. A panel opens one cell before the column its colour appears in, because the attribute that promotes the colour is set *at* its cell and that cell is the panel's first. It closes one cell after its last, for the same reason. """ events: list[tuple[int, str, Run | Panel | None]] = [] for panel in panels: events.append((panel.column + 1, "open", panel)) if panel.end < COLUMNS: events.append((panel.end + 1, "close", None)) events += [(run.column, "run", run) for run in runs] order = {"open": 0, "run": 1, "close": 2} return sorted(events, key=lambda event: (event[0], order[event[1]])) def _style_after( kind: str, what: "Run | Panel | None", state: Style, panels: Sequence["Panel"] ) -> Run: """The style the row is to be in after this event, as a run to ask about. A run drawn on a panel and saying nothing about a background takes the panel's, which is the point of declaring the panel: a run that turned the background off in the middle of a box would put a black hole in it. """ if kind == "close": return Run(column=0, style=replace(state, background=Colour.BLACK)) if isinstance(what, Panel): return Run(column=0, style=Style(colour=what.colour, background=what.colour)) run = what assert isinstance(run, Run) for panel in panels: if panel.covers(run): return replace(run, style=_with_panel_background(run.style, panel)) return run def _attributes_for(state: Style, graphics: bool, run: Run) -> list[Attribute]: """The shortest run of attributes taking one style to another. Order matters and is not arbitrary. The background comes first because setting it means setting the foreground and then saying "that one", so the foreground attribute that follows has to be the one that sticks. Conceal comes last because it hides whatever follows it, and has no off switch -- the hardware clears it at the end of the row and nowhere else. """ wanted = run.style if state.concealed and not wanted.concealed: raise DoesNotFit( "conceal cannot be turned off within a row: the hardware clears it " "only at the end of one" ) needed: list[Attribute] = [] colour_of = graphics_colour if run.graphics else alpha_colour foreground = state.colour if wanted.background is not state.background: if wanted.background is Colour.BLACK: needed.append(Attribute.BLACK_BACKGROUND) else: # There is no "set background": the current foreground becomes it. needed.append(colour_of(wanted.background)) needed.append(Attribute.NEW_BACKGROUND) foreground = wanted.background if run.graphics and wanted.separated is not state.separated: needed.append( Attribute.SEPARATED_GRAPHICS if wanted.separated else Attribute.CONTIGUOUS_GRAPHICS ) # The colour attribute chooses the character set as well, so it is needed # whenever either changes. if foreground is not wanted.colour or graphics is not run.graphics: needed.append(colour_of(wanted.colour)) if wanted.flashing is not state.flashing: needed.append(Attribute.FLASH if wanted.flashing else Attribute.STEADY) if wanted.double_height is not state.double_height: needed.append( Attribute.DOUBLE_HEIGHT if wanted.double_height else Attribute.NORMAL_HEIGHT ) if wanted.hold_graphics is not state.hold_graphics: needed.append( Attribute.HOLD_GRAPHICS if wanted.hold_graphics else Attribute.RELEASE_GRAPHICS ) if wanted.concealed and not state.concealed: needed.append(Attribute.CONCEAL) return needed