aliyun-controller 0.2.0__tar.gz → 0.2.2__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.
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/PKG-INFO +6 -5
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/README.md +5 -4
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/__init__.py +1 -1
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/app.py +5 -0
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/app.tcss +40 -30
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/screens/billing.py +3 -3
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/screens/dns.py +7 -7
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/screens/home.py +18 -19
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/screens/settings.py +29 -26
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/screens/setup.py +8 -2
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/ui.py +63 -35
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/widgets.py +238 -10
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/PKG-INFO +6 -5
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/pyproject.toml +1 -1
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/billing.py +0 -0
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/client.py +0 -0
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/config.py +0 -0
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/dns.py +0 -0
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/main.py +0 -0
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/screens/__init__.py +0 -0
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/SOURCES.txt +0 -0
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/dependency_links.txt +0 -0
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/entry_points.txt +0 -0
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/requires.txt +0 -0
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/top_level.txt +0 -0
- {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aliyun_controller
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: A Textual TUI for managing Alibaba Cloud services including billing queries and DNS management
|
|
5
5
|
Author-email: Moha-Master <hongkongreporter@outlook.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -47,21 +47,22 @@ Requires-Dist: pytest-asyncio; extra == "dev"
|
|
|
47
47
|
| 场景 | 快捷键 | 作用 |
|
|
48
48
|
|---|---|---|
|
|
49
49
|
| **全局** | `Tab` | 切换焦点 |
|
|
50
|
-
| | `Ctrl+Q` |
|
|
50
|
+
| | `Ctrl+Q` | 任意界面直接退出程序 |
|
|
51
51
|
| | `Home` / `End` / `PgUp` / `PgDn` | 页面滚动到顶 / 底 / 翻页(焦点在文本框时自动让位给文本框) |
|
|
52
52
|
| **主菜单** | `1` / `2` / `3` / `4` / `5` | 直达流量查询 / 账单归纳 / DNS 管理 / 设置 / 退出 |
|
|
53
53
|
| | `↑` / `↓` · `回车` | 移动光标 · 确认 |
|
|
54
|
-
| | `
|
|
54
|
+
| | `Esc` / `Ctrl+C` | 退出程序 |
|
|
55
55
|
| **功能页** | `Ctrl+R` | 刷新数据 |
|
|
56
|
-
| | `Esc` | 返回上一级(折叠菜单打开时优先关闭菜单) |
|
|
56
|
+
| | `Esc` / `Ctrl+C` | 返回上一级(折叠菜单打开时优先关闭菜单) |
|
|
57
57
|
| | `↑` / `↓` · `回车` | 表格内移动光标 · 操作所选行 |
|
|
58
58
|
| | `/` | 聚焦搜索框(实时模糊过滤) |
|
|
59
59
|
| | `←` / `→` | 月份选择器切换账期(焦点在选择器上时) |
|
|
60
60
|
| **DNS 记录页** | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除(作用于当前行) |
|
|
61
|
-
| **弹窗** | `Esc` | 取消并关闭 |
|
|
61
|
+
| **弹窗** | `Esc` / `Ctrl+C` | 取消并关闭 |
|
|
62
62
|
| | `回车` | 提交表单或确认 |
|
|
63
63
|
|
|
64
64
|
> 鼠标全程可用:按钮 / 选项 / 列表行一律**单击即执行**(表格行单击即进入下一级或打开编辑),滚轮滚动光标所在的滚动区域。
|
|
65
|
+
> 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
|
|
65
66
|
|
|
66
67
|
## 安装指南
|
|
67
68
|
|
|
@@ -19,21 +19,22 @@
|
|
|
19
19
|
| 场景 | 快捷键 | 作用 |
|
|
20
20
|
|---|---|---|
|
|
21
21
|
| **全局** | `Tab` | 切换焦点 |
|
|
22
|
-
| | `Ctrl+Q` |
|
|
22
|
+
| | `Ctrl+Q` | 任意界面直接退出程序 |
|
|
23
23
|
| | `Home` / `End` / `PgUp` / `PgDn` | 页面滚动到顶 / 底 / 翻页(焦点在文本框时自动让位给文本框) |
|
|
24
24
|
| **主菜单** | `1` / `2` / `3` / `4` / `5` | 直达流量查询 / 账单归纳 / DNS 管理 / 设置 / 退出 |
|
|
25
25
|
| | `↑` / `↓` · `回车` | 移动光标 · 确认 |
|
|
26
|
-
| | `
|
|
26
|
+
| | `Esc` / `Ctrl+C` | 退出程序 |
|
|
27
27
|
| **功能页** | `Ctrl+R` | 刷新数据 |
|
|
28
|
-
| | `Esc` | 返回上一级(折叠菜单打开时优先关闭菜单) |
|
|
28
|
+
| | `Esc` / `Ctrl+C` | 返回上一级(折叠菜单打开时优先关闭菜单) |
|
|
29
29
|
| | `↑` / `↓` · `回车` | 表格内移动光标 · 操作所选行 |
|
|
30
30
|
| | `/` | 聚焦搜索框(实时模糊过滤) |
|
|
31
31
|
| | `←` / `→` | 月份选择器切换账期(焦点在选择器上时) |
|
|
32
32
|
| **DNS 记录页** | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除(作用于当前行) |
|
|
33
|
-
| **弹窗** | `Esc` | 取消并关闭 |
|
|
33
|
+
| **弹窗** | `Esc` / `Ctrl+C` | 取消并关闭 |
|
|
34
34
|
| | `回车` | 提交表单或确认 |
|
|
35
35
|
|
|
36
36
|
> 鼠标全程可用:按钮 / 选项 / 列表行一律**单击即执行**(表格行单击即进入下一级或打开编辑),滚轮滚动光标所在的滚动区域。
|
|
37
|
+
> 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
|
|
37
38
|
|
|
38
39
|
## 安装指南
|
|
39
40
|
|
|
@@ -3,6 +3,7 @@ from pathlib import Path
|
|
|
3
3
|
|
|
4
4
|
from textual import work
|
|
5
5
|
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
6
7
|
from textual.widgets import Static
|
|
7
8
|
|
|
8
9
|
from .billing import AliCloudBssQuerier
|
|
@@ -21,6 +22,10 @@ class AliyunControllerApp(App):
|
|
|
21
22
|
TITLE = "Aliyun Controller"
|
|
22
23
|
ENABLE_COMMAND_PALETTE = False
|
|
23
24
|
|
|
25
|
+
BINDINGS = [
|
|
26
|
+
Binding("ctrl+q", "quit", "退出", priority=True),
|
|
27
|
+
]
|
|
28
|
+
|
|
24
29
|
def __init__(self) -> None:
|
|
25
30
|
super().__init__()
|
|
26
31
|
self.config: dict | None = None
|
|
@@ -4,6 +4,23 @@ Screen {
|
|
|
4
4
|
background: $surface;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
/* ---------- 按钮:全局单行(替代 compact,无边框;三项目一致) ---------- */
|
|
8
|
+
Button {
|
|
9
|
+
height: 1;
|
|
10
|
+
border: none;
|
|
11
|
+
min-width: 0;
|
|
12
|
+
padding: 0 1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
Button:focus {
|
|
16
|
+
border: none;
|
|
17
|
+
text-style: bold reverse;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Button:disabled {
|
|
21
|
+
text-opacity: 60%;
|
|
22
|
+
}
|
|
23
|
+
|
|
7
24
|
/* ---------- 模态三段式:标题栏 / 内容 / 按钮栏 ---------- */
|
|
8
25
|
.modal-box {
|
|
9
26
|
layout: vertical;
|
|
@@ -40,7 +57,7 @@ Screen {
|
|
|
40
57
|
|
|
41
58
|
/* ---------- 页面公共 ---------- */
|
|
42
59
|
.page-hint {
|
|
43
|
-
height:
|
|
60
|
+
height: 1;
|
|
44
61
|
padding: 0 1;
|
|
45
62
|
color: $text-muted;
|
|
46
63
|
}
|
|
@@ -141,7 +158,7 @@ Screen {
|
|
|
141
158
|
}
|
|
142
159
|
|
|
143
160
|
.tbl:focus {
|
|
144
|
-
border:
|
|
161
|
+
border: round $primary;
|
|
145
162
|
}
|
|
146
163
|
|
|
147
164
|
/* ---------- 信息面板 ---------- */
|
|
@@ -159,11 +176,17 @@ Screen {
|
|
|
159
176
|
}
|
|
160
177
|
|
|
161
178
|
#home-col {
|
|
162
|
-
width:
|
|
179
|
+
width: auto;
|
|
180
|
+
max-width: 90%;
|
|
163
181
|
height: auto;
|
|
164
182
|
}
|
|
165
183
|
|
|
166
|
-
#home-banner
|
|
184
|
+
#home-banner {
|
|
185
|
+
width: 1fr;
|
|
186
|
+
text-align: left;
|
|
187
|
+
color: $primary;
|
|
188
|
+
}
|
|
189
|
+
|
|
167
190
|
#home-plain {
|
|
168
191
|
width: 1fr;
|
|
169
192
|
text-align: center;
|
|
@@ -184,56 +207,43 @@ Screen {
|
|
|
184
207
|
background: transparent;
|
|
185
208
|
}
|
|
186
209
|
|
|
187
|
-
|
|
210
|
+
.logo-page #home-plain {
|
|
188
211
|
display: none;
|
|
189
212
|
}
|
|
190
213
|
|
|
191
|
-
|
|
214
|
+
.logo-page.-sm #home-banner {
|
|
192
215
|
display: none;
|
|
193
216
|
}
|
|
194
217
|
|
|
195
|
-
|
|
218
|
+
.logo-page.-sm #home-plain {
|
|
196
219
|
display: block;
|
|
197
220
|
}
|
|
198
221
|
|
|
199
|
-
|
|
222
|
+
.logo-page.-sm #home-col {
|
|
200
223
|
width: 1fr;
|
|
201
224
|
padding: 0 2;
|
|
202
225
|
}
|
|
203
226
|
|
|
204
|
-
/* ----------
|
|
205
|
-
|
|
206
|
-
height:
|
|
227
|
+
/* ---------- 设置页:kv 条目(litellm 式容器) ---------- */
|
|
228
|
+
.kv-row {
|
|
229
|
+
height: 1;
|
|
207
230
|
layout: horizontal;
|
|
208
|
-
padding: 0 1;
|
|
209
|
-
margin-bottom: 1;
|
|
210
|
-
pointer: pointer;
|
|
211
231
|
}
|
|
212
232
|
|
|
213
|
-
|
|
214
|
-
|
|
233
|
+
.kv-row.gap-top {
|
|
234
|
+
margin-top: 1;
|
|
215
235
|
}
|
|
216
236
|
|
|
217
|
-
|
|
237
|
+
.kv-label {
|
|
218
238
|
width: 18;
|
|
219
239
|
color: $text-muted;
|
|
220
|
-
content-align:
|
|
221
|
-
margin-right: 2;
|
|
240
|
+
content-align: left middle;
|
|
222
241
|
}
|
|
223
242
|
|
|
224
|
-
|
|
243
|
+
.kv-value {
|
|
225
244
|
width: 1fr;
|
|
226
245
|
content-align: left middle;
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
SettingsScreen .set-edit-hint {
|
|
230
|
-
width: auto;
|
|
231
|
-
color: $text-muted;
|
|
232
|
-
content-align: right middle;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
SettingsScreen .set-row:hover .set-edit-hint {
|
|
236
|
-
color: $primary;
|
|
246
|
+
text-style: bold;
|
|
237
247
|
}
|
|
238
248
|
|
|
239
249
|
/* ---------- 底部状态栏 ---------- */
|
|
@@ -16,7 +16,7 @@ FREE_QUOTA_GB = 20.0
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def _cycle_hint() -> str:
|
|
19
|
-
return "◀▶ 切月 · Ctrl+R 刷新 · Esc 返回 · 滚轮浏览"
|
|
19
|
+
return "◀▶ 切月 · Ctrl+R 刷新 · Esc/Ctrl+C 返回 · 滚轮浏览"
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
class _BillingScreen(PageScreen):
|
|
@@ -92,7 +92,7 @@ class TrafficScreen(_BillingScreen):
|
|
|
92
92
|
yield QuotaBar(free=FREE_QUOTA_GB, id="tr-bar")
|
|
93
93
|
with Horizontal(classes="cap-row"):
|
|
94
94
|
yield Static("", classes="page-hint", id="tr-cap")
|
|
95
|
-
yield Button("刷新", id="bl-refresh", variant="primary"
|
|
95
|
+
yield Button("刷新", id="bl-refresh", variant="primary")
|
|
96
96
|
table = make_table("产品名称", "计费项", "用量", "折算 GB", "类别")
|
|
97
97
|
table.id = "tr-table"
|
|
98
98
|
yield table
|
|
@@ -137,7 +137,7 @@ class SummaryScreen(_BillingScreen):
|
|
|
137
137
|
with Vertical(classes="panel"):
|
|
138
138
|
yield Static("", id="sum-total")
|
|
139
139
|
with Horizontal(classes="cap-row"):
|
|
140
|
-
yield Button("刷新", id="bl-refresh", variant="primary"
|
|
140
|
+
yield Button("刷新", id="bl-refresh", variant="primary")
|
|
141
141
|
table = make_table("产品名称", "产品代码", "账单条数", "总金额 (元)")
|
|
142
142
|
table.id = "sum-table"
|
|
143
143
|
yield table
|
|
@@ -45,7 +45,7 @@ class DomainListScreen(PageScreen):
|
|
|
45
45
|
"""域名列表,回车进入该域名的解析记录。"""
|
|
46
46
|
|
|
47
47
|
TITLE = "DNS 解析管理"
|
|
48
|
-
HINT = "↑↓ 移动 · 回车 管理解析记录 · / 搜索 · Ctrl+R 刷新 · Esc 返回"
|
|
48
|
+
HINT = "↑↓ 移动 · 回车 管理解析记录 · / 搜索 · Ctrl+R 刷新 · Esc/Ctrl+C 返回"
|
|
49
49
|
|
|
50
50
|
BINDINGS = [Binding("/", "focus_search", "搜索", show=False)]
|
|
51
51
|
|
|
@@ -60,7 +60,7 @@ class DomainListScreen(PageScreen):
|
|
|
60
60
|
with Horizontal(classes="filter-row"):
|
|
61
61
|
yield Static("搜索", classes="fl-label")
|
|
62
62
|
yield Input(placeholder="按域名筛选(/ 聚焦)", compact=True, id="dm-search")
|
|
63
|
-
yield Button("刷新", id="dm-refresh"
|
|
63
|
+
yield Button("刷新", id="dm-refresh")
|
|
64
64
|
table = make_table("域名", "记录数")
|
|
65
65
|
table.id = "dm-table"
|
|
66
66
|
yield table
|
|
@@ -197,7 +197,7 @@ class RecordListScreen(PageScreen):
|
|
|
197
197
|
"""某域名的解析记录列表,支持筛选、排序、增删改与启停。"""
|
|
198
198
|
|
|
199
199
|
TITLE = "解析记录"
|
|
200
|
-
HINT = "单击/回车 编辑 · Ctrl+N 新增 · Ctrl+E 编辑 · Ctrl+D 删除 · Ctrl+R 刷新 · Esc 返回"
|
|
200
|
+
HINT = "单击/回车 编辑 · Ctrl+N 新增 · Ctrl+E 编辑 · Ctrl+D 删除 · Ctrl+R 刷新 · Esc/Ctrl+C 返回"
|
|
201
201
|
|
|
202
202
|
BINDINGS = [
|
|
203
203
|
Binding("/", "focus_search", "搜索", show=False),
|
|
@@ -220,7 +220,7 @@ class RecordListScreen(PageScreen):
|
|
|
220
220
|
with Horizontal(classes="filter-row"):
|
|
221
221
|
yield Static("搜索", classes="fl-label")
|
|
222
222
|
yield Input(placeholder="按主机记录 / 记录值筛选(/ 聚焦)", compact=True, id="rc-search")
|
|
223
|
-
yield Button("刷新", id="rc-refresh"
|
|
223
|
+
yield Button("刷新", id="rc-refresh")
|
|
224
224
|
with Horizontal(classes="filter-row gap-top"):
|
|
225
225
|
yield Static("排序", classes="fl-label")
|
|
226
226
|
yield Select(
|
|
@@ -232,9 +232,9 @@ class RecordListScreen(PageScreen):
|
|
|
232
232
|
value=0, allow_blank=False, compact=True, id="sort-order",
|
|
233
233
|
)
|
|
234
234
|
yield Static(classes="fill")
|
|
235
|
-
yield Button("+ 新增", id="rc-add", variant="primary"
|
|
236
|
-
yield Button("编辑", id="rc-edit"
|
|
237
|
-
yield Button("删除", id="rc-del", variant="error"
|
|
235
|
+
yield Button("+ 新增", id="rc-add", variant="primary")
|
|
236
|
+
yield Button("编辑", id="rc-edit")
|
|
237
|
+
yield Button("删除", id="rc-del", variant="error")
|
|
238
238
|
table = make_table("主机记录", "类型", "记录值", "TTL", "状态")
|
|
239
239
|
table.id = "rc-table"
|
|
240
240
|
yield table
|
|
@@ -7,19 +7,18 @@ from textual.screen import Screen
|
|
|
7
7
|
from textual.widgets import OptionList, Static
|
|
8
8
|
|
|
9
9
|
from .. import __version__
|
|
10
|
-
from ..ui import LOGO_WIDTH, logo_text
|
|
11
|
-
|
|
12
|
-
try: # textual 各版本 Option 导出位置不同
|
|
13
|
-
from textual.widgets.option_list import Option
|
|
14
|
-
except ImportError: # pragma: no cover
|
|
15
|
-
from textual.widgets._option_list import Option
|
|
10
|
+
from ..ui import LOGO_WIDTH, home_col_width, logo_text
|
|
11
|
+
from ..widgets import HintBar, MenuMarquee
|
|
16
12
|
|
|
17
13
|
|
|
18
14
|
class HomeScreen(Screen):
|
|
19
15
|
"""功能主菜单:流量 / 账单 / DNS / 设置 / 退出。"""
|
|
20
16
|
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
super().__init__(classes="logo-page")
|
|
19
|
+
|
|
21
20
|
BINDINGS = [
|
|
22
|
-
Binding("
|
|
21
|
+
Binding("escape,ctrl+c", "menu('quit')", "退出"),
|
|
23
22
|
Binding("1", "menu('traffic')", "流量", show=False),
|
|
24
23
|
Binding("2", "menu('billing')", "账单", show=False),
|
|
25
24
|
Binding("3", "menu('dns')", "DNS", show=False),
|
|
@@ -42,29 +41,29 @@ class HomeScreen(Screen):
|
|
|
42
41
|
yield Static(Text("Aliyun Controller", style="bold"), id="home-plain")
|
|
43
42
|
yield Static(f"v{__version__}", id="home-version")
|
|
44
43
|
yield OptionList(id="home-list")
|
|
45
|
-
yield
|
|
44
|
+
yield HintBar("↑↓ 选择 · 回车 进入 · 1-5 直达 · Esc/Ctrl+C 退出", classes="page-hint")
|
|
46
45
|
|
|
47
46
|
def on_mount(self) -> None:
|
|
48
47
|
ol = self.query_one("#home-list", OptionList)
|
|
49
|
-
|
|
48
|
+
self._marquee = MenuMarquee(
|
|
49
|
+
self, "home-list", [(label, desc) for _, label, desc in self.ITEMS]
|
|
50
|
+
)
|
|
51
|
+
ol.add_options(self._marquee.placeholder_options())
|
|
50
52
|
ol.highlighted = 0
|
|
51
53
|
ol.focus()
|
|
52
54
|
self._apply_breakpoint()
|
|
55
|
+
self.call_after_refresh(self._marquee.start)
|
|
53
56
|
|
|
54
57
|
def on_resize(self, event) -> None:
|
|
55
58
|
self._apply_breakpoint()
|
|
59
|
+
if getattr(self, "_marquee", None) is not None:
|
|
60
|
+
self._marquee.sync()
|
|
56
61
|
|
|
57
62
|
def _apply_breakpoint(self) -> None:
|
|
58
|
-
"""
|
|
59
|
-
self.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
for i, (_, label, desc) in enumerate(self.ITEMS, start=1):
|
|
63
|
-
t = Text()
|
|
64
|
-
t.append(f" {i} ", style="bold")
|
|
65
|
-
t.append(f"{label:<14}", style="bold")
|
|
66
|
-
t.append(desc, style="dim")
|
|
67
|
-
yield Option(t)
|
|
63
|
+
"""宽度不足以容纳艺术字时降级为普通标题文本;列宽跟随 logo。"""
|
|
64
|
+
small = self.size.width < LOGO_WIDTH + 6
|
|
65
|
+
self.set_class(small, "-sm")
|
|
66
|
+
self.query_one("#home-col").styles.width = None if small else home_col_width()
|
|
68
67
|
|
|
69
68
|
def _open(self, key: str) -> None:
|
|
70
69
|
from .billing import SummaryScreen, TrafficScreen
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""设置屏:单容器展示配置内容,容器右下角「编辑」按钮进入弹窗(litellm 同款)。"""
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
2
4
|
from rich.text import Text
|
|
3
|
-
from textual import on
|
|
5
|
+
from textual import on, work
|
|
4
6
|
from textual.containers import Horizontal, Vertical
|
|
5
7
|
from textual.widgets import Button, Static
|
|
6
8
|
|
|
@@ -13,14 +15,13 @@ FIELDS = [
|
|
|
13
15
|
("access_key_id", "AccessKey ID", False),
|
|
14
16
|
("access_key_secret", "AccessKey Secret", True),
|
|
15
17
|
]
|
|
16
|
-
_LABEL = {k: lab for k, lab, _ in FIELDS}
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
class SettingsScreen(PageScreen):
|
|
20
21
|
"""阿里云访问密钥设置。"""
|
|
21
22
|
|
|
22
23
|
TITLE = "设置"
|
|
23
|
-
HINT = "
|
|
24
|
+
HINT = "「编辑访问密钥」修改 · Tab 轮切 · 回车 保存 · Esc/Ctrl+C 返回"
|
|
24
25
|
|
|
25
26
|
def __init__(self) -> None:
|
|
26
27
|
super().__init__()
|
|
@@ -28,32 +29,42 @@ class SettingsScreen(PageScreen):
|
|
|
28
29
|
|
|
29
30
|
def compose_page(self):
|
|
30
31
|
with Vertical(classes="panel"):
|
|
31
|
-
with Horizontal(classes="
|
|
32
|
-
yield Static("配置文件", classes="
|
|
33
|
-
yield Static("", classes="
|
|
32
|
+
with Horizontal(classes="kv-row"):
|
|
33
|
+
yield Static("配置文件", classes="kv-label")
|
|
34
|
+
yield Static("", classes="kv-value", id="st-path")
|
|
34
35
|
for key, label, _secret in FIELDS:
|
|
35
|
-
with Horizontal(classes="
|
|
36
|
-
yield Static(label, classes="
|
|
37
|
-
yield Static("", classes="
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
with Horizontal(classes="kv-row gap-top"):
|
|
37
|
+
yield Static(label, classes="kv-label")
|
|
38
|
+
yield Static("", classes="kv-value", id=f"view-{key}")
|
|
39
|
+
with Horizontal(classes="kv-row gap-top"):
|
|
40
|
+
yield Static("更新方式", classes="kv-label")
|
|
41
|
+
yield Static("更改后立即生效,无需重启", classes="kv-value", id="st-note")
|
|
42
|
+
with Horizontal(classes="filter-row gap-top"):
|
|
43
|
+
yield Static(classes="fill")
|
|
44
|
+
yield Button("编辑访问密钥", id="edit", variant="primary")
|
|
42
45
|
|
|
43
46
|
def on_mount(self) -> None:
|
|
47
|
+
self.reload_page()
|
|
48
|
+
self.query_one("#edit", Button).focus()
|
|
49
|
+
|
|
50
|
+
def reload_page(self) -> None:
|
|
51
|
+
self._load()
|
|
52
|
+
|
|
53
|
+
@work(exclusive=True)
|
|
54
|
+
async def _load(self) -> None:
|
|
44
55
|
try:
|
|
45
|
-
|
|
56
|
+
cfg = await asyncio.to_thread(load_config)
|
|
46
57
|
except Exception: # noqa: BLE001 — 配置损坏时以 App 内存态兜底
|
|
47
|
-
|
|
58
|
+
cfg = self.app.config or {}
|
|
59
|
+
self._config = cfg
|
|
48
60
|
self._refresh()
|
|
49
|
-
self.query_one("#edit", Button).focus()
|
|
50
61
|
|
|
51
62
|
def _refresh(self) -> None:
|
|
52
63
|
self.query_one("#st-path", Static).update(Text(str(get_config_path()), style=STYLE_DIM))
|
|
53
64
|
for key, _, _ in FIELDS:
|
|
54
65
|
self.query_one(f"#view-{key}", Static).update(mask_key(self._config.get(key, "")))
|
|
55
66
|
|
|
56
|
-
# ------------------------------------------------------------
|
|
67
|
+
# ------------------------------------------------------------ 编辑弹窗
|
|
57
68
|
|
|
58
69
|
def _open_editor(self) -> None:
|
|
59
70
|
fields = [
|
|
@@ -89,11 +100,3 @@ class SettingsScreen(PageScreen):
|
|
|
89
100
|
@on(Button.Pressed, "#edit")
|
|
90
101
|
def _on_edit(self) -> None:
|
|
91
102
|
self._open_editor()
|
|
92
|
-
|
|
93
|
-
def on_click(self, event) -> None:
|
|
94
|
-
"""点击任一密钥条目行进入编辑弹窗(父类 PageScreen.on_click 仍会被派发)。"""
|
|
95
|
-
for key, _, _ in FIELDS:
|
|
96
|
-
row = self.query_one(f"#row-{key}")
|
|
97
|
-
if row.region.contains(event.screen_x, event.screen_y):
|
|
98
|
-
self._open_editor()
|
|
99
|
-
return
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""首启 / 重置配置向导:采集阿里云 RAM 访问密钥并写盘。"""
|
|
2
2
|
from rich.text import Text
|
|
3
3
|
from textual import work
|
|
4
|
+
from textual.binding import Binding
|
|
4
5
|
from textual.containers import Vertical
|
|
5
6
|
from textual.screen import ModalScreen
|
|
6
7
|
from textual.widgets import Static
|
|
7
8
|
|
|
8
9
|
from ..config import save_config
|
|
9
|
-
from ..widgets import FormField, FormModal
|
|
10
|
+
from ..widgets import FormField, FormModal, HintBar
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def mask_key(key: str) -> str:
|
|
@@ -25,6 +26,8 @@ class SetupWizardScreen(ModalScreen[bool]):
|
|
|
25
26
|
SetupWizardScreen > .sw-intro { width: 90; height: auto; }
|
|
26
27
|
"""
|
|
27
28
|
|
|
29
|
+
BINDINGS = [Binding("escape,ctrl+c", "cancel", show=False)]
|
|
30
|
+
|
|
28
31
|
def __init__(self, reason: str = "", config_missing: bool = True) -> None:
|
|
29
32
|
super().__init__()
|
|
30
33
|
self.reason = reason
|
|
@@ -37,11 +40,14 @@ class SetupWizardScreen(ModalScreen[bool]):
|
|
|
37
40
|
yield Static(Text(title, style="bold"), classes="modal-title")
|
|
38
41
|
with Vertical(classes="modal-main"):
|
|
39
42
|
yield Static(body, id="sw-msg")
|
|
40
|
-
yield
|
|
43
|
+
yield HintBar("Esc/Ctrl+C 取消向导并退出程序", classes="page-hint")
|
|
41
44
|
|
|
42
45
|
def on_mount(self) -> None:
|
|
43
46
|
self._flow()
|
|
44
47
|
|
|
48
|
+
def action_cancel(self) -> None:
|
|
49
|
+
self.dismiss(False)
|
|
50
|
+
|
|
45
51
|
@work(exclusive=True)
|
|
46
52
|
async def _flow(self) -> None:
|
|
47
53
|
fields = [
|
|
@@ -8,6 +8,7 @@ import datetime
|
|
|
8
8
|
from collections.abc import Callable
|
|
9
9
|
from typing import ClassVar
|
|
10
10
|
|
|
11
|
+
from rich.cells import cell_len
|
|
11
12
|
from rich.text import Text
|
|
12
13
|
from textual import on
|
|
13
14
|
from textual.binding import Binding
|
|
@@ -15,31 +16,36 @@ from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
|
15
16
|
from textual.message import Message
|
|
16
17
|
from textual.reactive import reactive
|
|
17
18
|
from textual.screen import Screen
|
|
18
|
-
from textual.widgets import Button, OptionList, Static
|
|
19
|
+
from textual.widgets import Button, Input, OptionList, Static, TextArea
|
|
19
20
|
|
|
20
21
|
try: # 不同 textual 版本 Option 导出位置不同
|
|
21
22
|
from textual.widgets.option_list import Option
|
|
22
23
|
except ImportError: # pragma: no cover
|
|
23
24
|
from textual.widgets._option_list import Option
|
|
24
25
|
|
|
25
|
-
from .widgets import STYLE_ERR
|
|
26
|
+
from .widgets import EDIT_HINT, STYLE_ERR, HintBar
|
|
26
27
|
|
|
27
28
|
# ---------------------------------------------------------------- 首页 logo
|
|
28
29
|
|
|
29
30
|
LOGO_LINES = [
|
|
30
|
-
" █████╗ ██╗ ██╗██╗ ██╗██╗ ██╗███╗ ██╗",
|
|
31
|
-
"██╔══██╗██║ ██║╚██╗ ██╔╝██║ ██║████╗
|
|
32
|
-
"███████║██║ ██║ ╚████╔╝ ██║ ██║██╔██╗ ██║",
|
|
33
|
-
"██╔══██║██║ ██║ ╚██╔╝ ██║
|
|
34
|
-
"██║ ██║███████╗██║ ██║ ╚██████╔╝██║
|
|
35
|
-
"╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝",
|
|
31
|
+
" █████╗ ██╗ ██╗██╗ ██╗██╗ ██╗███╗ ██╗ ██████╗████████╗██╗ ",
|
|
32
|
+
"██╔══██╗██║ ██║╚██╗ ██╔╝██║ ██║████╗ ██║██╔════╝╚══██╔══╝██║ ",
|
|
33
|
+
"███████║██║ ██║ ╚████╔╝ ██║ ██║██╔██╗ ██║██║ ██║ ██║ ",
|
|
34
|
+
"██╔══██║██║ ██║ ╚██╔╝ ██║ ██║██║╚██╗██║██║ ██║ ██║ ",
|
|
35
|
+
"██║ ██║███████╗██║ ██║ ╚██████╔╝██║ ╚████║╚██████╗ ██║ ███████╗",
|
|
36
|
+
"╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚══════╝",
|
|
36
37
|
]
|
|
37
38
|
|
|
38
|
-
LOGO_WIDTH = max(
|
|
39
|
+
LOGO_WIDTH = max(cell_len(line.rstrip()) for line in LOGO_LINES) # 随 LOGO_LINES 自动计算,换 logo 无需改别处
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
def logo_text() -> Text:
|
|
42
|
-
return Text("\n".join(LOGO_LINES))
|
|
43
|
+
return Text("\n".join(line.rstrip() for line in LOGO_LINES))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def home_col_width() -> int:
|
|
47
|
+
"""首页栏列宽:跟随 logo,菜单文字更宽时以 40 列兜底。"""
|
|
48
|
+
return max(LOGO_WIDTH, 40)
|
|
43
49
|
|
|
44
50
|
|
|
45
51
|
# ---------------------------------------------------------------- 月份工具
|
|
@@ -86,10 +92,8 @@ class MonthPicker(Horizontal):
|
|
|
86
92
|
layers: base overlay;
|
|
87
93
|
}
|
|
88
94
|
MonthPicker > Button {
|
|
89
|
-
width:
|
|
90
|
-
min-width:
|
|
91
|
-
padding: 0;
|
|
92
|
-
height: 1;
|
|
95
|
+
width: 5;
|
|
96
|
+
min-width: 5;
|
|
93
97
|
}
|
|
94
98
|
MonthPicker > #mp-cur {
|
|
95
99
|
width: 12;
|
|
@@ -112,10 +116,8 @@ class MonthPicker(Horizontal):
|
|
|
112
116
|
layout: horizontal;
|
|
113
117
|
}
|
|
114
118
|
MonthPicker #mp-years Button {
|
|
115
|
-
width:
|
|
116
|
-
min-width:
|
|
117
|
-
padding: 0;
|
|
118
|
-
height: 1;
|
|
119
|
+
width: 5;
|
|
120
|
+
min-width: 5;
|
|
119
121
|
}
|
|
120
122
|
MonthPicker #mp-years .mp-year {
|
|
121
123
|
width: auto;
|
|
@@ -132,10 +134,8 @@ class MonthPicker(Horizontal):
|
|
|
132
134
|
layout: horizontal;
|
|
133
135
|
}
|
|
134
136
|
MonthPicker .mp-m {
|
|
135
|
-
width:
|
|
136
|
-
min-width:
|
|
137
|
-
height: 1;
|
|
138
|
-
padding: 0;
|
|
137
|
+
width: 6;
|
|
138
|
+
min-width: 6;
|
|
139
139
|
margin: 0;
|
|
140
140
|
}
|
|
141
141
|
"""
|
|
@@ -167,19 +167,19 @@ class MonthPicker(Horizontal):
|
|
|
167
167
|
self._popup_open = False
|
|
168
168
|
|
|
169
169
|
def compose(self):
|
|
170
|
-
yield Button("◀", id="mp-prev"
|
|
171
|
-
yield Button(self.value, id="mp-cur"
|
|
172
|
-
yield Button("▶", id="mp-next"
|
|
170
|
+
yield Button("◀", id="mp-prev")
|
|
171
|
+
yield Button(self.value, id="mp-cur")
|
|
172
|
+
yield Button("▶", id="mp-next")
|
|
173
173
|
with Vertical(id="mp-pop"):
|
|
174
174
|
with Horizontal(id="mp-years"):
|
|
175
|
-
yield Button("◀", id="mp-yprev"
|
|
175
|
+
yield Button("◀", id="mp-yprev")
|
|
176
176
|
yield Static(str(self._popup_year), classes="mp-year", id="mp-year-label")
|
|
177
|
-
yield Button("▶", id="mp-ynext"
|
|
177
|
+
yield Button("▶", id="mp-ynext")
|
|
178
178
|
with Vertical(id="mp-grid"):
|
|
179
179
|
for r in range(3):
|
|
180
180
|
with Horizontal(classes="mp-mrow"):
|
|
181
181
|
for m in range(r * 4 + 1, r * 4 + 5):
|
|
182
|
-
yield Button(f"{m:02d}", id=f"mp-m{m:02d}", classes="mp-m"
|
|
182
|
+
yield Button(f"{m:02d}", id=f"mp-m{m:02d}", classes="mp-m")
|
|
183
183
|
|
|
184
184
|
def watch_value(self, value: str) -> None:
|
|
185
185
|
if self.is_mounted:
|
|
@@ -332,10 +332,10 @@ class PageScreen(Screen):
|
|
|
332
332
|
|
|
333
333
|
TITLE: ClassVar[str] = ""
|
|
334
334
|
SUBTITLE: ClassVar[str] = ""
|
|
335
|
-
HINT: ClassVar[str] = "↑↓ 移动 · 回车 执行 · Ctrl+R 刷新 · Esc 返回"
|
|
335
|
+
HINT: ClassVar[str] = "↑↓ 移动 · 回车 执行 · Ctrl+R 刷新 · Esc/Ctrl+C 返回"
|
|
336
336
|
|
|
337
337
|
BINDINGS = [
|
|
338
|
-
Binding("escape", "page_back", "返回"),
|
|
338
|
+
Binding("escape,ctrl+c", "page_back", "返回"),
|
|
339
339
|
Binding("ctrl+r", "refresh_page", "刷新"),
|
|
340
340
|
Binding("home", "page_top", "顶部", show=False),
|
|
341
341
|
Binding("end", "page_bottom", "底部", show=False),
|
|
@@ -355,7 +355,8 @@ class PageScreen(Screen):
|
|
|
355
355
|
background: $panel;
|
|
356
356
|
layout: horizontal;
|
|
357
357
|
}
|
|
358
|
-
PageScreen #topbar
|
|
358
|
+
PageScreen #topbar #top-back,
|
|
359
|
+
PageScreen #topbar #top-menu {
|
|
359
360
|
margin-right: 2;
|
|
360
361
|
}
|
|
361
362
|
PageScreen .tb-left {
|
|
@@ -390,7 +391,7 @@ class PageScreen(Screen):
|
|
|
390
391
|
overflow-y: hidden;
|
|
391
392
|
}
|
|
392
393
|
PageScreen .page-hint {
|
|
393
|
-
height:
|
|
394
|
+
height: 1;
|
|
394
395
|
padding: 0 1;
|
|
395
396
|
color: $text-muted;
|
|
396
397
|
background: $surface;
|
|
@@ -423,17 +424,17 @@ class PageScreen(Screen):
|
|
|
423
424
|
|
|
424
425
|
def compose(self):
|
|
425
426
|
with Horizontal(id="topbar"):
|
|
426
|
-
yield Button("◀ 返回", id="top-back"
|
|
427
|
+
yield Button("◀ 返回", id="top-back")
|
|
427
428
|
with Horizontal(classes="tb-left"):
|
|
428
429
|
yield Static(Text(self.TITLE, style="bold"), classes="tb-title")
|
|
429
430
|
yield Static(self.subtitle, classes="tb-sub", id="tb-sub")
|
|
430
431
|
with Horizontal(classes="tb-right"):
|
|
431
432
|
yield from self.compose_toolbar()
|
|
432
433
|
if self.menu:
|
|
433
|
-
yield Button("菜单 ▾", id="top-menu"
|
|
434
|
+
yield Button("菜单 ▾", id="top-menu")
|
|
434
435
|
with Vertical(id="page"):
|
|
435
436
|
yield from self.compose_page()
|
|
436
|
-
yield
|
|
437
|
+
yield HintBar(self.HINT, classes="page-hint", id="page-hint")
|
|
437
438
|
if self.menu:
|
|
438
439
|
with Vertical(id="page-menu"):
|
|
439
440
|
yield OptionList(id="menu-list")
|
|
@@ -542,6 +543,33 @@ class PageScreen(Screen):
|
|
|
542
543
|
def reload_page(self) -> None:
|
|
543
544
|
"""子类覆写:重新取数。"""
|
|
544
545
|
|
|
546
|
+
# ------------------------------------------------------------ 文本编辑模式
|
|
547
|
+
|
|
548
|
+
def on_descendant_focus(self, event) -> None:
|
|
549
|
+
self._refresh_hint()
|
|
550
|
+
|
|
551
|
+
def on_descendant_blur(self, event) -> None:
|
|
552
|
+
self._refresh_hint()
|
|
553
|
+
|
|
554
|
+
def _editing(self) -> bool:
|
|
555
|
+
return isinstance(self.app.focused, (Input, TextArea))
|
|
556
|
+
|
|
557
|
+
def _refresh_hint(self) -> None:
|
|
558
|
+
"""焦点进出输入框时,在页面 HINT 与编辑键位 HINT 间切换。"""
|
|
559
|
+
if not self.is_mounted:
|
|
560
|
+
return
|
|
561
|
+
try:
|
|
562
|
+
hint = self.query_one("#page-hint", HintBar)
|
|
563
|
+
except Exception: # noqa: BLE001 — 组合早期尚未挂载
|
|
564
|
+
return
|
|
565
|
+
hint.update(EDIT_HINT if self._editing() else self.HINT)
|
|
566
|
+
|
|
567
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
568
|
+
"""文本编辑模式下禁用页面刷新(Ctrl+R),其余交给控件原生键位。"""
|
|
569
|
+
if action == "refresh_page" and self._editing():
|
|
570
|
+
return False
|
|
571
|
+
return True
|
|
572
|
+
|
|
545
573
|
def _scroller(self):
|
|
546
574
|
"""Home/End/PgUp/PgDn 目标:优先内容区内的显式滚动容器(表格滚动走其自身键位)。"""
|
|
547
575
|
page = self.query_one("#page", Vertical)
|
|
@@ -11,10 +11,15 @@ from rich.cells import cell_len
|
|
|
11
11
|
from rich.text import Text
|
|
12
12
|
from textual import events
|
|
13
13
|
from textual.binding import Binding
|
|
14
|
-
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
14
|
+
from textual.containers import Horizontal, ScrollableContainer, Vertical, VerticalScroll
|
|
15
15
|
from textual.coordinate import Coordinate
|
|
16
16
|
from textual.screen import ModalScreen
|
|
17
|
-
from textual.widgets import Button, DataTable, Input, Label, Select, Static, Switch
|
|
17
|
+
from textual.widgets import Button, DataTable, Input, Label, OptionList, Select, Static, Switch, TextArea
|
|
18
|
+
|
|
19
|
+
try: # textual 各版本 Option 导出位置不同
|
|
20
|
+
from textual.widgets.option_list import Option
|
|
21
|
+
except ImportError: # pragma: no cover
|
|
22
|
+
from textual.widgets._option_list import Option
|
|
18
23
|
|
|
19
24
|
# ---------------------------------------------------------------- 文案与配色
|
|
20
25
|
|
|
@@ -44,6 +49,11 @@ def colored_text(value: str, *, err: bool = False, warn: bool = False, dim: bool
|
|
|
44
49
|
|
|
45
50
|
HINT_PICK = "输入筛选 · ↑↓ 移动 · 回车 确认 · Esc 取消"
|
|
46
51
|
HINT_MENU = "↑↓ 移动 · 回车 确认 · Ctrl+Q 退出"
|
|
52
|
+
# 文本编辑模式(焦点在 Input / TextArea):GUI 风编辑键位,输入框聚焦时替换页面 HINT
|
|
53
|
+
HINT_FORM = "Tab 轮切字段 · Enter 提交 · Esc/Ctrl+C 取消"
|
|
54
|
+
EDIT_HINT = "Home/End 行首/行尾 · Ctrl+Shift+A 全选 · Ctrl+X/C/V 剪切/复制/粘贴 · Esc/Ctrl+C 返回"
|
|
55
|
+
HINT_FORM_EDIT = "Home/End 行首/行尾 · Ctrl+Shift+A 全选 · Ctrl+X/C/V 剪切/复制/粘贴 · Enter 提交 · Esc/Ctrl+C 取消"
|
|
56
|
+
HINT_FORM_TA = "Home/End 行首/行尾 · Ctrl+Shift+A 全选 · Ctrl+X/C/V 剪切/复制/粘贴 · Ctrl+Z/Y 撤销/重做 · Esc/Ctrl+C 取消"
|
|
47
57
|
|
|
48
58
|
|
|
49
59
|
# ---------------------------------------------------------------- 模糊匹配
|
|
@@ -91,7 +101,7 @@ class ConfirmModal(ModalScreen[bool]):
|
|
|
91
101
|
ConfirmModal .cf-msg { height: auto; }
|
|
92
102
|
"""
|
|
93
103
|
|
|
94
|
-
BINDINGS = [Binding("escape", "cancel", show=False)]
|
|
104
|
+
BINDINGS = [Binding("escape,ctrl+c", "cancel", show=False)]
|
|
95
105
|
|
|
96
106
|
def __init__(self, message, title="请确认", *, yes="确认", no="取消", default_yes=True):
|
|
97
107
|
super().__init__()
|
|
@@ -137,7 +147,7 @@ class InputModal(ModalScreen[str | None]):
|
|
|
137
147
|
InputModal .im-input { margin-bottom: 1; }
|
|
138
148
|
"""
|
|
139
149
|
|
|
140
|
-
BINDINGS = [Binding("escape", "cancel", show=False)]
|
|
150
|
+
BINDINGS = [Binding("escape,ctrl+c", "cancel", show=False)]
|
|
141
151
|
|
|
142
152
|
def __init__(
|
|
143
153
|
self,
|
|
@@ -166,12 +176,26 @@ class InputModal(ModalScreen[str | None]):
|
|
|
166
176
|
if self.hint_text:
|
|
167
177
|
yield Static(self.hint_text, classes="im-hint")
|
|
168
178
|
yield Static("", classes="im-error", id="im-error")
|
|
179
|
+
yield HintBar("", classes="page-hint", id="im-hint")
|
|
169
180
|
with Horizontal(classes="btn-row"):
|
|
170
181
|
yield Button("取消", id="im-cancel", variant="default")
|
|
171
182
|
yield Button("确定", id="im-ok", variant="primary")
|
|
172
183
|
|
|
173
184
|
def on_mount(self) -> None:
|
|
174
185
|
self.query_one("#im-input", Input).focus()
|
|
186
|
+
self._refresh_hint()
|
|
187
|
+
|
|
188
|
+
def on_descendant_focus(self, event) -> None:
|
|
189
|
+
self._refresh_hint()
|
|
190
|
+
|
|
191
|
+
def on_descendant_blur(self, event) -> None:
|
|
192
|
+
self._refresh_hint()
|
|
193
|
+
|
|
194
|
+
def _refresh_hint(self) -> None:
|
|
195
|
+
if not self.is_mounted:
|
|
196
|
+
return
|
|
197
|
+
editing = isinstance(self.app.focused, (Input, TextArea))
|
|
198
|
+
self.query_one("#im-hint", HintBar).update(HINT_FORM_EDIT if editing else HINT_FORM)
|
|
175
199
|
|
|
176
200
|
def _submit(self) -> None:
|
|
177
201
|
val = self.query_one("#im-input", Input).value.strip()
|
|
@@ -227,7 +251,7 @@ class FormModal(ModalScreen["dict | None"]):
|
|
|
227
251
|
FormModal .frm-pad { height: 1; }
|
|
228
252
|
"""
|
|
229
253
|
|
|
230
|
-
BINDINGS = [Binding("escape", "cancel", show=False)]
|
|
254
|
+
BINDINGS = [Binding("escape,ctrl+c", "cancel", show=False)]
|
|
231
255
|
|
|
232
256
|
def __init__(self, title: str, fields: list, *, message: str = "", ok_label="保存", extra_buttons=None):
|
|
233
257
|
super().__init__()
|
|
@@ -256,7 +280,7 @@ class FormModal(ModalScreen["dict | None"]):
|
|
|
256
280
|
yield Input(
|
|
257
281
|
value=f.value if f.kind == "text" else "",
|
|
258
282
|
password=(f.kind == "password"),
|
|
259
|
-
placeholder=(f.value if f.kind == "password" else f.placeholder),
|
|
283
|
+
placeholder=(mask_secret(f.value) if f.kind == "password" else f.placeholder),
|
|
260
284
|
compact=True,
|
|
261
285
|
id=f"in-{f.name}",
|
|
262
286
|
)
|
|
@@ -276,6 +300,7 @@ class FormModal(ModalScreen["dict | None"]):
|
|
|
276
300
|
if f.hint:
|
|
277
301
|
yield Static(f.hint, classes="frm-note")
|
|
278
302
|
yield Static("", classes="frm-err", id="frm-error")
|
|
303
|
+
yield HintBar("", classes="page-hint", id="frm-hint")
|
|
279
304
|
with Horizontal(classes="btn-row"):
|
|
280
305
|
yield Button("取消", id="frm-cancel", variant="default")
|
|
281
306
|
yield Button(self.ok_label, id="frm-ok", variant="primary")
|
|
@@ -286,6 +311,25 @@ class FormModal(ModalScreen["dict | None"]):
|
|
|
286
311
|
first_input = self.query(Input)
|
|
287
312
|
if first_input:
|
|
288
313
|
first_input.first().focus()
|
|
314
|
+
self._refresh_hint()
|
|
315
|
+
|
|
316
|
+
def on_descendant_focus(self, event) -> None:
|
|
317
|
+
self._refresh_hint()
|
|
318
|
+
|
|
319
|
+
def on_descendant_blur(self, event) -> None:
|
|
320
|
+
self._refresh_hint()
|
|
321
|
+
|
|
322
|
+
def _refresh_hint(self) -> None:
|
|
323
|
+
if not self.is_mounted:
|
|
324
|
+
return
|
|
325
|
+
focused = self.app.focused
|
|
326
|
+
if isinstance(focused, TextArea):
|
|
327
|
+
text = HINT_FORM_TA
|
|
328
|
+
elif isinstance(focused, Input):
|
|
329
|
+
text = HINT_FORM_EDIT
|
|
330
|
+
else:
|
|
331
|
+
text = HINT_FORM
|
|
332
|
+
self.query_one("#frm-hint", HintBar).update(text)
|
|
289
333
|
|
|
290
334
|
def _collect(self) -> "dict | None":
|
|
291
335
|
"""收集表单值;校验失败时写入错误信息并返回 None。可在子类扩展。"""
|
|
@@ -349,7 +393,7 @@ class OutputModal(ModalScreen[None]):
|
|
|
349
393
|
"""
|
|
350
394
|
|
|
351
395
|
BINDINGS = [
|
|
352
|
-
Binding("escape", "close", show=False),
|
|
396
|
+
Binding("escape,ctrl+c", "close", show=False),
|
|
353
397
|
Binding("enter", "close", show=False),
|
|
354
398
|
]
|
|
355
399
|
|
|
@@ -375,6 +419,180 @@ class OutputModal(ModalScreen[None]):
|
|
|
375
419
|
|
|
376
420
|
# ---------------------------------------------------------------- 表格辅助
|
|
377
421
|
|
|
422
|
+
class HintBar(ScrollableContainer):
|
|
423
|
+
"""单行操作提示栏:内容宽于容器时,像 LED 屏一样来回滚动(端点停留约 3s)。"""
|
|
424
|
+
|
|
425
|
+
DEFAULT_CSS = """
|
|
426
|
+
HintBar {
|
|
427
|
+
height: 1;
|
|
428
|
+
overflow-x: auto;
|
|
429
|
+
overflow-y: hidden;
|
|
430
|
+
scrollbar-size: 0 0;
|
|
431
|
+
}
|
|
432
|
+
HintBar .hint-text {
|
|
433
|
+
width: auto;
|
|
434
|
+
height: 1;
|
|
435
|
+
}
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
TICK = 0.25
|
|
439
|
+
HOLD = 12 # 到达端点后停留的拍数(12 x 0.25s = 3s)
|
|
440
|
+
|
|
441
|
+
def __init__(self, text: str = "", **kwargs) -> None:
|
|
442
|
+
super().__init__(**kwargs)
|
|
443
|
+
self._text = text
|
|
444
|
+
self._direction = 1
|
|
445
|
+
self._hold = 0
|
|
446
|
+
|
|
447
|
+
def compose(self):
|
|
448
|
+
yield Static(Text(self._text, no_wrap=True), classes="hint-text")
|
|
449
|
+
|
|
450
|
+
def on_mount(self) -> None:
|
|
451
|
+
self.set_interval(self.TICK, self._tick)
|
|
452
|
+
|
|
453
|
+
def update(self, text: str) -> None:
|
|
454
|
+
self._text = text
|
|
455
|
+
self._direction = 1
|
|
456
|
+
self._hold = 0
|
|
457
|
+
if self.is_mounted:
|
|
458
|
+
self.query_one(".hint-text", Static).update(Text(text, no_wrap=True))
|
|
459
|
+
self.scroll_x = 0
|
|
460
|
+
|
|
461
|
+
def _tick(self) -> None:
|
|
462
|
+
total = self.virtual_size.width
|
|
463
|
+
view = self.size.width
|
|
464
|
+
if total <= view:
|
|
465
|
+
if self.scroll_x:
|
|
466
|
+
self.scroll_x = 0
|
|
467
|
+
return
|
|
468
|
+
if self._hold > 0:
|
|
469
|
+
self._hold -= 1
|
|
470
|
+
return
|
|
471
|
+
end = total - view
|
|
472
|
+
x = int(self.scroll_x) + self._direction
|
|
473
|
+
if x >= end:
|
|
474
|
+
x = end
|
|
475
|
+
self._direction = -1
|
|
476
|
+
self._hold = self.HOLD
|
|
477
|
+
elif x <= 0:
|
|
478
|
+
x = 0
|
|
479
|
+
self._direction = 1
|
|
480
|
+
self._hold = self.HOLD
|
|
481
|
+
self.scroll_x = x
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def marquee_window(text: str, width: int, offset: int) -> str:
|
|
485
|
+
"""在 width 单元格内取 text 的循环窗口,起点为第 offset 个字符(跑马灯用)。"""
|
|
486
|
+
if width <= 0 or not text:
|
|
487
|
+
return " " * max(0, width)
|
|
488
|
+
s = text + " "
|
|
489
|
+
n = len(s)
|
|
490
|
+
used = 0
|
|
491
|
+
out: list[str] = []
|
|
492
|
+
i = offset % n
|
|
493
|
+
while used < width and len(out) <= n + width:
|
|
494
|
+
ch = s[i % n]
|
|
495
|
+
w = cell_len(ch) or 1
|
|
496
|
+
if used + w > width:
|
|
497
|
+
break
|
|
498
|
+
out.append(ch)
|
|
499
|
+
used += w
|
|
500
|
+
i += 1
|
|
501
|
+
return "".join(out) + " " * (width - used)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class MenuMarquee:
|
|
505
|
+
"""首页 OptionList 菜单行控制器:描述右对齐,超宽时来回跑马灯。
|
|
506
|
+
|
|
507
|
+
屏幕 on_mount 用法::
|
|
508
|
+
|
|
509
|
+
self._marquee = MenuMarquee(self, "home-list", [(label, desc), ...])
|
|
510
|
+
ol.add_options([Option("") for _ in self._marquee.rows])
|
|
511
|
+
self.call_after_refresh(self._marquee.start)
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
TICK = 0.25
|
|
515
|
+
HOLD = 12
|
|
516
|
+
|
|
517
|
+
def __init__(self, screen, list_id: str, rows, *, label_width: int = 14) -> None:
|
|
518
|
+
self.screen = screen
|
|
519
|
+
self.list = screen.query_one(f"#{list_id}", OptionList)
|
|
520
|
+
self.rows = list(rows)
|
|
521
|
+
self.label_width = label_width
|
|
522
|
+
self.offset = 0
|
|
523
|
+
self.direction = 1
|
|
524
|
+
self.hold = 0
|
|
525
|
+
self._timer = None
|
|
526
|
+
|
|
527
|
+
def placeholder_options(self):
|
|
528
|
+
"""与 rows 等长的占位 Option(先 add_options,再由 sync 填充文本)。"""
|
|
529
|
+
return [Option("") for _ in self.rows]
|
|
530
|
+
|
|
531
|
+
def start(self) -> None:
|
|
532
|
+
self.sync()
|
|
533
|
+
if self._timer is None:
|
|
534
|
+
self._timer = self.screen.set_interval(self.TICK, self._tick)
|
|
535
|
+
|
|
536
|
+
def set_rows(self, rows) -> None:
|
|
537
|
+
self.rows = list(rows)
|
|
538
|
+
self.offset = 0
|
|
539
|
+
self.direction = 1
|
|
540
|
+
self.hold = 0
|
|
541
|
+
self.sync()
|
|
542
|
+
|
|
543
|
+
def sync(self) -> None:
|
|
544
|
+
node = self.list
|
|
545
|
+
if not node.is_mounted or node.option_count != len(self.rows):
|
|
546
|
+
return
|
|
547
|
+
for idx in range(len(self.rows)):
|
|
548
|
+
node.replace_option_prompt_at_index(idx, self._prompt(idx))
|
|
549
|
+
|
|
550
|
+
def _room(self) -> int:
|
|
551
|
+
node = self.list
|
|
552
|
+
width = node.content_size.width
|
|
553
|
+
if node.show_vertical_scrollbar:
|
|
554
|
+
width -= node.scrollbar_size_vertical
|
|
555
|
+
return max(6, width - self.label_width - 4)
|
|
556
|
+
|
|
557
|
+
def _prompt(self, idx: int) -> Text:
|
|
558
|
+
label, desc = self.rows[idx]
|
|
559
|
+
label = shorten(label, self.label_width)
|
|
560
|
+
pad = " " * max(0, self.label_width - cell_len(label))
|
|
561
|
+
prefix = f" {idx + 1:<2} {label}{pad}"
|
|
562
|
+
room = self._room()
|
|
563
|
+
if cell_len(desc) <= room:
|
|
564
|
+
window = " " * (room - cell_len(desc)) + desc
|
|
565
|
+
else:
|
|
566
|
+
window = marquee_window(desc, room, self.offset)
|
|
567
|
+
text = Text(no_wrap=True, overflow="crop")
|
|
568
|
+
text.append(prefix, style="bold")
|
|
569
|
+
text.append(window, style="dim")
|
|
570
|
+
return text
|
|
571
|
+
|
|
572
|
+
def _tick(self) -> None:
|
|
573
|
+
room = self._room()
|
|
574
|
+
longest = max((cell_len(d) for _, d in self.rows), default=0)
|
|
575
|
+
if longest <= room:
|
|
576
|
+
if self.offset:
|
|
577
|
+
self.offset = 0
|
|
578
|
+
self.sync()
|
|
579
|
+
return
|
|
580
|
+
if self.hold > 0:
|
|
581
|
+
self.hold -= 1
|
|
582
|
+
return
|
|
583
|
+
span = max(cell_len(d) + 3 for _, d in self.rows if cell_len(d) > room)
|
|
584
|
+
self.offset += self.direction
|
|
585
|
+
if self.offset >= span:
|
|
586
|
+
self.offset = span - 1
|
|
587
|
+
self.direction = -1
|
|
588
|
+
self.hold = self.HOLD
|
|
589
|
+
elif self.offset <= 0:
|
|
590
|
+
self.offset = 0
|
|
591
|
+
self.direction = 1
|
|
592
|
+
self.hold = self.HOLD
|
|
593
|
+
self.sync()
|
|
594
|
+
|
|
595
|
+
|
|
378
596
|
class ClickTable(DataTable):
|
|
379
597
|
"""列表即操作的表格:单击数据单元格 = 移动光标并立即发 RowSelected。
|
|
380
598
|
|
|
@@ -400,8 +618,8 @@ class ClickTable(DataTable):
|
|
|
400
618
|
event.prevent_default()
|
|
401
619
|
|
|
402
620
|
|
|
403
|
-
def make_table(*headers, cursor: str = "row", zebra: bool = True, classes: str = "tbl") -> ClickTable:
|
|
404
|
-
dt = ClickTable(cursor_type=cursor, zebra_stripes=zebra, classes=classes)
|
|
621
|
+
def make_table(*headers, cursor: str = "row", zebra: bool = True, classes: str = "tbl", **kwargs) -> ClickTable:
|
|
622
|
+
dt = ClickTable(cursor_type=cursor, zebra_stripes=zebra, classes=classes, **kwargs)
|
|
405
623
|
dt.add_columns(*headers)
|
|
406
624
|
return dt
|
|
407
625
|
|
|
@@ -427,6 +645,16 @@ def shorten(text: str, limit: int) -> str:
|
|
|
427
645
|
return out + "…"
|
|
428
646
|
|
|
429
647
|
|
|
648
|
+
def mask_secret(v: str | None, keep: int = 4) -> str:
|
|
649
|
+
"""密钥/口令脱敏展示:首尾各 keep 位,过短则全圆点(placeholder 专用)。"""
|
|
650
|
+
s = (v or "").strip()
|
|
651
|
+
if not s:
|
|
652
|
+
return ""
|
|
653
|
+
if len(s) <= keep * 2 + 1:
|
|
654
|
+
return "•" * min(8, max(6, len(s)))
|
|
655
|
+
return f"{s[:keep]}…{s[-keep:]}"
|
|
656
|
+
|
|
657
|
+
|
|
430
658
|
def rcell(text, content_width: int) -> Text:
|
|
431
659
|
"""右对齐单元格:文本在 content_width 内右对齐。"""
|
|
432
660
|
pad = max(0, content_width - cell_len(str(text)))
|
|
@@ -466,5 +694,5 @@ __all__ = [
|
|
|
466
694
|
"shorten", "rcell", "fit_table_columns",
|
|
467
695
|
"tint", "colored_text",
|
|
468
696
|
"STYLE_OK", "STYLE_ERR", "STYLE_WARN", "STYLE_INFO", "STYLE_DIM",
|
|
469
|
-
"HINT_PICK", "HINT_MENU",
|
|
697
|
+
"HINT_PICK", "HINT_MENU", "HINT_FORM", "EDIT_HINT", "HINT_FORM_EDIT", "HINT_FORM_TA",
|
|
470
698
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aliyun_controller
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: A Textual TUI for managing Alibaba Cloud services including billing queries and DNS management
|
|
5
5
|
Author-email: Moha-Master <hongkongreporter@outlook.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -47,21 +47,22 @@ Requires-Dist: pytest-asyncio; extra == "dev"
|
|
|
47
47
|
| 场景 | 快捷键 | 作用 |
|
|
48
48
|
|---|---|---|
|
|
49
49
|
| **全局** | `Tab` | 切换焦点 |
|
|
50
|
-
| | `Ctrl+Q` |
|
|
50
|
+
| | `Ctrl+Q` | 任意界面直接退出程序 |
|
|
51
51
|
| | `Home` / `End` / `PgUp` / `PgDn` | 页面滚动到顶 / 底 / 翻页(焦点在文本框时自动让位给文本框) |
|
|
52
52
|
| **主菜单** | `1` / `2` / `3` / `4` / `5` | 直达流量查询 / 账单归纳 / DNS 管理 / 设置 / 退出 |
|
|
53
53
|
| | `↑` / `↓` · `回车` | 移动光标 · 确认 |
|
|
54
|
-
| | `
|
|
54
|
+
| | `Esc` / `Ctrl+C` | 退出程序 |
|
|
55
55
|
| **功能页** | `Ctrl+R` | 刷新数据 |
|
|
56
|
-
| | `Esc` | 返回上一级(折叠菜单打开时优先关闭菜单) |
|
|
56
|
+
| | `Esc` / `Ctrl+C` | 返回上一级(折叠菜单打开时优先关闭菜单) |
|
|
57
57
|
| | `↑` / `↓` · `回车` | 表格内移动光标 · 操作所选行 |
|
|
58
58
|
| | `/` | 聚焦搜索框(实时模糊过滤) |
|
|
59
59
|
| | `←` / `→` | 月份选择器切换账期(焦点在选择器上时) |
|
|
60
60
|
| **DNS 记录页** | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除(作用于当前行) |
|
|
61
|
-
| **弹窗** | `Esc` | 取消并关闭 |
|
|
61
|
+
| **弹窗** | `Esc` / `Ctrl+C` | 取消并关闭 |
|
|
62
62
|
| | `回车` | 提交表单或确认 |
|
|
63
63
|
|
|
64
64
|
> 鼠标全程可用:按钮 / 选项 / 列表行一律**单击即执行**(表格行单击即进入下一级或打开编辑),滚轮滚动光标所在的滚动区域。
|
|
65
|
+
> 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
|
|
65
66
|
|
|
66
67
|
## 安装指南
|
|
67
68
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "aliyun_controller"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "A Textual TUI for managing Alibaba Cloud services including billing queries and DNS management"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|