py-sparcli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py_sparcli-0.3.0.dist-info/METADATA +257 -0
- py_sparcli-0.3.0.dist-info/RECORD +61 -0
- py_sparcli-0.3.0.dist-info/WHEEL +4 -0
- py_sparcli-0.3.0.dist-info/licenses/LICENSE +21 -0
- sparcli/__init__.py +184 -0
- sparcli/core/__init__.py +92 -0
- sparcli/core/border.py +160 -0
- sparcli/core/color.py +306 -0
- sparcli/core/cursor.py +59 -0
- sparcli/core/geometry.py +141 -0
- sparcli/core/markup.py +215 -0
- sparcli/core/render.py +245 -0
- sparcli/core/style.py +128 -0
- sparcli/core/terminal.py +127 -0
- sparcli/core/text.py +143 -0
- sparcli/core/theme.py +117 -0
- sparcli/core/width.py +244 -0
- sparcli/errors.py +38 -0
- sparcli/input/__init__.py +42 -0
- sparcli/input/calc.py +214 -0
- sparcli/input/confirm.py +188 -0
- sparcli/input/datepicker.py +298 -0
- sparcli/input/editor.py +189 -0
- sparcli/input/event.py +551 -0
- sparcli/input/field.py +90 -0
- sparcli/input/fuzzy.py +401 -0
- sparcli/input/guard.py +103 -0
- sparcli/input/history.py +160 -0
- sparcli/input/line_edit.py +229 -0
- sparcli/input/number.py +232 -0
- sparcli/input/outcome.py +107 -0
- sparcli/input/password.py +233 -0
- sparcli/input/prompt.py +134 -0
- sparcli/input/select.py +336 -0
- sparcli/input/shortcut.py +123 -0
- sparcli/input/text.py +564 -0
- sparcli/input/textarea.py +242 -0
- sparcli/input/validate.py +67 -0
- sparcli/output/__init__.py +56 -0
- sparcli/output/alert.py +107 -0
- sparcli/output/badge.py +82 -0
- sparcli/output/box.py +196 -0
- sparcli/output/columns.py +157 -0
- sparcli/output/compose.py +111 -0
- sparcli/output/diff.py +229 -0
- sparcli/output/kv.py +175 -0
- sparcli/output/layout.py +77 -0
- sparcli/output/list.py +274 -0
- sparcli/output/live.py +129 -0
- sparcli/output/multiprogress.py +73 -0
- sparcli/output/pager.py +83 -0
- sparcli/output/panel.py +125 -0
- sparcli/output/progress.py +233 -0
- sparcli/output/rule.py +134 -0
- sparcli/output/spinner.py +138 -0
- sparcli/output/table/__init__.py +17 -0
- sparcli/output/table/plan.py +198 -0
- sparcli/output/table/render.py +302 -0
- sparcli/output/table/table.py +351 -0
- sparcli/output/tree.py +157 -0
- sparcli/py.typed +0 -0
sparcli/core/markup.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.core.markup
|
|
3
|
+
==================
|
|
4
|
+
|
|
5
|
+
Parses lightweight inline markup such as ``[bold red]text[/]`` into rich text.
|
|
6
|
+
|
|
7
|
+
Space-separated specs inside brackets combine attributes and colors; ``on
|
|
8
|
+
<color>`` sets a background; ``[/]`` closes the most recent tag; a backtick
|
|
9
|
+
span like ```code``` gets a cyan code style; and a backslash escapes the next
|
|
10
|
+
character. The parser never fails: a bracket that names no known style or
|
|
11
|
+
attribute (such as ``array[0]``) and any malformed bracket are emitted
|
|
12
|
+
literally.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from sparcli.core.color import Color
|
|
18
|
+
from sparcli.core.render import Renderable, Rendered
|
|
19
|
+
from sparcli.core.style import Attribute, Style
|
|
20
|
+
from sparcli.core.text import Line, Span, Text
|
|
21
|
+
|
|
22
|
+
_BACKGROUND_KEYWORD = "on"
|
|
23
|
+
|
|
24
|
+
_ATTRIBUTE_TOKENS: dict[str, Attribute] = {
|
|
25
|
+
"bold": Attribute.BOLD,
|
|
26
|
+
"b": Attribute.BOLD,
|
|
27
|
+
"dim": Attribute.DIM,
|
|
28
|
+
"d": Attribute.DIM,
|
|
29
|
+
"italic": Attribute.ITALIC,
|
|
30
|
+
"i": Attribute.ITALIC,
|
|
31
|
+
"underline": Attribute.UNDERLINED,
|
|
32
|
+
"underlined": Attribute.UNDERLINED,
|
|
33
|
+
"u": Attribute.UNDERLINED,
|
|
34
|
+
"strike": Attribute.STRIKETHROUGH,
|
|
35
|
+
"strikethrough": Attribute.STRIKETHROUGH,
|
|
36
|
+
"s": Attribute.STRIKETHROUGH,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse(markup: str) -> Text:
|
|
41
|
+
"""
|
|
42
|
+
Parses markup into a :class:`~sparcli.core.text.Text`.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
markup : str
|
|
47
|
+
The markup string.
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
Text
|
|
52
|
+
The parsed rich text.
|
|
53
|
+
|
|
54
|
+
Examples
|
|
55
|
+
--------
|
|
56
|
+
>>> text = parse("[bold]hi[/]")
|
|
57
|
+
>>> text.lines[0].plain()
|
|
58
|
+
'hi'
|
|
59
|
+
"""
|
|
60
|
+
return _Parser(markup).run()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def markup_print(markup: str) -> None:
|
|
64
|
+
"""Parses ``markup`` and prints it to stdout."""
|
|
65
|
+
_AsRenderable(parse(markup)).print()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def markup_println(markup: str) -> None:
|
|
69
|
+
"""Parses ``markup``, appends a blank line and prints it to stdout."""
|
|
70
|
+
text = parse(markup)
|
|
71
|
+
text.push_line(Line())
|
|
72
|
+
_AsRenderable(text).print()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _AsRenderable(Renderable):
|
|
76
|
+
"""Adapts a parsed :class:`Text` to the :class:`Renderable` interface."""
|
|
77
|
+
|
|
78
|
+
__slots__ = ("_text",)
|
|
79
|
+
|
|
80
|
+
def __init__(self, text: Text) -> None:
|
|
81
|
+
self._text = text
|
|
82
|
+
|
|
83
|
+
def render(self, max_width: int) -> Rendered:
|
|
84
|
+
"""Returns the text's lines as a rendered block."""
|
|
85
|
+
return Rendered.from_text(self._text)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _Parser:
|
|
89
|
+
"""A small state machine turning markup into styled lines."""
|
|
90
|
+
|
|
91
|
+
__slots__ = ("_chars", "_stack", "_lines", "_spans", "_buffer", "_in_code")
|
|
92
|
+
|
|
93
|
+
def __init__(self, markup: str) -> None:
|
|
94
|
+
self._chars = markup
|
|
95
|
+
self._stack: list[Style] = [Style.new()]
|
|
96
|
+
self._lines: list[Line] = []
|
|
97
|
+
self._spans: list[Span] = []
|
|
98
|
+
self._buffer: list[str] = []
|
|
99
|
+
self._in_code = False
|
|
100
|
+
|
|
101
|
+
def run(self) -> Text:
|
|
102
|
+
"""Parses the input and returns the resulting text."""
|
|
103
|
+
index = 0
|
|
104
|
+
length = len(self._chars)
|
|
105
|
+
while index < length:
|
|
106
|
+
char = self._chars[index]
|
|
107
|
+
if char == "\\":
|
|
108
|
+
index = self._consume_escape(index)
|
|
109
|
+
elif char == "[":
|
|
110
|
+
index = self._consume_tag(index)
|
|
111
|
+
elif char == "`":
|
|
112
|
+
self._toggle_code()
|
|
113
|
+
index += 1
|
|
114
|
+
elif char == "\n":
|
|
115
|
+
self._break_line()
|
|
116
|
+
index += 1
|
|
117
|
+
else:
|
|
118
|
+
self._buffer.append(char)
|
|
119
|
+
index += 1
|
|
120
|
+
self._break_line()
|
|
121
|
+
return Text(self._lines)
|
|
122
|
+
|
|
123
|
+
def _consume_escape(self, index: int) -> int:
|
|
124
|
+
"""Appends the escaped character literally and returns the next index."""
|
|
125
|
+
if index + 1 < len(self._chars):
|
|
126
|
+
self._buffer.append(self._chars[index + 1])
|
|
127
|
+
return index + 2
|
|
128
|
+
self._buffer.append("\\")
|
|
129
|
+
return index + 1
|
|
130
|
+
|
|
131
|
+
def _consume_tag(self, index: int) -> int:
|
|
132
|
+
"""Handles a ``[...]`` tag or emits an unrecognized bracket literally."""
|
|
133
|
+
end = self._chars.find("]", index)
|
|
134
|
+
if end == -1:
|
|
135
|
+
self._buffer.append("[")
|
|
136
|
+
return index + 1
|
|
137
|
+
tag = self._chars[index + 1 : end]
|
|
138
|
+
if tag.strip() == "/":
|
|
139
|
+
self._flush_buffer()
|
|
140
|
+
if len(self._stack) > 1:
|
|
141
|
+
self._stack.pop()
|
|
142
|
+
return end + 1
|
|
143
|
+
specs = _parse_specs(tag)
|
|
144
|
+
if specs == Style.new():
|
|
145
|
+
# A closed bracket that names no known style or attribute (e.g.
|
|
146
|
+
# "array[0]") is content, not markup, so emit it literally.
|
|
147
|
+
self._buffer.append(self._chars[index : end + 1])
|
|
148
|
+
return end + 1
|
|
149
|
+
self._flush_buffer()
|
|
150
|
+
self._stack.append(self._current().patch(specs))
|
|
151
|
+
return end + 1
|
|
152
|
+
|
|
153
|
+
def _toggle_code(self) -> None:
|
|
154
|
+
"""Toggles a cyan code style on or off at the current nesting."""
|
|
155
|
+
self._flush_buffer()
|
|
156
|
+
if self._in_code:
|
|
157
|
+
if len(self._stack) > 1:
|
|
158
|
+
self._stack.pop()
|
|
159
|
+
else:
|
|
160
|
+
code = self._current().patch(Style.new().with_fg(Color.CYAN))
|
|
161
|
+
self._stack.append(code)
|
|
162
|
+
self._in_code = not self._in_code
|
|
163
|
+
|
|
164
|
+
def _break_line(self) -> None:
|
|
165
|
+
"""Flushes the buffer and starts a new line."""
|
|
166
|
+
self._flush_buffer()
|
|
167
|
+
self._lines.append(Line(self._spans))
|
|
168
|
+
self._spans = []
|
|
169
|
+
|
|
170
|
+
def _flush_buffer(self) -> None:
|
|
171
|
+
"""Emits the buffered text as a span with the current style."""
|
|
172
|
+
if not self._buffer:
|
|
173
|
+
return
|
|
174
|
+
content = "".join(self._buffer)
|
|
175
|
+
self._spans.append(Span(content=content, style=self._current()))
|
|
176
|
+
self._buffer = []
|
|
177
|
+
|
|
178
|
+
def _current(self) -> Style:
|
|
179
|
+
"""Returns the style on top of the stack."""
|
|
180
|
+
return self._stack[-1]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse_specs(tag: str) -> Style:
|
|
184
|
+
"""Parses a tag's space-separated specs into a style."""
|
|
185
|
+
style = Style.new()
|
|
186
|
+
expect_background = False
|
|
187
|
+
for token in tag.split():
|
|
188
|
+
lowered = token.lower()
|
|
189
|
+
if lowered == _BACKGROUND_KEYWORD:
|
|
190
|
+
expect_background = True
|
|
191
|
+
continue
|
|
192
|
+
if expect_background:
|
|
193
|
+
color = _parse_color(token)
|
|
194
|
+
if color is not None:
|
|
195
|
+
style = style.with_bg(color)
|
|
196
|
+
expect_background = False
|
|
197
|
+
continue
|
|
198
|
+
style = _apply_token(style, lowered, token)
|
|
199
|
+
return style
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _apply_token(style: Style, lowered: str, token: str) -> Style:
|
|
203
|
+
"""Applies a single foreground or attribute token to ``style``."""
|
|
204
|
+
attribute = _ATTRIBUTE_TOKENS.get(lowered)
|
|
205
|
+
if attribute is not None:
|
|
206
|
+
return style.add_modifier(attribute)
|
|
207
|
+
color = _parse_color(token)
|
|
208
|
+
return style.with_fg(color) if color is not None else style
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _parse_color(token: str) -> Color | None:
|
|
212
|
+
"""Parses a hex or named color token, returning ``None`` if unknown."""
|
|
213
|
+
if token.startswith("#"):
|
|
214
|
+
return Color.from_hex(token)
|
|
215
|
+
return Color.from_name(token)
|
sparcli/core/render.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.core.render
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
Defines the render model: :class:`Rendered`, :class:`Renderable` and the ANSI
|
|
6
|
+
writer.
|
|
7
|
+
|
|
8
|
+
Every widget lays itself out into a :class:`Rendered` (a list of styled lines
|
|
9
|
+
of known width) before anything reaches the terminal. Colors and attributes are
|
|
10
|
+
turned into escape codes only here, in :func:`write_rendered`, downgraded to the
|
|
11
|
+
detected :class:`~sparcli.core.terminal.ColorSupport`. Plain, unstyled text is
|
|
12
|
+
written without a single escape, which keeps piped and ``NO_COLOR`` output
|
|
13
|
+
clean.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
from sparcli.core.color import ResolvedColor
|
|
24
|
+
from sparcli.core.style import Attribute, Style
|
|
25
|
+
from sparcli.core.terminal import ColorSupport, color_support, term_width
|
|
26
|
+
from sparcli.core.text import IntoLine, Line, Span, Text, into_line
|
|
27
|
+
|
|
28
|
+
_ESC = "\x1b"
|
|
29
|
+
_RESET = f"{_ESC}[0m"
|
|
30
|
+
_OSC8_START = f"{_ESC}]8;;"
|
|
31
|
+
_OSC8_END = f"{_ESC}\\"
|
|
32
|
+
|
|
33
|
+
# Control characters (C0, DEL and C1) minus tab. Emitting these verbatim lets
|
|
34
|
+
# untrusted content inject escape sequences or corrupt the layout, so they are
|
|
35
|
+
# stripped just before text reaches the terminal.
|
|
36
|
+
_CONTROL_TABLE: dict[int, None] = {
|
|
37
|
+
code: None
|
|
38
|
+
for code in (*range(0x00, 0x20), 0x7F, *range(0x80, 0xA0))
|
|
39
|
+
if code != 0x09
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# SGR parameter for each attribute flag.
|
|
43
|
+
_ATTRIBUTE_SGR: list[tuple[Attribute, str]] = [
|
|
44
|
+
(Attribute.BOLD, "1"),
|
|
45
|
+
(Attribute.DIM, "2"),
|
|
46
|
+
(Attribute.ITALIC, "3"),
|
|
47
|
+
(Attribute.UNDERLINED, "4"),
|
|
48
|
+
(Attribute.STRIKETHROUGH, "9"),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@runtime_checkable
|
|
53
|
+
class SupportsWrite(Protocol):
|
|
54
|
+
"""A minimal text sink: anything with a ``write(str)`` method."""
|
|
55
|
+
|
|
56
|
+
def write(self, data: str, /) -> int | None:
|
|
57
|
+
"""Writes ``data`` to the sink."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Renderable(ABC):
|
|
62
|
+
"""
|
|
63
|
+
The interface every printable widget implements.
|
|
64
|
+
|
|
65
|
+
Subclasses provide :meth:`render`; :meth:`print` and :meth:`print_to` are
|
|
66
|
+
derived from it.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
__slots__ = ()
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def render(self, max_width: int) -> Rendered:
|
|
73
|
+
"""
|
|
74
|
+
Lays the widget out into at most ``max_width`` columns.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
max_width : int
|
|
79
|
+
The maximum width available, in columns.
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
Rendered
|
|
84
|
+
The laid-out block of styled lines.
|
|
85
|
+
"""
|
|
86
|
+
raise NotImplementedError
|
|
87
|
+
|
|
88
|
+
def print(self) -> None:
|
|
89
|
+
"""Renders at the terminal width and writes the result to stdout."""
|
|
90
|
+
rendered = self.render(term_width())
|
|
91
|
+
write_rendered(sys.stdout, rendered, color_support())
|
|
92
|
+
sys.stdout.flush()
|
|
93
|
+
|
|
94
|
+
def print_to(self, writer: SupportsWrite) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Renders at the terminal width and writes to ``writer``.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
writer : SupportsWrite
|
|
101
|
+
Any object with a ``write(str)`` method (a file, ``io.StringIO``).
|
|
102
|
+
"""
|
|
103
|
+
rendered = self.render(term_width())
|
|
104
|
+
write_rendered(writer, rendered, color_support())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(slots=True)
|
|
108
|
+
class Rendered(Renderable):
|
|
109
|
+
"""
|
|
110
|
+
A laid-out block of styled lines of known width.
|
|
111
|
+
|
|
112
|
+
:class:`Rendered` is both the output of :meth:`Renderable.render` and the
|
|
113
|
+
composable unit used to stack and place widgets next to each other. It is
|
|
114
|
+
itself a :class:`Renderable`, so a pre-built block can be printed directly.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
lines: list[Line] = field(default_factory=list)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def empty(cls) -> Rendered:
|
|
121
|
+
"""Returns an empty rendered block."""
|
|
122
|
+
return cls([])
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def from_text(cls, text: Text) -> Rendered:
|
|
126
|
+
"""Returns a rendered block from a :class:`Text`'s lines."""
|
|
127
|
+
return cls(list(text.lines))
|
|
128
|
+
|
|
129
|
+
def render(self, max_width: int) -> Rendered:
|
|
130
|
+
"""Returns a copy of this block, ignoring ``max_width``."""
|
|
131
|
+
return Rendered(list(self.lines))
|
|
132
|
+
|
|
133
|
+
def push(self, line: IntoLine) -> None:
|
|
134
|
+
"""Appends a line to the block."""
|
|
135
|
+
self.lines.append(into_line(line))
|
|
136
|
+
|
|
137
|
+
def width(self) -> int:
|
|
138
|
+
"""Returns the width of the widest line in columns."""
|
|
139
|
+
return max((line.width() for line in self.lines), default=0)
|
|
140
|
+
|
|
141
|
+
def height(self) -> int:
|
|
142
|
+
"""Returns the number of lines."""
|
|
143
|
+
return len(self.lines)
|
|
144
|
+
|
|
145
|
+
def plain(self) -> str:
|
|
146
|
+
"""Returns the block's text without styling, lines joined by ``\\n``."""
|
|
147
|
+
return "\n".join(line.plain() for line in self.lines)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def write_rendered(
|
|
151
|
+
writer: SupportsWrite, rendered: Rendered, support: ColorSupport
|
|
152
|
+
) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Writes a :class:`Rendered` to ``writer`` as ANSI, one line per row.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
writer : SupportsWrite
|
|
159
|
+
The text sink to write to.
|
|
160
|
+
rendered : Rendered
|
|
161
|
+
The block to write.
|
|
162
|
+
support : ColorSupport
|
|
163
|
+
The color depth to emit.
|
|
164
|
+
"""
|
|
165
|
+
for line in rendered.lines:
|
|
166
|
+
write_line(writer, line, support)
|
|
167
|
+
writer.write("\n")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def write_line(
|
|
171
|
+
writer: SupportsWrite, line: Line, support: ColorSupport
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Writes a single styled line to ``writer`` without a trailing newline."""
|
|
174
|
+
for span in line.spans:
|
|
175
|
+
writer.write(_render_span(span, support))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _render_span(span: Span, support: ColorSupport) -> str:
|
|
179
|
+
"""Returns the escaped string for a single span at a support level."""
|
|
180
|
+
content = _sanitize(span.content)
|
|
181
|
+
if support is ColorSupport.NONE:
|
|
182
|
+
return content
|
|
183
|
+
codes = _sgr_codes(span.style, support)
|
|
184
|
+
content = _wrap_link(content, span.link)
|
|
185
|
+
if not codes:
|
|
186
|
+
return content
|
|
187
|
+
prefix = f"{_ESC}[{';'.join(codes)}m"
|
|
188
|
+
return f"{prefix}{content}{_RESET}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _sanitize(text: str) -> str:
|
|
192
|
+
"""Strips control characters (except tab) that would corrupt output."""
|
|
193
|
+
return text.translate(_CONTROL_TABLE)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _sgr_codes(style: Style, support: ColorSupport) -> list[str]:
|
|
197
|
+
"""Returns the SGR parameters for a style, empty when nothing is styled."""
|
|
198
|
+
codes: list[str] = []
|
|
199
|
+
if style.fg is not None:
|
|
200
|
+
resolved = style.fg.resolve(support)
|
|
201
|
+
if resolved is not None:
|
|
202
|
+
codes.append(_color_sgr(resolved, background=False))
|
|
203
|
+
if style.bg is not None:
|
|
204
|
+
resolved = style.bg.resolve(support)
|
|
205
|
+
if resolved is not None:
|
|
206
|
+
codes.append(_color_sgr(resolved, background=True))
|
|
207
|
+
for attribute, code in _ATTRIBUTE_SGR:
|
|
208
|
+
if style.attrs.contains(attribute):
|
|
209
|
+
codes.append(code)
|
|
210
|
+
return codes
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _color_sgr(resolved: ResolvedColor, *, background: bool) -> str:
|
|
214
|
+
"""Builds the SGR fragment for a resolved color."""
|
|
215
|
+
lead = "48" if background else "38"
|
|
216
|
+
match resolved:
|
|
217
|
+
case ("reset",):
|
|
218
|
+
return "49" if background else "39"
|
|
219
|
+
case ("ansi16", index):
|
|
220
|
+
return _ansi16_sgr(index, background=background)
|
|
221
|
+
case ("rgb", red, green, blue):
|
|
222
|
+
return f"{lead};2;{red};{green};{blue}"
|
|
223
|
+
case ("indexed", palette_index):
|
|
224
|
+
return f"{lead};5;{palette_index}"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _ansi16_sgr(index: int, *, background: bool) -> str:
|
|
228
|
+
"""Builds the SGR fragment for a 16-color palette index."""
|
|
229
|
+
if index < 8:
|
|
230
|
+
base = 40 if background else 30
|
|
231
|
+
return str(base + index)
|
|
232
|
+
base = 100 if background else 90
|
|
233
|
+
return str(base + index - 8)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _wrap_link(content: str, link: str | None) -> str:
|
|
237
|
+
"""Wraps ``content`` in an OSC-8 hyperlink when a link is present.
|
|
238
|
+
|
|
239
|
+
The URL is scrubbed of control characters so a crafted link cannot
|
|
240
|
+
terminate the OSC-8 sequence early and inject its own escapes.
|
|
241
|
+
"""
|
|
242
|
+
if link is None:
|
|
243
|
+
return content
|
|
244
|
+
safe = _sanitize(link)
|
|
245
|
+
return f"{_OSC8_START}{safe}{_OSC8_END}{content}{_OSC8_START}{_OSC8_END}"
|
sparcli/core/style.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.core.style
|
|
3
|
+
==================
|
|
4
|
+
|
|
5
|
+
Defines text styling: attributes and the :class:`Style` value type.
|
|
6
|
+
|
|
7
|
+
A :class:`Style` bundles an optional foreground and background color with a set
|
|
8
|
+
of :class:`Attribute` flags (bold, dim, italic, ...). Styles are immutable;
|
|
9
|
+
builder methods return a new style, and :meth:`Style.patch` layers one style on
|
|
10
|
+
top of another.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import enum
|
|
16
|
+
from dataclasses import dataclass, replace
|
|
17
|
+
|
|
18
|
+
from sparcli.core.color import Color
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Attribute(enum.IntFlag):
|
|
22
|
+
"""Bitflags for text attributes; combine with ``|``."""
|
|
23
|
+
|
|
24
|
+
NONE = 0
|
|
25
|
+
BOLD = enum.auto()
|
|
26
|
+
DIM = enum.auto()
|
|
27
|
+
ITALIC = enum.auto()
|
|
28
|
+
UNDERLINED = enum.auto()
|
|
29
|
+
STRIKETHROUGH = enum.auto()
|
|
30
|
+
|
|
31
|
+
def contains(self, other: Attribute) -> bool:
|
|
32
|
+
"""Returns whether every flag in ``other`` is set in ``self``."""
|
|
33
|
+
return (self & other) == other
|
|
34
|
+
|
|
35
|
+
def is_empty(self) -> bool:
|
|
36
|
+
"""Returns whether no attribute flag is set."""
|
|
37
|
+
return self == Attribute.NONE
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ratatui-familiar alias for the attribute flags.
|
|
41
|
+
Modifier = Attribute
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class Style:
|
|
46
|
+
"""
|
|
47
|
+
An immutable text style: foreground, background and attribute flags.
|
|
48
|
+
|
|
49
|
+
Attributes
|
|
50
|
+
----------
|
|
51
|
+
fg : Color | None
|
|
52
|
+
Foreground color, or ``None`` to inherit.
|
|
53
|
+
bg : Color | None
|
|
54
|
+
Background color, or ``None`` to inherit.
|
|
55
|
+
attrs : Attribute
|
|
56
|
+
The set of active attribute flags.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
fg: Color | None = None
|
|
60
|
+
bg: Color | None = None
|
|
61
|
+
attrs: Attribute = Attribute.NONE
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def new(cls) -> Style:
|
|
65
|
+
"""Returns an empty style with no color and no attributes."""
|
|
66
|
+
return cls()
|
|
67
|
+
|
|
68
|
+
def with_fg(self, color: Color) -> Style:
|
|
69
|
+
"""Returns a copy with the foreground color set."""
|
|
70
|
+
return replace(self, fg=color)
|
|
71
|
+
|
|
72
|
+
def with_bg(self, color: Color) -> Style:
|
|
73
|
+
"""Returns a copy with the background color set."""
|
|
74
|
+
return replace(self, bg=color)
|
|
75
|
+
|
|
76
|
+
def add_modifier(self, attr: Attribute) -> Style:
|
|
77
|
+
"""Returns a copy with ``attr`` added to the attribute flags."""
|
|
78
|
+
return replace(self, attrs=self.attrs | attr)
|
|
79
|
+
|
|
80
|
+
def remove_modifier(self, attr: Attribute) -> Style:
|
|
81
|
+
"""Returns a copy with ``attr`` cleared from the attribute flags."""
|
|
82
|
+
return replace(self, attrs=self.attrs & ~attr)
|
|
83
|
+
|
|
84
|
+
def bold(self) -> Style:
|
|
85
|
+
"""Returns a copy with the bold attribute set."""
|
|
86
|
+
return self.add_modifier(Attribute.BOLD)
|
|
87
|
+
|
|
88
|
+
def dim(self) -> Style:
|
|
89
|
+
"""Returns a copy with the dim attribute set."""
|
|
90
|
+
return self.add_modifier(Attribute.DIM)
|
|
91
|
+
|
|
92
|
+
def italic(self) -> Style:
|
|
93
|
+
"""Returns a copy with the italic attribute set."""
|
|
94
|
+
return self.add_modifier(Attribute.ITALIC)
|
|
95
|
+
|
|
96
|
+
def underlined(self) -> Style:
|
|
97
|
+
"""Returns a copy with the underlined attribute set."""
|
|
98
|
+
return self.add_modifier(Attribute.UNDERLINED)
|
|
99
|
+
|
|
100
|
+
def strikethrough(self) -> Style:
|
|
101
|
+
"""Returns a copy with the strikethrough attribute set."""
|
|
102
|
+
return self.add_modifier(Attribute.STRIKETHROUGH)
|
|
103
|
+
|
|
104
|
+
def patch(self, other: Style) -> Style:
|
|
105
|
+
"""
|
|
106
|
+
Layers ``other`` on top of this style.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
other : Style
|
|
111
|
+
The overriding style. Its set colors win; attribute flags from both
|
|
112
|
+
styles are combined.
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
Style
|
|
117
|
+
The merged style.
|
|
118
|
+
"""
|
|
119
|
+
return Style(
|
|
120
|
+
fg=other.fg if other.fg is not None else self.fg,
|
|
121
|
+
bg=other.bg if other.bg is not None else self.bg,
|
|
122
|
+
attrs=self.attrs | other.attrs,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_color(cls, color: Color) -> Style:
|
|
127
|
+
"""Returns a style whose foreground is ``color``."""
|
|
128
|
+
return cls(fg=color)
|