termcore 0.2.0__tar.gz → 0.4.0__tar.gz

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 (102) hide show
  1. {termcore-0.2.0 → termcore-0.4.0}/CHANGELOG.md +62 -1
  2. {termcore-0.2.0 → termcore-0.4.0}/CLAUDE.md +5 -4
  3. {termcore-0.2.0 → termcore-0.4.0}/PKG-INFO +175 -47
  4. {termcore-0.2.0 → termcore-0.4.0}/README.md +173 -45
  5. {termcore-0.2.0 → termcore-0.4.0}/TODO.md +13 -13
  6. {termcore-0.2.0 → termcore-0.4.0}/docs/CLEAN-UP.md +4 -4
  7. {termcore-0.2.0 → termcore-0.4.0}/docs/DEVELOPMENT.md +45 -6
  8. {termcore-0.2.0 → termcore-0.4.0}/pyproject.toml +15 -11
  9. {termcore-0.2.0 → termcore-0.4.0}/termcore/cli/__init__.py +1 -0
  10. termcore-0.4.0/termcore/cli/cli_theme.py +100 -0
  11. {termcore-0.2.0 → termcore-0.4.0}/termcore/cli/output.py +14 -10
  12. {termcore-0.2.0 → termcore-0.4.0}/termcore/io/app_state_storage.py +6 -2
  13. {termcore-0.2.0 → termcore-0.4.0}/termcore/io/textfile.py +67 -1
  14. termcore-0.4.0/termcore/tui/__init__.py +18 -0
  15. termcore-0.4.0/termcore/tui/binding_groups.py +129 -0
  16. termcore-0.4.0/termcore/tui/custom_widgets/app_header.py +197 -0
  17. termcore-0.4.0/termcore/tui/custom_widgets/footer_rows.py +288 -0
  18. termcore-0.4.0/termcore/tui/custom_widgets/multiline_footer.py +285 -0
  19. termcore-0.4.0/termcore/tui/custom_widgets/status_bar.py +105 -0
  20. termcore-0.4.0/termcore/tui/custom_widgets/tab_rows.py +166 -0
  21. termcore-0.4.0/termcore/tui/help_rows.py +278 -0
  22. termcore-0.4.0/termcore/tui/help_screen.py +218 -0
  23. termcore-0.4.0/termcore/tui/keymap.py +792 -0
  24. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/theme_loader.py +65 -4
  25. termcore-0.4.0/termcore/tui/theme_overrides.py +158 -0
  26. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/classic-black-saturated/style.css +1 -1
  27. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/classic-black-saturated/theme.py +1 -0
  28. {termcore-0.2.0/termcore/tui/themes/classic-black-v2 → termcore-0.4.0/termcore/tui/themes/classic-black-v1}/style.css +1 -1
  29. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/classic-black-v1/theme.py +1 -0
  30. {termcore-0.2.0/termcore/tui/themes/classic-black-v1 → termcore-0.4.0/termcore/tui/themes/classic-black-v2}/style.css +1 -1
  31. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/classic-black-v2/theme.py +1 -0
  32. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/compact-gray/style.tcss +2 -2
  33. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/compact-gray/theme.py +3 -1
  34. {termcore-0.2.0/termcore/tui/themes/mnml-deepblack → termcore-0.4.0/termcore/tui/themes/mnml-black}/style.css +1 -1
  35. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/mnml-black/theme.py +1 -0
  36. {termcore-0.2.0/termcore/tui/themes/mnml-black → termcore-0.4.0/termcore/tui/themes/mnml-deepblack}/style.css +1 -1
  37. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/mnml-deepblack/theme.py +1 -0
  38. termcore-0.4.0/termcore/util/logger.py +89 -0
  39. termcore-0.4.0/tests/cli/test_cli_theme.py +57 -0
  40. {termcore-0.2.0 → termcore-0.4.0}/tests/io/test_database.py +4 -2
  41. termcore-0.4.0/tests/io/test_textfile.py +196 -0
  42. {termcore-0.2.0 → termcore-0.4.0}/tests/test_docs.py +5 -3
  43. {termcore-0.2.0 → termcore-0.4.0}/tests/test_public_api.py +16 -4
  44. termcore-0.4.0/tests/test_theme_only_colours.py +154 -0
  45. {termcore-0.2.0 → termcore-0.4.0}/tests/tui/conftest.py +17 -0
  46. termcore-0.4.0/tests/tui/test_app_header.py +313 -0
  47. termcore-0.4.0/tests/tui/test_footer_rows.py +289 -0
  48. termcore-0.4.0/tests/tui/test_help_rows.py +284 -0
  49. termcore-0.4.0/tests/tui/test_help_screen.py +347 -0
  50. termcore-0.4.0/tests/tui/test_keymap.py +881 -0
  51. termcore-0.4.0/tests/tui/test_multiline_footer.py +288 -0
  52. termcore-0.4.0/tests/tui/test_status_bar.py +186 -0
  53. termcore-0.4.0/tests/tui/test_tab_rows.py +126 -0
  54. {termcore-0.2.0 → termcore-0.4.0}/tests/tui/test_theme_loader.py +8 -4
  55. termcore-0.4.0/tests/tui/test_theme_overrides.py +102 -0
  56. termcore-0.4.0/tests/util/__init__.py +0 -0
  57. {termcore-0.2.0 → termcore-0.4.0}/tests/util/test_datetime.py +4 -2
  58. {termcore-0.2.0 → termcore-0.4.0}/tests/util/test_index.py +2 -1
  59. {termcore-0.2.0 → termcore-0.4.0}/tests/util/test_string.py +2 -1
  60. termcore-0.4.0/uv.lock +430 -0
  61. termcore-0.2.0/termcore/tui/__init__.py +0 -9
  62. termcore-0.2.0/termcore/tui/custom_bindings.py +0 -551
  63. termcore-0.2.0/termcore/tui/custom_widgets/multiline_footer.py +0 -240
  64. termcore-0.2.0/termcore/util/logger.py +0 -36
  65. termcore-0.2.0/tests/tui/test_custom_bindings.py +0 -575
  66. {termcore-0.2.0 → termcore-0.4.0}/.gitignore +0 -0
  67. {termcore-0.2.0 → termcore-0.4.0}/LICENSE +0 -0
  68. {termcore-0.2.0 → termcore-0.4.0}/termcore/__init__.py +0 -0
  69. {termcore-0.2.0 → termcore-0.4.0}/termcore/io/__init__.py +0 -0
  70. {termcore-0.2.0 → termcore-0.4.0}/termcore/io/database.py +0 -0
  71. {termcore-0.2.0 → termcore-0.4.0}/termcore/io/errors.py +0 -0
  72. {termcore-0.2.0 → termcore-0.4.0}/termcore/io/file.py +0 -0
  73. {termcore-0.2.0 → termcore-0.4.0}/termcore/py.typed +0 -0
  74. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/custom_widgets/__init__.py +0 -0
  75. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/custom_widgets/custom_checkbox.py +0 -0
  76. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/custom_widgets/custom_data_table.py +0 -0
  77. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/custom_widgets/custom_selection.py +0 -0
  78. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/question_screen.py +0 -0
  79. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/__init__.py +0 -0
  80. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/classic-blue/theme.py +0 -0
  81. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/pure-amber/theme.py +0 -0
  82. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/pure-black/theme.py +0 -0
  83. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/pure-blue/theme.py +0 -0
  84. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/pure-green/theme.py +0 -0
  85. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/pure-sweet16/theme.py +0 -0
  86. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/xplore-black/theme.py +0 -0
  87. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/xplore-blue/theme.py +0 -0
  88. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/xplore-blue-muted/theme.py +0 -0
  89. {termcore-0.2.0 → termcore-0.4.0}/termcore/tui/themes/xplore-teal/theme.py +0 -0
  90. {termcore-0.2.0 → termcore-0.4.0}/termcore/util/__init__.py +0 -0
  91. {termcore-0.2.0 → termcore-0.4.0}/termcore/util/datetime.py +0 -0
  92. {termcore-0.2.0 → termcore-0.4.0}/termcore/util/debug.py +0 -0
  93. {termcore-0.2.0 → termcore-0.4.0}/termcore/util/index.py +0 -0
  94. {termcore-0.2.0 → termcore-0.4.0}/termcore/util/singleton.py +0 -0
  95. {termcore-0.2.0 → termcore-0.4.0}/termcore/util/string.py +0 -0
  96. {termcore-0.2.0 → termcore-0.4.0}/termcore/util/validation.py +0 -0
  97. {termcore-0.2.0 → termcore-0.4.0}/termcore/util/version.py +0 -0
  98. {termcore-0.2.0 → termcore-0.4.0}/tests/__init__.py +0 -0
  99. {termcore-0.2.0/tests/io → termcore-0.4.0/tests/cli}/__init__.py +0 -0
  100. {termcore-0.2.0/tests/tui → termcore-0.4.0/tests/io}/__init__.py +0 -0
  101. {termcore-0.2.0 → termcore-0.4.0}/tests/io/conftest.py +0 -0
  102. {termcore-0.2.0/tests/util → termcore-0.4.0/tests/tui}/__init__.py +0 -0
