clash-controller 0.2.2__tar.gz → 0.2.3__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 (28) hide show
  1. {clash_controller-0.2.2 → clash_controller-0.2.3}/PKG-INFO +1 -1
  2. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/__init__.py +1 -1
  3. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/app.py +66 -3
  4. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/screens/config.py +53 -52
  5. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/screens/overview.py +58 -43
  6. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/screens/settings.py +29 -12
  7. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/widgets.py +282 -2
  8. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller.egg-info/PKG-INFO +1 -1
  9. {clash_controller-0.2.2 → clash_controller-0.2.3}/pyproject.toml +1 -1
  10. {clash_controller-0.2.2 → clash_controller-0.2.3}/README.md +0 -0
  11. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/__main__.py +0 -0
  12. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/api.py +0 -0
  13. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/app.tcss +0 -0
  14. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/config.py +0 -0
  15. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/deploy.py +0 -0
  16. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/main.py +0 -0
  17. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/screens/__init__.py +0 -0
  18. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/screens/home.py +0 -0
  19. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/screens/logs.py +0 -0
  20. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/screens/profiles.py +0 -0
  21. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/ssh.py +0 -0
  22. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller/ui.py +0 -0
  23. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller.egg-info/SOURCES.txt +0 -0
  24. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller.egg-info/dependency_links.txt +0 -0
  25. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller.egg-info/entry_points.txt +0 -0
  26. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller.egg-info/requires.txt +0 -0
  27. {clash_controller-0.2.2 → clash_controller-0.2.3}/clash_controller.egg-info/top_level.txt +0 -0
  28. {clash_controller-0.2.2 → clash_controller-0.2.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clash_controller
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: A Textual TUI for managing Clash proxy instances
5
5
  Author-email: Moha-Master <hongkongreporter@outlook.com>
6
6
  License-Expression: MIT
@@ -2,4 +2,4 @@
2
2
  Clash Controller - A Textual TUI for managing Clash proxy instances.
3
3
  """
4
4
 
5
- __version__ = "0.2.2"
5
+ __version__ = "0.2.3"
@@ -2,16 +2,63 @@
2
2
  from datetime import datetime
3
3
  from pathlib import Path
4
4
 
5
+ from rich.spinner import SPINNERS
6
+ from rich.text import Text
5
7
  from textual import work
6
8
  from textual.app import App, ComposeResult
7
9
  from textual.binding import Binding
10
+ from textual.widget import Widget
8
11
  from textual.widgets import Static
9
12
 
10
13
  from .api import ClashAPI
14
+ from .widgets import ProgressCover
15
+
16
+ _DOTS = SPINNERS["dots"]
11
17
 
12
18
 
13
19
  class StatusBar(Static):
14
- """底部状态栏:展示最近一次操作结果 / 进行中提示。"""
20
+ """底部状态栏:展示最近一次操作结果 / 进行中提示;spinner=True 时显示点阵转圈。"""
21
+
22
+ def __init__(self, *args, **kwargs) -> None:
23
+ super().__init__(*args, **kwargs)
24
+ self._text = ""
25
+ self._busy = False
26
+ self._frame = 0
27
+ self._timer = None
28
+
29
+ def render(self) -> Text:
30
+ if self._busy:
31
+ glyph = _DOTS["frames"][self._frame % len(_DOTS["frames"])]
32
+ return Text.assemble((glyph, "bold"), " ", self._text)
33
+ return Text(self._text)
34
+
35
+ def set_status(self, text: str) -> None:
36
+ self._busy = False
37
+ self._text = text
38
+ self._stop_timer()
39
+ self.refresh()
40
+
41
+ def set_spinner(self, text: str) -> None:
42
+ self._busy = True
43
+ self._text = text
44
+ self._ensure_timer()
45
+ self.refresh()
46
+
47
+ def _ensure_timer(self) -> None:
48
+ if self._timer is None and self.is_mounted:
49
+ self._timer = self.set_interval(_DOTS["interval"] / 1000, self._tick)
50
+
51
+ def _stop_timer(self) -> None:
52
+ if self._timer is not None:
53
+ self._timer.stop()
54
+ self._timer = None
55
+
56
+ def _tick(self) -> None:
57
+ self._frame += 1
58
+ self.refresh()
59
+
60
+ def on_mount(self) -> None:
61
+ self._ensure_timer()
15
62
 
16
63
 
17
64
  class ClashControllerApp(App):
@@ -67,13 +114,29 @@ class ClashControllerApp(App):
67
114
  def compose(self) -> ComposeResult:
68
115
  yield StatusBar("", id="status")
69
116
 
117
+ def get_loading_widget(self) -> Widget:
118
+ """全局默认:任意控件 .loading=True 时用 indeterminate 进度条覆盖其区域。"""
119
+ return ProgressCover()
120
+
70
121
  def status(self, message: str, kind: str = "") -> None:
71
122
  try:
72
123
  bar = self.query_one("#status", StatusBar)
73
- except Exception:
124
+ except Exception: # noqa: BLE001
74
125
  return
75
126
  bar.update_classes({"ok": kind == "ok", "warn": kind == "warn", "err": kind == "err"})
76
- bar.update(message)
127
+ bar.set_status(message)
128
+
129
+ def spinner(self, on: bool, message: str = "加载中…") -> None:
130
+ """状态栏点阵 spinner(无遮罩、不阻断输入):适合轻量、即时的后台动作。"""
131
+ try:
132
+ bar = self.query_one("#status", StatusBar)
133
+ except Exception: # noqa: BLE001
134
+ return
135
+ bar.update_classes({})
136
+ if on:
137
+ bar.set_spinner(message)
138
+ else:
139
+ bar.set_status("")
77
140
 
78
141
  def notify_ok(self, message: str) -> None:
79
142
  self.status(message, "ok")
@@ -11,7 +11,7 @@ from ..config import load_config_providers, save_config_providers
11
11
  from ..deploy import compute_sha256, evaluate_update, fetch_remote_config, read_current_config
12
12
  from ..ssh import apply_local_config_file, apply_remote_config_via_ssh
13
13
  from ..ui import PageScreen
14
- from ..widgets import ConfirmModal, FormField, FormModal, fit_table_columns, load_rows, make_table, shorten
14
+ from ..widgets import ConfirmModal, FormField, FormModal, busy, fit_table_columns, load_rows, make_table, shorten
15
15
 
16
16
  COLUMN_WEIGHTS = [2, 7]
17
17
 
@@ -176,61 +176,62 @@ class ConfigScreen(PageScreen):
176
176
  debug = self.app.debug_mode or bool(profile.get("debug_ssh"))
177
177
  url = provider.get("url", "")
178
178
 
179
- self.app.status(f"正在从 {url} 拉取配置…")
180
- self.app.add_log(f"Fetching config from {url}...")
181
- try:
182
- remote_payload, remote_hash, remote_mod_time = await asyncio.to_thread(fetch_remote_config, url)
183
- local_mtime, local_bytes = await asyncio.to_thread(
184
- read_current_config, profile, self.app.add_log, debug
185
- )
186
- except Exception as e: # noqa: BLE001
187
- self.app.notify_err(f"拉取或读取配置失败: {type(e).__name__}: {e}")
188
- return
179
+ async with busy(self.app, "正在拉取远程配置…", mode="bar") as ov:
180
+ self.app.add_log(f"Fetching config from {url}...")
181
+ try:
182
+ remote_payload, remote_hash, remote_mod_time = await asyncio.to_thread(fetch_remote_config, url)
183
+ ov.set_message("正在读取目标配置…")
184
+ local_mtime, local_bytes = await asyncio.to_thread(
185
+ read_current_config, profile, self.app.add_log, debug
186
+ )
187
+ except Exception as e: # noqa: BLE001
188
+ self.app.notify_err(f"拉取或读取配置失败: {type(e).__name__}: {e}")
189
+ return
189
190
 
190
- local_hash = compute_sha256(local_bytes)
191
- decision = evaluate_update(remote_hash, remote_mod_time, local_hash, local_mtime)
192
-
193
- proceed = True
194
- if decision == "auto":
195
- self.app.add_log("Remote config newer with different hash. Proceeding without prompt.")
196
- elif decision == "confirm_older":
197
- proceed = await self.app.push_screen_wait(
198
- ConfirmModal("远程配置可能比本地更旧,仍要覆盖目标配置吗?", default_yes=False)
199
- )
200
- elif decision == "confirm_unknown":
201
- proceed = await self.app.push_screen_wait(
202
- ConfirmModal("远程配置内容不同,但无法获取更新时间。是否继续覆盖目标配置?", default_yes=True)
203
- )
204
- else: # confirm_same
205
- proceed = await self.app.push_screen_wait(
206
- ConfirmModal("远程配置与目标配置内容相同,仍要覆盖吗?", default_yes=False)
207
- )
208
-
209
- if not proceed:
210
- self.app.status("已取消更新", "warn")
211
- self.app.add_log("Config update cancelled by user after comparison.")
212
- return
191
+ local_hash = compute_sha256(local_bytes)
192
+ decision = evaluate_update(remote_hash, remote_mod_time, local_hash, local_mtime)
213
193
 
214
- self.app.status("正在部署配置…")
215
- try:
216
- if profile.get("endpoint_type") == "remote":
217
- self.app.add_log("Deploying config to remote endpoint via SSH.")
218
- await asyncio.to_thread(
219
- apply_remote_config_via_ssh, profile, remote_payload, self.app.add_log, debug
194
+ proceed = True
195
+ if decision == "auto":
196
+ self.app.add_log("Remote config newer with different hash. Proceeding without prompt.")
197
+ elif decision == "confirm_older":
198
+ proceed = await self.app.push_screen_wait(
199
+ ConfirmModal("远程配置可能比本地更旧,仍要覆盖目标配置吗?", default_yes=False)
200
+ )
201
+ elif decision == "confirm_unknown":
202
+ proceed = await self.app.push_screen_wait(
203
+ ConfirmModal("远程配置内容不同,但无法获取更新时间。是否继续覆盖目标配置?", default_yes=True)
204
+ )
205
+ else: # confirm_same
206
+ proceed = await self.app.push_screen_wait(
207
+ ConfirmModal("远程配置与目标配置内容相同,仍要覆盖吗?", default_yes=False)
220
208
  )
221
- else:
222
- self.app.add_log("Deploying config to local endpoint file.")
223
- await asyncio.to_thread(apply_local_config_file, profile, remote_payload)
224
- except Exception as e: # noqa: BLE001
225
- self.app.notify_err(f"部署配置失败: {type(e).__name__}: {e}")
226
- return
227
209
 
228
- self.app.status("正在通过 Clash API 重载配置…")
229
- try:
230
- result, error = await asyncio.to_thread(self.app.get_api().reload_configs)
231
- except Exception as e: # noqa: BLE001
232
- self.app.notify_err(f"重载配置失败: {type(e).__name__}: {e}")
233
- return
210
+ if not proceed:
211
+ self.app.status("已取消更新", "warn")
212
+ self.app.add_log("Config update cancelled by user after comparison.")
213
+ return
214
+
215
+ ov.set_message("正在部署配置…")
216
+ try:
217
+ if profile.get("endpoint_type") == "remote":
218
+ self.app.add_log("Deploying config to remote endpoint via SSH.")
219
+ await asyncio.to_thread(
220
+ apply_remote_config_via_ssh, profile, remote_payload, self.app.add_log, debug
221
+ )
222
+ else:
223
+ self.app.add_log("Deploying config to local endpoint file.")
224
+ await asyncio.to_thread(apply_local_config_file, profile, remote_payload)
225
+ except Exception as e: # noqa: BLE001
226
+ self.app.notify_err(f"部署配置失败: {type(e).__name__}: {e}")
227
+ return
228
+
229
+ ov.set_message("正在通过 Clash API 重载配置…")
230
+ try:
231
+ result, error = await asyncio.to_thread(self.app.get_api().reload_configs)
232
+ except Exception as e: # noqa: BLE001
233
+ self.app.notify_err(f"重载配置失败: {type(e).__name__}: {e}")
234
+ return
234
235
  self.app.report("重载配置", result, error)
235
236
 
236
237
  @on(Button.Pressed, "#reload-api")
@@ -1,6 +1,7 @@
1
1
  """概览屏:版本 + 实时流量 / 内存(流式接口)+ 当前活动连接表(每秒轮询)。"""
2
2
  import asyncio
3
3
  import json
4
+ import time
4
5
  from collections.abc import Callable
5
6
  from datetime import datetime
6
7
 
@@ -10,7 +11,7 @@ from textual.containers import Horizontal, Vertical
10
11
  from textual.widgets import DataTable, Static
11
12
 
12
13
  from ..ui import PageScreen
13
- from ..widgets import fit_table_columns, load_rows, make_table, shorten
14
+ from ..widgets import fit_table_columns, hold_busy, load_rows, make_table, shorten
14
15
 
15
16
  COLUMN_WEIGHTS = [4, 1.5, 1.5, 3, 3]
16
17
 
@@ -55,7 +56,7 @@ class OverviewScreen(PageScreen):
55
56
  self._consume("内存", api.get_memory_stream, self._on_memory)
56
57
  self.query_one(DataTable).focus()
57
58
  self._timer = self.set_interval(1.0, self._refresh_connections)
58
- self._refresh_connections()
59
+ self._refresh_connections(show_overlay=True)
59
60
 
60
61
  def on_unmount(self) -> None:
61
62
  self._stop = True
@@ -72,7 +73,7 @@ class OverviewScreen(PageScreen):
72
73
 
73
74
  def reload_page(self) -> None:
74
75
  self._fetch_version()
75
- self._refresh_connections()
76
+ self._refresh_connections(show_overlay=True)
76
77
 
77
78
  # ------------------------------------------------------------ 后台线程
78
79
 
@@ -130,50 +131,64 @@ class OverviewScreen(PageScreen):
130
131
  fit_table_columns(self.query_one("#cn-table", DataTable), COLUMN_WEIGHTS, rows=self._count)
131
132
 
132
133
  @work(exclusive=False)
133
- async def _refresh_connections(self) -> None:
134
- if self._busy or not self.is_mounted:
134
+ async def _refresh_connections(self, show_overlay: bool = False) -> None:
135
+ if self._busy:
135
136
  return
136
137
  self._busy = True
138
+ started = time.monotonic()
139
+ table = self.query_one("#cn-table", DataTable)
140
+ if show_overlay:
141
+ table.loading = True
137
142
  try:
138
- data, error = await asyncio.to_thread(self.app.get_api().get_connections)
139
- except Exception as e: # noqa: BLE001
140
- self._busy = False
141
- self.app.notify_err(f"获取连接失败: {type(e).__name__}: {e}")
142
- return
143
- self._busy = False
144
- if not self.is_mounted:
145
- return
146
-
147
- if error:
148
- self.app.notify_err(f"获取连接失败: {error}")
149
- return
150
-
151
- data = data if isinstance(data, dict) else {}
152
- connections = data.get("connections", []) or []
153
- total_dl = data.get("downloadTotal", 0) / (1024 * 1024)
154
- total_ul = data.get("uploadTotal", 0) / (1024 * 1024)
155
-
156
- rows = []
157
- for conn in connections:
158
- metadata = conn.get("metadata", {}) or {}
159
- host = metadata.get("host") or metadata.get("destinationIP", "未知")
160
- rows.append([
161
- Text(shorten(str(host), 40)),
162
- Text(str(metadata.get("network", "?"))),
163
- Text(str(metadata.get("type", "?"))),
164
- Text(shorten(str(conn.get("rule", "?")), 30)),
165
- Text(shorten(" -> ".join(conn.get("chains", []) or []), 30)),
166
- ])
167
- self._count = len(rows)
168
- load_rows(self.query_one("#cn-table", DataTable), rows)
169
- self._refit()
170
- self.query_one("#cn-summary", Static).update(
171
- Text(
172
- f"{len(connections)} 条 · 上传 {total_ul:.2f} MB / 下载 {total_dl:.2f} MB"
173
- f" · 更新于 {datetime.now():%H:%M:%S}"
143
+ try:
144
+ data, error = await asyncio.to_thread(self.app.get_api().get_connections)
145
+ except Exception as e: # noqa: BLE001
146
+ self.app.notify_err(f"获取连接失败: {type(e).__name__}: {e}")
147
+ return
148
+ if not self.is_mounted:
149
+ return
150
+ if error:
151
+ self.app.notify_err(f"获取连接失败: {error}")
152
+ return
153
+
154
+ data = data if isinstance(data, dict) else {}
155
+ connections = data.get("connections", []) or []
156
+ total_dl = data.get("downloadTotal", 0) / (1024 * 1024)
157
+ total_ul = data.get("uploadTotal", 0) / (1024 * 1024)
158
+
159
+ rows = []
160
+ for conn in connections:
161
+ metadata = conn.get("metadata", {}) or {}
162
+ host = metadata.get("host") or metadata.get("destinationIP", "未知")
163
+ rows.append([
164
+ Text(shorten(str(host), 40)),
165
+ Text(str(metadata.get("network", "?"))),
166
+ Text(str(metadata.get("type", "?"))),
167
+ Text(shorten(str(conn.get("rule", "?")), 30)),
168
+ Text(shorten(" -> ".join(conn.get("chains", []) or []), 30)),
169
+ ])
170
+ self._count = len(rows)
171
+ load_rows(table, rows)
172
+ self._refit()
173
+ self.query_one("#cn-summary", Static).update(
174
+ Text(
175
+ f"{len(connections)} 条 · 上传 {total_ul:.2f} MB / 下载 {total_dl:.2f} MB"
176
+ f" · 更新于 {datetime.now():%H:%M:%S}"
177
+ )
174
178
  )
175
- )
176
- self.app.status(f"连接总数 {len(connections)}")
179
+ self.app.status(f"连接总数 {len(connections)}")
180
+ finally:
181
+ self._busy = False
182
+ if show_overlay:
183
+ # 无论正常/异常/取消,覆盖层都必须撤下
184
+ try:
185
+ if self.is_mounted:
186
+ await hold_busy(started)
187
+ finally:
188
+ table.loading = False
189
+ if self.is_mounted:
190
+ self.call_after_refresh(self._refit) # 覆盖层撤下后区域宽可能变化,按最终宽度重排
191
+ table.focus()
177
192
 
178
193
  # ------------------------------------------------------------ UI 更新
179
194
 
@@ -1,12 +1,13 @@
1
1
  """设置屏:TUN / 模式即时生效控件(无保存动作)、重载与升级操作;日志入口在顶栏折叠菜单。"""
2
2
  import asyncio
3
+ import time
3
4
 
4
5
  from textual import on, work
5
6
  from textual.containers import Horizontal, Vertical
6
7
  from textual.widgets import Button, Select, Static, Switch
7
8
 
8
9
  from ..ui import PageScreen
9
- from ..widgets import ConfirmModal
10
+ from ..widgets import ConfirmModal, ControlBusy, hold_busy
10
11
 
11
12
  MODES = [("规则", "rule"), ("全局", "global"), ("直连", "direct")]
12
13
 
@@ -19,6 +20,7 @@ class SettingsScreen(PageScreen):
19
20
  super().__init__()
20
21
  self._tun = False
21
22
  self._mode = "rule"
23
+ self._busy = ControlBusy(self)
22
24
  self.menu = [("查看应用日志", self._open_logs, False)]
23
25
 
24
26
  def compose_page(self):
@@ -83,7 +85,7 @@ class SettingsScreen(PageScreen):
83
85
  if bool(event.value) == self._tun:
84
86
  return
85
87
  self._tun = bool(event.value)
86
- self._call("toggle_tun", f"{'启用' if self._tun else '禁用'} TUN", self._tun)
88
+ self._call("toggle_tun", f"{'启用' if self._tun else '禁用'} TUN", self._tun, control_id="st-tun")
87
89
 
88
90
  @on(Select.Changed, "#st-mode")
89
91
  def _on_mode_changed(self, event: Select.Changed) -> None:
@@ -94,45 +96,60 @@ class SettingsScreen(PageScreen):
94
96
  return
95
97
  self._mode = mode
96
98
  labels = dict(MODES)
97
- self._call("set_mode", f"切换模式为 {labels.get(mode, mode)}", mode)
99
+ self._call(
100
+ "set_mode", f"切换模式为 {labels.get(mode, mode)}", mode, control_id="st-mode"
101
+ )
98
102
 
99
103
  @on(Button.Pressed, "#reload-geo")
100
104
  def _on_reload_geo(self) -> None:
101
- self._call("reload_geo_databases", "重载 GEO 数据库")
105
+ self._call("reload_geo_databases", "重载 GEO 数据库", control_id="reload-geo")
102
106
 
103
107
  @on(Button.Pressed, "#restart")
104
108
  def _on_restart(self) -> None:
105
- self._confirm_then("确定要重启 Clash Core 吗?", "restart", "重启 Clash Core")
109
+ self._confirm_then("确定要重启 Clash Core 吗?", "restart", "重启 Clash Core", "restart")
106
110
 
107
111
  @on(Button.Pressed, "#up-kernel")
108
112
  def _on_up_kernel(self) -> None:
109
- self._confirm_then("确定要请求升级内核吗?", "upgrade_kernel", "升级内核")
113
+ self._confirm_then("确定要请求升级内核吗?", "upgrade_kernel", "升级内核", "up-kernel")
110
114
 
111
115
  @on(Button.Pressed, "#up-ui")
112
116
  def _on_up_ui(self) -> None:
113
- self._confirm_then("确定要请求升级 UI 吗?", "upgrade_ui", "升级 UI")
117
+ self._confirm_then("确定要请求升级 UI 吗?", "upgrade_ui", "升级 UI", "up-ui")
114
118
 
115
119
  @on(Button.Pressed, "#up-geo")
116
120
  def _on_up_geo(self) -> None:
117
- self._confirm_then("确定要请求升级 GEO 数据库吗?", "upgrade_geo_databases", "升级 GEO 数据库")
121
+ self._confirm_then("确定要请求升级 GEO 数据库吗?", "upgrade_geo_databases", "升级 GEO 数据库", "up-geo")
118
122
 
119
- def _confirm_then(self, message: str, method_name: str, desc: str) -> None:
123
+ def _confirm_then(self, message: str, method_name: str, desc: str, control_id: str) -> None:
120
124
  def confirm(ok: bool) -> None:
121
125
  if ok:
122
- self._call(method_name, desc)
126
+ self._call(method_name, desc, control_id=control_id)
123
127
 
124
128
  self.app.push_screen(ConfirmModal(message, default_yes=False), confirm)
125
129
 
126
130
  @work(exclusive=True)
127
- async def _call(self, method_name: str, desc: str, *args) -> None:
131
+ async def _call(self, method_name: str, desc: str, *args, control_id: str | None = None) -> None:
128
132
  api = self.app.get_api()
129
133
  method = getattr(api, method_name)
130
- self.app.status(f"正在{desc}…")
134
+ started = time.monotonic()
135
+ control = self.query_one(f"#{control_id}") if control_id else None
136
+ if control is not None:
137
+ # 就地反馈:禁用触发控件并把文本换成点阵 spinner(比顶栏更直观)
138
+ self._busy.start(control)
139
+ else:
140
+ self.app.spinner(True, f"正在{desc}…")
131
141
  try:
132
142
  result, error = await asyncio.to_thread(method, *args)
143
+ await hold_busy(started)
133
144
  except Exception as e: # noqa: BLE001
145
+ await hold_busy(started)
134
146
  self.app.notify_err(f"{desc}失败: {type(e).__name__}: {e}")
135
147
  self._load_configs() # 失败回滚控件到服务端真实状态
136
148
  return
149
+ finally:
150
+ if control is not None:
151
+ self._busy.stop()
152
+ else:
153
+ self.app.spinner(False)
137
154
  self.app.report(desc, result, error)
138
155
  self._load_configs() # 以服务端状态为准回写控件(失败时即回滚)
@@ -3,18 +3,40 @@
3
3
  所有模态均为 ModalScreen 泛型返回值,配合 push_screen_wait / 回调消费;
4
4
  数据展示统一使用 DataTable(ClickTable 单击执行),不构造字符串伪表格。
5
5
  """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import contextlib
10
+ import time
6
11
  from collections.abc import Callable
7
12
  from dataclasses import dataclass, field
8
13
  from typing import Any
9
14
 
10
15
  from rich.cells import cell_len
16
+ from rich.spinner import SPINNERS
17
+ from rich.style import Style
11
18
  from rich.text import Text
12
19
  from textual import events
13
20
  from textual.binding import Binding
21
+ from textual.clock import Clock
14
22
  from textual.containers import Horizontal, ScrollableContainer, Vertical, VerticalScroll
15
23
  from textual.coordinate import Coordinate
24
+ from textual.css.query import NoMatches
16
25
  from textual.screen import ModalScreen
17
- from textual.widgets import Button, DataTable, Input, Label, OptionList, Select, Static, Switch, TextArea
26
+ from textual.widget import Widget
27
+ from textual.widgets import (
28
+ Button,
29
+ DataTable,
30
+ Input,
31
+ Label,
32
+ LoadingIndicator,
33
+ OptionList,
34
+ ProgressBar,
35
+ Select,
36
+ Static,
37
+ Switch,
38
+ TextArea,
39
+ )
18
40
 
19
41
  try: # textual 各版本 Option 导出位置不同
20
42
  from textual.widgets.option_list import Option
@@ -340,7 +362,7 @@ class FormModal(ModalScreen["dict | None"]):
340
362
  text = HINT_FORM
341
363
  self.query_one("#frm-hint", HintBar).update(text)
342
364
 
343
- def _collect(self) -> "dict | None":
365
+ def _collect(self) -> dict | None:
344
366
  """收集表单值;校验失败时写入错误信息并返回 None。可在子类扩展。"""
345
367
  err_box = self.query_one("#frm-error", Static)
346
368
  err_box.update(Text(""))
@@ -699,10 +721,268 @@ def fit_table_columns(table: DataTable, weights: list[float], rows: int | None =
699
721
  return visible
700
722
 
701
723
 
724
+ # ---------------------------------------------------------------- 加载指示器
725
+
726
+ class _IndeterminateBar:
727
+ """自渲染不定进度条:按**绘制时**的区域宽度取景并居中。
728
+
729
+ 不依赖 ``Widget.size``:覆盖层是交给合成器临时绘制的,首帧 ``_size`` 可能
730
+ 尚未由 ``_size_updated`` 写入,若在 ``render()`` 里读 ``self.size`` 会拿到 0
731
+ 而画出一格宽的「隐形条」。改用 Rich 渲染协议,``options.max_width`` 即为本次
732
+ 真正要绘制的区域宽度,保证任何一帧都居中可见。
733
+ """
734
+
735
+ def __init__(
736
+ self, clock: Clock, animation_level: str, highlight: Style, track: Style, cap: int
737
+ ) -> None:
738
+ self._clock = clock
739
+ self._animation_level = animation_level
740
+ self._highlight = highlight
741
+ self._track = track
742
+ self._cap = cap
743
+
744
+ def __rich_console__(self, console, options): # noqa: ANN001, ANN201
745
+ total = max(1, options.max_width)
746
+ width = min(total, self._cap)
747
+ bar = max(2, int(width * 0.25))
748
+ span = width + bar
749
+ if self._animation_level == "none":
750
+ start = 0
751
+ else:
752
+ speed = 30 # cells/s,与 Textual ProgressBar 的 indeterminate 一致
753
+ pos = int((speed * self._clock.time) % (2 * span))
754
+ if pos > span:
755
+ pos = 2 * span - pos
756
+ start = pos - bar
757
+ text = Text()
758
+ for i in range(width):
759
+ text.append("━", style=self._highlight if start <= i < start + bar else self._track)
760
+ yield text
761
+
762
+
763
+ class ProgressCover(Widget):
764
+ """控件 loading=True 时覆盖其区域的不定进度条(方框 + 居中往复高亮条)。
765
+
766
+ 必须是**单个自渲染 widget**:Textual 的 ``Widget._cover()`` 只把它交给合成器
767
+ 替换原控件区域,并不挂进 DOM 参与布局;若用容器 + 子件,子件 region 恒为 0
768
+ 而完全不可见(``ProgressBar`` 自身 compose 出 ``Bar`` 子件,因此同样不可用)。
769
+ """
770
+
771
+ COMPONENT_CLASSES = {"pc--bar", "pc--highlight"}
772
+
773
+ DEFAULT_CSS = """
774
+ ProgressCover {
775
+ width: 100%;
776
+ height: 100%;
777
+ background: $surface;
778
+ border: round $secondary;
779
+ margin: 1 1 0 1;
780
+ content-align: center middle;
781
+ &> .pc--bar { color: $panel-lighten-1; background: $surface; }
782
+ &> .pc--highlight { color: $primary; background: $surface; }
783
+ }
784
+ """
785
+
786
+ BAR_WIDTH = 40
787
+ """进度条固定宽(与全屏 BusyOverlay `.busy-box` 的内容宽一致),由 content-align 居中。"""
788
+
789
+ def __init__(self) -> None:
790
+ super().__init__()
791
+ self._clock = Clock()
792
+
793
+ def on_mount(self) -> None:
794
+ self._clock.reset()
795
+ self.auto_refresh = 1 / 15
796
+
797
+ def render(self) -> _IndeterminateBar:
798
+ highlight = Style.from_color(self.get_component_rich_style("pc--highlight").color)
799
+ track = Style.from_color(self.get_component_rich_style("pc--bar").color)
800
+ return _IndeterminateBar(
801
+ self._clock, self.app.animation_level, highlight, track, self.BAR_WIDTH
802
+ )
803
+
804
+
805
+ class BusyOverlay(ModalScreen[None]):
806
+ """全屏忙碌遮罩:阻断一切输入直到被 pop。
807
+
808
+ 使用固定宽度(46)与分行结构防止文案变化引起容器抖动或 Emoji 换行。
809
+ """
810
+
811
+ DEFAULT_CSS = """
812
+ BusyOverlay {
813
+ align: center middle;
814
+ background: $boost;
815
+ }
816
+ BusyOverlay .busy-box {
817
+ width: 46;
818
+ height: auto;
819
+ min-height: 5;
820
+ padding: 1 2;
821
+ border: round $primary;
822
+ background: $surface;
823
+ align: center middle;
824
+ }
825
+ BusyOverlay #busy-title {
826
+ width: 100%;
827
+ content-align: center middle;
828
+ text-style: bold;
829
+ text-wrap: nowrap;
830
+ text-overflow: ellipsis;
831
+ overflow: hidden;
832
+ margin-bottom: 1;
833
+ }
834
+ BusyOverlay LoadingIndicator { width: 100%; height: 1; }
835
+ BusyOverlay ProgressBar { width: 100%; }
836
+ BusyOverlay #busy-counter {
837
+ width: 100%;
838
+ content-align: center middle;
839
+ color: $text-muted;
840
+ margin-top: 1;
841
+ }
842
+ """
843
+
844
+ def __init__(self, message: str = "正在处理…", *, mode: str = "dots", total: float | None = None) -> None:
845
+ super().__init__()
846
+ self._message = message
847
+ self._mode = mode
848
+ self._total = total
849
+ self._done = 0.0
850
+
851
+ def compose(self):
852
+ with Vertical(classes="busy-box"):
853
+ yield Static(self._message, id="busy-title")
854
+ if self._mode == "bar":
855
+ yield ProgressBar(total=None, show_bar=True, show_percentage=False, show_eta=False)
856
+ elif self._mode == "percent":
857
+ yield ProgressBar(total=self._total, show_percentage=True, show_eta=False)
858
+ yield Static(f"0/{int(self._total or 0)} (0%)", id="busy-counter")
859
+ else:
860
+ yield LoadingIndicator()
861
+
862
+ def set_message(self, message: str) -> None:
863
+ self._message = message
864
+ if self.is_mounted:
865
+ with contextlib.suppress(NoMatches):
866
+ self.query_one("#busy-title", Static).update(message)
867
+
868
+ def advance(self, amount: float = 1) -> None:
869
+ """percent 模式:推进进度条并刷新计数文案。"""
870
+ self._done += amount
871
+ if self.is_mounted:
872
+ with contextlib.suppress(NoMatches):
873
+ bar = self.query_one(ProgressBar)
874
+ bar.progress += amount
875
+ if self._mode == "percent":
876
+ pct = int(round((self._done / (self._total or 1)) * 100))
877
+ self.query_one("#busy-counter", Static).update(
878
+ f"{int(self._done)}/{int(self._total or 0)} ({pct}%)"
879
+ )
880
+
881
+
882
+ @contextlib.asynccontextmanager
883
+ async def busy(app, message: str = "正在处理…", *, mode: str = "dots", total: float | None = None):
884
+ """异步 worker 内包裹耗时操作,全程显示全屏忙碌遮罩(异常/取消亦收起)。
885
+
886
+ 用法:async with busy(self.app, "正在保存…", mode="bar"): await asyncio.to_thread(...)
887
+ 需要进度回调时接住返回的遮罩对象:async with busy(app, "拉取中", mode="percent", total=n) as ov: ...
888
+ """
889
+ overlay = BusyOverlay(message, mode=mode, total=total)
890
+ app.push_screen(overlay)
891
+ try:
892
+ yield overlay
893
+ finally:
894
+ # 确保遮罩被收起:可能仍是栈顶,也可能已被其它层覆盖
895
+ for _ in range(len(app.screen_stack)):
896
+ if isinstance(app.screen, BusyOverlay) or app.screen is overlay:
897
+ app.pop_screen()
898
+ else:
899
+ break
900
+ if overlay.is_mounted:
901
+ overlay.remove()
902
+
903
+
904
+ # ---------------------------------------------------------------- 控件就地忙碌态
905
+
906
+ BUSY_MIN_SECONDS = 0.3
907
+ """忙碌态最短可见时长(秒):本地 API 常在 1 帧内返回,过短的反馈肉眼看不见。"""
908
+
909
+
910
+ async def hold_busy(started: float) -> None:
911
+ """把忙碌反馈补足到 BUSY_MIN_SECONDS,避免快速操作只闪一帧。"""
912
+ remaining = BUSY_MIN_SECONDS - (time.monotonic() - started)
913
+ if remaining > 0:
914
+ await asyncio.sleep(remaining)
915
+
916
+
917
+ class ControlBusy:
918
+ """异步操作期间在触发控件上就地反馈:禁用控件 + 文本换成点阵 spinner。
919
+
920
+ - `Button`:改写自身 `label`(如 `⠋ 重启 Clash`),结束后复原。
921
+ - `Select` / `Switch`:禁用控件,并在同行左侧标签(`.kv-label` / `.fl-label`)前插 spinner。
922
+ """
923
+
924
+ def __init__(self, screen) -> None:
925
+ self.screen = screen
926
+ self._control = None
927
+ self._targets: list[tuple[object, str]] = []
928
+ self._frame = 0
929
+ self._timer = None
930
+
931
+ @staticmethod
932
+ def _row_label(control):
933
+ parent = control.parent
934
+ if parent is not None:
935
+ for child in parent.children:
936
+ if isinstance(child, Static):
937
+ return child
938
+ return None
939
+
940
+ def start(self, control) -> None:
941
+ self.stop()
942
+ self._control = control
943
+ control.disabled = True
944
+ if isinstance(control, Button):
945
+ self._targets.append((control, control.label.plain))
946
+ else:
947
+ label = self._row_label(control)
948
+ if label is not None:
949
+ self._targets.append((label, str(label.content)))
950
+ self._apply()
951
+ self._timer = self.screen.set_interval(SPINNERS["dots"]["interval"] / 1000, self._tick)
952
+
953
+ def _apply(self) -> None:
954
+ frames = SPINNERS["dots"]["frames"]
955
+ glyph = frames[self._frame % len(frames)]
956
+ for widget, original in self._targets:
957
+ if isinstance(widget, Button):
958
+ widget.label = f"{glyph} {original}"
959
+ else:
960
+ widget.update(f"{glyph} {original}")
961
+
962
+ def _tick(self) -> None:
963
+ self._frame += 1
964
+ self._apply()
965
+
966
+ def stop(self) -> None:
967
+ if self._timer is not None:
968
+ self._timer.stop()
969
+ self._timer = None
970
+ for widget, original in self._targets:
971
+ if isinstance(widget, Button):
972
+ widget.label = original
973
+ else:
974
+ widget.update(original)
975
+ self._targets = []
976
+ if self._control is not None:
977
+ self._control.disabled = False
978
+ self._control = None
979
+
980
+
702
981
  __all__ = [
703
982
  "ConfirmModal", "InputModal", "FormField", "FormModal", "OutputModal",
704
983
  "ClickTable", "make_table", "load_rows", "filter_fuzzy", "fuzzy_score",
705
984
  "shorten", "rcell", "fit_table_columns",
985
+ "ProgressCover", "BusyOverlay", "busy", "ControlBusy", "hold_busy", "BUSY_MIN_SECONDS",
706
986
  "tint", "colored_text",
707
987
  "STYLE_OK", "STYLE_ERR", "STYLE_WARN", "STYLE_INFO", "STYLE_DIM",
708
988
  "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: clash_controller
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: A Textual TUI for managing Clash proxy instances
5
5
  Author-email: Moha-Master <hongkongreporter@outlook.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "clash_controller"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "A Textual TUI for managing Clash proxy instances"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"