clash-controller 0.2.1__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.1 → clash_controller-0.2.3}/PKG-INFO +1 -1
  2. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/__init__.py +1 -1
  3. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/app.py +66 -3
  4. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/app.tcss +26 -4
  5. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/screens/config.py +63 -61
  6. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/screens/home.py +14 -20
  7. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/screens/overview.py +58 -43
  8. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/screens/profiles.py +28 -28
  9. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/screens/settings.py +39 -21
  10. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/ui.py +16 -9
  11. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/widgets.py +477 -8
  12. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller.egg-info/PKG-INFO +1 -1
  13. {clash_controller-0.2.1 → clash_controller-0.2.3}/pyproject.toml +1 -1
  14. {clash_controller-0.2.1 → clash_controller-0.2.3}/README.md +0 -0
  15. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/__main__.py +0 -0
  16. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/api.py +0 -0
  17. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/config.py +0 -0
  18. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/deploy.py +0 -0
  19. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/main.py +0 -0
  20. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/screens/__init__.py +0 -0
  21. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/screens/logs.py +0 -0
  22. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/ssh.py +0 -0
  23. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller.egg-info/SOURCES.txt +0 -0
  24. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller.egg-info/dependency_links.txt +0 -0
  25. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller.egg-info/entry_points.txt +0 -0
  26. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller.egg-info/requires.txt +0 -0
  27. {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller.egg-info/top_level.txt +0 -0
  28. {clash_controller-0.2.1 → 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.1
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.1"
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")
@@ -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
  }
@@ -104,7 +121,7 @@ Screen {
104
121
  }
105
122
 
106
123
  .tbl:focus {
107
- border: tall $primary;
124
+ border: round $primary;
108
125
  }
109
126
 
110
127
  /* ---------- 信息面板 ---------- */
@@ -165,12 +182,17 @@ Screen {
165
182
  }
166
183
 
167
184
  #home-col {
168
- width: 72;
185
+ width: auto;
169
186
  max-width: 90%;
170
187
  height: auto;
171
188
  }
172
189
 
173
- #home-banner,
190
+ #home-banner {
191
+ width: 1fr;
192
+ text-align: left;
193
+ color: $primary;
194
+ }
195
+
174
196
  #home-plain {
175
197
  width: 1fr;
176
198
  text-align: center;
@@ -4,14 +4,14 @@ import asyncio
4
4
  from rich.text import Text
5
5
  from textual import on, work
6
6
  from textual.binding import Binding
7
- from textual.containers import Horizontal
7
+ from textual.containers import Horizontal, Vertical
8
8
  from textual.widgets import Button, DataTable, Static
9
9
 
10
10
  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
 
@@ -32,14 +32,15 @@ class ConfigScreen(PageScreen):
32
32
  self._display: list = []
33
33
 
34
34
  def compose_page(self):
35
- with Horizontal(classes="filter-row"):
36
- yield Button("拉取并部署", id="deploy", variant="primary", compact=True)
37
- yield Button("+ 新增", id="add", compact=True)
38
- yield Button("编辑", id="edit", compact=True)
39
- yield Button("删除", id="delete", variant="error", compact=True)
40
- yield Button("重载配置", id="reload-api", compact=True)
41
- yield Static("", classes="fill")
42
- yield Static("单击行 = 部署该提供方", classes="fl-label")
35
+ with Vertical(classes="panel"):
36
+ with Horizontal(classes="filter-row"):
37
+ yield Button("拉取并部署", id="deploy", variant="primary")
38
+ yield Button("+ 新增", id="add")
39
+ yield Button("编辑", id="edit")
40
+ yield Button("删除", id="delete", variant="error")
41
+ yield Button("重载配置", id="reload-api")
42
+ yield Static("", classes="fill")
43
+ yield Static("单击行 = 部署该提供方", classes="fl-label")
43
44
  yield make_table("名称", "地址", id="cf-table")
