zettcode-tui 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. zettcode/tui/__init__.py +422 -0
  2. zettcode/tui/_compat.py +32 -0
  3. zettcode/tui/capabilities.py +107 -0
  4. zettcode/tui/clipboard.py +91 -0
  5. zettcode/tui/console.py +229 -0
  6. zettcode/tui/core/__init__.py +63 -0
  7. zettcode/tui/core/app.py +658 -0
  8. zettcode/tui/core/events.py +134 -0
  9. zettcode/tui/core/focus.py +85 -0
  10. zettcode/tui/core/geometry.py +212 -0
  11. zettcode/tui/core/host.py +66 -0
  12. zettcode/tui/core/keymap.py +218 -0
  13. zettcode/tui/core/scheduler.py +105 -0
  14. zettcode/tui/core/screen.py +80 -0
  15. zettcode/tui/core/theme.py +248 -0
  16. zettcode/tui/core/widget.py +204 -0
  17. zettcode/tui/gallery.py +588 -0
  18. zettcode/tui/glyphs.py +73 -0
  19. zettcode/tui/input/__init__.py +18 -0
  20. zettcode/tui/input/bridge.py +46 -0
  21. zettcode/tui/input/decoder.py +234 -0
  22. zettcode/tui/input/events.py +64 -0
  23. zettcode/tui/input/keys.py +289 -0
  24. zettcode/tui/input/reader.py +147 -0
  25. zettcode/tui/layout/__init__.py +24 -0
  26. zettcode/tui/layout/box.py +153 -0
  27. zettcode/tui/layout/overlay.py +164 -0
  28. zettcode/tui/layout/scroll.py +477 -0
  29. zettcode/tui/layout/solver.py +95 -0
  30. zettcode/tui/layout/spacing.py +137 -0
  31. zettcode/tui/py.typed +0 -0
  32. zettcode/tui/render/__init__.py +71 -0
  33. zettcode/tui/render/ansi.py +371 -0
  34. zettcode/tui/render/canvas.py +153 -0
  35. zettcode/tui/render/code.py +456 -0
  36. zettcode/tui/render/color.py +177 -0
  37. zettcode/tui/render/renderer.py +151 -0
  38. zettcode/tui/render/rich_text.py +80 -0
  39. zettcode/tui/render/style.py +121 -0
  40. zettcode/tui/render/sweep.py +87 -0
  41. zettcode/tui/render/text.py +319 -0
  42. zettcode/tui/runner.py +316 -0
  43. zettcode/tui/terminal.py +222 -0
  44. zettcode/tui/testing/__init__.py +6 -0
  45. zettcode/tui/testing/harness.py +220 -0
  46. zettcode/tui/testing/snapshot.py +64 -0
  47. zettcode/tui/theme_file.py +300 -0
  48. zettcode/tui/themes/ayu-mirage.toml +44 -0
  49. zettcode/tui/themes/catppuccin-latte.toml +44 -0
  50. zettcode/tui/themes/catppuccin-mocha.toml +44 -0
  51. zettcode/tui/themes/dracula.toml +44 -0
  52. zettcode/tui/themes/github-dimmed.toml +44 -0
  53. zettcode/tui/themes/github-light.toml +44 -0
  54. zettcode/tui/themes/gruvbox-dark.toml +44 -0
  55. zettcode/tui/themes/gruvbox-light.toml +44 -0
  56. zettcode/tui/themes/kanagawa.toml +44 -0
  57. zettcode/tui/themes/nord.toml +44 -0
  58. zettcode/tui/themes/one-dark.toml +44 -0
  59. zettcode/tui/themes/rose-pine-dawn.toml +44 -0
  60. zettcode/tui/themes/rose-pine.toml +44 -0
  61. zettcode/tui/themes/solarized-dark.toml +44 -0
  62. zettcode/tui/themes/solarized-light.toml +44 -0
  63. zettcode/tui/themes/tokyo-night.toml +44 -0
  64. zettcode/tui/widgets/__init__.py +121 -0
  65. zettcode/tui/widgets/collapsible.py +116 -0
  66. zettcode/tui/widgets/completion.py +211 -0
  67. zettcode/tui/widgets/console.py +442 -0
  68. zettcode/tui/widgets/dialog.py +165 -0
  69. zettcode/tui/widgets/diff.py +236 -0
  70. zettcode/tui/widgets/list.py +293 -0
  71. zettcode/tui/widgets/list_page.py +121 -0
  72. zettcode/tui/widgets/markdown.py +657 -0
  73. zettcode/tui/widgets/progress.py +166 -0
  74. zettcode/tui/widgets/rich_text.py +84 -0
  75. zettcode/tui/widgets/status.py +106 -0
  76. zettcode/tui/widgets/table.py +101 -0
  77. zettcode/tui/widgets/tasks.py +130 -0
  78. zettcode/tui/widgets/text.py +111 -0
  79. zettcode/tui/widgets/textarea.py +671 -0
  80. zettcode/tui/widgets/toast.py +137 -0
  81. zettcode_tui-0.4.0.dist-info/METADATA +20 -0
  82. zettcode_tui-0.4.0.dist-info/RECORD +83 -0
  83. zettcode_tui-0.4.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,153 @@
