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,551 @@
1
+ """
2
+ Key bindings for Textual applications, declared in YAML.
3
+
4
+ This module defines the class `CustomBindings` for managing custom keyboard
5
+ bindings in a Textual application. It loads key binding definitions from
6
+ a YAML file and exposes them as Textual `Binding` objects.
7
+
8
+ Reserved group naming conventions:
9
+
10
+ - `_global` Always-visible bindings; action is used as-is (no prefix)
11
+ - `<name>_tab` Shown only when that tab is active; action is prefixed with
12
+ the full group name, e.g. `tasks_tab_add_task`
13
+ - `<name>_screen` Screen-specific bindings; action is used as-is (no prefix)
14
+
15
+ YAML structure
16
+ --------------
17
+ The YAML file is a mapping of group names to lists of binding definitions.
18
+ Each binding supports the following fields:
19
+
20
+ key (required) Key to bind, e.g. `q`, `f1`, `ctrl+s`
21
+ action (required) Action name (see group-specific prefixing below)
22
+ description (required) Short label shown in the footer
23
+ tooltip Longer description shown on hover
24
+ key_display Override how the key is rendered in the footer
25
+ row Footer row index (0-based, default: 0)
26
+ priority bool - show binding even when a widget captures input
27
+ show bool - whether to show in the footer (default: true)
28
+ id Optional binding ID
29
+ system bool - mark as a system binding
30
+
31
+ Group naming rules
32
+ ------------------
33
+ `_global`
34
+ Bindings that are always visible. The action is used as-is (no prefix),
35
+ e.g. `action: quit` → `quit`. When included in a Screen's `BINDINGS`
36
+ via `get_bindings(for_screen=True)`, these actions are automatically
37
+ prefixed with `app.` so Textual dispatches them on the App.
38
+
39
+ `<name>_tab`
40
+ Tab-specific bindings, shown only when that tab is active. The action is
41
+ prefixed with the group name, e.g. group `tasks_tab` + `action: add`
42
+ → `tasks_tab_add`. Use `check_action` with the active tab name as
43
+ `active_group` to control visibility.
44
+
45
+ `<name>_screen`
46
+ Screen-specific bindings. The action is used as-is (no prefix), so it maps
47
+ directly to an `action_<name>` method on the Screen.
48
+
49
+ Example
50
+ -------
51
+ .. code-block:: yaml
52
+
53
+ # Always shown (action = <action>, no prefix)
54
+ _global:
55
+ - key: q
56
+ action: quit
57
+ description: Quit
58
+ tooltip: Exit the application
59
+ priority: true
60
+ row: 1
61
+
62
+ # Shown only when "tasks_tab" is active (action = tasks_tab_<action>)
63
+ tasks_tab:
64
+ - key: a
65
+ action: add_task
66
+ description: Add
67
+ tooltip: Add a new task
68
+ row: 0
69
+ - key: d
70
+ action: mark_done
71
+ description: Done
72
+ tooltip: Mark the selected task as done
73
+ row: 0
74
+
75
+ # Shown only on AddScreen (action used as-is)
76
+ add_screen:
77
+ - key: escape
78
+ action: cancel
79
+ description: Cancel
80
+ tooltip: Cancel and close
81
+ row: 0
82
+ """
83
+ import logging
84
+ import re
85
+ from dataclasses import replace
86
+ from pathlib import Path
87
+
88
+ import yaml
89
+ from textual.binding import Binding, BindingType
90
+
91
+ __all__ = [
92
+ "CustomBindings",
93
+ ]
94
+
95
+ _logger = logging.getLogger(__name__)
96
+
97
+
98
+ def _sort_key(binding: Binding) -> str:
99
+ """Turns a key like "F1" or "f1" into "f01", so f2 sorts before f10."""
100
+ match = re.match(r"(f)(\d+)", binding.key.lower())
101
+ if match:
102
+ return f"{match.group(1)}{int(match.group(2)):02d}"
103
+
104
+ return binding.key.lower()
105
+
106
+
107
+ class CustomBindings:
108
+ """
109
+ Manages the key bindings loaded from a YAML file.
110
+
111
+ See the module docstring for the YAML structure and the group naming
112
+ rules.
113
+
114
+ Attributes
115
+ ----------
116
+ yaml_file_path : str
117
+ Path to the YAML file containing key bindings.
118
+ sort_alphabetically : bool
119
+ Whether to sort bindings alphabetically by key.
120
+ If false, they are sorted in the order they appear in the YAML file.
121
+ bindings_dict_raw : dict[str, list[dict[str, str]]]
122
+ Raw data loaded from the YAML file.
123
+ bindings_dict : dict[str, list[Binding]]
124
+ Processed key bindings grouped by their group name.
125
+ action_to_groups : dict[str, list[str]]
126
+ Maps actions to the groups they belong to.
127
+ action_row_map : dict[str, int]
128
+ Maps actions to their specified footer row index.
129
+ global_actions : list[str]
130
+ List of actions that are always shown globally.
131
+ """
132
+
133
+ _yaml_file_path: str
134
+ _sort_alphabetically: bool
135
+ _bindings_dict_raw: dict[str, list[dict[str, str]]]
136
+ _bindings_dict: dict[str, list[Binding]]
137
+ _action_to_groups: dict[str, list[str]]
138
+ _action_row_map: dict[str, int]
139
+ _global_actions: list[str]
140
+
141
+
142
+ def __init__(self, yaml_file: str) -> None:
143
+ """
144
+ Reads the YAML file, keeping the order it declares.
145
+
146
+ Use `CustomBindings.sorted_by_key` for a set sorted by key instead.
147
+ """
148
+ self._yaml_file_path = yaml_file
149
+ self._sort_alphabetically = False
150
+ self._bindings_dict = {}
151
+ self._action_to_groups = {}
152
+ self._action_row_map = {}
153
+ self._global_actions = []
154
+ self._read_yaml_file()
155
+ self._process_bindings()
156
+
157
+ @classmethod
158
+ def sorted_by_key(cls, yaml_file: str) -> "CustomBindings":
159
+ """
160
+ Returns the bindings of the file, sorted by key within each group.
161
+
162
+ Function keys sort numerically, so f2 comes before f10.
163
+
164
+ Parameters
165
+ ----------
166
+ yaml_file : str
167
+ Path to the YAML file containing the key bindings.
168
+
169
+ Returns
170
+ -------
171
+ CustomBindings
172
+ The loaded bindings, ordered by key rather than by file order.
173
+ """
174
+ bindings = cls(yaml_file)
175
+ bindings._sort_alphabetically = True
176
+
177
+ return bindings
178
+
179
+ def _read_yaml_file(self) -> None:
180
+ """Loads the binding definitions from the YAML file."""
181
+ with Path(self._yaml_file_path).open(encoding="utf-8") as file:
182
+ self._bindings_dict_raw = yaml.safe_load(file)
183
+
184
+ def _process_bindings(self) -> None:
185
+ """
186
+ Turns the raw YAML data into `Binding` instances.
187
+
188
+ Groups them by their group name, builds the `action_to_groups`
189
+ mapping and records the global actions in `global_actions`.
190
+ """
191
+ # Loop groups
192
+ for group, bindings in self._bindings_dict_raw.items():
193
+ if group not in self._bindings_dict:
194
+ self._bindings_dict[group] = []
195
+
196
+ # Loop bindings
197
+ for binding in bindings:
198
+ key = self._parse_key(binding.get("key"))
199
+ action = self._parse_action(binding.get("action"), group)
200
+ description = self._parse_description(
201
+ binding.get("description")
202
+ )
203
+ show = self._parse_show(binding.get("show"))
204
+ key_display = self._parse_key_display(
205
+ key, binding.get("key_display")
206
+ )
207
+ priority = self._parse_priority(binding.get("priority"))
208
+ tooltip = self._parse_tooltip(binding.get("tooltip"))
209
+ binding_id = self._parse_id(binding.get("id"))
210
+ system = self._parse_system(binding.get("system"))
211
+
212
+ # Skip if any required field is missing. Spelled out rather
213
+ # than derived from the list below, so the type checker can
214
+ # narrow the three values for the constructor call.
215
+ if key is None or action is None or description is None:
216
+ missing = [
217
+ name for name, value in (
218
+ ("key", key),
219
+ ("action", action),
220
+ ("description", description),
221
+ ) if value is None
222
+ ]
223
+ _logger.warning(
224
+ "Skipping a binding in group %r: %s missing",
225
+ group, ", ".join(missing)
226
+ )
227
+ continue
228
+
229
+ binding_instance = Binding(
230
+ key =key,
231
+ action =action,
232
+ description=description,
233
+ show =show,
234
+ key_display=key_display,
235
+ priority =priority,
236
+ tooltip =tooltip,
237
+ id =binding_id,
238
+ system =system
239
+ )
240
+ self._bindings_dict[group].append(binding_instance)
241
+
242
+ # Add action to action_to_groups mapping
243
+ if action not in self._action_to_groups:
244
+ self._action_to_groups[action] = [group]
245
+ else:
246
+ self._action_to_groups[action].append(group)
247
+
248
+ # Store row for this action. A second group claiming the
249
+ # same action overwrites the first one's footer row, which
250
+ # is worth saying rather than doing quietly.
251
+ if action in self._action_row_map:
252
+ _logger.warning(
253
+ "Action %r is declared more than once (%s); the "
254
+ "footer row of the earlier binding is overwritten",
255
+ action, ", ".join(self._action_to_groups[action])
256
+ )
257
+ self._action_row_map[action] = int(binding.get("row", 0))
258
+
259
+ # Add action to global actions if applicable
260
+ if group.startswith("_global"):
261
+ self._global_actions.append(action)
262
+
263
+
264
+ def get_row_map(self) -> dict[str, int]:
265
+ """
266
+ Returns a row map for use with `MultiLineFooter(auto_wrap=False)`.
267
+
268
+ Uses the `row` values defined in the YAML file. Pair this with
269
+ `get_bindings`; a Screen wants `get_screen_row_map` instead.
270
+
271
+ Returns
272
+ -------
273
+ dict[str, int]
274
+ A mapping of action names to row numbers (0-based).
275
+ """
276
+ return dict(self._action_row_map)
277
+
278
+ def get_screen_row_map(self) -> dict[str, int]:
279
+ """
280
+ Returns the row map as a Screen needs it.
281
+
282
+ Global actions are keyed with the `app.` prefix, matching the actions
283
+ `get_screen_bindings` produces. If the two disagree, the footer
284
+ silently loses the row of every global binding.
285
+
286
+ Returns
287
+ -------
288
+ dict[str, int]
289
+ A mapping of action names to row numbers (0-based).
290
+ """
291
+ return {
292
+ self._dispatch_name(action): row
293
+ for action, row in self._action_row_map.items()
294
+ }
295
+
296
+ def get_bindings(self, tab_name: str | None = None) -> list[BindingType]:
297
+ """
298
+ Returns the bindings for the App itself.
299
+
300
+ Includes every tab group, or just one when `tab_name` is given, plus
301
+ the global bindings. Screen groups are left out; a Screen uses
302
+ `get_screen_bindings`.
303
+
304
+ Parameters
305
+ ----------
306
+ tab_name : str or None, optional
307
+ Restricts the result to the bindings of that one tab.
308
+
309
+ Returns
310
+ -------
311
+ list[BindingType]
312
+ The bindings, globals last.
313
+ """
314
+ self._sort_groups_by_key()
315
+ bindings: list[BindingType] = self._tab_bindings(tab_name)
316
+ bindings.extend(self._global_bindings())
317
+
318
+ return bindings
319
+
320
+ def get_screen_bindings(
321
+ self, screen_name: str | None = None
322
+ ) -> list[BindingType]:
323
+ """
324
+ Returns the bindings for a Screen, with globals dispatched on the App.
325
+
326
+ Without a name the tab bindings are included, which is what a screen
327
+ holding a `TabbedContent` needs. With one, only that screen's own
328
+ group is.
329
+
330
+ Parameters
331
+ ----------
332
+ screen_name : str or None, optional
333
+ Name of the screen, without the `_screen` suffix its group
334
+ carries in the YAML file.
335
+
336
+ Returns
337
+ -------
338
+ list[BindingType]
339
+ The bindings, globals last and prefixed with `app.`.
340
+ """
341
+ self._sort_groups_by_key()
342
+
343
+ bindings: list[BindingType] = (
344
+ self._tab_bindings(None) if screen_name is None
345
+ else self._screen_bindings(screen_name)
346
+ )
347
+ bindings.extend(self._app_dispatched_globals())
348
+
349
+ return bindings
350
+
351
+ def _sort_groups_by_key(self) -> None:
352
+ """Sorts every group by key, if this instance was built that way."""
353
+ if not self._sort_alphabetically:
354
+ return
355
+
356
+ for group, bindings in self._bindings_dict.items():
357
+ self._bindings_dict[group] = sorted(bindings, key=_sort_key)
358
+
359
+ def _tab_bindings(self, tab_name: str | None) -> list[BindingType]:
360
+ """Returns the bindings of every tab group, or of one named tab."""
361
+ if tab_name is not None:
362
+ self._warn_if_unknown(tab_name.lower(), tab_name)
363
+
364
+ bindings: list[BindingType] = []
365
+ for group, group_bindings in self._bindings_dict.items():
366
+ # Global and screen groups are appended by the callers instead
367
+ if group.startswith("_global") or group.endswith("_screen"):
368
+ continue
369
+ if tab_name and group != tab_name.lower():
370
+ continue
371
+
372
+ bindings.extend(group_bindings)
373
+
374
+ return bindings
375
+
376
+ def _screen_bindings(self, screen_name: str) -> list[BindingType]:
377
+ """Returns the bindings of one screen group."""
378
+ group = f"{screen_name.lower()}_screen"
379
+ self._warn_if_unknown(group, screen_name)
380
+
381
+ return list(self._bindings_dict.get(group, []))
382
+
383
+ def _warn_if_unknown(self, group: str, given_name: str) -> None:
384
+ """
385
+ Warns when a name matches no group in the YAML file.
386
+
387
+ Without this a typo is indistinguishable from a tab or screen that
388
+ genuinely declares no shortcuts of its own.
389
+ """
390
+ if group in self._bindings_dict:
391
+ return
392
+
393
+ _logger.warning(
394
+ "No binding group %r for name %r; returning only the global "
395
+ "bindings", group, given_name
396
+ )
397
+
398
+ def _global_bindings(self) -> list[BindingType]:
399
+ """Returns the always-visible bindings, as they were declared."""
400
+ bindings: list[BindingType] = []
401
+ for group, group_bindings in self._bindings_dict.items():
402
+ if group.startswith("_global"):
403
+ bindings.extend(group_bindings)
404
+
405
+ return bindings
406
+
407
+ def _app_dispatched_globals(self) -> list[BindingType]:
408
+ """
409
+ Returns the global bindings with their action prefixed by `app.`.
410
+
411
+ A Screen would otherwise look for `action_quit` on itself, find
412
+ nothing, and the key would quietly do nothing.
413
+ """
414
+ return [
415
+ replace(binding, action=f"app.{binding.action}")
416
+ for binding in self._global_bindings()
417
+ if isinstance(binding, Binding)
418
+ ]
419
+
420
+ def _dispatch_name(self, action: str) -> str:
421
+ """Returns the name a Screen dispatches the given action under."""
422
+ if action in self._global_actions:
423
+ return f"app.{action}"
424
+
425
+ return action
426
+
427
+ def handle_check_action(
428
+ self, action: str,
429
+ _parameters: tuple[object, ...],
430
+ active_group: str
431
+ ) -> bool | None:
432
+ """
433
+ Checks whether an action should be shown in the active group.
434
+
435
+ This is meant to be used in the check_action method of a Textual app.
436
+
437
+ Parameters
438
+ ----------
439
+ action : str
440
+ The action to check.
441
+ parameters : tuple[object, ...]
442
+ Parameters for the action (not used).
443
+ active_group : str
444
+ The currently active group or tab.
445
+
446
+ Returns
447
+ -------
448
+ bool or None
449
+ True if the action should be displayed, False otherwise.
450
+ """
451
+ # Ignore actions that are not defined in custom bindings
452
+ if not self._is_custom_action(action):
453
+ return True
454
+
455
+ # Global actions are always shown
456
+ if self._is_global_key(action):
457
+ return True
458
+
459
+ # Check if the action belongs to the current tab/group
460
+ return active_group in self._action_to_groups[action]
461
+
462
+ def _is_global_key(self, action: str) -> bool:
463
+ """Checks if the given action belongs to a global key binding."""
464
+ return action in self._global_actions
465
+
466
+ def _is_custom_action(self, action: str) -> bool:
467
+ """Returns True if the action is one the bindings declare."""
468
+ return action in self._action_to_groups
469
+
470
+ def _parse_key(self, key: str | None) -> str | None:
471
+ """Parses the key field from the YAML binding definition."""
472
+ return key
473
+
474
+ def _parse_action(self, action: str | None, group: str) -> str | None:
475
+ """Parses the action field, applying the group prefixing rules."""
476
+ if action is None:
477
+ return None
478
+ if group.startswith("_global") or group.endswith("_screen"):
479
+ return action
480
+ return f"{group}_{action}"
481
+
482
+ def _parse_description(self, description: str | None) -> str | None:
483
+ """Parses the description field from the YAML binding definition."""
484
+ return description
485
+
486
+ def _parse_show(self, show: object) -> bool:
487
+ """Parses the show field, defaulting to True."""
488
+ return self._parse_bool(show, field="show", default=True)
489
+
490
+ def _parse_key_display(
491
+ self, key: str | None, key_display: str | None
492
+ ) -> str | None:
493
+ """
494
+ Parses the key_display field of a binding.
495
+
496
+ A declared value wins - the field exists to override how the key is
497
+ rendered. Without one, a function key such as "f1" becomes "F1", and
498
+ anything else returns None, which leaves the rendering to Textual.
499
+ """
500
+ if key_display is not None:
501
+ return key_display
502
+ if key is None:
503
+ return None
504
+
505
+ match = re.fullmatch(r"(f)(\d+)", key.lower())
506
+ if match:
507
+ return f"F{int(match.group(2))}"
508
+
509
+ return None
510
+
511
+ def _parse_priority(self, priority: object) -> bool:
512
+ """Parses the priority field, defaulting to False."""
513
+ return self._parse_bool(priority, field="priority", default=False)
514
+
515
+ def _parse_tooltip(self, tooltip: str | None) -> str:
516
+ """Parses the tooltip field, defaulting to an empty string."""
517
+ if tooltip is None:
518
+ return ""
519
+ return tooltip
520
+
521
+ def _parse_id(self, binding_id: str | None) -> str | None:
522
+ """Parses the id field from the YAML binding definition."""
523
+ return binding_id
524
+
525
+ def _parse_system(self, system: object) -> bool:
526
+ """
527
+ Parses the system field, defaulting to False.
528
+
529
+ A system binding is not overridden by a widget that captures input.
530
+ """
531
+ return self._parse_bool(system, field="system", default=False)
532
+
533
+ def _parse_bool(self, value: object, field: str, *, default: bool) -> bool:
534
+ """
535
+ Reads a YAML boolean, falling back to the documented default.
536
+
537
+ Only a genuine boolean counts. `bool(value)` would turn the quoted
538
+ string "false" into True, so a quoted boolean would silently mean the
539
+ opposite of what it says.
540
+ """
541
+ if value is None:
542
+ return default
543
+ if isinstance(value, bool):
544
+ return value
545
+
546
+ _logger.warning(
547
+ "Expected true or false for %r, got %r; using the default %r",
548
+ field, value, default
549
+ )
550
+
551
+ return default
@@ -0,0 +1 @@
1
+ """Reusable Textual widgets: tables, inputs, selections and the footer."""
@@ -0,0 +1,41 @@
1
+ """A Textual Checkbox that shows a check mark instead of an X."""
2
+
3
+ from textual.events import Callback, Mount
4
+ from textual.widgets import Checkbox
5
+
6
+ __all__ = [
7
+ "CustomCheckbox",
8
+ ]
9
+
10
+
11
+ class CustomCheckbox(Checkbox):
12
+ """
13
+ A custom Textual Checkbox that displays a check mark when selected.
14
+
15
+ When nothing is selected, it shows an empty box - instead of the default
16
+ behavior of showing an "X".
17
+
18
+ Attributes
19
+ ----------
20
+ CUSTOM_BUTTON_INNER : str
21
+ The character to display when the checkbox is checked.
22
+ """
23
+
24
+ CUSTOM_BUTTON_INNER = "✔"
25
+
26
+
27
+ def _on_mount(self, event: Mount) -> None:
28
+ """Applies the custom glyph as soon as the widget is mounted."""
29
+ self.toggle_button_inner()
30
+ return super()._on_mount(event)
31
+
32
+ def on_checkbox_changed(self, _event: Callback) -> None:
33
+ """Swaps the glyph whenever the checkbox is toggled."""
34
+ self.toggle_button_inner()
35
+
36
+ def toggle_button_inner(self) -> None:
37
+ """Sets the inner glyph to match the current value."""
38
+ if self.value:
39
+ self.BUTTON_INNER = self.CUSTOM_BUTTON_INNER
40
+ else:
41
+ self.BUTTON_INNER = " " # pyright:ignore[reportConstantRedefinition]