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/terminal.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.core.terminal
|
|
3
|
+
=====================
|
|
4
|
+
|
|
5
|
+
Detects terminal capabilities and size.
|
|
6
|
+
|
|
7
|
+
The functions here answer three questions the renderer and prompts need: how
|
|
8
|
+
wide is the terminal, is standard output (or input) an interactive TTY, and how
|
|
9
|
+
much color can be emitted. Behaviour honours the widely supported environment
|
|
10
|
+
variables ``NO_COLOR``, ``CLICOLOR_FORCE`` and ``COLORTERM``, plus the test-only
|
|
11
|
+
override ``SPARCLI_NO_TTY``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import enum
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
DEFAULT_WIDTH = 80
|
|
22
|
+
DEFAULT_HEIGHT = 24
|
|
23
|
+
|
|
24
|
+
_NO_COLOR = "NO_COLOR"
|
|
25
|
+
_CLICOLOR_FORCE = "CLICOLOR_FORCE"
|
|
26
|
+
_COLORTERM = "COLORTERM"
|
|
27
|
+
_NO_TTY_OVERRIDE = "SPARCLI_NO_TTY"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ColorSupport(enum.Enum):
|
|
31
|
+
"""How much color the current output stream can display."""
|
|
32
|
+
|
|
33
|
+
NONE = enum.auto()
|
|
34
|
+
ANSI16 = enum.auto()
|
|
35
|
+
TRUECOLOR = enum.auto()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def terminal_size() -> tuple[int, int]:
|
|
39
|
+
"""
|
|
40
|
+
Returns the terminal size as a ``(width, height)`` column/row pair.
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
tuple[int, int]
|
|
45
|
+
The terminal width and height, falling back to ``(80, 24)`` when the
|
|
46
|
+
size cannot be determined.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
size = shutil.get_terminal_size(
|
|
50
|
+
fallback=(DEFAULT_WIDTH, DEFAULT_HEIGHT)
|
|
51
|
+
)
|
|
52
|
+
except (ValueError, OSError):
|
|
53
|
+
return (DEFAULT_WIDTH, DEFAULT_HEIGHT)
|
|
54
|
+
return (size.columns, size.lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def term_width() -> int:
|
|
58
|
+
"""Returns the terminal width in columns."""
|
|
59
|
+
return terminal_size()[0]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def term_height() -> int:
|
|
63
|
+
"""Returns the terminal height in rows."""
|
|
64
|
+
return terminal_size()[1]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_output_tty() -> bool:
|
|
68
|
+
"""Returns whether standard output is an interactive terminal."""
|
|
69
|
+
return not _no_tty_override() and _isatty(sys.stdout)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_input_tty() -> bool:
|
|
73
|
+
"""
|
|
74
|
+
Returns whether both standard input and output are interactive terminals.
|
|
75
|
+
|
|
76
|
+
Prompts require input and output to be a TTY; either being redirected means
|
|
77
|
+
the prompt cannot run interactively.
|
|
78
|
+
"""
|
|
79
|
+
return not _no_tty_override() and _isatty(sys.stdin) and _isatty(sys.stdout)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def color_support() -> ColorSupport:
|
|
83
|
+
"""
|
|
84
|
+
Returns the color support level for the current output stream.
|
|
85
|
+
|
|
86
|
+
Returns
|
|
87
|
+
-------
|
|
88
|
+
ColorSupport
|
|
89
|
+
``NONE`` when color is disabled (``NO_COLOR`` or a non-TTY without
|
|
90
|
+
``CLICOLOR_FORCE``), ``TRUECOLOR`` when ``COLORTERM`` advertises 24-bit
|
|
91
|
+
color, otherwise ``ANSI16``.
|
|
92
|
+
"""
|
|
93
|
+
if _NO_COLOR in os.environ:
|
|
94
|
+
return ColorSupport.NONE
|
|
95
|
+
if not _env_enabled(_CLICOLOR_FORCE) and not is_output_tty():
|
|
96
|
+
return ColorSupport.NONE
|
|
97
|
+
if _colorterm_truecolor():
|
|
98
|
+
return ColorSupport.TRUECOLOR
|
|
99
|
+
return ColorSupport.ANSI16
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _isatty(stream: object) -> bool:
|
|
103
|
+
"""Returns whether a stream reports itself as a TTY, defensively."""
|
|
104
|
+
isatty = getattr(stream, "isatty", None)
|
|
105
|
+
if not callable(isatty):
|
|
106
|
+
return False
|
|
107
|
+
try:
|
|
108
|
+
return bool(isatty())
|
|
109
|
+
except (ValueError, OSError):
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _no_tty_override() -> bool:
|
|
114
|
+
"""Returns whether the SPARCLI_NO_TTY test override forces a non-TTY."""
|
|
115
|
+
return _env_enabled(_NO_TTY_OVERRIDE)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _env_enabled(key: str) -> bool:
|
|
119
|
+
"""Returns whether an env var is set to a non-empty value other than '0'."""
|
|
120
|
+
value = os.environ.get(key)
|
|
121
|
+
return value is not None and value != "" and value != "0"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _colorterm_truecolor() -> bool:
|
|
125
|
+
"""Returns whether COLORTERM advertises truecolor / 24-bit support."""
|
|
126
|
+
value = os.environ.get(_COLORTERM, "").lower()
|
|
127
|
+
return "truecolor" in value or "24bit" in value
|
sparcli/core/text.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.core.text
|
|
3
|
+
=================
|
|
4
|
+
|
|
5
|
+
Defines the rich-text primitives :class:`Span`, :class:`Line` and
|
|
6
|
+
:class:`Text`.
|
|
7
|
+
|
|
8
|
+
A :class:`Span` is a run of text with a single style and an optional hyperlink.
|
|
9
|
+
A :class:`Line` is a sequence of spans; a :class:`Text` is a sequence of lines.
|
|
10
|
+
Together they mirror ratatui's text vocabulary. The ``into_*`` helpers coerce
|
|
11
|
+
plain strings into these types, so widget APIs can accept either.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field, replace
|
|
17
|
+
|
|
18
|
+
from sparcli.core.style import Style
|
|
19
|
+
from sparcli.core.width import visible_width
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class Span:
|
|
24
|
+
"""
|
|
25
|
+
A styled run of text with an optional hyperlink target.
|
|
26
|
+
|
|
27
|
+
Attributes
|
|
28
|
+
----------
|
|
29
|
+
content : str
|
|
30
|
+
The text of the span.
|
|
31
|
+
style : Style
|
|
32
|
+
The style applied to the text.
|
|
33
|
+
link : str | None
|
|
34
|
+
An OSC-8 hyperlink target, or ``None``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
content: str = ""
|
|
38
|
+
style: Style = field(default_factory=Style)
|
|
39
|
+
link: str | None = None
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def raw(cls, content: str) -> Span:
|
|
43
|
+
"""Returns an unstyled span with no link."""
|
|
44
|
+
return cls(content=content)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def styled(cls, content: str, style: Style) -> Span:
|
|
48
|
+
"""Returns a span with the given style."""
|
|
49
|
+
return cls(content=content, style=style)
|
|
50
|
+
|
|
51
|
+
def with_link(self, url: str) -> Span:
|
|
52
|
+
"""Returns a copy of the span carrying an OSC-8 hyperlink."""
|
|
53
|
+
return replace(self, link=url)
|
|
54
|
+
|
|
55
|
+
def width(self) -> int:
|
|
56
|
+
"""Returns the display width of the span in columns."""
|
|
57
|
+
return visible_width(self.content)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True)
|
|
61
|
+
class Line:
|
|
62
|
+
"""A single line of text made of one or more :class:`Span` objects."""
|
|
63
|
+
|
|
64
|
+
spans: list[Span] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def raw(cls, content: str) -> Line:
|
|
68
|
+
"""Returns a line holding a single unstyled span."""
|
|
69
|
+
return cls([Span.raw(content)])
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def styled(cls, content: str, style: Style) -> Line:
|
|
73
|
+
"""Returns a line holding a single styled span."""
|
|
74
|
+
return cls([Span.styled(content, style)])
|
|
75
|
+
|
|
76
|
+
def width(self) -> int:
|
|
77
|
+
"""Returns the total display width of the line in columns."""
|
|
78
|
+
return sum(span.width() for span in self.spans)
|
|
79
|
+
|
|
80
|
+
def plain(self) -> str:
|
|
81
|
+
"""Returns the line's text without any styling."""
|
|
82
|
+
return "".join(span.content for span in self.spans)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(slots=True)
|
|
86
|
+
class Text:
|
|
87
|
+
"""A block of styled text made of zero or more :class:`Line` objects."""
|
|
88
|
+
|
|
89
|
+
lines: list[Line] = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def raw(cls, content: str) -> Text:
|
|
93
|
+
"""Returns text with ``content`` split into lines on newlines."""
|
|
94
|
+
return cls([Line.raw(part) for part in content.split("\n")])
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def styled(cls, content: str, style: Style) -> Text:
|
|
98
|
+
"""Returns styled text with ``content`` split on newlines."""
|
|
99
|
+
return cls([Line.styled(part, style) for part in content.split("\n")])
|
|
100
|
+
|
|
101
|
+
def push_line(self, line: IntoLine) -> None:
|
|
102
|
+
"""Appends a line to the text."""
|
|
103
|
+
self.lines.append(into_line(line))
|
|
104
|
+
|
|
105
|
+
def width(self) -> int:
|
|
106
|
+
"""Returns the width of the widest line in columns."""
|
|
107
|
+
return max((line.width() for line in self.lines), default=0)
|
|
108
|
+
|
|
109
|
+
def height(self) -> int:
|
|
110
|
+
"""Returns the number of lines."""
|
|
111
|
+
return len(self.lines)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
IntoSpan = str | Span
|
|
115
|
+
IntoLine = str | Span | Line
|
|
116
|
+
IntoText = str | Span | Line | Text
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def into_span(value: IntoSpan) -> Span:
|
|
120
|
+
"""Coerces a string or span into a :class:`Span`."""
|
|
121
|
+
if isinstance(value, Span):
|
|
122
|
+
return value
|
|
123
|
+
return Span.raw(value)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def into_line(value: IntoLine) -> Line:
|
|
127
|
+
"""Coerces a string, span or line into a :class:`Line`."""
|
|
128
|
+
if isinstance(value, Line):
|
|
129
|
+
return value
|
|
130
|
+
if isinstance(value, Span):
|
|
131
|
+
return Line([value])
|
|
132
|
+
return Line.raw(value)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def into_text(value: IntoText) -> Text:
|
|
136
|
+
"""Coerces a string, span, line or text into a :class:`Text`."""
|
|
137
|
+
if isinstance(value, Text):
|
|
138
|
+
return value
|
|
139
|
+
if isinstance(value, Line):
|
|
140
|
+
return Text([value])
|
|
141
|
+
if isinstance(value, Span):
|
|
142
|
+
return Text([Line([value])])
|
|
143
|
+
return Text.raw(value)
|
sparcli/core/theme.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.core.theme
|
|
3
|
+
=================
|
|
4
|
+
|
|
5
|
+
Defines the unified :class:`Theme` shared by output widgets and input prompts.
|
|
6
|
+
|
|
7
|
+
A single process-wide theme drives accent color, semantic styles (success,
|
|
8
|
+
error, ...), the default border and whether Unicode or ASCII glyphs are used.
|
|
9
|
+
Read the active theme with :func:`theme` and replace it with :func:`set_theme`;
|
|
10
|
+
per-call widget options still override individual theme values.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from sparcli.core.border import BorderType
|
|
19
|
+
from sparcli.core.color import Color
|
|
20
|
+
from sparcli.core.style import Style
|
|
21
|
+
|
|
22
|
+
DEFAULT_ACCENT = Color.rgb(137, 180, 250)
|
|
23
|
+
|
|
24
|
+
_TITLE = Style.from_color(DEFAULT_ACCENT).bold()
|
|
25
|
+
_SECONDARY = Style.new().dim()
|
|
26
|
+
_SELECTION = Style.from_color(DEFAULT_ACCENT).bold()
|
|
27
|
+
_CURSOR = Style.new().with_fg(Color.BLACK).with_bg(DEFAULT_ACCENT)
|
|
28
|
+
|
|
29
|
+
# Two-step glyphs: (unicode, ascii). ASCII is the fallback for plain terminals.
|
|
30
|
+
_BULLET = ("•", "*")
|
|
31
|
+
_CURSOR_MARKER = ("‣ ", "> ")
|
|
32
|
+
_MARKER = (" ", " ")
|
|
33
|
+
_CHECKBOX_ON = ("◉ ", "[x] ")
|
|
34
|
+
_CHECKBOX_OFF = ("◯ ", "[ ] ")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Theme:
|
|
39
|
+
"""
|
|
40
|
+
The look shared across output and input.
|
|
41
|
+
|
|
42
|
+
Attributes
|
|
43
|
+
----------
|
|
44
|
+
accent : Color
|
|
45
|
+
The single highlight color for titles, selections and active items.
|
|
46
|
+
title, heading, secondary, success, error, warning, info, debug, hint : Style
|
|
47
|
+
Semantic styles applied throughout the library.
|
|
48
|
+
selection, cursor : Style
|
|
49
|
+
Styles for the highlighted item and the text cursor.
|
|
50
|
+
border : BorderType
|
|
51
|
+
The default border look.
|
|
52
|
+
unicode : bool
|
|
53
|
+
When ``False``, glyph accessors return their ASCII fallbacks.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
accent: Color = DEFAULT_ACCENT
|
|
57
|
+
title: Style = _TITLE
|
|
58
|
+
heading: Style = _TITLE
|
|
59
|
+
secondary: Style = _SECONDARY
|
|
60
|
+
success: Style = Style.from_color(Color.GREEN)
|
|
61
|
+
error: Style = Style.from_color(Color.RED)
|
|
62
|
+
warning: Style = Style.from_color(Color.YELLOW)
|
|
63
|
+
info: Style = Style.from_color(DEFAULT_ACCENT)
|
|
64
|
+
debug: Style = Style.from_color(Color.MAGENTA)
|
|
65
|
+
hint: Style = _SECONDARY
|
|
66
|
+
selection: Style = _SELECTION
|
|
67
|
+
cursor: Style = _CURSOR
|
|
68
|
+
border: BorderType = BorderType.ROUNDED
|
|
69
|
+
unicode: bool = True
|
|
70
|
+
|
|
71
|
+
def bullet(self) -> str:
|
|
72
|
+
"""Returns the list bullet glyph."""
|
|
73
|
+
return self._glyph(_BULLET)
|
|
74
|
+
|
|
75
|
+
def cursor_marker(self) -> str:
|
|
76
|
+
"""Returns the marker shown next to the active list item."""
|
|
77
|
+
return self._glyph(_CURSOR_MARKER)
|
|
78
|
+
|
|
79
|
+
def marker(self) -> str:
|
|
80
|
+
"""Returns the blank marker aligning non-active list items."""
|
|
81
|
+
return self._glyph(_MARKER)
|
|
82
|
+
|
|
83
|
+
def checkbox_on(self) -> str:
|
|
84
|
+
"""Returns the checked checkbox glyph."""
|
|
85
|
+
return self._glyph(_CHECKBOX_ON)
|
|
86
|
+
|
|
87
|
+
def checkbox_off(self) -> str:
|
|
88
|
+
"""Returns the unchecked checkbox glyph."""
|
|
89
|
+
return self._glyph(_CHECKBOX_OFF)
|
|
90
|
+
|
|
91
|
+
def _glyph(self, pair: tuple[str, str]) -> str:
|
|
92
|
+
"""Returns the unicode or ascii glyph based on the theme setting."""
|
|
93
|
+
return pair[0] if self.unicode else pair[1]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_lock = threading.Lock()
|
|
97
|
+
_current = Theme()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def theme() -> Theme:
|
|
101
|
+
"""Returns the active process-wide theme."""
|
|
102
|
+
with _lock:
|
|
103
|
+
return _current
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def set_theme(new_theme: Theme) -> None:
|
|
107
|
+
"""
|
|
108
|
+
Replaces the active process-wide theme.
|
|
109
|
+
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
new_theme : Theme
|
|
113
|
+
The theme to install. Widget options still override individual values.
|
|
114
|
+
"""
|
|
115
|
+
global _current
|
|
116
|
+
with _lock:
|
|
117
|
+
_current = new_theme
|
sparcli/core/width.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.core.width
|
|
3
|
+
==================
|
|
4
|
+
|
|
5
|
+
Computes display width and reflows text, aware of Unicode and ANSI escapes.
|
|
6
|
+
|
|
7
|
+
Wide glyphs (CJK, many emoji) count as two columns, zero-width combining marks
|
|
8
|
+
as zero, and ANSI escape sequences as nothing. These helpers are the basis for
|
|
9
|
+
every alignment, truncation and wrapping decision in the library. Width is
|
|
10
|
+
derived from the standard library's :mod:`unicodedata`, so no third-party
|
|
11
|
+
dependency is required.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import unicodedata
|
|
17
|
+
|
|
18
|
+
_ESC = "\x1b"
|
|
19
|
+
_BELL = "\x07"
|
|
20
|
+
_CSI_FINAL_MIN = 0x40
|
|
21
|
+
_CSI_FINAL_MAX = 0x7E
|
|
22
|
+
_ZERO_WIDTH_CATEGORIES = frozenset({"Mn", "Me", "Cf"})
|
|
23
|
+
_WIDE_EAST_ASIAN = frozenset({"W", "F"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def char_width(char: str) -> int:
|
|
27
|
+
"""
|
|
28
|
+
Returns the display width of a single character in terminal columns.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
char : str
|
|
33
|
+
A single character.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
int
|
|
38
|
+
``0`` for combining or control characters, ``2`` for wide East Asian
|
|
39
|
+
glyphs, otherwise ``1``. An empty string is ``0`` and a multi-character
|
|
40
|
+
string sums its characters, so the function never raises on bad input.
|
|
41
|
+
"""
|
|
42
|
+
if len(char) != 1:
|
|
43
|
+
return sum(char_width(part) for part in char)
|
|
44
|
+
if char == "\x00" or unicodedata.combining(char):
|
|
45
|
+
return 0
|
|
46
|
+
category = unicodedata.category(char)
|
|
47
|
+
if category in _ZERO_WIDTH_CATEGORIES or category == "Cc":
|
|
48
|
+
return 0
|
|
49
|
+
if unicodedata.east_asian_width(char) in _WIDE_EAST_ASIAN:
|
|
50
|
+
return 2
|
|
51
|
+
return 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def visible_width(text: str) -> int:
|
|
55
|
+
"""
|
|
56
|
+
Returns the column width of ``text``, ignoring ANSI escape sequences.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
text : str
|
|
61
|
+
The text to measure. May contain ANSI escapes.
|
|
62
|
+
|
|
63
|
+
Returns
|
|
64
|
+
-------
|
|
65
|
+
int
|
|
66
|
+
The number of terminal columns the text occupies.
|
|
67
|
+
"""
|
|
68
|
+
if _ESC not in text:
|
|
69
|
+
return sum(char_width(char) for char in text)
|
|
70
|
+
return sum(char_width(char) for char in strip_ansi(text))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def strip_ansi(text: str) -> str:
|
|
74
|
+
"""
|
|
75
|
+
Removes CSI and OSC escape sequences from ``text``.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
text : str
|
|
80
|
+
The text to clean.
|
|
81
|
+
|
|
82
|
+
Returns
|
|
83
|
+
-------
|
|
84
|
+
str
|
|
85
|
+
``text`` with ANSI control sequences removed.
|
|
86
|
+
"""
|
|
87
|
+
if _ESC not in text:
|
|
88
|
+
return text
|
|
89
|
+
out: list[str] = []
|
|
90
|
+
index = 0
|
|
91
|
+
length = len(text)
|
|
92
|
+
while index < length:
|
|
93
|
+
char = text[index]
|
|
94
|
+
if char == _ESC:
|
|
95
|
+
index = _skip_escape(text, index)
|
|
96
|
+
continue
|
|
97
|
+
out.append(char)
|
|
98
|
+
index += 1
|
|
99
|
+
return "".join(out)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def truncate(text: str, max_cols: int, ellipsis: str = "…") -> str:
|
|
103
|
+
"""
|
|
104
|
+
Truncates ``text`` to ``max_cols`` columns, appending an ellipsis.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
text : str
|
|
109
|
+
The text to truncate. Assumed to contain no ANSI escapes.
|
|
110
|
+
max_cols : int
|
|
111
|
+
The maximum number of columns the result may occupy.
|
|
112
|
+
ellipsis : str
|
|
113
|
+
The marker appended when content is dropped.
|
|
114
|
+
|
|
115
|
+
Returns
|
|
116
|
+
-------
|
|
117
|
+
str
|
|
118
|
+
The original text if it fits, otherwise a truncated copy ending in the
|
|
119
|
+
ellipsis.
|
|
120
|
+
"""
|
|
121
|
+
if max_cols <= 0:
|
|
122
|
+
return ""
|
|
123
|
+
if visible_width(text) <= max_cols:
|
|
124
|
+
return text
|
|
125
|
+
budget = max_cols - visible_width(ellipsis)
|
|
126
|
+
if budget <= 0:
|
|
127
|
+
return ellipsis[:max_cols]
|
|
128
|
+
kept: list[str] = []
|
|
129
|
+
used = 0
|
|
130
|
+
for char in text:
|
|
131
|
+
width = char_width(char)
|
|
132
|
+
if used + width > budget:
|
|
133
|
+
break
|
|
134
|
+
kept.append(char)
|
|
135
|
+
used += width
|
|
136
|
+
return "".join(kept) + ellipsis
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def wrap(text: str, width: int) -> list[str]:
|
|
140
|
+
"""
|
|
141
|
+
Wraps ``text`` to ``width`` columns on word boundaries.
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
text : str
|
|
146
|
+
The text to wrap. Newlines split paragraphs first.
|
|
147
|
+
width : int
|
|
148
|
+
The maximum column width per line. A width of ``0`` disables wrapping.
|
|
149
|
+
|
|
150
|
+
Returns
|
|
151
|
+
-------
|
|
152
|
+
list[str]
|
|
153
|
+
The wrapped lines. Empty input yields a single empty line.
|
|
154
|
+
"""
|
|
155
|
+
if width <= 0:
|
|
156
|
+
return [text]
|
|
157
|
+
lines: list[str] = []
|
|
158
|
+
for paragraph in text.split("\n"):
|
|
159
|
+
lines.extend(_wrap_paragraph(paragraph, width))
|
|
160
|
+
return lines or [""]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _wrap_paragraph(text: str, width: int) -> list[str]:
|
|
164
|
+
"""Wraps a single newline-free paragraph to ``width`` columns."""
|
|
165
|
+
lines: list[str] = []
|
|
166
|
+
current = ""
|
|
167
|
+
current_width = 0
|
|
168
|
+
for word in text.split():
|
|
169
|
+
word_width = visible_width(word)
|
|
170
|
+
gap = 1 if current else 0
|
|
171
|
+
if current and current_width + gap + word_width > width:
|
|
172
|
+
lines.append(current)
|
|
173
|
+
current = ""
|
|
174
|
+
current_width = 0
|
|
175
|
+
gap = 0
|
|
176
|
+
if word_width > width:
|
|
177
|
+
if current:
|
|
178
|
+
lines.append(current)
|
|
179
|
+
current = ""
|
|
180
|
+
current_width = 0
|
|
181
|
+
lines.extend(_split_long_word(word, width))
|
|
182
|
+
if lines:
|
|
183
|
+
current = lines.pop()
|
|
184
|
+
current_width = visible_width(current)
|
|
185
|
+
continue
|
|
186
|
+
current = f"{current} {word}" if current else word
|
|
187
|
+
current_width += gap + word_width
|
|
188
|
+
lines.append(current)
|
|
189
|
+
return lines
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _split_long_word(word: str, width: int) -> list[str]:
|
|
193
|
+
"""Hard-splits a word wider than ``width`` into column-sized chunks."""
|
|
194
|
+
chunks: list[str] = []
|
|
195
|
+
current = ""
|
|
196
|
+
current_width = 0
|
|
197
|
+
for char in word:
|
|
198
|
+
char_w = char_width(char)
|
|
199
|
+
if current_width + char_w > width and current:
|
|
200
|
+
chunks.append(current)
|
|
201
|
+
current = ""
|
|
202
|
+
current_width = 0
|
|
203
|
+
current += char
|
|
204
|
+
current_width += char_w
|
|
205
|
+
if current:
|
|
206
|
+
chunks.append(current)
|
|
207
|
+
return chunks
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _skip_escape(text: str, index: int) -> int:
|
|
211
|
+
"""Returns the index just past the escape sequence starting at ``index``."""
|
|
212
|
+
length = len(text)
|
|
213
|
+
if index + 1 >= length:
|
|
214
|
+
return index + 1
|
|
215
|
+
marker = text[index + 1]
|
|
216
|
+
if marker == "[":
|
|
217
|
+
return _skip_csi(text, index + 2)
|
|
218
|
+
if marker == "]":
|
|
219
|
+
return _skip_osc(text, index + 2)
|
|
220
|
+
return index + 2
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _skip_csi(text: str, index: int) -> int:
|
|
224
|
+
"""Returns the index past a CSI sequence body starting at ``index``."""
|
|
225
|
+
length = len(text)
|
|
226
|
+
while index < length:
|
|
227
|
+
code = ord(text[index])
|
|
228
|
+
if _CSI_FINAL_MIN <= code <= _CSI_FINAL_MAX:
|
|
229
|
+
return index + 1
|
|
230
|
+
index += 1
|
|
231
|
+
return length
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _skip_osc(text: str, index: int) -> int:
|
|
235
|
+
"""Returns the index past an OSC sequence starting at ``index``."""
|
|
236
|
+
length = len(text)
|
|
237
|
+
while index < length:
|
|
238
|
+
char = text[index]
|
|
239
|
+
if char == _BELL:
|
|
240
|
+
return index + 1
|
|
241
|
+
if char == _ESC and index + 1 < length and text[index + 1] == "\\":
|
|
242
|
+
return index + 2
|
|
243
|
+
index += 1
|
|
244
|
+
return length
|
sparcli/errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sparcli.errors
|
|
3
|
+
==============
|
|
4
|
+
|
|
5
|
+
Defines the exception hierarchy raised across the library.
|
|
6
|
+
|
|
7
|
+
All errors derive from :class:`SparcliError`, so callers can catch the whole
|
|
8
|
+
family with a single ``except SparcliError``. More specific subclasses carry
|
|
9
|
+
context about what went wrong: terminal I/O, a missing interactive terminal or
|
|
10
|
+
invalid configuration.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SparcliError(Exception):
|
|
17
|
+
"""Base class for every error raised by sparcli."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TerminalError(SparcliError):
|
|
21
|
+
"""Raised when reading from or writing to the terminal fails."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NoTerminalError(SparcliError):
|
|
25
|
+
"""Raised when an interactive prompt runs without a usable terminal."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self, message: str = "no interactive terminal available"
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConfigError(SparcliError):
|
|
34
|
+
"""Raised when a widget or prompt is configured with invalid options."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, detail: str) -> None:
|
|
37
|
+
super().__init__(f"invalid configuration: {detail}")
|
|
38
|
+
self.detail = detail
|