Styles
Every element takes a style, a Style that says how it's laid out and drawn.
Rather than build one field by field, most code merges utilities,
small styles that each set a few fields, as Tailwind's classes do.
Merging
Styles are merged using the | operator: left | right.
Every field starts out unset (the counterweight.styles.UNSET sentinel),
and the result takes right's value for every field right sets, and left's value otherwise.
A field set to its default value is still set, so it overrides left.
CellStyle(bold=True) | CellStyle(bold=False) is CellStyle(bold=False),
border | border_none draws no border,
and text_justify_center | text_justify_left justifies left.
Utilities set only the fields they are about, so they layer without clearing each other:
text_color("red", 500) | text_bg("slate", 900) is red text on a slate background.
merge does the same for any number of Styles:
merge(a, b, c) is a | b | c.
It skips None wherever it appears, so a style that only sometimes applies can be passed inline,
as in merge(base, hover_style if hovered else None), and merge() is an empty Style().
Nested styles merge field by field too:
Style(text_style=CellStyle(bold=True)) | Style(text_style=CellStyle(italic=True))
is both bold and italic.
layout follows the same rule, field by field.
Each Text below is styled by the expression it shows:
MERGES: tuple[tuple[str, Style], ...] = (
('text_color("red", 500) | text_bg("slate", 800)', text_color("red", 500) | text_bg("slate", 800)),
('text_color("red", 500) | text_color("sky", 400)', text_color("red", 500) | text_color("sky", 400)),
(
"Style(text_style=CellStyle(bold=True)) | Style(text_style=CellStyle(italic=True))",
Style(text_style=CellStyle(bold=True)) | Style(text_style=CellStyle(italic=True)),
),
("border | border_heavy", border | border_heavy),
("border | border_heavy | border_double", border | border_heavy | border_double),
("border | border_heavy | border_none", border | border_heavy | border_none),
)
@component
def layering() -> Div:
return Div(
style=col | align_children_start | gap(1),
children=tuple(Text(style=style, content=expression) for expression, style in MERGES),
)
text_color("red", 500) | text_bg("slate", 800)
text_color("red", 500) | text_color("sky", 400)
Style(text_style=CellStyle(bold=True)) | Style(text_style=CellStyle(italic=True))
┏━━━━━━━━━━━━━━━━━━━━━┓
┃border | border_heavy┃
┗━━━━━━━━━━━━━━━━━━━━━┛
╔═════════════════════════════════════╗
║border | border_heavy | border_double║
╚═════════════════════════════════════╝
border | border_heavy | border_none
- The first line is red on slate, since each utility sets a different field.
- The second is sky, since both set the text's foreground and the right one wins.
- The third is bold and italic, since the two
text_styles merge field by field. border_doublereplacesborder_heavy's characters, andborder_nonetakes the border away entirely.
A component usually builds its style from a base and a few variants that apply only sometimes.
Below, every button starts from BUTTON;
the selected one merges in SELECTED, which overrides its border kind and color and sets a text color,
and the one under the mouse (use_hovered) merges in HOVERED,
which only adds a background, so it layers over a selected button as well as an unselected one.
BUTTON = border | border_lightrounded | border_color("slate", 500) | pad_x(1)
SELECTED = border_heavy | border_color("amber", 400) | text_color("amber", 300)
HOVERED = text_bg("slate", 700) | padding_color("slate", 700)
@component
def button(label: str, is_selected: bool, select: Callable[[str], object]) -> Text:
hovered = use_hovered()
def choose() -> None:
select(label)
return Text(
content=label,
style=merge(BUTTON, SELECTED if is_selected else None, HOVERED if hovered.border else None),
on_input=on_click(choose),
)
@component
def toolbar() -> Div:
selected, set_selected = use_state("Edit")
return Div(
style=row | gap(1),
children=tuple(button(label, label == selected, set_selected) for label in ("File", "Edit", "View", "Help")),
)
Any color a style holds can be a gradient instead, which colors each cell by where it is in its box.
When an element is drawn, any field still unset takes its default from
STYLE_DEFAULTS
and CELL_STYLE_DEFAULTS.
Text in a filled box
A Text's characters are drawn on its text_style's background,
and where that's unset, on the Text's own content_color, as a Canvas's painter's cells are.
They don't take the color of a box the Text lies in:
an unset background is the default, black, not transparent as a CSS background-color starts out.
Layouts here put elements side by side, or lay a modal over the rest with a fill of its own,
rather than stacking partly painted elements, so each cell's color is decided by the element that draws it.
A Div with a content_color therefore shows it around a Text inside it, but not under the text,
and there are two ways to color text inside a filled box:
Div(style=content_color("slate", 800), children=(Text(content="hi", style=content_color("slate", 800)),))
Div(style=content_color("slate", 800), children=(Text(content="hi", style=text_bg("slate", 800)),))
The first colors the Text's whole content box, under its text and below its last line,
and the second colors only its lines of text, each across the content box's width.
Padding takes its padding_color either way.
What isn't painted at all still shows what lies beneath it:
a margin without a margin_color, and an element with no border, padding, text or content_color.
API
counterweight.styles.Style
dataclass
Style(
*,
layout: Style = Style(),
z: int | UNSET = UNSET,
clip_to: ClipTo | UNSET = UNSET,
overflow_anchor: OverflowAnchor | UNSET = UNSET,
stick_to_end_x: bool | UNSET = UNSET,
stick_to_end_y: bool | UNSET = UNSET,
margin_color: AnyColor | Gradient | UNSET = UNSET,
padding_color: AnyColor | Gradient | UNSET = UNSET,
content_color: AnyColor | Gradient | UNSET = UNSET,
border_kind: BorderKind | UNSET | None = UNSET,
border_style: CellStyle = _DEFAULT_CELL_STYLE,
border_contract: int | UNSET = UNSET,
text_style: CellStyle = _DEFAULT_CELL_STYLE,
text_justify: TextJustify | UNSET = UNSET,
text_wrap: TextWrap | UNSET = UNSET,
text_overflow: TextOverflow | UNSET = UNSET,
)
How an element is laid out and drawn.
Every field except layout starts UNSET, and the nested border_style and text_style
start as empty CellStyles. Merging with | takes the right side's value wherever it is set,
including when it is set to the default, so border | border_none has no border.
layout merges the same way, field by field.
Unset fields resolve to the defaults in STYLE_DEFAULTS.
Each color, margin_color, padding_color and content_color and the colors of border_style and text_style,
can be a gradient, which maps over the box its color paints
unless the gradient's box names another:
the margin box for margin_color, the padding box for padding_color, the border box for content_color
and border_style, and the content box for text_style (and the chunks of a Text).
A border side is drawn where its width in layout (border_top, ...) is 1,
and border_kind chooses the characters it's drawn with.
A border_kind of None removes the border, along with the space reserved for it.
Each side is one cell, so a Style whose layout sets a border width other than
Length(0) or Length(1) raises ValueError.
clip_to chooses what clips the element.
With "padding", the default, or "border", it's its parent's padding or border box,
on each axis where the parent's overflow is Hidden, Clip or Scroll,
and every clip above the parent clips the element too;
"border" lets a title be drawn on its parent's border.
With "screen", the element is unclipped: no ancestor clips it, so a popup can reach past them,
though its own overflow still clips its descendants.
overflow_anchor and stick_to_end_x and stick_to_end_y choose how a scroll container's offset follows
its content when the content changes.
With overflow_anchor "auto", the default, a scroll container keeps the content in view where it was,
as CSS's overflow-anchor does;
with "none", a scroll container keeps its offset, and nothing inside an element that sets it is
an anchor for the scroll container around it.
A scroll container that sticks to its end on an axis and is scrolled to its end there
stays scrolled to its end when its content grows.
text_overflow chooses how a Text ends a line wider than its content box.
With "clip", the default, the line is drawn whole, past the box unless something clips it;
with "ellipsis", it keeps the graphemes that fit before the box's last cell, which … takes,
in the Text's own style, as CSS draws the ellipsis in its block's.
A two-cell grapheme the cut runs through is left out, with a space in its style in its place.
It changes only what's painted, never the size of any element.
resolved
class-attribute
instance-attribute
resolved: ResolvedStyle[AnyColor | Gradient] = field(
init=False, repr=False, compare=False, hash=False
)
This style's drawing fields, with the unset ones filled from STYLE_DEFAULTS,
resolved once when the style is built, since paint reads them for every element on every frame.
counterweight.styles.merge
Merge styles left to right, the same as chaining them with |.
Later styles win wherever they set a field, and Nones are skipped wherever they appear,
so a conditional style can be passed as hover_style if hovered else None.
With no styles, or only Nones, the result is an empty Style().
counterweight.styles.Color
A color as red, green and blue channels, each from 0 to 255, which the terminal draws exactly.
from_name
classmethod
A color by its CSS name, such as "rebeccapurple".
from_hex
cached
classmethod
A color from #rrggbb or the CSS shorthand #rgb, with or without the #.
Any other number of digits, like #rrggbbaa's alpha, raises ValueError.
counterweight.styles.IndexedColor
dataclass
IndexedColor(index: int)
Entry index, from 0 to 255, of the terminal's palette, whose color the terminal decides,
as the SGR sequences 38;5;n and 48;5;n name it.
Entries 0 to 7 are black, red, green, yellow, blue, magenta, cyan and white, and 8 to 15 their bright forms,
all of which the user's theme sets; 16 to 231 are a 6x6x6 color cube and 232 to 255 a gray ramp,
which terminals define as xterm does.
Where no terminal decides, as in a screenshot, it's drawn as DEFAULT_PALETTE[index].
An index outside the palette raises ValueError.
counterweight.styles.TerminalDefault
dataclass
The terminal's default color, which the user's theme sets:
its default foreground where it colors text (SGR 39), and its default background where it fills one (SGR 49).
Where no terminal decides, as in a screenshot, it's drawn as white text on black
(SCREENSHOT_FOREGROUND and
SCREENSHOT_BACKGROUND).
Every TerminalDefault is equal, so use TERMINAL_DEFAULT.
counterweight.styles.TERMINAL_DEFAULT
module-attribute
TERMINAL_DEFAULT = TerminalDefault()
The terminal's default color (see TerminalDefault).
counterweight.styles.AnyColor
AnyColor = Color | IndexedColor | TerminalDefault
A color a cell can be drawn in: an RGB Color, which looks the same in every terminal,
or an IndexedColor or TerminalDefault, which follow the user's theme.
counterweight.styles.DEFAULT_PALETTE
module-attribute
DEFAULT_PALETTE = palette(
tuple(
Color.from_hex(hex)
for hex in (
"#1d1f21",
"#cc6666",
"#b5bd68",
"#f0c674",
"#81a2be",
"#b294bb",
"#8abeb7",
"#c5c8c6",
"#666666",
"#d54e53",
"#b9ca4a",
"#e7c547",
"#7aa6da",
"#c397d8",
"#70c0b1",
"#eaeaea",
)
)
)
The colors each IndexedColor is drawn in where no terminal decides,
as in a screenshot, and that an Emulator's palette starts with:
Tomorrow Night's 16 colors, as libghostty's default palette has,
then xterm's color cube and grays.
counterweight.styles.CellStyle
dataclass
CellStyle(
*,
foreground: AnyColor | Gradient | UNSET = UNSET,
background: AnyColor | Gradient | UNSET = UNSET,
bold: bool | UNSET = UNSET,
dim: bool | UNSET = UNSET,
italic: bool | UNSET = UNSET,
underline: bool | UNSET = UNSET,
strikethrough: bool | UNSET = UNSET,
link: str | UNSET | None = UNSET,
)
How to draw a cell's character: its colors and attributes.
Every field starts UNSET. Merging with | takes the right side's value wherever it is set,
including when it is set to the default, so CellStyle(bold=False) turns bold off.
Unset fields resolve to the defaults in
CELL_STYLE_DEFAULTS.
foreground and background can each be a color or a gradient,
which colors each cell by where it is in the box the gradient maps over.
link makes the cell a hyperlink to that URI, which the terminal opens when it's clicked
(an OSC 8 hyperlink);
None makes it not a link, overriding a link it would otherwise inherit.
The URI must be printable ASCII, so percent-encode anything else,
or CellStyle raises ValueError.
is_solid
class-attribute
instance-attribute
Whether neither color is a gradient.
counterweight.styles.STYLE_DEFAULTS
module-attribute
STYLE_DEFAULTS: ResolvedStyle[AnyColor] = ResolvedStyle(
z=None,
clip_to="padding",
overflow_anchor="auto",
stick_to_end_x=False,
stick_to_end_y=False,
margin_color=_BLACK,
padding_color=_BLACK,
content_color=_BLACK,
border_kind=BorderKind.Light,
border_style=CELL_STYLE_DEFAULTS,
border_contract=0,
text_style=CELL_STYLE_DEFAULTS,
text_justify="left",
text_wrap="none",
text_overflow="clip",
)
What each field of a Style that's left UNSET is drawn as.
counterweight.styles.CELL_STYLE_DEFAULTS
module-attribute
CELL_STYLE_DEFAULTS: ResolvedCellStyle[AnyColor] = (
ResolvedCellStyle(
foreground=_WHITE,
background=_BLACK,
bold=False,
dim=False,
italic=False,
underline=False,
strikethrough=False,
link=None,
)
)
What each field of a CellStyle that's left UNSET is drawn as: white text on black, with no attributes or link.
counterweight.styles.ResolvedCellStyle
dataclass
ResolvedCellStyle(
*,
foreground: C,
background: C,
bold: bool,
dim: bool,
italic: bool,
underline: bool,
strikethrough: bool,
link: str | None,
)
A CellStyle with every field filled in, as paint and output read it.
Its colors are C: a ResolvedCellStyle[AnyColor | Gradient] is what a style resolves to,
and a ResolvedCellStyle[AnyColor], whose colors are each one color, is what a cell is drawn in,
which paint makes from the other by coloring each cell from its gradients.
A style whose colors are all solid is the same object as either type.
is_solid
class-attribute
instance-attribute
Whether neither color is a gradient, which is_solid_cell_style narrows on.
counterweight.styles.resolve_cell_style
cached
resolve_cell_style(
style: CellStyle,
defaults: ResolvedCellStyle[
AnyColor | Gradient
] = CELL_STYLE_DEFAULTS,
) -> ResolvedCellStyle[AnyColor | Gradient]
Fill style's unset fields from defaults, as paint does for every cell it draws,
so the result says exactly what a cell in style looks like.
Resolving against a resolved base is the same as merging onto it first:
resolve_cell_style(b, resolve_cell_style(a)) equals resolve_cell_style(a | b).
counterweight.styles.is_solid_cell_style
is_solid_cell_style(
style: ResolvedCellStyle[AnyColor | Gradient],
) -> TypeIs[ResolvedCellStyle[AnyColor]]
Whether style's colors are each one color, so that a cell can be drawn in it as it is,
narrowing its type to the one a Cell's style has.
counterweight.styles.as_solid
as_solid(
style: ResolvedCellStyle[AnyColor | Gradient],
) -> ResolvedCellStyle[AnyColor]
style, which must be solid, as the type a cell is drawn in, raising TypeError if it holds a gradient,
as a CellStyle with no gradient resolves to: as_solid(resolve_cell_style(style)).
counterweight.styles.with_colors
with_colors(
style: ResolvedCellStyle[AnyColor | Gradient],
foreground: C,
background: C,
) -> ResolvedCellStyle[C]
style's attributes with foreground and background for its colors, such as a gradient's colors at a cell.
counterweight.styles.is_solid_color
Whether color is one color, rather than a gradient.
counterweight.styles.rgb_of
The RGB color is drawn in where no terminal decides, as in a screenshot:
DEFAULT_PALETTE's entry for an IndexedColor,
and terminal_default for TerminalDefault, which a screenshot draws as
SCREENSHOT_FOREGROUND for text
and SCREENSHOT_BACKGROUND for a fill.
counterweight.styles.palette
A whole 256-color palette with ansi as its first 16 entries,
then xterm's 6x6x6 color cube and its ramp of 24 grays.
counterweight.styles.UNSET
module-attribute
UNSET = sentinel('UNSET')
What a style field holds until it's set, so merging a style over another keeps the other's value there.
An unset field is drawn with its value from STYLE_DEFAULTS or CELL_STYLE_DEFAULTS.
counterweight.styles.BorderKind
The characters a border is drawn with, which the border_<kind> utilities choose.
Where a border is drawn is up to the border widths in Style.layout, which border and its sides set.
Light
class-attribute
instance-attribute
Light = BorderParts(
left="│",
right="│",
top="─",
bottom="─",
left_top="┌",
right_top="┐",
left_bottom="└",
right_bottom="┘",
)
LightRounded
class-attribute
instance-attribute
LightRounded = BorderParts(
left="│",
right="│",
top="─",
bottom="─",
left_top="╭",
right_top="╮",
left_bottom="╰",
right_bottom="╯",
)
LightAngled
class-attribute
instance-attribute
LightAngled = BorderParts(
left="▏",
right="▕",
top="▔",
bottom="▁",
left_top="/",
right_top="╲",
left_bottom="╲",
right_bottom="/",
)
Heavy
class-attribute
instance-attribute
Heavy = BorderParts(
left="┃",
right="┃",
top="━",
bottom="━",
left_top="┏",
right_top="┓",
left_bottom="┗",
right_bottom="┛",
)
Double
class-attribute
instance-attribute
Double = BorderParts(
left="║",
right="║",
top="═",
bottom="═",
left_top="╔",
right_top="╗",
left_bottom="╚",
right_bottom="╝",
)
Thick
class-attribute
instance-attribute
Thick = BorderParts(
left="▌",
right="▐",
top="▀",
bottom="▄",
left_top="▛",
right_top="▜",
left_bottom="▙",
right_bottom="▟",
)
McGugan
class-attribute
instance-attribute
McGugan = BorderParts(
left="▕",
right="▏",
top="▁",
bottom="▔",
left_top=" ",
right_top=" ",
left_bottom=" ",
right_bottom=" ",
)
LightShade
class-attribute
instance-attribute
LightShade = BorderParts(
left="░",
right="░",
top="░",
bottom="░",
left_top="░",
right_top="░",
left_bottom="░",
right_bottom="░",
)
MediumShade
class-attribute
instance-attribute
MediumShade = BorderParts(
left="▒",
right="▒",
top="▒",
bottom="▒",
left_top="▒",
right_top="▒",
left_bottom="▒",
right_bottom="▒",
)
HeavyShade
class-attribute
instance-attribute
HeavyShade = BorderParts(
left="▓",
right="▓",
top="▓",
bottom="▓",
left_top="▓",
right_top="▓",
left_bottom="▓",
right_bottom="▓",
)
counterweight.styles.ClipTo
module-attribute
ClipTo = Literal['padding', 'border', 'screen']
What clips an element: its parent's padding box, its parent's border box, or only the screen.
counterweight.styles.OverflowAnchor
module-attribute
OverflowAnchor = Literal['auto', 'none']
Whether a scroll container keeps its content where it was in view as the content above it changes size,
as CSS's overflow-anchor says.
counterweight.styles.TextJustify
module-attribute
TextJustify = Literal['left', 'center', 'right']
Where each line of a Text sits in its content box, as CSS's text-align puts it.
counterweight.styles.TextWrap
module-attribute
TextWrap = Literal['none', 'stable', 'pretty', 'balance']
How a Text wraps its lines, as CSS's text-wrap does:
not at all, greedily, minimizing raggedness over the paragraph, or evening out the lines' lengths.
counterweight.styles.TextOverflow
module-attribute
TextOverflow = Literal['clip', 'ellipsis']
How a Text ends a line too wide for its content box, as CSS's
text-overflow does:
by drawing the whole line, for a clip (or nothing) to cut,
or by keeping what fits before the box's last cell and drawing … there.