44
45
 
45
46
  def on_mount(self) -> None:
@@ -175,61 +176,62 @@ class ConfigScreen(PageScreen):
175
176
  debug = self.app.debug_mode or bool(profile.get("debug_ssh"))
176
177
  url = provider.get("url", "")
177
178
 
178
- self.app.status(f"正在从 {url} 拉取配置…")
179
- self.app.add_log(f"Fetching config from {url}...")
180
- try:
181
- remote_payload, remote_hash, remote_mod_time = await asyncio.to_thread(fetch_remote_config, url)
182
- local_mtime, local_bytes = await asyncio.to_thread(
183
- read_current_config, profile, self.app.add_log, debug
184
- )
185
- except Exception as e: # noqa: BLE001
186
- self.app.notify_err(f"拉取或读取配置失败: {type(e).__name__}: {e}")
187
- 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
188
190
 
189
- local_hash = compute_sha256(local_bytes)
190
- decision = evaluate_update(remote_hash, remote_mod_time, local_hash, local_mtime)
191
-
192
- proceed = True
193
- if decision == "auto":
194
- self.app.add_log("Remote config newer with different hash. Proceeding without prompt.")
195
- elif decision == "confirm_older":
196
- proceed = await self.app.push_screen_wait(
197
- ConfirmModal("远程配置可能比本地更旧,仍要覆盖目标配置吗?", default_yes=False)
198
- )
199
- elif decision == "confirm_unknown":
200
- proceed = await self.app.push_screen_wait(
201
- ConfirmModal("远程配置内容不同,但无法获取更新时间。是否继续覆盖目标配置?", default_yes=True)
202
- )
203
- else: # confirm_same
204
- proceed = await self.app.push_screen_wait(
205
- ConfirmModal("远程配置与目标配置内容相同,仍要覆盖吗?", default_yes=False)
206
- )
207
-
208
- if not proceed:
209
- self.app.status("已取消更新", "warn")
210
- self.app.add_log("Config update cancelled by user after comparison.")
211
- return
191
+ local_hash = compute_sha256(local_bytes)
192
+ decision = evaluate_update(remote_hash, remote_mod_time, local_hash, local_mtime)
212
193
 
213
- self.app.status("正在部署配置…")
214
- try:
215
- if profile.get("endpoint_type") == "remote":
216
- self.app.add_log("Deploying config to remote endpoint via SSH.")
217
- await asyncio.to_thread(
218
- 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)
219
208
  )
220
- else:
221
- self.app.add_log("Deploying config to local endpoint file.")
222
- await asyncio.to_thread(apply_local_config_file, profile, remote_payload)
223
- except Exception as e: # noqa: BLE001
224
- self.app.notify_err(f"部署配置失败: {type(e).__name__}: {e}")
225
- return
226
209
 
227
- self.app.status("正在通过 Clash API 重载配置…")
228
- try:
229
- result, error = await asyncio.to_thread(self.app.get_api().reload_configs)
230
- except Exception as e: # noqa: BLE001
231
- self.app.notify_err(f"重载配置失败: {type(e).__name__}: {e}")
232
- 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
233
235
  self.app.report("重载配置", result, error)
234
236
 
235
237
  @on(Button.Pressed, "#reload-api")
@@ -7,19 +7,13 @@ 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
  """功能主菜单:概览 / 配置管理 / 设置 / 切换端点。"""
20
16
 
21
- CSS_CLASSES = "logo-page"
22
-
23
17
  def __init__(self) -> None:
24
18
  super().__init__(classes="logo-page")
25
19
 
@@ -46,29 +40,29 @@ class HomeScreen(Screen):
46
40
  name = (self.app.profile or {}).get("name", "")
47
41
  yield Static(f"v{__version__}" + (f" · {name}" if name else ""), id="home-version")
48
42
  yield OptionList(id="home-list")
