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.
Files changed (26) hide show
  1. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/PKG-INFO +1 -4
  2. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/README.md +0 -3
  3. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/__init__.py +1 -1
  4. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/app.py +66 -3
  5. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/screens/billing.py +8 -0
  6. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/screens/dns.py +34 -14
  7. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/ui.py +14 -2
  8. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/widgets.py +229 -8
  9. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/PKG-INFO +1 -4
  10. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/pyproject.toml +1 -1
  11. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/app.tcss +0 -0
  12. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/billing.py +0 -0
  13. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/client.py +0 -0
  14. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/config.py +0 -0
  15. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/dns.py +0 -0
  16. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/main.py +0 -0
  17. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/screens/__init__.py +0 -0
  18. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/screens/home.py +0 -0
  19. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/screens/settings.py +0 -0
  20. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller/screens/setup.py +0 -0
  21. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/SOURCES.txt +0 -0
  22. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/dependency_links.txt +0 -0
  23. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/entry_points.txt +0 -0
  24. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/requires.txt +0 -0
  25. {aliyun_controller-0.2.2 → aliyun_controller-0.2.4}/aliyun_controller.egg-info/top_level.txt +0 -0
  26. {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.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,4 +1,4 @@
1
1
  """
2
2
  Aliyun Controller
3
3
  """
4
- __version__ = "0.2.2"
4
+ __version__ = "0.2.4"
@@ -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.update(message)
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 = "↑↓ 移动 · 回车 管理解析记录 · / 搜索 · Ctrl+R 刷新 · Esc/Ctrl+C 返回"
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 = "单击/回车 编辑 · Ctrl+N 新增 · Ctrl+E 编辑 · Ctrl+D 删除 · Ctrl+R 刷新 · Esc/Ctrl+C 返回"
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
- def _save_record(self, record: dict | None, is_new: bool, data: dict) -> None:
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
- try:
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
- try:
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
- if self.is_mounted:
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
- if self.is_mounted:
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.widgets import Button, DataTable, Input, Label, OptionList, Select, Static, Switch, TextArea
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) -> "dict | None":
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
- """列表即操作的表格:单击数据单元格 = 移动光标并立即发 RowSelected。
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) and isinstance(col, int)
610
- and 0 <= row < self.row_count and col >= 0
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._post_selected_message()
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(*headers, cursor: str = "row", zebra: bool = True, classes: str = "tbl", **kwargs) -> ClickTable:
622
- dt = ClickTable(cursor_type=cursor, zebra_stripes=zebra, classes=classes, **kwargs)
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.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.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 = [