@@ -4,6 +4,63 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.4.0] – 2026-11-06
10
+
11
+ ### Added
12
+
13
+ - `tui/theme_overrides.py`: `apply_color_overrides`, which layers single colours from an application's config over a theme. A base colour (`primary`, `surface`, `error`, ...) replaces the theme's field, any other name the CSS variable Textual generates under it (`footer-background`, `text-muted`, ...). An unknown name or an unparseable value is logged and skipped, so a typo does not stop the app; `color_override_problems` returns a message for each so the application can show it, and `known_color_names` and `unknown_color_names` list the valid and the unknown names
14
+ - `ThemeLoader.set_color_overrides`, which applies those overrides to every theme the loader registers, Textual's built-in ones included, so they survive a theme switch. Without overrides the built-in themes stay as Textual ships them
15
+ - `ThemeLoader.get_theme`, returning a registered or built-in theme by name with the overrides applied, without a running app
16
+ - `cli/cli_theme.py`: `CliTheme`, the colours of the four message panels, with `set_cli_theme` and `cli_theme_from_textual`. A CLI that hands it the TUI's theme shows errors, warnings, successes and notes in that theme's colours; the defaults keep the previous red, yellow, green and cyan
17
+ - `tests/test_theme_only_colours.py`, which fails on a colour value anywhere in the package outside the themes' `theme.py` files and `cli/cli_theme.py`: in Python strings, `DEFAULT_CSS` blocks and stylesheets alike. Textual's and Rich's own parsers decide what a colour is
18
+
19
+ ### Changed
20
+
21
+ - **Breaking.** `CustomBindings` is now `Keymap`, and the module `tui/custom_bindings.py` is now `tui/keymap.py`. The old name described the class by its relation to Textual's own `BINDINGS` rather than by what it does: it is the map from a scope and key to a binding
22
+ - `print_error`, `print_warning`, `print_success` and `print_info` read their colour from the active `CliTheme` instead of a fixed one; the look is unchanged until an application sets a theme
23
+ - The six bundled themes with a stylesheet (`classic-black-v1`, `classic-black-v2`, `classic-black-saturated`, `compact-gray`, `mnml-black`, `mnml-deepblack`) no longer hold a colour value in it. The cursor colour is the new theme variable `input-cursor-foreground`, so a config override reaches it too
24
+ - `compact-gray` draws the text of an `Option` in the theme's `$error` instead of a fixed `red`, which is a slightly different red
25
+
26
+ ## [0.3.0] – 2026-07-22
27
+
28
+ ### Added
29
+
30
+ - `Textfile.write_atomic`, which writes through a synced temporary file and a rename. `AppStateStorage`, `ThemeLoader` and any consumer rewriting a file whole now keep their old content when a write is interrupted; a plain write truncates first, so a crash between truncating and writing loses everything rather than the last change
31
+ - `setup_logging` takes a `log_dir`, so an application that resolves its own paths does not have a second resolver behind its back. Its default now follows `XDG_STATE_HOME` instead of hard-coding `~/.local/state`
32
+ - `AppHeader` posts `TabSelected` when a tab label is clicked. The header does not switch anything itself - it does not own the content - and only inactive tabs carry the click, because Textual restyles anything clickable with its link colour and that would flatten the active tab to the same grey as the rest
33
+
34
+ - `tui/binding_groups.py`: `BindingGroup`, the labelled row of shortcuts that the footer and the help overlay both render from, plus `active_actions` to snapshot what is bound on a screen and `dispatch_name` to strip the `app.` prefix a Screen adds
35
+ - `CustomBindings.get_groups`, returning every declared group in the order the YAML file declares it. It replaces both row maps, and the defect they invited disappears with them: there is no longer a pair of calls that can be mismatched
36
+ - A `group` entry in the binding YAML. A scope may now hold groups, single bindings, or both; consecutive single bindings share one unlabelled row. Grouping is optional and adds no ceremony where it is not wanted
37
+ - `tui/help_screen.py`: `HelpScreen`, the overlay section 1.8 of the style guide asks for - every shortcut, grouped, with a fuzzy search over them and a `ctrl+t` toggle between all bindings and the ones currently active. Matching uses Textual's own `Matcher` rather than a hand-written one
38
+ - `tui/help_rows.py`: the filtering and row order behind the overlay, testable without an application
39
+ - A second file of scope display names, passed to `CustomBindings` as `scopes_file`. Scope names such as `tasks_tab` are identifiers; this maps them to the words people read. `scope_title` resolves one, `BindingGroup.scope_title` carries it, and a title naming no scope is reported - nothing else would ever notice it
40
+ - `display_scope`, which falls back to the raw scope name where no title was declared. The fallback is meant to look unfinished rather than invent a heading nobody chose
41
+ - `tui/custom_widgets/footer_rows.py`: the footer's arithmetic - label column width, key width and wrapping - split out so it can be tested without an application and so `multiline_footer.py` stays inside the file-size limit
42
+ - `tui/custom_widgets/app_header.py`: `AppHeader`, a header carrying the application name in its own column and the tabs after it, wrapping onto further rows whose brand cell is blank but as wide. Textual's `Header` is fixed at one line (three with `-tall`) with three hard-wired slots, and `Tabs` scrolls rather than wraps, so neither could serve
43
+ - `tui/custom_widgets/tab_rows.py`: the header's packing, testable without an application
44
+ - `tui/custom_widgets/status_bar.py`: `StatusBar`, one line with standing information on the left and a passing message on the right. Textual has no status widget; `App.notify` covers only the passing half, as a box that expires on a timer
45
+ - A `key` field in the scopes file, and `scope_key`, `get_tab_scopes` and `get_tab_bindings` to read it. Declaring a key is what makes a scope a tab: the key selects it, the header prints it, and the order of the file is the order of the bar
46
+ - The first tests for `MultiLineFooter`, which had none: row order, separator count, label alignment, wrapping at a narrow width, and that clicking a key still runs its action
47
+
48
+ ### Changed
49
+
50
+ - **Breaking.** The `row` field is gone, and with it `CustomBindings.get_row_map`, `get_screen_row_map` and the `row_map` parameter of `MultiLineFooter`. A group is now one row, so a second way to assign rows could only contradict the first
51
+ - **Breaking.** `CustomBindings.sorted_by_key` is gone, along with the alphabetical sorting it enabled. The order of the YAML file is now the order of the footer, without exception
52
+ - **Breaking.** `MultiLineFooter` no longer takes `auto_wrap`. Passing `groups` selects the grouped layout; omitting it wraps on width, which is what the old default did
53
+ - **Breaking.** `handle_check_action`'s keyword argument `active_group` is now `active_scope`. The word "group" now means a footer row throughout, and what it used to mean - a top-level key of the YAML file - is called a scope
54
+ - The help overlay groups on two levels: the scope heads the section, the group is the level below it, and a scope holding a single group prints no group heading at all. The footer keeps its one-dimensional layout - the two surfaces answer different questions, which is why clibase gives them separate tables as well
55
+ - **Breaking.** The scopes file maps each scope to a mapping of `title` and optional `key`, where it previously mapped straight to the title. One shape covers both a plain heading and a tab
56
+ - **Breaking.** `HelpScope` is now `HelpCoverage` and `HelpRequest.scope` is `coverage`. With the overlay grouping by scope, the old name meant two things at once, which is exactly the ambiguity the group/scope split had just removed
57
+ - The footer prints group labels in a left column of shared width, keys separated by ` · `. A group too wide to fit wraps onto continuation rows whose label cell stays blank, so the keys align under the keys rather than under the label
58
+ - The command palette key's width is now reserved, so the last row can no longer wrap into the space it is docked in
59
+ - `setup_logging` installs a file handler only, and now says so. Its docstring claimed "file and console logging" while installing neither a console handler nor wanting one: a console handler writes into the terminal the application is drawing on
60
+ - The docstring layout rule of style guide 3.2.4 is enforced in the test suite as well. `tests/**` ignored the whole `D` group, so the one rule that is about layout rather than about requiring a docstring was off there too; the ignore is now `D1`
61
+ - Text fields are read strictly: a `key`, `action` or `description` that YAML resolved to a boolean is dropped with a warning naming the fix, instead of rendering `False` as a label
62
+ - Widths are measured in terminal cells rather than characters, so a CJK label no longer pushes the footer out of alignment
63
+
7
64
  ## [0.2.0] – 2026-07-19