1
+ """Clipped two-dimensional cell buffer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field, replace
7
+
8
+ from .style import DEFAULT_STYLE, Cell, Span, Style
9
+ from .text import cell_glyph
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class Canvas:
14
+ """Clipped two-dimensional cell buffer."""
15
+
16
+ width: int
17
+ height: int
18
+ cells: list[list[Cell]] = field(init=False)
19
+
20
+ def __post_init__(self) -> None:
21
+ """Allocate the full grid of blank cells."""
22
+ self.cells = [[Cell() for _ in range(self.width)] for _ in range(self.height)]
23
+
24
+ def set_cell(self, x: int, y: int, character: str, style: Style = DEFAULT_STYLE) -> int:
25
+ """Draw one printable glyph and return its display width.
26
+
27
+ A style that names no background keeps the one already in the cell, so a
28
+ foreground-only span cannot punch a hole through the page fill or the
29
+ panel band it is drawn on top of. ``Canvas.fill`` writes cells verbatim
30
+ and stays the way to clear a region on purpose.
31
+ """
32
+ glyph, width = cell_glyph(character)
33
+ if not (0 <= y < self.height) or x >= self.width:
34
+ return width
35
+ if width == 0:
36
+ if x > 0:
37
+ previous = self.cells[y][x - 1]
38
+ self.cells[y][x - 1] = Cell(previous.character + character, previous.style, previous.continuation)
39
+ return 0
40
+ if x < 0 or x + width > self.width:
41
+ return width
42
+ style = self._keep_background(x, y, style)
43
+ self.cells[y][x] = Cell(glyph, style)
44
+ for offset in range(1, width):
45
+ self.cells[y][x + offset] = Cell("", style, continuation=True)
46
+ return width
47
+
48
+ def _keep_background(self, x: int, y: int, style: Style) -> Style:
49
+ """Return ``style`` with the cell's existing background when it names none."""
50
+ if style.background is not None:
51
+ return style
52
+ existing = self.cells[y][x].style.background
53
+ return style if existing is None else replace(style, background=existing)
54
+
55
+ def draw_text(
56
+ self,
57
+ x: int,
58
+ y: int,
59
+ text: str,
60
+ style: Style = DEFAULT_STYLE,
61
+ *,
62
+ max_width: int | None = None,
63
+ tab_size: int = 4,
64
+ ) -> int:
65
+ """Draw one unwrapped line and return consumed columns.
66
+
67
+ Args:
68
+ x: Left column; may be negative, in which case the text is clipped.
69
+ y: Row; nothing is drawn when it falls outside the canvas.
70
+ text: Single line; ``\\r``, ``\\n``, and anything past the budget stop
71
+ the draw without raising.
72
+ style: Style applied to every painted cell.
73
+ max_width: Column budget counted from ``x``; the canvas edge always
74
+ wins when it is nearer.
75
+ tab_size: Cells between tab stops, used to expand ``\\t`` in place.
76
+
77
+ Returns:
78
+ Columns consumed, which is not the same as characters drawn.
79
+ """
80
+ used = 0
81
+ limit = self.width - x if max_width is None else min(max_width, self.width - x)
82
+ for character in text:
83
+ if character in "\r\n":
84
+ break
85
+ if character == "\t":
86
+ spaces = tab_size - ((x + used) % tab_size)
87
+ spaces = min(spaces, max(0, limit - used))
88
+ self.fill(x + used, y, spaces, 1, style)
89
+ used += spaces
90
+ continue
91
+ width = cell_glyph(character)[1]
92
+ if used + width > limit:
93
+ break
94
+ self.set_cell(x + used, y, character, style)
95
+ used += width
96
+ return used
97
+
98
+ def draw_spans(self, x: int, y: int, spans: tuple[Span, ...], *, max_width: int | None = None) -> int:
99
+ """Draw styled fragments without crossing the requested width.
100
+
101
+ Args:
102
+ x: Left column.
103
+ y: Row.
104
+ spans: Fragments drawn left to right with their own styles.
105
+ max_width: Column budget counted from ``x``; ``None`` uses the
106
+ distance to the canvas edge.
107
+
108
+ Returns:
109
+ Columns consumed.
110
+ """
111
+ used = 0
112
+ limit = self.width - x if max_width is None else min(max_width, self.width - x)
113
+ for span in spans:
114
+ if used >= limit:
115
+ break
116
+ used += self.draw_text(x + used, y, span.text, span.style, max_width=limit - used)
117
+ return used
118
+
119
+ def fill(self, x: int, y: int, width: int, height: int, style: Style, character: str = " ") -> None:
120
+ """Fill one clipped rectangle.
121
+
122
+ Args:
123
+ x: Left column; parts outside the canvas are dropped.
124
+ y: Top row.
125
+ width: Cells to fill horizontally.
126
+ height: Rows to fill vertically.
127
+ style: Style applied to every cell written.
128
+ character: Glyph written into each cell; wide glyphs are not widened.
129
+ """
130
+ for row in range(max(0, y), min(self.height, y + height)):
131
+ for column in range(max(0, x), min(self.width, x + width)):
132
+ self.cells[row][column] = Cell(character, style)
133
+
134
+ def restyle(self, x: int, y: int, width: int, transform: Callable[[Style], Style]) -> None:
135
+ """Rewrite the style of one clipped column range, leaving glyphs alone.
136
+
137
+ Selection is a repaint of cells that were already drawn, so it must be
138
+ able to change a style without disturbing the character underneath.
139
+
140
+ Args:
141
+ x: Left column of the range.
142
+ y: Row to rewrite; out-of-range rows are ignored.
143
+ width: Cells to visit, clipped to the canvas.
144
+ transform: Applied to the current style of each cell; wide glyph
145
+ continuation cells are skipped so only the leader is rewritten.
146
+ """
147
+ if not 0 <= y < self.height:
148
+ return
149
+ for column in range(max(0, x), min(self.width, x + width)):
150
+ cell = self.cells[y][column]
151
+ if cell.continuation:
152
+ continue
153
+ self.cells[y][column] = Cell(cell.character, transform(cell.style), cell.continuation)
@@ -0,0 +1,456 @@
1
+ """Line-oriented syntax highlighting whose colours come from a theme.
2
+
3
+ The tokenizer is deliberately small: it covers the shapes a terminal reader
4
+ actually scans for (comments, strings, numbers, keywords, called names) and
5
+ merges everything else into plain code text. Nothing here picks a colour — a
6
+ :class:`CodeTheme` supplies every one, so a palette is data, not code.
7
+
8
+ Languages are data too. Almost every language a fence can name shares the same
9
+ shapes and differs only in its word lists and markers — JavaScript's ``//``
10
+ against SQL's ``--``, quotes, keywords — so :func:`_scanner` compiles one from
11
+ that description and :data:`_SPECS` holds the descriptions: the brace languages,
12
+ the config formats, SQL, and the markup family. Python and shell keep
13
+ hand-written scanners because their lexical rules (decorators and triple
14
+ quotes; variables and command positions) do not fit the shared shape, and LaTeX
15
+ has its own because ``\\command``, math mode, and ``%`` are unlike any of them.
16
+ An unknown fence label still falls back to the generic scanner.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import builtins
22
+ import keyword
23
+ import re
24
+ from dataclasses import dataclass
25
+
26
+ from .style import Span, Style
27
+
28
+ PYTHON = "python"
29
+ SHELL = "shell"
30
+ LATEX = "latex"
31
+ GENERIC = "generic"
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class CodeTheme:
36
+ """Token colours for highlighted code.
37
+
38
+ Attributes:
39
+ name: Palette name, shown in theme listings.
40
+ inline: Colour of inline code spans inside Markdown prose.
41
+ text: Colour of code that belongs to no other token class.
42
+ keyword: Colour of language keywords.
43
+ string: Colour of quoted literals.
44
+ comment: Colour of comments; also drawn italic.
45
+ number: Colour of numeric literals.
46
+ function: Colour of a name that is immediately called.
47
+ builtin: Colour of builtin names and the ``True``/``False``/``None`` trio.
48
+ operator: Colour of arithmetic and comparison operators.
49
+ punctuator: Colour of brackets, commas, and other separators.
50
+ """
51
+
52
+ name: str = "default"
53
+ inline: str = "#9bddad"
54
+ text: str = "#f2f5f3"
55
+ keyword: str = "#e58fa8"
56
+ string: str = "#9bddad"
57
+ comment: str = "#6d7a70"
58
+ number: str = "#d8b46a"
59
+ function: str = "#7fb7d8"
60
+ builtin: str = "#b8a6e0"
61
+ operator: str = "#a3ada6"
62
+ punctuator: str = "#a3ada6"
63
+
64
+ def style_for(self, kind: str) -> Style:
65
+ """Return the style for one token class."""
66
+ return Style(foreground=getattr(self, kind, self.text), italic=kind == "comment")
67
+
68
+
69
+ DEFAULT_CODE_THEME = CodeTheme()
70
+
71
+ _PYTHON_TOKENS = re.compile(
72
+ r"(?P<space>\s+)"
73
+ r"|(?P<comment>#[^\n]*)"
74
+ r"|(?P<string>[rbfuRBFU]{0,2}(?:'''[^\n]*|'''|\"\"\"[^\n]*|\"\"\"|'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"))"
75
+ r"|(?P<number>\b(?:0[xXbBoO][0-9a-fA-F_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?)\b)"
76
+ r"|(?P<name>[A-Za-z_][A-Za-z0-9_]*)"
77
+ r"|(?P<operator>[-+*/%=<>!&|^~]+)"
78
+ r"|(?P<other>.)"
79
+ )
80
+
81
+ _GENERIC_TOKENS = re.compile(
82
+ r"(?P<space>\s+)"
83
+ r"|(?P<comment>//[^\n]*|#[^\n]*|--[^\n]*)"
84
+ r"|(?P<string>'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\")"
85
+ r"|(?P<number>\b\d[\d_]*(?:\.\d+)?\b)"
86
+ r"|(?P<name>[A-Za-z_][A-Za-z0-9_]*)"
87
+ r"|(?P<other>.)"
88
+ )
89
+
90
+ _SHELL_TOKENS = re.compile(
91
+ r"(?P<space>\s+)"
92
+ r"|(?P<comment>#[^\n]*)"
93
+ r"|(?P<string>'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\")"
94
+ r"|(?P<variable>\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|\$[0-9@*#?!$])"
95
+ r"|(?P<operator>\|\||&&|\d*[<>]&?\d*|[|;&<>])"
96
+ r"|(?P<number>\b\d[\d_]*(?:\.\d+)?\b)"
97
+ r"|(?P<name>[A-Za-z_][\w./-]*)"
98
+ r"|(?P<other>.)"
99
+ )
100
+
101
+ _LATEX_TOKENS = re.compile(
102
+ r"(?P<space>\s+)"
103
+ r"|(?P<comment>%[^\n]*)"
104
+ r"|(?P<string>\$\$[^\n]*?\$\$|\$[^$\n]*\$)"
105
+ r"|(?P<function>\\[A-Za-z@]+|\\.)"
106
+ r"|(?P<number>\b\d[\d_]*(?:\.\d+)?\b)"
107
+ r"|(?P<name>[A-Za-z][A-Za-z0-9]*)"
108
+ r"|(?P<other>.)"
109
+ )
110
+
111
+ _BUILTINS = frozenset(dir(builtins))
112
+
113
+ #: Token kinds that borrow another kind's colour.
114
+ _ALIASES = {"variable": "builtin"}
115
+
116
+ #: Shell operators that start a new command, so the next word is a command
117
+ #: name; redirections such as ``2>`` leave the following word an argument.
118
+ _SHELL_SEPARATORS = frozenset({"&&", "||", "|", ";", "&"})
119
+
120
+
121
+ def _scanner(
122
+ *, comments: tuple[str, ...] = (), block: tuple[str, str] | None = None, quotes: str = "\"'"
123
+ ) -> re.Pattern[str]:
124
+ """Compile the scanner shared by the languages that differ only in markers.
125
+
126
+ Args:
127
+ comments: Prefixes that start a comment running to the end of the line.
128
+ block: Opening and closing markers of an inline block comment; a block
129
+ left open is treated as a comment to the end of the line, which is
130
+ all a line-oriented highlighter can promise while text streams.
131
+ quotes: Quote characters; a string that never closes still takes the
132
+ rest of the line, so a half-streamed literal is not painted as code.
133
+ """
134
+ comment: list[str] = []
135
+ if block is not None:
136
+ opening, closing = block
137
+ comment.append(rf"{re.escape(opening)}.*?(?:{re.escape(closing)}|$)")
138
+ comment.extend(rf"{re.escape(marker)}[^\n]*" for marker in comments)
139
+ escaped = "".join(re.escape(quote) for quote in quotes)
140
+ parts = [r"(?P<space>\s+)"]
141
+ if comment:
142
+ parts.append("|(?P<comment>" + "|".join(comment) + ")")
143
+ if escaped:
144
+ parts.append(rf"|(?P<string>[{escaped}](?:\\.|[^\\{escaped}])*(?:[{escaped}]|$))")
145
+ parts.append(r"|(?P<number>\b\d[\d_]*(?:\.\d+)?\b)")
146
+ parts.append(r"|(?P<name>[A-Za-z_][\w./-]*)")
147
+ parts.append(r"|(?P<other>.)")
148
+ return re.compile("".join(parts))
149
+
150
+
151
+ def _words(text: str) -> frozenset[str]:
152
+ """Return the words of one compact keyword list as a set."""
153
+ return frozenset(text.split())
154
+
155
+
156
+ #: Keyword lists, one line per language; the scanner colours whatever it finds.
157
+ _KEYWORDS = {
158
+ "javascript": _words(
159
+ "async await break case catch class const continue debugger default delete do else export extends false "
160
+ "finally for from function get if import in instanceof let new null of return set static super switch this "
161
+ "throw true try typeof undefined var void while with yield"
162
+ ),
163
+ "typescript": _words(
164
+ "abstract any as asserts async await boolean break case catch class const constructor continue declare "
165
+ "default delete do else enum export extends false finally for from function get if implements import in "
166
+ "infer instanceof interface is keyof let module namespace never new null number object of out override "
167
+ "private protected public readonly return satisfies set static string super switch symbol this throw true "
168
+ "try type typeof undefined unique unknown var void while yield"
169
+ ),
170
+ "rust": _words(
171
+ "as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod "
172
+ "move mut pub ref return self Self static struct super trait true type unsafe use where while"
173
+ ),
174
+ "go": _words(
175
+ "break case chan const continue default defer else fallthrough false for func go goto if import interface "
176
+ "map nil package range return select struct switch true type var"
177
+ ),
178
+ "java": _words(
179
+ "abstract assert boolean break byte case catch char class const continue default do double else enum "
180
+ "extends false final finally float for goto if implements import instanceof int interface long native new "
181
+ "null package private protected public return short static strictfp super switch synchronized this throw "
182
+ "throws transient true try void volatile while"
183
+ ),
184
+ "c": _words(
185
+ "auto break case char const continue default do double else enum extern false float for goto if inline int "
186
+ "long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile "
187
+ "while"
188
+ ),
189
+ "cpp": _words(
190
+ "alignas alignof asm auto bool break case catch class concept const constexpr const_cast continue decltype "
191
+ "default delete do double dynamic_cast else enum explicit export extern false float for friend goto if "
192
+ "inline int long mutable namespace new noexcept nullptr operator override private protected public "
193
+ "reinterpret_cast requires return short signed sizeof static static_assert static_cast struct switch "
194
+ "template this thread_local throw true try typedef typename union unsigned using virtual void volatile while"
195
+ ),
196
+ "csharp": _words(
197
+ "abstract as async await base bool break byte case catch char checked class const continue decimal default "
198
+ "delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit "
199
+ "in int interface internal is lock long namespace new null object operator out override params private "
200
+ "protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this "
201
+ "throw true try typeof uint ulong unchecked unsafe ushort using var virtual void volatile while"
202
+ ),
203
+ "ruby": _words(
204
+ "alias and begin break case class def defined do else elsif end ensure false for if in module next nil not "
205
+ "or redo rescue retry return self super then true undef unless until when while yield"
206
+ ),
207
+ "php": _words(
208
+ "abstract and array as break case catch class clone const continue declare default do echo else elseif empty "
209
+ "enddeclare endfor endforeach endif endswitch endwhile eval exit extends false final finally fn for foreach "
210
+ "function global goto if implements include instanceof interface isset list match namespace new null or "
211
+ "print private protected public readonly require return static switch throw trait true try unset use var "
212
+ "while xor yield"
213
+ ),
214
+ "sql": _words(
215
+ "add all alter and any as asc begin between by case cast check column commit constraint create cross default "
216
+ "delete desc distinct drop else end exists foreign from full group having if in index inner insert into is "
217
+ "join key left like limit not null offset on or order outer primary references right rollback select set "
218
+ "table then transaction union unique update values view when where"
219
+ ),
220
+ "lua": _words(
221
+ "and break do else elseif end false for function goto if in local nil not or repeat return then true until "
222
+ "while"
223
+ ),
224
+ "haskell": _words(
225
+ "case class data default deriving do else foreign if import in infix instance let module newtype of then "
226
+ "type where"
227
+ ),
228
+ "swift": _words(
229
+ "as associatedtype break case catch class continue default defer deinit do else enum extension fallthrough "
230
+ "false fileprivate for func guard if import in init inout internal is let nil open operator private "
231
+ "protocol public repeat return self static struct subscript super switch throw throws true try typealias "
232
+ "var where while"
233
+ ),
234
+ "kotlin": _words(
235
+ "abstract actual as break by catch class companion const constructor continue crossinline data delegate do "
236
+ "dynamic else enum expect external false field file final finally for fun get if import in infix init "
237
+ "inline inner interface internal is lateinit noinline null object open operator out override package "
238
+ "private protected public reified return sealed set super suspend tailrec this throw true try typealias "
239
+ "val var vararg when where while"
240
+ ),
241
+ "yaml": _words("true false null yes no on off"),
242
+ "toml": _words("true false"),
243
+ "json": _words("true false null"),
244
+ "html": _words(
245
+ "html head body div span a p ul ol li table thead tbody tr td th script style link meta title h1 h2 h3 h4 "
246
+ "h5 h6 img form input button select option textarea section header footer main nav aside article code pre em "
247
+ "strong br hr"
248
+ ),
249
+ "css": frozenset(),
250
+ }
251
+
252
+
253
+ @dataclass(frozen=True, slots=True)
254
+ class Language:
255
+ """One language: its scanner and the word lists the scanner asks about.
256
+
257
+ Attributes:
258
+ name: Canonical name, which is what :func:`language_for` returns.
259
+ pattern: Compiled scanner for one line.
260
+ keywords: Words drawn with the keyword colour.
261
+ builtins: Words drawn with the builtin colour.
262
+ fold_case: Match the word lists case-insensitively, as SQL keywords are.
263
+ segments: A word after an operator opens a command (the shell scanner).
264
+ """
265
+
266
+ name: str
267
+ pattern: re.Pattern[str]
268
+ keywords: frozenset[str] = frozenset()
269
+ builtins: frozenset[str] = frozenset()
270
+ fold_case: bool = False
271
+ segments: bool = False
272
+
273
+
274
+ def _language(
275
+ name: str,
276
+ *,
277
+ comments: tuple[str, ...] = (),
278
+ block: tuple[str, str] | None = None,
279
+ quotes: str = "\"'",
280
+ fold_case: bool = False,
281
+ ) -> Language:
282
+ """Build one scanner-backed language from its markers and keyword list."""
283
+ return Language(
284
+ name=name,
285
+ pattern=_scanner(comments=comments, block=block, quotes=quotes),
286
+ keywords=_KEYWORDS.get(name, frozenset()),
287
+ fold_case=fold_case,
288
+ )
289
+
290
+
291
+ _SPECS: dict[str, Language] = {
292
+ PYTHON: Language(
293
+ name=PYTHON,
294
+ pattern=_PYTHON_TOKENS,
295
+ keywords=frozenset(keyword.kwlist) | {"match", "case"},
296
+ builtins=_BUILTINS | {"True", "False", "None"},
297
+ ),
298
+ SHELL: Language(name=SHELL, pattern=_SHELL_TOKENS, segments=True),
299
+ LATEX: Language(name=LATEX, pattern=_LATEX_TOKENS),
300
+ "javascript": _language("javascript", comments=("//",), block=("/*", "*/"), quotes="\"'`"),
301
+ "typescript": _language("typescript", comments=("//",), block=("/*", "*/"), quotes="\"'`"),
302
+ "rust": _language("rust", comments=("//",), block=("/*", "*/")),
303
+ "go": _language("go", comments=("//",), block=("/*", "*/"), quotes="\"'`"),
304
+ "java": _language("java", comments=("//",), block=("/*", "*/")),
305
+ "c": _language("c", comments=("//",), block=("/*", "*/")),
306
+ "cpp": _language("cpp", comments=("//",), block=("/*", "*/")),
307
+ "csharp": _language("csharp", comments=("//",), block=("/*", "*/")),
308
+ "ruby": _language("ruby", comments=("#",)),
309
+ "php": _language("php", comments=("#", "//"), block=("/*", "*/")),
310
+ "sql": _language("sql", comments=("--",), block=("/*", "*/"), fold_case=True),
311
+ "lua": _language("lua", comments=("--",), block=("--[[", "]]")),
312
+ "haskell": _language("haskell", comments=("--",), block=("{-", "-}")),
313
+ "swift": _language("swift", comments=("//",), block=("/*", "*/")),
314
+ "kotlin": _language("kotlin", comments=("//",), block=("/*", "*/")),
315
+ "yaml": _language("yaml", comments=("#",)),
316
+ "toml": _language("toml", comments=("#",)),
317
+ "ini": _language("ini", comments=("#", ";")),
318
+ "json": _language("json", quotes='"', block=("/*", "*/")),
319
+ "html": _language("html", block=("<!--", "-->")),
320
+ "css": _language("css", block=("/*", "*/")),
321
+ }
322
+
323
+ #: Fence labels that resolve to one of the scanners above; anything else is
324
+ #: generic. Aliases exist because fences name a language in whatever way its
325
+ #: users write it — ``py``, ``python3``, ``c++``, ``yml``.
326
+ _LABELS = {
327
+ "py": PYTHON,
328
+ "python": PYTHON,
329
+ "python3": PYTHON,
330
+ "sh": SHELL,
331
+ "bash": SHELL,
332
+ "zsh": SHELL,
333
+ "shell": SHELL,
334
+ "console": SHELL,
335
+ "make": SHELL,
336
+ "makefile": SHELL,
337
+ "dockerfile": SHELL,
338
+ "docker": SHELL,
339
+ "tex": LATEX,
340
+ "latex": LATEX,
341
+ "js": "javascript",
342
+ "mjs": "javascript",
343
+ "cjs": "javascript",
344
+ "node": "javascript",
345
+ "jsx": "javascript",
346
+ "ts": "typescript",
347
+ "tsx": "typescript",
348
+ "rs": "rust",
349
+ "golang": "go",
350
+ "h": "c",
351
+ "cc": "cpp",
352
+ "c++": "cpp",
353
+ "cxx": "cpp",
354
+ "hpp": "cpp",
355
+ "cs": "csharp",
356
+ "rb": "ruby",
357
+ "kt": "kotlin",
358
+ "kts": "kotlin",
359
+ "hs": "haskell",
360
+ "pyi": "python",
361
+ "yml": "yaml",
362
+ "htm": "html",
363
+ "xml": "html",
364
+ "svg": "html",
365
+ "scss": "css",
366
+ "less": "css",
367
+ }
368
+
369
+
370
+ def language_for(label: str) -> str:
371
+ """Map a fence label to a canonical language name.
372
+
373
+ Args:
374
+ label: Text after the opening fence; case and surrounding space are
375
+ ignored, and an unknown label falls back to ``GENERIC``.
376
+ """
377
+ key = label.strip().lower()
378
+ if key in _SPECS:
379
+ return key
380
+ return _LABELS.get(key, GENERIC)
381
+
382
+
383
+ def languages() -> tuple[str, ...]:
384
+ """Return every language name a fence can select, in a stable order."""
385
+ return tuple(sorted(_SPECS))
386
+
387
+
388
+ def highlight(line: str, language: str = GENERIC, theme: CodeTheme = DEFAULT_CODE_THEME) -> list[Span]:
389
+ """Return styled spans for one line of code.
390
+
391
+ Args:
392
+ line: Single source line, without its trailing newline.
393
+ language: Fence label such as ``"python"``, ``"rust"``, ``"latex"``, or
394
+ ``"generic"``; an unknown label falls back to the generic scanner.
395
+ theme: Palette the token colours are read from.
396
+ """
397
+ spec = _SPECS.get(language_for(language))
398
+ pattern = spec.pattern if spec is not None else _GENERIC_TOKENS
399
+ matches = list(pattern.finditer(line))
400
+ spans: list[Span] = []
401
+ for index, match in enumerate(matches):
402
+ kind = match.lastgroup or "other"
403
+ kind = _ALIASES.get(kind, kind)
404
+ text = match.group()
405
+ if kind == "name":
406
+ kind = _classify_name(text, matches, index, spec)
407
+ elif kind == "other":
408
+ kind = "punctuator" if text.strip() else "text"
409
+ spans.append(Span(text, theme.style_for(kind)))
410
+ return _merge(spans)
411
+
412
+
413
+ def _classify_name(text: str, matches: list[re.Match[str]], index: int, spec: Language | None) -> str:
414
+ """Decide whether an identifier is a keyword, a builtin, or a call target."""
415
+ if spec is None:
416
+ return "text"
417
+ word = text.lower() if spec.fold_case else text
418
+ if word in spec.keywords:
419
+ return "keyword"
420
+ if word in spec.builtins:
421
+ return "builtin"
422
+ if spec.segments:
423
+ return "function" if _opens_a_shell_segment(matches[:index]) else "text"
424
+ # A name that is immediately called is a function in every C-like language,
425
+ # and in Python too; nothing else about the identifier is known.
426
+ following = matches[index + 1].group() if index + 1 < len(matches) else ""
427
+ return "function" if following.lstrip().startswith("(") else "text"
428
+
429
+
430
+ def _opens_a_shell_segment(previous: list[re.Match[str]]) -> bool:
431
+ """Say whether a shell word starts a command rather than being its argument.
432
+
433
+ A word opens a segment when nothing but whitespace precedes it, or when the
434
+ last meaningful token is an operator such as ``&&``, ``|``, or ``;``. That
435
+ is what colours ``cd`` and ``rm`` in ``cd /tmp && rm -rf build`` while the
436
+ paths and flags after them stay plain text.
437
+ """
438
+ for match in reversed(previous):
439
+ kind = match.lastgroup
440
+ if kind == "space":
441
+ continue
442
+ return kind == "operator" and match.group() in _SHELL_SEPARATORS
443
+ return True
444
+
445
+
446
+ def _merge(spans: list[Span]) -> list[Span]:
447
+ """Join adjacent runs that ended up with the same style."""
448
+ merged: list[Span] = []
449
+ for span in spans:
450
+ if not span.text:
451
+ continue
452
+ if merged and merged[-1].style == span.style:
453
+ merged[-1] = Span(merged[-1].text + span.text, span.style)
454
+ else:
455
+ merged.append(span)
456
+ return merged