aliyun-controller 0.2.2__tar.gz → 0.2.4__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.2 → aliyun_controller-0.2.4}/PKG-INFO +1 -4
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/README.md +0 -3
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/__init__.py +1 -1
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/app.py +66 -3
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/screens/billing.py +8 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/screens/dns.py +34 -14
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/ui.py +14 -2
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/widgets.py +229 -8
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/PKG-INFO +1 -4
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/pyproject.toml +1 -1
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/app.tcss +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/billing.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/client.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/config.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/dns.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/main.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/screens/__init__.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/screens/home.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/screens/settings.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/screens/setup.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/SOURCES.txt +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/dependency_links.txt +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/entry_points.txt +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/requires.txt +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/top_level.txt +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/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.4
|
|
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
|
|
@@ -61,9 +61,6 @@ Requires-Dist: pytest-asyncio; extra == "dev"
|
|
|
61
61
|
| **弹窗** | `Esc` / `Ctrl+C` | 取消并关闭 |
|
|
62
62
|
| | `回车` | 提交表单或确认 |
|
|
63
63
|
|
|
64
|
-
> 鼠标全程可用:按钮 / 选项 / 列表行一律**单击即执行**(表格行单击即进入下一级或打开编辑),滚轮滚动光标所在的滚动区域。
|
|
65
|
-
> 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
|
|
66
|
-
|
|
67
64
|
## 安装指南
|
|
68
65
|
|
|
69
66
|
### 通过 pip 安装(推荐)
|
|
@@ -33,9 +33,6 @@
|
|
|
33
33
|
| **弹窗** | `Esc` / `Ctrl+C` | 取消并关闭 |
|
|
34
34
|
| | `回车` | 提交表单或确认 |
|
|
35
35
|
|
|
36
|
-
> 鼠标全程可用:按钮 / 选项 / 列表行一律**单击即执行**(表格行单击即进入下一级或打开编辑),滚轮滚动光标所在的滚动区域。
|
|
37
|
-
> 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
|
|
38
|
-
|
|
39
36
|
## 安装指南
|
|
40
37
|
|
|
41
38
|
### 通过 pip 安装(推荐)
|
|
@@ -1,18 +1,65 @@
|
|
|
1
1
|
"""Textual 应用入口:全局配置、服务客户端、状态栏与首启配置向导。"""
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
|
+
from rich.spinner import SPINNERS
|
|
5
|
+
from rich.text import Text
|
|
4
6
|
from textual import work
|
|
5
7
|
from textual.app import App, ComposeResult
|
|
6
8
|
from textual.binding import Binding
|
|
9
|
+
from textual.widget import Widget
|
|
7
10
|
from textual.widgets import Static
|
|
8
11
|
|
|
9
12
|
from .billing import AliCloudBssQuerier
|
|
10
13
|
from .config import config_exists, load_config
|
|
11
14
|
from .dns import AliCloudDnsQuerier
|
|
15
|
+
from .widgets import ProgressCover
|
|
16
|
+
|
|
17
|
+
_DOTS = SPINNERS["dots"]
|
|
12
18
|
|
|
13
19
|
|
|
14
20
|
class StatusBar(Static):
|
|
15
|
-
"""底部状态栏:展示最近一次操作结果 /
|
|
21
|
+
"""底部状态栏:展示最近一次操作结果 / 进行中提示;spinner=True 时显示点阵转圈。"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
24
|
+
super().__init__(*args, **kwargs)
|
|
25
|
+
self._text = ""
|
|
26
|
+
self._busy = False
|
|
27
|
+
self._frame = 0
|
|
28
|
+
self._timer = None
|
|
29
|
+
|
|
30
|
+
def render(self) -> Text:
|
|
31
|
+
if self._busy:
|
|
32
|
+
glyph = _DOTS["frames"][self._frame % len(_DOTS["frames"])]
|
|
33
|
+
return Text.assemble((glyph, "bold"), " ", self._text)
|
|
34
|
+
return Text(self._text)
|
|
35
|
+
|
|
36
|
+
def set_status(self, text: str) -> None:
|
|
37
|
+
self._busy = False
|
|
38
|
+
self._text = text
|
|
39
|
+
self._stop_timer()
|
|
40
|
+
self.refresh()
|
|
41
|
+
|
|
42
|
+
def set_spinner(self, text: str) -> None:
|
|
43
|
+
self._busy = True
|
|
44
|
+
self._text = text
|
|
45
|
+
self._ensure_timer()
|
|
46
|
+
self.refresh()
|
|
47
|
+
|
|
48
|
+
def _ensure_timer(self) -> None:
|
|
49
|
+
if self._timer is None and self.is_mounted:
|
|
50
|
+
self._timer = self.set_interval(_DOTS["interval"] / 1000, self._tick)
|
|
51
|
+
|
|
52
|
+
def _stop_timer(self) -> None:
|
|
53
|
+
if self._timer is not None:
|
|
54
|
+
self._timer.stop()
|
|
55
|
+
self._timer = None
|
|
56
|
+
|
|
57
|
+
def _tick(self) -> None:
|
|
58
|
+
self._frame += 1
|
|
59
|
+
self.refresh()
|
|
60
|
+
|
|
61
|
+
def on_mount(self) -> None:
|
|
62
|
+
self._ensure_timer()
|
|
16
63
|
|
|
17
64
|
|
|
18
65
|
class AliyunControllerApp(App):
|
|
@@ -58,13 +105,29 @@ class AliyunControllerApp(App):
|
|
|
58
105
|
def compose(self) -> ComposeResult:
|
|
59
106
|
yield StatusBar("", id="status")
|
|
60
107
|
|
|
108
|
+
def get_loading_widget(self) -> Widget:
|
|
109
|
+
"""全局默认:任意控件 .loading=True 时用 indeterminate 进度条覆盖其区域。"""
|
|
110
|
+
return ProgressCover()
|
|
111
|
+
|
|
61
112
|
def status(self, message: str, kind: str = "") -> None:
|
|
62
113
|
try:
|
|
63
114
|
bar = self.query_one("#status", StatusBar)
|
|
64
|
-
except Exception:
|
|
115
|
+
except Exception: # noqa: BLE001
|
|
65
116
|
return
|
|
66
117
|
bar.update_classes({"ok": kind == "ok", "warn": kind == "warn", "err": kind == "err"})
|
|
67
|
-
bar.
|
|
118
|
+
bar.set_status(message)
|
|
119
|
+
|
|
120
|
+
def spinner(self, on: bool, message: str = "加载中…") -> None:
|
|
121
|
+
"""状态栏点阵 spinner(无遮罩、不阻断输入):适合轻量、即时的后台动作。"""
|
|
122
|
+
try:
|
|
123
|
+
bar = self.query_one("#status", StatusBar)
|
|
124
|
+
except Exception: # noqa: BLE001
|
|
125
|
+
return
|
|
126
|
+
bar.update_classes({})
|
|
127
|
+
if on:
|
|
128
|
+
bar.set_spinner(message)
|
|
129
|
+
else:
|
|
130
|
+
bar.set_status("")
|
|
68
131
|
|
|
69
132
|
def notify_ok(self, message: str) -> None:
|
|
70
133
|
self.status(message, "ok")
|
|
@@ -45,6 +45,9 @@ class _BillingScreen(PageScreen):
|
|
|
45
45
|
@work(exclusive=True)
|
|
46
46
|
async def _reload(self) -> None:
|
|
47
47
|
self.app.status(f"正在查询 {self.billing_cycle} 的账单明细…")
|
|
48
|
+
tables = list(self.query(DataTable))
|
|
49
|
+
for t in tables:
|
|
50
|
+
t.loading = True
|
|
48
51
|
querier = self.app.get_billing()
|
|
49
52
|
try:
|
|
50
53
|
payload = await asyncio.to_thread(self.fetch, self.billing_cycle, querier)
|
|
@@ -56,6 +59,11 @@ class _BillingScreen(PageScreen):
|
|
|
56
59
|
self.app.notify_err(f"查询失败: {type(e).__name__}: {e}")
|
|
57
60
|
self._show_error()
|
|
58
61
|
return
|
|
62
|
+
finally:
|
|
63
|
+
for t in tables:
|
|
64
|
+
t.loading = False
|
|
65
|
+
if tables:
|
|
66
|
+
tables[0].focus()
|
|
59
67
|
self.set_subtitle(self.billing_cycle)
|
|
60
68
|
self._show_result(payload)
|
|
61
69
|
|
|
@@ -25,6 +25,7 @@ from ..widgets import (
|
|
|
25
25
|
ConfirmModal,
|
|
26
26
|
FormField,
|
|
27
27
|
FormModal,
|
|
28
|
+
busy,
|
|
28
29
|
filter_fuzzy,
|
|
29
30
|
fit_table_columns,
|
|
30
31
|
load_rows,
|
|
@@ -45,7 +46,7 @@ class DomainListScreen(PageScreen):
|
|
|
45
46
|
"""域名列表,回车进入该域名的解析记录。"""
|
|
46
47
|
|
|
47
48
|
TITLE = "DNS 解析管理"
|
|
48
|
-
HINT = "↑↓ 移动 ·
|
|
49
|
+
HINT = "↑↓ 移动 · 双击/回车 管理解析记录 · / 搜索 · Ctrl+R 刷新 · Esc/Ctrl+C 返回"
|
|
49
50
|
|
|
50
51
|
BINDINGS = [Binding("/", "focus_search", "搜索", show=False)]
|
|
51
52
|
|
|
@@ -77,6 +78,8 @@ class DomainListScreen(PageScreen):
|
|
|
77
78
|
@work(exclusive=True)
|
|
78
79
|
async def _load(self) -> None:
|
|
79
80
|
self.app.status("正在获取域名列表…")
|
|
81
|
+
table = self.query_one(DataTable)
|
|
82
|
+
table.loading = True
|
|
80
83
|
try:
|
|
81
84
|
domains = await asyncio.to_thread(self.app.get_dns().get_domains)
|
|
82
85
|
except AliyunApiError as e:
|
|
@@ -89,6 +92,9 @@ class DomainListScreen(PageScreen):
|
|
|
89
92
|
self._domains = []
|
|
90
93
|
self._rebuild()
|
|
91
94
|
return
|
|
95
|
+
finally:
|
|
96
|
+
table.loading = False
|
|
97
|
+
table.focus()
|
|
92
98
|
self._domains = domains
|
|
93
99
|
self._rebuild()
|
|
94
100
|
|
|
@@ -197,7 +203,7 @@ class RecordListScreen(PageScreen):
|
|
|
197
203
|
"""某域名的解析记录列表,支持筛选、排序、增删改与启停。"""
|
|
198
204
|
|
|
199
205
|
TITLE = "解析记录"
|
|
200
|
-
HINT = "
|
|
206
|
+
HINT = "双击/回车 编辑 · Ctrl+N 新增 · Ctrl+E 编辑 · Ctrl+D 删除 · Ctrl+R 刷新 · Esc/Ctrl+C 返回"
|
|
201
207
|
|
|
202
208
|
BINDINGS = [
|
|
203
209
|
Binding("/", "focus_search", "搜索", show=False),
|
|
@@ -252,6 +258,8 @@ class RecordListScreen(PageScreen):
|
|
|
252
258
|
@work(exclusive=True)
|
|
253
259
|
async def _load(self) -> None:
|
|
254
260
|
self.app.status(f"正在获取 {self.domain_name} 的解析记录…")
|
|
261
|
+
table = self._table()
|
|
262
|
+
table.loading = True
|
|
255
263
|
try:
|
|
256
264
|
records = await asyncio.to_thread(self.app.get_dns().get_domain_records, self.domain_name)
|
|
257
265
|
except AliyunApiError as e:
|
|
@@ -264,6 +272,9 @@ class RecordListScreen(PageScreen):
|
|
|
264
272
|
self._records = []
|
|
265
273
|
self._rebuild()
|
|
266
274
|
return
|
|
275
|
+
finally:
|
|
276
|
+
table.loading = False
|
|
277
|
+
table.focus()
|
|
267
278
|
self._records = records
|
|
268
279
|
self._rebuild()
|
|
269
280
|
|
|
@@ -350,14 +361,14 @@ class RecordListScreen(PageScreen):
|
|
|
350
361
|
elif isinstance(result, dict):
|
|
351
362
|
self._save_record(record, is_new, result)
|
|
352
363
|
|
|
353
|
-
|
|
364
|
+
@work(exclusive=True)
|
|
365
|
+
async def _save_record(self, record: dict | None, is_new: bool, data: dict) -> None:
|
|
354
366
|
dns = self.app.get_dns()
|
|
355
367
|
enabled = bool(data.get("status", True))
|
|
356
|
-
|
|
368
|
+
|
|
369
|
+
def _apply() -> None:
|
|
357
370
|
if is_new:
|
|
358
|
-
rid = dns.add_domain_record(
|
|
359
|
-
self.domain_name, data["rr"], data["type"], data["value"], data["ttl"]
|
|
360
|
-
)
|
|
371
|
+
rid = dns.add_domain_record(self.domain_name, data["rr"], data["type"], data["value"], data["ttl"])
|
|
361
372
|
if rid and not enabled:
|
|
362
373
|
dns.set_domain_record_status(rid, "Disable")
|
|
363
374
|
else:
|
|
@@ -368,6 +379,10 @@ class RecordListScreen(PageScreen):
|
|
|
368
379
|
dns.set_domain_record_status(
|
|
369
380
|
record.get("RecordId"), "Enable" if enabled else "Disable"
|
|
370
381
|
)
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
async with busy(self.app, "正在保存解析记录…", mode="bar"):
|
|
385
|
+
await asyncio.to_thread(_apply)
|
|
371
386
|
except AliyunApiError as e:
|
|
372
387
|
self.app.notify_err(str(e))
|
|
373
388
|
return
|
|
@@ -380,13 +395,7 @@ class RecordListScreen(PageScreen):
|
|
|
380
395
|
def confirm(ok: bool) -> None:
|
|
381
396
|
if not ok:
|
|
382
397
|
return
|
|
383
|
-
|
|
384
|
-
self.app.get_dns().delete_domain_record(record.get("RecordId"))
|
|
385
|
-
except AliyunApiError as e:
|
|
386
|
-
self.app.notify_err(str(e))
|
|
387
|
-
return
|
|
388
|
-
self.app.notify_ok(f"已删除解析记录 {name}")
|
|
389
|
-
self._load()
|
|
398
|
+
self._do_delete(record, name)
|
|
390
399
|
|
|
391
400
|
self.app.push_screen(
|
|
392
401
|
ConfirmModal(
|
|
@@ -398,6 +407,17 @@ class RecordListScreen(PageScreen):
|
|
|
398
407
|
confirm,
|
|
399
408
|
)
|
|
400
409
|
|
|
410
|
+
@work(exclusive=True)
|
|
411
|
+
async def _do_delete(self, record: dict, name: str) -> None:
|
|
412
|
+
try:
|
|
413
|
+
async with busy(self.app, f"正在删除 {name}…", mode="bar"):
|
|
414
|
+
await asyncio.to_thread(self.app.get_dns().delete_domain_record, record.get("RecordId"))
|
|
415
|
+
except AliyunApiError as e:
|
|
416
|
+
self.app.notify_err(str(e))
|
|
417
|
+
return
|
|
418
|
+
self.app.notify_ok(f"已删除解析记录 {name}")
|
|
419
|
+
self._load()
|
|
420
|
+
|
|
401
421
|
# ------------------------------------------------------------ 事件
|
|
402
422
|
|
|
403
423
|
@on(Input.Changed, "#rc-search")
|
|
@@ -13,6 +13,7 @@ from rich.text import Text
|
|
|
13
13
|
from textual import on
|
|
14
14
|
from textual.binding import Binding
|
|
15
15
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
16
|
+
from textual.css.query import NoMatches
|
|
16
17
|
from textual.message import Message
|
|
17
18
|
from textual.reactive import reactive
|
|
18
19
|
from textual.screen import Screen
|
|
@@ -448,14 +449,25 @@ class PageScreen(Screen):
|
|
|
448
449
|
# ------------------------------------------------------------ 标题 / 副标题
|
|
449
450
|
|
|
450
451
|
def set_subtitle(self, text: str) -> None:
|
|
452
|
+
"""更新顶栏副标题。
|
|
453
|
+
|
|
454
|
+
判定不能用 ``is_mounted``:`on_mount` 里它仍是 `False`(Textual 派发完
|
|
455
|
+
Mount 事件之后才置位),首屏副标题会永远停在空串。改为按顶栏控件是否
|
|
456
|
+
已组合判断:未组合时仅保存文本,交由 compose 使用。
|
|
457
|
+
"""
|
|
451
458
|
self.subtitle = text
|
|
452
|
-
|
|
459
|
+
try:
|
|
453
460
|
self.query_one("#tb-sub", Static).update(text)
|
|
461
|
+
except NoMatches:
|
|
462
|
+
pass
|
|
454
463
|
|
|
455
464
|
def set_title(self, text: str) -> None:
|
|
465
|
+
"""更新顶栏标题;判定口径同 set_subtitle。"""
|
|
456
466
|
self.TITLE = text
|
|
457
|
-
|
|
467
|
+
try:
|
|
458
468
|
self.query_one(".tb-title", Static).update(Text(text, style="bold"))
|
|
469
|
+
except NoMatches:
|
|
470
|
+
pass
|
|
459
471
|
|
|
460
472
|
# ------------------------------------------------------------ 折叠菜单
|
|
461
473
|
|
|
@@ -3,18 +3,37 @@
|
|
|
3
3
|
所有模态均为 ModalScreen 泛型返回值,配合 push_screen_wait / 回调消费;
|
|
4
4
|
数据展示统一使用 DataTable,不构造字符串伪表格。
|
|
5
5
|
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
6
9
|
from collections.abc import Callable
|
|
7
10
|
from dataclasses import dataclass, field
|
|
8
11
|
from typing import Any
|
|
9
12
|
|
|
10
13
|
from rich.cells import cell_len
|
|
14
|
+
from rich.style import Style
|
|
11
15
|
from rich.text import Text
|
|
12
16
|
from textual import events
|
|
13
17
|
from textual.binding import Binding
|
|
18
|
+
from textual.clock import Clock
|
|
14
19
|
from textual.containers import Horizontal, ScrollableContainer, Vertical, VerticalScroll
|
|
15
20
|
from textual.coordinate import Coordinate
|
|
21
|
+
from textual.css.query import NoMatches
|
|
16
22
|
from textual.screen import ModalScreen
|
|
17
|
-
from textual.
|
|
23
|
+
from textual.widget import Widget
|
|
24
|
+
from textual.widgets import (
|
|
25
|
+
Button,
|
|
26
|
+
DataTable,
|
|
27
|
+
Input,
|
|
28
|
+
Label,
|
|
29
|
+
LoadingIndicator,
|
|
30
|
+
OptionList,
|
|
31
|
+
ProgressBar,
|
|
32
|
+
Select,
|
|
33
|
+
Static,
|
|
34
|
+
Switch,
|
|
35
|
+
TextArea,
|
|
36
|
+
)
|
|
18
37
|
|
|
19
38
|
try: # textual 各版本 Option 导出位置不同
|
|
20
39
|
from textual.widgets.option_list import Option
|
|
@@ -331,7 +350,7 @@ class FormModal(ModalScreen["dict | None"]):
|
|
|
331
350
|
text = HINT_FORM
|
|
332
351
|
self.query_one("#frm-hint", HintBar).update(text)
|
|
333
352
|
|
|
334
|
-
def _collect(self) ->
|
|
353
|
+
def _collect(self) -> dict | None:
|
|
335
354
|
"""收集表单值;校验失败时写入错误信息并返回 None。可在子类扩展。"""
|
|
336
355
|
err_box = self.query_one("#frm-error", Static)
|
|
337
356
|
err_box.update(Text(""))
|
|
@@ -594,32 +613,53 @@ class MenuMarquee:
|
|
|
594
613
|
|
|
595
614
|
|
|
596
615
|
class ClickTable(DataTable):
|
|
597
|
-
"""
|
|
616
|
+
"""可操作表格:单击只选中,双击(或 Enter)才执行。
|
|
617
|
+
|
|
618
|
+
``activate="double"``(默认):单击数据格只移动光标(发 ``RowHighlighted``,
|
|
619
|
+
供状态栏 / 控制按钮刷新),``event.chain >= 2``(同格 0.5s 内第二次点击)
|
|
620
|
+
才发 ``RowSelected``。避免误点一下就把详情 / 表单顶到脸上。
|
|
621
|
+
|
|
622
|
+
``activate="click"``:单击即发 ``RowSelected``,用于「选中即生效」的选择器
|
|
623
|
+
(如格式列表、纯选择用途的主从列表)。
|
|
598
624
|
|
|
599
625
|
注意:Textual 沿 MRO 逐类派发事件,重写 `_on_click` 后父类实现仍会被调用一次,
|
|
600
626
|
因此接管分支与回落分支都必须 prevent_default(),防止 RowSelected 双发。
|
|
601
627
|
"""
|
|
602
628
|
|
|
629
|
+
def __init__(self, *, activate: str = "double", **kwargs) -> None:
|
|
630
|
+
super().__init__(**kwargs)
|
|
631
|
+
self.activate_mode = "click" if activate == "click" else "double"
|
|
632
|
+
|
|
603
633
|
async def _on_click(self, event: events.Click) -> None:
|
|
604
634
|
meta = event.style.meta if event.style is not None else {}
|
|
605
635
|
row = meta.get("row", -1)
|
|
606
636
|
col = meta.get("column", -1)
|
|
607
637
|
is_data_cell = (
|
|
608
638
|
self.cursor_type == "row"
|
|
609
|
-
and isinstance(row, int)
|
|
610
|
-
and
|
|
639
|
+
and isinstance(row, int)
|
|
640
|
+
and isinstance(col, int)
|
|
641
|
+
and 0 <= row < self.row_count
|
|
642
|
+
and col >= 0
|
|
611
643
|
and not meta.get("out_of_bounds", False)
|
|
612
644
|
)
|
|
613
645
|
if is_data_cell:
|
|
614
646
|
self.cursor_coordinate = Coordinate(row, col)
|
|
615
|
-
self.
|
|
647
|
+
if self.activate_mode == "click" or event.chain >= 2:
|
|
648
|
+
self._post_selected_message()
|
|
616
649
|
else:
|
|
617
650
|
await super()._on_click(event)
|
|
618
651
|
event.prevent_default()
|
|
619
652
|
|
|
620
653
|
|
|
621
|
-
def make_table(
|
|
622
|
-
|
|
654
|
+
def make_table(
|
|
655
|
+
*headers,
|
|
656
|
+
cursor: str = "row",
|
|
657
|
+
zebra: bool = True,
|
|
658
|
+
classes: str = "tbl",
|
|
659
|
+
activate: str = "double",
|
|
660
|
+
**kwargs,
|
|
661
|
+
) -> ClickTable:
|
|
662
|
+
dt = ClickTable(cursor_type=cursor, zebra_stripes=zebra, classes=classes, activate=activate, **kwargs)
|
|
623
663
|
dt.add_columns(*headers)
|
|
624
664
|
return dt
|
|
625
665
|
|
|
@@ -688,10 +728,191 @@ def fit_table_columns(table: DataTable, weights: list[float], rows: int | None =
|
|
|
688
728
|
return visible
|
|
689
729
|
|
|
690
730
|
|
|
731
|
+
# ---------------------------------------------------------------- 加载指示器
|
|
732
|
+
|
|
733
|
+
class _IndeterminateBar:
|
|
734
|
+
"""自渲染不定进度条:按**绘制时**的区域宽度取景并居中。
|
|
735
|
+
|
|
736
|
+
不依赖 ``Widget.size``:覆盖层是交给合成器临时绘制的,首帧 ``_size`` 可能
|
|
737
|
+
尚未由 ``_size_updated`` 写入,若在 ``render()`` 里读 ``self.size`` 会拿到 0
|
|
738
|
+
而画出一格宽的「隐形条」。改用 Rich 渲染协议,``options.max_width`` 即为本次
|
|
739
|
+
真正要绘制的区域宽度,保证任何一帧都居中可见。
|
|
740
|
+
"""
|
|
741
|
+
|
|
742
|
+
def __init__(
|
|
743
|
+
self, clock: Clock, animation_level: str, highlight: Style, track: Style, cap: int
|
|
744
|
+
) -> None:
|
|
745
|
+
self._clock = clock
|
|
746
|
+
self._animation_level = animation_level
|
|
747
|
+
self._highlight = highlight
|
|
748
|
+
self._track = track
|
|
749
|
+
self._cap = cap
|
|
750
|
+
|
|
751
|
+
def __rich_console__(self, console, options): # noqa: ANN001, ANN201
|
|
752
|
+
total = max(1, options.max_width)
|
|
753
|
+
width = min(total, self._cap)
|
|
754
|
+
bar = max(2, int(width * 0.25))
|
|
755
|
+
span = width + bar
|
|
756
|
+
if self._animation_level == "none":
|
|
757
|
+
start = 0
|
|
758
|
+
else:
|
|
759
|
+
speed = 30 # cells/s,与 Textual ProgressBar 的 indeterminate 一致
|
|
760
|
+
pos = int((speed * self._clock.time) % (2 * span))
|
|
761
|
+
if pos > span:
|
|
762
|
+
pos = 2 * span - pos
|
|
763
|
+
start = pos - bar
|
|
764
|
+
text = Text()
|
|
765
|
+
for i in range(width):
|
|
766
|
+
text.append("━", style=self._highlight if start <= i < start + bar else self._track)
|
|
767
|
+
yield text
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
class ProgressCover(Widget):
|
|
771
|
+
"""控件 loading=True 时覆盖其区域的不定进度条(方框 + 居中往复高亮条)。
|
|
772
|
+
|
|
773
|
+
必须是**单个自渲染 widget**:Textual 的 ``Widget._cover()`` 只把它交给合成器
|
|
774
|
+
替换原控件区域,并不挂进 DOM 参与布局;若用容器 + 子件,子件 region 恒为 0
|
|
775
|
+
而完全不可见(``ProgressBar`` 自身 compose 出 ``Bar`` 子件,因此同样不可用)。
|
|
776
|
+
"""
|
|
777
|
+
|
|
778
|
+
COMPONENT_CLASSES = {"pc--bar", "pc--highlight"}
|
|
779
|
+
|
|
780
|
+
DEFAULT_CSS = """
|
|
781
|
+
ProgressCover {
|
|
782
|
+
width: 100%;
|
|
783
|
+
height: 100%;
|
|
784
|
+
background: $surface;
|
|
785
|
+
border: round $secondary;
|
|
786
|
+
margin: 1 1 0 1;
|
|
787
|
+
content-align: center middle;
|
|
788
|
+
&> .pc--bar { color: $panel-lighten-1; background: $surface; }
|
|
789
|
+
&> .pc--highlight { color: $primary; background: $surface; }
|
|
790
|
+
}
|
|
791
|
+
"""
|
|
792
|
+
|
|
793
|
+
BAR_WIDTH = 40
|
|
794
|
+
"""进度条固定宽(与全屏 BusyOverlay `.busy-box` 的内容宽一致),由 content-align 居中。"""
|
|
795
|
+
|
|
796
|
+
def __init__(self) -> None:
|
|
797
|
+
super().__init__()
|
|
798
|
+
self._clock = Clock()
|
|
799
|
+
|
|
800
|
+
def on_mount(self) -> None:
|
|
801
|
+
self._clock.reset()
|
|
802
|
+
self.auto_refresh = 1 / 15
|
|
803
|
+
|
|
804
|
+
def render(self) -> _IndeterminateBar:
|
|
805
|
+
highlight = Style.from_color(self.get_component_rich_style("pc--highlight").color)
|
|
806
|
+
track = Style.from_color(self.get_component_rich_style("pc--bar").color)
|
|
807
|
+
return _IndeterminateBar(
|
|
808
|
+
self._clock, self.app.animation_level, highlight, track, self.BAR_WIDTH
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
class BusyOverlay(ModalScreen[None]):
|
|
813
|
+
"""全屏忙碌遮罩:阻断一切输入直到被 pop。
|
|
814
|
+
|
|
815
|
+
使用固定宽度(46)与分行结构防止文案变化引起容器抖动或 Emoji 换行。
|
|
816
|
+
"""
|
|
817
|
+
|
|
818
|
+
DEFAULT_CSS = """
|
|
819
|
+
BusyOverlay {
|
|
820
|
+
align: center middle;
|
|
821
|
+
background: $boost;
|
|
822
|
+
}
|
|
823
|
+
BusyOverlay .busy-box {
|
|
824
|
+
width: 46;
|
|
825
|
+
height: auto;
|
|
826
|
+
min-height: 5;
|
|
827
|
+
padding: 1 2;
|
|
828
|
+
border: round $primary;
|
|
829
|
+
background: $surface;
|
|
830
|
+
align: center middle;
|
|
831
|
+
}
|
|
832
|
+
BusyOverlay #busy-title {
|
|
833
|
+
width: 100%;
|
|
834
|
+
content-align: center middle;
|
|
835
|
+
text-style: bold;
|
|
836
|
+
text-wrap: nowrap;
|
|
837
|
+
text-overflow: ellipsis;
|
|
838
|
+
overflow: hidden;
|
|
839
|
+
margin-bottom: 1;
|
|
840
|
+
}
|
|
841
|
+
BusyOverlay LoadingIndicator { width: 100%; height: 1; }
|
|
842
|
+
BusyOverlay ProgressBar { width: 100%; }
|
|
843
|
+
BusyOverlay #busy-counter {
|
|
844
|
+
width: 100%;
|
|
845
|
+
content-align: center middle;
|
|
846
|
+
color: $text-muted;
|
|
847
|
+
margin-top: 1;
|
|
848
|
+
}
|
|
849
|
+
"""
|
|
850
|
+
|
|
851
|
+
def __init__(self, message: str = "正在处理…", *, mode: str = "dots", total: float | None = None) -> None:
|
|
852
|
+
super().__init__()
|
|
853
|
+
self._message = message
|
|
854
|
+
self._mode = mode
|
|
855
|
+
self._total = total
|
|
856
|
+
self._done = 0.0
|
|
857
|
+
|
|
858
|
+
def compose(self):
|
|
859
|
+
with Vertical(classes="busy-box"):
|
|
860
|
+
yield Static(self._message, id="busy-title")
|
|
861
|
+
if self._mode == "bar":
|
|
862
|
+
yield ProgressBar(total=None, show_bar=True, show_percentage=False, show_eta=False)
|
|
863
|
+
elif self._mode == "percent":
|
|
864
|
+
yield ProgressBar(total=self._total, show_percentage=True, show_eta=False)
|
|
865
|
+
yield Static(f"0/{int(self._total or 0)} (0%)", id="busy-counter")
|
|
866
|
+
else:
|
|
867
|
+
yield LoadingIndicator()
|
|
868
|
+
|
|
869
|
+
def set_message(self, message: str) -> None:
|
|
870
|
+
self._message = message
|
|
871
|
+
if self.is_mounted:
|
|
872
|
+
with contextlib.suppress(NoMatches):
|
|
873
|
+
self.query_one("#busy-title", Static).update(message)
|
|
874
|
+
|
|
875
|
+
def advance(self, amount: float = 1) -> None:
|
|
876
|
+
"""percent 模式:推进进度条并刷新计数文案。"""
|
|
877
|
+
self._done += amount
|
|
878
|
+
if self.is_mounted:
|
|
879
|
+
with contextlib.suppress(NoMatches):
|
|
880
|
+
bar = self.query_one(ProgressBar)
|
|
881
|
+
bar.progress += amount
|
|
882
|
+
if self._mode == "percent":
|
|
883
|
+
pct = int(round((self._done / (self._total or 1)) * 100))
|
|
884
|
+
self.query_one("#busy-counter", Static).update(
|
|
885
|
+
f"{int(self._done)}/{int(self._total or 0)} ({pct}%)"
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
@contextlib.asynccontextmanager
|
|
890
|
+
async def busy(app, message: str = "正在处理…", *, mode: str = "dots", total: float | None = None):
|
|
891
|
+
"""异步 worker 内包裹耗时操作,全程显示全屏忙碌遮罩(异常/取消亦收起)。
|
|
892
|
+
|
|
893
|
+
用法:async with busy(self.app, "正在保存…", mode="bar"): await asyncio.to_thread(...)
|
|
894
|
+
需要进度回调时接住返回的遮罩对象:async with busy(app, "拉取中", mode="percent", total=n) as ov: ...
|
|
895
|
+
"""
|
|
896
|
+
overlay = BusyOverlay(message, mode=mode, total=total)
|
|
897
|
+
app.push_screen(overlay)
|
|
898
|
+
try:
|
|
899
|
+
yield overlay
|
|
900
|
+
finally:
|
|
901
|
+
# 确保遮罩被收起:可能仍是栈顶,也可能已被其它层覆盖
|
|
902
|
+
for _ in range(len(app.screen_stack)):
|
|
903
|
+
if isinstance(app.screen, BusyOverlay) or app.screen is overlay:
|
|
904
|
+
app.pop_screen()
|
|
905
|
+
else:
|
|
906
|
+
break
|
|
907
|
+
if overlay.is_mounted:
|
|
908
|
+
overlay.remove()
|
|
909
|
+
|
|
910
|
+
|
|
691
911
|
__all__ = [
|
|
692
912
|
"ConfirmModal", "InputModal", "FormField", "FormModal", "OutputModal",
|
|
693
913
|
"ClickTable", "make_table", "load_rows", "filter_fuzzy", "fuzzy_score",
|
|
694
914
|
"shorten", "rcell", "fit_table_columns",
|
|
915
|
+
"ProgressCover", "BusyOverlay", "busy",
|
|
695
916
|
"tint", "colored_text",
|
|
696
917
|
"STYLE_OK", "STYLE_ERR", "STYLE_WARN", "STYLE_INFO", "STYLE_DIM",
|
|
697
918
|
"HINT_PICK", "HINT_MENU", "HINT_FORM", "EDIT_HINT", "HINT_FORM_EDIT", "HINT_FORM_TA",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aliyun_controller
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
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
|
|
@@ -61,9 +61,6 @@ Requires-Dist: pytest-asyncio; extra == "dev"
|
|
|
61
61
|
| **弹窗** | `Esc` / `Ctrl+C` | 取消并关闭 |
|
|
62
62
|
| | `回车` | 提交表单或确认 |
|
|
63
63
|
|
|
64
|
-
> 鼠标全程可用:按钮 / 选项 / 列表行一律**单击即执行**(表格行单击即进入下一级或打开编辑),滚轮滚动光标所在的滚动区域。
|
|
65
|
-
> 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
|
|
66
|
-
|
|
67
64
|
## 安装指南
|
|
68
65
|
|
|
69
66
|
### 通过 pip 安装(推荐)
|
|
@@ -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.4"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|