Skip to content

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),
    )

Merged styles, each labeled with its expression

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_double replaces border_heavy's characters, and border_none takes 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")),
    )

A toolbar with Edit selected and View hovered

╭──────╮ ┏━━━━━━┓ ╭──────╮ ╭──────╮
│ 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.

layout class-attribute instance-attribute

layout: Style = field(default_factory=waxy.Style)

z class-attribute instance-attribute

z: int | UNSET = UNSET

clip_to class-attribute instance-attribute

clip_to: ClipTo | UNSET = UNSET

overflow_anchor class-attribute instance-attribute

overflow_anchor: OverflowAnchor | UNSET = UNSET

stick_to_end_x class-attribute instance-attribute

stick_to_end_x: bool | UNSET = UNSET

stick_to_end_y class-attribute instance-attribute

stick_to_end_y: bool | UNSET = UNSET

margin_color class-attribute instance-attribute

margin_color: AnyColor | Gradient | UNSET = UNSET

padding_color class-attribute instance-attribute

padding_color: AnyColor | Gradient | UNSET = UNSET

content_color class-attribute instance-attribute

content_color: AnyColor | Gradient | UNSET = UNSET

border_kind class-attribute instance-attribute

border_kind: BorderKind | UNSET | None = UNSET

border_style class-attribute instance-attribute

border_style: CellStyle = _DEFAULT_CELL_STYLE

border_contract class-attribute instance-attribute

border_contract: int | UNSET = UNSET

text_style class-attribute instance-attribute

text_style: CellStyle = _DEFAULT_CELL_STYLE

text_justify class-attribute instance-attribute

text_justify: TextJustify | UNSET = UNSET

text_wrap class-attribute instance-attribute

text_wrap: TextWrap | UNSET = UNSET

text_overflow class-attribute instance-attribute

text_overflow: TextOverflow | UNSET = UNSET

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.

__post_init__

__post_init__() -> None

__hash__

__hash__() -> int

counterweight.styles.merge

merge(*styles: Style | None) -> Style

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.

red instance-attribute

red: int

green instance-attribute

green: int

blue instance-attribute

blue: int

hex property

hex: str

The color as #rrggbb.

from_name classmethod

from_name(name: str) -> Color

A color by its CSS name, such as "rebeccapurple".

from_hex cached classmethod

from_hex(hex: str) -> Color

A color from #rrggbb or the CSS shorthand #rgb, with or without the #. Any other number of digits, like #rrggbbaa's alpha, raises ValueError.

blend

blend(other: Color, alpha: float) -> Color

This color with alpha of other mixed in, from 0 (this color) to 1 (other), in sRGB.

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.

index instance-attribute

index: int

__post_init__

__post_init__() -> None

counterweight.styles.TerminalDefault dataclass

TerminalDefault()

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

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.

foreground class-attribute instance-attribute

foreground: AnyColor | Gradient | UNSET = UNSET

background class-attribute instance-attribute

background: AnyColor | Gradient | UNSET = UNSET

bold class-attribute instance-attribute

bold: bool | UNSET = UNSET

dim class-attribute instance-attribute

dim: bool | UNSET = UNSET

italic class-attribute instance-attribute

italic: bool | UNSET = UNSET

underline class-attribute instance-attribute

underline: bool | UNSET = UNSET

strikethrough class-attribute instance-attribute

strikethrough: bool | UNSET = UNSET
link: str | UNSET | None = UNSET

is_solid class-attribute instance-attribute

is_solid: bool = field(
    init=False,
    repr=False,
    compare=False,
    hash=False,
    default=True,
)

Whether neither color is a gradient.

__post_init__

__post_init__() -> None

__hash__

__hash__() -> int

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.

foreground instance-attribute

foreground: C

background instance-attribute

background: C

bold instance-attribute

bold: bool

dim instance-attribute

dim: bool

italic instance-attribute

italic: bool

underline instance-attribute

underline: bool

strikethrough instance-attribute

strikethrough: bool
link: str | None

is_solid class-attribute instance-attribute

is_solid: bool = field(
    init=False,
    repr=False,
    compare=False,
    hash=False,
    default=True,
)

Whether neither color is a gradient, which is_solid_cell_style narrows on.

__post_init__

__post_init__() -> None

__hash__

__hash__() -> int

counterweight.styles.resolve_cell_style cached

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

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

is_solid_color(
    color: AnyColor | Gradient,
) -> TypeIs[AnyColor]

Whether color is one color, rather than a gradient.

counterweight.styles.rgb_of

rgb_of(color: AnyColor, terminal_default: Color) -> Color

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

palette(ansi: Sequence[Color]) -> tuple[Color, ...]

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="▓",
)

Star class-attribute instance-attribute

Star = BorderParts(
    left="*",
    right="*",
    top="*",
    bottom="*",
    left_top="*",
    right_top="*",
    left_bottom="*",
    right_bottom="*",
)

__repr__

__repr__() -> str

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.