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.
@@ -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
@@ -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.
@@ -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)