# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Measure a line of terminal text, and compose one of a stated width.
A terminal is a grid of cells, and everything a CLI draws on it has to agree on
how many cells a string occupies. That number is not the string's length: an
ideograph takes two cells and a combining mark none. {func}`cell_width` answers
it once, and every line this package composes is built on that answer.
The module holds the composition helpers built on it, starting with
{func}`center_in_rule`. They are terminal-text primitives, not capture
machinery: {mod}`click_extra.screenshot` consumes them to lay a picture out on
the same grid, and so can any CLI drawing a divider of its own.
"""
from __future__ import annotations
from unicodedata import bidirectional
from click import style
from wcwidth import width as wcwidth_width, wrap as wcwidth_wrap
from .styling import Style, split_ansi
PADDING = " \N{NO-BREAK SPACE}"
"""Characters that separate one column of terminal text from the next.
Both are one cell wide and draw nothing.
{func}`~click_extra.screenshot.render_svg` emits every space as a non-breaking
one, so the padding survives an XML round-trip and no renderer collapses a run
of them.
"""
RULE_GLYPH = "\N{BOX DRAWINGS LIGHT HORIZONTAL}"
"""Character {func}`center_in_rule` draws a rule with, absent a better one.
An unbroken line, which is what a divider between two whole things is.
"""
RULE_COLOR = "bright_black"
"""Color {func}`center_in_rule` paints a rule and its brackets.
A rule is the one line of a screen nothing printed, so it is drawn to recede:
dimmer than the text it separates, in both the gallery of `click-extra themes`
and the marker standing in for what a capture cut. A rule a caller spells out
in full is written as given, color included, since a caller naming one has
already decided how it should look.
"""
RESET = "\x1b[0m"
"""Escape closing every style a line left open, written before any padding."""
[docs]
def cell_width(text: str, term_program: str | None = None) -> int:
"""Columns `text` occupies on a terminal's character grid.
Not its length. A CJK ideograph is drawn two cells wide, a combining mark
none, an ANSI escape or an OSC 8 hyperlink none at all while adding several
characters, and a tab as many as it takes to reach the next stop eight
columns along. `wcwidth.width` answers for all of them, which is what lets a
caller measure text as it arrived rather than stripping it first.
```{note}
`wcwidth.wcswidth`, the older call, refuses any string carrying a control
character and answers `-1` for it, which is every styled string. Reach for
it only where a consumer reads that value back, as `tabulate` does.
```
:param text: the text to measure.
:param term_program: name of the terminal drawing the text, as
`$TERM_PROGRAM` holds it. A few of them advance an emoji-presentation
sequence by one column where the Unicode tables say two, and naming the
terminal applies `wcwidth`'s correction for it. `None` measures what the
tables say.
:return: the number of cells it occupies, never negative.
"""
return wcwidth_width(text, term_program=term_program or False)
[docs]
def pad_to(text: str, width: int) -> str:
"""Pad `text` with blanks until it occupies `width` cells.
{meth}`str.ljust` counts characters, so on a styled line it counts the
escapes it cannot see and pads too little, or nothing at all. Text already
at least `width` cells wide comes back untouched.
Any style the text left open is closed before the blanks, so a background
cannot bleed across the gap into whatever sits beside it.
:param text: the text to pad, styled or not.
:param width: cells the result occupies.
:return: the padded text.
"""
gap = width - cell_width(text)
if gap <= 0:
return text
reset = RESET if "\x1b[" in text and not text.endswith(RESET) else ""
return f"{text}{reset}{' ' * gap}"
[docs]
def center_in_rule(
label: str | None,
width: int,
rule: str = RULE_GLYPH,
opening: str = "[ ",
closing: str = " ]",
color: str | None = RULE_COLOR,
) -> str:
"""One line of `width` cells: `label` centered in a rule drawn with `rule`.
`label` may arrive already styled: {func}`cell_width` discounts its
escapes, so a caller paints the label its own way and hands the whole
thing over. `color` paints the rule and the two brackets, which is the half
a caller cannot pre-style without knowing where they fall.
A `None` or empty label draws no brackets and one contiguous rule: a
divider naming nothing should not look like a frame around nothing. A width
too narrow for the brackets drops them the same way, and one too narrow for
the label leaves the label alone rather than drawing a rule that cannot
close.
Measured in cells, not characters: a label carrying a wide glyph shifts a
rule built on `len` by one column per glyph.
:param label: the text the rule is drawn around, styled or not, or `None`
for an unbroken rule.
:param width: columns the line occupies.
:param rule: character the rule is drawn with.
:param opening: bracket written between the rule and `label`.
:param closing: bracket written between `label` and the rule.
:param color: color the rule and brackets are painted, or `None` to leave
them as they are. `label` is never repainted: a caller styles it
itself, or leaves it in the terminal's own ink.
:return: the whole line.
"""
label = label or ""
if not cell_width(label):
opening = closing = ""
framed = cell_width(label) + cell_width(opening) + cell_width(closing)
if framed > width:
opening = closing = ""
framed = cell_width(label)
padding = max(width - framed, 0)
left = padding // 2
def paint(text: str) -> str:
return style(text, fg=color) if color and text else text
return f"{paint(rule * left + opening)}{label}{paint(closing + rule * (padding - left))}"
LINE_NUMBER_SEPARATOR = " β "
"""Rule drawn between a line's number and the line itself.
A vertical bar rather than a bare space, so the gutter reads as a column of its
own even where the output is itself indented.
"""
RTL_BIDI_CLASSES = frozenset({"R", "AL", "AN"})
"""Unicode bidirectional classes written right to left.
Right-to-left letters, Arabic letters and Arabic-Indic numbers, as
{func}`unicodedata.bidirectional` names them. See {func}`is_bidirectional`.
"""
[docs]
def number_lines(text: str, start: int = 1) -> str:
"""Prefix each line of `text` with its number, in a dim gutter.
The numbers are drawn into the terminal text rather than into a column
beside it, which is the same trade Pygments makes with its inline line
numbers: every renderer places them for free, and every reader copying the
text copies them too.
Right-aligned on the widest number, so the gutter is one column whatever the
output's length, and separated by {data}`LINE_NUMBER_SEPARATOR`.
:param text: the text to number, ANSI escape sequences included.
:param start: number given to the first line.
:return: the numbered text.
"""
lines = text.splitlines()
if not lines:
return text
width = len(str(start + len(lines) - 1))
gutter = (
f"{style(str(number).rjust(width), dim=True)}"
f"{style(LINE_NUMBER_SEPARATOR, dim=True)}"
for number in range(start, start + len(lines))
)
return "\n".join(f"{prefix}{line}" for prefix, line in zip(gutter, lines))
[docs]
def is_bidirectional(text: str) -> bool:
"""Whether `text` carries a character written right to left.
Arabic, Hebrew and their neighbours are reordered by whoever draws them, and
the cursive ones are shaped: a letter's form depends on what it joins. A
terminal grid describes neither, which is why
{func}`~click_extra.screenshot.render_svg` stops pinning such a run to an
exact width.
:param text: the text to inspect.
:return: `True` when at least one character is right-to-left.
"""
return any(bidirectional(char) in RTL_BIDI_CLASSES for char in text)
def _char_width(char: str) -> int:
"""Cells one character occupies, cached.
{func}`grid` measures every character one at a time, and terminal output
draws from a small alphabet, so the cache turns the repeated width-table
walks of {func}`cell_width` into dict hits.
"""
return cell_width(char)
[docs]
def fit_columns(text: str, floor: int = 0) -> int:
"""Width, in characters, of the longest line in `text`.
ANSI escapes style the glyphs around them and occupy no cell of their own,
so they are discounted. Measured in terminal cells, so a line of CJK asks
for the two columns per glyph it is drawn with.
:param text: the text to measure, ANSI escape sequences included.
:param floor: width to return when every line is narrower than it. A caller
laying the text out somewhere with a minimum of its own states that
minimum here; the default floors at nothing.
:return: the width laying every line out without folding any.
"""
return max(
[floor, *(cell_width(line) for line in text.splitlines())],
)
[docs]
def grid(text: str, columns: int) -> list[list[tuple[Style, str, int]]]:
"""Lay ANSI text out on a terminal's character grid.
Where a stream of styled text stops being a stream and becomes a picture.
Each styled run of {func}`~click_extra.styling.split_ansi` is split at
newlines into rows, then placed on the column it starts at, measured in
cells rather than characters so a wide glyph takes the two it is drawn
with.
A line reaching past `columns` soft-wraps onto the next row, the way it would
on a terminal that narrow, rather than being cropped: a command is free to
print a line it never wraps itself (a long URL, a wide table, a
machine-readable dump), and a layout that silently swallowed the overflow
would be lying about what ran. A glyph straddling the edge moves down whole.
Returning the column with each run is what lets a renderer place a run
without measuring anything back out of its own output.
:param text: the text to lay out, ANSI escape sequences included.
:param columns: width of the grid, in cells.
:return: one list of `(style, text, column)` runs per row.
"""
rows: list[list[tuple[Style, str, int]]] = [[]]
column = 0
for run_style, run in split_ansi(text):
for index, line in enumerate(run.split("\n")):
if index:
rows.append([])
column = 0
if not line:
continue
kept: list[str] = []
start = column
for char in line:
size = _char_width(char)
# `and column` keeps a glyph wider than the whole grid on the
# row it started, instead of wrapping forever onto empty ones.
if column + size > columns and column:
if kept:
rows[-1].append((run_style, "".join(kept), start))
kept = []
rows.append([])
column = start = 0
kept.append(char)
column += size
if kept:
rows[-1].append((run_style, "".join(kept), start))
return rows
[docs]
def wrap_ansi(text: str, width: int) -> list[str]:
"""Wrap *text* to *width* terminal cells, preserving its ANSI styling.
{func}`textwrap.wrap` counts every byte of an ANSI escape toward the line
length, so a styled string wraps far earlier than its visible width
warrants. `wcwidth.wrap` measures an escape at no cells and every
character between escapes at the width a terminal advances by. It reopens
on each line the styling still in effect, so no escape sequence crosses a
line boundary: each returned line carries the styling it needs, opened and
closed within the line.
Returns a list of lines, empty *text* yielding a single empty one.
```{note}
Breaks land where {func}`textwrap.wrap` puts them on plain ASCII, so
long-word breaking and whitespace handling match it exactly. The two
measures part on a double-width character, which counts for the two cells
it takes, and on an OSC 8 hyperlink, which counts for none.
```
"""
# Tab expansion would change the visible width of the text. Disabled,
# `replace_whitespace` still substitutes a single space for each whitespace
# character, which preserves it.
return wcwidth_wrap(text, width, expand_tabs=False) or [""]