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.
- {cmdgui-0.2.0/src/cmdgui.egg-info → cmdgui-0.3.0}/PKG-INFO +12 -3
- {cmdgui-0.2.0 → cmdgui-0.3.0}/README.md +11 -2
- {cmdgui-0.2.0 → cmdgui-0.3.0}/pyproject.toml +1 -1
- cmdgui-0.3.0/src/cmdgui/__init__.py +14 -0
- {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/_platform.py +5 -2
- cmdgui-0.3.0/src/cmdgui/clipboard.py +53 -0
- {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/inputs.py +32 -3
- cmdgui-0.3.0/src/cmdgui/layout.py +444 -0
- cmdgui-0.3.0/src/cmdgui/raster.py +429 -0
- {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/shorts.py +160 -4
- {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/view.py +511 -56
- {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/widgets/__init__.py +4 -0
- {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/widgets/base.py +33 -7
- cmdgui-0.3.0/src/cmdgui/widgets/container.py +83 -0
- {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/widgets/controls.py +13 -6
- cmdgui-0.3.0/src/cmdgui/widgets/dates.py +231 -0
- cmdgui-0.3.0/src/cmdgui/widgets/graphics.py +282 -0
- {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/widgets/lists.py +173 -24
- cmdgui-0.3.0/src/cmdgui/widgets/log.py +204 -0
- {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/widgets/tabs.py +8 -29
- cmdgui-0.3.0/src/cmdgui/widgets/text.py +443 -0
- {cmdgui-0.2.0 → cmdgui-0.3.0/src/cmdgui.egg-info}/PKG-INFO +12 -3
- {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui.egg-info/SOURCES.txt +6 -0
- cmdgui-0.2.0/src/cmdgui/__init__.py +0 -14
- cmdgui-0.2.0/src/cmdgui/layout.py +0 -249
- cmdgui-0.2.0/src/cmdgui/widgets/text.py +0 -259
- {cmdgui-0.2.0 → cmdgui-0.3.0}/LICENSE +0 -0
- {cmdgui-0.2.0 → cmdgui-0.3.0}/setup.cfg +0 -0
- {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui/widgets/stdout.py +0 -0
- {cmdgui-0.2.0 → cmdgui-0.3.0}/src/cmdgui.egg-info/dependency_links.txt +0 -0
- {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.
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
@@ -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
|
|
26
|
-
|
|
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(
|
|
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
|