8
65
 
9
66
  ### Added
@@ -92,4 +149,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
92
149
 
93
150
  *PyPI name reservation – no functional code.*
94
151
 
95
- [0.2.0]: https://github.com/cgroening/py-termcore/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/cgroening/py-termcore/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/cgroening/py-termcore/releases/tag/v0.1.0
152
+ [0.4.0]: https://github.com/cgroening/py-termcore/releases/tag/v0.4.0
153
+ [0.3.0]: https://github.com/cgroening/py-termcore/compare/v0.2.0...v0.3.0
154
+ [0.2.0]: https://github.com/cgroening/py-termcore/compare/v0.1.1...v0.2.0
155
+ [0.1.1]: https://github.com/cgroening/py-termcore/compare/v0.1.0...v0.1.1
156
+ [0.1.0]: https://github.com/cgroening/py-termcore/releases/tag/v0.1.0
@@ -1,10 +1,10 @@
1
1
  # CLAUDE.md
2
2
 
3
- Project-specific instructions for termcore. The global style guide referenced in `~/.claude/CLAUDE.md` remains authoritative; this file only records what is specific to this repository.
3
+ Project-specific instructions for termcore. This file records what is specific to this repository.
4
4
 
5
5
  ## What this project is
6
6
 
7
- termcore is a library, not an application. It is the foundation under termplate, which section 3.2.9 of the style guide names as the template every Textual-based TUI is checked against.
7
+ termcore is a library, not an application. It is the foundation under termplate, the template every Textual-based TUI is checked against.
8
8
 