49
- yield Static("↑↓ 选择 · 回车 进入 · 1-4 直达 · Esc/Ctrl+C 退出", classes="page-hint")
43
+ yield HintBar("↑↓ 选择 · 回车 进入 · 1-4 直达 · Esc/Ctrl+C 退出", classes="page-hint")
50
44
 
51
45
  def on_mount(self) -> None:
52
46
  ol = self.query_one("#home-list", OptionList)
53
- ol.add_options(self._options())
47
+ self._marquee = MenuMarquee(
48
+ self, "home-list", [(label, desc) for _, label, desc in self.ITEMS]
49
+ )
50
+ ol.add_options(self._marquee.placeholder_options())
54
51
  ol.highlighted = 0
55
52
  ol.focus()
56
53
  self._apply_breakpoint()
54
+ self.call_after_refresh(self._marquee.start)
57
55
 
58
56
  def on_resize(self, event) -> None:
59
57
  self._apply_breakpoint()
58
+ if getattr(self, "_marquee", None) is not None:
59
+ self._marquee.sync()
60
60
 
61
61
  def _apply_breakpoint(self) -> None:
62
- """宽度不足以容纳艺术字时降级为普通标题文本。"""
63
- self.set_class(self.size.width < LOGO_WIDTH + 6, "-sm")
64
-
65
- def _options(self):
66
- for i, (_, label, desc) in enumerate(self.ITEMS, start=1):
67
- t = Text()
68
- t.append(f" {i} ", style="bold")
69
- t.append(f"{label:<14}", style="bold")
70
- t.append(desc, style="dim")
71
- yield Option(t)
62
+ """宽度不足以容纳艺术字时降级为普通标题文本;列宽跟随 logo。"""
63
+ small = self.size.width < LOGO_WIDTH + 6
64
+ self.set_class(small, "-sm")
65
+ self.query_one("#home-col").styles.width = None if small else home_col_width()
72
66
 
73
67
  def _open(self, key: str) -> None:
74
68
  from .config import ConfigScreen
@@ -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
 
@@ -11,8 +11,8 @@ from textual.widgets import Button, OptionList, Static
11
11
  from .. import __version__
12
12
  from ..config import load_profiles, save_profiles
13
13
  from ..deploy import default_endpoint_type
14
- from ..ui import LOGO_WIDTH, logo_text
15
- from ..widgets import ConfirmModal, FormField, FormModal, shorten
14
+ from ..ui import LOGO_WIDTH, home_col_width, logo_text
15
+ from ..widgets import ConfirmModal, FormField, FormModal, HintBar, MenuMarquee
16
16
 
17
17
  try: # textual 各版本 Option 导出位置不同
18
18
  from textual.widgets.option_list import Option
@@ -20,8 +20,6 @@ except ImportError: # pragma: no cover
20
20
  from textual.widgets._option_list import Option
21
21
 
22
22
  REQUIRED_FIELDS = ["endpoint_type", "config_directory", "use_sudo"]
23
- NAME_MAX = 14
24
- URL_MAX = 30
25
23
 
26
24
 
27
25
  def validate_profile(profile: dict) -> str | None:
@@ -210,6 +208,7 @@ class ProfileListScreen(Screen):
210
208
  super().__init__(classes="logo-page")
211
209
  self._profiles: list = []
212
210
  self._display: list = []
211
+ self._marquee = None
213
212
 
214
213
  # ------------------------------------------------------------ 组合
215
214
 
@@ -221,15 +220,17 @@ class ProfileListScreen(Screen):
221
220
  yield Static(f"v{__version__}", id="home-version")
222
221
  yield OptionList(id="pf-list")
223
222
  with Horizontal(id="pf-actions"):
224
- yield Button("+ 新增端点", id="add", variant="primary", compact=True)
225
- yield Button("编辑", id="edit", compact=True)
226
- yield Button("删除", id="delete", variant="error", compact=True)
227
- yield Static(self.HINT, classes="page-hint")
223
+ yield Button("+ 新增端点", id="add", variant="primary")
224
+ yield Button("编辑", id="edit")
225
+ yield Button("删除", id="delete", variant="error")
226
+ yield HintBar(self.HINT, classes="page-hint")
228
227
 
