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/border.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.core.border
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
Defines border styles and their glyph sets.
|
|
6
|
+
|
|
7
|
+
:class:`BorderType` selects a look (rounded, single, double, thick, ASCII or
|
|
8
|
+
none); :meth:`BorderType.chars` returns the concrete :class:`BorderChars` glyph
|
|
9
|
+
set used to draw boxes, tables and rules. The ASCII set is the fallback for
|
|
10
|
+
terminals without Unicode line-drawing support.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import enum
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class BorderChars:
|
|
21
|
+
"""
|
|
22
|
+
The glyphs used to draw a border.
|
|
23
|
+
|
|
24
|
+
Attributes
|
|
25
|
+
----------
|
|
26
|
+
top_left, top_right, bottom_left, bottom_right : str
|
|
27
|
+
Corner glyphs.
|
|
28
|
+
horizontal, vertical : str
|
|
29
|
+
Edge glyphs.
|
|
30
|
+
cross : str
|
|
31
|
+
The four-way junction glyph.
|
|
32
|
+
tee_down, tee_up, tee_right, tee_left : str
|
|
33
|
+
T-junction glyphs used by tables.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
top_left: str
|
|
37
|
+
top_right: str
|
|
38
|
+
bottom_left: str
|
|
39
|
+
bottom_right: str
|
|
40
|
+
horizontal: str
|
|
41
|
+
vertical: str
|
|
42
|
+
cross: str
|
|
43
|
+
tee_down: str
|
|
44
|
+
tee_up: str
|
|
45
|
+
tee_right: str
|
|
46
|
+
tee_left: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_SPACE = BorderChars(
|
|
50
|
+
top_left=" ",
|
|
51
|
+
top_right=" ",
|
|
52
|
+
bottom_left=" ",
|
|
53
|
+
bottom_right=" ",
|
|
54
|
+
horizontal=" ",
|
|
55
|
+
vertical=" ",
|
|
56
|
+
cross=" ",
|
|
57
|
+
tee_down=" ",
|
|
58
|
+
tee_up=" ",
|
|
59
|
+
tee_right=" ",
|
|
60
|
+
tee_left=" ",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_ASCII = BorderChars(
|
|
64
|
+
top_left="+",
|
|
65
|
+
top_right="+",
|
|
66
|
+
bottom_left="+",
|
|
67
|
+
bottom_right="+",
|
|
68
|
+
horizontal="-",
|
|
69
|
+
vertical="|",
|
|
70
|
+
cross="+",
|
|
71
|
+
tee_down="+",
|
|
72
|
+
tee_up="+",
|
|
73
|
+
tee_right="+",
|
|
74
|
+
tee_left="+",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
_SINGLE = BorderChars(
|
|
78
|
+
top_left="┌",
|
|
79
|
+
top_right="┐",
|
|
80
|
+
bottom_left="└",
|
|
81
|
+
bottom_right="┘",
|
|
82
|
+
horizontal="─",
|
|
83
|
+
vertical="│",
|
|
84
|
+
cross="┼",
|
|
85
|
+
tee_down="┬",
|
|
86
|
+
tee_up="┴",
|
|
87
|
+
tee_right="├",
|
|
88
|
+
tee_left="┤",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
_DOUBLE = BorderChars(
|
|
92
|
+
top_left="╔",
|
|
93
|
+
top_right="╗",
|
|
94
|
+
bottom_left="╚",
|
|
95
|
+
bottom_right="╝",
|
|
96
|
+
horizontal="═",
|
|
97
|
+
vertical="║",
|
|
98
|
+
cross="╬",
|
|
99
|
+
tee_down="╦",
|
|
100
|
+
tee_up="╩",
|
|
101
|
+
tee_right="╠",
|
|
102
|
+
tee_left="╣",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
_ROUNDED = BorderChars(
|
|
106
|
+
top_left="╭",
|
|
107
|
+
top_right="╮",
|
|
108
|
+
bottom_left="╰",
|
|
109
|
+
bottom_right="╯",
|
|
110
|
+
horizontal="─",
|
|
111
|
+
vertical="│",
|
|
112
|
+
cross="┼",
|
|
113
|
+
tee_down="┬",
|
|
114
|
+
tee_up="┴",
|
|
115
|
+
tee_right="├",
|
|
116
|
+
tee_left="┤",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
_THICK = BorderChars(
|
|
120
|
+
top_left="┏",
|
|
121
|
+
top_right="┓",
|
|
122
|
+
bottom_left="┗",
|
|
123
|
+
bottom_right="┛",
|
|
124
|
+
horizontal="━",
|
|
125
|
+
vertical="┃",
|
|
126
|
+
cross="╋",
|
|
127
|
+
tee_down="┳",
|
|
128
|
+
tee_up="┻",
|
|
129
|
+
tee_right="┣",
|
|
130
|
+
tee_left="┫",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class BorderType(enum.Enum):
|
|
135
|
+
"""A border look; :meth:`chars` returns its glyph set."""
|
|
136
|
+
|
|
137
|
+
NONE = enum.auto()
|
|
138
|
+
ASCII = enum.auto()
|
|
139
|
+
SINGLE = enum.auto()
|
|
140
|
+
DOUBLE = enum.auto()
|
|
141
|
+
ROUNDED = enum.auto()
|
|
142
|
+
THICK = enum.auto()
|
|
143
|
+
|
|
144
|
+
def chars(self) -> BorderChars:
|
|
145
|
+
"""Returns the concrete glyph set for this border type."""
|
|
146
|
+
return _BORDER_CHARS[self]
|
|
147
|
+
|
|
148
|
+
def is_none(self) -> bool:
|
|
149
|
+
"""Returns whether this border type draws nothing."""
|
|
150
|
+
return self is BorderType.NONE
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_BORDER_CHARS: dict[BorderType, BorderChars] = {
|
|
154
|
+
BorderType.NONE: _SPACE,
|
|
155
|
+
BorderType.ASCII: _ASCII,
|
|
156
|
+
BorderType.SINGLE: _SINGLE,
|
|
157
|
+
BorderType.DOUBLE: _DOUBLE,
|
|
158
|
+
BorderType.ROUNDED: _ROUNDED,
|
|
159
|
+
BorderType.THICK: _THICK,
|
|
160
|
+
}
|
sparcli/core/color.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.core.color
|
|
3
|
+
==================
|
|
4
|
+
|
|
5
|
+
Defines the :class:`Color` type and its downgrade to lower color depths.
|
|
6
|
+
|
|
7
|
+
Colors are authored abstractly (named ANSI colors, 24-bit RGB or 256-color
|
|
8
|
+
indices) and only turned into concrete escape codes at render time via
|
|
9
|
+
:meth:`Color.resolve`, which downgrades to the available
|
|
10
|
+
:class:`~sparcli.core.terminal.ColorSupport`. The named-color set mirrors the
|
|
11
|
+
Rust original, including the quirk that ``GRAY`` is standard ANSI 7 and
|
|
12
|
+
``WHITE`` is bright ANSI 15.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import enum
|
|
18
|
+
from typing import ClassVar, Literal
|
|
19
|
+
|
|
20
|
+
from sparcli.core.terminal import ColorSupport
|
|
21
|
+
|
|
22
|
+
# Downgrade thresholds for mapping an RGB channel onto the 16-color palette.
|
|
23
|
+
_CHANNEL_ON = 0x60
|
|
24
|
+
_CHANNEL_BRIGHT = 0xC0
|
|
25
|
+
|
|
26
|
+
# 256-color index boundaries used when collapsing to the 16-color palette.
|
|
27
|
+
_GRAYSCALE_LIGHT = 244
|
|
28
|
+
_GRAYSCALE_DARK = 232
|
|
29
|
+
|
|
30
|
+
# Resolved-color shapes handed to the renderer, tagged by their first element.
|
|
31
|
+
ResetColor = tuple[Literal["reset"]]
|
|
32
|
+
Ansi16Color = tuple[Literal["ansi16"], int]
|
|
33
|
+
RgbColor = tuple[Literal["rgb"], int, int, int]
|
|
34
|
+
IndexedColor = tuple[Literal["indexed"], int]
|
|
35
|
+
ResolvedColor = ResetColor | Ansi16Color | RgbColor | IndexedColor
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ColorName(enum.Enum):
|
|
39
|
+
"""The named ANSI colors, including reset and the eight bright variants."""
|
|
40
|
+
|
|
41
|
+
RESET = enum.auto()
|
|
42
|
+
BLACK = enum.auto()
|
|
43
|
+
RED = enum.auto()
|
|
44
|
+
GREEN = enum.auto()
|
|
45
|
+
YELLOW = enum.auto()
|
|
46
|
+
BLUE = enum.auto()
|
|
47
|
+
MAGENTA = enum.auto()
|
|
48
|
+
CYAN = enum.auto()
|
|
49
|
+
GRAY = enum.auto()
|
|
50
|
+
DARK_GRAY = enum.auto()
|
|
51
|
+
LIGHT_RED = enum.auto()
|
|
52
|
+
LIGHT_GREEN = enum.auto()
|
|
53
|
+
LIGHT_YELLOW = enum.auto()
|
|
54
|
+
LIGHT_BLUE = enum.auto()
|
|
55
|
+
LIGHT_MAGENTA = enum.auto()
|
|
56
|
+
LIGHT_CYAN = enum.auto()
|
|
57
|
+
WHITE = enum.auto()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Each named color's index in the standard 16-color palette (0..15).
|
|
61
|
+
_ANSI16_INDEX: dict[ColorName, int] = {
|
|
62
|
+
ColorName.BLACK: 0,
|
|
63
|
+
ColorName.RED: 1,
|
|
64
|
+
ColorName.GREEN: 2,
|
|
65
|
+
ColorName.YELLOW: 3,
|
|
66
|
+
ColorName.BLUE: 4,
|
|
67
|
+
ColorName.MAGENTA: 5,
|
|
68
|
+
ColorName.CYAN: 6,
|
|
69
|
+
ColorName.GRAY: 7,
|
|
70
|
+
ColorName.DARK_GRAY: 8,
|
|
71
|
+
ColorName.LIGHT_RED: 9,
|
|
72
|
+
ColorName.LIGHT_GREEN: 10,
|
|
73
|
+
ColorName.LIGHT_YELLOW: 11,
|
|
74
|
+
ColorName.LIGHT_BLUE: 12,
|
|
75
|
+
ColorName.LIGHT_MAGENTA: 13,
|
|
76
|
+
ColorName.LIGHT_CYAN: 14,
|
|
77
|
+
ColorName.WHITE: 15,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Accepted spellings for :meth:`Color.from_name`, including common aliases.
|
|
81
|
+
_NAME_ALIASES: dict[str, ColorName] = {
|
|
82
|
+
"reset": ColorName.RESET,
|
|
83
|
+
"default": ColorName.RESET,
|
|
84
|
+
"black": ColorName.BLACK,
|
|
85
|
+
"red": ColorName.RED,
|
|
86
|
+
"green": ColorName.GREEN,
|
|
87
|
+
"yellow": ColorName.YELLOW,
|
|
88
|
+
"blue": ColorName.BLUE,
|
|
89
|
+
"magenta": ColorName.MAGENTA,
|
|
90
|
+
"purple": ColorName.MAGENTA,
|
|
91
|
+
"cyan": ColorName.CYAN,
|
|
92
|
+
"gray": ColorName.GRAY,
|
|
93
|
+
"grey": ColorName.GRAY,
|
|
94
|
+
"white": ColorName.GRAY,
|
|
95
|
+
"darkgray": ColorName.DARK_GRAY,
|
|
96
|
+
"darkgrey": ColorName.DARK_GRAY,
|
|
97
|
+
"lightred": ColorName.LIGHT_RED,
|
|
98
|
+
"lightgreen": ColorName.LIGHT_GREEN,
|
|
99
|
+
"lightyellow": ColorName.LIGHT_YELLOW,
|
|
100
|
+
"lightblue": ColorName.LIGHT_BLUE,
|
|
101
|
+
"lightmagenta": ColorName.LIGHT_MAGENTA,
|
|
102
|
+
"lightcyan": ColorName.LIGHT_CYAN,
|
|
103
|
+
"brightwhite": ColorName.WHITE,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_HEX_DIGITS = 6
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Color:
|
|
110
|
+
"""
|
|
111
|
+
A terminal color: a named ANSI color, an RGB triple or a 256-color index.
|
|
112
|
+
|
|
113
|
+
Construct named colors via the class constants (``Color.RED``), true color
|
|
114
|
+
via :meth:`rgb`, and palette colors via :meth:`indexed`. Instances are
|
|
115
|
+
immutable and compare by value.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
RESET: ClassVar[Color]
|
|
119
|
+
BLACK: ClassVar[Color]
|
|
120
|
+
RED: ClassVar[Color]
|
|
121
|
+
GREEN: ClassVar[Color]
|
|
122
|
+
YELLOW: ClassVar[Color]
|
|
123
|
+
BLUE: ClassVar[Color]
|
|
124
|
+
MAGENTA: ClassVar[Color]
|
|
125
|
+
CYAN: ClassVar[Color]
|
|
126
|
+
GRAY: ClassVar[Color]
|
|
127
|
+
DARK_GRAY: ClassVar[Color]
|
|
128
|
+
LIGHT_RED: ClassVar[Color]
|
|
129
|
+
LIGHT_GREEN: ClassVar[Color]
|
|
130
|
+
LIGHT_YELLOW: ClassVar[Color]
|
|
131
|
+
LIGHT_BLUE: ClassVar[Color]
|
|
132
|
+
LIGHT_MAGENTA: ClassVar[Color]
|
|
133
|
+
LIGHT_CYAN: ClassVar[Color]
|
|
134
|
+
WHITE: ClassVar[Color]
|
|
135
|
+
|
|
136
|
+
__slots__ = ("_name", "_rgb", "_index")
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
*,
|
|
141
|
+
name: ColorName | None = None,
|
|
142
|
+
rgb: tuple[int, int, int] | None = None,
|
|
143
|
+
index: int | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
self._name = name
|
|
146
|
+
self._rgb = rgb
|
|
147
|
+
self._index = index
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def named(cls, name: ColorName) -> Color:
|
|
151
|
+
"""Returns the color for a :class:`ColorName` member."""
|
|
152
|
+
return cls(name=name)
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def rgb(cls, red: int, green: int, blue: int) -> Color:
|
|
156
|
+
"""
|
|
157
|
+
Returns a 24-bit true color.
|
|
158
|
+
|
|
159
|
+
Parameters
|
|
160
|
+
----------
|
|
161
|
+
red, green, blue : int
|
|
162
|
+
Channel values, each clamped to the ``0..255`` range.
|
|
163
|
+
|
|
164
|
+
Returns
|
|
165
|
+
-------
|
|
166
|
+
Color
|
|
167
|
+
The RGB color.
|
|
168
|
+
"""
|
|
169
|
+
return cls(
|
|
170
|
+
rgb=(_clamp_byte(red), _clamp_byte(green), _clamp_byte(blue))
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def indexed(cls, index: int) -> Color:
|
|
175
|
+
"""Returns a 256-color palette color for ``index`` (clamped 0..255)."""
|
|
176
|
+
return cls(index=_clamp_byte(index))
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_name(cls, text: str) -> Color | None:
|
|
180
|
+
"""
|
|
181
|
+
Parses a color name (case-insensitive), returning ``None`` if unknown.
|
|
182
|
+
|
|
183
|
+
Parameters
|
|
184
|
+
----------
|
|
185
|
+
text : str
|
|
186
|
+
A color name such as ``"red"`` or an alias such as ``"purple"``.
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
Color | None
|
|
191
|
+
The matching color, or ``None`` when the name is not recognized.
|
|
192
|
+
"""
|
|
193
|
+
name = _NAME_ALIASES.get(text.strip().lower())
|
|
194
|
+
return cls(name=name) if name is not None else None
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def from_hex(cls, text: str) -> Color | None:
|
|
198
|
+
"""
|
|
199
|
+
Parses ``#rrggbb`` or ``rrggbb`` into an RGB color.
|
|
200
|
+
|
|
201
|
+
Parameters
|
|
202
|
+
----------
|
|
203
|
+
text : str
|
|
204
|
+
A six-digit hex color, optionally prefixed with ``#``.
|
|
205
|
+
|
|
206
|
+
Returns
|
|
207
|
+
-------
|
|
208
|
+
Color | None
|
|
209
|
+
The RGB color, or ``None`` when the string is malformed.
|
|
210
|
+
"""
|
|
211
|
+
digits = text.strip().removeprefix("#")
|
|
212
|
+
if len(digits) != _HEX_DIGITS:
|
|
213
|
+
return None
|
|
214
|
+
try:
|
|
215
|
+
value = int(digits, 16)
|
|
216
|
+
except ValueError:
|
|
217
|
+
return None
|
|
218
|
+
return cls.rgb(value >> 16 & 0xFF, value >> 8 & 0xFF, value & 0xFF)
|
|
219
|
+
|
|
220
|
+
def resolve(self, support: ColorSupport) -> ResolvedColor | None:
|
|
221
|
+
"""
|
|
222
|
+
Resolves the color for a given support level, downgrading if needed.
|
|
223
|
+
|
|
224
|
+
Parameters
|
|
225
|
+
----------
|
|
226
|
+
support : ColorSupport
|
|
227
|
+
The color depth the output stream can display.
|
|
228
|
+
|
|
229
|
+
Returns
|
|
230
|
+
-------
|
|
231
|
+
ResolvedColor | None
|
|
232
|
+
A tagged tuple the renderer turns into an escape code, or ``None``
|
|
233
|
+
when no color should be emitted (``ColorSupport.NONE``).
|
|
234
|
+
"""
|
|
235
|
+
if support is ColorSupport.NONE:
|
|
236
|
+
return None
|
|
237
|
+
if self._name is ColorName.RESET:
|
|
238
|
+
return ("reset",)
|
|
239
|
+
if self._name is not None:
|
|
240
|
+
return ("ansi16", _ANSI16_INDEX[self._name])
|
|
241
|
+
if self._rgb is not None:
|
|
242
|
+
if support is ColorSupport.TRUECOLOR:
|
|
243
|
+
return ("rgb", *self._rgb)
|
|
244
|
+
return ("ansi16", _nearest_ansi16(*self._rgb))
|
|
245
|
+
if self._index is not None:
|
|
246
|
+
if support is ColorSupport.TRUECOLOR:
|
|
247
|
+
return ("indexed", self._index)
|
|
248
|
+
return ("ansi16", _indexed_to_ansi16(self._index))
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
def __eq__(self, other: object) -> bool:
|
|
252
|
+
if not isinstance(other, Color):
|
|
253
|
+
return NotImplemented
|
|
254
|
+
return (
|
|
255
|
+
self._name == other._name
|
|
256
|
+
and self._rgb == other._rgb
|
|
257
|
+
and self._index == other._index
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def __hash__(self) -> int:
|
|
261
|
+
return hash((self._name, self._rgb, self._index))
|
|
262
|
+
|
|
263
|
+
def __repr__(self) -> str:
|
|
264
|
+
if self._name is not None:
|
|
265
|
+
return f"Color.{self._name.name}"
|
|
266
|
+
if self._rgb is not None:
|
|
267
|
+
return f"Color.rgb{self._rgb}"
|
|
268
|
+
return f"Color.indexed({self._index})"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _clamp_byte(value: int) -> int:
|
|
272
|
+
"""Clamps an integer into the ``0..255`` byte range."""
|
|
273
|
+
return max(0, min(255, value))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _nearest_ansi16(red: int, green: int, blue: int) -> int:
|
|
277
|
+
"""Maps an RGB triple onto the nearest 16-color palette index."""
|
|
278
|
+
base = 0
|
|
279
|
+
if red >= _CHANNEL_ON:
|
|
280
|
+
base |= 1
|
|
281
|
+
if green >= _CHANNEL_ON:
|
|
282
|
+
base |= 2
|
|
283
|
+
if blue >= _CHANNEL_ON:
|
|
284
|
+
base |= 4
|
|
285
|
+
bright = max(red, green, blue) >= _CHANNEL_BRIGHT
|
|
286
|
+
return base + 8 if bright else base
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _indexed_to_ansi16(index: int) -> int:
|
|
290
|
+
"""Collapses a 256-color index onto the 16-color palette."""
|
|
291
|
+
if index < 16:
|
|
292
|
+
return index
|
|
293
|
+
if index >= _GRAYSCALE_LIGHT:
|
|
294
|
+
return 15
|
|
295
|
+
if index >= _GRAYSCALE_DARK:
|
|
296
|
+
return 8
|
|
297
|
+
return 7
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _install_named_colors() -> None:
|
|
301
|
+
"""Attaches a class constant for every named color after class creation."""
|
|
302
|
+
for name in ColorName:
|
|
303
|
+
setattr(Color, name.name, Color(name=name))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
_install_named_colors()
|
sparcli/core/cursor.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.core.cursor
|
|
3
|
+
==================
|
|
4
|
+
|
|
5
|
+
Hides and restores the terminal hardware cursor around in-place redraws.
|
|
6
|
+
|
|
7
|
+
During spinner and progress animations, and during interactive prompts, the
|
|
8
|
+
library rewrites frames in place; a blinking hardware cursor at the end of a
|
|
9
|
+
frame is distracting, and prompts draw their own styled cursor anyway. These
|
|
10
|
+
helpers hide the cursor on the first request and restore it on the last, both
|
|
11
|
+
idempotently, and register an :mod:`atexit` handler so the cursor is always
|
|
12
|
+
shown again when the process exits (even after an unhandled exception).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import atexit
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
_HIDE = "\x1b[?25l"
|
|
21
|
+
_SHOW = "\x1b[?25h"
|
|
22
|
+
|
|
23
|
+
_hidden = False
|
|
24
|
+
_atexit_registered = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def hide() -> None:
|
|
28
|
+
"""Hides the terminal cursor once; a no-op if already hidden."""
|
|
29
|
+
global _hidden, _atexit_registered
|
|
30
|
+
if _hidden:
|
|
31
|
+
return
|
|
32
|
+
_write(_HIDE)
|
|
33
|
+
_hidden = True
|
|
34
|
+
if not _atexit_registered:
|
|
35
|
+
atexit.register(show)
|
|
36
|
+
_atexit_registered = True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def show() -> None:
|
|
40
|
+
"""Restores the terminal cursor if it was hidden; a no-op otherwise."""
|
|
41
|
+
global _hidden
|
|
42
|
+
if not _hidden:
|
|
43
|
+
return
|
|
44
|
+
_write(_SHOW)
|
|
45
|
+
_hidden = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_hidden() -> bool:
|
|
49
|
+
"""Returns whether the cursor is currently hidden by this module."""
|
|
50
|
+
return _hidden
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _write(escape: str) -> None:
|
|
54
|
+
"""Writes an escape to stdout, ignoring closed-stream errors at shutdown."""
|
|
55
|
+
try:
|
|
56
|
+
sys.stdout.write(escape)
|
|
57
|
+
sys.stdout.flush()
|
|
58
|
+
except (OSError, ValueError):
|
|
59
|
+
pass
|
sparcli/core/geometry.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.core.geometry
|
|
3
|
+
=====================
|
|
4
|
+
|
|
5
|
+
Defines layout primitives: alignment enums, box spacing and titles.
|
|
6
|
+
|
|
7
|
+
:class:`Align` and :class:`VAlign` control horizontal and vertical placement,
|
|
8
|
+
:class:`Position` selects which edge a title sits on, :class:`Edges` models
|
|
9
|
+
box-model spacing (padding/margins), and :class:`Title` is a positioned,
|
|
10
|
+
aligned label used by panels, rules and tables.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import enum
|
|
16
|
+
from dataclasses import dataclass, field, replace
|
|
17
|
+
|
|
18
|
+
from sparcli.core.style import Style
|
|
19
|
+
from sparcli.core.text import IntoText, Line, Span, Text, into_text
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Align(enum.Enum):
|
|
23
|
+
"""Horizontal alignment within an available width."""
|
|
24
|
+
|
|
25
|
+
LEFT = enum.auto()
|
|
26
|
+
CENTER = enum.auto()
|
|
27
|
+
RIGHT = enum.auto()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class VAlign(enum.Enum):
|
|
31
|
+
"""Vertical alignment within an available height."""
|
|
32
|
+
|
|
33
|
+
TOP = enum.auto()
|
|
34
|
+
MIDDLE = enum.auto()
|
|
35
|
+
BOTTOM = enum.auto()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Position(enum.Enum):
|
|
39
|
+
"""Which edge a title is placed on."""
|
|
40
|
+
|
|
41
|
+
TOP = enum.auto()
|
|
42
|
+
BOTTOM = enum.auto()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Edges:
|
|
47
|
+
"""
|
|
48
|
+
Box-model spacing for the four edges.
|
|
49
|
+
|
|
50
|
+
Attributes
|
|
51
|
+
----------
|
|
52
|
+
top, right, bottom, left : int
|
|
53
|
+
The spacing on each edge, in cells.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
top: int = 0
|
|
57
|
+
right: int = 0
|
|
58
|
+
bottom: int = 0
|
|
59
|
+
left: int = 0
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def all(cls, value: int) -> Edges:
|
|
63
|
+
"""Returns edges with the same spacing on every side."""
|
|
64
|
+
return cls(top=value, right=value, bottom=value, left=value)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def symmetric(cls, vertical: int, horizontal: int) -> Edges:
|
|
68
|
+
"""Returns edges with matching vertical and horizontal spacing."""
|
|
69
|
+
return cls(
|
|
70
|
+
top=vertical,
|
|
71
|
+
right=horizontal,
|
|
72
|
+
bottom=vertical,
|
|
73
|
+
left=horizontal,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def horizontal(self) -> int:
|
|
77
|
+
"""Returns the combined left and right spacing."""
|
|
78
|
+
return self.left + self.right
|
|
79
|
+
|
|
80
|
+
def vertical(self) -> int:
|
|
81
|
+
"""Returns the combined top and bottom spacing."""
|
|
82
|
+
return self.top + self.bottom
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(slots=True)
|
|
86
|
+
class Title:
|
|
87
|
+
"""
|
|
88
|
+
A positioned, aligned label rendered inside a border or rule.
|
|
89
|
+
|
|
90
|
+
Attributes
|
|
91
|
+
----------
|
|
92
|
+
content : Text
|
|
93
|
+
The title text.
|
|
94
|
+
align : Align
|
|
95
|
+
Horizontal alignment along the edge.
|
|
96
|
+
position : Position
|
|
97
|
+
Which edge the title sits on.
|
|
98
|
+
pad : int
|
|
99
|
+
Horizontal padding around the title text, in cells.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
content: Text = field(default_factory=Text)
|
|
103
|
+
align: Align = Align.LEFT
|
|
104
|
+
position: Position = Position.TOP
|
|
105
|
+
pad: int = 1
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def new(cls, content: IntoText) -> Title:
|
|
109
|
+
"""Returns a top-left title with default padding."""
|
|
110
|
+
return cls(content=into_text(content))
|
|
111
|
+
|
|
112
|
+
def with_align(self, align: Align) -> Title:
|
|
113
|
+
"""Returns a copy with the alignment set."""
|
|
114
|
+
return replace(self, align=align)
|
|
115
|
+
|
|
116
|
+
def with_position(self, position: Position) -> Title:
|
|
117
|
+
"""Returns a copy with the edge position set."""
|
|
118
|
+
return replace(self, position=position)
|
|
119
|
+
|
|
120
|
+
def with_pad(self, pad: int) -> Title:
|
|
121
|
+
"""Returns a copy with the horizontal padding set."""
|
|
122
|
+
return replace(self, pad=pad)
|
|
123
|
+
|
|
124
|
+
def with_style(self, style: Style) -> Title:
|
|
125
|
+
"""Returns a copy with ``style`` patched onto every span."""
|
|
126
|
+
styled = Text(
|
|
127
|
+
[
|
|
128
|
+
Line(
|
|
129
|
+
[
|
|
130
|
+
Span(
|
|
131
|
+
content=span.content,
|
|
132
|
+
style=span.style.patch(style),
|
|
133
|
+
link=span.link,
|
|
134
|
+
)
|
|
135
|
+
for span in line.spans
|
|
136
|
+
]
|
|
137
|
+
)
|
|
138
|
+
for line in self.content.lines
|
|
139
|
+
]
|
|
140
|
+
)
|
|
141
|
+
return replace(self, content=styled)
|