9
9
  That ordering has a consequence worth stating plainly: a defect here reaches every application built on the toolkit, and it reaches them silently, because applications trust a library rather than testing it. Four of the theme bugs that held up termplate lived in this repository and none of them failed loudly – they just rendered the wrong colours. Prefer failing loudly over carrying on, and prefer a test over a comment.
10
10
 
@@ -37,17 +37,18 @@ The public API of each package is re-exported through `from .module import *` in
37
37
 
38
38
  ## The defect this repository keeps producing
39
39
 
40
- Mutable class attributes shared by every instance. It has been found and fixed four times: `ThemeLoader._theme_names` and `_theme_data`, the four registries in `CustomBindings`, `CustomDataTable.flexible_columns` and `AppStateStorage._json_dict`. Each time the symptom was different and none of them raised.
40
+ Mutable class attributes shared by every instance. It has been found and fixed four times: `ThemeLoader._theme_names` and `_theme_data`, the four registries in `Keymap`, `CustomDataTable.flexible_columns` and `AppStateStorage._json_dict`. Each time the symptom was different and none of them raised.
41
41
 
42
42
  Declare instance attributes annotated at class level and assign them in `__init__`, as section 3.2.5 says. `RUF012` catches the annotated-with-a-value form; it does not catch every shape, so this stays worth looking for by hand.
