cmdgui 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {cmdgui-0.2.0/src/cmdgui.egg-info → cmdgui-0.3.0}/PKG-INFO +12 -3
  2. {cmdgui-0.2.0 → cmdgui-0.3.0}/README.md +11 -2
  3. {cmdgui-0.2.0 → cmdgui-0.3.0}/pyproject.toml +1 -1
  4. cmdgui-0.3.0/src/cmdgui/__init__.py +14 -0
  5. {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/_platform.py +5 -2
  6. cmdgui-0.3.0/src/cmdgui/clipboard.py +53 -0
  7. {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/inputs.py +32 -3
  8. cmdgui-0.3.0/src/cmdgui/layout.py +444 -0
  9. cmdgui-0.3.0/src/cmdgui/raster.py +429 -0
  10. {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/shorts.py +160 -4
  11. {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/view.py +511 -56
  12. {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/widgets/__init__.py +4 -0
  13. {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/widgets/base.py +33 -7
  14. cmdgui-0.3.0/src/cmdgui/widgets/container.py +83 -0
  15. {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/widgets/controls.py +13 -6
  16. cmdgui-0.3.0/src/cmdgui/widgets/dates.py +231 -0
  17. cmdgui-0.3.0/src/cmdgui/widgets/graphics.py +282 -0
  18. {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/widgets/lists.py +173 -24
  19. cmdgui-0.3.0/src/cmdgui/widgets/log.py +204 -0
  20. {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/widgets/tabs.py +8 -29
  21. cmdgui-0.3.0/src/cmdgui/widgets/text.py +443 -0
  22. {cmdgui-0.2.0 → cmdgui-0.3.0/src/cmdgui.egg-info}/PKG-INFO +12 -3
  23. {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui.egg-info/SOURCES.txt +6 -0
  24. cmdgui-0.2.0/src/cmdgui/__init__.py +0 -14
  25. cmdgui-0.2.0/src/cmdgui/layout.py +0 -249
  26. cmdgui-0.2.0/src/cmdgui/widgets/text.py +0 -259
  27. {cmdgui-0.2.0 → cmdgui-0.3.0}/LICENSE +0 -0
  28. {cmdgui-0.2.0 → cmdgui-0.3.0}/setup.cfg +0 -0
  29. {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/widgets/stdout.py +0 -0
  30. {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui.egg-info/dependency_links.txt +0 -0
  31. {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cmdgui
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Simple terminal GUIs: draw your layout as text, the view runs itself
5
5
  Author: olliez-mods
6
6
  License-Expression: MIT
@@ -74,7 +74,15 @@ editor knows `view.start` is a `Button` — autocomplete and type checking work.
74
74
  across cells, and borders between neighbours join up.
75
75
  - **Widgets**: text, labels, buttons, one-line and multi-line text boxes (with password
76
76
  mode and history), checkboxes, toggles, radio buttons, dropdowns, sliders, progress
77
- bars, menus, folding trees, tables, tabs, and a box that shows everything you print.
77
+ bars, menus, folding trees, tables, a calendar and date picker, tabs, split panes to
78
+ drag, a log viewer with levels and search, a box that shows everything you print, and
79
+ a pixel canvas with lines, shapes and curves.
80
+ - **Styled text**: `"[bold red]Error:[/] couldn't open [cyan]notes.txt[/]"` in labels,
81
+ buttons and text.
82
+ - **Text boxes that behave**: select with the mouse or Shift, copy, cut and paste,
83
+ pasting in one go, and dim hints before, after and ahead of the cursor for autocomplete.
84
+ - **Inline views**: draw in a few rows under the prompt instead of the whole screen, with
85
+ printed text scrolling above.
78
86
  - **Popups**: dialogs, dropdowns and command menus that float over the layout.
79
87
  - **Mouse and keyboard**: clicks, dragging, the scroll wheel, Tab between widgets, and
80
88
  your own key bindings.
@@ -94,4 +102,5 @@ editor knows `view.start` is a `Button` — autocomplete and type checking work.
94
102
  - [Your own widgets](https://github.com/olliez-mods/cmdgui/blob/main/docs/custom-widgets.md)
95
103
 
96
104
  The [examples](https://github.com/olliez-mods/cmdgui/tree/main/examples) folder has a
97
- widget demo, a dashboard, popups, tabs, a console and a file browser.
105
+ widget demo, a dashboard, popups, tabs, a console, a file browser, pixel graphics, a
106
+ log viewer with split panes, date pickers, and an inline progress display.
@@ -57,7 +57,15 @@ editor knows `view.start` is a `Button` — autocomplete and type checking work.
57
57
  across cells, and borders between neighbours join up.
58
58
  - **Widgets**: text, labels, buttons, one-line and multi-line text boxes (with password
59
59
  mode and history), checkboxes, toggles, radio buttons, dropdowns, sliders, progress
60
- bars, menus, folding trees, tables, tabs, and a box that shows everything you print.
60
+ bars, menus, folding trees, tables, a calendar and date picker, tabs, split panes to
61
+ drag, a log viewer with levels and search, a box that shows everything you print, and
62
+ a pixel canvas with lines, shapes and curves.
63
+ - **Styled text**: `"[bold red]Error:[/] couldn't open [cyan]notes.txt[/]"` in labels,
64
+ buttons and text.
65
+ - **Text boxes that behave**: select with the mouse or Shift, copy, cut and paste,
66
+ pasting in one go, and dim hints before, after and ahead of the cursor for autocomplete.
67
+ - **Inline views**: draw in a few rows under the prompt instead of the whole screen, with
68
+ printed text scrolling above.
61
69
  - **Popups**: dialogs, dropdowns and command menus that float over the layout.
62
70
  - **Mouse and keyboard**: clicks, dragging, the scroll wheel, Tab between widgets, and
63
71
  your own key bindings.
@@ -77,4 +85,5 @@ editor knows `view.start` is a `Button` — autocomplete and type checking work.
77
85
  - [Your own widgets](https://github.com/olliez-mods/cmdgui/blob/main/docs/custom-widgets.md)
78
86
 
79
87
  The [examples](https://github.com/olliez-mods/cmdgui/tree/main/examples) folder has a
80
- widget demo, a dashboard, popups, tabs, a console and a file browser.
88
+ widget demo, a dashboard, popups, tabs, a console, a file browser, pixel graphics, a
89
+ log viewer with split panes, date pickers, and an inline progress display.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cmdgui"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Simple terminal GUIs: draw your layout as text, the view runs itself"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,14 @@
1
+ from .view import View, Popup, Panel, Edge, Timer
2
+ from .widgets import (
3
+ Widget, Text, Label, Button, TextInput, TextArea, ProgressBar, Slider, Checkbox, Toggle,
4
+ RadioGroup, Select, Menu, Tree, Table, Tabs, Graphics, Calendar, DatePicker, Log, Stdout, DrawMouse, DEFAULT_THEME, field,
5
+ )
6
+ from .inputs import Input, mouse
7
+ from .layout import LayoutError
8
+ from .shorts import Canvas, style, styled, Color, ColorName
9
+
10
+ __all__ = [
11
+ "View", "Popup", "Panel", "Edge", "Timer", "Widget", "Text", "Label", "Button", "TextInput", "TextArea", "ProgressBar",
12
+ "Slider", "Checkbox", "Toggle", "RadioGroup", "Select", "Menu", "Tree", "Table", "Tabs", "Graphics", "Calendar", "DatePicker", "Log", "Stdout", "DrawMouse", "DEFAULT_THEME", "field",
13
+ "Input", "mouse", "LayoutError", "Canvas", "style", "styled", "Color", "ColorName",
14
+ ]
@@ -22,8 +22,11 @@ if not WINDOWS:
22
22
  fd = sys.stdin.fileno()
23
23
  self._saved = termios.tcgetattr(fd)
24
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)
25
+ # No line buffering or echo. Ctrl+C, Ctrl+V, Ctrl+S and so on come through as keys
26
+ # (ISIG, IEXTEN, IXON off): the view turns Ctrl+C back into KeyboardInterrupt when
27
+ # it isn't copying, and Ctrl+S no longer freezes the output
28
+ attrs[3] &= ~(termios.ICANON | termios.ECHO | termios.ISIG | termios.IEXTEN)
29
+ attrs[0] &= ~termios.IXON
27
30
  termios.tcsetattr(fd, termios.TCSANOW, attrs)
28
31
 
29
32
  def disable(self):
@@ -0,0 +1,53 @@
1
+ """Copying to the system clipboard. A terminal app can't reach the clipboard
2
+ directly, so copy() tries both ways there are:
3
+
4
+ - OSC 52, an escape code asking the terminal to copy. Works over SSH. Most modern
5
+ terminals support it (kitty, WezTerm, Ghostty, Alacritty, Windows Terminal, foot,
6
+ iTerm2 once allowed in its settings); macOS's Terminal.app doesn't.
7
+ - the system's own tool (pbcopy, wl-copy, xclip, xsel, clip), when running locally.
8
+
9
+ Pasting from the system clipboard is done by the terminal (Cmd+V, or Ctrl+Shift+V),
10
+ which sends the text to the app as a paste. paste() gives what was last copied in
11
+ this program, for Ctrl+V."""
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from .shorts import write
20
+
21
+ _last = "" # the last text copied, for paste()
22
+
23
+ # Clipboard tools, most likely first: (command, needs)
24
+ TOOLS = [
25
+ (["pbcopy"], "darwin"),
26
+ (["wl-copy"], "WAYLAND_DISPLAY"),
27
+ (["xclip", "-selection", "clipboard"], "DISPLAY"),
28
+ (["xsel", "--clipboard", "--input"], "DISPLAY"),
29
+ (["clip"], "win32"),
30
+ ]
31
+
32
+
33
+ def copy(text: str) -> None:
34
+ """Put text on the system clipboard (as far as the terminal allows), and keep it
35
+ for paste()."""
36
+ global _last
37
+ _last = text
38
+ write("\x1b]52;c;" + base64.b64encode(text.encode("utf-8")).decode("ascii") + "\x07")
39
+ if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"):
40
+ return # the tools would copy on the remote machine, not yours
41
+ for command, needs in TOOLS:
42
+ if (needs == sys.platform or os.environ.get(needs)) and shutil.which(command[0]):
43
+ try:
44
+ subprocess.run(command, input=text.encode("utf-8"), timeout=2, check=False,
45
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
46
+ except (OSError, subprocess.SubprocessError):
47
+ continue
48
+ return
49
+
50
+
51
+ def paste() -> str:
52
+ """The text last copied with copy() in this program."""
53
+ return _last
@@ -1,6 +1,7 @@
1
1
  import queue
2
2
  import re
3
3
  import sys
4
+ import time
4
5
 
5
6
  from .shorts import ESC, write
6
7
  from ._platform import Terminal
@@ -57,6 +58,10 @@ def update_state(input):
57
58
  # 1006 = SGR encoding, which gives readable decimal coordinates with no size limit
58
59
  MOUSE_ON = ESC + "?1003h" + ESC + "?1006h"
59
60
  MOUSE_OFF = ESC + "?1003l" + ESC + "?1006l"
61
+ # Bracketed paste: the terminal wraps pasted text in these, so it arrives as one
62
+ # "paste" input instead of keys (a pasted newline isn't Enter)
63
+ PASTE_ON, PASTE_OFF = ESC + "?2004h", ESC + "?2004l"
64
+ PASTE_START, PASTE_END = "\x1b[200~", "\x1b[201~"
60
65
 
61
66
  # SGR mouse report: ESC [ < button ; x ; y then M (press/move) or m (release)
62
67
  SGR_MOUSE = re.compile(r"\x1b\[<(\d+);(\d+);(\d+)([Mm])")
@@ -105,7 +110,7 @@ def enable():
105
110
  """Raw keyboard mode, mouse reporting, and capture print output."""
106
111
  _stderr_log.clear()
107
112
  _terminal.enable()
108
- write(MOUSE_ON)
113
+ write(MOUSE_ON + PASTE_ON)
109
114
  sys.stdout = StreamInterceptor(sys.stdout, "stdout")
110
115
  sys.stderr = StreamInterceptor(sys.stderr, "stderr")
111
116
 
@@ -116,7 +121,7 @@ def disable():
116
121
  sys.stdout = sys.stdout.real
117
122
  if isinstance(sys.stderr, StreamInterceptor):
118
123
  sys.stderr = sys.stderr.real
119
- write(MOUSE_OFF)
124
+ write(MOUSE_OFF + PASTE_OFF)
120
125
  _terminal.disable()
121
126
 
122
127
 
@@ -125,6 +130,23 @@ def captured_stderr():
125
130
  return "".join(_stderr_log)
126
131
 
127
132
 
133
+ CURSOR_REPORT = re.compile(r"\x1b\[(\d+);(\d+)R")
134
+
135
+ def cursor_position(timeout=0.5):
136
+ """Ask the terminal where the cursor is: (x, y), 0-based, or None if it doesn't say.
137
+ Call after enable() and before reading inputs; anything typed meanwhile is kept."""
138
+ global _buffer
139
+ write(ESC + "6n")
140
+ end = time.monotonic() + timeout
141
+ while time.monotonic() < end:
142
+ _buffer += _terminal.read(max(0.0, end - time.monotonic()))
143
+ match = CURSOR_REPORT.search(_buffer)
144
+ if match:
145
+ _buffer = _buffer[:match.start()] + _buffer[match.end():]
146
+ return int(match.group(2)) - 1, int(match.group(1)) - 1
147
+ return None
148
+
149
+
128
150
  def wake():
129
151
  """Make read_inputs() return straight away. Safe from any thread."""
130
152
  _terminal.wake()
@@ -146,7 +168,14 @@ def read_inputs(timeout=0.1):
146
168
  inputs.append(Input(kind, {"text": text}))
147
169
 
148
170
  while _buffer:
149
- if _buffer.startswith("\x1b[<"):
171
+ if _buffer.startswith(PASTE_START):
172
+ end = _buffer.find(PASTE_END)
173
+ if end < 0:
174
+ break # the rest of the paste is still coming
175
+ text = _buffer[len(PASTE_START):end].replace("\r\n", "\n").replace("\r", "\n")
176
+ inputs.append(Input("paste", {"text": text}))
177
+ _buffer = _buffer[end + len(PASTE_END):]
178
+ elif _buffer.startswith("\x1b[<"):
150
179
  match = SGR_MOUSE.match(_buffer)
151
180
  if not match:
152
181
  break # incomplete mouse report, wait for the rest
@@ -0,0 +1,444 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ # type, type[name], with optional flags on the end: name{+b,w=20}
5
+ TOKEN = re.compile(r"^([A-Za-z_]\w*)(?:\[([A-Za-z_]\w*)\])?(?:\{([^{}]*)\})?$")
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
+ # The border styles, for b=style
19
+ BORDER_STYLES = ("single", "rounded", "heavy", "double", "ascii")
20
+ # Flags that switch something on (+) or off (-), and the widget attribute they set
21
+ SWITCHES = {"b": "border", "e": "enabled", "f": "tab_stop", "v": "visible"}
22
+ FLAG_HELP = "+b/-b border, b=style, w=size, h=size, +e/-e enabled, +f/-f in Tab order, +v/-v shown, t=title"
23
+
24
+
25
+ def parse_flags(text, where=""):
26
+ """The widget attributes set by a cell's flags: "+b,w=20" -> {"border": True, "preferred_width": 20}."""
27
+ attrs = {}
28
+ prefix = f"{where}: " if where else ""
29
+ for flag in (part.strip() for part in text.split(",")):
30
+ if not flag:
31
+ continue
32
+ if flag in ("b", "nb"):
33
+ raise LayoutError(f"{prefix}{{{flag}}} is now {{{'+b' if flag == 'b' else '-b'}}}")
34
+ if flag[0] in "+-" and flag[1:] in SWITCHES:
35
+ attrs[SWITCHES[flag[1:]]] = flag[0] == "+"
36
+ continue
37
+ key, equals, value = flag.partition("=")
38
+ if equals and key in ("w", "h"):
39
+ parse_size(value, where) # check it now
40
+ attrs["preferred_width" if key == "w" else "preferred_height"] = int(value) if value.isdigit() else value
41
+ elif equals and key == "b":
42
+ if value not in BORDER_STYLES:
43
+ raise LayoutError(f"{prefix}unknown border style '{value}' (use {', '.join(BORDER_STYLES)})")
44
+ attrs["border"], attrs["border_style"] = True, value
45
+ elif equals and key == "t":
46
+ attrs["title"] = value.replace("_", " ")
47
+ else:
48
+ raise LayoutError(f"{prefix}unknown flag '{flag}' (flags: {FLAG_HELP})")
49
+ return attrs
50
+
51
+
52
+ @dataclass
53
+ class Slot:
54
+ name: str
55
+ type: str # None when the layout just names a widget that was passed in
56
+ row: int
57
+ col: int
58
+ rowspan: int = 1
59
+ colspan: int = 1
60
+ flags: dict = None # widget attributes set by the cell's flags, e.g. {"border": True}
61
+
62
+
63
+ @dataclass
64
+ class Layout:
65
+ rows: int
66
+ cols: int
67
+ slots: dict # name -> Slot, in the order they were declared
68
+
69
+
70
+ def parse_layout(text, types=None, names=()):
71
+ """Parse a layout string into a Layout.
72
+
73
+ types is an optional collection of known type names; if given, unknown
74
+ types are an error. names are widgets provided by the caller: a bare word
75
+ matching one of them refers to that widget instead of a type."""
76
+ lines = [line.split() for line in text.splitlines() if line.strip()]
77
+ if not lines: raise LayoutError("layout is empty")
78
+
79
+ cols = len(lines[0])
80
+ for r, cells in enumerate(lines):
81
+ if len(cells) != cols:
82
+ raise LayoutError(f"row {r + 1} has {len(cells)} cells, but row 1 has {cols}")
83
+
84
+ slot_types = {} # name -> type, in declaration order
85
+ slot_flags = {} # name -> widget attributes from its flags
86
+ grid = [[None] * cols for _ in lines] # name in each cell, None for empty
87
+
88
+ for r, cells in enumerate(lines):
89
+ for c, token in enumerate(cells):
90
+ where = f"row {r + 1}, column {c + 1}"
91
+
92
+ if token == EMPTY: continue
93
+
94
+ if token == SPAN_LEFT:
95
+ if c == 0: raise LayoutError(f"{where}: '-' has nothing to its left")
96
+ if grid[r][c - 1] is None: raise LayoutError(f"{where}: '-' can't extend an empty cell")
97
+ grid[r][c] = grid[r][c - 1]
98
+ continue
99
+
100
+ if token == SPAN_UP:
101
+ if r == 0: raise LayoutError(f"{where}: '|' has nothing above it")
102
+ if grid[r - 1][c] is None: raise LayoutError(f"{where}: '|' can't extend an empty cell")
103
+ grid[r][c] = grid[r - 1][c]
104
+ continue
105
+
106
+ match = TOKEN.match(token)
107
+ if not match:
108
+ raise LayoutError(f"{where}: can't understand '{token}'")
109
+ type_, name, flag = match.groups()
110
+ if name is None and type_ in slot_types: # repeat of an existing name: span
111
+ if flag is not None:
112
+ raise LayoutError(f"{where}: put {{{flag}}} on the first '{type_}' cell, not a repeat")
113
+ grid[r][c] = type_
114
+ continue
115
+ if name is None:
116
+ name = type_
117
+ if name in names:
118
+ type_ = None # a provided widget, no type needed
119
+ elif name in slot_types:
120
+ raise LayoutError(f"{where}: '{name}' is already declared; repeat it as just '{name}' to span")
121
+
122
+ if types is not None and type_ is not None and type_ not in types:
123
+ known = ", ".join(sorted(types))
124
+ raise LayoutError(f"{where}: unknown widget type '{type_}' (known: {known})")
125
+ slot_types[name] = type_
126
+ slot_flags[name] = parse_flags(flag, where) if flag is not None else {}
127
+ grid[r][c] = name
128
+
129
+ return Layout(len(lines), cols, _build_slots(grid, slot_types, slot_flags))
130
+
131
+
132
+ def _build_slots(grid, slot_types, slot_flags):
133
+ """Turn the grid of names into Slots, checking each name covers a rectangle."""
134
+ cells = {} # name -> list of (row, col)
135
+ for r, row in enumerate(grid):
136
+ for c, name in enumerate(row):
137
+ if name is not None:
138
+ cells.setdefault(name, []).append((r, c))
139
+
140
+ slots = {}
141
+ for name, type_ in slot_types.items():
142
+ rows = [r for r, _ in cells[name]]
143
+ cols = [c for _, c in cells[name]]
144
+ top, left = min(rows), min(cols)
145
+ rowspan, colspan = max(rows) - top + 1, max(cols) - left + 1
146
+ if len(cells[name]) != rowspan * colspan:
147
+ hint = f"; to add a second {type_}, give it its own name: {type_}[other_name]" if type_ else ""
148
+ raise LayoutError(f"'{name}' doesn't form a rectangle{hint}")
149
+ slots[name] = Slot(name, type_, top, left, rowspan, colspan, slot_flags[name])
150
+ return slots
151
+
152
+
153
+ def pair_axis(layout, first, second):
154
+ """Check two sides (lists of widget names) can have a draggable line between them,
155
+ and say which way it's dragged: "x" when they're side by side, "y" when stacked.
156
+ Returns (axis, first, second), swapped if second comes first.
157
+
158
+ Together the two sides must form a rectangle, so dragging only changes them.
159
+ A side with more than one widget is a stack along the line: each of its widgets
160
+ reaches all the way from the line to the side's far edge."""
161
+ def describe(names):
162
+ return names[0] if len(names) == 1 else "[" + ", ".join(names) + "]"
163
+
164
+ def box(names):
165
+ """(top, left, bottom, right) of the side, in grid cells (bottom, right exclusive)."""
166
+ slots = [layout.slots[name] for name in names]
167
+ top, left = min(s.row for s in slots), min(s.col for s in slots)
168
+ bottom, right = max(s.row + s.rowspan for s in slots), max(s.col + s.colspan for s in slots)
169
+ if sum(s.rowspan * s.colspan for s in slots) != (bottom - top) * (right - left):
170
+ raise LayoutError(f"{describe(names)} doesn't form a rectangle, so it can't be one side of a draggable line")
171
+ return top, left, bottom, right
172
+
173
+ def span(top, left, bottom, right, axis):
174
+ kind, low, high = ("row", top, bottom) if axis == "x" else ("column", left, right)
175
+ return f"{kind} {low + 1}" if high - low == 1 else f"{kind}s {low + 1}-{high}"
176
+
177
+ if set(first) & set(second):
178
+ raise LayoutError(f"{', '.join(sorted(set(first) & set(second)))} can't be on both sides of a draggable line")
179
+ a, b = box(first), box(second)
180
+ if (b[3] == a[1] and b[0::2] == a[0::2]) or (b[2] == a[0] and b[1::2] == a[1::2]): # second comes first
181
+ first, second, a, b = second, first, b, a
182
+ if a[3] == b[1] and a[0::2] == b[0::2]:
183
+ axis = "x"
184
+ elif a[2] == b[0] and a[1::2] == b[1::2]:
185
+ axis = "y"
186
+ elif a[3] == b[1] or b[3] == a[1]:
187
+ raise LayoutError(f"{describe(first)} and {describe(second)} have to line up to drag the line between them: "
188
+ f"{describe(first)} covers {span(*a, 'x')}, {describe(second)} {span(*b, 'x')}")
189
+ elif a[2] == b[0] or b[2] == a[0]:
190
+ raise LayoutError(f"{describe(first)} and {describe(second)} have to line up to drag the line between them: "
191
+ f"{describe(first)} covers {span(*a, 'y')}, {describe(second)} {span(*b, 'y')}")
192
+ else:
193
+ raise LayoutError(f"{describe(first)} and {describe(second)} aren't next to each other")
194
+ for names, (top, left, bottom, right) in ((first, a), (second, b)):
195
+ for name in names:
196
+ s = layout.slots[name]
197
+ whole = (s.col, s.col + s.colspan) == (left, right) if axis == "x" else (s.row, s.row + s.rowspan) == (top, bottom)
198
+ if not whole:
199
+ raise LayoutError(f"'{name}' doesn't fill the width of its side of the draggable line"
200
+ if axis == "x" else
201
+ f"'{name}' doesn't fill the height of its side of the draggable line")
202
+ return axis, first, second
203
+
204
+
205
+ def parse_size(spec, where=""):
206
+ """Turn a size into (min, max), max None for no limit.
207
+ 5 or "5" exactly, "5+" at least 5, "5-10" between, None flexible (at least 1)."""
208
+ if spec is None:
209
+ return 1, None
210
+ if isinstance(spec, int):
211
+ return spec, spec
212
+ match = SIZE.match(str(spec).strip())
213
+ if not match:
214
+ raise LayoutError(f"{where + ': ' if where else ''}bad size '{spec}' (use e.g. 5, '5+' or '5-10')")
215
+ low, plus, high = match.groups()
216
+ low = int(low)
217
+ if plus:
218
+ return low, None
219
+ if high is not None:
220
+ if int(high) < low:
221
+ raise LayoutError(f"{where + ': ' if where else ''}bad size '{spec}': max is smaller than min")
222
+ return low, int(high)
223
+ return low, low
224
+
225
+
226
+ @dataclass
227
+ class Placement:
228
+ rects: dict # name -> (x, y, width, height) of the widget's content
229
+ frames: dict # name -> (x, y, width, height) of the border, for bordered widgets
230
+ min_width: int # smallest screen the layout fits on
231
+ min_height: int
232
+ fits: bool # False if the screen is smaller than that
233
+ lines: dict = None # pair index -> (x, y, w, h) of its draggable line
234
+ spans: dict = None # pair index -> (axis, start, room, low, high, at) along the axis, for dragging:
235
+ # the room the pair has, the line's limits, and where it is
236
+
237
+
238
+ def place(layout, width, height, sizes=None, borders=None, outer=False, hidden=(), pairs=(), positions=None):
239
+ """Work out where every widget goes on a width x height screen.
240
+
241
+ sizes: name -> (width spec, height spec), see parse_size
242
+ borders: name -> True if the widget has a border
243
+ outer: always leave room for a border around the whole layout (popups)
244
+ hidden: names of widgets that take no room: rows and columns that only they
245
+ cover shrink to nothing, and get no rects or frames
246
+ pairs: (axis, first names, second names) with a draggable line between them, see pair_axis
247
+ positions: pair index -> where its line goes, (kind, side, amount): kind "share" for a
248
+ fraction of the room (0 to 1), or "cells"; side 0 for the first side's size,
249
+ 1 for the second's. Without one, the line goes where the grid puts it.
250
+
251
+ Border lines sit between grid rows/columns and are shared by neighbours,
252
+ so two bordered widgets next to each other have one line between them."""
253
+ sizes = sizes or {}
254
+ borders = borders or {}
255
+ every = list(layout.slots.values())
256
+ slots = [s for s in every if s.name not in hidden]
257
+ specs = {s.name: [parse_size(spec) for spec in sizes.get(s.name, (None, None))] for s in slots}
258
+
259
+ # Rows and columns with hidden widgets in them collapse, as long as every shown widget
260
+ # keeps some room (tracks with nothing in them stay flexible)
261
+ gone = [s for s in every if s.name in hidden]
262
+ gone_cols = _collapsed([(s.col, s.colspan) for s in gone], [(s.col, s.colspan) for s in slots])
263
+ gone_rows = _collapsed([(s.row, s.rowspan) for s in gone], [(s.row, s.rowspan) for s in slots])
264
+ col_lines = _lines(layout.cols, [(s.col, s.colspan) for s in slots if borders.get(s.name)], gone_cols)
265
+ row_lines = _lines(layout.rows, [(s.row, s.rowspan) for s in slots if borders.get(s.name)], gone_rows)
266
+ if outer:
267
+ col_lines[0] = col_lines[-1] = row_lines[0] = row_lines[-1] = 1
268
+ # Pairs with a draggable line between them (hidden widgets left out), and room for
269
+ # both sides' minimums plus the line, even with no border line there in the grid
270
+ pairs = [(i, axis, [n for n in first if n not in hidden], [n for n in second if n not in hidden])
271
+ for i, (axis, first, second) in enumerate(pairs)]
272
+ pairs = [(i, axis, first, second) for i, axis, first, second in pairs if first and second]
273
+ col_items = [(s.col, s.colspan, *specs[s.name][0]) for s in slots]
274
+ row_items = [(s.row, s.rowspan, *specs[s.name][1]) for s in slots]
275
+ for _, axis, first, second in pairs:
276
+ k = 0 if axis == "x" else 1
277
+ ends = [(s.col, s.col + s.colspan) if axis == "x" else (s.row, s.row + s.rowspan)
278
+ for s in (layout.slots[n] for n in first + second)]
279
+ start, end = min(a for a, _ in ends), max(b for _, b in ends)
280
+ need = max(specs[n][k][0] for n in first) + max(specs[n][k][0] for n in second) + 1
281
+ (col_items if axis == "x" else row_items).append((start, end - start, need, None))
282
+ col_widths, min_width = _size_tracks(width, layout.cols, col_lines, col_items, gone_cols)
283
+ row_heights, min_height = _size_tracks(height, layout.rows, row_lines, row_items, gone_rows)
284
+ col_x = _starts(col_widths, col_lines)
285
+ row_y = _starts(row_heights, row_lines)
286
+
287
+ rects = {}
288
+ for s in slots:
289
+ x, y = col_x[s.col], row_y[s.row]
290
+ last_col, last_row = s.col + s.colspan - 1, s.row + s.rowspan - 1
291
+ w = col_x[last_col] + col_widths[last_col] - x
292
+ h = row_y[last_row] + row_heights[last_row] - y
293
+ rects[s.name] = [x, y, w, h]
294
+ lines, spans = _place_pairs(rects, pairs, specs, positions or {})
295
+ frames = {name: (x - 1, y - 1, w + 2, h + 2) for name, (x, y, w, h) in rects.items() if borders.get(name)}
296
+ edges = list(frames.values()) + list(lines.values()) + ([(0, 0, width, height)] if outer else [])
297
+ lines = {i: _reach(line, spans[i][0], edges) for i, line in lines.items()}
298
+ rects = {name: tuple(rect) for name, rect in rects.items()}
299
+ return Placement(rects, frames, min_width, min_height, width >= min_width and height >= min_height,
300
+ lines, spans)
301
+
302
+
303
+ def _place_pairs(rects, pairs, specs, positions):
304
+ """Move the line between each pair to its position, changing only the widgets
305
+ either side of it. rects (name -> [x, y, w, h]) are changed in place. Returns each
306
+ pair's line (x, y, w, h), and its spans (see Placement).
307
+
308
+ A position is measured in the room the grid gave the pair, so moving one line
309
+ doesn't move another that shares a widget with it."""
310
+ grid = {name: rect[:] for name, rect in rects.items()} # before any lines moved
311
+ lines, spans = {}, {}
312
+ for i, axis, first, second in pairs:
313
+ k, j = (0, 1) if axis == "x" else (1, 0) # along the drag, and across it
314
+ start = min(grid[n][k] for n in first)
315
+ end = max(grid[n][k] + grid[n][k + 2] for n in second)
316
+ room = end - start - 1 # for the two sides, less the line
317
+ at = max(grid[n][k] + grid[n][k + 2] for n in first) # just after the first side, as the grid has it
318
+ if positions.get(i):
319
+ kind, side, amount = positions[i]
320
+ size = round(amount * room) if kind == "share" else amount
321
+ at = start + (size if side == 0 else room - size)
322
+ # Each side keeps its minimum (a maximum, like w=24, is only where the line starts)
323
+ low = max(rects[n][k] + specs[n][k][0] for n in first)
324
+ high = min(rects[n][k] + rects[n][k + 2] - 1 - specs[n][k][0] for n in second)
325
+ at = max(low, min(high, at))
326
+ for n in first:
327
+ rects[n][k + 2] = max(0, at - rects[n][k])
328
+ for n in second:
329
+ far = rects[n][k] + rects[n][k + 2]
330
+ rects[n][k], rects[n][k + 2] = at + 1, max(0, far - at - 1)
331
+ across = min(rects[n][j] for n in first + second)
332
+ length = max(rects[n][j] + rects[n][j + 2] for n in first + second) - across
333
+ lines[i] = (at, across, 1, length) if axis == "x" else (across, at, length, 1)
334
+ spans[i] = (axis, start, room, low, high, at)
335
+ return lines, spans
336
+
337
+
338
+ def _reach(line, axis, edges):
339
+ """Make a line one cell longer at each end where it meets a border or another line,
340
+ so they join up."""
341
+ def on_edge(px, py):
342
+ return any((px in (x, x + w - 1) and y <= py < y + h) or (py in (y, y + h - 1) and x <= px < x + w)
343
+ for x, y, w, h in edges if (x, y, w, h) != line)
344
+ x, y, w, h = line
345
+ if axis == "x": # a line dragged sideways goes up and down
346
+ if on_edge(x, y - 1): y, h = y - 1, h + 1
347
+ if on_edge(x, y + h): h += 1
348
+ else:
349
+ if on_edge(x - 1, y): x, w = x - 1, w + 1
350
+ if on_edge(x + w, y): w += 1
351
+ return x, y, w, h
352
+
353
+
354
+ def _collapsed(hidden, shown):
355
+ """The tracks (rows or columns) that take no room: the ones hidden widgets are in,
356
+ except where that would leave a shown widget with none at all. A shown widget
357
+ spanning a collapsed track just gets smaller."""
358
+ gone = {i for start, span in hidden for i in range(start, start + span)}
359
+ changed = True
360
+ while changed:
361
+ changed = False
362
+ for start, span in shown:
363
+ tracks = set(range(start, start + span))
364
+ if tracks <= gone: # it would vanish: keep its tracks
365
+ gone -= tracks
366
+ changed = True
367
+ return gone
368
+
369
+
370
+ def _lines(count, spans, gone=()):
371
+ """Thickness (0 or 1) of the border line before each track, plus one after the last.
372
+ Across a run of collapsed tracks, the lines either side become one."""
373
+ lines = [0] * (count + 1)
374
+ for start, span in spans:
375
+ lines[start] = lines[start + span] = 1
376
+ i = 0
377
+ while i < count:
378
+ if i in gone:
379
+ end = i
380
+ while end + 1 < count and end + 1 in gone: end += 1
381
+ keep = max(lines[i:end + 2])
382
+ lines[i:end + 2] = [keep] + [0] * (end + 1 - i)
383
+ i = end + 1
384
+ else:
385
+ i += 1
386
+ return lines
387
+
388
+
389
+ def _size_tracks(total, count, lines, items, gone=()):
390
+ """Size the rows (or columns). items are (start, span, min, max) per widget.
391
+ gone: tracks that take no room. Returns the sizes and the minimum total needed."""
392
+ # Each track's range comes from the widgets that sit only in that track
393
+ mins = [0] * count
394
+ maxs = [0 if i in gone else None for i in range(count)]
395
+ single = [[] for _ in range(count)]
396
+ for start, span, low, high in items:
397
+ if span == 1:
398
+ single[start].append((low, high))
399
+ for i, ranges in enumerate(single):
400
+ if ranges:
401
+ mins[i] = max(low for low, _ in ranges)
402
+ # A widget with a size limit limits its track; flexible neighbours adapt
403
+ highs = [high for _, high in ranges if high is not None]
404
+ maxs[i] = max(min(highs), mins[i]) if highs else None
405
+
406
+ # Spanning widgets: if their tracks are too small together, grow one of them
407
+ for start, span, low, high in items:
408
+ if span > 1:
409
+ covered = sum(mins[start:start + span]) + sum(lines[start + 1:start + span])
410
+ if covered < low:
411
+ tracks = range(start, start + span)
412
+ grow = next((i for i in reversed(tracks) if maxs[i] is None), tracks[-1])
413
+ mins[grow] += low - covered
414
+ if maxs[grow] is not None:
415
+ maxs[grow] = max(maxs[grow], mins[grow])
416
+
417
+ # Everyone starts at their min, then the rest is shared equally between
418
+ # tracks that can still grow, in rounds, until it runs out or all are full
419
+ sizes = mins[:]
420
+ remaining = total - sum(lines) - sum(sizes)
421
+ while remaining > 0:
422
+ growable = [i for i in range(count) if maxs[i] is None or sizes[i] < maxs[i]]
423
+ if not growable:
424
+ break
425
+ share = remaining // len(growable)
426
+ if share == 0:
427
+ for i in growable[:remaining]:
428
+ sizes[i] += 1
429
+ break
430
+ for i in growable:
431
+ add = share if maxs[i] is None else min(share, maxs[i] - sizes[i])
432
+ sizes[i] += add
433
+ remaining -= add
434
+ return sizes, sum(mins) + sum(lines)
435
+
436
+
437
+ def _starts(sizes, lines):
438
+ """Start position of each track, leaving room for the border lines."""
439
+ starts, pos = [], 0
440
+ for i, size in enumerate(sizes):
441
+ pos += lines[i]
442
+ starts.append(pos)
443
+ pos += size
444
+ return starts