feat(palette): sixteen ANSI colour ladders, cycled with c

This commit is contained in:
beefcarver committed 2026-11-04 08:55:51 -05:00
1 parent 253b462df1
commit 43ddd8666b
14 files changed
+622 -111

No files matched your search

+63 -41
View File
@@ -1,9 +1,12 @@
"""Pane frames: one grey box, with two corners fading out of it.
"""Pane frames: one grey box whose corners glow in the active palette.
The panes draw their own frames instead of using Textual's ``border`` so the
corners can be coloured individually. Two of them — the top right and the
bottom left — run white → light grey → bright cyan → cyan along both arms;
everything else, including the other two corners, is the border grey.
corners can be coloured. Two of them — the top right and the bottom left —
run a short fade out of the palette's hot end and back into the border grey;
everything else, including the other two corners, is that grey.
The ladder is whatever palette the app is on (``c`` cycles them), which is why
every function here takes ``stops`` rather than reading a module constant.
"""
from __future__ import annotations
@@ -13,26 +16,30 @@ from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
from ..palettes import DEFAULT_PALETTE, PALETTES
from .common import active_stops
#: The frame itself: one grey, the same grey as the scrollbar thumb.
EDGE = "bright_black"
#: A faded corner runs white → light grey → bright cyan → cyan, then settles
#: back into the border grey.
CORNER_GRADIENT = ("bright_white", "white", "bright_cyan", "cyan")
#: How many cells a corner fade reaches into the edge.
FADE_CELLS = 4
def fade(distance: int) -> str:
"""Colour ``distance`` cells away from a corner (0 is the corner itself)."""
if 0 <= distance < len(CORNER_GRADIENT):
return CORNER_GRADIENT[distance]
return EDGE
def corner_fade(distance: int, stops: tuple[str, ...] = ()) -> str:
"""Colour ``distance`` cells out from a corner; 0 is the corner glyph.
def side_style(row: int, rows: int, side: str = "left") -> str:
"""Colour of a side cell: only two corners fade, so only one end glows."""
if side == "right":
return fade(row + 1) # fades down from the top-right corner
return fade(rows - row) # fades up from the bottom-left corner
The fade is short — four cells — and walks backwards down the palette from
its hot end, so the corner glows in the palette's brightest colour and then
hands over to the plain border grey.
"""
if not stops:
stops = PALETTES[DEFAULT_PALETTE]
distance = max(0, distance)
if distance >= FADE_CELLS:
return EDGE
index = len(stops) - 1 - distance
return stops[index] if index >= 0 else EDGE
def inner_size(width: int, height: int) -> tuple[int, int]:
@@ -40,12 +47,12 @@ def inner_size(width: int, height: int) -> tuple[int, int]:
return max(1, width - 2), max(1, height - 2)
def _edge_cells(width: int, faded_end: str = "right") -> list[tuple[str, str]]:
def _edge_cells(width: int, faded_end: str, stops: tuple[str, ...]) -> list[tuple[str, str]]:
"""Inner cells of an edge; only one end of it fades into a corner."""
inner = max(0, width - 2)
if faded_end == "left":
return [("─", fade(column + 1)) for column in range(inner)]
return [("─", fade(inner - column)) for column in range(inner)]
return [("─", corner_fade(column + 1, stops)) for column in range(inner)]
return [("─", corner_fade(inner - column, stops)) for column in range(inner)]
def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
@@ -62,9 +69,9 @@ def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
line.append("".join(run), style=style)
def top(width: int, title: str = "") -> Text:
def top(width: int, title: str = "", stops: tuple[str, ...] = ()) -> Text:
width = max(2, width)
cells = _edge_cells(width, "right") # only the top-right corner fades
cells = _edge_cells(width, "right", stops) # only the top-right corner fades
if title:
label = f" {title} "
start = 1
@@ -75,29 +82,38 @@ def top(width: int, title: str = "") -> Text:
line = Text(no_wrap=True)
line.append("╭", style=EDGE)
_paint(line, cells)
line.append("╮", style=f"bold {fade(0)}")
line.append("╮", style=f"bold {corner_fade(0, stops)}")
return line
def bottom(width: int) -> Text:
def bottom(width: int, stops: tuple[str, ...] = ()) -> Text:
width = max(2, width)
line = Text(no_wrap=True)
line.append("╰", style=f"bold {fade(0)}")
_paint(line, _edge_cells(width, "left")) # only the bottom-left fades
line.append("╰", style=f"bold {corner_fade(0, stops)}")
_paint(line, _edge_cells(width, "left", stops)) # only the bottom-left fades
line.append("╯", style=EDGE)
return line
def side() -> Text:
return Text("│", style=EDGE)
def side_style(row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()) -> str:
"""Colour of a side cell: only two corners fade, so only one end glows."""
if side == "right":
return corner_fade(row + 1, stops) # fades down from the top-right corner
return corner_fade(rows - row, stops) # fades up from the bottom-left corner
def side_row(row: int, rows: int, side: str = "left") -> Text:
return Text("│", style=side_style(row, rows, side))
def side_row(
row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()
) -> Text:
return Text("│", style=side_style(row, rows, side, stops))
def wrap(
lines: list[Text], width: int, height: int, title: str = ""
lines: list[Text],
width: int,
height: int,
title: str = "",
stops: tuple[str, ...] = (),
) -> RenderableType:
"""Frame ``lines`` (already sized for the content box) in a pane."""
width = max(4, width)
@@ -105,24 +121,29 @@ def wrap(
inner_width, inner_height = inner_size(width, height)
rows = len(lines[:inner_height])
framed: list[Text] = [top(width, title)]
framed: list[Text] = [top(width, title, stops)]
for index, line in enumerate(lines[:inner_height]):
if cell_len(line.plain) > inner_width:
# A pane must never spill over its neighbour: trim, whatever the
# caller asked for.
line = line.copy()
line.truncate(inner_width, overflow="ellipsis")
row = Text(no_wrap=True)
row.append_text(side_row(index, rows, "left"))
row.append_text(side_row(index, rows, "left", stops))
row.append_text(line)
pad = inner_width - cell_len(line.plain)
if pad > 0:
row.append(" " * pad)
row.append_text(side_row(index, rows, "right"))
row.append_text(side_row(index, rows, "right", stops))
framed.append(row)
while len(framed) < height - 1:
index = len(framed) - 1
row = Text(no_wrap=True)
row.append_text(side_row(index, inner_height, "left"))
row.append_text(side_row(index, inner_height, "left", stops))
row.append(" " * inner_width)
row.append_text(side_row(index, inner_height, "right"))
row.append_text(side_row(index, inner_height, "right", stops))
framed.append(row)
framed.append(bottom(width))
framed.append(bottom(width, stops))
return Group(*framed[:height])
@@ -139,14 +160,14 @@ class FrameTop(Widget):
self.title = title
def render(self) -> RenderableType:
return top(max(2, self.size.width), self.title)
return top(max(2, self.size.width), self.title, active_stops(self.app))
class FrameBottom(Widget):
can_focus = False
def render(self) -> RenderableType:
return bottom(max(2, self.size.width))
return bottom(max(2, self.size.width), active_stops(self.app))
class FrameSide(Widget):
@@ -158,6 +179,7 @@ class FrameSide(Widget):
def render(self) -> RenderableType:
rows = max(1, self.size.height)
stops = active_stops(self.app)
return Group(
*[side_row(row, rows, self.side) for row in range(rows)]
*[side_row(row, rows, self.side, stops) for row in range(rows)]
)