43
43
 
44
44
  ## Conventions particular to this repo
45
45
 
46
- - No boolean parameter selects behaviour anywhere in the public API. Section 1.1.2 is enforced by `FBT`, and the established answers are two named functions (`next_index` and `clamped_index`, `timing` and `timing_ns`), a classmethod factory (`ThemeLoader.custom_only`, `CustomBindings.sorted_by_key`) or an enum (`DateFormat`). Pick whichever fits the parameter; a format selector is an enum, a behaviour switch is two functions.
46
+ - No boolean parameter selects behaviour anywhere in the public API. Section 1.1.2 is enforced by `FBT`, and the established answers are two named functions (`next_index` and `clamped_index`, `timing` and `timing_ns`), a classmethod factory (`ThemeLoader.custom_only`) or an enum (`DateFormat`, `HelpScope`). Pick whichever fits the parameter; a format selector is an enum, a behaviour switch is two functions. A fourth answer appeared with `MultiLineFooter(groups=...)`: where the two behaviours differ only in whether data was supplied, the presence of that data selects the mode and no extra parameter is needed.
47
47
  - Domain exceptions live in `termcore/io/errors.py`. A library that calls `sys.exit()` or prints an error leaves its caller no way to react – `AppStateStorage` did both until recently.
48
48
  - Diagnostics go to a module logger with lazy `%`-formatting, never to `print`. `termcore/cli/output.py` is the one module where printing is the product, and it carries a per-file ignore saying so.
49
49
  - Every SQL statement binds its values as parameters, and every identifier passes `_quoted_table` or `_quoted_column`, which check it against the schema of the open database before quoting it. The `# noqa: S608` markers state exactly what the rule cannot see. There is no other way to build a statement here.
50
50
  - A theme is identified by the `name` in its `theme.py` plus its registration prefix; the directory it lives in carries no meaning. Theme modules are imported by file path, never as a package – importing them by folder name made the first folder loaded in a process shadow every later one of the same name.
51
+ - Every colour comes from a theme. Colour values live only in the themes' `theme.py` files and in the defaults of `termcore/cli/cli_theme.py`; stylesheets and `DEFAULT_CSS` use `$variables`, and a colour Textual has no variable for becomes a theme variable. `tests/test_theme_only_colours.py` enforces it. Single colours a user changes come in through `ThemeLoader.set_color_overrides`, never through a new code path.
51
52
  - Textual reads annotations at runtime, so type-only imports stay at module level under `termcore/tui/`. The per-file ignore for `TC002` and `TC003` in `pyproject.toml` is that rule, not an oversight.
52
53
  - hatchling ships everything inside the package directory, so runtime data (`py.typed`, the theme `.css` and `theme.py` files) needs no `package-data` entry. Every package directory does need an `__init__.py`, or it becomes an implicit namespace package that a stricter packaging tool would drop.
53
54
  - termplate is the only consumer. A breaking change is pulled through it in the same pass, and its suite has to be green before the change is finished.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: termcore
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Terminal utilities for CLI, TUI, IO and general use
5
5
  Project-URL: Homepage, https://github.com/cgroening/py-termcore
6
6
  Project-URL: Repository, https://github.com/cgroening/py-termcore
@@ -76,15 +76,27 @@ clear_lines(4)
76
76
 
77
77
  | Function | Description |
78
78
  |----------|-------------|
