pyclack-lib 1.0.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 (59) hide show
  1. pyclack_lib-1.0.0/PKG-INFO +2212 -0
  2. pyclack_lib-1.0.0/README.md +2199 -0
  3. pyclack_lib-1.0.0/pyproject.toml +39 -0
  4. pyclack_lib-1.0.0/pyproject.toml.orig +32 -0
  5. pyclack_lib-1.0.0/src/pyclack/__init__.py +7 -0
  6. pyclack_lib-1.0.0/src/pyclack/config/__init__.py +3 -0
  7. pyclack_lib-1.0.0/src/pyclack/config/theme_conf.py +32 -0
  8. pyclack_lib-1.0.0/src/pyclack/prompts/__init__.py +19 -0
  9. pyclack_lib-1.0.0/src/pyclack/prompts/ask.py +258 -0
  10. pyclack_lib-1.0.0/src/pyclack/prompts/autocomplete.py +466 -0
  11. pyclack_lib-1.0.0/src/pyclack/prompts/autocomplete_multiselect.py +564 -0
  12. pyclack_lib-1.0.0/src/pyclack/prompts/confirm.py +203 -0
  13. pyclack_lib-1.0.0/src/pyclack/prompts/multiline.py +279 -0
  14. pyclack_lib-1.0.0/src/pyclack/prompts/multiselect.py +472 -0
  15. pyclack_lib-1.0.0/src/pyclack/prompts/password.py +251 -0
  16. pyclack_lib-1.0.0/src/pyclack/prompts/pick_date.py +444 -0
  17. pyclack_lib-1.0.0/src/pyclack/prompts/prompt_base.py +210 -0
  18. pyclack_lib-1.0.0/src/pyclack/prompts/select.py +346 -0
  19. pyclack_lib-1.0.0/src/pyclack/prompts/select_key.py +318 -0
  20. pyclack_lib-1.0.0/src/pyclack/prompts/select_path.py +521 -0
  21. pyclack_lib-1.0.0/src/pyclack/prompts/util.py +785 -0
  22. pyclack_lib-1.0.0/src/pyclack/prompts_async/__init__.py +11 -0
  23. pyclack_lib-1.0.0/src/pyclack/prompts_async/async_prompts.py +458 -0
  24. pyclack_lib-1.0.0/src/pyclack/py.typed +0 -0
  25. pyclack_lib-1.0.0/src/pyclack/renderer/__init__.py +9 -0
  26. pyclack_lib-1.0.0/src/pyclack/renderer/render_frame.py +122 -0
  27. pyclack_lib-1.0.0/src/pyclack/renderer/symbols.py +233 -0
  28. pyclack_lib-1.0.0/src/pyclack/renderer/text.py +146 -0
  29. pyclack_lib-1.0.0/src/pyclack/renderer/themes.py +1479 -0
  30. pyclack_lib-1.0.0/src/pyclack/terminal/__init__.py +4 -0
  31. pyclack_lib-1.0.0/src/pyclack/terminal/base_key.py +37 -0
  32. pyclack_lib-1.0.0/src/pyclack/terminal/cursor_controller.py +139 -0
  33. pyclack_lib-1.0.0/src/pyclack/terminal/echo_controller.py +60 -0
  34. pyclack_lib-1.0.0/src/pyclack/terminal/key_reader.py +60 -0
  35. pyclack_lib-1.0.0/src/pyclack/terminal/os_utils.py +19 -0
  36. pyclack_lib-1.0.0/src/pyclack/terminal/posix_keys.py +40 -0
  37. pyclack_lib-1.0.0/src/pyclack/terminal/stdout.py +33 -0
  38. pyclack_lib-1.0.0/src/pyclack/terminal/win_keys.py +34 -0
  39. pyclack_lib-1.0.0/src/pyclack/widgets/__init__.py +13 -0
  40. pyclack_lib-1.0.0/src/pyclack/widgets/activity.py +317 -0
  41. pyclack_lib-1.0.0/src/pyclack/widgets/box.py +81 -0
  42. pyclack_lib-1.0.0/src/pyclack/widgets/cancel.py +37 -0
  43. pyclack_lib-1.0.0/src/pyclack/widgets/intro.py +33 -0
  44. pyclack_lib-1.0.0/src/pyclack/widgets/log/__init__.py +1 -0
  45. pyclack_lib-1.0.0/src/pyclack/widgets/log/log.py +214 -0
  46. pyclack_lib-1.0.0/src/pyclack/widgets/note.py +51 -0
  47. pyclack_lib-1.0.0/src/pyclack/widgets/outro.py +34 -0
  48. pyclack_lib-1.0.0/src/pyclack/widgets/progress.py +329 -0
  49. pyclack_lib-1.0.0/src/pyclack/widgets/spinner.py +283 -0
  50. pyclack_lib-1.0.0/src/pyclack/widgets/stream/__init__.py +3 -0
  51. pyclack_lib-1.0.0/src/pyclack/widgets/stream/stream.py +40 -0
  52. pyclack_lib-1.0.0/src/pyclack/widgets/stream/util.py +81 -0
  53. pyclack_lib-1.0.0/src/pyclack/widgets/task_log.py +218 -0
  54. pyclack_lib-1.0.0/src/pyclack/widgets_async/__init__.py +11 -0
  55. pyclack_lib-1.0.0/src/pyclack/widgets_async/async_log/__init__.py +1 -0
  56. pyclack_lib-1.0.0/src/pyclack/widgets_async/async_log/async_log.py +85 -0
  57. pyclack_lib-1.0.0/src/pyclack/widgets_async/async_stream/__init__.py +3 -0
  58. pyclack_lib-1.0.0/src/pyclack/widgets_async/async_stream/async_stream.py +33 -0
  59. pyclack_lib-1.0.0/src/pyclack/widgets_async/async_widgets.py +502 -0
