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 +4 -0
- panelmark/draw.py +182 -0
- panelmark/exceptions.py +13 -0
- panelmark/interactions/__init__.py +4 -0
- panelmark/interactions/base.py +63 -0
- panelmark/layout.py +362 -0
- panelmark/observer.py +49 -0
- panelmark/parser.py +279 -0
- panelmark/shell.py +239 -0
- panelmark/style.py +118 -0
- panelmark-0.1.0.dist-info/LICENSE +21 -0
- panelmark-0.1.0.dist-info/METADATA +178 -0
- panelmark-0.1.0.dist-info/RECORD +15 -0
- panelmark-0.1.0.dist-info/WHEEL +5 -0
- panelmark-0.1.0.dist-info/top_level.txt +1 -0
panelmark/__init__.py
ADDED
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
|
panelmark/exceptions.py
ADDED
|
@@ -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,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)
|