79
- | `print_error(message)` | Red panel with ✗ prefix |
80
- | `print_warning(message)` | Yellow panel with ⚠ prefix |
81
- | `print_success(message)` | Green panel with ✓ prefix |
82
- | `print_info(message)` | Cyan panel with ℹ prefix |
79
+ | `print_error(message)` | Error panel (theme colour, red by default) with ✗ prefix |
80
+ | `print_warning(message)` | Warning panel (yellow by default) with ⚠ prefix |
81
+ | `print_success(message)` | Success panel (green by default) with ✓ prefix |
82
+ | `print_info(message)` | Info panel (cyan by default) with ℹ prefix |
83
83
  | `print_panel(message, color)` | Panel with bold colored text |
84
84
  | `print_custom_panel(formatted_message, panel_color)` | Panel with pre-formatted Rich markup |
85
85
  | `clear_lines(count)` | Move cursor up `count` lines and clear everything below |
86
86
  | `get_console()` | Returns the shared Rich `Console` instance |
87
87
 
88
+ ### Panel colours
89
+
90
+ The four message panels take their colours from a `CliTheme` (defaults: red, yellow, green, cyan). An application that also has a TUI hands the CLI the TUI's theme, so both show the same colours:
91
+
92
+ ```python
93
+ from termcore import ThemeLoader, cli_theme_from_textual, set_cli_theme
94
+
95
+ theme = ThemeLoader("themes").get_theme("TERMCORE_xplore-blue")
96
+ if theme is not None:
97
+ set_cli_theme(cli_theme_from_textual(theme)) # error, warning, success, primary
98
+ ```
99
+
88
100
  ## termcore.tui – Textual TUI Helpers
89
101
 
90
102
  ### ThemeLoader
@@ -127,6 +139,20 @@ Theme name prefixes:
127
139
 
128
140
  Both prefixes can be customized via the `ThemeLoader` constructor.
129
141
 
142
+ ### Single colours from the config
143
+
144
+ A user who likes a theme but wants one colour different sets that colour instead of writing a theme. `set_color_overrides` takes `name -> colour` and layers it over every theme the loader registers, Textual's built-in ones included, so the change survives a theme switch:
145
+
146
+ ```python
147
+ loader.set_color_overrides({
148
+ "primary": "#ff8800", # a base colour of the theme
149
+ "footer-background": "#101010", # any variable Textual generates
150
+ })
151
+ loader.register_themes_in_textual_app(app)
152
+ ```
153
+
154
+ The names are a theme's base colours (`primary`, `secondary`, `accent`, `foreground`, `background`, `surface`, `panel`, `boost`, `warning`, `error`, `success`) plus every CSS variable Textual derives from them (`known_color_names()` lists them for the installed Textual). An unknown name or a value Textual cannot parse is logged and skipped; `color_override_problems` returns a message for each, so an application can show them (`unknown_color_names` lists only the names). `get_theme(name)` returns a theme with the overrides applied, without a running app.
155
+
130
156
  ### QuestionScreen
131
157
 
132
158
  A Textual `ModalScreen` that presents a yes/no dialog and returns a `bool`.
@@ -184,73 +210,174 @@ yield CustomSelectionList(
184
210
  )
