py-sparcli 0.3.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 (118) hide show
  1. py_sparcli-0.4.0/CHANGELOG.md +94 -0
  2. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/CLAUDE.md +24 -8
  3. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/CONTRIBUTING.md +1 -6
  4. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/PKG-INFO +28 -4
  5. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/README.md +27 -3
  6. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/docs/DEVELOPMENT.md +42 -12
  7. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/examples/output_dynamic.py +2 -1
  8. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/examples/output_gallery.py +30 -1
  9. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/examples/output_readme.py +2 -1
  10. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/examples/prompt_readme.py +2 -1
  11. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/examples/prompts.py +6 -2
  12. py_sparcli-0.4.0/pyproject.toml +163 -0
  13. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/__init__.py +4 -2
  14. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/core/__init__.py +13 -2
  15. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/core/border.py +62 -0
  16. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/core/color.py +93 -2
  17. py_sparcli-0.4.0/sparcli/core/command.py +86 -0
  18. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/core/cursor.py +4 -3
  19. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/core/geometry.py +4 -1
  20. py_sparcli-0.4.0/sparcli/core/hsl.py +139 -0
  21. py_sparcli-0.3.0/sparcli/output/live.py → py_sparcli-0.4.0/sparcli/core/inplace.py +51 -48
  22. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/core/markup.py +17 -5
  23. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/core/render.py +44 -15
  24. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/core/style.py +3 -1
  25. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/core/terminal.py +26 -1
  26. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/core/text.py +4 -1
  27. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/core/theme.py +15 -8
  28. py_sparcli-0.4.0/sparcli/core/width/__init__.py +37 -0
  29. py_sparcli-0.4.0/sparcli/core/width/line.py +272 -0
  30. py_sparcli-0.3.0/sparcli/core/width.py → py_sparcli-0.4.0/sparcli/core/width/measure.py +3 -3
  31. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/__init__.py +1 -1
  32. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/calc.py +1 -1
  33. py_sparcli-0.4.0/sparcli/input/completion.py +214 -0
  34. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/confirm.py +32 -17
  35. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/datepicker.py +36 -19
  36. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/editor.py +40 -12
  37. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/event.py +38 -69
  38. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/field.py +6 -2
  39. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/fuzzy.py +29 -57
  40. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/guard.py +5 -2
  41. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/history.py +65 -13
  42. py_sparcli-0.4.0/sparcli/input/keydecode.py +140 -0
  43. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/line_edit.py +78 -3
  44. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/number.py +24 -15
  45. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/password.py +24 -24
  46. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/prompt.py +48 -7
  47. py_sparcli-0.4.0/sparcli/input/recall.py +118 -0
  48. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/select.py +54 -84
  49. py_sparcli-0.4.0/sparcli/input/selection.py +144 -0
  50. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/shortcut.py +28 -2
  51. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/text.py +92 -226
  52. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/textarea.py +19 -49
  53. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/validate.py +1 -1
  54. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/__init__.py +3 -1
  55. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/alert.py +20 -5
  56. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/badge.py +14 -2
  57. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/box.py +1 -1
  58. py_sparcli-0.4.0/sparcli/output/card/__init__.py +17 -0
  59. py_sparcli-0.4.0/sparcli/output/card/card.py +377 -0
  60. py_sparcli-0.4.0/sparcli/output/card/palette.py +130 -0
  61. py_sparcli-0.4.0/sparcli/output/card/render.py +459 -0
  62. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/columns.py +19 -4
  63. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/compose.py +7 -3
  64. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/diff.py +19 -7
  65. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/kv.py +17 -5
  66. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/list.py +25 -8
  67. py_sparcli-0.4.0/sparcli/output/live.py +50 -0
  68. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/multiprogress.py +8 -5
  69. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/pager.py +17 -13
  70. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/panel.py +19 -3
  71. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/progress.py +97 -53
  72. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/rule.py +23 -7
  73. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/spinner.py +10 -7
  74. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/table/__init__.py +1 -1
  75. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/table/plan.py +2 -2
  76. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/table/render.py +4 -4
  77. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/table/table.py +23 -8
  78. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/tree.py +21 -5
  79. py_sparcli-0.4.0/tests/conftest.py +61 -0
  80. py_sparcli-0.4.0/tests/test_box.py +171 -0
  81. py_sparcli-0.4.0/tests/test_card.py +297 -0
  82. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_confirm.py +5 -1
  83. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_core.py +147 -10
  84. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_cursor.py +4 -1
  85. py_sparcli-0.4.0/tests/test_external_commands.py +128 -0
  86. py_sparcli-0.4.0/tests/test_history.py +157 -0
  87. py_sparcli-0.4.0/tests/test_input_collaborators.py +226 -0
  88. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_input_infra.py +4 -1
  89. py_sparcli-0.4.0/tests/test_keydecode.py +87 -0
  90. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_number.py +5 -1
  91. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_output_basic.py +3 -10
  92. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_output_dynamic.py +4 -2
  93. py_sparcli-0.4.0/tests/test_output_streams.py +91 -0
  94. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_output_structured.py +5 -1
  95. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_password.py +1 -1
  96. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_select.py +4 -3
  97. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_table.py +14 -9
  98. py_sparcli-0.4.0/tests/test_table_plan.py +154 -0
  99. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_text_input.py +22 -8
  100. py_sparcli-0.4.0/uv.lock +806 -0
  101. py_sparcli-0.3.0/.pre-commit-config.yaml +0 -32
  102. py_sparcli-0.3.0/CHANGELOG.md +0 -57
  103. py_sparcli-0.3.0/pyproject.toml +0 -76
  104. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/.editorconfig +0 -0
  105. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/.gitattributes +0 -0
  106. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/.gitignore +0 -0
  107. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/LICENSE +0 -0
  108. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/TODO.md +0 -0
  109. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/images/screenshot-1.png +0 -0
  110. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/images/screenshot-2.png +0 -0
  111. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/errors.py +0 -0
  112. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/input/outcome.py +0 -0
  113. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/output/layout.py +0 -0
  114. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/sparcli/py.typed +0 -0
  115. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_datepicker.py +0 -0
  116. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_fuzzy.py +0 -0
  117. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_public_api.py +0 -0
  118. {py_sparcli-0.3.0 → py_sparcli-0.4.0}/tests/test_textarea.py +0 -0
