tuikinter 0.1.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.
- tuikinter-0.1.0/.gitignore +30 -0
- tuikinter-0.1.0/CHANGELOG.md +24 -0
- tuikinter-0.1.0/LICENSE +21 -0
- tuikinter-0.1.0/PKG-INFO +165 -0
- tuikinter-0.1.0/README.md +131 -0
- tuikinter-0.1.0/examples/demo.py +74 -0
- tuikinter-0.1.0/pyproject.toml +67 -0
- tuikinter-0.1.0/src/tuikinter/__init__.py +88 -0
- tuikinter-0.1.0/src/tuikinter/core.py +553 -0
- tuikinter-0.1.0/tests/test_tuikinter.py +122 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
.Python
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
*.egg
|
|
12
|
+
wheels/
|
|
13
|
+
|
|
14
|
+
# Virtual environments
|
|
15
|
+
.venv/
|
|
16
|
+
venv/
|
|
17
|
+
env/
|
|
18
|
+
ENV/
|
|
19
|
+
|
|
20
|
+
# Test / coverage
|
|
21
|
+
.pytest_cache/
|
|
22
|
+
.coverage
|
|
23
|
+
htmlcov/
|
|
24
|
+
.tox/
|
|
25
|
+
|
|
26
|
+
# IDE / editor
|
|
27
|
+
.idea/
|
|
28
|
+
.vscode/
|
|
29
|
+
*.swp
|
|
30
|
+
.DS_Store
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-06-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release.
|
|
14
|
+
- `tkinter`-style imperative façade over [Textual](https://github.com/Textualize/textual).
|
|
15
|
+
- Root window `Tk` with `title()`, `mainloop()`, `destroy()`, `after()`, `every()`.
|
|
16
|
+
- Widgets: `Frame`, `Label`, `Button`, `Entry`, `Text`, `Checkbutton`, `Listbox`, `Progressbar`.
|
|
17
|
+
- Observable variables `StringVar`, `IntVar`, `BooleanVar` with two-way binding and `trace_add`.
|
|
18
|
+
- Layout managers: `pack()` (primary) and `grid()` (best-effort).
|
|
19
|
+
- `config()` / `configure()` and dict-style `widget["key"] = value` configuration.
|
|
20
|
+
- `command=` callback routing via Textual's message system.
|
|
21
|
+
- Headless test suite driven by Textual's `Pilot` harness.
|
|
22
|
+
|
|
23
|
+
[Unreleased]: https://github.com/joshuamaojh/tuikinter/compare/v0.1.0...HEAD
|
|
24
|
+
[0.1.0]: https://github.com/joshuamaojh/tuikinter/releases/tag/v0.1.0
|
tuikinter-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joshua Mao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tuikinter-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tuikinter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Write terminal UIs with a tkinter/PyQt-style imperative API, powered by Textual.
|
|
5
|
+
Project-URL: Homepage, https://github.com/joshuamaojh/tuikinter
|
|
6
|
+
Project-URL: Repository, https://github.com/joshuamaojh/tuikinter
|
|
7
|
+
Project-URL: Issues, https://github.com/joshuamaojh/tuikinter/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/joshuamaojh/tuikinter/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Joshua Mao
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: cli,console,terminal,textual,tkinter,tui,ui,widgets
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Requires-Dist: textual>=0.40
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: build; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
32
|
+
Requires-Dist: twine; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# tuikinter
|
|
36
|
+
|
|
37
|
+
**Write terminal UIs (TUIs) with a `tkinter` / `PyQt`-style imperative API — powered by [Textual](https://github.com/Textualize/textual).**
|
|
38
|
+
|
|
39
|
+
Textual is a fantastic, modern TUI framework, but its programming model is *declarative and async*: you `yield` widgets inside `App.compose()`, style them with TCSS, and manage state with reactives. If your mental model is `tkinter` — *create widgets one by one, lay them out with `pack` / `grid`, then call `mainloop()`* — `tuikinter` gives you exactly that, while Textual does the heavy lifting underneath.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import tuikinter as tk
|
|
43
|
+
|
|
44
|
+
root = tk.Tk()
|
|
45
|
+
root.title("Hello")
|
|
46
|
+
|
|
47
|
+
name = tk.StringVar(root, value="World")
|
|
48
|
+
tk.Label(root, text="Name:").pack(side="left")
|
|
49
|
+
tk.Entry(root, textvariable=name).pack(side="left", fill="x", expand=True)
|
|
50
|
+
|
|
51
|
+
tk.Button(root, text="Greet", command=lambda: print(f"Hi {name.get()}")).pack()
|
|
52
|
+
tk.Button(root, text="Quit", command=root.destroy).pack()
|
|
53
|
+
|
|
54
|
+
root.mainloop()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Why
|
|
58
|
+
|
|
59
|
+
If you already know `tkinter`, you already know `tuikinter`. The API mirrors it closely — same widget names, same `pack`/`grid`, same `StringVar`/`IntVar`/`BooleanVar` with `trace_add`, same `command=` callbacks, same `widget.config(...)` and `widget["text"] = ...`. You get a real, keyboard-navigable terminal app (and, because it's Textual underneath, it can even run in a browser via `textual serve`).
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install tuikinter
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Requires Python 3.9+. Textual is pulled in automatically.
|
|
68
|
+
|
|
69
|
+
## How it works
|
|
70
|
+
|
|
71
|
+
A façade in two phases:
|
|
72
|
+
|
|
73
|
+
1. **Build phase** (before `mainloop()`): each widget you create is a lightweight *spec proxy* that records which Textual widget to use, its options, callbacks, and layout intent, attaching itself to its parent to form a tree.
|
|
74
|
+
2. **Run phase** (`mainloop()`): a Textual `App` subclass is generated on the fly. Its `compose()` walks the tree and instantiates the real Textual widgets; `pack`/`grid` are translated into containers (`Vertical` / `Horizontal` / `Grid`) plus styles; callbacks are routed back to your plain functions through Textual's message system.
|
|
75
|
+
|
|
76
|
+
This is the same trick `tkinter` itself uses behind the scenes with Tcl commands.
|
|
77
|
+
|
|
78
|
+
## Widgets
|
|
79
|
+
|
|
80
|
+
| tuikinter | maps to (Textual) | notes |
|
|
81
|
+
|-----------|-------------------|-------|
|
|
82
|
+
| `Tk` | `App` | root window; call `.mainloop()` |
|
|
83
|
+
| `Frame` | `Vertical` / `Horizontal` / `Grid` | container; nest for complex layouts |
|
|
84
|
+
| `Label` | `Static` | `config(text=...)` to update |
|
|
85
|
+
| `Button` | `Button` | `command=fn`; `variant="success"/"error"/...` |
|
|
86
|
+
| `Entry` | `Input` | `textvariable=`, `get()`, `insert()`, `delete()`, `show="*"` |
|
|
87
|
+
| `Text` | `TextArea` | multi-line |
|
|
88
|
+
| `Checkbutton` | `Checkbox` | `variable=BooleanVar`, `command=fn` |
|
|
89
|
+
| `Listbox` | `OptionList` | `values=[...]`, `insert()`, `get_selected()` |
|
|
90
|
+
| `Progressbar` | `ProgressBar` | `maximum=`, `set()`, `step()` |
|
|
91
|
+
|
|
92
|
+
## Variables
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
name = tk.StringVar(root, value="Joshua")
|
|
96
|
+
name.trace_add("write", lambda: print("changed to", name.get()))
|
|
97
|
+
name.set("World") # pushes to any bound Entry + fires traces
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`StringVar`, `IntVar`, and `BooleanVar` bind two-way to `Entry` / `Checkbutton`.
|
|
101
|
+
|
|
102
|
+
## Layout
|
|
103
|
+
|
|
104
|
+
`pack()` is the primary, robust layout manager:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
tk.Label(root, text="Top").pack(side="top", fill="x")
|
|
108
|
+
tk.Button(root, text="Left").pack(side="left", padx=1)
|
|
109
|
+
tk.Entry(root).pack(side="left", fill="x", expand=True)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`grid()` is supported on a best-effort basis (terminal grid semantics differ from pixel grids):
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
grid = tk.Frame(root); grid.pack()
|
|
116
|
+
for i, t in enumerate("789456123"):
|
|
117
|
+
tk.Button(grid, text=t).grid(row=i // 3, column=i % 3)
|
|
118
|
+
tk.Button(grid, text="0").grid(row=3, column=0, columnspan=3)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Timers
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
root.after(1000, lambda: print("once, after 1s"))
|
|
125
|
+
root.every(1000, lambda: clock.config(text=time.strftime("%H:%M:%S")))
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Limitations / honest notes
|
|
129
|
+
|
|
130
|
+
- **`pack` is the robust path; `grid` is best-effort.** For non-trivial layouts, nest `Frame`s (just like idiomatic `tkinter`) rather than mixing `side`s in one frame.
|
|
131
|
+
- Colors and sizes use **Textual semantics** (color names, `"100%"`, `"1fr"`), not pixels.
|
|
132
|
+
- An `Entry` bound to a `textvariable` fires its trace once on mount (Textual emits a `Changed` event when the `Input` first appears). Usually harmless — worth knowing if your trace has side effects.
|
|
133
|
+
- Dynamically creating + packing widgets *after* `mainloop()` has started is best-effort.
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
git clone https://github.com/joshuamaojh/tuikinter
|
|
139
|
+
cd tuikinter
|
|
140
|
+
python -m venv .venv && source .venv/bin/activate
|
|
141
|
+
pip install -e ".[dev]"
|
|
142
|
+
pytest
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Tests drive the app headlessly with Textual's `Pilot` harness.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT © Joshua Mao
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 中文简述
|
|
154
|
+
|
|
155
|
+
`tuikinter` 让你用 **`tkinter` / `PyQt` 那样的命令式写法**来写终端 TUI,底层由 Textual 驱动。会 `tkinter` 就会它:控件名、`pack`/`grid`、`StringVar`/`IntVar`/`BooleanVar`(带 `trace_add`)、`command=` 回调、`config()` / `widget["text"]=...` 都一致。
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
import tuikinter as tk
|
|
159
|
+
root = tk.Tk()
|
|
160
|
+
tk.Label(root, text="你好").pack()
|
|
161
|
+
tk.Button(root, text="退出", command=root.destroy).pack()
|
|
162
|
+
root.mainloop()
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
几个要点:`pack` 最稳,复杂布局请像 tkinter 那样嵌套 `Frame`;颜色/尺寸走 Textual 语义(色名、`"100%"`、`"1fr"`),不是像素。
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# tuikinter
|
|
2
|
+
|
|
3
|
+
**Write terminal UIs (TUIs) with a `tkinter` / `PyQt`-style imperative API — powered by [Textual](https://github.com/Textualize/textual).**
|
|
4
|
+
|
|
5
|
+
Textual is a fantastic, modern TUI framework, but its programming model is *declarative and async*: you `yield` widgets inside `App.compose()`, style them with TCSS, and manage state with reactives. If your mental model is `tkinter` — *create widgets one by one, lay them out with `pack` / `grid`, then call `mainloop()`* — `tuikinter` gives you exactly that, while Textual does the heavy lifting underneath.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import tuikinter as tk
|
|
9
|
+
|
|
10
|
+
root = tk.Tk()
|
|
11
|
+
root.title("Hello")
|
|
12
|
+
|
|
13
|
+
name = tk.StringVar(root, value="World")
|
|
14
|
+
tk.Label(root, text="Name:").pack(side="left")
|
|
15
|
+
tk.Entry(root, textvariable=name).pack(side="left", fill="x", expand=True)
|
|
16
|
+
|
|
17
|
+
tk.Button(root, text="Greet", command=lambda: print(f"Hi {name.get()}")).pack()
|
|
18
|
+
tk.Button(root, text="Quit", command=root.destroy).pack()
|
|
19
|
+
|
|
20
|
+
root.mainloop()
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Why
|
|
24
|
+
|
|
25
|
+
If you already know `tkinter`, you already know `tuikinter`. The API mirrors it closely — same widget names, same `pack`/`grid`, same `StringVar`/`IntVar`/`BooleanVar` with `trace_add`, same `command=` callbacks, same `widget.config(...)` and `widget["text"] = ...`. You get a real, keyboard-navigable terminal app (and, because it's Textual underneath, it can even run in a browser via `textual serve`).
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install tuikinter
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Requires Python 3.9+. Textual is pulled in automatically.
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
A façade in two phases:
|
|
38
|
+
|
|
39
|
+
1. **Build phase** (before `mainloop()`): each widget you create is a lightweight *spec proxy* that records which Textual widget to use, its options, callbacks, and layout intent, attaching itself to its parent to form a tree.
|
|
40
|
+
2. **Run phase** (`mainloop()`): a Textual `App` subclass is generated on the fly. Its `compose()` walks the tree and instantiates the real Textual widgets; `pack`/`grid` are translated into containers (`Vertical` / `Horizontal` / `Grid`) plus styles; callbacks are routed back to your plain functions through Textual's message system.
|
|
41
|
+
|
|
42
|
+
This is the same trick `tkinter` itself uses behind the scenes with Tcl commands.
|
|
43
|
+
|
|
44
|
+
## Widgets
|
|
45
|
+
|
|
46
|
+
| tuikinter | maps to (Textual) | notes |
|
|
47
|
+
|-----------|-------------------|-------|
|
|
48
|
+
| `Tk` | `App` | root window; call `.mainloop()` |
|
|
49
|
+
| `Frame` | `Vertical` / `Horizontal` / `Grid` | container; nest for complex layouts |
|
|
50
|
+
| `Label` | `Static` | `config(text=...)` to update |
|
|
51
|
+
| `Button` | `Button` | `command=fn`; `variant="success"/"error"/...` |
|
|
52
|
+
| `Entry` | `Input` | `textvariable=`, `get()`, `insert()`, `delete()`, `show="*"` |
|
|
53
|
+
| `Text` | `TextArea` | multi-line |
|
|
54
|
+
| `Checkbutton` | `Checkbox` | `variable=BooleanVar`, `command=fn` |
|
|
55
|
+
| `Listbox` | `OptionList` | `values=[...]`, `insert()`, `get_selected()` |
|
|
56
|
+
| `Progressbar` | `ProgressBar` | `maximum=`, `set()`, `step()` |
|
|
57
|
+
|
|
58
|
+
## Variables
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
name = tk.StringVar(root, value="Joshua")
|
|
62
|
+
name.trace_add("write", lambda: print("changed to", name.get()))
|
|
63
|
+
name.set("World") # pushes to any bound Entry + fires traces
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`StringVar`, `IntVar`, and `BooleanVar` bind two-way to `Entry` / `Checkbutton`.
|
|
67
|
+
|
|
68
|
+
## Layout
|
|
69
|
+
|
|
70
|
+
`pack()` is the primary, robust layout manager:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
tk.Label(root, text="Top").pack(side="top", fill="x")
|
|
74
|
+
tk.Button(root, text="Left").pack(side="left", padx=1)
|
|
75
|
+
tk.Entry(root).pack(side="left", fill="x", expand=True)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`grid()` is supported on a best-effort basis (terminal grid semantics differ from pixel grids):
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
grid = tk.Frame(root); grid.pack()
|
|
82
|
+
for i, t in enumerate("789456123"):
|
|
83
|
+
tk.Button(grid, text=t).grid(row=i // 3, column=i % 3)
|
|
84
|
+
tk.Button(grid, text="0").grid(row=3, column=0, columnspan=3)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Timers
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
root.after(1000, lambda: print("once, after 1s"))
|
|
91
|
+
root.every(1000, lambda: clock.config(text=time.strftime("%H:%M:%S")))
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Limitations / honest notes
|
|
95
|
+
|
|
96
|
+
- **`pack` is the robust path; `grid` is best-effort.** For non-trivial layouts, nest `Frame`s (just like idiomatic `tkinter`) rather than mixing `side`s in one frame.
|
|
97
|
+
- Colors and sizes use **Textual semantics** (color names, `"100%"`, `"1fr"`), not pixels.
|
|
98
|
+
- An `Entry` bound to a `textvariable` fires its trace once on mount (Textual emits a `Changed` event when the `Input` first appears). Usually harmless — worth knowing if your trace has side effects.
|
|
99
|
+
- Dynamically creating + packing widgets *after* `mainloop()` has started is best-effort.
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
git clone https://github.com/joshuamaojh/tuikinter
|
|
105
|
+
cd tuikinter
|
|
106
|
+
python -m venv .venv && source .venv/bin/activate
|
|
107
|
+
pip install -e ".[dev]"
|
|
108
|
+
pytest
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Tests drive the app headlessly with Textual's `Pilot` harness.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT © Joshua Mao
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 中文简述
|
|
120
|
+
|
|
121
|
+
`tuikinter` 让你用 **`tkinter` / `PyQt` 那样的命令式写法**来写终端 TUI,底层由 Textual 驱动。会 `tkinter` 就会它:控件名、`pack`/`grid`、`StringVar`/`IntVar`/`BooleanVar`(带 `trace_add`)、`command=` 回调、`config()` / `widget["text"]=...` 都一致。
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import tuikinter as tk
|
|
125
|
+
root = tk.Tk()
|
|
126
|
+
tk.Label(root, text="你好").pack()
|
|
127
|
+
tk.Button(root, text="退出", command=root.destroy).pack()
|
|
128
|
+
root.mainloop()
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
几个要点:`pack` 最稳,复杂布局请像 tkinter 那样嵌套 `Frame`;颜色/尺寸走 Textual 语义(色名、`"100%"`、`"1fr"`),不是像素。
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tuikinter 演示 —— 写法跟 tkinter 几乎一模一样。
|
|
3
|
+
在真终端里运行: python examples/demo.py (q 或点 Quit 退出)
|
|
4
|
+
"""
|
|
5
|
+
import time
|
|
6
|
+
import tuikinter as tk
|
|
7
|
+
|
|
8
|
+
root = tk.Tk()
|
|
9
|
+
root.title("tuikinter demo —— 像 tkinter 一样写 TUI")
|
|
10
|
+
|
|
11
|
+
# ── 顶部: 实时时钟(用 every 周期刷新)──────────────────
|
|
12
|
+
clock = tk.Label(root, text="")
|
|
13
|
+
clock.pack(fill="x", pady=1)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def tick():
|
|
17
|
+
clock.config(text=f" 现在时间 {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
root.every(1000, tick) # 每秒刷新
|
|
21
|
+
tick()
|
|
22
|
+
|
|
23
|
+
# ── 计数器 ───────────────────────────────────
|
|
24
|
+
counter_box = tk.Frame(root)
|
|
25
|
+
counter_box.pack(fill="x", pady=1)
|
|
26
|
+
|
|
27
|
+
count = tk.IntVar(root, value=0)
|
|
28
|
+
count_label = tk.Label(counter_box, text="计数: 0")
|
|
29
|
+
count_label.pack(side="left", padx=2)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def bump(delta):
|
|
33
|
+
count.set(count.get() + delta)
|
|
34
|
+
count_label.config(text=f"计数: {count.get()}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
tk.Button(counter_box, text="-1", variant="error",
|
|
38
|
+
command=lambda: bump(-1)).pack(side="left", padx=1)
|
|
39
|
+
tk.Button(counter_box, text="+1", variant="success",
|
|
40
|
+
command=lambda: bump(+1)).pack(side="left", padx=1)
|
|
41
|
+
|
|
42
|
+
# ── 表单: Entry + StringVar 双向绑定, 实时镜像到下面的 Label ────────
|
|
43
|
+
form = tk.Frame(root)
|
|
44
|
+
form.pack(fill="x", pady=1)
|
|
45
|
+
|
|
46
|
+
tk.Label(form, text="你的名字:").pack(side="left", padx=1)
|
|
47
|
+
name = tk.StringVar(root, value="Joshua")
|
|
48
|
+
tk.Entry(form, textvariable=name, placeholder="在这里输入...").pack(
|
|
49
|
+
side="left", fill="x", expand=True, padx=1)
|
|
50
|
+
|
|
51
|
+
mirror = tk.Label(root, text="")
|
|
52
|
+
mirror.pack(fill="x")
|
|
53
|
+
# trace: 变量一变, 自动更新镜像 Label —— 不用手动刷新
|
|
54
|
+
name.trace_add("write", lambda: mirror.config(text=f" 你好, {name.get()}!"))
|
|
55
|
+
mirror.config(text=f" 你好, {name.get()}!")
|
|
56
|
+
|
|
57
|
+
# ── 勾选框 + 进度条联动 ───────────────────────────
|
|
58
|
+
agree = tk.BooleanVar(root, value=False)
|
|
59
|
+
bar = tk.Progressbar(root, maximum=100)
|
|
60
|
+
bar.pack(fill="x", pady=1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def on_toggle():
|
|
64
|
+
bar.set(100 if agree.get() else 0)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
tk.Checkbutton(root, text="勾上把进度条拉满", variable=agree,
|
|
68
|
+
command=on_toggle).pack()
|
|
69
|
+
|
|
70
|
+
# ── 退出按钮 ────────────────────────────────
|
|
71
|
+
tk.Button(root, text="Quit", command=root.destroy).pack(pady=1)
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
root.mainloop()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tuikinter"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Write terminal UIs with a tkinter/PyQt-style imperative API, powered by Textual."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Joshua Mao" }]
|
|
14
|
+
keywords = ["tui", "terminal", "textual", "tkinter", "ui", "cli", "widgets", "console"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: Software Development :: User Interfaces",
|
|
28
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"textual>=0.40",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=7",
|
|
37
|
+
"pytest-asyncio>=0.21",
|
|
38
|
+
"build",
|
|
39
|
+
"twine",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/joshuamaojh/tuikinter"
|
|
44
|
+
Repository = "https://github.com/joshuamaojh/tuikinter"
|
|
45
|
+
Issues = "https://github.com/joshuamaojh/tuikinter/issues"
|
|
46
|
+
Changelog = "https://github.com/joshuamaojh/tuikinter/blob/main/CHANGELOG.md"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.version]
|
|
49
|
+
path = "src/tuikinter/__init__.py"
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.wheel]
|
|
52
|
+
packages = ["src/tuikinter"]
|
|
53
|
+
|
|
54
|
+
[tool.hatch.build.targets.sdist]
|
|
55
|
+
include = [
|
|
56
|
+
"src",
|
|
57
|
+
"tests",
|
|
58
|
+
"examples",
|
|
59
|
+
"README.md",
|
|
60
|
+
"LICENSE",
|
|
61
|
+
"CHANGELOG.md",
|
|
62
|
+
"pyproject.toml",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
asyncio_mode = "auto"
|
|
67
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tuikinter —— 用 tkinter / PyQt 那样的命令式 API 写终端 TUI,底层由 Textual 驱动。
|
|
3
|
+
|
|
4
|
+
快速上手::
|
|
5
|
+
|
|
6
|
+
import tuikinter as tk
|
|
7
|
+
|
|
8
|
+
root = tk.Tk()
|
|
9
|
+
root.title("Hello")
|
|
10
|
+
|
|
11
|
+
tk.Label(root, text="名字:").pack(side="left")
|
|
12
|
+
name = tk.StringVar(root, value="World")
|
|
13
|
+
tk.Entry(root, textvariable=name).pack(side="left", fill="x", expand=True)
|
|
14
|
+
|
|
15
|
+
tk.Button(root, text="退出", command=root.destroy).pack()
|
|
16
|
+
root.mainloop()
|
|
17
|
+
|
|
18
|
+
更多见 README 与 examples/demo.py。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .core import (
|
|
22
|
+
Tk,
|
|
23
|
+
Frame,
|
|
24
|
+
Label,
|
|
25
|
+
Button,
|
|
26
|
+
Entry,
|
|
27
|
+
Text,
|
|
28
|
+
Checkbutton,
|
|
29
|
+
Listbox,
|
|
30
|
+
Progressbar,
|
|
31
|
+
Widget,
|
|
32
|
+
Variable,
|
|
33
|
+
StringVar,
|
|
34
|
+
IntVar,
|
|
35
|
+
BooleanVar,
|
|
36
|
+
TOP,
|
|
37
|
+
BOTTOM,
|
|
38
|
+
LEFT,
|
|
39
|
+
RIGHT,
|
|
40
|
+
X,
|
|
41
|
+
Y,
|
|
42
|
+
BOTH,
|
|
43
|
+
END,
|
|
44
|
+
NORMAL,
|
|
45
|
+
DISABLED,
|
|
46
|
+
W,
|
|
47
|
+
E,
|
|
48
|
+
N,
|
|
49
|
+
S,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__version__ = "0.1.0"
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
# 根窗口与控件
|
|
56
|
+
"Tk",
|
|
57
|
+
"Frame",
|
|
58
|
+
"Label",
|
|
59
|
+
"Button",
|
|
60
|
+
"Entry",
|
|
61
|
+
"Text",
|
|
62
|
+
"Checkbutton",
|
|
63
|
+
"Listbox",
|
|
64
|
+
"Progressbar",
|
|
65
|
+
"Widget",
|
|
66
|
+
# 变量
|
|
67
|
+
"Variable",
|
|
68
|
+
"StringVar",
|
|
69
|
+
"IntVar",
|
|
70
|
+
"BooleanVar",
|
|
71
|
+
# 常量
|
|
72
|
+
"TOP",
|
|
73
|
+
"BOTTOM",
|
|
74
|
+
"LEFT",
|
|
75
|
+
"RIGHT",
|
|
76
|
+
"X",
|
|
77
|
+
"Y",
|
|
78
|
+
"BOTH",
|
|
79
|
+
"END",
|
|
80
|
+
"NORMAL",
|
|
81
|
+
"DISABLED",
|
|
82
|
+
"W",
|
|
83
|
+
"E",
|
|
84
|
+
"N",
|
|
85
|
+
"S",
|
|
86
|
+
# 元信息
|
|
87
|
+
"__version__",
|
|
88
|
+
]
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tuikinter.core —— 实现主体。
|
|
3
|
+
|
|
4
|
+
设计思路(facade 模式):
|
|
5
|
+
Textual 本身是「声明式 + 异步」的: 你在 App.compose() 里 yield 组件,用 TCSS 写样式,
|
|
6
|
+
用 reactive 管状态。这跟 tkinter「先逐个 new 出控件、pack/grid 布局、最后 mainloop()」
|
|
7
|
+
的命令式手感完全相反。本模块在中间加一层翻译:
|
|
8
|
+
|
|
9
|
+
1. 建树期(mainloop 之前): 每个控件其实是一个「规格代理」, 记录要用哪个 Textual 控件、
|
|
10
|
+
参数、回调、布局意图, 并挂到父节点的 children 上, 形成一棵树。
|
|
11
|
+
2. 运行期(mainloop 时): 动态生成一个 Textual App 子类, 它的 compose() 遍历这棵树、
|
|
12
|
+
把真正的 Textual 控件造出来 yield 出去; 布局意图翻译成容器(Vertical/Horizontal/
|
|
13
|
+
Grid) + styles; 回调通过 Textual 的消息系统路由回你注册的普通函数。
|
|
14
|
+
|
|
15
|
+
这跟 tkinter 背后用 Tcl 命令是同一个套路。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
# ---- tkinter 风格常量 -------------------------------------------------------
|
|
21
|
+
TOP, BOTTOM, LEFT, RIGHT = "top", "bottom", "left", "right"
|
|
22
|
+
X, Y, BOTH = "x", "y", "both"
|
|
23
|
+
END = "end"
|
|
24
|
+
NORMAL, DISABLED = "normal", "disabled"
|
|
25
|
+
W, E, N, S = "w", "e", "n", "s"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---- 布局翻译 ---------------------------------------------------------------
|
|
29
|
+
def _apply_pack_layout(child: "Widget", w, horizontal: bool) -> None:
|
|
30
|
+
"""把一个 pack 进容器的子控件的布局意图翻译成 Textual styles。"""
|
|
31
|
+
lay = child._layout
|
|
32
|
+
padx, pady = lay.get("padx", 0), lay.get("pady", 0)
|
|
33
|
+
if padx or pady:
|
|
34
|
+
w.styles.margin = (pady, padx, pady, padx)
|
|
35
|
+
fill, expand, side = lay.get("fill"), lay.get("expand", False), lay.get("side", TOP)
|
|
36
|
+
if horizontal:
|
|
37
|
+
if fill in (Y, BOTH):
|
|
38
|
+
w.styles.height = "100%"
|
|
39
|
+
if expand or fill == BOTH:
|
|
40
|
+
w.styles.width = "1fr"
|
|
41
|
+
else:
|
|
42
|
+
# 否则缩到内容宽度, 避免某些控件(如 Static)默认抢占 1fr
|
|
43
|
+
w.styles.width = "auto"
|
|
44
|
+
if side == RIGHT:
|
|
45
|
+
w.styles.dock = "right"
|
|
46
|
+
else:
|
|
47
|
+
if fill in (X, BOTH):
|
|
48
|
+
w.styles.width = "100%"
|
|
49
|
+
if expand or fill == BOTH:
|
|
50
|
+
w.styles.height = "1fr"
|
|
51
|
+
else:
|
|
52
|
+
w.styles.height = "auto"
|
|
53
|
+
if side == BOTTOM:
|
|
54
|
+
w.styles.dock = "bottom"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build_container(node, cid: str):
|
|
58
|
+
"""把 node(根或 Frame)的已布局子控件造成一个 Textual 容器。"""
|
|
59
|
+
from textual.containers import Vertical, Horizontal, Grid
|
|
60
|
+
|
|
61
|
+
placed = [c for c in node.children if c._layout is not None]
|
|
62
|
+
use_grid = any(c._layout.get("_mgr") == "grid" for c in placed)
|
|
63
|
+
|
|
64
|
+
if use_grid:
|
|
65
|
+
cols = max((c._layout.get("column", 0) + c._layout.get("columnspan", 1)
|
|
66
|
+
for c in placed), default=1)
|
|
67
|
+
rows = max((c._layout.get("row", 0) + c._layout.get("rowspan", 1)
|
|
68
|
+
for c in placed), default=1)
|
|
69
|
+
from textual.widgets import Static
|
|
70
|
+
cells = [Static("") for _ in range(cols * rows)]
|
|
71
|
+
for c in placed:
|
|
72
|
+
idx = c._layout.get("row", 0) * cols + c._layout.get("column", 0)
|
|
73
|
+
built = c._build()
|
|
74
|
+
if 0 <= idx < len(cells):
|
|
75
|
+
cells[idx] = built
|
|
76
|
+
return Grid(*cells, id=cid)
|
|
77
|
+
|
|
78
|
+
horizontal = any(c._layout.get("side") in (LEFT, RIGHT) for c in placed)
|
|
79
|
+
built = []
|
|
80
|
+
for c in placed:
|
|
81
|
+
w = c._build()
|
|
82
|
+
_apply_pack_layout(c, w, horizontal)
|
|
83
|
+
built.append(w)
|
|
84
|
+
Cont = Horizontal if horizontal else Vertical
|
|
85
|
+
return Cont(*built, id=cid)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _collect_grid_css(node, cid: str, out: list) -> None:
|
|
89
|
+
"""grid 容器的 layout/grid-size 必须走 CSS, 在建 App 前预扫描收集。"""
|
|
90
|
+
placed = [c for c in node.children if c._layout is not None]
|
|
91
|
+
if any(c._layout.get("_mgr") == "grid" for c in placed):
|
|
92
|
+
cols = max((c._layout.get("column", 0) + c._layout.get("columnspan", 1)
|
|
93
|
+
for c in placed), default=1)
|
|
94
|
+
rows = max((c._layout.get("row", 0) + c._layout.get("rowspan", 1)
|
|
95
|
+
for c in placed), default=1)
|
|
96
|
+
out.append(f"#{cid} {{ layout: grid; grid-size: {cols} {rows}; grid-gutter: 0 1; }}")
|
|
97
|
+
for c in placed:
|
|
98
|
+
cs, rs = c._layout.get("columnspan", 1), c._layout.get("rowspan", 1)
|
|
99
|
+
if cs > 1 or rs > 1:
|
|
100
|
+
out.append(f"#{c._cid} {{ column-span: {cs}; row-span: {rs}; }}")
|
|
101
|
+
for c in node.children:
|
|
102
|
+
if isinstance(c, Frame):
|
|
103
|
+
_collect_grid_css(c, c._cid, out)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---- 变量(StringVar / IntVar / BooleanVar)----------------------------------
|
|
107
|
+
class Variable:
|
|
108
|
+
"""tkinter 风格的可观察变量, 支持与 Entry/Checkbutton 双向绑定 + trace 回调。"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, master=None, value=""):
|
|
111
|
+
self._value = value
|
|
112
|
+
self._entries = [] # 绑定的 Entry
|
|
113
|
+
self._checks = [] # 绑定的 Checkbutton
|
|
114
|
+
self._traces = [] # 回调列表
|
|
115
|
+
|
|
116
|
+
def get(self):
|
|
117
|
+
return self._value
|
|
118
|
+
|
|
119
|
+
def set(self, value):
|
|
120
|
+
self._value = value
|
|
121
|
+
for e in self._entries:
|
|
122
|
+
e._set_live_value(value)
|
|
123
|
+
for c in self._checks:
|
|
124
|
+
c._set_live_value(value)
|
|
125
|
+
self._fire()
|
|
126
|
+
|
|
127
|
+
def trace_add(self, mode, callback):
|
|
128
|
+
self._traces.append(callback)
|
|
129
|
+
return callback
|
|
130
|
+
trace = trace_add # 兼容老 tkinter 写法
|
|
131
|
+
|
|
132
|
+
# --- 内部 ---
|
|
133
|
+
def _fire(self):
|
|
134
|
+
for cb in self._traces:
|
|
135
|
+
try:
|
|
136
|
+
cb()
|
|
137
|
+
except TypeError:
|
|
138
|
+
cb(None, None, "write")
|
|
139
|
+
|
|
140
|
+
def _bind_entry(self, e):
|
|
141
|
+
self._entries.append(e)
|
|
142
|
+
|
|
143
|
+
def _bind_check(self, c):
|
|
144
|
+
self._checks.append(c)
|
|
145
|
+
|
|
146
|
+
def _update_from_widget(self, value):
|
|
147
|
+
self._value = value
|
|
148
|
+
self._fire()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class StringVar(Variable):
|
|
152
|
+
def __init__(self, master=None, value=""):
|
|
153
|
+
super().__init__(master, str(value))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class IntVar(Variable):
|
|
157
|
+
def __init__(self, master=None, value=0):
|
|
158
|
+
super().__init__(master, int(value))
|
|
159
|
+
|
|
160
|
+
def _update_from_widget(self, value):
|
|
161
|
+
try:
|
|
162
|
+
self._value = int(value)
|
|
163
|
+
except (ValueError, TypeError):
|
|
164
|
+
self._value = 0
|
|
165
|
+
self._fire()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class BooleanVar(Variable):
|
|
169
|
+
def __init__(self, master=None, value=False):
|
|
170
|
+
super().__init__(master, bool(value))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---- 控件基类 ---------------------------------------------------------------
|
|
174
|
+
class Widget:
|
|
175
|
+
"""所有控件的基类: 负责挂树、分配 id、存参数/布局, 以及运行期把配置转发给真控件。"""
|
|
176
|
+
|
|
177
|
+
def __init__(self, master, **opts):
|
|
178
|
+
if master is None:
|
|
179
|
+
raise ValueError("控件必须有 master(父容器或根窗口)")
|
|
180
|
+
self.master = master
|
|
181
|
+
self.root = master.root
|
|
182
|
+
self.children = []
|
|
183
|
+
master.children.append(self)
|
|
184
|
+
self._opts = dict(opts)
|
|
185
|
+
self._command = opts.get("command")
|
|
186
|
+
self._layout = None # pack/grid 后才有
|
|
187
|
+
self._widget = None # 运行期的真 Textual 控件
|
|
188
|
+
self._suppress = False # 防双向绑定回环
|
|
189
|
+
self._cid = self.root._newid()
|
|
190
|
+
|
|
191
|
+
# --- 布局 ---
|
|
192
|
+
def pack(self, side=TOP, fill=None, expand=False, padx=0, pady=0, **_):
|
|
193
|
+
self._layout = dict(_mgr="pack", side=side, fill=fill,
|
|
194
|
+
expand=expand, padx=padx, pady=pady)
|
|
195
|
+
if self.root._running:
|
|
196
|
+
self._mount_live()
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
def grid(self, row=0, column=0, columnspan=1, rowspan=1,
|
|
200
|
+
sticky=None, padx=0, pady=0, **_):
|
|
201
|
+
self._layout = dict(_mgr="grid", row=row, column=column,
|
|
202
|
+
columnspan=columnspan, rowspan=rowspan,
|
|
203
|
+
sticky=sticky, padx=padx, pady=pady)
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
def place(self, **kw): # 终端没有绝对坐标, 退化成 pack
|
|
207
|
+
return self.pack()
|
|
208
|
+
|
|
209
|
+
# --- 配置(tkinter 的 config / 字典访问)---
|
|
210
|
+
def config(self, **opts):
|
|
211
|
+
for k, v in opts.items():
|
|
212
|
+
self._opts[k] = v
|
|
213
|
+
if k == "command":
|
|
214
|
+
self._command = v
|
|
215
|
+
else:
|
|
216
|
+
self._apply(k, v)
|
|
217
|
+
return self
|
|
218
|
+
configure = config
|
|
219
|
+
|
|
220
|
+
def __setitem__(self, key, value):
|
|
221
|
+
self.config(**{key: value})
|
|
222
|
+
|
|
223
|
+
def __getitem__(self, key):
|
|
224
|
+
return self._opts.get(key)
|
|
225
|
+
|
|
226
|
+
def cget(self, key):
|
|
227
|
+
return self._opts.get(key)
|
|
228
|
+
|
|
229
|
+
# --- 子类可覆写 ---
|
|
230
|
+
def _create_textual(self):
|
|
231
|
+
raise NotImplementedError
|
|
232
|
+
|
|
233
|
+
def _apply_text(self, value):
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
def _apply(self, key, value):
|
|
237
|
+
"""运行期把通用配置转发到真控件的 styles。"""
|
|
238
|
+
w = self._widget
|
|
239
|
+
if key in ("text", "label"):
|
|
240
|
+
self._apply_text(value)
|
|
241
|
+
return
|
|
242
|
+
if w is None:
|
|
243
|
+
return
|
|
244
|
+
if key in ("fg", "foreground", "color"):
|
|
245
|
+
w.styles.color = value
|
|
246
|
+
elif key in ("bg", "background"):
|
|
247
|
+
w.styles.background = value
|
|
248
|
+
elif key == "width":
|
|
249
|
+
w.styles.width = value
|
|
250
|
+
elif key == "height":
|
|
251
|
+
w.styles.height = value
|
|
252
|
+
elif key == "state":
|
|
253
|
+
w.disabled = (value == DISABLED)
|
|
254
|
+
elif key == "disabled":
|
|
255
|
+
w.disabled = bool(value)
|
|
256
|
+
|
|
257
|
+
# --- 内部: 造真控件 + 套上存好的样式 ---
|
|
258
|
+
def _build(self):
|
|
259
|
+
w = self._create_textual()
|
|
260
|
+
self.root._by_id[self._cid] = self
|
|
261
|
+
self._widget = w
|
|
262
|
+
o = self._opts
|
|
263
|
+
for k in ("fg", "foreground", "color", "bg", "background",
|
|
264
|
+
"width", "height", "state", "disabled"):
|
|
265
|
+
if k in o:
|
|
266
|
+
self._apply(k, o[k])
|
|
267
|
+
return w
|
|
268
|
+
|
|
269
|
+
def _mount_live(self):
|
|
270
|
+
"""运行期动态 pack: best-effort 直接挂到父控件下。"""
|
|
271
|
+
try:
|
|
272
|
+
parent = self.master._widget if isinstance(self.master, Widget) \
|
|
273
|
+
else self.root._app.screen
|
|
274
|
+
parent.mount(self._build())
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---- 容器 -------------------------------------------------------------------
|
|
280
|
+
class Frame(Widget):
|
|
281
|
+
"""容器, 对应 Textual 的 Vertical/Horizontal/Grid。子控件 pack/grid 到它里面。"""
|
|
282
|
+
|
|
283
|
+
def _create_textual(self):
|
|
284
|
+
return _build_container(self, self._cid)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---- 文本类 -----------------------------------------------------------------
|
|
288
|
+
class Label(Widget):
|
|
289
|
+
def _create_textual(self):
|
|
290
|
+
from textual.widgets import Static
|
|
291
|
+
return Static(str(self._opts.get("text", "")), id=self._cid)
|
|
292
|
+
|
|
293
|
+
def _apply_text(self, value):
|
|
294
|
+
if self._widget is not None:
|
|
295
|
+
self._widget.update(str(value))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class Button(Widget):
|
|
299
|
+
def _create_textual(self):
|
|
300
|
+
from textual.widgets import Button as TButton
|
|
301
|
+
return TButton(str(self._opts.get("text", "Button")),
|
|
302
|
+
variant=self._opts.get("variant", "default"),
|
|
303
|
+
id=self._cid)
|
|
304
|
+
|
|
305
|
+
def _apply_text(self, value):
|
|
306
|
+
if self._widget is not None:
|
|
307
|
+
self._widget.label = str(value)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class Entry(Widget):
|
|
311
|
+
def _create_textual(self):
|
|
312
|
+
from textual.widgets import Input
|
|
313
|
+
var = self._opts.get("textvariable")
|
|
314
|
+
init = var.get() if var is not None else self._opts.get("text", "")
|
|
315
|
+
w = Input(value=str(init),
|
|
316
|
+
placeholder=self._opts.get("placeholder", ""),
|
|
317
|
+
password=self._opts.get("show") == "*",
|
|
318
|
+
id=self._cid)
|
|
319
|
+
if var is not None:
|
|
320
|
+
var._bind_entry(self)
|
|
321
|
+
return w
|
|
322
|
+
|
|
323
|
+
def get(self):
|
|
324
|
+
if self._widget is not None:
|
|
325
|
+
return self._widget.value
|
|
326
|
+
var = self._opts.get("textvariable")
|
|
327
|
+
return var.get() if var is not None else self._opts.get("text", "")
|
|
328
|
+
|
|
329
|
+
def set(self, value):
|
|
330
|
+
self._opts["text"] = value
|
|
331
|
+
self._set_live_value(value)
|
|
332
|
+
|
|
333
|
+
def insert(self, index, s):
|
|
334
|
+
cur = self.get()
|
|
335
|
+
if index in (END, "end") or (isinstance(index, int) and index >= len(cur)):
|
|
336
|
+
self.set(cur + s)
|
|
337
|
+
else:
|
|
338
|
+
self.set(cur[:index] + s + cur[index:])
|
|
339
|
+
|
|
340
|
+
def delete(self, first, last=None):
|
|
341
|
+
cur = self.get()
|
|
342
|
+
if first in (0, "0") and last in (END, "end", None):
|
|
343
|
+
self.set("")
|
|
344
|
+
else:
|
|
345
|
+
last = len(cur) if last in (END, "end", None) else last
|
|
346
|
+
self.set(cur[:first] + cur[last:])
|
|
347
|
+
|
|
348
|
+
def _set_live_value(self, value):
|
|
349
|
+
if self._widget is not None:
|
|
350
|
+
self._suppress = True
|
|
351
|
+
self._widget.value = str(value)
|
|
352
|
+
self._suppress = False
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class Text(Widget):
|
|
356
|
+
"""多行文本框, 对应 Textual TextArea。"""
|
|
357
|
+
|
|
358
|
+
def _create_textual(self):
|
|
359
|
+
from textual.widgets import TextArea
|
|
360
|
+
return TextArea(text=str(self._opts.get("text", "")), id=self._cid)
|
|
361
|
+
|
|
362
|
+
def get(self, *a):
|
|
363
|
+
return self._widget.text if self._widget is not None else self._opts.get("text", "")
|
|
364
|
+
|
|
365
|
+
def insert(self, index, s):
|
|
366
|
+
if self._widget is not None:
|
|
367
|
+
self._widget.text = self._widget.text + s
|
|
368
|
+
else:
|
|
369
|
+
self._opts["text"] = self._opts.get("text", "") + s
|
|
370
|
+
|
|
371
|
+
def delete(self, *a):
|
|
372
|
+
if self._widget is not None:
|
|
373
|
+
self._widget.text = ""
|
|
374
|
+
else:
|
|
375
|
+
self._opts["text"] = ""
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---- 选择类 -----------------------------------------------------------------
|
|
379
|
+
class Checkbutton(Widget):
|
|
380
|
+
def _create_textual(self):
|
|
381
|
+
from textual.widgets import Checkbox
|
|
382
|
+
var = self._opts.get("variable")
|
|
383
|
+
init = bool(var.get()) if var is not None else bool(self._opts.get("value", False))
|
|
384
|
+
w = Checkbox(str(self._opts.get("text", "")), value=init, id=self._cid)
|
|
385
|
+
if var is not None:
|
|
386
|
+
var._bind_check(self)
|
|
387
|
+
return w
|
|
388
|
+
|
|
389
|
+
def get(self):
|
|
390
|
+
if self._widget is not None:
|
|
391
|
+
return self._widget.value
|
|
392
|
+
var = self._opts.get("variable")
|
|
393
|
+
return bool(var.get()) if var is not None else bool(self._opts.get("value", False))
|
|
394
|
+
|
|
395
|
+
def _set_live_value(self, value):
|
|
396
|
+
if self._widget is not None:
|
|
397
|
+
self._suppress = True
|
|
398
|
+
self._widget.value = bool(value)
|
|
399
|
+
self._suppress = False
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class Listbox(Widget):
|
|
403
|
+
"""列表框, 对应 Textual OptionList。选中项变化触发 command。"""
|
|
404
|
+
|
|
405
|
+
def _create_textual(self):
|
|
406
|
+
from textual.widgets import OptionList
|
|
407
|
+
items = [str(i) for i in self._opts.get("values", [])]
|
|
408
|
+
return OptionList(*items, id=self._cid)
|
|
409
|
+
|
|
410
|
+
def insert(self, index, *items):
|
|
411
|
+
for it in items:
|
|
412
|
+
if self._widget is not None:
|
|
413
|
+
self._widget.add_option(str(it))
|
|
414
|
+
else:
|
|
415
|
+
self._opts.setdefault("values", []).append(it)
|
|
416
|
+
|
|
417
|
+
def get_selected(self):
|
|
418
|
+
if self._widget is not None and self._widget.highlighted is not None:
|
|
419
|
+
return self._widget.get_option_at_index(self._widget.highlighted).prompt
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# ---- 进度条 -----------------------------------------------------------------
|
|
424
|
+
class Progressbar(Widget):
|
|
425
|
+
def _create_textual(self):
|
|
426
|
+
from textual.widgets import ProgressBar
|
|
427
|
+
return ProgressBar(total=self._opts.get("maximum", 100),
|
|
428
|
+
show_eta=False, id=self._cid)
|
|
429
|
+
|
|
430
|
+
def set(self, value):
|
|
431
|
+
self._opts["value"] = value
|
|
432
|
+
if self._widget is not None:
|
|
433
|
+
self._widget.update(progress=value)
|
|
434
|
+
|
|
435
|
+
def step(self, delta=1):
|
|
436
|
+
self.set(self._opts.get("value", 0) + delta)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# ---- 根窗口 -----------------------------------------------------------------
|
|
440
|
+
class Tk:
|
|
441
|
+
"""根窗口, 对应 Textual App。建好整棵控件树后调用 mainloop() 启动。"""
|
|
442
|
+
|
|
443
|
+
def __init__(self, screen_name=None):
|
|
444
|
+
self.root = self
|
|
445
|
+
self.children = []
|
|
446
|
+
self._by_id = {}
|
|
447
|
+
self._counter = 0
|
|
448
|
+
self._title = ""
|
|
449
|
+
self._css = ""
|
|
450
|
+
self._timers = [] # mainloop 前排队的 after/every
|
|
451
|
+
self._app = None
|
|
452
|
+
self._running = False
|
|
453
|
+
|
|
454
|
+
def _newid(self):
|
|
455
|
+
self._counter += 1
|
|
456
|
+
return f"w{self._counter}"
|
|
457
|
+
|
|
458
|
+
# --- tkinter 风格窗口方法 ---
|
|
459
|
+
def title(self, text):
|
|
460
|
+
self._title = text
|
|
461
|
+
if self._app is not None:
|
|
462
|
+
self._app.title = text
|
|
463
|
+
return self
|
|
464
|
+
|
|
465
|
+
def geometry(self, *a): # 终端尺寸固定, 忽略
|
|
466
|
+
return self
|
|
467
|
+
|
|
468
|
+
def after(self, ms, func):
|
|
469
|
+
if self._running and self._app is not None:
|
|
470
|
+
self._app.set_timer(ms / 1000, func)
|
|
471
|
+
else:
|
|
472
|
+
self._timers.append((ms / 1000, func, False))
|
|
473
|
+
|
|
474
|
+
def every(self, ms, func): # 周期定时器(tkinter 要手动重排, 这里直接给个便捷版)
|
|
475
|
+
if self._running and self._app is not None:
|
|
476
|
+
self._app.set_interval(ms / 1000, func)
|
|
477
|
+
else:
|
|
478
|
+
self._timers.append((ms / 1000, func, True))
|
|
479
|
+
|
|
480
|
+
def destroy(self):
|
|
481
|
+
if self._app is not None:
|
|
482
|
+
self._app.exit()
|
|
483
|
+
quit = destroy
|
|
484
|
+
|
|
485
|
+
# --- 生命周期 ---
|
|
486
|
+
def _build_app(self):
|
|
487
|
+
css_lines = []
|
|
488
|
+
_collect_grid_css(self, "_root", css_lines)
|
|
489
|
+
self._css = "\n".join(css_lines)
|
|
490
|
+
return _make_app_class(self)()
|
|
491
|
+
|
|
492
|
+
def mainloop(self):
|
|
493
|
+
self._app = self._build_app()
|
|
494
|
+
self._app.run()
|
|
495
|
+
run = mainloop
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ---- 动态生成 Textual App 子类 ----------------------------------------------
|
|
499
|
+
def _make_app_class(root: Tk):
|
|
500
|
+
from textual.app import App
|
|
501
|
+
from textual.widgets import Button, Input, Checkbox, OptionList
|
|
502
|
+
|
|
503
|
+
class _TkApp(App):
|
|
504
|
+
CSS = root._css
|
|
505
|
+
|
|
506
|
+
def compose(self):
|
|
507
|
+
yield _build_container(root, "_root")
|
|
508
|
+
|
|
509
|
+
def on_mount(self):
|
|
510
|
+
root._running = True
|
|
511
|
+
if root._title:
|
|
512
|
+
self.title = root._title
|
|
513
|
+
for delay, cb, repeat in root._timers:
|
|
514
|
+
(self.set_interval if repeat else self.set_timer)(delay, cb)
|
|
515
|
+
|
|
516
|
+
def on_unmount(self):
|
|
517
|
+
root._running = False
|
|
518
|
+
|
|
519
|
+
# --- 消息 -> 回调路由 ---
|
|
520
|
+
def on_button_pressed(self, event: Button.Pressed):
|
|
521
|
+
w = root._by_id.get(event.button.id)
|
|
522
|
+
if w is not None and w._command is not None:
|
|
523
|
+
w._command()
|
|
524
|
+
|
|
525
|
+
def on_input_changed(self, event: Input.Changed):
|
|
526
|
+
w = root._by_id.get(event.input.id)
|
|
527
|
+
if w is None or getattr(w, "_suppress", False):
|
|
528
|
+
return
|
|
529
|
+
var = w._opts.get("textvariable")
|
|
530
|
+
if var is not None:
|
|
531
|
+
var._update_from_widget(event.value)
|
|
532
|
+
|
|
533
|
+
def on_input_submitted(self, event: Input.Submitted):
|
|
534
|
+
w = root._by_id.get(event.input.id)
|
|
535
|
+
if w is not None and w._command is not None:
|
|
536
|
+
w._command()
|
|
537
|
+
|
|
538
|
+
def on_checkbox_changed(self, event: Checkbox.Changed):
|
|
539
|
+
w = root._by_id.get(event.checkbox.id)
|
|
540
|
+
if w is None:
|
|
541
|
+
return
|
|
542
|
+
var = w._opts.get("variable")
|
|
543
|
+
if var is not None and not getattr(w, "_suppress", False):
|
|
544
|
+
var._update_from_widget(event.value)
|
|
545
|
+
if w._command is not None:
|
|
546
|
+
w._command()
|
|
547
|
+
|
|
548
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected):
|
|
549
|
+
w = root._by_id.get(event.option_list.id)
|
|
550
|
+
if w is not None and w._command is not None:
|
|
551
|
+
w._command()
|
|
552
|
+
|
|
553
|
+
return _TkApp
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""tuikinter 的无头测试 —— 用 Textual 的 Pilot 真跑交互。"""
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
import tuikinter as tk
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _build_form():
|
|
8
|
+
"""搭一套包含各类控件的小表单, 返回 (root, handles)。"""
|
|
9
|
+
root = tk.Tk()
|
|
10
|
+
root.title("test")
|
|
11
|
+
state = {"clicks": 0, "checked": None, "traced": []}
|
|
12
|
+
|
|
13
|
+
lab = tk.Label(root, text="hello")
|
|
14
|
+
lab.pack(pady=1)
|
|
15
|
+
|
|
16
|
+
def on_click():
|
|
17
|
+
state["clicks"] += 1
|
|
18
|
+
lab.config(text=f"clicked {state['clicks']}")
|
|
19
|
+
|
|
20
|
+
btn = tk.Button(root, text="go", command=on_click)
|
|
21
|
+
btn.pack()
|
|
22
|
+
|
|
23
|
+
sv = tk.StringVar(root, value="init")
|
|
24
|
+
sv.trace_add("write", lambda: state["traced"].append(sv.get()))
|
|
25
|
+
ent = tk.Entry(root, textvariable=sv)
|
|
26
|
+
ent.pack(fill="x")
|
|
27
|
+
|
|
28
|
+
bv = tk.BooleanVar(root, value=False)
|
|
29
|
+
|
|
30
|
+
def on_check():
|
|
31
|
+
state["checked"] = bv.get()
|
|
32
|
+
|
|
33
|
+
chk = tk.Checkbutton(root, text="agree", variable=bv, command=on_check)
|
|
34
|
+
chk.pack()
|
|
35
|
+
|
|
36
|
+
pb = tk.Progressbar(root, maximum=100)
|
|
37
|
+
pb.pack(fill="x")
|
|
38
|
+
|
|
39
|
+
return root, dict(lab=lab, btn=btn, ent=ent, sv=sv, bv=bv, chk=chk, pb=pb,
|
|
40
|
+
state=state)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def test_button_command_and_label():
|
|
44
|
+
root, h = _build_form()
|
|
45
|
+
app = root._build_app()
|
|
46
|
+
async with app.run_test(size=(80, 24)) as pilot:
|
|
47
|
+
await pilot.pause()
|
|
48
|
+
await pilot.click(f"#{h['btn']._cid}")
|
|
49
|
+
await pilot.pause(0.3)
|
|
50
|
+
await pilot.click(f"#{h['btn']._cid}")
|
|
51
|
+
await pilot.pause(0.3)
|
|
52
|
+
assert h["state"]["clicks"] == 2
|
|
53
|
+
assert "clicked 2" in str(h["lab"]._widget.render())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def test_stringvar_pushes_to_entry():
|
|
57
|
+
root, h = _build_form()
|
|
58
|
+
app = root._build_app()
|
|
59
|
+
async with app.run_test(size=(80, 24)) as pilot:
|
|
60
|
+
await pilot.pause()
|
|
61
|
+
h["sv"].set("pushed")
|
|
62
|
+
await pilot.pause()
|
|
63
|
+
assert h["ent"]._widget.value == "pushed"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def test_entry_updates_stringvar_and_trace():
|
|
67
|
+
root, h = _build_form()
|
|
68
|
+
app = root._build_app()
|
|
69
|
+
async with app.run_test(size=(80, 24)) as pilot:
|
|
70
|
+
await pilot.pause()
|
|
71
|
+
h["ent"]._widget.focus()
|
|
72
|
+
await pilot.pause()
|
|
73
|
+
h["ent"]._widget.value = ""
|
|
74
|
+
await pilot.press("a", "b", "c")
|
|
75
|
+
await pilot.pause()
|
|
76
|
+
assert h["sv"].get() == "abc"
|
|
77
|
+
assert "abc" in h["state"]["traced"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def test_checkbutton_with_booleanvar():
|
|
81
|
+
root, h = _build_form()
|
|
82
|
+
app = root._build_app()
|
|
83
|
+
async with app.run_test(size=(80, 24)) as pilot:
|
|
84
|
+
await pilot.pause()
|
|
85
|
+
await pilot.click(f"#{h['chk']._cid}")
|
|
86
|
+
await pilot.pause(0.3)
|
|
87
|
+
assert h["bv"].get() is True
|
|
88
|
+
assert h["state"]["checked"] is True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def test_progressbar_set():
|
|
92
|
+
root, h = _build_form()
|
|
93
|
+
app = root._build_app()
|
|
94
|
+
async with app.run_test(size=(80, 24)) as pilot:
|
|
95
|
+
await pilot.pause()
|
|
96
|
+
h["pb"].set(42)
|
|
97
|
+
await pilot.pause()
|
|
98
|
+
assert abs(h["pb"]._widget.progress - 42) < 0.01
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def test_grid_layout_mounts_all():
|
|
102
|
+
root = tk.Tk()
|
|
103
|
+
grid = tk.Frame(root)
|
|
104
|
+
grid.pack(fill="both", expand=True)
|
|
105
|
+
for i, t in enumerate("789456123"):
|
|
106
|
+
tk.Button(grid, text=t).grid(row=i // 3, column=i % 3)
|
|
107
|
+
tk.Button(grid, text="0").grid(row=3, column=0, columnspan=3)
|
|
108
|
+
|
|
109
|
+
app = root._build_app()
|
|
110
|
+
async with app.run_test(size=(60, 20)) as pilot:
|
|
111
|
+
await pilot.pause()
|
|
112
|
+
from textual.widgets import Button as TButton
|
|
113
|
+
assert len(app.query(TButton)) == 10
|
|
114
|
+
# grid-size 应被算成 3 列 4 行
|
|
115
|
+
assert "grid-size: 3 4" in app.CSS
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_package_metadata():
|
|
119
|
+
assert tk.__version__ == "0.1.0"
|
|
120
|
+
for name in ("Tk", "Frame", "Label", "Button", "Entry", "StringVar"):
|
|
121
|
+
assert name in tk.__all__
|
|
122
|
+
assert hasattr(tk, name)
|