termcore 0.2.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.
- termcore/__init__.py +6 -0
- termcore/cli/__init__.py +3 -0
- termcore/cli/output.py +70 -0
- termcore/io/__init__.py +7 -0
- termcore/io/app_state_storage.py +185 -0
- termcore/io/database.py +655 -0
- termcore/io/errors.py +71 -0
- termcore/io/file.py +263 -0
- termcore/io/textfile.py +92 -0
- termcore/py.typed +0 -0
- termcore/tui/__init__.py +9 -0
- termcore/tui/custom_bindings.py +551 -0
- termcore/tui/custom_widgets/__init__.py +1 -0
- termcore/tui/custom_widgets/custom_checkbox.py +41 -0
- termcore/tui/custom_widgets/custom_data_table.py +247 -0
- termcore/tui/custom_widgets/custom_selection.py +80 -0
- termcore/tui/custom_widgets/multiline_footer.py +240 -0
- termcore/tui/question_screen.py +107 -0
- termcore/tui/theme_loader.py +495 -0
- termcore/tui/themes/__init__.py +2 -0
- termcore/tui/themes/classic-black-saturated/style.css +57 -0
- termcore/tui/themes/classic-black-saturated/theme.py +22 -0
- termcore/tui/themes/classic-black-v1/style.css +57 -0
- termcore/tui/themes/classic-black-v1/theme.py +22 -0
- termcore/tui/themes/classic-black-v2/style.css +57 -0
- termcore/tui/themes/classic-black-v2/theme.py +24 -0
- termcore/tui/themes/classic-blue/theme.py +21 -0
- termcore/tui/themes/compact-gray/style.tcss +175 -0
- termcore/tui/themes/compact-gray/theme.py +22 -0
- termcore/tui/themes/mnml-black/style.css +57 -0
- termcore/tui/themes/mnml-black/theme.py +21 -0
- termcore/tui/themes/mnml-deepblack/style.css +57 -0
- termcore/tui/themes/mnml-deepblack/theme.py +21 -0
- termcore/tui/themes/pure-amber/theme.py +18 -0
- termcore/tui/themes/pure-black/theme.py +18 -0
- termcore/tui/themes/pure-blue/theme.py +18 -0
- termcore/tui/themes/pure-green/theme.py +18 -0
- termcore/tui/themes/pure-sweet16/theme.py +20 -0
- termcore/tui/themes/xplore-black/theme.py +18 -0
- termcore/tui/themes/xplore-blue/theme.py +18 -0
- termcore/tui/themes/xplore-blue-muted/theme.py +18 -0
- termcore/tui/themes/xplore-teal/theme.py +18 -0
- termcore/util/__init__.py +10 -0
- termcore/util/datetime.py +173 -0
- termcore/util/debug.py +196 -0
- termcore/util/index.py +112 -0
- termcore/util/logger.py +36 -0
- termcore/util/singleton.py +46 -0
- termcore/util/string.py +263 -0
- termcore/util/validation.py +31 -0
- termcore/util/version.py +56 -0
- termcore-0.2.0.dist-info/METADATA +504 -0
- termcore-0.2.0.dist-info/RECORD +55 -0
- termcore-0.2.0.dist-info/WHEEL +4 -0
- termcore-0.2.0.dist-info/licenses/LICENSE +21 -0
termcore/util/string.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provides utility functions for string manipulation and formatting.
|
|
3
|
+
|
|
4
|
+
This module contains helper methods for performing common string operations.
|
|
5
|
+
|
|
6
|
+
Included Features:
|
|
7
|
+
- `linewrap`: Splits long text into multiple lines, respecting a maximum line
|
|
8
|
+
width and avoiding word breaks when possible.
|
|
9
|
+
- `charpos`: Returns all positions of a given character within a string.
|
|
10
|
+
- `cell_width`: Counts the terminal cells a string occupies.
|
|
11
|
+
- `str_with_fixed_width`: Truncates or pads a string to an exact width.
|
|
12
|
+
|
|
13
|
+
These utilities are useful for simple text formatting tasks, especially when
|
|
14
|
+
preparing console output or working with fixed-width layouts.
|
|
15
|
+
|
|
16
|
+
Widths here are counted in terminal cells, not in code points: a CJK glyph
|
|
17
|
+
occupies two cells and a combining mark none. Counting characters instead is
|
|
18
|
+
what pushes a fixed-width column out of alignment as soon as the data is not
|
|
19
|
+
plain ASCII.
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import unicodedata
|
|
24
|
+
from collections.abc import Iterable
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ALIGNMENTS",
|
|
28
|
+
"ALIGN_CENTER",
|
|
29
|
+
"ALIGN_LEFT",
|
|
30
|
+
"ALIGN_RIGHT",
|
|
31
|
+
"ELLIPSIS",
|
|
32
|
+
"cell_width",
|
|
33
|
+
"charpos",
|
|
34
|
+
"linewrap",
|
|
35
|
+
"str_with_fixed_width",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
ALIGN_LEFT = "left"
|
|
39
|
+
ALIGN_RIGHT = "right"
|
|
40
|
+
ALIGN_CENTER = "center"
|
|
41
|
+
ALIGNMENTS = (ALIGN_LEFT, ALIGN_RIGHT, ALIGN_CENTER)
|
|
42
|
+
|
|
43
|
+
ELLIPSIS = "…"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def linewrap(text: str, linewidth: int) -> str:
|
|
47
|
+
r"""
|
|
48
|
+
Splits a string into multiple lines with a specified maximum width.
|
|
49
|
+
|
|
50
|
+
Words are kept intact where possible. A word longer than `linewidth` has
|
|
51
|
+
nothing to break at and is cut at the width instead.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
text : str
|
|
56
|
+
The input text to be split into lines.
|
|
57
|
+
linewidth : int
|
|
58
|
+
Maximum number of characters allowed per line.
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
str
|
|
63
|
+
A string with lines separated by line breaks (\n).
|
|
64
|
+
|
|
65
|
+
Raises
|
|
66
|
+
------
|
|
67
|
+
ValueError
|
|
68
|
+
If `linewidth` is smaller than 1. No progress could be made per
|
|
69
|
+
iteration, so the text would never be consumed.
|
|
70
|
+
"""
|
|
71
|
+
if linewidth < 1:
|
|
72
|
+
raise ValueError(f"linewidth must be at least 1, got {linewidth}")
|
|
73
|
+
|
|
74
|
+
lines: list[str] = []
|
|
75
|
+
while len(text) > 0:
|
|
76
|
+
# Cut the maximum portion out of the given string
|
|
77
|
+
maxcutpos = min(linewidth, len(text))
|
|
78
|
+
|
|
79
|
+
# If the maximum portion doesn't end with a whitespace, cut off
|
|
80
|
+
# at the last whitespace
|
|
81
|
+
if len(text) > linewidth and text[maxcutpos-1] != " " \
|
|
82
|
+
and text[maxcutpos] != " ":
|
|
83
|
+
cutpos = _last_space_position(text[0:maxcutpos-1], maxcutpos)
|
|
84
|
+
else:
|
|
85
|
+
cutpos = maxcutpos
|
|
86
|
+
|
|
87
|
+
# Set snippet for this line and remove it from given text
|
|
88
|
+
line = text[0:cutpos].strip()
|
|
89
|
+
text = text[cutpos:len(text)]
|
|
90
|
+
|
|
91
|
+
# Add line break if it's not the last line of the text
|
|
92
|
+
if maxcutpos == linewidth and len(text[0:linewidth].strip()) > 0:
|
|
93
|
+
line += "\n"
|
|
94
|
+
|
|
95
|
+
lines.append(line)
|
|
96
|
+
|
|
97
|
+
return "".join(lines)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _last_space_position(text: str, fallback: int) -> int:
|
|
101
|
+
"""
|
|
102
|
+
Returns the position to break at, or `fallback` if there is no useful one.
|
|
103
|
+
|
|
104
|
+
A word longer than the line has no space to break at, and a leading space
|
|
105
|
+
would give position 0 - both would leave the text unchanged and loop
|
|
106
|
+
forever, so the caller's hard cut is used instead.
|
|
107
|
+
"""
|
|
108
|
+
space_positions = charpos(text, " ")
|
|
109
|
+
if not space_positions or max(space_positions) < 1:
|
|
110
|
+
return fallback
|
|
111
|
+
return max(space_positions)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def charpos(text: str, char: str) -> list[int]:
|
|
115
|
+
"""
|
|
116
|
+
Finds all positions of a specific character in a string.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
text : str
|
|
121
|
+
The input text to search in.
|
|
122
|
+
char : str
|
|
123
|
+
The character to search for.
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
list[int]
|
|
128
|
+
A list of indices where the character occurs in the text.
|
|
129
|
+
"""
|
|
130
|
+
return [pos for pos, c in enumerate(text) if c == char]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cell_width(text: str) -> int:
|
|
134
|
+
"""
|
|
135
|
+
Returns the number of terminal cells the given text occupies.
|
|
136
|
+
|
|
137
|
+
A character whose East Asian width is wide or fullwidth takes two cells,
|
|
138
|
+
a combining mark takes none, everything else takes one.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
text : str
|
|
143
|
+
The text to measure.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
int
|
|
148
|
+
The number of cells the text occupies when rendered.
|
|
149
|
+
|
|
150
|
+
Examples
|
|
151
|
+
--------
|
|
152
|
+
>>> cell_width("abc")
|
|
153
|
+
3
|
|
154
|
+
>>> cell_width("日本語")
|
|
155
|
+
6
|
|
156
|
+
"""
|
|
157
|
+
return sum(_char_cells(char) for char in text)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _char_cells(char: str) -> int:
|
|
161
|
+
"""Returns how many terminal cells a single character occupies."""
|
|
162
|
+
if unicodedata.combining(char):
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
return 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def str_with_fixed_width(
|
|
169
|
+
text: str, width: int, align: str = ALIGN_LEFT
|
|
170
|
+
) -> str:
|
|
171
|
+
"""
|
|
172
|
+
Return a string occupying exactly `width` terminal cells.
|
|
173
|
+
|
|
174
|
+
If the text is wider it is truncated with an ellipsis (…). With
|
|
175
|
+
`align="right"` the tail of the text is kept and the ellipsis marks the
|
|
176
|
+
cut at the front; `align="left"` and `align="center"` keep the head,
|
|
177
|
+
since there is nothing left to centre once the text has been cut.
|
|
178
|
+
|
|
179
|
+
A double-width character cannot be split, so where one would straddle the
|
|
180
|
+
boundary the result is padded with a space to reach the width exactly.
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
text : str
|
|
185
|
+
The input text to format.
|
|
186
|
+
width : int
|
|
187
|
+
The exact output width in terminal cells.
|
|
188
|
+
align : str
|
|
189
|
+
One of 'left', 'right', or 'center'. Defaults to 'left'.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
str
|
|
194
|
+
A string occupying exactly `width` cells.
|
|
195
|
+
|
|
196
|
+
Raises
|
|
197
|
+
------
|
|
198
|
+
ValueError
|
|
199
|
+
If `align` is not one of the supported alignments, or if `width` is
|
|
200
|
+
negative.
|
|
201
|
+
"""
|
|
202
|
+
if align not in ALIGNMENTS:
|
|
203
|
+
raise ValueError(f"Invalid alignment: {align}")
|
|
204
|
+
if width < 0:
|
|
205
|
+
raise ValueError(f"width must not be negative, got {width}")
|
|
206
|
+
if width == 0:
|
|
207
|
+
return ""
|
|
208
|
+
|
|
209
|
+
if cell_width(text) > width:
|
|
210
|
+
return _truncated(text, width, align)
|
|
211
|
+
|
|
212
|
+
return _padded(text, width, align)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _truncated(text: str, width: int, align: str) -> str:
|
|
216
|
+
"""Cuts the text to `width` cells, marking the cut with an ellipsis."""
|
|
217
|
+
room = width - _char_cells(ELLIPSIS)
|
|
218
|
+
|
|
219
|
+
if align == ALIGN_RIGHT:
|
|
220
|
+
kept = _take_cells(reversed(text), room)[::-1]
|
|
221
|
+
return _pad_left(ELLIPSIS + kept, width)
|
|
222
|
+
|
|
223
|
+
kept = _take_cells(text, room)
|
|
224
|
+
|
|
225
|
+
return _pad_right(kept + ELLIPSIS, width)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _take_cells(chars: Iterable[str], room: int) -> str:
|
|
229
|
+
"""Takes characters while they still fit into `room` cells."""
|
|
230
|
+
taken: list[str] = []
|
|
231
|
+
used = 0
|
|
232
|
+
for char in chars:
|
|
233
|
+
cells = _char_cells(char)
|
|
234
|
+
if used + cells > room:
|
|
235
|
+
break
|
|
236
|
+
taken.append(char)
|
|
237
|
+
used += cells
|
|
238
|
+
|
|
239
|
+
return "".join(taken)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _padded(text: str, width: int, align: str) -> str:
|
|
243
|
+
"""Pads the text with spaces until it occupies `width` cells."""
|
|
244
|
+
missing = width - cell_width(text)
|
|
245
|
+
|
|
246
|
+
if align == ALIGN_LEFT:
|
|
247
|
+
return text + " " * missing
|
|
248
|
+
if align == ALIGN_RIGHT:
|
|
249
|
+
return " " * missing + text
|
|
250
|
+
|
|
251
|
+
left = missing // 2
|
|
252
|
+
|
|
253
|
+
return " " * left + text + " " * (missing - left)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _pad_right(text: str, width: int) -> str:
|
|
257
|
+
"""Fills up to `width` cells on the right, for a straddling glyph."""
|
|
258
|
+
return text + " " * (width - cell_width(text))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _pad_left(text: str, width: int) -> str:
|
|
262
|
+
"""Fills up to `width` cells on the left, for a straddling glyph."""
|
|
263
|
+
return " " * (width - cell_width(text)) + text
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Small predicates for validating loosely typed input."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"is_number",
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_number(value: int | float | str | None) -> bool:
|
|
10
|
+
"""
|
|
11
|
+
Returns True if the given value can be read as a number.
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
value : int or float or str or None
|
|
16
|
+
The value to check.
|
|
17
|
+
|
|
18
|
+
Returns
|
|
19
|
+
-------
|
|
20
|
+
bool
|
|
21
|
+
True if `float()` accepts the value, False otherwise.
|
|
22
|
+
"""
|
|
23
|
+
if value is None:
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
float(value)
|
|
28
|
+
except ValueError:
|
|
29
|
+
return False
|
|
30
|
+
else:
|
|
31
|
+
return True
|
termcore/util/version.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Reads the installed package version, falling back to pyproject.toml."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from importlib.metadata import PackageNotFoundError
|
|
5
|
+
from importlib.metadata import version as pkg_version
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"get_version",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_version(
|
|
15
|
+
package_name: str, pyproject_toml_parent_path: Path | None = None
|
|
16
|
+
) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Returns the version string for the given package.
|
|
19
|
+
|
|
20
|
+
Tries `importlib.metadata` first (installed packages), then falls back
|
|
21
|
+
to reading `pyproject.toml` - useful during development when the package
|
|
22
|
+
is not installed.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
package_name : str
|
|
27
|
+
The distribution name of the package (as in pyproject.toml).
|
|
28
|
+
pyproject_toml_parent_path : Path, optional
|
|
29
|
+
Path of the folder that contains project's `pyproject.toml`.
|
|
30
|
+
Required when the package is not installed and the fallback is needed.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
str
|
|
35
|
+
The version string (e.g. `0.1.0`).
|
|
36
|
+
|
|
37
|
+
Raises
|
|
38
|
+
------
|
|
39
|
+
FileNotFoundError
|
|
40
|
+
If the metadata is unavailable and no `pyproject.toml` was provided
|
|
41
|
+
or found at the given path.
|
|
42
|
+
KeyError
|
|
43
|
+
If `pyproject.toml` exists but contains no `[project] version`.
|
|
44
|
+
"""
|
|
45
|
+
if pyproject_toml_parent_path:
|
|
46
|
+
pyproject_toml = pyproject_toml_parent_path / "pyproject.toml"
|
|
47
|
+
else:
|
|
48
|
+
pyproject_toml = None
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
return pkg_version(package_name)
|
|
52
|
+
except PackageNotFoundError:
|
|
53
|
+
toml_path = pyproject_toml or Path("pyproject.toml")
|
|
54
|
+
with Path(toml_path).open("rb") as f:
|
|
55
|
+
data = tomllib.load(f)
|
|
56
|
+
return cast("str", data["project"]["version"])
|