229
228
  def on_mount(self) -> None:
229
+ self._marquee = MenuMarquee(self, "pf-list", [])
230
230
  self._load()
231
231
  self.query_one("#pf-list", OptionList).focus()
232
232
  self._apply_breakpoint()
233
+ self.call_after_refresh(self._marquee.start)
233
234
 
234
235
  def on_resize(self, event) -> None:
235
236
  self._apply_breakpoint()
@@ -237,8 +238,10 @@ class ProfileListScreen(Screen):
237
238
  self._rebuild()
238
239
 
239
240
  def _apply_breakpoint(self) -> None:
240
- """宽度不足以容纳艺术字时降级为普通标题文本(与首页一致)。"""
241
- self.set_class(self.size.width < LOGO_WIDTH + 6, "-sm")
241
+ """宽度不足以容纳艺术字时降级为普通标题文本;列宽跟随 logo。"""
242
+ small = self.size.width < LOGO_WIDTH + 6
243
+ self.set_class(small, "-sm")
244
+ self.query_one("#home-col").styles.width = None if small else home_col_width()
242
245
 
243
246
  # ------------------------------------------------------------ 数据加载
244
247
 
@@ -252,31 +255,28 @@ class ProfileListScreen(Screen):
252
255
  def action_refresh_page(self) -> None:
253
256
  self._load()
254
257
 
255
- def _options(self) -> list[Option]:
256
- if not self._display:
257
- return [Option(Text("(暂无端点,Ctrl+N 创建)", style="dim"), disabled=True)]
258
+ def _rows(self) -> list[tuple[str, str]]:
258
259
  labels = {"local": "本地", "remote": "远程"}
259
- opts = []
260
- w = self.size.width or 120
261
- col_width = min(72, w)
262
- url_cap = max(10, col_width - 29 - 4)
263
- for i, p in enumerate(self._display, start=1):
264
- t = Text()
265
- t.append(f" {i} ", style="bold")
266
- t.append(f"{shorten(str(p.get('name', '?')), NAME_MAX):<{NAME_MAX}}", style="bold")
267
- t.append(" · ", style="dim")
268
- t.append(f"{shorten(str(p.get('url', '')), url_cap):<{url_cap}}", style="dim")
269
- t.append(" · ", style="dim")
270
- t.append(labels.get(p.get("endpoint_type", ""), p.get("endpoint_type", "?")))
271
- opts.append(Option(t))
272
- return opts
260
+ rows = []
261
+ for p in self._display:
262
+ kind = labels.get(p.get("endpoint_type", ""), p.get("endpoint_type", "?"))
263
+ name = str(p.get("name", "?") or "?")
264
+ rows.append((name, f"{p.get('url', '')} · {kind}"))
265
+ return rows
273
266
 
274
267
  def _rebuild(self) -> None:
268
+ if self._marquee is None:
269
+ return
275
270
  self._display = list(self._profiles)
276
271
  ol = self.query_one("#pf-list", OptionList)
277
272
  prev = ol.highlighted
278
273
  ol.clear_options()
279
- ol.add_options(self._options())
274
+ if self._display:
275
+ self._marquee.rows = self._rows()
276
+ ol.add_options(self._marquee.placeholder_options())
277
+ self._marquee.set_rows(self._rows())
278
+ else:
279
+ ol.add_options([Option(Text("(暂无端点,Ctrl+N 创建)", style="dim"), disabled=True)])
280
280
  ol.highlighted = prev if prev is not None and 0 <= prev < len(self._display) else (0 if self._display else None)
281
281
  n = len(self._profiles)
282
282
  self.query_one("#home-version", Static).update(f"v{__version__} · {n} 个端点")