185
211
  ```
186
212
 
213
+ ### AppHeader
214
+
215
+ The header every terminal interface needs: the application name in a column of its own, then the tabs with the key that selects each, wrapping onto further rows when they do not fit. Textual's `Header` cannot do it - it is fixed at `height: 1` (3 with `-tall`) with three hard-wired slots and a `nowrap` title - and neither can `Tabs`, which is `height: 2` with `overflow: hidden` and scrolls instead of wrapping.
216
+
217
+ ```python
218
+ from termcore.tui.custom_widgets.app_header import AppHeader, HeaderTab
219
+
220
+ yield AppHeader(
221
+ "mdtask",
222
+ [HeaderTab(scope, bindings.scope_key(scope), bindings.scope_title(scope))
223
+ for scope in bindings.get_tab_scopes()],
224
+ active="tasks_tab",
225
+ )
226
+ ```
227
+
228
+ ```text
229
+ mdtask [1] Tasks │ [2] Software │ [3] Allgemein │ [4] Notes
230
+ [5] Quick Notes │ [6] Focus │ [7] Checklists
231
+ ```
232
+
233
+ The continuation rows leave the brand cell blank but exactly as wide, so the tabs form a column. The active tab is bold at normal brightness and the others are dimmed; the separator is drawn only between tabs on the same row, never at either end of one. Set `active` to move the highlight and `set_tabs` to replace the bar.
234
+
235
+ `TabbedContent` keeps its panes - hide only its own bar with `TabbedContent > ContentTabs { display: none; }` and drive `TabbedContent.active` from the key bindings `Keymap.get_tab_bindings` builds.
236
+
237
+ ### StatusBar
238
+
239
+ One line of status: standing information on the left, a passing message on the right. Textual has no status widget - `App.notify` covers only the passing half, and only as a box docked bottom right that expires on a timer.
240
+
241
+ ```python
242
+ from termcore.tui.custom_widgets.status_bar import StatusBar
243
+
244
+ bar = self.query_one(StatusBar)
245
+ bar.info = "3 tasks · 1 done · 2 open"
246
+ bar.message = "theme: classic-black"
247
+ ```
248
+
249
+ ```text
250
+ 3 tasks · 1 done · 2 open theme: classic-black
251
+ ```
252
+
253
+ It does not dock itself, on purpose: Textual places everything docked to one edge in the same place, so a docked status bar would sit underneath the footer it belongs above. Put it last among the flowing widgets, above the docked footer, and give the content above it `height: 1fr`.
254
+
255
+ Clearing `message` is the caller's job. The convention is the next key press, which Textual dispatches before the key's own action, so an action is free to set a new message right afterwards:
256
+
257
+ ```python
258
+ def on_key(self, _event: events.Key) -> None:
259
+ self.query_one(StatusBar).message = ""
260
+ ```
261
+
187
262
  ### MultiLineFooter
188
263
 
189
- A drop-in replacement for Textual's built-in `Footer` that supports multiple rows of key bindings. Two modes are available:
264
+ A drop-in replacement for Textual's built-in `Footer` that supports multiple rows of key bindings. Which of its two modes applies follows from whether groups are passed:
190
265
 
191
- - **`auto_wrap=True`** (default) – bindings wrap automatically when the row is full.
192
- - **`auto_wrap=False`** – bindings are assigned to rows explicitly via `row_map`.
266
+ - **without `groups`** – the keys wrap onto a new row whenever the current one is full.
267
+ - **with `groups`** – each declared group becomes one row, its label printed in a column on the left and its keys separated by ` · `. A group too wide for the terminal wraps onto continuation rows whose label cell stays blank, so the keys line up under the keys rather than under the label.
193
268
 
194
269
  ```python
270
+ from termcore.tui.keymap import Keymap
195
271
  from termcore.tui.custom_widgets.multiline_footer import MultiLineFooter
196
272
 
197
- # Auto-wrap (default)
273
+ bindings = Keymap("bindings.yaml")
274
+
275
+ # Wrap on width
198
276
  yield MultiLineFooter()
199
277
 
200
- # Manual row assignment
201
- yield MultiLineFooter(
202
- auto_wrap=False,
203
- row_map={
204
- 'quit': 0,
205
- 'save': 0,
206
- 'toggle_dark': 1,
207
- 'help': 1,
208
- },
209
- )
278
+ # One row per group, in the order the YAML file declares them
279
+ yield MultiLineFooter(groups=bindings.get_groups())
280
+ ```
281
+
282
+ ```text
283
+ Tasks: a Add · d Done
284
+ Appearance: w Previous Theme · s Next Theme
285
+ App: ? Help · q Quit
210
286
  ```
211
287
 
212
- ### CustomBindings
288
+ `get_groups()` returns every declared group, not only the ones belonging to the current screen. A group whose bindings are not active right now renders no row at all, so the same call serves every screen.
289
+
290
+ ### HelpScreen
291
+
292
+ The overlay every terminal interface needs: every shortcut, grouped, with a fuzzy search over them. It reads the same groups the footer does, so a binding cannot appear in one and be missing from the other.
293
+
294
+ Where the footer lays those groups out in one dimension, the overlay uses two. Its outer heading is the scope - the tab or screen a shortcut works on, which is what someone opening a help screen is asking about - and the group is the inner one. A scope holding a single group prints no group heading, since repeating "Tasks" under "Tasks" tells nobody anything.
295
+
296
+ ```python
297
+ from termcore.tui.binding_groups import active_actions
298
+ from termcore.tui.help_screen import HelpScreen
299
+
300
+ def action_help(self) -> None:
301
+ # Snapshot before the push: afterwards `self.screen` is the modal.
302
+ active = active_actions(self.screen)
303
+ self.push_screen(HelpScreen(bindings.get_groups(), active))
304
+ ```
305
+
306
+ ```text
307
+ TASKS
308
+ a Add
309
+ d Done
310
+ GLOBAL
311
+ Appearance
312
+ w Previous Theme
313
+ App
314
+ q Quit
315
+ ```
316
+
317
+ It opens listing every declared shortcut. `ctrl+t` narrows the list to the bindings that would actually fire where the overlay was opened, which is what the snapshot above feeds; `escape` and `?` close it. Matching is Textual's own `Matcher`, the fuzzy search behind its command palette, so `ntm` finds "next theme".
318
+
319
+ ### Keymap
213
320
 
214
321
  Loads keyboard bindings from a YAML file and exposes them as Textual `Binding` objects. Supports global bindings, tab-specific bindings, and screen-specific bindings.
215
322
 
216
- **Group naming conventions:**
323
+ Two words are kept apart and are not interchangeable. A **scope** is a top-level key of the file: it decides when a binding is visible and how its action is named. A **group** is one labelled footer row inside a scope: it decides only how the footer and the help overlay lay the bindings out. Grouping is optional.
217
324
 
218
- | Group name | Scope | Action prefix |
219
- |------------|-------|---------------|
325
+ **Scope naming conventions:**
326
+
327
+ | Scope name | Visibility | Action prefix |
328
+ |------------|------------|---------------|
220
329
  | `_global` | Always visible | none (used as-is) |
221
330
  | `<name>_tab` | Shown when that tab is active | `<name>_tab_` |
222
331
  | `<name>_screen` | Shown on that screen | none (used as-is) |
223
332
 
333
+ The order of the file is the order of the footer, without exception. Nothing is re-sorted on the way out, so a scope moved in the file moves its rows - which is why `_global` usually belongs at the bottom.
334
+
224
335
  **YAML example (`bindings.yaml`):**
225
336
 
226
337
  ```yaml
