panelmark 0.1.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.
panelmark/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .shell import Shell
2
+ from .interactions import Interaction
3
+
4
+ __all__ = ["Shell", "Interaction"]
panelmark/draw.py ADDED
@@ -0,0 +1,182 @@
1
+ """Draw commands and render context for the panelmark renderer abstraction.
2
+
3
+ Interactions return a ``list[DrawCommand]`` from their ``render()`` method
4
+ rather than performing side effects directly. Each renderer provides an
5
+ executor that translates the command list to its output surface (terminal,
6
+ HTML, etc.).
7
+
8
+ Coordinate system
9
+ -----------------
10
+ All row and column values in draw commands are **region-relative**:
11
+ ``(0, 0)`` is the top-left cell of the interaction's assigned region.
12
+ The executor maps these to screen-absolute or document-absolute coordinates
13
+ internally. Interactions never need to know their absolute position.
14
+
15
+ Style dict
16
+ ----------
17
+ The optional ``style`` argument on ``WriteCmd`` and ``FillCmd`` is a plain
18
+ dict. All keys are optional. Renderers apply the keys they support and ignore
19
+ the rest — unknown keys are not an error.
20
+
21
+ Valid keys and values::
22
+
23
+ bold bool — bold / heavy weight
24
+ italic bool — italic (renderers that lack italic use normal)
25
+ underline bool — underline
26
+ reverse bool — swap foreground and background colours
27
+ color str — foreground colour name: 'red', 'green', 'yellow',
28
+ 'blue', 'magenta', 'cyan', 'white', 'black'
29
+ bg str — background colour name (same values as color)
30
+
31
+ Example — a minimal custom render() implementation::
32
+
33
+ from panelmark.draw import WriteCmd, FillCmd, RenderContext, DrawCommand
34
+
35
+ def render(self, context: RenderContext, focused: bool = False) -> list[DrawCommand]:
36
+ text = self._value[:context.width].ljust(context.width)
37
+ style = {'reverse': True} if focused else None
38
+ return [
39
+ FillCmd(row=0, col=0, width=context.width, height=context.height),
40
+ WriteCmd(row=0, col=0, text=text, style=style),
41
+ ]
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ from dataclasses import dataclass, field
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class RenderContext:
51
+ """Read-only rendering context passed to ``Interaction.render()``.
52
+
53
+ Carries the dimensions of the interaction's assigned region and a set
54
+ of capability flags describing what the current renderer supports.
55
+ Interactions use ``supports()`` to degrade gracefully on renderers that
56
+ lack a capability rather than failing or producing garbled output.
57
+
58
+ Attributes
59
+ ----------
60
+ width:
61
+ Width of the region in character columns.
62
+ height:
63
+ Height of the region in character rows.
64
+ capabilities:
65
+ Frozenset of feature strings supported by the renderer. Do not
66
+ inspect this directly — use ``supports(feature)`` instead.
67
+ """
68
+
69
+ width: int
70
+ height: int
71
+ capabilities: frozenset[str] = field(default_factory=frozenset)
72
+
73
+ def supports(self, feature: str) -> bool:
74
+ """Return True if the renderer supports *feature*.
75
+
76
+ Known feature strings (renderers may support any subset):
77
+
78
+ ``'color'``
79
+ At least 8 foreground/background colours are available.
80
+ ``'256color'``
81
+ 256-colour palette is available.
82
+ ``'truecolor'``
83
+ 24-bit (16 million colour) palette is available.
84
+ ``'unicode'``
85
+ Unicode characters (box-drawing, block elements, etc.) render
86
+ correctly. ASCII-only renderers do not set this.
87
+ ``'cursor'``
88
+ A text cursor can be positioned within the region. Set by TUI
89
+ renderers and interactive web renderers; not set by static HTML.
90
+ ``'italic'``
91
+ Italic text is visually distinct from normal weight text.
92
+
93
+ Returns False for unknown or unsupported feature strings.
94
+ """
95
+ return feature in self.capabilities
96
+
97
+
98
+ @dataclass
99
+ class WriteCmd:
100
+ """Write styled text at region-relative (row, col).
101
+
102
+ The text is written left-to-right starting at the given position.
103
+ No automatic clipping is performed — the executor clips to the region
104
+ boundary. Callers should ensure text does not exceed ``context.width``
105
+ columns from ``col``.
106
+
107
+ Parameters
108
+ ----------
109
+ row:
110
+ Zero-based row offset from the top of the region.
111
+ col:
112
+ Zero-based column offset from the left of the region.
113
+ text:
114
+ The string to write. Should not contain newlines.
115
+ style:
116
+ Optional style dict. See module docstring for valid keys.
117
+ """
118
+
119
+ row: int
120
+ col: int
121
+ text: str
122
+ style: dict | None = None
123
+
124
+
125
+ @dataclass
126
+ class FillCmd:
127
+ """Fill a rectangle with a repeated character, optionally styled.
128
+
129
+ Useful for clearing a region before writing content, or for drawing
130
+ a styled background block.
131
+
132
+ Parameters
133
+ ----------
134
+ row:
135
+ Zero-based row offset of the top-left corner.
136
+ col:
137
+ Zero-based column offset of the top-left corner.
138
+ width:
139
+ Number of columns to fill.
140
+ height:
141
+ Number of rows to fill.
142
+ char:
143
+ The character to fill with. Defaults to a space (blank/clear).
144
+ style:
145
+ Optional style dict. See module docstring for valid keys.
146
+ """
147
+
148
+ row: int
149
+ col: int
150
+ width: int
151
+ height: int
152
+ char: str = ' '
153
+ style: dict | None = None
154
+
155
+
156
+ @dataclass
157
+ class CursorCmd:
158
+ """Hint: place the text cursor at region-relative (row, col).
159
+
160
+ This is a positioning hint, not a draw operation. Renderers that
161
+ support a visible text cursor (``context.supports('cursor')``) move
162
+ the cursor here after executing all other commands in the list.
163
+ Renderers that do not support a cursor ignore this command entirely.
164
+
165
+ There should be at most one ``CursorCmd`` per command list. If multiple
166
+ are present, the executor uses the last one.
167
+
168
+ Parameters
169
+ ----------
170
+ row:
171
+ Zero-based row offset from the top of the region.
172
+ col:
173
+ Zero-based column offset from the left of the region.
174
+ """
175
+
176
+ row: int
177
+ col: int
178
+
179
+
180
+ #: Union type alias for the three command types.
181
+ #: A ``render()`` method returns ``list[DrawCommand]``.
182
+ DrawCommand = WriteCmd | FillCmd | CursorCmd
@@ -0,0 +1,13 @@
1
+ class ShellSyntaxError(Exception):
2
+ def __init__(self, message, line=None):
3
+ self.message = message
4
+ self.line = line # 1-based line number or None
5
+ super().__init__(f"line {line}: {message}" if line else message)
6
+
7
+
8
+ class RegionNotFoundError(Exception):
9
+ pass
10
+
11
+
12
+ class CircularUpdateError(Exception):
13
+ pass
@@ -0,0 +1,4 @@
1
+ from .base import Interaction
2
+ from panelmark.draw import DrawCommand, RenderContext, WriteCmd, FillCmd, CursorCmd
3
+
4
+ __all__ = ["Interaction", "DrawCommand", "RenderContext", "WriteCmd", "FillCmd", "CursorCmd"]
@@ -0,0 +1,63 @@
1
+ from abc import ABC, abstractmethod
2
+ from panelmark.draw import DrawCommand, RenderContext
3
+
4
+
5
+ class Interaction(ABC):
6
+ _shell = None # Set by Shell.assign()
7
+
8
+ @property
9
+ def is_focusable(self) -> bool:
10
+ """Return True if this interaction can meaningfully receive keyboard focus.
11
+ Display-only interactions override this to return False."""
12
+ return True
13
+
14
+ @abstractmethod
15
+ def render(self, context: RenderContext, focused: bool = False) -> list[DrawCommand]:
16
+ """Return draw commands describing the current visual state of this interaction.
17
+
18
+ Commands use region-relative coordinates: ``(0, 0)`` is the top-left
19
+ cell of this interaction's assigned region. The renderer maps them to
20
+ screen-absolute positions when executing via its command executor.
21
+
22
+ The returned list should be a complete description of the interaction's
23
+ visual state for the given context dimensions. Partial updates are not
24
+ supported — callers may skip calling ``render()`` for regions they
25
+ determine are unchanged, so the list must always be fully self-contained.
26
+
27
+ Parameters
28
+ ----------
29
+ context:
30
+ Rendering context carrying region dimensions (``context.width``,
31
+ ``context.height``) and renderer capability flags. Use
32
+ ``context.supports(feature)`` to degrade gracefully on renderers
33
+ that lack a capability.
34
+ focused:
35
+ True if this interaction currently has keyboard focus.
36
+ """
37
+ ...
38
+
39
+ @abstractmethod
40
+ def handle_key(self, key) -> tuple:
41
+ """
42
+ Handle a keypress.
43
+ Returns (value_changed, new_value).
44
+ new_value is whatever Shell.get returns.
45
+ """
46
+ ...
47
+
48
+ @abstractmethod
49
+ def get_value(self):
50
+ """Return the current value of this interaction."""
51
+ ...
52
+
53
+ @abstractmethod
54
+ def set_value(self, value) -> None:
55
+ """Set the current value of this interaction."""
56
+ ...
57
+
58
+ def signal_return(self) -> tuple:
59
+ """
60
+ Called after handle_key to check if this interaction wants Shell.run() to return.
61
+ Returns (should_exit, return_value).
62
+ """
63
+ return False, None
panelmark/layout.py ADDED
@@ -0,0 +1,362 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class Region:
6
+ name: str
7
+ row: int # 0-based terminal row
8
+ col: int # 0-based terminal col
9
+ width: int
10
+ height: int
11
+ heading: str | None = None
12
+
13
+
14
+ @dataclass
15
+ class BorderRow:
16
+ style: str # 'single' or 'double'
17
+ title: str | None
18
+
19
+
20
+ @dataclass
21
+ class Panel:
22
+ """Leaf node — a single named (or unnamed) content region."""
23
+ name: str | None
24
+ heading: str | None
25
+ # Width spec
26
+ width: int | None # fixed chars; None = fill or use pct
27
+ is_pct: bool
28
+ pct: float | None # percentage 0..100; used when is_pct is True
29
+ # Height spec
30
+ row_count: int | None # None = infer from num_rows_def
31
+ row_count_is_pct: bool
32
+ row_pct: float | None
33
+ num_rows_def: int = 0 # definition lines in this panel's slot
34
+
35
+
36
+ @dataclass
37
+ class HSplit:
38
+ """Horizontal split: top child above a border line, bottom child below."""
39
+ top: object # LayoutNode | None
40
+ bottom: object # LayoutNode | None
41
+ border: BorderRow | None
42
+
43
+
44
+ @dataclass
45
+ class VSplit:
46
+ """Vertical split: left and right children side by side."""
47
+ left: object # LayoutNode
48
+ right: object # LayoutNode
49
+ divider: str # 'single' or 'double'
50
+
51
+
52
+ # Type alias (for documentation only — Python does not enforce it)
53
+ # LayoutNode = HSplit | VSplit | Panel | None
54
+
55
+
56
+ @dataclass
57
+ class LayoutModel:
58
+ root: object # LayoutNode | None
59
+ has_percentage: bool
60
+
61
+ def resolve(self, term_width: int, term_height: int,
62
+ offset_row: int = 0, offset_col: int = 0) -> list:
63
+ """Return a flat list of Region objects with absolute coordinates.
64
+
65
+ offset_row / offset_col shift the origin so that modal shells
66
+ (which render at an arbitrary screen position) produce regions
67
+ with the correct absolute terminal coordinates from the start.
68
+ """
69
+ if self.root is None:
70
+ return []
71
+ # Content area starts one column inside the left border wall.
72
+ return _resolve_node(self.root,
73
+ offset_row,
74
+ offset_col + 1,
75
+ term_width - 2, term_height,
76
+ pct_base=None)
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Recursive resolve
81
+ # ---------------------------------------------------------------------------
82
+
83
+ def _resolve_node(node, row: int, col: int, width: int, height: int,
84
+ pct_base: int | None) -> list:
85
+ """
86
+ Recursively resolve a layout node to a flat list of Regions.
87
+
88
+ row, col — absolute top-left position of this node
89
+ width — available width (excluding outer border chars)
90
+ height — available height in terminal rows
91
+ pct_base — total interior width used as the denominator for % specs;
92
+ None means "compute on first VSplit encountered"
93
+ """
94
+ if node is None:
95
+ return []
96
+
97
+ if isinstance(node, Panel):
98
+ if node.name:
99
+ return [Region(name=node.name, row=row, col=col,
100
+ width=width, height=height,
101
+ heading=node.heading)]
102
+ return []
103
+
104
+ if isinstance(node, VSplit):
105
+ if pct_base is None:
106
+ num_cols = _num_vsplit_cols(node)
107
+ # Subtract one divider per internal column boundary
108
+ pct_base = width - (num_cols - 1)
109
+
110
+ left_width = _vsplit_left_width(node, width, pct_base)
111
+ right_width = width - left_width - 1
112
+
113
+ regions = _resolve_node(node.left, row, col,
114
+ left_width, height, pct_base)
115
+ regions += _resolve_node(node.right, row, col + left_width + 1,
116
+ right_width, height, pct_base)
117
+ return regions
118
+
119
+ if isinstance(node, HSplit):
120
+ top_height = (_declared_height(node.top, height)
121
+ if node.top is not None else 0)
122
+ border_rows = 1 if node.border is not None else 0
123
+ bottom_height = max(0, height - top_height - border_rows)
124
+
125
+ regions = []
126
+ if node.top is not None:
127
+ regions += _resolve_node(node.top, row, col, width, top_height,
128
+ None)
129
+ if node.bottom is not None:
130
+ regions += _resolve_node(node.bottom,
131
+ row + top_height + border_rows,
132
+ col, width, bottom_height, None)
133
+ return regions
134
+
135
+ return []
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Width / height helpers
140
+ # ---------------------------------------------------------------------------
141
+
142
+ def _declared_width(node, available: int, pct_base: int) -> int:
143
+ """
144
+ How wide does this node want to be?
145
+
146
+ available — remaining width at this level (after subtracting the
147
+ divider that separates it from its sibling)
148
+ pct_base — total interior width for percentage calculations
149
+ """
150
+ if node is None:
151
+ return available
152
+
153
+ if isinstance(node, Panel):
154
+ if node.is_pct and node.pct is not None:
155
+ return int(pct_base * node.pct / 100)
156
+ if node.width is not None:
157
+ return node.width
158
+ return available # fill
159
+
160
+ if isinstance(node, HSplit):
161
+ # Both halves of an HSplit share the same width; delegate to a child.
162
+ child = node.top if node.top is not None else node.bottom
163
+ return _declared_width(child, available, pct_base)
164
+
165
+ if isinstance(node, VSplit):
166
+ # A VSplit takes all available width.
167
+ return available
168
+
169
+ return available
170
+
171
+
172
+ def _declared_height(node, available: int) -> int:
173
+ """
174
+ How tall does this node want to be?
175
+ Panels without an explicit row_count use num_rows_def as a minimum.
176
+ The caller may give a Panel more height than it declares (stretch).
177
+ """
178
+ if node is None:
179
+ return 0
180
+
181
+ if isinstance(node, Panel):
182
+ if node.row_count is not None:
183
+ if node.row_count_is_pct and node.row_pct is not None:
184
+ return int(available * node.row_pct / 100)
185
+ return node.row_count
186
+ return max(1, node.num_rows_def)
187
+
188
+ if isinstance(node, VSplit):
189
+ lh = _declared_height(node.left, available)
190
+ rh = _declared_height(node.right, available)
191
+ return max(lh, rh)
192
+
193
+ if isinstance(node, HSplit):
194
+ top_h = (_declared_height(node.top, available)
195
+ if node.top is not None else 0)
196
+ border_h = 1 if node.border is not None else 0
197
+ bot_h = (_declared_height(node.bottom, available)
198
+ if node.bottom is not None else 0)
199
+ return top_h + border_h + bot_h
200
+
201
+ return 0
202
+
203
+
204
+ def _fixed_width(node) -> int | None:
205
+ """
206
+ Return the node's exact content width if every column uses a fixed character
207
+ count (no percentages, no fill). Returns None if any dimension is variable.
208
+
209
+ Does NOT include the two outer border-wall columns — add 2 for terminal width.
210
+ """
211
+ if node is None:
212
+ return 0
213
+
214
+ if isinstance(node, Panel):
215
+ if node.width is not None and not node.is_pct:
216
+ return node.width
217
+ return None # fill or percentage
218
+
219
+ if isinstance(node, VSplit):
220
+ lw = _fixed_width(node.left)
221
+ rw = _fixed_width(node.right)
222
+ if lw is None or rw is None:
223
+ return None
224
+ return lw + 1 + rw # left content + divider + right content
225
+
226
+ if isinstance(node, HSplit):
227
+ child = node.top if node.top is not None else node.bottom
228
+ return _fixed_width(child)
229
+
230
+ return None
231
+
232
+
233
+ def _fixed_height(node) -> int | None:
234
+ """
235
+ Return the node's exact height in rows if every row-bearing panel carries an
236
+ explicit nR declaration (no fill, no percentage rows). Returns None otherwise.
237
+
238
+ Border rows are always 1 row each and are always counted.
239
+ """
240
+ if node is None:
241
+ return 0
242
+
243
+ if isinstance(node, Panel):
244
+ if node.row_count is not None and not node.row_count_is_pct:
245
+ return node.row_count
246
+ return None # fill (num_rows_def) or percentage
247
+
248
+ if isinstance(node, VSplit):
249
+ lh = _fixed_height(node.left)
250
+ rh = _fixed_height(node.right)
251
+ if lh is None or rh is None:
252
+ return None
253
+ return max(lh, rh)
254
+
255
+ if isinstance(node, HSplit):
256
+ top_h = 0
257
+ if node.top is not None:
258
+ top_h = _fixed_height(node.top)
259
+ if top_h is None:
260
+ return None
261
+ bot_h = 0
262
+ if node.bottom is not None:
263
+ bot_h = _fixed_height(node.bottom)
264
+ if bot_h is None:
265
+ return None
266
+ border_h = 1 if node.border is not None else 0
267
+ return top_h + border_h + bot_h
268
+
269
+ return None
270
+
271
+
272
+ def _vsplit_left_width(node: "VSplit", width: int, pct_base: int) -> int:
273
+ """Return the left-panel width for a VSplit given *width* available chars.
274
+
275
+ Three cases, evaluated in order:
276
+
277
+ 1. **Both sides entirely fill-width** — content space is divided equally
278
+ among all leaf columns; any remainder falls to the rightmost columns.
279
+ 2. **Left side fill, right side has fixed/pct constraints** — right gets its
280
+ declared width first; left takes what remains.
281
+ 3. **Left side has fixed/pct constraints** — left gets its declared width;
282
+ right takes what remains (including its own internal fill/fixed columns).
283
+
284
+ ``pct_base`` must already be computed before calling this helper.
285
+ """
286
+ left_all_fill = _is_all_fill(node.left)
287
+ right_all_fill = _is_all_fill(node.right)
288
+
289
+ if left_all_fill and right_all_fill:
290
+ # Equal distribution: each leaf column gets the same content width.
291
+ # Divide available content space (width minus inter-column dividers)
292
+ # evenly; remainder pixels fall to the rightmost columns naturally
293
+ # because the right child receives whatever the left doesn't claim.
294
+ total_cols = _num_vsplit_cols(node)
295
+ left_cols = _num_vsplit_cols(node.left)
296
+ content_width = width - (total_cols - 1) # strip all inter-col dividers
297
+ content_per_col = content_width // total_cols
298
+ # Left total = its content + its own internal dividers
299
+ left_width = content_per_col * left_cols + (left_cols - 1)
300
+ return max(0, min(left_width, width - 1))
301
+
302
+ if left_all_fill and not right_all_fill:
303
+ # Right has at least one fixed/pct column; allocate it first.
304
+ right_w = min(_declared_width(node.right, width - 1, pct_base), width - 1)
305
+ return max(0, width - right_w - 1)
306
+
307
+ # Left has at least one fixed/pct column; allocate it first.
308
+ return _declared_width(node.left, width - 1, pct_base)
309
+
310
+
311
+ def _is_fill_node(node) -> bool:
312
+ """Return True if *node* itself has no explicit fixed-char or percentage width.
313
+
314
+ This is a shallow check — a VSplit always returns False because it is
315
+ treated as taking all available width regardless of its children's specs.
316
+ Use ``_is_all_fill`` to check whether an entire subtree is fill-only.
317
+ """
318
+ if node is None:
319
+ return True
320
+ if isinstance(node, Panel):
321
+ return node.width is None and not node.is_pct
322
+ if isinstance(node, HSplit):
323
+ child = node.top if node.top is not None else node.bottom
324
+ return _is_fill_node(child)
325
+ if isinstance(node, VSplit):
326
+ # A VSplit consumes all available width regardless of its children.
327
+ return False
328
+ return True
329
+
330
+
331
+ def _is_all_fill(node) -> bool:
332
+ """Return True if *every* leaf Panel in this subtree is fill-width.
333
+
334
+ Unlike ``_is_fill_node``, this recurses into VSplit children so that a
335
+ VSplit whose entire column tree has no fixed/pct constraints is correctly
336
+ identified as all-fill.
337
+ """
338
+ if node is None:
339
+ return True
340
+ if isinstance(node, Panel):
341
+ return node.width is None and not node.is_pct
342
+ if isinstance(node, HSplit):
343
+ child = node.top if node.top is not None else node.bottom
344
+ return _is_all_fill(child)
345
+ if isinstance(node, VSplit):
346
+ return _is_all_fill(node.left) and _is_all_fill(node.right)
347
+ return True
348
+
349
+
350
+ def _num_vsplit_cols(node) -> int:
351
+ """Count the total number of leaf Panel columns in a node's subtree."""
352
+ if node is None:
353
+ return 0
354
+ if isinstance(node, Panel):
355
+ return 1
356
+ if isinstance(node, VSplit):
357
+ return _num_vsplit_cols(node.left) + _num_vsplit_cols(node.right)
358
+ if isinstance(node, HSplit):
359
+ # Both halves of an HSplit are in the same column — count one side.
360
+ child = node.top if node.top is not None else node.bottom
361
+ return _num_vsplit_cols(child)
362
+ return 1
panelmark/observer.py ADDED
@@ -0,0 +1,49 @@
1
+ from .exceptions import CircularUpdateError
2
+
3
+
4
+ class ChangeHandle:
5
+ def __init__(self, observer, name, callback_id):
6
+ self._observer = observer
7
+ self._name = name
8
+ self._callback_id = callback_id
9
+
10
+ def remove(self) -> None:
11
+ self._observer._remove(self._name, self._callback_id)
12
+
13
+
14
+ class Observer:
15
+ def __init__(self):
16
+ self._callbacks = {} # name -> dict of {id: callback}
17
+ self._next_id = 0
18
+
19
+ def register(self, name: str, callback) -> ChangeHandle:
20
+ if name not in self._callbacks:
21
+ self._callbacks[name] = {}
22
+ cb_id = self._next_id
23
+ self._next_id += 1
24
+ self._callbacks[name][cb_id] = callback
25
+ return ChangeHandle(self, name, cb_id)
26
+
27
+ def _remove(self, name: str, callback_id: int) -> None:
28
+ if name in self._callbacks and callback_id in self._callbacks[name]:
29
+ del self._callbacks[name][callback_id]
30
+
31
+ def notify(self, name: str, value, updating: set | None = None) -> None:
32
+ """
33
+ Notify all callbacks registered on name.
34
+ updating is the set of currently-updating region names for cycle detection.
35
+ Raises CircularUpdateError if name is already in updating.
36
+ """
37
+ if updating is None:
38
+ updating = set()
39
+
40
+ if name in updating:
41
+ raise CircularUpdateError(
42
+ f"circular update detected involving '{name}'"
43
+ )
44
+
45
+ updating = updating | {name}
46
+
47
+ callbacks = self._callbacks.get(name, {})
48
+ for cb_id, callback in list(callbacks.items()):
49
+ callback(value, updating)