@@ -0,0 +1,2212 @@
1
+ Metadata-Version: 2.3
2
+ Name: pyclack-lib
3
+ Version: 1.0.0
4
+ Summary: A Python library for building beautiful interactive CLI prompts with clack-style UX.
5
+ Author: Maddox-RVS
6
+ Author-email: Maddox-RVS <>
7
+ Requires-Dist: colorama>=0.4.6
8
+ Requires-Dist: readchar>=4.2.2
9
+ Requires-Dist: rich>=15.0.0
10
+ Requires-Dist: strsimpy>=0.2.1
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+
14
+ ![GitHub last commit](https://img.shields.io/github/last-commit/Maddox-RVS/Ollamadex?style=for-the-badge)
15
+ ![GitHub repo size](https://img.shields.io/github/repo-size/Maddox-RVS/Ollamadex?style=for-the-badge)
16
+ [![License](https://img.shields.io/badge/License-MIT-blue?style=for-the-badge)](LICENSE)
17
+
18
+ ![Clack](https://img.shields.io/badge/Clack-Inspired-black?style=for-the-badge)
19
+ ![Python](https://img.shields.io/badge/Python-Implementation-blue?style=for-the-badge)
20
+
21
+ # pyclack
22
+
23
+ **pyclack** is a Python port of [Clack](https://github.com/bombshell-dev/clack), the beautiful and minimal command-line prompt library for JavaScript, originally created by [Nate Moore (@natemoo-re)](https://github.com/natemoo-re).
24
+
25
+ pyclack brings Clack's interactive prompts, terminal UI components, and styling to Python while maintaining the same philosophy of providing a simple API for building beautiful command-line applications.
26
+
27
+ > [!NOTE]
28
+ > pyclack is an independent Python implementation inspired by Clack. It is not affiliated with or maintained by the Clack/Bombshell project.
29
+
30
+ ![pyclack demo](assets/pyclack-demo.gif)
31
+
32
+ I miss-spelled "writing" 😔, but I'm too lazy to re-record and edit this GIF again so deal with it...
33
+
34
+ # Features
35
+
36
+ - Interactive prompts for text input, multiline input, passwords, confirmations, selections, autocomplete, dates, and filesystem paths
37
+ - Synchronous and asynchronous APIs
38
+ - Animated spinners and progress bars
39
+ - Streaming output
40
+ - Task logging and semantic logging
41
+ - Intro, outro, cancellation, notes, and boxed messages
42
+ - Cross-platform terminal support for Windows, Linux, and macOS
43
+ - Terminal cursor, echo, and keyboard input control
44
+ - Customizable themes and symbols
45
+ - Reusable terminal rendering system
46
+ - Type-safe Python API with bundled type information
47
+
48
+ # Table of Contents
49
+
50
+ - [Installation & Setup](#installation--setup)
51
+ - [Quick Start (Recommended)](#quick-start-recommended)
52
+ - [Developer Installation & Local Development Build](#developer-installation--local-development-build)
53
+ - [Documentation](#documentation)
54
+ - [`ClackOption`](#clackoption)
55
+ - [Generic typing convention](#generic-typing-convention)
56
+ - [Prompts](#prompts)
57
+ - [Prompt overview](#prompt-overview)
58
+ - [`ask()`](#ask)
59
+ - [`password()`](#password)
60
+ - [`confirm()`](#confirm)
61
+ - [`pick_date()`](#pick_date)
62
+ - [`multiline()`](#multiline)
63
+ - [`select()`](#select)
64
+ - [`multiselect()`](#multiselect)
65
+ - [`autocomplete()`](#autocomplete)
66
+ - [`autocomplete_multiselect()`](#autocomplete_multiselect)
67
+ - [`select_key()`](#select_key)
68
+ - [`select_path()`](#select_path)
69
+ - [Cancellation](#cancellation)
70
+ - [Cancellation values](#cancellation-values)
71
+ - [Widgets](#widgets)
72
+ - [`intro()`](#intro)
73
+ - [`outro()`](#outro)
74
+ - [`cancel()`](#cancel)
75
+ - [`note()`](#note)
76
+ - [`box()`](#box)
77
+ - [`log`](#log)
78
+ - [`TaskLog`](#tasklog)
79
+ - [Adding messages](#adding-messages)
80
+ - [Reading the log](#reading-the-log)
81
+ - [Cancellation](#cancellation-1)
82
+ - [`Spinner`](#spinner)
83
+ - [Lifecycle](#lifecycle)
84
+ - [Updating the message](#updating-the-message)
85
+ - [Cancellation and errors](#cancellation-and-errors)
86
+ - [Clearing](#clearing)
87
+ - [Checking cancellation](#checking-cancellation)
88
+ - [`Progress`](#progress)
89
+ - [Basic lifecycle](#basic-lifecycle)
90
+ - [`Activity`](#activity)
91
+ - [Lifecycle](#lifecycle-1)
92
+ - [`stream`](#stream)
93
+ - [A normal iterable](#a-normal-iterable)
94
+ - [An info stream](#an-info-stream)
95
+ - [A step stream](#a-step-stream)
96
+ - [An async iterable](#an-async-iterable)
97
+ - [Asynchronous APIs](#asynchronous-apis)
98
+ - [Themes](#themes)
99
+ - [Custom themes](#custom-themes)
100
+ - [Unicode and ASCII symbols](#unicode-and-ascii-symbols)
101
+ - [Rendering](#rendering)
102
+ - [`Text`](#text)
103
+ - [`FrameBuilder`](#framebuilder)
104
+ - [`RenderFrame`](#renderframe)
105
+ - [Terminal](#terminal)
106
+ - [`KeyReader`](#keyreader)
107
+ - [`CursorController`](#cursorcontroller)
108
+ - [`Stdout`](#stdout)
109
+ - [`EchoController`](#echocontroller)
110
+ - [Building a custom prompt](#building-a-custom-prompt)
111
+ - [Custom prompt structure](#custom-prompt-structure)
112
+ - [Validation](#validation)
113
+ - [Propagating the key after an error](#propagating-the-key-after-an-error)
114
+ - [Building a custom widget](#building-a-custom-widget)
115
+ - [Custom component conventions](#custom-component-conventions)
116
+ - [Prompts](#prompts-1)
117
+ - [Widgets](#widgets-1)
118
+ - [Themes](#themes-1)
119
+ - [Example](#example)
120
+
121
+ # Installation & Setup
122
+
123
+ ## Quick Start (Recommended)
124
+
125
+ The easiest way to get started with pyclack is via pip or uv. These methods will install the latest stable version directly into your active environment.
126
+
127
+ **Using pip:**
128
+ ```bash
129
+ pip install pyclack-lib
130
+ ```
131
+
132
+ **Using uv** (recommended for fast virtual environment management):
133
+ ```bash
134
+ uv add pyclack-lib
135
+ ```
136
+
137
+ ### Developer Installation & Local Development Build
138
+
139
+ > **Prerequisites:**
140
+ > - uv (needed to manage project enviornment and to run the project)
141
+
142
+ If you are developing with the repository and need to test changes locally, follow these steps. This process uses uv sync to ensure your virtual environment is perfectly synchronized with the project's dependencies.
143
+
144
+ 1). Clone the Repository:
145
+ ```bash
146
+ git clone https://github.com/Maddox-RVS/pyclack.git
147
+ cd pyclack
148
+ ```
149
+ 2). Create and Sync Virtual Environment:
150
+ This command reads the project's dependency definitions in `pyproject.toml` and sets up a
151
+ pristine, isolated virtual environment based on those requirements.
152
+ ```bash
153
+ uv sync --frozen
154
+ ```
155
+ 3). Run the Project:
156
+ You can now run the application using the local package installation:
157
+ ```bash
158
+ uv run examples/my_pyclack_text_script.py
159
+ ```
160
+
161
+ ---
162
+
163
+ # Documentation
164
+
165
+ pyclack has two kinds of building blocks:
166
+
167
+ - **Prompts** collect input from the user and return a value.
168
+ - **Widgets** show output in the terminal and usually return `None`.
169
+ The synchronous API lives under `pyclack.prompts` and `pyclack.widgets`.
170
+
171
+ The asynchronous API lives under `pyclack.prompts_async` and `pyclack.widgets_async`.
172
+
173
+ ---
174
+
175
+ # `ClackOption`
176
+
177
+ Every selection prompt uses `ClackOption`, a generic dataclass.
178
+
179
+ ```python
180
+ from pyclack import ClackOption
181
+
182
+ option: ClackOption[str] = ClackOption[str](
183
+ value='python',
184
+ label='Python')
185
+ ```
186
+
187
+ Its fields are:
188
+
189
+ ```python
190
+ from pyclack import ClackOption
191
+
192
+ option: ClackOption[str] = ClackOption[str](
193
+ value='python',
194
+ label='Python',
195
+ hint='Recommended',
196
+ disabled=False)
197
+ ```
198
+
199
+ - `value: V` - the value the option carries
200
+ - `label: str` - the text shown to the user
201
+ - `hint: str | None` - optional extra text next to the label
202
+ - `disabled: bool` - whether the user can select this option
203
+ ## Generic typing convention
204
+
205
+ When you know the type of `value`, parameterize both the annotation and the constructor.
206
+
207
+ ```python
208
+ from pyclack import ClackOption
209
+
210
+ integer_option: ClackOption[int] = ClackOption[int](
211
+ value=3,
212
+ label='Three')
213
+ ```
214
+
215
+ For a `str` value:
216
+
217
+ ```python
218
+ from pyclack import ClackOption
219
+
220
+ string_option: ClackOption[str] = ClackOption[str](
221
+ value='python',
222
+ label='Python')
223
+ ```
224
+
225
+ For a custom type:
226
+
227
+ ```python
228
+ from dataclasses import dataclass
229
+
230
+ from pyclack import ClackOption
231
+
232
+ @dataclass
233
+ class Language:
234
+ name: str
235
+ version: str
236
+
237
+ language: Language = Language(
238
+ name='Python',
239
+ version='3.13')
240
+
241
+ option: ClackOption[Language] = ClackOption[Language](
242
+ value=language,
243
+ label='Python 3.13')
244
+ ```
245
+
246
+ ---
247
+
248
+ # Prompts
249
+
250
+ A prompt blocks until the user submits, cancels, or its `abort_time` runs out.
251
+
252
+ A successful prompt returns the value described in its own section below.
253
+
254
+ A cancelled prompt raises `CancelException`. It does not return a special value for cancellation.
255
+
256
+ ## Prompt overview
257
+
258
+ | Prompt | Purpose | Successful return |
259
+ | --- | --- | --- |
260
+ | `ask()` | Single-line text input | `str` |
261
+ | `password()` | Hidden text input | `str` |
262
+ | `confirm()` | Yes/no confirmation | `bool` |
263
+ | `pick_date()` | Date selection within a range | `date` |
264
+ | `multiline()` | Multi-line text input | `str` |
265
+ | `select()` | Single selection from options | `ClackOption[T]` |
266
+ | `multiselect()` | Multiple selection from options | `list[ClackOption[T]]` |
267
+ | `autocomplete()` | Filtered single selection | `ClackOption[T]` |
268
+ | `autocomplete_multiselect()` | Filtered multiple selection | `list[ClackOption[T]]` |
269
+ | `select_key()` | Selection by pressing a key | `ClackOption[str]` |
270
+ | `select_path()` | Filesystem path selection | `Path` |
271
+
272
+ Import any prompt from `pyclack.prompts`:
273
+
274
+ ```python
275
+ from pyclack.prompts import ask, autocomplete, autocomplete_multiselect, confirm, multiline, multiselect, password, pick_date, select, select_key, select_path
276
+ ```
277
+
278
+ ## Common prompt options
279
+
280
+ Most prompts accept an `abort_time`, in seconds. When it runs out, the prompt cancels itself.
281
+
282
+ ```python
283
+ from pyclack.prompts import ask
284
+
285
+ name: str = ask(
286
+ message='What is your name?',
287
+ abort_time=10.0)
288
+ ```
289
+
290
+ Most prompts also accept a `validate` function.
291
+
292
+ ```python
293
+ from pyclack.prompts import ask
294
+
295
+ def validate_name(value: str) -> str | None:
296
+ if not value: return 'Name cannot be empty'
297
+
298
+ name: str = ask(
299
+ message='What is your name?',
300
+ validate=validate_name)
301
+ ```
302
+
303
+ `validate` returns `None` when the value passes. It returns a `str` error message when the value fails.
304
+
305
+ ---
306
+
307
+ # `ask()`
308
+
309
+ `ask()` collects one line of text.
310
+
311
+ ### Input
312
+
313
+ ```python
314
+ from pyclack.prompts import ask
315
+
316
+ name: str = ask(
317
+ message='What is your name?',
318
+ placeholder='(e.g. Bobby)')
319
+ ```
320
+
321
+ Parameters:
322
+
323
+ - `message: str` - the prompt message
324
+ - `placeholder: str | None` - text shown when the input is empty
325
+ - `initial_value: str | None` - the starting text in the input
326
+ - `validate: Callable[[str], str | None] | None` - an optional validator
327
+ - `abort_time: float | None` - an optional timeout, in seconds
328
+ ### Output
329
+
330
+ ```python
331
+ from pyclack.prompts import ask
332
+
333
+ name: str = ask('What is your name?')
334
+ ```
335
+
336
+ The return value is the entered `str`.
337
+
338
+ ### Cancellation
339
+
340
+ `e.value` holds the text the user had typed at the time of cancellation.
341
+
342
+ ```python
343
+ from pyclack.prompts import CancelException, ask
344
+
345
+ try:
346
+ name: str = ask('What is your name?')
347
+ except CancelException as e:
348
+ current_name: str | None = e.value
349
+ ```
350
+
351
+ ---
352
+
353
+ # `password()`
354
+
355
+ `password()` collects text and masks each character as the user types it.
356
+
357
+ ### Input
358
+
359
+ ```python
360
+ from pyclack.prompts import password
361
+
362
+ secret: str = password(
363
+ message='Create a password',
364
+ show_nothing=False)
365
+ ```
366
+
367
+ Parameters:
368
+
369
+ - `message: str` - the prompt message
370
+ - `mask: Symbol | None` - a custom mask symbol
371
+ - `show_nothing: bool` - hide the entered characters completely, with no mask at all
372
+ - `clear_on_error: bool` - clear the input after a failed validation
373
+ - `validate: Callable[[str], str | None] | None` - an optional validator
374
+ - `abort_time: float | None` - an optional timeout, in seconds
375
+ The active theme sets the default mask symbol.
376
+
377
+ ### Output
378
+
379
+ ```python
380
+ from pyclack.prompts import password
381
+
382
+ secret: str = password('Create a password')
383
+ ```
384
+
385
+ The return value is the entered `str`, in plain text. It is never the masked text.
386
+
387
+ ### Cancellation
388
+
389
+ `e.value` holds the password text entered so far.
390
+
391
+ ```python
392
+ from pyclack.prompts import CancelException, password
393
+
394
+ try:
395
+ secret: str = password('Create a password')
396
+ except CancelException as e:
397
+ current_secret: str | None = e.value
398
+ ```
399
+
400
+ ---
401
+
402
+ # `confirm()`
403
+
404
+ `confirm()` asks a yes/no question and returns a `bool`.
405
+
406
+ ### Input
407
+
408
+ ```python
409
+ from pyclack.prompts import confirm
410
+
411
+ confirmed: bool = confirm('Continue?')
412
+ ```
413
+
414
+ Parameters:
415
+
416
+ - `message: str` - the prompt message
417
+ - `active: str` - the label for `True`
418
+ - `inactive: str` - the label for `False`
419
+ - `vertical: bool` - stack the two choices vertically instead of side by side
420
+ - `default_option: bool` - the option selected when the prompt opens
421
+ - `abort_time: float | None` - an optional timeout, in seconds
422
+ ### Output
423
+
424
+ ```python
425
+ from pyclack.prompts import confirm
426
+
427
+ confirmed: bool = confirm('Continue?')
428
+ ```
429
+
430
+ The return value is `True` or `False`.
431
+
432
+ ### Cancellation
433
+
434
+ `e.value` holds the option selected at the time of cancellation.
435
+
436
+ ```python
437
+ from pyclack.prompts import CancelException, confirm
438
+
439
+ try:
440
+ confirmed: bool = confirm('Continue?')
441
+ except CancelException as e:
442
+ current_choice: bool | None = e.value
443
+ ```
444
+
445
+ ---
446
+
447
+ # `pick_date()`
448
+
449
+ `pick_date()` collects a date within a minimum and maximum bound.
450
+
451
+ ### Input
452
+
453
+ ```python
454
+ from datetime import date
455
+
456
+ from pyclack.prompts import pick_date
457
+
458
+ release_date: date = pick_date(
459
+ message='Release date',
460
+ initial_date=date.today(),
461
+ min_date=date(2026, 1, 1),
462
+ max_date=date(2030, 12, 31))
463
+ ```
464
+
465
+ Parameters:
466
+
467
+ - `message: str` - the prompt message
468
+ - `initial_date: date` - the date the prompt starts on
469
+ - `min_date: date` - the earliest date the prompt accepts
470
+ - `max_date: date` - the latest date the prompt accepts
471
+ - `validate: Callable[[date], str | None] | None` - an optional validator
472
+ - `abort_time: float | None` - an optional timeout, in seconds
473
+ The user enters the date as `mm/dd/yyyy`.
474
+
475
+ ### Output
476
+
477
+ ```python
478
+ from datetime import date
479
+
480
+ from pyclack.prompts import pick_date
481
+
482
+ release_date: date = pick_date(
483
+ 'Release date',
484
+ initial_date=date.today(),
485
+ min_date=date(2026, 1, 1),
486
+ max_date=date(2030, 12, 31))
487
+ ```
488
+
489
+ The return value is a `datetime.date`.
490
+
491
+ ### Cancellation
492
+
493
+ At cancellation, pyclack converts the current date fields to a `YYYY-MM-DD` string and stores that string in `e.value`.
494
+
495
+ ```python
496
+ from datetime import date
497
+
498
+ from pyclack.prompts import CancelException, pick_date
499
+
500
+ try:
501
+ release_date: date = pick_date(
502
+ 'Release date',
503
+ initial_date=date.today(),
504
+ min_date=date(2026, 1, 1),
505
+ max_date=date(2030, 12, 31))
506
+ except CancelException as e:
507
+ current_date: str | None = e.value
508
+ ```
509
+
510
+ ---
511
+
512
+ # `multiline()`
513
+
514
+ `multiline()` collects text that can span several lines.
515
+
516
+ ### Input
517
+
518
+ ```python
519
+ from pyclack.prompts import multiline
520
+
521
+ description: str = multiline(
522
+ message='Description',
523
+ placeholder='Enter a description...',
524
+ show_submit=True)
525
+ ```
526
+
527
+ Parameters:
528
+
529
+ - `message: str` - the prompt message
530
+ - `placeholder: str | None` - text shown when the input is empty
531
+ - `initial_value: str | None` - the starting text in the input
532
+ - `validate: Callable[[str], str | None] | None` - an optional validator
533
+ - `show_submit: bool` - show a submit button the user can move focus to
534
+ - `abort_time: float | None` - an optional timeout, in seconds
535
+ When `show_submit=False`, pressing Enter twice in a row submits the input.
536
+
537
+ When `show_submit=True`, Tab moves focus to the submit button, then Enter submits.
538
+
539
+ ### Output
540
+
541
+ ```python
542
+ from pyclack.prompts import multiline
543
+
544
+ description: str = multiline('Description')
545
+ ```
546
+
547
+ The return value is a `str`. It keeps the newline characters the user typed.
548
+
549
+ ### Cancellation
550
+
551
+ `e.value` holds the text entered so far.
552
+
553
+ ```python
554
+ from pyclack.prompts import CancelException, multiline
555
+
556
+ try:
557
+ description: str = multiline('Description')
558
+ except CancelException as e:
559
+ current_description: str | None = e.value
560
+ ```
561
+
562
+ ---
563
+
564
+ # `select()`
565
+
566
+ `select()` lets the user choose exactly one option.
567
+
568
+ ### Input
569
+
570
+ ```python
571
+ from pyclack import ClackOption
572
+ from pyclack.prompts import select
573
+
574
+ options: list[ClackOption[str]] = [
575
+ ClackOption[str](value='python', label='Python'),
576
+ ClackOption[str](value='rust', label='Rust'),
577
+ ClackOption[str](value='go', label='Go')]
578
+
579
+ selected: ClackOption[str] = select(
580
+ message='Choose a language',
581
+ options=options)
582
+ ```
583
+
584
+ Parameters:
585
+
586
+ - `message: str` - the prompt message
587
+ - `options: list[ClackOption[T]]` - the list of options to choose from
588
+ - `show_instructions: bool` - show navigation instructions above the list
589
+ - `max_items: int` - the maximum number of option lines shown at once (the list scrolls past this)
590
+ - `abort_time: float | None` - an optional timeout, in seconds
591
+ `max_items` sets the size of the visible window into the option list. pyclack keeps this window at least 5 lines tall, even if you pass a smaller value.
592
+
593
+ ### Output
594
+
595
+ The return value is the selected `ClackOption`, not its `.value`.
596
+
597
+ ```python
598
+ from pyclack import ClackOption
599
+ from pyclack.prompts import select
600
+
601
+ options: list[ClackOption[int]] = [
602
+ ClackOption[int](value=1, label='One'),
603
+ ClackOption[int](value=2, label='Two'),
604
+ ClackOption[int](value=3, label='Three')]
605
+
606
+ selected: ClackOption[int] = select(
607
+ message='Choose a number',
608
+ options=options)
609
+
610
+ number: int = selected.value
611
+ ```
612
+
613
+ ### Cancellation
614
+
615
+ `e.value` holds the option that was highlighted at the time of cancellation.
616
+
617
+ ```python
618
+ from pyclack import CancelException, ClackOption
619
+ from pyclack.prompts import select
620
+
621
+ options: list[ClackOption[str]] = [
622
+ ClackOption[str](value='python', label='Python'),
623
+ ClackOption[str](value='rust', label='Rust')]
624
+
625
+ try:
626
+ selected: ClackOption[str] = select(
627
+ message='Language',
628
+ options=options)
629
+ except CancelException as e:
630
+ selected_before_cancel: ClackOption[str] | None = e.value
631
+ ```
632
+
633
+ An empty option list, or an option list where every option is disabled, raises `RuntimeError`.
634
+
635
+ ---
636
+
637
+ # `multiselect()`
638
+
639
+ `multiselect()` lets the user select more than one option.
640
+
641
+ ### Input
642
+
643
+ ```python
644
+ from pyclack import ClackOption
645
+ from pyclack.prompts import multiselect
646
+
647
+ options: list[ClackOption[str]] = [
648
+ ClackOption[str](value='git', label='Git'),
649
+ ClackOption[str](value='docker', label='Docker'),
650
+ ClackOption[str](value='pytest', label='Pytest')]
651
+
652
+ selected: list[ClackOption[str]] = multiselect(
653
+ message='Select tools',
654
+ options=options)
655
+ ```
656
+
657
+ Parameters:
658
+
659
+ - `message: str` - the prompt message
660
+ - `options: list[ClackOption[T]]` - the list of options to choose from
661
+ - `show_instructions: bool` - show navigation instructions above the list
662
+ - `max_items: int` - the maximum number of option lines shown at once (the list scrolls past this)
663
+ - `abort_time: float | None` - an optional timeout, in seconds
664
+ Space selects or deselects the focused option.
665
+
666
+ ### Output
667
+
668
+ ```python
669
+ from pyclack import ClackOption
670
+ from pyclack.prompts import multiselect
671
+
672
+ options: list[ClackOption[str]] = [
673
+ ClackOption[str](value='git', label='Git'),
674
+ ClackOption[str](value='docker', label='Docker')]
675
+
676
+ selected: list[ClackOption[str]] = multiselect(
677
+ message='Select tools',
678
+ options=options)
679
+
680
+ tools: list[str] = [option.value for option in selected]
681
+ ```
682
+
683
+ ### Cancellation
684
+
685
+ `e.value` holds the list of options selected so far. It is an empty list if nothing was selected yet.
686
+
687
+ ```python
688
+ from pyclack import CancelException, ClackOption
689
+ from pyclack.prompts import multiselect
690
+
691
+ options: list[ClackOption[str]] = [
692
+ ClackOption[str](value='git', label='Git'),
693
+ ClackOption[str](value='docker', label='Docker')]
694
+
695
+ try:
696
+ selected: list[ClackOption[str]] = multiselect(
697
+ message='Select tools',
698
+ options=options)
699
+ except CancelException as e:
700
+ selected_before_cancel: list[ClackOption[str]] | None = e.value
701
+ ```
702
+
703
+ An empty option list, or an option list where every option is disabled, raises `RuntimeError`.
704
+
705
+ ---
706
+
707
+ # `autocomplete()`
708
+
709
+ `autocomplete()` adds text search on top of single-option selection.
710
+
711
+ ### Input
712
+
713
+ ```python
714
+ from pyclack import ClackOption
715
+ from pyclack.prompts import autocomplete
716
+
717
+ options: list[ClackOption[str]] = [
718
+ ClackOption[str](value='python', label='Python'),
719
+ ClackOption[str](value='rust', label='Rust'),
720
+ ClackOption[str](value='javascript', label='JavaScript')]
721
+
722
+ selected: ClackOption[str] = autocomplete(
723
+ message='Language',
724
+ options=options,
725
+ placeholder='Type to search...')
726
+ ```
727
+
728
+ Parameters:
729
+
730
+ - `message: str` - the prompt message
731
+ - `options: list[ClackOption[T]]` - the list of options to choose from
732
+ - `placeholder: str` - text shown when the search input is empty
733
+ - `show_instructions: bool` - show navigation instructions above the list
734
+ - `max_items: int` - the maximum number of option lines shown at once (the list scrolls past this)
735
+ - `filter: Callable[[str, list[ClackOption[T]]], list[ClackOption[T]]] | None` - an optional custom search filter
736
+ - `abort_time: float | None` - an optional timeout, in seconds
737
+ When `filter=None`, pyclack uses its default filter.
738
+
739
+ A custom filter receives the current search text and the full option list, and returns the options to show.
740
+
741
+ ```python
742
+ from pyclack import ClackOption
743
+ from pyclack.prompts import autocomplete
744
+
745
+ def filter_options(search: str, options: list[ClackOption[str]]) -> list[ClackOption[str]]:
746
+ search_lower: str = search.lower()
747
+ return [option for option in options if search_lower in option.label.lower()]
748
+
749
+ options: list[ClackOption[str]] = [
750
+ ClackOption[str](value='python', label='Python'),
751
+ ClackOption[str](value='rust', label='Rust')]
752
+
753
+ selected: ClackOption[str] = autocomplete(
754
+ message='Language',
755
+ options=options,
756
+ filter=filter_options)
757
+ ```
758
+
759
+ ### Output
760
+
761
+ The return value is the selected `ClackOption`.
762
+
763
+ ```python
764
+ from pyclack import ClackOption
765
+ from pyclack.prompts import autocomplete
766
+
767
+ options: list[ClackOption[int]] = [
768
+ ClackOption[int](value=1, label='One'),
769
+ ClackOption[int](value=2, label='Two')]
770
+
771
+ selected: ClackOption[int] = autocomplete(
772
+ message='Number',
773
+ options=options)
774
+
775
+ number: int = selected.value
776
+ ```
777
+
778
+ ### Cancellation
779
+
780
+ `e.value` holds the currently highlighted, enabled option. It is `None` when no option is usable.
781
+
782
+ ```python
783
+ from pyclack import CancelException, ClackOption
784
+ from pyclack.prompts import autocomplete
785
+
786
+ options: list[ClackOption[str]] = [
787
+ ClackOption[str](value='python', label='Python'),
788
+ ClackOption[str](value='rust', label='Rust')]
789
+
790
+ try:
791
+ selected: ClackOption[str] = autocomplete(
792
+ message='Language',
793
+ options=options)
794
+ except CancelException as e:
795
+ selected_before_cancel: ClackOption[str] | None = e.value
796
+ ```
797
+
798
+ An empty option list, or an option list where every option is disabled, raises `RuntimeError`.
799
+
800
+ ---
801
+
802
+ # `autocomplete_multiselect()`
803
+
804
+ `autocomplete_multiselect()` adds text search on top of multiple selection.
805
+
806
+ ### Input
807
+
808
+ ```python
809
+ from pyclack import ClackOption
810
+ from pyclack.prompts import autocomplete_multiselect
811
+
812
+ options: list[ClackOption[str]] = [
813
+ ClackOption[str](value='git', label='Git'),
814
+ ClackOption[str](value='docker', label='Docker'),
815
+ ClackOption[str](value='pytest', label='Pytest')]
816
+
817
+ selected: list[ClackOption[str]] = autocomplete_multiselect(
818
+ message='Select tools',
819
+ options=options)
820
+ ```
821
+
822
+ Parameters:
823
+
824
+ - `message: str` - the prompt message
825
+ - `options: list[ClackOption[T]]` - the list of options to choose from
826
+ - `placeholder: str` - text shown when the search input is empty
827
+ - `show_instructions: bool` - show navigation instructions above the list
828
+ - `max_items: int` - the maximum number of option lines shown at once (the list scrolls past this)
829
+ - `filter: Callable[[str, list[ClackOption[T]]], list[ClackOption[T]]] | None` - an optional custom search filter
830
+ - `abort_time: float | None` - an optional timeout, in seconds
831
+ Space selects or deselects the highlighted option.
832
+
833
+ ### Output
834
+
835
+ ```python
836
+ from pyclack import ClackOption
837
+ from pyclack.prompts import autocomplete_multiselect
838
+
839
+ options: list[ClackOption[int]] = [
840
+ ClackOption[int](value=1, label='One'),
841
+ ClackOption[int](value=2, label='Two')]
842
+
843
+ selected: list[ClackOption[int]] = autocomplete_multiselect(
844
+ message='Select numbers',
845
+ options=options)
846
+
847
+ numbers: list[int] = [option.value for option in selected]
848
+ ```
849
+
850
+ ### Cancellation
851
+
852
+ `e.value` holds the list of options selected so far. It is an empty list if nothing was selected yet.
853
+
854
+ ```python
855
+ from pyclack import CancelException, ClackOption
856
+ from pyclack.prompts import autocomplete_multiselect
857
+
858
+ options: list[ClackOption[str]] = [
859
+ ClackOption[str](value='git', label='Git'),
860
+ ClackOption[str](value='docker', label='Docker')]
861
+
862
+ try:
863
+ selected: list[ClackOption[str]] = autocomplete_multiselect(
864
+ message='Select tools',
865
+ options=options)
866
+ except CancelException as e:
867
+ selected_before_cancel: list[ClackOption[str]] | None = e.value
868
+ ```
869
+
870
+ An empty option list, or an option list where every option is disabled, raises `RuntimeError`.
871
+
872
+ ---
873
+
874
+ # `select_key()`
875
+
876
+ `select_key()` selects an option when the user presses its key.
877
+
878
+ Each option's `value` must be the key string that selects it.
879
+
880
+ ### Input
881
+
882
+ ```python
883
+ from pyclack import ClackOption
884
+ from pyclack.prompts import select_key
885
+
886
+ options: list[ClackOption[str]] = [
887
+ ClackOption[str](value='y', label='Yes'),
888
+ ClackOption[str](value='n', label='No'),
889
+ ClackOption[str](value='s', label='Skip')]
890
+
891
+ selected: ClackOption[str] = select_key(
892
+ 'Choose an action',
893
+ options=options,
894
+ case_sensitive=True)
895
+ ```
896
+
897
+ Parameters:
898
+
899
+ - `message: str` - the prompt message
900
+ - `options: list[ClackOption[str]]` - the list of options to choose from
901
+ - `case_sensitive: bool` - treat upper and lower case key presses as different keys
902
+ - `abort_time: float | None` - an optional timeout, in seconds
903
+ Pressing Enter selects the first option in the list.
904
+
905
+ ### Output
906
+
907
+ ```python
908
+ from pyclack import ClackOption
909
+ from pyclack.prompts import select_key
910
+
911
+ options: list[ClackOption[str]] = [
912
+ ClackOption[str](value='y', label='Yes'),
913
+ ClackOption[str](value='n', label='No')]
914
+
915
+ selected: ClackOption[str] = select_key(
916
+ 'Continue?',
917
+ options=options)
918
+
919
+ key: str = selected.value
920
+ ```
921
+
922
+ ### Cancellation
923
+
924
+ `e.value` holds the first option in the list.
925
+
926
+ ```python
927
+ from pyclack import CancelException, ClackOption
928
+ from pyclack.prompts import select_key
929
+
930
+ options: list[ClackOption[str]] = [
931
+ ClackOption[str](value='y', label='Yes'),
932
+ ClackOption[str](value='n', label='No')]
933
+
934
+ try:
935
+ selected: ClackOption[str] = select_key(
936
+ 'Continue?',
937
+ options=options)
938
+ except CancelException as e:
939
+ default_option: ClackOption[str] | None = e.value
940
+ ```
941
+
942
+ `select_key()` raises `RuntimeError` for an invalid or duplicate key value, an empty option list, or an option list where every option is disabled.
943
+
944
+ ---
945
+
946
+ # `select_path()`
947
+
948
+ `select_path()` is an autocomplete-style filesystem picker.
949
+
950
+ ### Input
951
+
952
+ ```python
953
+ from pathlib import Path
954
+
955
+ from pyclack.prompts import select_path
956
+
957
+ selected_path: Path = select_path(
958
+ message='Select a path',
959
+ root=Path.cwd(),
960
+ directory=False)
961
+ ```
962
+
963
+ Parameters:
964
+
965
+ - `message: str` - the prompt message
966
+ - `placeholder: str` - text shown when the search input is empty
967
+ - `show_instructions: bool` - show navigation instructions above the list
968
+ - `max_items: int` - the maximum number of option lines shown at once (the list scrolls past this)
969
+ - `root: Path` - the directory the prompt starts in
970
+ - `directory: bool` - show only directories, not files
971
+ - `abort_time: float | None` - an optional timeout, in seconds
972
+ When `directory=False`, the prompt shows both files and directories.
973
+
974
+ When `directory=True`, the prompt shows only directories.
975
+
976
+ `root` defaults to the current working directory.
977
+
978
+ ### Output
979
+
980
+ ```python
981
+ from pathlib import Path
982
+
983
+ from pyclack.prompts import select_path
984
+
985
+ selected_path: Path = select_path('Select a file')
986
+ ```
987
+
988
+ The return value is a `Path`.
989
+
990
+ If `root` does not exist, `select_path()` raises `FileNotFoundError`.
991
+
992
+ ### Cancellation
993
+
994
+ `e.value` holds the currently selected `Path`. It is `None` when nothing is selected.
995
+
996
+ ```python
997
+ from pathlib import Path
998
+
999
+ from pyclack import CancelException
1000
+ from pyclack.prompts import select_path
1001
+
1002
+ try:
1003
+ selected_path: Path = select_path('Select a file')
1004
+ except CancelException as e:
1005
+ selected_before_cancel: Path | None = e.value
1006
+ ```
1007
+
1008
+ ---
1009
+
1010
+ # Cancellation
1011
+
1012
+ Cancellation works the same way across every prompt.
1013
+
1014
+ Escape or Ctrl+C cancels the active prompt. A prompt also cancels itself when its `abort_time` runs out.
1015
+
1016
+ The prompt raises `CancelException`:
1017
+
1018
+ ```python
1019
+ from pyclack import CancelException
1020
+ from pyclack.prompts import ask
1021
+
1022
+ try:
1023
+ value: str = ask('Value')
1024
+ except CancelException as e:
1025
+ current_value: str | None = e.value
1026
+ ```
1027
+
1028
+ `CancelException` is generic over the type of value it carries:
1029
+
1030
+ ```python
1031
+ from pyclack import CancelException
1032
+
1033
+ exception: CancelException[str] = CancelException('partial input')
1034
+ ```
1035
+
1036
+ Its `value` attribute holds whatever state the prompt chose to keep at the time of cancellation:
1037
+
1038
+ ```python
1039
+ from pyclack import CancelException
1040
+
1041
+ exception: CancelException[str] = CancelException('partial input')
1042
+
1043
+ value: str | None = exception.value
1044
+ ```
1045
+
1046
+ ## Cancellation values
1047
+
1048
+ | Prompt | Successful return | `e.value` |
1049
+ | --- | --- | --- |
1050
+ | `ask()` | `str` | current `str`, or `None` |
1051
+ | `password()` | `str` | current `str`, or `None` |
1052
+ | `confirm()` | `bool` | current `bool`, or `None` |
1053
+ | `pick_date()` | `date` | current date as a `YYYY-MM-DD` `str`, or `None` |
1054
+ | `multiline()` | `str` | current `str`, or `None` |
1055
+ | `select()` | `ClackOption[T]` | highlighted `ClackOption[T]`, or `None` |
1056
+ | `multiselect()` | `list[ClackOption[T]]` | selected `list[ClackOption[T]]`, or `None` |
1057
+ | `autocomplete()` | `ClackOption[T]` | highlighted `ClackOption[T]`, or `None` |
1058
+ | `autocomplete_multiselect()` | `list[ClackOption[T]]` | selected `list[ClackOption[T]]`, or `None` |
1059
+ | `select_key()` | `ClackOption[str]` | first option `ClackOption[str]`, or `None` |
1060
+ | `select_path()` | `Path` | highlighted `Path`, or `None` |
1061
+
1062
+ The exception always signals cancellation. `e.value` always carries the useful partial state. This split stays the same across every prompt.
1063
+
1064
+ ---
1065
+
1066
+ # Widgets
1067
+
1068
+ A widget shows terminal output. It does not collect a value from the user.
1069
+
1070
+ Import the synchronous widget API from `pyclack.widgets`:
1071
+
1072
+ ```python
1073
+ from pyclack.widgets import Activity, Progress, ProgressStyle, Spinner, TaskLog, box, cancel, intro, log, note, outro, stream
1074
+ ```
1075
+
1076
+ A simple widget, such as `intro()` or `note()`, returns `None` and has no state.
1077
+
1078
+ A stateful widget, such as `Spinner`, `Progress`, `Activity`, and `TaskLog`, is an object. Its methods control what it shows and when.
1079
+
1080
+ ---
1081
+
1082
+ # `intro()`
1083
+
1084
+ `intro()` shows an introductory message.
1085
+
1086
+ ```python
1087
+ from pyclack.widgets import intro
1088
+
1089
+ intro('My Application')
1090
+ ```
1091
+
1092
+ Parameters:
1093
+
1094
+ - `title: str` - the title to show
1095
+ - `custom_style: Style | None` - an optional style override for the title
1096
+ ---
1097
+
1098
+ # `outro()`
1099
+
1100
+ `outro()` shows a closing message.
1101
+
1102
+ ```python
1103
+ from pyclack.widgets import outro
1104
+
1105
+ outro('Done!')
1106
+ ```
1107
+
1108
+ Parameters:
1109
+
1110
+ - `message: str` - the message to show
1111
+ - `custom_style: Style | None` - an optional style override for the message
1112
+ ---
1113
+
1114
+ # `cancel()`
1115
+
1116
+ `cancel()` shows a cancellation message.
1117
+
1118
+ ```python
1119
+ from pyclack.widgets import cancel
1120
+
1121
+ cancel('Operation cancelled.')
1122
+ ```
1123
+
1124
+ Parameters:
1125
+
1126
+ - `message: str` - the cancellation message to show
1127
+ ---
1128
+
1129
+ # `note()`
1130
+
1131
+ `note()` shows a titled note.
1132
+
1133
+ ```python
1134
+ from pyclack.widgets import note
1135
+
1136
+ note('Configuration', 'Using configuration from pyproject.toml.')
1137
+ ```
1138
+
1139
+ Parameters:
1140
+
1141
+ - `title: str` - the note's title
1142
+ - `message: str` - the text inside the note
1143
+ ---
1144
+
1145
+ # `box()`
1146
+
1147
+ `box()` shows text inside a bordered box.
1148
+
1149
+ ```python
1150
+ from pyclack import Alignment
1151
+ from pyclack.widgets import box
1152
+
1153
+ box(
1154
+ 'Build complete.',
1155
+ 'Status',
1156
+ content_align=Alignment.CENTER,
1157
+ title_align=Alignment.LEFT,
1158
+ rounded=True)
1159
+ ```
1160
+
1161
+ Parameters:
1162
+
1163
+ - `content: str` - the text inside the box
1164
+ - `title: str` - the box's title
1165
+ - `content_align: Alignment` - the alignment of the content
1166
+ - `title_align: Alignment` - the alignment of the title
1167
+ - `width: int | None` - the box's maximum width in the terminal, or `None` for no maximum
1168
+ - `rounded: bool` - use rounded corners
1169
+ - `title_padding: int` - the spacing on each side of the title
1170
+ - `content_padding: int` - the spacing on each side of the content
1171
+ ---
1172
+
1173
+ # `log`
1174
+
1175
+ `log` shows one-off messages at different severity levels.
1176
+
1177
+ ```python
1178
+ from pyclack.widgets import log
1179
+
1180
+ log.message('Starting build')
1181
+ log.info('Using Python 3.13')
1182
+ log.warning('Configuration file not found')
1183
+ log.error('Compilation failed')
1184
+ log.success('Build complete')
1185
+ log.step('Installing dependencies')
1186
+ ```
1187
+
1188
+ Each function takes one argument:
1189
+
1190
+ - `msg: str` - the message to show
1191
+
1192
+ The available levels are:
1193
+
1194
+ - `message()`
1195
+ - `info()`
1196
+ - `warning()`
1197
+ - `warn()`
1198
+ - `error()`
1199
+ - `success()`
1200
+ - `step()`
1201
+
1202
+ `warn()` is an alias for `warning()`.
1203
+
1204
+ ---
1205
+
1206
+ # `TaskLog`
1207
+
1208
+ `TaskLog` shows a running task and the messages it produces while it works.
1209
+
1210
+ ### Input
1211
+
1212
+ ```python
1213
+ from pyclack.widgets import TaskLog
1214
+
1215
+ task: TaskLog = TaskLog(
1216
+ title='Building project',
1217
+ limit=5,
1218
+ retain_log=False)
1219
+ ```
1220
+
1221
+ Parameters:
1222
+
1223
+ - `title: str` - the title shown above the log
1224
+ - `limit: int | None` - the maximum number of messages kept and shown at once
1225
+ - `retain_log: bool` - keep the full log instead of trimming it to `limit`
1226
+ ### Adding messages
1227
+
1228
+ ```python
1229
+ from pyclack.widgets import TaskLog
1230
+
1231
+ task: TaskLog = TaskLog(title='Building')
1232
+
1233
+ task.message('Compiling main.py')
1234
+ task.message('Compiling utils.py')
1235
+ task.success('Build complete')
1236
+ ```
1237
+
1238
+ Once `success()` runs, the task is marked done. Later calls to `message()` have no effect.
1239
+
1240
+ ### Reading the log
1241
+
1242
+ ```python
1243
+ from pyclack.widgets import TaskLog
1244
+
1245
+ task: TaskLog = TaskLog(title='Building')
1246
+
1247
+ task.message('Compiling')
1248
+ task.message('Linking')
1249
+
1250
+ messages: list[str] = task.get_log()
1251
+ ```
1252
+
1253
+ The stored log includes the initial title as its first entry.
1254
+
1255
+ ### Cancellation
1256
+
1257
+ Ctrl+C while a `TaskLog` is active raises `CancelException`, with no value attached.
1258
+
1259
+ ```python
1260
+ from pyclack import CancelException
1261
+ from pyclack.widgets import TaskLog, cancel
1262
+
1263
+ task: TaskLog = TaskLog(title='Building')
1264
+
1265
+ try:
1266
+ task.message('Compiling')
1267
+ # ... do some work ...
1268
+ except CancelException:
1269
+ cancel('Compiling cancelled!')
1270
+ exit(0)
1271
+ ```
1272
+
1273
+ ---
1274
+
1275
+ # `Spinner`
1276
+
1277
+ `Spinner` shows an animated spinner while work runs.
1278
+
1279
+ ### Input
1280
+
1281
+ ```python
1282
+ from pyclack.widgets import Spinner
1283
+
1284
+ spinner: Spinner = Spinner(
1285
+ show_timer=False,
1286
+ show_elipse=True,
1287
+ spinner_delay=80,
1288
+ elipse_delay=500)
1289
+ ```
1290
+
1291
+ Parameters:
1292
+
1293
+ - `show_timer: bool` - show elapsed time since the spinner started
1294
+ - `show_elipse: bool` - show an animated ellipsis after the message
1295
+ - `spinner_delay: float` - milliseconds between spinner frames
1296
+ - `elipse_delay: float` - milliseconds between ellipsis frames
1297
+ - `spinner_frames: SpinnerSymbols | None` - a custom set of spinner frames, or `None` to use the active theme's set
1298
+ ### Lifecycle
1299
+
1300
+ ```python
1301
+ from pyclack.widgets import Spinner
1302
+
1303
+ spinner: Spinner = Spinner()
1304
+
1305
+ spinner.start('Installing dependencies')
1306
+ # ... do some work ...
1307
+ spinner.stop('Dependencies installed')
1308
+ ```
1309
+
1310
+ ### Updating the message
1311
+
1312
+ ```python
1313
+ from pyclack.widgets import Spinner
1314
+
1315
+ spinner: Spinner = Spinner()
1316
+
1317
+ spinner.start('Installing')
1318
+ # ... do some work ...
1319
+ spinner.set_message('Installing package 2/5')
1320
+ ```
1321
+
1322
+ ### Cancellation and errors
1323
+
1324
+ ```python
1325
+ from pyclack.widgets import Spinner
1326
+
1327
+ spinner: Spinner = Spinner()
1328
+
1329
+ spinner.start('Installing')
1330
+ # ... do some interrupted work ...
1331
+ spinner.cancel('Installation cancelled')
1332
+ ```
1333
+
1334
+ ```python
1335
+ from pyclack.widgets import Spinner
1336
+
1337
+ spinner: Spinner = Spinner()
1338
+
1339
+ spinner.start('Installing')
1340
+ # ... do some failed work ...
1341
+ spinner.error('Installation failed')
1342
+ ```
1343
+
1344
+ ### Clearing
1345
+
1346
+ ```python
1347
+ from pyclack.widgets import Spinner
1348
+
1349
+ spinner: Spinner = Spinner()
1350
+
1351
+ spinner.start('Installing')
1352
+ # ... do some work ...
1353
+ spinner.clear()
1354
+ ```
1355
+
1356
+ ### Checking cancellation
1357
+
1358
+ ```python
1359
+ from pyclack.widgets import Spinner
1360
+
1361
+ spinner: Spinner = Spinner()
1362
+
1363
+ cancelled: bool = spinner.is_cancelled()
1364
+ ```
1365
+
1366
+ Ctrl+C raises `CancelException` while the spinner runs. The spinner restores the terminal state during cleanup either way.
1367
+
1368
+ ```python
1369
+ from pyclack import CancelException
1370
+ from pyclack.widgets import Spinner, cancel
1371
+
1372
+ spinner: Spinner = Spinner()
1373
+
1374
+ spinner.start('Installing')
1375
+ try:
1376
+ pass # ... do some work ...
1377
+ except CancelException:
1378
+ spinner.cancel('Installation cancelled')
1379
+ cancel('Operation cancelled')
1380
+ exit(0)
1381
+ spinner.stop('Dependencies installed')
1382
+ ```
1383
+
1384
+ ---
1385
+
1386
+ # `Progress`
1387
+
1388
+ `Progress` shows a progress bar, with an optional spinner, ellipsis, and timer.
1389
+
1390
+ ### Input
1391
+
1392
+ ```python
1393
+ from pyclack.widgets import Progress, ProgressStyle
1394
+
1395
+ progress: Progress = Progress(
1396
+ max=100,
1397
+ size=30,
1398
+ style=ProgressStyle.HEAVY,
1399
+ show_timer=False,
1400
+ show_elipse=True)
1401
+ ```
1402
+
1403
+ Parameters:
1404
+
1405
+ - `max: int` - the progress value that means "done"
1406
+ - `size: int` - the bar's width, in characters
1407
+ - `style: ProgressStyle` - `LIGHT`, `HEAVY`, or `BLOCK`
1408
+ - `show_timer: bool` - show elapsed time since the bar started
1409
+ - `show_elipse: bool` - show an animated ellipsis after the message
1410
+ - `spinner_delay: float` - milliseconds between spinner frames
1411
+ - `elipse_delay: float` - milliseconds between ellipsis frames
1412
+ - `spinner_frames: SpinnerSymbols | None` - a custom set of spinner frames, or `None` to use the active theme's set
1413
+ ### Basic lifecycle
1414
+
1415
+ ```python
1416
+ from pyclack.widgets import Progress
1417
+
1418
+ progress: Progress = Progress(max=100, size=30)
1419
+
1420
+ progress.start('Downloading')
1421
+
1422
+ progress.advance(25)
1423
+ progress.advance(25)
1424
+
1425
+ progress.stop('Download complete')
1426
+ ```
1427
+
1428
+ `Progress` also supports:
1429
+
1430
+ ```python
1431
+ progress.error('Download failed')
1432
+ progress.clear()
1433
+ cancelled: bool = progress.is_cancelled()
1434
+ ```
1435
+
1436
+ `Progress` is stateful. It keeps its current value and redraws its own frame as that value changes.
1437
+
1438
+ Ctrl+C raises `CancelException` while the bar runs. The bar restores the terminal state during cleanup either way.
1439
+
1440
+ ```python
1441
+ from pyclack import CancelException, cancel
1442
+ from pyclack.widgets import Progress
1443
+
1444
+ progress: Progress = Progress(max=100, size=30)
1445
+
1446
+ progress.start('Downloading')
1447
+ try:
1448
+ for i in range(100):
1449
+ progress.advance()
1450
+ except CancelException:
1451
+ progress.cancel('Download cancelled')
1452
+ cancel('Operation cancelled')
1453
+ exit(0)
1454
+ progress.stop('Download complete')
1455
+ ```
1456
+
1457
+ ---
1458
+
1459
+ # `Activity`
1460
+
1461
+ `Activity` pairs a spinner with a running log of activity messages.
1462
+
1463
+ ### Input
1464
+
1465
+ ```python
1466
+ from pyclack.widgets import Activity
1467
+
1468
+ activity: Activity = Activity(
1469
+ limit=5,
1470
+ show_timer=False,
1471
+ show_elipse=True)
1472
+ ```
1473
+
1474
+ Parameters:
1475
+
1476
+ - `limit: int | None` - the maximum number of messages kept and shown at once
1477
+ - `show_timer: bool` - show elapsed time since the activity started
1478
+ - `show_elipse: bool` - show an animated ellipsis after the spinner message
1479
+ - `spinner_delay: float` - milliseconds between spinner frames
1480
+ - `elipse_delay: float` - milliseconds between ellipsis frames
1481
+ - `spinner_frames: SpinnerSymbols | None` - a custom set of spinner frames, or `None` to use the active theme's set
1482
+ ### Lifecycle
1483
+
1484
+ ```python
1485
+ from pyclack.widgets import Activity
1486
+
1487
+ activity: Activity = Activity()
1488
+
1489
+ activity.start('Building')
1490
+ activity.set_activity_message('Compiling main.py')
1491
+ activity.set_activity_message('Compiling utils.py')
1492
+ activity.stop('Build complete')
1493
+ ```
1494
+
1495
+ The activity message and the spinner message are separate pieces of state.
1496
+
1497
+ ```python
1498
+ from pyclack.widgets import Activity
1499
+
1500
+ activity: Activity = Activity()
1501
+
1502
+ activity.start('Building')
1503
+ activity.set_spinner_message('Still building')
1504
+ activity.set_activity_message('Compiling main.py')
1505
+
1506
+ current_activity: str = activity.get_activity_message()
1507
+ ```
1508
+
1509
+ `Activity` also supports:
1510
+
1511
+ ```python
1512
+ activity.cancel('Build cancelled')
1513
+ activity.error('Build failed')
1514
+ activity.clear()
1515
+ cancelled: bool = activity.is_cancelled()
1516
+ ```
1517
+
1518
+ ---
1519
+
1520
+ # `stream`
1521
+
1522
+ `stream` is for output where the number of messages is not known ahead of time.
1523
+
1524
+ Unlike the other widgets, `stream` accepts an `Iterable[str]` or an `AsyncIterable[str]` directly, and shows each item as it arrives.
1525
+
1526
+ ```python
1527
+ from pyclack.widgets import stream
1528
+
1529
+ messages: list[str] = ['Downloading...', 'Extracting...', 'Installing...', 'Complete']
1530
+
1531
+ stream.message(messages)
1532
+ ```
1533
+
1534
+ The three stream levels are:
1535
+
1536
+ - `stream.message()`
1537
+ - `stream.info()`
1538
+ - `stream.step()`
1539
+ ### A normal iterable
1540
+
1541
+ ```python
1542
+ from collections.abc import Iterator
1543
+
1544
+ from pyclack.widgets import stream
1545
+
1546
+ def messages() -> Iterator[str]:
1547
+ yield 'Downloading...'
1548
+ yield 'Extracting...'
1549
+ yield 'Installing...'
1550
+ yield 'Complete'
1551
+
1552
+ stream.message(messages())
1553
+ ```
1554
+
1555
+ ### An info stream
1556
+
1557
+ ```python
1558
+ from collections.abc import Iterator
1559
+
1560
+ from pyclack.widgets import stream
1561
+
1562
+ def messages() -> Iterator[str]:
1563
+ yield 'Connected'
1564
+ yield 'Downloading'
1565
+ yield 'Complete'
1566
+
1567
+ stream.info(messages())
1568
+ ```
1569
+
1570
+ ### A step stream
1571
+
1572
+ ```python
1573
+ from collections.abc import Iterator
1574
+
1575
+ from pyclack.widgets import stream
1576
+
1577
+ def steps() -> Iterator[str]:
1578
+ yield 'Installing dependencies'
1579
+ yield 'Building project'
1580
+ yield 'Running tests'
1581
+
1582
+ stream.step(steps())
1583
+ ```
1584
+
1585
+ ### An async iterable
1586
+
1587
+ ```python
1588
+ from collections.abc import AsyncIterator
1589
+
1590
+ from pyclack.widgets import stream
1591
+
1592
+ async def messages() -> AsyncIterator[str]:
1593
+ yield 'Connecting...'
1594
+ yield 'Downloading...'
1595
+ yield 'Complete'
1596
+
1597
+ stream.message(messages())
1598
+ ```
1599
+
1600
+ `stream.message()`, `stream.info()`, and `stream.step()` all accept the same type:
1601
+
1602
+ ```python
1603
+ from collections.abc import AsyncIterable, Iterable
1604
+
1605
+ values: Iterable[str] | AsyncIterable[str]
1606
+ ```
1607
+
1608
+ Every stream function blocks until its iterable runs out of items.
1609
+
1610
+ ---
1611
+
1612
+ # Asynchronous APIs
1613
+
1614
+ pyclack provides asynchronous wrappers for every prompt, under `pyclack.prompts_async`.
1615
+
1616
+ ```python
1617
+ from pyclack.prompts_async import ask
1618
+
1619
+ async def get_name() -> str:
1620
+ name: str = await ask('Name')
1621
+ return name
1622
+ ```
1623
+
1624
+ Each wrapper keeps the same behavior and return type as its synchronous counterpart.
1625
+
1626
+ A wrapper runs the synchronous prompt in a worker thread, through `asyncio.to_thread()`. This lets you `await` the prompt without blocking the event loop.
1627
+
1628
+ Every prompt has a wrapper:
1629
+
1630
+ ```python
1631
+ from pyclack.prompts_async import ask, autocomplete, autocomplete_multiselect, confirm, multiline, multiselect, password, pick_date, select, select_key, select_path
1632
+ ```
1633
+
1634
+ The widget wrappers live under `pyclack.widgets_async`:
1635
+
1636
+ ```python
1637
+ from pyclack.widgets_async import Activity, Progress, Spinner, TaskLog, box, cancel, intro, note, outro, stream, log
1638
+ ```
1639
+
1640
+ The async widget wrappers also delegate through `asyncio.to_thread()`. For example:
1641
+
1642
+ ```python
1643
+ from pyclack.widgets_async import Spinner
1644
+
1645
+ async def build() -> None:
1646
+ spinner: Spinner = Spinner()
1647
+
1648
+ await spinner.start('Building')
1649
+ await do_async_build()
1650
+ await spinner.stop('Build complete')
1651
+ ```
1652
+
1653
+ ---
1654
+
1655
+ # Themes
1656
+
1657
+ A theme sets the colors and symbols every prompt and widget uses.
1658
+
1659
+ A `Theme` holds:
1660
+
1661
+ - an `active` style
1662
+ - a `submit` style
1663
+ - a `cancel` style
1664
+ - an `error` style
1665
+ - an `info` style
1666
+ - a `muted` style
1667
+ - a `text` style
1668
+ - a `cursor` style
1669
+ - a `Symbols` object
1670
+ `Theme` defines one theme. `Themes` collects the built-in themes.
1671
+
1672
+ Change the active theme with `set_active_theme()`:
1673
+
1674
+ ```python
1675
+ from pyclack import Themes, set_active_theme
1676
+
1677
+ set_active_theme(Themes.DEFAULT)
1678
+ ```
1679
+
1680
+ Read the active theme with `get_active_theme()`:
1681
+
1682
+ ```python
1683
+ from pyclack import get_active_theme
1684
+
1685
+ theme = get_active_theme()
1686
+ ```
1687
+
1688
+ The theme system stays separate from the prompt and widget code. A prompt or widget asks the active theme for its colors and symbols each time it renders.
1689
+
1690
+ This means switching the active theme changes how every prompt and widget looks, without changing a single line of their rendering code.
1691
+
1692
+ ## Custom themes
1693
+
1694
+ Build a `Theme` from `Style`, `Symbols`, `Symbol`, and `SpinnerSymbols`.
1695
+
1696
+ ```python
1697
+ from pyclack import set_active_theme
1698
+ from pyclack.renderer import SpinnerSymbols, Style, Symbol, Symbols, Theme
1699
+
1700
+ custom_theme: Theme = Theme(
1701
+ active=Style(fg_color='cyan'),
1702
+ submit=Style(fg_color='green'),
1703
+ cancel=Style(fg_color='red'),
1704
+ error=Style(fg_color='yellow'),
1705
+ info=Style(fg_color='blue'),
1706
+ muted=Style(fg_color='bright_black'),
1707
+ text=Style(fg_color='white'),
1708
+ cursor=Style(fg_color='bright_black', bg_color='white'),
1709
+ symbols=Symbols(
1710
+ step_marker_active=Symbol('◆', '*'),
1711
+ step_marker_cancel=Symbol('■', 'x'),
1712
+ step_marker_error=Symbol('▲', 'x'),
1713
+ step_marker_submit=Symbol('◇', 'o'),
1714
+ connector_bar_start=Symbol('┌', 'T'),
1715
+ connector_bar_vertical=Symbol('│', '|'),
1716
+ connector_bar_end=Symbol('└', '-'),
1717
+ selection_widget_radio_active=Symbol('●', '>'),
1718
+ selection_widget_radio_inactive=Symbol('○', ' '),
1719
+ selection_widget_checkbox_active=Symbol('◻', '[•]'),
1720
+ selection_widget_checkbox_selected=Symbol('◼', '[+]'),
1721
+ selection_widget_checkbox_inactive=Symbol('◻', '[ ]'),
1722
+ selection_widget_password_mask=Symbol('▪', '*'),
1723
+ box_drawing_horizontal_bar=Symbol('─', '-'),
1724
+ box_drawing_vertical_bar=Symbol('│', '|'),
1725
+ box_drawing_top_right_corner_rounded=Symbol('╮', '+'),
1726
+ box_drawing_left_connector=Symbol('├', '+'),
1727
+ box_drawing_bottom_right_corner_rounded=Symbol('╯', '+'),
1728
+ box_drawing_top_left_corner_rounded=Symbol('╭', '+'),
1729
+ box_drawing_bottom_left_corner_rounded=Symbol('╰', '+'),
1730
+ box_drawing_top_right_corner=Symbol('┐', '+'),
1731
+ box_drawing_bottom_right_corner=Symbol('┘', '+'),
1732
+ box_drawing_top_left_corner=Symbol('┌', '+'),
1733
+ box_drawing_bottom_left_corner=Symbol('└', '+'),
1734
+ log_level_info=Symbol('●', 'i'),
1735
+ log_level_success=Symbol('◆', '*'),
1736
+ log_level_warn=Symbol('▲', '!'),
1737
+ log_level_error=Symbol('■', 'x'),
1738
+ spinner=SpinnerSymbols(
1739
+ unicode_symbols=('◒', '◐', '◓', '◑'),
1740
+ ascii_symbols=('|', '/', '-', '\\')),
1741
+ progress_light=Symbol('─', '-'),
1742
+ progress_heavy=Symbol('━', '='),
1743
+ progress_block=Symbol('█', '#')))
1744
+
1745
+ set_active_theme(custom_theme)
1746
+ ```
1747
+
1748
+ The repository ships more than 30 built-in themes, each with its own colors and symbols. See `demos/demo.py` for the full list of names under `Themes`.
1749
+
1750
+ ## Unicode and ASCII symbols
1751
+
1752
+ Every `Symbol` holds a Unicode form and an ASCII fallback.
1753
+
1754
+ ```python
1755
+ from pyclack.renderer import Symbol
1756
+
1757
+ marker: Symbol = Symbol('◆', '*')
1758
+ ```
1759
+
1760
+ The renderer picks whichever form fits the current terminal.
1761
+
1762
+ Force ASCII-only output with:
1763
+
1764
+ ```python
1765
+ from pyclack import set_print_mode_ascii
1766
+
1767
+ set_print_mode_ascii()
1768
+ ```
1769
+
1770
+ ---
1771
+
1772
+ # Rendering
1773
+
1774
+ The rendering system lives under `pyclack.renderer`.
1775
+
1776
+ ```python
1777
+ from pyclack.renderer import FrameBuilder, RenderFrame, SpinnerSymbols, Style, Symbol, Symbols, Text, Theme, Themes
1778
+ ```
1779
+
1780
+ The rendering model has four steps:
1781
+
1782
+ 1. Build `Text` objects.
1783
+ 2. Add them to a `FrameBuilder`.
1784
+ 3. Build the frame.
1785
+ 4. Draw it with `RenderFrame`.
1786
+ ## `Text`
1787
+
1788
+ `Text` pairs terminal text with an optional style.
1789
+
1790
+ ```python
1791
+ from pyclack.renderer import Style, Text
1792
+
1793
+ style: Style = Style(fg_color='cyan', bold=True)
1794
+
1795
+ text: Text = Text('Hello', style)
1796
+ ```
1797
+
1798
+ You can combine `Text` objects to build more complex output.
1799
+
1800
+ ## `FrameBuilder`
1801
+
1802
+ `FrameBuilder` collects the lines that make up one frame.
1803
+
1804
+ ```python
1805
+ from pyclack.renderer import FrameBuilder, Text
1806
+
1807
+ builder: FrameBuilder = FrameBuilder()
1808
+
1809
+ builder.add_line(Text('First line'))
1810
+ builder.add_line(Text('Second line'))
1811
+
1812
+ frame: tuple[Text, ...] = builder.build()
1813
+ ```
1814
+
1815
+ Use `add_lines()` to add more than one line at once.
1816
+
1817
+ ```python
1818
+ from pyclack.renderer import FrameBuilder, Text
1819
+
1820
+ builder: FrameBuilder = FrameBuilder()
1821
+
1822
+ builder.add_lines(
1823
+ Text('First'),
1824
+ Text('Second'),
1825
+ Text('Third'))
1826
+
1827
+ frame: tuple[Text, ...] = builder.build()
1828
+ ```
1829
+
1830
+ ## `RenderFrame`
1831
+
1832
+ `RenderFrame` owns the frame currently on screen.
1833
+
1834
+ When you draw a new frame, `RenderFrame` clears the old one first.
1835
+
1836
+ ```python
1837
+ from pyclack.renderer import RenderFrame, Text
1838
+
1839
+ render_frame: RenderFrame = RenderFrame()
1840
+
1841
+ render_frame.draw_frame(Text('Loading...'))
1842
+ ```
1843
+
1844
+ Clear a frame directly with:
1845
+
1846
+ ```python
1847
+ from pyclack.renderer import RenderFrame, Text
1848
+
1849
+ render_frame: RenderFrame = RenderFrame()
1850
+
1851
+ render_frame.draw_frame(Text('Loading...'))
1852
+ render_frame.clear_frame()
1853
+ ```
1854
+
1855
+ This frame model lets a spinner, a prompt, or a progress bar redraw itself in place, instead of printing a new line every time it updates.
1856
+
1857
+ ---
1858
+
1859
+ # Terminal
1860
+
1861
+ The terminal subsystem lives under `pyclack.terminal`.
1862
+
1863
+ ```python
1864
+ from pyclack.terminal import CursorController, EchoController, KeyReader, Stdout
1865
+ ```
1866
+
1867
+ It gives prompts and widgets the low-level terminal operations they need.
1868
+
1869
+ ## `KeyReader`
1870
+
1871
+ `KeyReader` reads one key at a time, instead of waiting for a full line of input.
1872
+
1873
+ A custom component should use `KeyReader` instead of reading `stdin` directly.
1874
+
1875
+ ## `CursorController`
1876
+
1877
+ `CursorController` builds the escape sequences that hide the cursor, show the cursor, and move or clear rendered lines.
1878
+
1879
+ ```python
1880
+ from pyclack.terminal import CursorController
1881
+
1882
+ hide_sequence: str = CursorController.hide_cursor()
1883
+ show_sequence: str = CursorController.show_cursor()
1884
+ ```
1885
+
1886
+ ## `Stdout`
1887
+
1888
+ `Stdout` is the output abstraction every pyclack component writes through.
1889
+
1890
+ A custom widget should use `Stdout` instead of calling `print()` directly inside its rendering code.
1891
+
1892
+ ## `EchoController`
1893
+
1894
+ `EchoController` turns terminal echo on and off for interactive components.
1895
+
1896
+ This matters most for a prompt that needs to control how a keypress, such as Ctrl+C, appears on screen.
1897
+
1898
+ A custom interactive component should use the existing terminal controllers, instead of writing its own platform-specific terminal code.
1899
+
1900
+ ---
1901
+
1902
+ # Building a custom prompt
1903
+
1904
+ If none of the built-in prompts fit your use case, subclass `PromptBase`. It gives your prompt the same state machine and rendering conventions the built-in prompts use.
1905
+
1906
+ A prompt moves through five states:
1907
+
1908
+ ```python
1909
+ from pyclack.prompts import PromptState
1910
+
1911
+ initial: PromptState = PromptState.INITIAL
1912
+ active: PromptState = PromptState.ACTIVE
1913
+ submit: PromptState = PromptState.SUBMIT
1914
+ cancel: PromptState = PromptState.CANCEL
1915
+ error: PromptState = PromptState.ERROR
1916
+ ```
1917
+
1918
+ The normal flow is:
1919
+
1920
+ ```text
1921
+ INITIAL
1922
+ |
1923
+ v
1924
+ ACTIVE <----+
1925
+ | |
1926
+ v |
1927
+ SUBMIT |
1928
+ | |
1929
+ +-- error
1930
+ |
1931
+ v
1932
+ EXIT
1933
+
1934
+ ACTIVE/ERROR
1935
+ |
1936
+ +----> CANCEL
1937
+ ```
1938
+
1939
+ `PromptBase` runs the state machine. Your subclass supplies the behavior for each state.
1940
+
1941
+ ## Custom prompt structure
1942
+
1943
+ A custom prompt should:
1944
+
1945
+ 1. Subclass `PromptBase`.
1946
+ 2. Store its input state on the prompt instance.
1947
+ 3. Create a `RenderFrame`.
1948
+ 4. Implement `handle_active()`.
1949
+ 5. Implement `handle_submit()`.
1950
+ 6. Implement `handle_error()`, if the prompt needs validation.
1951
+ 7. Implement `handle_cancel()`.
1952
+ 8. Raise `CancelException` from `handle_cancel()`.
1953
+ 9. Render each state through `FrameBuilder`, `Text`, the active `Theme`, and `RenderFrame`.
1954
+ 10. Expose a small public function that builds the prompt and returns its final value.
1955
+ A minimal prompt looks like this:
1956
+
1957
+ ```python
1958
+ from typing import override
1959
+
1960
+ from pyclack.prompts import CancelException, PromptBase
1961
+ from pyclack.renderer import FrameBuilder, RenderFrame, Text
1962
+
1963
+ class CustomPrompt(PromptBase):
1964
+ def __init__(self, message: str) -> None:
1965
+ super().__init__()
1966
+
1967
+ self.message: str = message
1968
+ self.value: str = ''
1969
+ self.render_frame: RenderFrame = RenderFrame()
1970
+
1971
+ self.activate()
1972
+
1973
+ @override
1974
+ def handle_active(self, key: str | None) -> bool:
1975
+ frame_builder: FrameBuilder = FrameBuilder()
1976
+ frame_builder.add_line(Text(self.message))
1977
+ frame_builder.add_line(Text(self.value))
1978
+
1979
+ frame: tuple[Text, ...] = frame_builder.build()
1980
+ self.render_frame.draw_frame(*frame)
1981
+
1982
+ if key == 'ENTER': return True
1983
+ if key: self.value += key
1984
+ return False
1985
+
1986
+ @override
1987
+ def handle_submit(self) -> bool:
1988
+ return True
1989
+
1990
+ @override
1991
+ def handle_cancel(self) -> None:
1992
+ raise CancelException[str](self.value)
1993
+ ```
1994
+
1995
+ Then expose it through a small function:
1996
+
1997
+ ```python
1998
+ from pyclack.prompts import CancelException
1999
+
2000
+ def custom_prompt(message: str) -> str:
2001
+ prompt: CustomPrompt = CustomPrompt(message)
2002
+ return prompt.value
2003
+ ```
2004
+
2005
+ A real prompt's rendering is usually more involved than this. The convention that matters is: the prompt owns its state, and `PromptBase` owns the state machine.
2006
+
2007
+ ## Validation
2008
+
2009
+ When the prompt can enter an invalid state, `handle_submit()` should return `False`.
2010
+
2011
+ Returning `False` sends the prompt into `handle_error()`.
2012
+
2013
+ ```python
2014
+ from typing import override
2015
+
2016
+ from pyclack.prompts import PromptBase
2017
+
2018
+ class ValidatedPrompt(PromptBase):
2019
+ @override
2020
+ def handle_submit(self) -> bool:
2021
+ return self._is_valid()
2022
+ ```
2023
+
2024
+ `handle_error()` renders the error state, then returns one of:
2025
+
2026
+ - `False`, to stay in the error state
2027
+ - `True`, to return to the active state
2028
+ `PromptBase` handles the state change either way.
2029
+
2030
+ ## Propagating the key after an error
2031
+
2032
+ A prompt that wants the key that clears an error to also act as the next active-state key can set:
2033
+
2034
+ ```python
2035
+ from pyclack.prompts import PromptBase
2036
+
2037
+ class CustomPrompt(PromptBase):
2038
+ def __init__(self) -> None:
2039
+ super().__init__()
2040
+
2041
+ self.propagate_key_after_error: bool = True
2042
+ ```
2043
+
2044
+ ---
2045
+
2046
+ # Building a custom widget
2047
+
2048
+ A widget does not use the prompt state machine.
2049
+
2050
+ A custom widget owns its own rendering state directly, and uses `RenderFrame` to redraw it.
2051
+
2052
+ A minimal stateful widget looks like this:
2053
+
2054
+ ```python
2055
+ from pyclack.renderer import FrameBuilder, RenderFrame, Text
2056
+
2057
+ class CustomWidget:
2058
+ def __init__(self) -> None:
2059
+ self.render_frame: RenderFrame = RenderFrame()
2060
+ self.message: str = ''
2061
+
2062
+ def start(self, message: str) -> None:
2063
+ self.message = message
2064
+ self._render()
2065
+
2066
+ def set_message(self, message: str) -> None:
2067
+ self.message = message
2068
+ self._render()
2069
+
2070
+ def clear(self) -> None:
2071
+ self.render_frame.clear_frame()
2072
+
2073
+ def _render(self) -> None:
2074
+ frame_builder: FrameBuilder = FrameBuilder()
2075
+ frame_builder.add_line(Text(self.message))
2076
+
2077
+ frame: tuple[Text, ...] = frame_builder.build()
2078
+ self.render_frame.draw_frame(*frame)
2079
+ ```
2080
+
2081
+ A real pyclack widget pulls its style from the active theme, instead of hard-coding it:
2082
+
2083
+ ```python
2084
+ from pyclack import get_active_theme
2085
+ from pyclack.renderer import FrameBuilder, RenderFrame, Text
2086
+
2087
+ class ThemedWidget:
2088
+ def __init__(self) -> None:
2089
+ self.render_frame: RenderFrame = RenderFrame()
2090
+
2091
+ def render(self, message: str) -> None:
2092
+ theme = get_active_theme()
2093
+
2094
+ frame_builder: FrameBuilder = FrameBuilder()
2095
+ frame_builder.add_line(Text(message, theme.text))
2096
+
2097
+ frame: tuple[Text, ...] = frame_builder.build()
2098
+ self.render_frame.draw_frame(*frame)
2099
+ ```
2100
+
2101
+ This keeps the widget correct under every active theme.
2102
+
2103
+ Some widgets take time to render, what if the user presses Ctrl+C during that? `CancelException` should be raised. pyclack does this be swapping the hander that runs when a `SIGINT` is raised by a Ctrl+C press.
2104
+
2105
+ This is usually run at the start of the pyclack widget:
2106
+ ```python
2107
+ import signal
2108
+ from pyclack import CancelException
2109
+
2110
+ old_sigint_handler = signal.getsignal(signal.SIGINT)
2111
+ def handle_interrupt(signum, frame) -> None:
2112
+ signal.signal(signal.SIGINT, old_sigint_handler) # resets back to old handler if cancelled
2113
+ raise CancelException
2114
+ signal.signal(signal.SIGINT, handle_interrupt)
2115
+ ```
2116
+
2117
+ At the end of the pyclack widget the handler is reset:
2118
+ ```python
2119
+ signal.signal(signal.SIGINT, old_sigint_handler)
2120
+ ```
2121
+
2122
+ ---
2123
+
2124
+ # Custom component conventions
2125
+
2126
+ When you extend pyclack, follow the same pattern as the built-in components.
2127
+
2128
+ ### Prompts
2129
+
2130
+ - Subclass `PromptBase`.
2131
+ - Keep interactive state on the prompt instance.
2132
+ - Drive the prompt through `PromptState`, using the base state machine, instead of writing a separate input loop.
2133
+ - Render every state through `RenderFrame`.
2134
+ - Build output with `Text` and `FrameBuilder`.
2135
+ - Pull colors and symbols from `get_active_theme()`.
2136
+ - Signal cancellation through `CancelException`.
2137
+ - Put useful partial state in `CancelException.value`.
2138
+ - Support `abort_time` when the prompt should be able to cancel itself.
2139
+ - Expose one small public function that returns the prompt's final value.
2140
+ ### Widgets
2141
+
2142
+ - Do not subclass `PromptBase`.
2143
+ - Own the widget's state directly.
2144
+ - Set own custom Ctrl+C handler.
2145
+ - Use `RenderFrame` for output that redraws in place.
2146
+ - Build frames with `FrameBuilder` and `Text`.
2147
+ - Pull visual properties from the active theme.
2148
+ - Use `Stdout` and the terminal controllers for terminal changes.
2149
+ - Restore old handler, cursor, and echo state when the widget finishes or is cancelled.
2150
+ ### Themes
2151
+
2152
+ - Never hard-code a color or symbol that belongs in the theme.
2153
+ - Use `Style` for styles.
2154
+ - Use `Symbol` for individual symbols.
2155
+ - Use `SpinnerSymbols` for animated spinner frames.
2156
+ - Provide both a Unicode form and an ASCII fallback where one applies.
2157
+ This separation lets pyclack change its whole appearance without touching a single prompt or widget's code.
2158
+
2159
+ ---
2160
+
2161
+ # Example
2162
+
2163
+ This small script combines a few prompts and widgets. It follows the same style as `demos/demo.py`, which covers every prompt and widget in the package.
2164
+
2165
+ ```python
2166
+ from pyclack import CancelException, ClackOption
2167
+ from pyclack.prompts import ask, select
2168
+ from pyclack.widgets import intro, outro, cancel
2169
+
2170
+ def main() -> None:
2171
+ intro('Example')
2172
+
2173
+ try:
2174
+ name: str = ask('What is your name?')
2175
+
2176
+ languages: list[ClackOption[str]] = [
2177
+ ClackOption[str](value='python', label='Python'),
2178
+ ClackOption[str](value='rust', label='Rust')]
2179
+
2180
+ language: ClackOption[str] = select(
2181
+ 'Favorite language',
2182
+ options=languages)
2183
+ except CancelException:
2184
+ cancel('Operation cancelled.')
2185
+ exit(0)
2186
+
2187
+ outro(f'Hello {name}! You chose {language.value}.')
2188
+
2189
+ if __name__ == '__main__':
2190
+ main()
2191
+ ```
2192
+
2193
+ Run the full demo with:
2194
+
2195
+ ```bash
2196
+ uv run demos/demo.py
2197
+ ```
2198
+
2199
+ The pattern behind every piece of pyclack stays the same:
2200
+
2201
+ ```text
2202
+ prompt -> value
2203
+ widget -> terminal output
2204
+ cancel -> CancelException
2205
+ partial state -> e.value
2206
+ selection -> ClackOption[T]
2207
+ theme -> active Theme
2208
+ custom prompt -> PromptBase
2209
+ custom widget -> RenderFrame
2210
+ ```
2211
+
2212
+ That is the core of pyclack.