@@ -0,0 +1,94 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
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
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.4.0] – 2026-07-19
10
+
11
+ ### Added
12
+
13
+ - `Card`, a filled counterpart to `Panel`: a colored surface with its own title and footer rows instead of a title embedded in the border. A single `accent()` derives the whole palette through HSL – the title keeps the accent saturated, the body text and both backgrounds become desaturated, darker shades of the same hue. The border is opt-in, the card fills the width it is rendered into, content wraps, and title, body and footer each take their own padding and alignment. The style setters patch the derived values rather than replacing them. Below truecolor support the backgrounds are dropped and the card renders as accented text, because the derived shades would collapse onto one ANSI-16 color. Ported from the Rust original, whose output it matches byte for byte.
14
+ - `BorderType.TALL`, a thin block border around a card's filled surface: the side bars ink a quarter of their cell's width and the top and bottom lines an eighth of their cell's height, which comes out equally thick because a terminal cell is about twice as tall as it is wide, and the horizontal lines run across the corner cells so the corners close. Only `Card` draws it natively – the bars need a filled surface to read against, all four edges use a different glyph, and the right-hand one is painted with foreground and background swapped, none of which `BorderChars` can express. Every other widget receives the `BorderType.THICK` glyphs from `BorderType.chars`.
15
+ - `Color.to_rgb` returns the 24-bit value of any color: named colors and palette indices resolve through the standard xterm palette (fixed table, 6x6x6 cube, grayscale ramp). `Color.RESET` has no fixed value and returns `None`.
16
+ - `width.wrap_line` and `width.truncate_line` are style-preserving counterparts to `wrap` and `truncate`. They keep each span's style and hyperlink, and a word straddling a span boundary stays whole instead of being wrapped apart.
17
+ - `core.command`, holding the quote-aware `split_command` and the `resolve_from_env` precedence rule. Both `input/editor.py` and `output/pager.py` had carried their own private copy of the splitter. Mirrors the Rust port.
18
+ - `terminal.is_error_tty`, `terminal.output_width` and `terminal.UNCONSTRAINED_WIDTH` expose which stream a widget draws to and how wide printed output should be laid out. Mirrors the Rust port.
19
+ - Coverage gate: `pytest` now measures branch coverage of `sparcli` and fails below the configured threshold.
20
+ - Direct test coverage for `output/box.py`, `output/table/plan.py`, the key decoding primitives, persisted history and the external editor/pager command handling.
21
+
22
+ ### Changed
23
+
24
+ - Progress indicators (`ProgressBar`, `Spinner`, `MultiProgress`) now draw on standard error rather than standard output, and only when standard error is itself a terminal. A caller piping standard output onward no longer receives animation frames in the payload. Interactive prompts and `Live` continue to draw on standard output. Mirrors the Rust port.
25
+ - `Renderable.print` and `print_to` no longer truncate when standard output is not a terminal. Previously they laid out for an invented 80 columns, so piping a wide table clipped its cells with `…` and lost data without saying so. An explicit `render(max_width)` is unaffected. Mirrors the Rust port.
26
+ - A `Card` without an explicit `width` now lays out at its natural content width when the available width is unconstrained, instead of filling it. Mirrors the Rust port.
27
+ - A blank `Pager` command override now falls through to `$PAGER` and the platform default instead of raising `ConfigError`. Blank counts as unset everywhere, which is how `$EDITOR` already behaved. An unparsable command still raises `ConfigError`. Mirrors the Rust port.
28
+ - `sparcli.core.width` is a package: `measure` holds the plain-string helpers, `line` the style-preserving ones. Every importer keeps writing `from sparcli.core.width import ...`, and the `sparcli.width` namespace is unchanged.
29
+ - The Ruff rule set now covers the full documented list (docstrings, annotations, complexity, security, pathlib, pytest and more) instead of nine groups; every exception carries a comment explaining why.
30
+ - `InPlace` moved from `sparcli.output.live` to `sparcli.core.inplace` so that `input` no longer depends on `output`. It is still re-exported from `sparcli.output.live` and the flat `sparcli` namespace, so imports keep working.
31
+ - `TextInput` delegates suggestion handling to `sparcli.input.completion.Completion` and history recall to `sparcli.input.recall.HistoryRecall`. Public behavior and the builder API are unchanged.
32
+ - The list prompts share `sparcli.input.selection` for cursor movement, scrolling and result collection; the free-text prompts share the Ctrl-key tables and the caret keys from `sparcli.input.line_edit`.
33
+ - `ProgressBar` keeps its appearance in a `ProgressOpts` value object, mirroring how `Table` uses `TableOpts`.
34
+ - The byte-level primitives behind the terminal event source live in `sparcli.input.keydecode`.
35
+
36
+ ### Fixed
37
+
38
+ - `$EDITOR`, `$VISUAL` and `$PAGER` are split with `shlex` instead of `str.split`, so an editor or pager path containing spaces no longer breaks into invalid arguments.
39
+ - A pager that cannot be spawned now raises `TerminalError` instead of a bare `FileNotFoundError`, matching how the editor already behaved.
40
+ - The history state directory is resolved to an absolute path, and an application name containing a path separator or `..` is rejected instead of being written outside the state directory.
41
+ - `History.load` keeps only the newest `max_entries` lines instead of reading the whole file into memory. The file is foreign input and may have been written by a build with a larger limit. Mirrors the Rust port.
42
+
43
+ ## [0.3.0] – 2026-07-11
44
+
45
+ ### Added
46
+
47
+ - `Style.remove_modifier` clears one or more attributes from a layered style (the counterpart to `add_modifier`).
48
+ - `TextInput` and `PasswordInput` gained keyword-only constructor options mirroring their fluent setters, so they configure like every other widget.
49
+ - `OutcomeKind` is re-exported from the flat `sparcli` namespace.
50
+
51
+ ### Changed
52
+
53
+ - Hide the terminal hardware cursor during in-place redraws (spinner, progress, multi-progress, live) and interactive prompts, and restore it reliably on finish, on prompt exit and at interpreter exit.
54
+
55
+ ### Fixed
56
+
57
+ - `DatePicker` navigation past the representable date range (before year 1 or after year 9999) no longer raises; it clamps to `date.min` / `date.max`.
58
+ - Inline markup no longer swallows a closed bracket that names no known style or attribute (such as `array[0]`); such text is emitted literally.
59
+ - `char_width` no longer raises on an empty or multi-character string.
60
+ - A negative `NumberInput.decimals` setting is clamped to zero instead of raising when the value is formatted.
61
+ - The raw-mode paths in `TerminalGuard` and the external-editor suspension now also catch `termios.error`, so a non-terminal stdin falls back to the documented no-op instead of raising (`termios.error` is not an `OSError`).
62
+ - `TextInput`'s Ctrl-G external editor now runs with raw mode suspended (cooked), matching `Textarea` and the Rust original; previously the editor launched while the terminal was still in raw mode.
63
+ - `KeyValue` rows are no longer padded with trailing spaces when there is no right margin; shorter rows stay ragged, matching the Rust original.
64
+ - Word wrapping splits on runs of whitespace (tabs, repeated spaces), matching Rust's `split_whitespace`, instead of on a single space only.
65
+ - History files are split into entries on newlines only (like Rust's `str::lines`), so an entry containing an exotic line separator is no longer split in two.
66
+
67
+ ### Security
68
+
69
+ - Control characters (C0, DEL and C1, except tab) are stripped from span content and OSC-8 link URLs before they reach the terminal, so untrusted text can no longer inject escape sequences or terminate a hyperlink early.
70
+ - History files are written atomically (temp file plus rename), so a crash or a concurrent writer can no longer truncate the file.
71
+
72
+ ### Documentation
73
+
74
+ - Align the minor version with the Rust `sparcli` port (both at 0.3.x); the two ports now track the same minor number.
75
+ - Add `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `TODO.md`, README badges and editor/VCS tooling configs (`.editorconfig`, `.gitattributes`); migrate packaging metadata to an SPDX license expression and expand the project URLs for PyPI.
76
+
77
+ ### Notes
78
+
79
+ - `DatePicker`'s initial "today" uses the local date here, while the Rust original uses UTC (it stays dependency-free). This one-point divergence is intentional; near midnight the default day can differ by one between the two ports.
80
+
81
+ ## [0.1.0] – 2026-07-10
82
+
83
+ ### Added
84
+
85
+ - Initial release: idiomatic Python port of the Rust `sparcli` library.
86
+ - Styled output widgets: `Panel`, `Table`, `List`, `Rule`, `Alert`, `Badge`, `Columns`, `Tree`, `KeyValue`, `Diff`, `Spinner`, `ProgressBar`, `MultiProgress`, `Live`, `Pager`.
87
+ - Interactive input widgets: `TextInput`, `PasswordInput`, `NumberInput`, `Confirm`, `Select`, `FuzzySelect`, `DatePicker`, `Textarea`.
88
+ - Unified `Theme`, inline `markup`, width utilities, terminal capability detection, validators, shortcuts and command history.
89
+ - Dependency-free (standard library only).
90
+
91
+ [Unreleased]: https://github.com/cgroening/py-sparcli/compare/v0.4.0...HEAD
92
+ [0.4.0]: https://github.com/cgroening/py-sparcli/compare/v0.3.0...v0.4.0
93
+ [0.3.0]: https://github.com/cgroening/py-sparcli/compare/v0.1.0...v0.3.0
94
+ [0.1.0]: https://github.com/cgroening/py-sparcli/releases/tag/v0.1.0
@@ -27,12 +27,24 @@ Requirements for all future sessions in this project. On conflict, this file tak
27
27
 
28
28
  ## Architecture (keep the layers strictly separated)
29
29
 
30
- - `core/` – foundation: style, color, text, render, geometry, border, theme, width, terminal, markup. No widget logic.
30
+ - `core/` – foundation: style, color, text, render, geometry, border, theme, width, terminal, markup, `inplace` (the redraw engine), `command` (quote-aware splitting and env resolution for `$EDITOR`/`$PAGER`). No widget logic.
31
31
  - `output/` – printable widgets, subclass `Renderable` (`render(max_width) -> Rendered`). Shared helpers: `layout`, `compose`, `box`, `live`.
32
- - `input/` – interactive prompts over `EventSource` (DI) + the `prompt.run_prompt` loop + `line_edit.LineEditor` (SSOT for text editing) + `field` (rendering).
33
- - **Dependency direction:** `output`/`input` → `core`. Never cyclic, never `core` → widget layer.
32
+ - `input/` – interactive prompts over `EventSource` (DI) + the `prompt.run_prompt` loop and `prompt.run_on_terminal` guard + `line_edit.LineEditor` (SSOT for text editing, plus the shared `CTRL_ACTIONS` tables and `apply_caret_key`) + `field` (rendering). The prompts are composed from small collaborators: `completion.Completion`, `recall.HistoryRecall`, `selection.SelectionCursor` and the `keydecode` byte primitives.
33
+ - **Dependency direction:** `output`/`input` → `core`. Never cyclic, never `core` → widget layer, and never `input` → `output` (this is why `InPlace` lives in `core/inplace.py`; `output/live.py` re-exports it for the public API).
34
34
  - **A single unified theme** in `core/theme.py` drives both input and output.
35
- - The public API is re-exported flat (`from sparcli import ...`, 80 symbols) plus the namespaces `sparcli.width/terminal/markup/event/validate/shortcut`. Every `__init__.py` carries an explicit `__all__` list.
35
+
36
+ ### One helper per rule
37
+
38
+ Where a rule applies in more than one widget it lives in exactly one place. Do not reimplement these per call site: `core.width.truncate`/`truncate_line` for `…` clipping, `input.selection.SelectionCursor` for cursor and scroll window, `input.prompt.run_on_terminal` for the terminal handover, `core.command.split_command`/`resolve_from_env` for external commands.
39
+
40
+ ## Output streams (§1.6)
41
+
42
+ sparcli is a library, not a CLI program, so §1.6 binds it only through what it prints:
43
+
44
+ - Payload goes to **stdout**, progress indicators to **stderr**, and progress draws only when stderr is a terminal. `InPlace.progress()` is the seam.
45
+ - `print`/`print_to` do **not** truncate when stdout is not a terminal: they lay out at `UNCONSTRAINED_WIDTH`, because clipping to an invented width loses piped data silently. An explicit `render(max_width)` still truncates – that width is the caller's decision.
46
+ - §1.7 (TUI) rules – scrollbar on overflow, a `?` overlay in every prompt, `Ctrl+Q` – are deliberately **not** implemented. They govern terminal UIs, not a CLI output library.
47
+ - The public API is re-exported flat (`from sparcli import ...`, 81 symbols) plus the namespaces `sparcli.width/terminal/markup/event/validate/shortcut`. Every `__init__.py` carries an explicit `__all__` list.
36
48
 
37
49
  ## Dependencies
38
50
 
@@ -47,17 +59,19 @@ Requirements for all future sessions in this project. On conflict, this file tak
47
59
  ## Style & tooling
48
60
 
49
61
  - Target Python: **3.12+** (`requires-python = ">=3.12"`).
50
- - **Strings: double quotes `"..."`** – a deliberate, user-mandated deviation from the style-guide default (§12.5). Enforced via `[tool.ruff.format] quote-style = "double"`. Exceptions that stay single-quoted: string literals nested inside a double-quoted f-string (`';'`, `' '`) and `repr` outputs in doctests.
51
- - **Formatting/linting:** `ruff` (format + lint). 80-character lines. Imports isort-sorted (stdlib → third-party → local). `from __future__ import annotations` as the first line of every module.
62
+ - **Strings: double quotes `"..."`** everywhere, including nested inside f-strings (PEP 701). Enforced via the `Q` lint rules with `inline-quotes = "double"` and `avoid-escape = false`; the formatter uses `quote-style = "preserve"` so it cannot rewrite the nested case back to single quotes. `repr` outputs in doctests keep their single quotes, because that is what Python prints.
63
+ - **Formatting/linting:** `ruff` (format + lint), running the full rule set from the style guide. 80-character lines, enforced (`E501` is on). Imports isort-sorted (stdlib → third-party → local). `from __future__ import annotations` as the first line of every module. Every entry in the `ignore` list carries a comment saying why.
64
+ - **Docstrings:** NumPy convention. The fluent builder setters keep a single-line docstring instead of full `Parameters`/`Returns` blocks – they take one argument and return `self`, so the blocks would add length without information. Full blocks are required everywhere else.
52
65
  - **Type checking:** `basedpyright` in **strict** mode must report 0 errors. Complete, modern type hints (`X | None`, builtin generics, PEP 695).
53
66
  - No em dash; straight quotes/apostrophes; named constants instead of magic numbers/strings.
54
67
 
55
- Commands (from the project root):
68
+ Commands (from the project root, all four must be clean):
56
69
 
57
70
  ```bash
58
71
  ruff check --fix . && ruff format .
59
72
  basedpyright sparcli examples
60
- python -m pytest -q
73
+ python -m pytest -q # includes doctests and the branch-coverage gate
74
+ pip-audit .
61
75
  ```
62
76
 
63
77
  ## API conventions (idiomatic)
@@ -91,6 +105,8 @@ python -m pytest -q
91
105
  - Doctests in `# Examples` count as tests (`--doctest-modules`) and must pass. `repr` expected-outputs keep single quotes.
92
106
  - Test override: `SPARCLI_NO_TTY=1` forces non-TTY.
93
107
  - **Run all tests after every change** and keep ruff + basedpyright clean.
108
+ - Shared helpers and fixtures live in `tests/conftest.py` (`plain_lines`, `joined`, `render_to_string`, the theme reset and the `state_home` fixture). Import them as `from conftest import ...`.
109
+ - Branch coverage is measured on every run and gated by `fail_under` in `[tool.coverage.report]`. Do not lower it to make a change pass.
94
110
 
95
111
  ## Environment variables
96
112
 
@@ -16,12 +16,7 @@ ruff format --check . # formatting (drop --check to apply)
16
16
  basedpyright sparcli examples # strict type checking
17
17
  ```
18
18
 
19
- All three checks must be clean before a change is ready. Optionally, install the pre-commit hooks to run linting, formatting and type checks automatically:
20
-
21
- ```bash
22
- pre-commit install # once
23
- pre-commit run --all-files # on demand
24
- ```
19
+ All four checks must be clean before a change is ready.
25
20
 
26
21
  ## Ground rules
27
22
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-sparcli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Styled CLI output and interactive input widgets, dependency-free
5
5
  Project-URL: Homepage, https://github.com/cgroening/py-sparcli
6
6
  Project-URL: Repository, https://github.com/cgroening/py-sparcli
@@ -40,7 +40,7 @@ sparcli is a native Python port of the Rust library of the same name. It renders
40
40
 
41
41
  ## Highlights
42
42
 
43
- - Output: styled text, inline markup, tables (colspan, rowspan, striping, wrapping, titles), panels, alerts, rules, lists, trees, key-value lists, badges, progress bars, spinners, multi-progress, diffs, columns, live in-place display, pager, and the composition helpers `align`, `pad` and `vstack`.
43
+ - Output: styled text, inline markup, tables (colspan, rowspan, striping, wrapping, titles), panels, cards (a filled surface whose whole palette is derived from one accent color), alerts, rules, lists, trees, key-value lists, badges, progress bars, spinners, multi-progress, diffs, columns, live in-place display, pager, and the composition helpers `align`, `pad` and `vstack`.
44
44
  - Input: confirm, text (validation, character filters, history, ghost autocomplete, dropdown), password, number (with a calculator), textarea, single and multi select, an inline fuzzy select, and a calendar date picker.
45
45
  - A unified `Theme` for input and output, set once and overridable per call.
46
46
  - Robust by design: prompts never raise on input, a RAII terminal guard restores the terminal, and results come back as values rather than exceptions.
@@ -66,7 +66,7 @@ sparcli requires Python 3.12 or newer.
66
66
  | Category | Components |
67
67
  | --- | --- |
68
68
  | Text and style | `Style`, `Color`, `Attribute`, `Span`, `Line`, `Text`, `markup` |
69
- | Framing and layout | `Panel`, `Rule`, `Columns`, `align`, `pad`, `vstack`, `BorderType`, `Align`, `Edges`, `Title` |
69
+ | Framing and layout | `Panel`, `Card`, `Rule`, `Columns`, `align`, `pad`, `vstack`, `BorderType`, `Align`, `Edges`, `Title` |
70
70
  | Data widgets | `Table` (`Column`, `Cell`), `List` (`Marker`), `Tree` (`TreeNode`), `KeyValue`, `Diff`, `Badge`, `Alert` (`AlertKind`) |
71
71
  | Progress and live | `Spinner` (`SpinnerStyle`), `ProgressBar` (`ProgressStyle`, `Thresholds`), `MultiProgress`, `Live`, `Pager` |
72
72
  | Input prompts | `TextInput`, `PasswordInput`, `NumberInput`, `Confirm`, `Select`, `FuzzySelect`, `DatePicker`, `Textarea` |
@@ -99,7 +99,7 @@ Table().columns(["Name", "Status"]).row(["web-1", "online"]).row(
99
99
  ╰───────┴────────╯
100
100
  ```
101
101
 
102
- A `Panel` frames content with a rounded border and an optional title. A left-aligned title reads as part of the frame: one connecting border glyph sits before it, never a flush corner - unless the title is too wide for the frame, in which case it is truncated into the border rather than widening the panel.
102
+ A `Panel` frames content with a rounded border and an optional title. A left-aligned title reads as part of the frame: one connecting border glyph sits before it, never a flush corner – unless the title is too wide for the frame, in which case it is truncated into the border rather than widening the panel.
103
103
 
104
104
  ```python
105
105
  from sparcli import Panel
@@ -113,6 +113,30 @@ Panel("All systems nominal.").title("Status").print()
113
113
  ╰──────────────────────╯
114
114
  ```
115
115
 
116
+ Where a `Panel` draws a frame, a `Card` fills a surface. It takes a single accent color and derives everything else from it through HSL: the title stays saturated, the body text and both backgrounds become desaturated, darker shades of the same hue. Title and footer sit on rows of their own, each with its own background, and each region takes its own padding and alignment.
117
+
118
+ ```python
119
+ from sparcli import BorderType, Card, Color
120
+
121
+ Card("Deployed to production.").title("Release 1.4.0").footer(
122
+ "3 minutes ago"
123
+ ).accent(Color.from_hex("#a6e3a1") or Color.GREEN).border(
124
+ BorderType.TALL
125
+ ).width(40).print()
126
+ ```
127
+
128
+ ```text
129
+ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
130
+ ┃ Release 1.4.0 ┃
131
+ ┃ ┃
132
+ ┃ Deployed to production. ┃
133
+ ┃ ┃
134
+ ┃ 3 minutes ago ┃
135
+ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
136
+ ```
137
+
138
+ That block is what the card degrades to, because the preview above is captured without color. With truecolor, `BorderType.TALL` draws a thin block frame instead: the side bars ink a quarter of their cell's width and the top and bottom lines an eighth of their cell's height, which comes out equally thick since a terminal cell is about twice as tall as it is wide, and the horizontal lines run across the corner cells so the corners close. Only `Card` renders it that way – the bars need a filled surface to read against, so every other framed widget receives the heavy line glyphs shown here. The same applies to a card without truecolor support, and `BorderType.ASCII` takes over when the theme disables Unicode.
139
+
116
140
  ## Input example
117
141
 
118
142
  Prompts return an `Outcome` – a submitted value, a cancellation, or a fired shortcut – and never raise on input. They require an interactive terminal; without one, `run()` raises `NoTerminalError`. Each prompt also has a `frame()` method that renders its static opening frame without a TTY, which is how the previews below are produced.
@@ -14,7 +14,7 @@ sparcli is a native Python port of the Rust library of the same name. It renders
14
14
 
15
15
  ## Highlights
16
16
 
17
- - Output: styled text, inline markup, tables (colspan, rowspan, striping, wrapping, titles), panels, alerts, rules, lists, trees, key-value lists, badges, progress bars, spinners, multi-progress, diffs, columns, live in-place display, pager, and the composition helpers `align`, `pad` and `vstack`.
17
+ - Output: styled text, inline markup, tables (colspan, rowspan, striping, wrapping, titles), panels, cards (a filled surface whose whole palette is derived from one accent color), alerts, rules, lists, trees, key-value lists, badges, progress bars, spinners, multi-progress, diffs, columns, live in-place display, pager, and the composition helpers `align`, `pad` and `vstack`.
18
18
  - Input: confirm, text (validation, character filters, history, ghost autocomplete, dropdown), password, number (with a calculator), textarea, single and multi select, an inline fuzzy select, and a calendar date picker.
19
19
  - A unified `Theme` for input and output, set once and overridable per call.
20
20
  - Robust by design: prompts never raise on input, a RAII terminal guard restores the terminal, and results come back as values rather than exceptions.
@@ -40,7 +40,7 @@ sparcli requires Python 3.12 or newer.
40
40
  | Category | Components |
41
41
  | --- | --- |
42
42
  | Text and style | `Style`, `Color`, `Attribute`, `Span`, `Line`, `Text`, `markup` |
43
- | Framing and layout | `Panel`, `Rule`, `Columns`, `align`, `pad`, `vstack`, `BorderType`, `Align`, `Edges`, `Title` |
43
+ | Framing and layout | `Panel`, `Card`, `Rule`, `Columns`, `align`, `pad`, `vstack`, `BorderType`, `Align`, `Edges`, `Title` |
44
44
  | Data widgets | `Table` (`Column`, `Cell`), `List` (`Marker`), `Tree` (`TreeNode`), `KeyValue`, `Diff`, `Badge`, `Alert` (`AlertKind`) |
45
45
  | Progress and live | `Spinner` (`SpinnerStyle`), `ProgressBar` (`ProgressStyle`, `Thresholds`), `MultiProgress`, `Live`, `Pager` |
46
46
  | Input prompts | `TextInput`, `PasswordInput`, `NumberInput`, `Confirm`, `Select`, `FuzzySelect`, `DatePicker`, `Textarea` |
@@ -73,7 +73,7 @@ Table().columns(["Name", "Status"]).row(["web-1", "online"]).row(
73
73
  ╰───────┴────────╯
74
74
  ```
75
75
 
76
- A `Panel` frames content with a rounded border and an optional title. A left-aligned title reads as part of the frame: one connecting border glyph sits before it, never a flush corner - unless the title is too wide for the frame, in which case it is truncated into the border rather than widening the panel.
76
+ A `Panel` frames content with a rounded border and an optional title. A left-aligned title reads as part of the frame: one connecting border glyph sits before it, never a flush corner – unless the title is too wide for the frame, in which case it is truncated into the border rather than widening the panel.
77
77
 
78
78
  ```python
79
79
  from sparcli import Panel
@@ -87,6 +87,30 @@ Panel("All systems nominal.").title("Status").print()
87
87
  ╰──────────────────────╯
88
88
  ```
89
89
 
90
+ Where a `Panel` draws a frame, a `Card` fills a surface. It takes a single accent color and derives everything else from it through HSL: the title stays saturated, the body text and both backgrounds become desaturated, darker shades of the same hue. Title and footer sit on rows of their own, each with its own background, and each region takes its own padding and alignment.
91
+
92
+ ```python
93
+ from sparcli import BorderType, Card, Color
94
+
95
+ Card("Deployed to production.").title("Release 1.4.0").footer(
96
+ "3 minutes ago"
97
+ ).accent(Color.from_hex("#a6e3a1") or Color.GREEN).border(
98
+ BorderType.TALL
99
+ ).width(40).print()
100
+ ```
101
+
102
+ ```text
103
+ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
104
+ ┃ Release 1.4.0 ┃
105
+ ┃ ┃
106
+ ┃ Deployed to production. ┃
107
+ ┃ ┃
108
+ ┃ 3 minutes ago ┃
109
+ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
110
+ ```
111
+
112
+ That block is what the card degrades to, because the preview above is captured without color. With truecolor, `BorderType.TALL` draws a thin block frame instead: the side bars ink a quarter of their cell's width and the top and bottom lines an eighth of their cell's height, which comes out equally thick since a terminal cell is about twice as tall as it is wide, and the horizontal lines run across the corner cells so the corners close. Only `Card` renders it that way – the bars need a filled surface to read against, so every other framed widget receives the heavy line glyphs shown here. The same applies to a card without truecolor support, and `BorderType.ASCII` takes over when the theme disables Unicode.
113
+
90
114
  ## Input example
91
115
 
92
116
  Prompts return an `Outcome` – a submitted value, a cancellation, or a fired shortcut – and never raise on input. They require an interactive terminal; without one, `run()` raises `NoTerminalError`. Each prompt also has a `frame()` method that renders its static opening frame without a TTY, which is how the previews below are produced.
@@ -5,15 +5,29 @@ How to build, test and work on `sparcli`. This is a dependency-free Python libra
5
5
  ## Prerequisites
6
6
 
7
7
  - Python 3.12 or newer (the code uses PEP 695 generics and `type` statements).
8
- - The dev tools `ruff`, `basedpyright` and `pytest`. No runtime dependencies and no system dependencies beyond a terminal.
8
+ - The dev tools `ruff`, `basedpyright`, `pytest` and `pytest-cov`, plus `pip-audit`. No runtime dependencies and no system dependencies beyond a terminal.
9
9
 
10
10
  Install the package in editable mode together with the dev tools:
11
11
 
12
12
  ```bash
13
13
  pip install -e .
14
- pip install ruff basedpyright pytest
14
+ pip install --group dev
15
15
  ```
16
16
 
17
+ ## The quality gate
18
+
19
+ Four commands define what has to pass. All four must be clean before a change is ready:
20
+
21
+ ```bash
22
+ ruff check . # lint
23
+ ruff format --check . # formatting
24
+ basedpyright sparcli examples # strict type checking, zero errors
25
+ python -m pytest -q # tests, doctests and the coverage gate
26
+ pip-audit . # known vulnerabilities in the dev toolchain
27
+ ```
28
+
29
+ `ruff check --fix . && ruff format .` applies what ruff can repair on its own. The sections below spell each step out.
30
+
17
31
  ## Test
18
32
 
19
33
  The whole suite is headless: output widgets render to an in-memory `Rendered` block and are asserted on their visible text, and input prompts are driven by a scripted fake event source. No test needs a real terminal, so it is safe to run in CI.
@@ -22,7 +36,7 @@ The whole suite is headless: output widgets render to an in-memory `Rendered` bl
22
36
  python -m pytest -q
23
37
  ```
24
38
 
25
- The pytest configuration in `pyproject.toml` also runs docstring examples (`--doctest-modules`) across the package and ignores the `examples/` directory. A few focused runs:
39
+ The pytest configuration in `pyproject.toml` also runs docstring examples (`--doctest-modules`) across the package, ignores the `examples/` directory, turns warnings into errors and measures branch coverage. The run fails if coverage drops below the `fail_under` threshold in `[tool.coverage.report]`. A few focused runs:
26
40
 
27
41
  ```bash
28
42
  python -m pytest tests/test_table.py # one test module
@@ -38,7 +52,15 @@ ruff format --check . # formatting (drop --check to apply)
38
52
  basedpyright sparcli examples # strict type checking
39
53
  ```
40
54
 
41
- Both `ruff check` and `basedpyright` must be clean. The project targets strict typing: every public item is annotated, and `ruff` enforces double quotes, an 80-column line length (the `E501` long-line rule is relaxed because rendered strings and docstrings sometimes exceed it) and an import ordering.
55
+ Both `ruff check` and `basedpyright` must be clean. The project targets strict typing: every public item is annotated, and `ruff` runs the full documented rule set, including docstring style (NumPy convention), annotations, complexity, security (`flake8-bandit`), `pathlib` usage and import ordering. Line length is enforced at 80 columns.
56
+
57
+ Every entry in the `ignore` list in `pyproject.toml` carries a comment saying why it is there. The two that most often surprise newcomers: `D401` is off because the style guide mandates third-person docstring summaries ("Returns ...") where that rule wants the imperative, and `FBT001`/`FBT002` are off because the fluent builder API takes booleans by design (`Table.striped(True)`) to mirror the Rust port.
58
+
59
+ Dependencies are audited separately:
60
+
61
+ ```bash
62
+ pip-audit .
63
+ ```
42
64
 
43
65
  ## Run the examples
44
66
 
@@ -56,15 +78,22 @@ Piping the static examples (`| cat`, `> file`) or setting `NO_COLOR=1` yields pl
56
78
 
57
79
  ```text
58
80
  sparcli/
59
- core/ Foundation: color, style, text, markup, theme, border, geometry,
60
- width, terminal, render. No widget logic.
61
- output/ Printable widgets implementing Renderable.
81
+ core/ Foundation: color, hsl, style, text, markup, theme, border,
82
+ geometry, width, terminal, render, inplace. No widget logic.
83
+ width/ is a package: measure holds the plain-string helpers,
84
+ line the style-preserving ones.
85
+ output/ Printable widgets implementing Renderable. A widget large enough
86
+ to warrant it becomes a package instead of a module, splitting
87
+ along responsibilities: table/ and card/ do this.
62
88
  input/ Interactive prompts over an EventSource, plus the shared line
63
- editor, terminal guard and prompt driver.
89
+ editor, terminal guard, prompt driver and the collaborators the
90
+ prompts are composed from: completion, recall, selection and the
91
+ keydecode primitives.
64
92
  errors.py The SparcliError hierarchy.
65
93
  examples/ Runnable demos (output_gallery, output_dynamic, prompts,
66
94
  output_readme, prompt_readme).
67
- tests/ Headless tests over the public API.
95
+ tests/ Headless tests over the public API; shared helpers and fixtures
96
+ live in tests/conftest.py.
68
97
  docs/ This document.
69
98
  ```
70
99
 
@@ -72,7 +101,7 @@ The public API is the curated set re-exported at the package root (`sparcli/__in
72
101
 
73
102
  ## Dependency direction
74
103
 
75
- The dependency direction is strictly one-way: `output` and `input` depend on `core`, never the reverse, and `core` depends on nothing internal. Keep it that way when adding code – a `core` module must not import from `output` or `input`. This keeps the foundation reusable and the layering easy to reason about.
104
+ The dependency direction is strictly one-way: `output` and `input` depend on `core`, never the reverse, and `core` depends on nothing internal. `input` must not depend on `output` either, which is why the in-place redraw engine `InPlace` lives in `sparcli.core.inplace` rather than next to `Live`. Keep it that way when adding code – a `core` module must not import from `output` or `input`. This keeps the foundation reusable and the layering easy to reason about.
76
105
 
77
106
  ## Environment variables
78
107
 
@@ -94,7 +123,8 @@ from sparcli import Table
94
123
  def test_table_has_a_header_row() -> None:
95
124
  rendered = Table().columns(["A", "B"]).row(["1", "2"]).render(80)
96
125
  lines = [line.plain() for line in rendered.lines]
97
- assert "A" in lines[1] and "B" in lines[1]
126
+ assert "A" in lines[1]
127
+ assert "B" in lines[1]
98
128
  ```
99
129
 
100
130
  Input prompts are driven headlessly through `run_with(source)`, where the source is a `ScriptedSource` of queued keys. The scripted source auto-cancels on exhaustion, so a prompt can never loop forever in a test:
@@ -117,5 +147,5 @@ def test_types_and_submits() -> None:
117
147
  2. Keep the dependency direction: import only from `core` (and, within a layer, sibling helpers). Never import `output` or `input` from `core`.
118
148
  3. Add unit tests under `tests/`, following the patterns above. Name tests for the behavior they assert.
119
149
  4. Re-export the public type from `sparcli/__init__.py` and add it to `__all__`; free-function utilities go into the relevant facade submodule.
120
- 5. Run `python -m pytest -q`, `ruff check .`, `ruff format .` and `basedpyright sparcli` until clean.
150
+ 5. Run the quality gate above until clean.
121
151
  6. Add a docstring to every public item (with a `# Examples` doctest where the usage is not obvious) and update `README.md` and, if relevant, the example gallery.
@@ -1,4 +1,5 @@
1
- """Time-based and interactive output widgets.
1
+ """
2
+ Time-based and interactive output widgets.
2
3
 
3
4
  Covers the spinner, a single progress bar, a multi-progress group, a live
4
5
  in-place redraw and the pager. Run it in a real terminal for the full effect:
@@ -1,4 +1,5 @@
1
- """A gallery of every static output widget.
1
+ """
2
+ A gallery of every static output widget.
2
3
 
3
4
  Run it with ``python examples/output_gallery.py``. Everything here renders to a
4
5
  fixed frame, so the output is deterministic and pipe-friendly: piping it
@@ -14,6 +15,7 @@ from sparcli import (
14
15
  Align,
15
16
  Badge,
16
17
  BorderType,
18
+ Card,
17
19
  Cell,
18
20
  Color,
19
21
  Column,
@@ -126,6 +128,32 @@ def panels() -> None:
126
128
  ).width(40).print()
127
129
 
128
130
 
131
+ def cards() -> None:
132
+ """Cards: filled surfaces whose colors all come from one accent."""
133
+ section("Cards")
134
+ Card("Every tone is derived from the theme accent.").title("Default").width(
135
+ 52
136
+ ).print()
137
+ print()
138
+ Card("A single accent sets title, surface, text and border.").title(
139
+ "Derived from one color"
140
+ ).footer("border and footer, same accent").accent(
141
+ Color.from_hex("#a6e3a1") or Color.GREEN
142
+ ).border(BorderType.ROUNDED).width(52).print()
143
+ print()
144
+ Card("A thin block frame with even strokes and closed corners.").title(
145
+ "Tall border"
146
+ ).accent(Color.from_hex("#cba6f7") or Color.MAGENTA).border(
147
+ BorderType.TALL
148
+ ).title_padding(Edges.symmetric(1, 1)).width(52).print()
149
+ print()
150
+ Card("Centered, flat title, wider padding.").title("Flat").accent(
151
+ Color.from_hex("#f9e2af") or Color.YELLOW
152
+ ).flat_title().title_align(Align.CENTER).content_align(
153
+ Align.CENTER
154
+ ).padding(Edges.symmetric(1, 3)).width(52).print()
155
+
156
+
129
157
  def tables() -> None:
130
158
  """Tables: colspan plus striping, and a footer with a wrapping column."""
131
159
  section("Tables")
@@ -252,6 +280,7 @@ def main() -> None:
252
280
  sections()
253
281
  alerts()
254
282
  panels()
283
+ cards()
255
284
  tables()
256
285
  lists_and_trees()
257
286
  key_values_and_badges()
@@ -1,4 +1,5 @@
1
- """The output showcase collage embedded in the README.
1
+ """
2
+ The output showcase collage embedded in the README.
2
3
 
3
4
  Prints a titled hero panel, a three-column dashboard (table | list + tree |
4
5
  key-value + badges) and a progress bar. Everything is deterministic, so
@@ -1,4 +1,5 @@
1
- """The input showcase collage embedded in the README.
1
+ """
2
+ The input showcase collage embedded in the README.
2
3
 
3
4
  Renders the static opening frame of every interactive prompt via its
4
5
  ``frame()`` method – no TTY and no interaction – and arranges them into a
@@ -1,4 +1,5 @@
1
- """Interactive prompt examples covering every input widget.
1
+ """
2
+ Interactive prompt examples covering every input widget.
2
3
 
3
4
  Run it in a real terminal: ``python examples/prompts.py``. Each prompt runs in
4
5
  turn; pressing Esc cancels that prompt and the demo reports it as cancelled
@@ -9,7 +10,7 @@ notice and exits cleanly when standard input or output is redirected.
9
10
  from __future__ import annotations
10
11
 
11
12
  import sys
12
- from collections.abc import Callable
13
+ from typing import TYPE_CHECKING
13
14
 
14
15
  from sparcli import (
15
16
  Confirm,
@@ -30,6 +31,9 @@ from sparcli import (
30
31
  validate,
31
32
  )
32
33
 
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Callable
36
+
33
37
 
34
38
  class _Block(Renderable):
35
39
  """Wraps a pre-rendered block so the footer hint can be printed."""