cmdgui 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.
cmdgui/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ from .view import View, Popup
2
+ from .widgets import (
3
+ Widget, Text, Label, Button, TextInput, ProgressBar, Checkbox, Toggle,
4
+ Menu, Table, Stdout, DrawMouse, DEFAULT_THEME, field,
5
+ )
6
+ from .inputs import Input, mouse
7
+ from .layout import LayoutError
8
+ from .shorts import Canvas, style
9
+
10
+ __all__ = [
11
+ "View", "Popup", "Widget", "Text", "Label", "Button", "TextInput", "ProgressBar",
12
+ "Checkbox", "Toggle", "Menu", "Table", "Stdout", "DrawMouse", "DEFAULT_THEME", "field",
13
+ "Input", "mouse", "LayoutError", "Canvas", "style",
14
+ ]
cmdgui/_platform.py ADDED
@@ -0,0 +1,126 @@
1
+ """The OS-specific bits: raw keyboard mode, reading input with a timeout, and
2
+ waking a blocked read from another thread. Everything else is shared."""
3
+ import codecs
4
+ import os
5
+ import sys
6
+
7
+ WINDOWS = os.name == "nt"
8
+
9
+
10
+ if not WINDOWS:
11
+ import select
12
+ import termios
13
+
14
+ class Terminal:
15
+ def __init__(self):
16
+ self._saved = None
17
+ self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
18
+ self._wake_r, self._wake_w = os.pipe()
19
+ os.set_blocking(self._wake_w, False)
20
+
21
+ def enable(self):
22
+ fd = sys.stdin.fileno()
23
+ self._saved = termios.tcgetattr(fd)
24
+ attrs = termios.tcgetattr(fd)
25
+ # No line buffering, no echo. ISIG stays on so Ctrl+C still raises KeyboardInterrupt.
26
+ attrs[3] &= ~(termios.ICANON | termios.ECHO)
27
+ termios.tcsetattr(fd, termios.TCSANOW, attrs)
28
+
29
+ def disable(self):
30
+ if self._saved is not None:
31
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, self._saved)
32
+ self._saved = None
33
+
34
+ def read(self, timeout):
35
+ """Wait up to timeout seconds, return whatever was typed ("" if nothing)."""
36
+ fd = sys.stdin.fileno()
37
+ ready, _, _ = select.select([fd, self._wake_r], [], [], timeout)
38
+ text = ""
39
+ if fd in ready:
40
+ text = self._decoder.decode(os.read(fd, 1024)) # handles characters split across reads
41
+ if self._wake_r in ready:
42
+ os.read(self._wake_r, 4096)
43
+ return text
44
+
45
+ def wake(self):
46
+ """Make a read() that's waiting return straight away. Safe from any thread."""
47
+ try:
48
+ os.write(self._wake_w, b"x")
49
+ except BlockingIOError:
50
+ pass # pipe full, it's already going to wake up
51
+
52
+
53
+ else:
54
+ import ctypes
55
+ from ctypes import wintypes
56
+
57
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
58
+
59
+ STD_INPUT_HANDLE, STD_OUTPUT_HANDLE = -10, -11
60
+ ENABLE_PROCESSED_INPUT = 0x0001
61
+ ENABLE_LINE_INPUT = 0x0002
62
+ ENABLE_ECHO_INPUT = 0x0004
63
+ ENABLE_QUICK_EDIT_MODE = 0x0040
64
+ ENABLE_EXTENDED_FLAGS = 0x0080
65
+ ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
66
+ ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
67
+ KEY_EVENT = 0x0001
68
+ WAIT_TIMEOUT = 0x102
69
+
70
+ class KEY_EVENT_RECORD(ctypes.Structure):
71
+ _fields_ = [("bKeyDown", wintypes.BOOL), ("wRepeatCount", wintypes.WORD),
72
+ ("wVirtualKeyCode", wintypes.WORD), ("wVirtualScanCode", wintypes.WORD),
73
+ ("UnicodeChar", wintypes.WCHAR), ("dwControlKeyState", wintypes.DWORD)]
74
+
75
+ class _EVENT(ctypes.Union):
76
+ # KEY_EVENT_RECORD is the largest member we read; pad to the real union size (16 bytes)
77
+ _fields_ = [("KeyEvent", KEY_EVENT_RECORD), ("_pad", ctypes.c_byte * 16)]
78
+
79
+ class INPUT_RECORD(ctypes.Structure):
80
+ _fields_ = [("EventType", wintypes.WORD), ("Event", _EVENT)]
81
+
82
+ class Terminal:
83
+ """Windows console. With virtual terminal input on, keys and the mouse
84
+ arrive as the same escape sequences Unix terminals send."""
85
+ def __init__(self):
86
+ self._in = kernel32.GetStdHandle(STD_INPUT_HANDLE)
87
+ self._out = kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
88
+ self._saved = None
89
+ self._wake_event = kernel32.CreateEventW(None, False, False, None)
90
+
91
+ def enable(self):
92
+ in_mode, out_mode = wintypes.DWORD(), wintypes.DWORD()
93
+ kernel32.GetConsoleMode(self._in, ctypes.byref(in_mode))
94
+ kernel32.GetConsoleMode(self._out, ctypes.byref(out_mode))
95
+ self._saved = (in_mode.value, out_mode.value)
96
+ new_in = (in_mode.value | ENABLE_VIRTUAL_TERMINAL_INPUT | ENABLE_EXTENDED_FLAGS | ENABLE_PROCESSED_INPUT) \
97
+ & ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_QUICK_EDIT_MODE)
98
+ kernel32.SetConsoleMode(self._in, new_in)
99
+ kernel32.SetConsoleMode(self._out, out_mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
100
+
101
+ def disable(self):
102
+ if self._saved is not None:
103
+ kernel32.SetConsoleMode(self._in, self._saved[0])
104
+ kernel32.SetConsoleMode(self._out, self._saved[1])
105
+ self._saved = None
106
+
107
+ def read(self, timeout):
108
+ handles = (wintypes.HANDLE * 2)(self._in, self._wake_event)
109
+ result = kernel32.WaitForMultipleObjects(2, handles, False, int(timeout * 1000))
110
+ if result != 0: # timed out, or woken
111
+ return ""
112
+ text = []
113
+ count = wintypes.DWORD()
114
+ kernel32.GetNumberOfConsoleInputEvents(self._in, ctypes.byref(count))
115
+ if count.value:
116
+ records = (INPUT_RECORD * count.value)()
117
+ read = wintypes.DWORD()
118
+ kernel32.ReadConsoleInputW(self._in, records, count.value, ctypes.byref(read))
119
+ for record in records[:read.value]:
120
+ key = record.Event.KeyEvent
121
+ if record.EventType == KEY_EVENT and key.bKeyDown and key.UnicodeChar != "\0":
122
+ text.append(key.UnicodeChar * max(1, key.wRepeatCount))
123
+ return "".join(text)
124
+
125
+ def wake(self):
126
+ kernel32.SetEvent(self._wake_event)
cmdgui/inputs.py ADDED
@@ -0,0 +1,214 @@
1
+ import queue
2
+ import re
3
+ import sys
4
+
5
+ from .shorts import ESC, write
6
+ from ._platform import Terminal
7
+
8
+
9
+ class Input():
10
+ def __init__(self, type, details):
11
+ self.type:str = type
12
+ self.details:dict = details
13
+
14
+ def __repr__(self):
15
+ return f"Input({self.type!r}, {self.details!r})"
16
+
17
+
18
+ class Mouse():
19
+ """Current mouse state, kept up to date by the view before each input is
20
+ handed to widgets. Coordinates are 0-based screen positions."""
21
+ def __init__(self):
22
+ self.x = 0
23
+ self.y = 0
24
+ self.prev_x = 0 # position before the current input
25
+ self.prev_y = 0
26
+ self.buttons = set() # held buttons: 0 left, 1 middle, 2 right
27
+
28
+ def pos(self):
29
+ return self.x, self.y
30
+
31
+ def is_down(self, button=0):
32
+ return button in self.buttons
33
+
34
+ @property
35
+ def moved(self):
36
+ """True if the current input moved the mouse to a different cell."""
37
+ return (self.x, self.y) != (self.prev_x, self.prev_y)
38
+
39
+ mouse = Mouse()
40
+
41
+
42
+ def update_state(input):
43
+ """Apply an input to the shared state (mouse position and buttons)."""
44
+ if not input.type.startswith("mouse"):
45
+ return
46
+ mouse.prev_x, mouse.prev_y = mouse.x, mouse.y
47
+ mouse.x, mouse.y = input.details["x"], input.details["y"]
48
+ if input.type == "mouse_down":
49
+ mouse.buttons.add(input.details["button"])
50
+ elif input.type == "mouse_up":
51
+ mouse.buttons.discard(input.details["button"])
52
+ elif input.type == "mouse_move" and "button" not in input.details:
53
+ mouse.buttons.clear() # no button held; fixes a release we missed (e.g. outside the window)
54
+
55
+
56
+ # Mouse tracking: 1003 = report all motion (not just while a button is held),
57
+ # 1006 = SGR encoding, which gives readable decimal coordinates with no size limit
58
+ MOUSE_ON = ESC + "?1003h" + ESC + "?1006h"
59
+ MOUSE_OFF = ESC + "?1003l" + ESC + "?1006l"
60
+
61
+ # SGR mouse report: ESC [ < button ; x ; y then M (press/move) or m (release)
62
+ SGR_MOUSE = re.compile(r"\x1b\[<(\d+);(\d+);(\d+)([Mm])")
63
+ # Other escape sequences: ESC [ params final, or ESC O final (some terminals' arrow keys)
64
+ CSI = re.compile(r"\x1b\[([0-9;]*)([A-Za-z~])")
65
+ SS3 = re.compile(r"\x1bO([A-Za-z])")
66
+
67
+ KEY_NAMES = {
68
+ "A": "up", "B": "down", "C": "right", "D": "left", "H": "home", "F": "end", "Z": "shift_tab",
69
+ "1~": "home", "2~": "insert", "3~": "delete", "4~": "end", "5~": "page_up", "6~": "page_down",
70
+ "7~": "home", "8~": "end",
71
+ }
72
+ CHAR_NAMES = {"\r": "enter", "\n": "enter", "\t": "tab", "\x7f": "backspace", "\x08": "backspace",
73
+ "\x1b": "escape", " ": "space"}
74
+ MODIFIERS = {"2": "shift", "3": "alt", "4": "alt+shift", "5": "ctrl", "6": "ctrl+shift", "7": "ctrl+alt"}
75
+
76
+ _terminal = Terminal()
77
+ _buffer = ""
78
+
79
+ # Text written to sys.stdout / sys.stderr while intercepted
80
+ _text_queue = queue.SimpleQueue()
81
+ _stderr_log = [] # everything written to stderr, shown again after the view closes
82
+
83
+
84
+ class StreamInterceptor:
85
+ """Stands in for sys.stdout or sys.stderr and turns everything written
86
+ into "stdout" / "stderr" inputs."""
87
+ def __init__(self, real, kind):
88
+ self.real = real
89
+ self.kind = kind
90
+ def write(self, s):
91
+ if s:
92
+ _text_queue.put((self.kind, s))
93
+ if self.kind == "stderr":
94
+ _stderr_log.append(s)
95
+ _terminal.wake()
96
+ return len(s)
97
+ def flush(self):
98
+ pass
99
+ def __getattr__(self, name):
100
+ # Anything else (encoding, fileno, isatty...) comes from the real stream
101
+ return getattr(self.real, name)
102
+
103
+
104
+ def enable():
105
+ """Raw keyboard mode, mouse reporting, and capture print output."""
106
+ _stderr_log.clear()
107
+ _terminal.enable()
108
+ write(MOUSE_ON)
109
+ sys.stdout = StreamInterceptor(sys.stdout, "stdout")
110
+ sys.stderr = StreamInterceptor(sys.stderr, "stderr")
111
+
112
+
113
+ def disable():
114
+ """Undo enable(). Safe to call more than once."""
115
+ if isinstance(sys.stdout, StreamInterceptor):
116
+ sys.stdout = sys.stdout.real
117
+ if isinstance(sys.stderr, StreamInterceptor):
118
+ sys.stderr = sys.stderr.real
119
+ write(MOUSE_OFF)
120
+ _terminal.disable()
121
+
122
+
123
+ def captured_stderr():
124
+ """Everything written to stderr since enable(), e.g. tracebacks."""
125
+ return "".join(_stderr_log)
126
+
127
+
128
+ def wake():
129
+ """Make read_inputs() return straight away. Safe from any thread."""
130
+ _terminal.wake()
131
+
132
+
133
+ def read_inputs(timeout=0.1):
134
+ """Wait up to timeout seconds for input, and return it as a list of Inputs."""
135
+ global _buffer
136
+ _buffer += _terminal.read(timeout)
137
+
138
+ inputs = []
139
+
140
+ # print() does several writes per call, so merge queued text into one input per stream
141
+ while not _text_queue.empty():
142
+ kind, text = _text_queue.get()
143
+ if inputs and inputs[-1].type == kind:
144
+ inputs[-1].details["text"] += text
145
+ else:
146
+ inputs.append(Input(kind, {"text": text}))
147
+
148
+ while _buffer:
149
+ if _buffer.startswith("\x1b[<"):
150
+ match = SGR_MOUSE.match(_buffer)
151
+ if not match:
152
+ break # incomplete mouse report, wait for the rest
153
+ inputs.append(_mouse_input(*match.groups()))
154
+ _buffer = _buffer[match.end():]
155
+ elif _buffer.startswith("\x1b[") or _buffer.startswith("\x1bO"):
156
+ match = CSI.match(_buffer) or SS3.match(_buffer)
157
+ if not match:
158
+ if len(_buffer) < 8:
159
+ break # incomplete, wait for the rest
160
+ match = re.match(r"\x1b.", _buffer) # garbage, skip it
161
+ inputs.append(_sequence_key(match))
162
+ _buffer = _buffer[match.end():]
163
+ elif _buffer.startswith("\x1b") and len(_buffer) > 1:
164
+ inputs.append(_key("alt+" + _char_name(_buffer[1])))
165
+ _buffer = _buffer[2:]
166
+ else:
167
+ inputs.append(_key(_char_name(_buffer[0]), _buffer[0]))
168
+ _buffer = _buffer[1:]
169
+ return inputs
170
+
171
+
172
+ def _key(name, char=None):
173
+ """A key input. char is the printable character typed, if any."""
174
+ printable = char is not None and (char == " " or char.isprintable())
175
+ return Input("key", {"key": name, "char": char if printable else None})
176
+
177
+
178
+ def _char_name(char):
179
+ if char in CHAR_NAMES:
180
+ return CHAR_NAMES[char]
181
+ if "\x01" <= char <= "\x1a":
182
+ return "ctrl+" + chr(ord(char) + 96)
183
+ return char
184
+
185
+
186
+ def _sequence_key(match):
187
+ groups = match.groups()
188
+ if len(groups) == 1: # ESC O x
189
+ return _key(KEY_NAMES.get(groups[0], match.group()))
190
+ if len(groups) == 0: # skipped garbage
191
+ return _key(match.group())
192
+ params, final = groups
193
+ parts = params.split(";") if params else []
194
+ if final == "~":
195
+ name = KEY_NAMES.get((parts[0] if parts else "") + "~", match.group())
196
+ else:
197
+ name = KEY_NAMES.get(final, match.group())
198
+ if len(parts) == 2 and parts[1] in MODIFIERS: # e.g. ESC [1;5A is ctrl+up
199
+ name = MODIFIERS[parts[1]] + "+" + name
200
+ return _key(name)
201
+
202
+
203
+ def _mouse_input(button, x, y, final):
204
+ button, x, y = int(button), int(x) - 1, int(y) - 1 # to 0-based screen coords
205
+ details = {"x": x, "y": y}
206
+ if button & 64:
207
+ details["direction"] = "up" if button & 1 == 0 else "down"
208
+ return Input("mouse_scroll", details)
209
+ if button & 32:
210
+ if button & 3 != 3:
211
+ details["button"] = button & 3
212
+ return Input("mouse_move", details)
213
+ details["button"] = button & 3 # 0 left, 1 middle, 2 right
214
+ return Input("mouse_down" if final == "M" else "mouse_up", details)
cmdgui/layout.py ADDED
@@ -0,0 +1,249 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ # type, type[name], with an optional {b} (border) or {nb} (no border) on the end
5
+ TOKEN = re.compile(r"^([A-Za-z_]\w*)(?:\[([A-Za-z_]\w*)\])?(?:\{(b|nb)\})?$")
6
+ # a size: "5" exactly, "5+" at least, "5-10" between
7
+ SIZE = re.compile(r"^(\d+)(?:(\+)|-(\d+))?$")
8
+
9
+ EMPTY = "."
10
+ SPAN_LEFT = "-"
11
+ SPAN_UP = "|"
12
+
13
+
14
+ class LayoutError(ValueError):
15
+ pass
16
+
17
+
18
+ @dataclass
19
+ class Slot:
20
+ name: str
21
+ type: str # None when the layout just names a widget that was passed in
22
+ row: int
23
+ col: int
24
+ rowspan: int = 1
25
+ colspan: int = 1
26
+ border: bool = None # True/False from {b}/{nb}, None to use the widget's default
27
+
28
+
29
+ @dataclass
30
+ class Layout:
31
+ rows: int
32
+ cols: int
33
+ slots: dict # name -> Slot, in the order they were declared
34
+
35
+
36
+ def parse_layout(text, types=None, names=()):
37
+ """Parse a layout string into a Layout.
38
+
39
+ types is an optional collection of known type names; if given, unknown
40
+ types are an error. names are widgets provided by the caller: a bare word
41
+ matching one of them refers to that widget instead of a type."""
42
+ lines = [line.split() for line in text.splitlines() if line.strip()]
43
+ if not lines: raise LayoutError("layout is empty")
44
+
45
+ cols = len(lines[0])
46
+ for r, cells in enumerate(lines):
47
+ if len(cells) != cols:
48
+ raise LayoutError(f"row {r + 1} has {len(cells)} cells, but row 1 has {cols}")
49
+
50
+ slot_types = {} # name -> type, in declaration order
51
+ slot_borders = {} # name -> True/False/None
52
+ grid = [[None] * cols for _ in lines] # name in each cell, None for empty
53
+
54
+ for r, cells in enumerate(lines):
55
+ for c, token in enumerate(cells):
56
+ where = f"row {r + 1}, column {c + 1}"
57
+
58
+ if token == EMPTY: continue
59
+
60
+ if token == SPAN_LEFT:
61
+ if c == 0: raise LayoutError(f"{where}: '-' has nothing to its left")
62
+ if grid[r][c - 1] is None: raise LayoutError(f"{where}: '-' can't extend an empty cell")
63
+ grid[r][c] = grid[r][c - 1]
64
+ continue
65
+
66
+ if token == SPAN_UP:
67
+ if r == 0: raise LayoutError(f"{where}: '|' has nothing above it")
68
+ if grid[r - 1][c] is None: raise LayoutError(f"{where}: '|' can't extend an empty cell")
69
+ grid[r][c] = grid[r - 1][c]
70
+ continue
71
+
72
+ match = TOKEN.match(token)
73
+ if not match:
74
+ raise LayoutError(f"{where}: can't understand '{token}'")
75
+ type_, name, flag = match.groups()
76
+ if name is None and type_ in slot_types: # repeat of an existing name: span
77
+ if flag:
78
+ raise LayoutError(f"{where}: put {{{flag}}} on the first '{type_}' cell, not a repeat")
79
+ grid[r][c] = type_
80
+ continue
81
+ if name is None:
82
+ name = type_
83
+ if name in names:
84
+ type_ = None # a provided widget, no type needed
85
+ elif name in slot_types:
86
+ raise LayoutError(f"{where}: '{name}' is already declared; repeat it as just '{name}' to span")
87
+
88
+ if types is not None and type_ is not None and type_ not in types:
89
+ known = ", ".join(sorted(types))
90
+ raise LayoutError(f"{where}: unknown widget type '{type_}' (known: {known})")
91
+ slot_types[name] = type_
92
+ slot_borders[name] = {"b": True, "nb": False, None: None}[flag]
93
+ grid[r][c] = name
94
+
95
+ return Layout(len(lines), cols, _build_slots(grid, slot_types, slot_borders))
96
+
97
+
98
+ def _build_slots(grid, slot_types, slot_borders):
99
+ """Turn the grid of names into Slots, checking each name covers a rectangle."""
100
+ cells = {} # name -> list of (row, col)
101
+ for r, row in enumerate(grid):
102
+ for c, name in enumerate(row):
103
+ if name is not None:
104
+ cells.setdefault(name, []).append((r, c))
105
+
106
+ slots = {}
107
+ for name, type_ in slot_types.items():
108
+ rows = [r for r, _ in cells[name]]
109
+ cols = [c for _, c in cells[name]]
110
+ top, left = min(rows), min(cols)
111
+ rowspan, colspan = max(rows) - top + 1, max(cols) - left + 1
112
+ if len(cells[name]) != rowspan * colspan:
113
+ hint = f"; to add a second {type_}, give it its own name: {type_}[other_name]" if type_ else ""
114
+ raise LayoutError(f"'{name}' doesn't form a rectangle{hint}")
115
+ slots[name] = Slot(name, type_, top, left, rowspan, colspan, slot_borders[name])
116
+ return slots
117
+
118
+
119
+ def parse_size(spec):
120
+ """Turn a size into (min, max), max None for no limit.
121
+ 5 or "5" exactly, "5+" at least 5, "5-10" between, None flexible (at least 1)."""
122
+ if spec is None:
123
+ return 1, None
124
+ if isinstance(spec, int):
125
+ return spec, spec
126
+ match = SIZE.match(str(spec).strip())
127
+ if not match:
128
+ raise LayoutError(f"bad size '{spec}' (use e.g. 5, '5+' or '5-10')")
129
+ low, plus, high = match.groups()
130
+ low = int(low)
131
+ if plus:
132
+ return low, None
133
+ if high is not None:
134
+ if int(high) < low:
135
+ raise LayoutError(f"bad size '{spec}': max is smaller than min")
136
+ return low, int(high)
137
+ return low, low
138
+
139
+
140
+ @dataclass
141
+ class Placement:
142
+ rects: dict # name -> (x, y, width, height) of the widget's content
143
+ frames: dict # name -> (x, y, width, height) of the border, for bordered widgets
144
+ min_width: int # smallest screen the layout fits on
145
+ min_height: int
146
+ fits: bool # False if the screen is smaller than that
147
+
148
+
149
+ def place(layout, width, height, sizes=None, borders=None, outer=False):
150
+ """Work out where every widget goes on a width x height screen.
151
+
152
+ sizes: name -> (width spec, height spec), see parse_size
153
+ borders: name -> True if the widget has a border
154
+ outer: always leave room for a border around the whole layout (popups)
155
+
156
+ Border lines sit between grid rows/columns and are shared by neighbours,
157
+ so two bordered widgets next to each other have one line between them."""
158
+ sizes = sizes or {}
159
+ borders = borders or {}
160
+ slots = list(layout.slots.values())
161
+ specs = {s.name: [parse_size(spec) for spec in sizes.get(s.name, (None, None))] for s in slots}
162
+
163
+ col_lines = _lines(layout.cols, [(s.col, s.colspan) for s in slots if borders.get(s.name)])
164
+ row_lines = _lines(layout.rows, [(s.row, s.rowspan) for s in slots if borders.get(s.name)])
165
+ if outer:
166
+ col_lines[0] = col_lines[-1] = row_lines[0] = row_lines[-1] = 1
167
+ col_widths, min_width = _size_tracks(width, layout.cols, col_lines,
168
+ [(s.col, s.colspan, *specs[s.name][0]) for s in slots])
169
+ row_heights, min_height = _size_tracks(height, layout.rows, row_lines,
170
+ [(s.row, s.rowspan, *specs[s.name][1]) for s in slots])
171
+ col_x = _starts(col_widths, col_lines)
172
+ row_y = _starts(row_heights, row_lines)
173
+
174
+ rects, frames = {}, {}
175
+ for s in slots:
176
+ x, y = col_x[s.col], row_y[s.row]
177
+ last_col, last_row = s.col + s.colspan - 1, s.row + s.rowspan - 1
178
+ w = col_x[last_col] + col_widths[last_col] - x
179
+ h = row_y[last_row] + row_heights[last_row] - y
180
+ rects[s.name] = (x, y, w, h)
181
+ if borders.get(s.name):
182
+ frames[s.name] = (x - 1, y - 1, w + 2, h + 2)
183
+ return Placement(rects, frames, min_width, min_height, width >= min_width and height >= min_height)
184
+
185
+
186
+ def _lines(count, spans):
187
+ """Thickness (0 or 1) of the border line before each track, plus one after the last."""
188
+ lines = [0] * (count + 1)
189
+ for start, span in spans:
190
+ lines[start] = lines[start + span] = 1
191
+ return lines
192
+
193
+
194
+ def _size_tracks(total, count, lines, items):
195
+ """Size the rows (or columns). items are (start, span, min, max) per widget.
196
+ Returns the sizes and the minimum total needed."""
197
+ # Each track's range comes from the widgets that sit only in that track
198
+ mins = [0] * count
199
+ maxs = [None] * count
200
+ single = [[] for _ in range(count)]
201
+ for start, span, low, high in items:
202
+ if span == 1:
203
+ single[start].append((low, high))
204
+ for i, ranges in enumerate(single):
205
+ if ranges:
206
+ mins[i] = max(low for low, _ in ranges)
207
+ # A widget with a size limit limits its track; flexible neighbours adapt
208
+ highs = [high for _, high in ranges if high is not None]
209
+ maxs[i] = max(min(highs), mins[i]) if highs else None
210
+
211
+ # Spanning widgets: if their tracks are too small together, grow one of them
212
+ for start, span, low, high in items:
213
+ if span > 1:
214
+ covered = sum(mins[start:start + span]) + sum(lines[start + 1:start + span])
215
+ if covered < low:
216
+ tracks = range(start, start + span)
217
+ grow = next((i for i in reversed(tracks) if maxs[i] is None), tracks[-1])
218
+ mins[grow] += low - covered
219
+ if maxs[grow] is not None:
220
+ maxs[grow] = max(maxs[grow], mins[grow])
221
+
222
+ # Everyone starts at their min, then the rest is shared equally between
223
+ # tracks that can still grow, in rounds, until it runs out or all are full
224
+ sizes = mins[:]
225
+ remaining = total - sum(lines) - sum(sizes)
226
+ while remaining > 0:
227
+ growable = [i for i in range(count) if maxs[i] is None or sizes[i] < maxs[i]]
228
+ if not growable:
229
+ break
230
+ share = remaining // len(growable)
231
+ if share == 0:
232
+ for i in growable[:remaining]:
233
+ sizes[i] += 1
234
+ break
235
+ for i in growable:
236
+ add = share if maxs[i] is None else min(share, maxs[i] - sizes[i])
237
+ sizes[i] += add
238
+ remaining -= add
239
+ return sizes, sum(mins) + sum(lines)
240
+
241
+
242
+ def _starts(sizes, lines):
243
+ """Start position of each track, leaving room for the border lines."""
244
+ starts, pos = [], 0
245
+ for i, size in enumerate(sizes):
246
+ pos += lines[i]
247
+ starts.append(pos)
248
+ pos += size
249
+ return starts