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.
Files changed (26) hide show
  1. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/PKG-INFO +6 -5
  2. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/README.md +5 -4
  3. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/__init__.py +1 -1
  4. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/app.py +5 -0
  5. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/app.tcss +40 -30
  6. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/screens/billing.py +3 -3
  7. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/screens/dns.py +7 -7
  8. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/screens/home.py +18 -19
  9. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/screens/settings.py +29 -26
  10. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/screens/setup.py +8 -2
  11. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/ui.py +63 -35
  12. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/widgets.py +238 -10
  13. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/PKG-INFO +6 -5
  14. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/pyproject.toml +1 -1
  15. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/billing.py +0 -0
  16. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/client.py +0 -0
  17. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/config.py +0 -0
  18. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/dns.py +0 -0
  19. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/main.py +0 -0
  20. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller/screens/__init__.py +0 -0
  21. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/SOURCES.txt +0 -0
  22. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/dependency_links.txt +0 -0
  23. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/entry_points.txt +0 -0
  24. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/requires.txt +0 -0
  25. {aliyun_controller-0.2.0 → aliyun_controller-0.2.2}/aliyun_controller.egg-info/top_level.txt +0 -0
  26. {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.0
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
- | | `q` | 退出程序 |
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
- | | `q` | 退出程序 |
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
 
@@ -1,4 +1,4 @@
1
1
  """
2
2
  Aliyun Controller
3
3
  """
4
- __version__ = "0.2.0"
4
+ __version__ = "0.2.2"
@@ -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: auto;
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: tall $primary;
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: 58;
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
- HomeScreen #home-plain {
210
+ .logo-page #home-plain {
188
211
  display: none;
189
212
  }
190
213
 
191
- HomeScreen.-sm #home-banner {
214
+ .logo-page.-sm #home-banner {
192
215
  display: none;
193
216
  }
194
217
 
195
- HomeScreen.-sm #home-plain {
218
+ .logo-page.-sm #home-plain {
196
219
  display: block;
197
220
  }
198
221
 
199
- HomeScreen.-sm #home-col {
222
+ .logo-page.-sm #home-col {
200
223
  width: 1fr;
201
224
  padding: 0 2;
202
225
  }
203
226
 
204
- /* ---------- 设置页:条目展示(点击进入编辑弹窗) ---------- */
205
- SettingsScreen .set-row {
206
- height: auto;
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
- SettingsScreen .set-row:hover {
214
- background: $boost;
233
+ .kv-row.gap-top {
234
+ margin-top: 1;
215
235
  }
216
236
 
217
- SettingsScreen .set-label {
237
+ .kv-label {
218
238
  width: 18;
219
239
  color: $text-muted;
220
- content-align: right middle;
221
- margin-right: 2;
240
+ content-align: left middle;
222
241
  }
223
242
 
224
- SettingsScreen .set-value {
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", compact=True)
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", compact=True)
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", compact=True)
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", compact=True)
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", compact=True)
236
- yield Button("编辑", id="rc-edit", compact=True)
237
- yield Button("删除", id="rc-del", variant="error", compact=True)
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("q", "menu('quit')", "退出"),
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 Static("↑↓ 选择 · 回车 进入 · 1-5 直达 · Ctrl+Q 退出", classes="page-hint")
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
- ol.add_options(self._options())
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.set_class(self.size.width < LOGO_WIDTH + 6, "-sm")
60
-
61
- def _options(self):
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
- """设置屏:一个大容器直接呈现设置内容,点击「编辑」或密钥行进入与 DNS 同款的操作弹窗。"""
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 = "点击密钥条目或「编辑」按钮修改 · 回车 保存 · Esc 返回"
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="set-row"):
32
- yield Static("配置文件", classes="set-label")
33
- yield Static("", classes="set-value", id="st-path")
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="set-row", id=f"row-{key}"):
36
- yield Static(label, classes="set-label")
37
- yield Static("", classes="set-value", id=f"view-{key}")
38
- yield Static("点击编辑 ▸", classes="set-edit-hint")
39
- with Horizontal(classes="sub-toolbar"):
40
- yield Button("编辑访问密钥", id="edit", variant="primary")
41
- yield Static("更改后立即生效,无需重启", classes="tb-note")
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
- self._config = load_config()
56
+ cfg = await asyncio.to_thread(load_config)
46
57
  except Exception: # noqa: BLE001 — 配置损坏时以 App 内存态兜底
47
- self._config = self.app.config or {}
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
- # ------------------------------------------------------------ 编辑弹窗(与 DNS 记录同款)
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 Static("Esc 取消向导并退出程序", classes="page-hint")
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(len(line) for line in LOGO_LINES)
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: 3;
90
- min-width: 3;
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: 3;
116
- min-width: 3;
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: 4;
136
- min-width: 4;
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", compact=True)
171
- yield Button(self.value, id="mp-cur", compact=True)
172
- yield Button("▶", id="mp-next", compact=True)
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", compact=True)
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", compact=True)
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", compact=True)
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 Button {
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: auto;
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", compact=True)
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", compact=True)
434
+ yield Button("菜单 ▾", id="top-menu")
434
435
  with Vertical(id="page"):
435
436
  yield from self.compose_page()
436
- yield Static(self.HINT, classes="page-hint")
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.0
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
- | | `q` | 退出程序 |
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.0"
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 = [