338
+ tasks_tab:
339
+ - group: Tasks
340
+ bindings:
341
+ - key: a
342
+ action: add_task
343
+ description: Add
344
+ - key: d
345
+ action: mark_done
346
+ description: Done
347
+ - key: z # no group: its own row, no label
348
+ action: zoom
349
+ description: Zoom
350
+
227
351
  _global:
228
- - key: q
229
- action: quit
230
- description: Quit
231
- priority: true
232
- row: 1
352
+ - group: App
353
+ bindings:
354
+ - key: q
355
+ action: quit
356
+ description: Quit
357
+ priority: true
358
+ ```
233
359
 
234
- tasks_tab:
235
- - key: a
236
- action: add_task
237
- description: Add
238
- row: 0
360
+ An entry carrying `bindings` is a group; an entry carrying `key` is a single binding. Both may be mixed inside one scope, and consecutive single bindings share one unlabelled row.
361
+
362
+ **Scope display names (`scopes.yaml`):**
363
+
364
+ Scope names are identifiers, not labels. A second file maps them to the names people see - the headings of the help overlay, and in an app that reads them, the labels of the tabs:
239
365
 
240
- add_screen:
241
- - key: escape
242
- action: cancel
243
- description: Cancel
244
- row: 0
366
+ ```yaml
367
+ tasks_tab: "Tasks"
368
+ add_screen: "Add dialog"
369
+ _global: "Global"
245
370
  ```
246
371
 
372
+ A scope with no entry there is shown under its raw name, which is meant to look unfinished rather than be quietly invented. A title naming no scope is reported, because nothing else would ever notice it. `bindings.scope_title(scope)` resolves one, and every `BindingGroup` carries the title of its own scope.
373
+
247
374
  **Usage:**
248
375
 
249
376
  ```python
250
- from termcore.tui.custom_bindings import CustomBindings
377
+ from termcore.tui.keymap import Keymap
251
378
 
252
- bindings = CustomBindings("bindings.yaml") # file order
253
- bindings = CustomBindings.sorted_by_key("bindings.yaml") # sorted by key
379
+ bindings = Keymap("bindings.yaml") # file order, always
380
+ bindings = Keymap("bindings.yaml", "scopes.yaml") # with titles
254
381
 
255
382
  # In your App or Screen:
256
383
  # On the App: globals dispatch as declared
@@ -261,13 +388,14 @@ BINDINGS = bindings.get_bindings(tab_name="tasks_tab") # one tab + global
261
388
  BINDINGS = bindings.get_screen_bindings() # every tab + global
262
389
  BINDINGS = bindings.get_screen_bindings("add") # that screen + global
263
390
 
264
- # Row map for MultiLineFooter(auto_wrap=False):
265
- row_map = bindings.get_row_map() # pair with get_bindings
266
- row_map = bindings.get_screen_row_map() # pair with get_screen_bindings
391
+ # Footer rows and help overlay, both from the same call:
392
+ groups = bindings.get_groups()
267
393
 
268
394
  # In check_action to hide tab bindings that don't belong to the active tab:
269
395
  def check_action(self, action, parameters):
270
- return bindings.handle_check_action(action, parameters, active_group=self.active_tab)
396
+ return bindings.handle_check_action(
397
+ dispatch_name(action), parameters, active_scope=self.active_tab
398
+ )
271
399
  ```
272
400
 
273
401
  ## termcore.io – IO Utilities
@@ -492,7 +620,7 @@ uv venv
492
620
  source .venv/bin/activate
493
621
  uv pip install -e ".[dev]"
494
622
 
495
- ruff check . # linting; the rule set is the style guide's baseline
623
+ ruff check . # linting; rule set in pyproject.toml
496
624
  basedpyright # strict type checking
497
625
  pytest # tests, with coverage
498
626
  ```