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.
Files changed (55) hide show
  1. termcore/__init__.py +6 -0
  2. termcore/cli/__init__.py +3 -0
  3. termcore/cli/output.py +70 -0
  4. termcore/io/__init__.py +7 -0
  5. termcore/io/app_state_storage.py +185 -0
  6. termcore/io/database.py +655 -0
  7. termcore/io/errors.py +71 -0
  8. termcore/io/file.py +263 -0
  9. termcore/io/textfile.py +92 -0
  10. termcore/py.typed +0 -0
  11. termcore/tui/__init__.py +9 -0
  12. termcore/tui/custom_bindings.py +551 -0
  13. termcore/tui/custom_widgets/__init__.py +1 -0
  14. termcore/tui/custom_widgets/custom_checkbox.py +41 -0
  15. termcore/tui/custom_widgets/custom_data_table.py +247 -0
  16. termcore/tui/custom_widgets/custom_selection.py +80 -0
  17. termcore/tui/custom_widgets/multiline_footer.py +240 -0
  18. termcore/tui/question_screen.py +107 -0
  19. termcore/tui/theme_loader.py +495 -0
  20. termcore/tui/themes/__init__.py +2 -0
  21. termcore/tui/themes/classic-black-saturated/style.css +57 -0
  22. termcore/tui/themes/classic-black-saturated/theme.py +22 -0
  23. termcore/tui/themes/classic-black-v1/style.css +57 -0
  24. termcore/tui/themes/classic-black-v1/theme.py +22 -0
  25. termcore/tui/themes/classic-black-v2/style.css +57 -0
  26. termcore/tui/themes/classic-black-v2/theme.py +24 -0
  27. termcore/tui/themes/classic-blue/theme.py +21 -0
  28. termcore/tui/themes/compact-gray/style.tcss +175 -0
  29. termcore/tui/themes/compact-gray/theme.py +22 -0
  30. termcore/tui/themes/mnml-black/style.css +57 -0
  31. termcore/tui/themes/mnml-black/theme.py +21 -0
  32. termcore/tui/themes/mnml-deepblack/style.css +57 -0
  33. termcore/tui/themes/mnml-deepblack/theme.py +21 -0
  34. termcore/tui/themes/pure-amber/theme.py +18 -0
  35. termcore/tui/themes/pure-black/theme.py +18 -0
  36. termcore/tui/themes/pure-blue/theme.py +18 -0
  37. termcore/tui/themes/pure-green/theme.py +18 -0
  38. termcore/tui/themes/pure-sweet16/theme.py +20 -0
  39. termcore/tui/themes/xplore-black/theme.py +18 -0
  40. termcore/tui/themes/xplore-blue/theme.py +18 -0
  41. termcore/tui/themes/xplore-blue-muted/theme.py +18 -0
  42. termcore/tui/themes/xplore-teal/theme.py +18 -0
  43. termcore/util/__init__.py +10 -0
  44. termcore/util/datetime.py +173 -0
  45. termcore/util/debug.py +196 -0
  46. termcore/util/index.py +112 -0
  47. termcore/util/logger.py +36 -0
  48. termcore/util/singleton.py +46 -0
  49. termcore/util/string.py +263 -0
  50. termcore/util/validation.py +31 -0
  51. termcore/util/version.py +56 -0
  52. termcore-0.2.0.dist-info/METADATA +504 -0
  53. termcore-0.2.0.dist-info/RECORD +55 -0
  54. termcore-0.2.0.dist-info/WHEEL +4 -0
  55. termcore-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -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
@@ -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"])