clash-controller 0.2.1__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 (28) hide show
  1. {clash_controller-0.2.1 → clash_controller-0.2.2}/PKG-INFO +1 -1
  2. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/__init__.py +1 -1
  3. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/app.tcss +26 -4
  4. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/screens/config.py +10 -9
  5. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/screens/home.py +14 -20
  6. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/screens/profiles.py +28 -28
  7. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/screens/settings.py +10 -9
  8. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/ui.py +16 -9
  9. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/widgets.py +196 -7
  10. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller.egg-info/PKG-INFO +1 -1
  11. {clash_controller-0.2.1 → clash_controller-0.2.2}/pyproject.toml +1 -1
  12. {clash_controller-0.2.1 → clash_controller-0.2.2}/README.md +0 -0
  13. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/__main__.py +0 -0
  14. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/api.py +0 -0
  15. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/app.py +0 -0
  16. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/config.py +0 -0
  17. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/deploy.py +0 -0
  18. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/main.py +0 -0
  19. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/screens/__init__.py +0 -0
  20. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/screens/logs.py +0 -0
  21. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/screens/overview.py +0 -0
  22. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller/ssh.py +0 -0
  23. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller.egg-info/SOURCES.txt +0 -0
  24. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller.egg-info/dependency_links.txt +0 -0
  25. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller.egg-info/entry_points.txt +0 -0
  26. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller.egg-info/requires.txt +0 -0
  27. {clash_controller-0.2.1 → clash_controller-0.2.2}/clash_controller.egg-info/top_level.txt +0 -0
  28. {clash_controller-0.2.1 → clash_controller-0.2.2}/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.2
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.2"
@@ -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,7 +4,7 @@ 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
@@ -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:
@@ -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
@@ -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} 个端点")
@@ -29,15 +29,16 @@ class SettingsScreen(PageScreen):
29
29
  with Horizontal(classes="kv-row gap-top"):
30
30
  yield Static("模式", classes="kv-label")
31
31
  yield Select(MODES, value="rule", allow_blank=False, compact=True, id="st-mode", classes="kv-ctl")
32
- with Horizontal(classes="filter-row gap-top"):
33
- yield Static("核心操作", classes="fl-label")
34
- yield Button("重载 GEO", id="reload-geo", compact=True)
35
- with Horizontal(classes="filter-row gap-top"):
36
- yield Static("重启升级", classes="fl-label")
37
- yield Button("重启 Clash", id="restart", variant="error", compact=True)
38
- yield Button("升级内核", id="up-kernel", compact=True)
39
- yield Button("升级 UI", id="up-ui", compact=True)
40
- yield Button("升级 GEO", id="up-geo", compact=True)
32
+ with Vertical(classes="panel"):
33
+ with Horizontal(classes="filter-row"):
34
+ yield Static("核心操作", classes="fl-label")
35
+ yield Button("重载 GEO", id="reload-geo")
36
+ with Horizontal(classes="filter-row gap-top"):
37
+ yield Static("重启升级", classes="fl-label")
38
+ yield Button("重启 Clash", id="restart", variant="error")
39
+ yield Button("升级内核", id="up-kernel")
40
+ yield Button("升级 UI", id="up-ui")
41
+ yield Button("升级 GEO", id="up-geo")
41
42
 
42
43
  def on_mount(self) -> None:
43
44
  self.set_subtitle((self.app.profile or {}).get("name", ""))
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
  from collections.abc import Callable
8
8
  from typing import ClassVar
9
9
 
10
+ from rich.cells import cell_len
10
11
  from rich.text import Text
11
12
  from textual import on
12
13
  from textual.binding import Binding
@@ -19,7 +20,7 @@ try: # 不同 textual 版本 Option 导出位置不同
19
20
  except ImportError: # pragma: no cover
20
21
  from textual.widgets._option_list import Option
21
22
 
22
- from .widgets import EDIT_HINT, STYLE_ERR
23
+ from .widgets import EDIT_HINT, STYLE_ERR, HintBar
23
24
 
24
25
  # ---------------------------------------------------------------- 首页 logo
25
26
 
@@ -32,11 +33,16 @@ LOGO_LINES = [
32
33
  " ╚═════╝╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚══════╝",
33
34
  ]
34
35
 
35
- LOGO_WIDTH = max(len(line) for line in LOGO_LINES)
36
+ LOGO_WIDTH = max(cell_len(line.rstrip()) for line in LOGO_LINES) # 随 LOGO_LINES 自动计算,换 logo 无需改别处
36
37
 
37
38
 
38
39
  def logo_text() -> Text:
39
- return Text("\n".join(LOGO_LINES))
40
+ return Text("\n".join(line.rstrip() for line in LOGO_LINES))
41
+
42
+
43
+ def home_col_width() -> int:
44
+ """首页栏列宽:跟随 logo,菜单文字更宽时以 40 列兜底。"""
45
+ return max(LOGO_WIDTH, 40)
40
46
 
41
47
 
42
48
  # ---------------------------------------------------------------- 页面骨架
@@ -78,7 +84,8 @@ class PageScreen(Screen):
78
84
  background: $panel;
79
85
  layout: horizontal;
80
86
  }
81
- PageScreen #topbar Button {
87
+ PageScreen #topbar #top-back,
88
+ PageScreen #topbar #top-menu {
82
89
  margin-right: 2;
83
90
  }
84
91
  PageScreen .tb-left {
@@ -112,7 +119,7 @@ class PageScreen(Screen):
112
119
  overflow-y: hidden;
113
120
  }
114
121
  PageScreen .page-hint {
115
- height: auto;
122
+ height: 1;
116
123
  padding: 0 1;
117
124
  color: $text-muted;
118
125
  background: $surface;
@@ -145,17 +152,17 @@ class PageScreen(Screen):
145
152
 
146
153
  def compose(self):
147
154
  with Horizontal(id="topbar"):
148
- yield Button("◀ 返回", id="top-back", compact=True)
155
+ yield Button("◀ 返回", id="top-back")
149
156
  with Horizontal(classes="tb-left"):
150
157
  yield Static(Text(self.TITLE, style="bold"), classes="tb-title")
151
158
  yield Static(self.subtitle, classes="tb-sub", id="tb-sub")
152
159
  with Horizontal(classes="tb-right"):
153
160
  yield from self.compose_toolbar()
154
161
  if self.menu:
155
- yield Button("菜单 ▾", id="top-menu", compact=True)
162
+ yield Button("菜单 ▾", id="top-menu")
156
163
  with Vertical(id="page"):
157
164
  yield from self.compose_page()
158
- yield Static(self.HINT, classes="page-hint", id="page-hint")
165
+ yield HintBar(self.HINT, classes="page-hint", id="page-hint")
159
166
  if self.menu:
160
167
  with Vertical(id="page-menu"):
161
168
  yield OptionList(id="menu-list")
@@ -272,7 +279,7 @@ class PageScreen(Screen):
272
279
  if not self.is_mounted:
273
280
  return
274
281
  try:
275
- hint = self.query_one("#page-hint", Static)
282
+ hint = self.query_one("#page-hint", HintBar)
276
283
  except Exception: # noqa: BLE001 — 组合早期尚未挂载
277
284
  return
278
285
  hint.update(EDIT_HINT if self._editing() else self.HINT)
@@ -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, TextArea
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
 
@@ -175,7 +180,7 @@ class InputModal(ModalScreen[str | None]):
175
180
  if self.hint_text:
176
181
  yield Static(self.hint_text, classes="im-hint")
177
182
  yield Static("", classes="im-error", id="im-error")
178
- yield Static("", classes="page-hint", id="im-hint")
183
+ yield HintBar("", classes="page-hint", id="im-hint")
179
184
  with Horizontal(classes="btn-row"):
180
185
  yield Button("取消", id="im-cancel", variant="default")
181
186
  yield Button("确定", id="im-ok", variant="primary")
@@ -194,7 +199,7 @@ class InputModal(ModalScreen[str | None]):
194
199
  if not self.is_mounted:
195
200
  return
196
201
  editing = isinstance(self.app.focused, (Input, TextArea))
197
- self.query_one("#im-hint", Static).update(HINT_FORM_EDIT if editing else HINT_FORM)
202
+ self.query_one("#im-hint", HintBar).update(HINT_FORM_EDIT if editing else HINT_FORM)
198
203
 
199
204
  def _submit(self) -> None:
200
205
  val = self.query_one("#im-input", Input).value.strip()
@@ -280,7 +285,7 @@ class FormModal(ModalScreen["dict | None"]):
280
285
  yield Input(
281
286
  value=f.value if f.kind == "text" else "",
282
287
  password=(f.kind == "password"),
283
- placeholder=(f.value if f.kind == "password" else f.placeholder),
288
+ placeholder=(mask_secret(f.value) if f.kind == "password" else f.placeholder),
284
289
  compact=True,
285
290
  id=f"in-{f.name}",
286
291
  )
@@ -302,7 +307,7 @@ class FormModal(ModalScreen["dict | None"]):
302
307
  if f.hint:
303
308
  yield Static(f.hint, classes="frm-note")
304
309
  yield Static("", classes="frm-err", id="frm-error")
305
- yield Static("", classes="page-hint", id="frm-hint")
310
+ yield HintBar("", classes="page-hint", id="frm-hint")
306
311
  with Horizontal(classes="btn-row"):
307
312
  yield Button("取消", id="frm-cancel", variant="default")
308
313
  yield Button(self.ok_label, id="frm-ok", variant="primary")
@@ -333,7 +338,7 @@ class FormModal(ModalScreen["dict | None"]):
333
338
  text = HINT_FORM_EDIT
334
339
  else:
335
340
  text = HINT_FORM
336
- self.query_one("#frm-hint", Static).update(text)
341
+ self.query_one("#frm-hint", HintBar).update(text)
337
342
 
338
343
  def _collect(self) -> "dict | None":
339
344
  """收集表单值;校验失败时写入错误信息并返回 None。可在子类扩展。"""
@@ -425,6 +430,180 @@ class OutputModal(ModalScreen[None]):
425
430
 
426
431
  # ---------------------------------------------------------------- 表格辅助
427
432
 
433
+ class HintBar(ScrollableContainer):
434
+ """单行操作提示栏:内容宽于容器时,像 LED 屏一样来回滚动(端点停留约 3s)。"""
435
+
436
+ DEFAULT_CSS = """
437
+ HintBar {
438
+ height: 1;
439
+ overflow-x: auto;
440
+ overflow-y: hidden;
441
+ scrollbar-size: 0 0;
442
+ }
443
+ HintBar .hint-text {
444
+ width: auto;
445
+ height: 1;
446
+ }
447
+ """
448
+
449
+ TICK = 0.25
450
+ HOLD = 12 # 到达端点后停留的拍数(12 x 0.25s = 3s)
451
+
452
+ def __init__(self, text: str = "", **kwargs) -> None:
453
+ super().__init__(**kwargs)
454
+ self._text = text
455
+ self._direction = 1
456
+ self._hold = 0
457
+
458
+ def compose(self):
459
+ yield Static(Text(self._text, no_wrap=True), classes="hint-text")
460
+
461
+ def on_mount(self) -> None:
462
+ self.set_interval(self.TICK, self._tick)
463
+
464
+ def update(self, text: str) -> None:
465
+ self._text = text
466
+ self._direction = 1
467
+ self._hold = 0
468
+ if self.is_mounted:
469
+ self.query_one(".hint-text", Static).update(Text(text, no_wrap=True))
470
+ self.scroll_x = 0
471
+
472
+ def _tick(self) -> None:
473
+ total = self.virtual_size.width
474
+ view = self.size.width
475
+ if total <= view:
476
+ if self.scroll_x:
477
+ self.scroll_x = 0
478
+ return
479
+ if self._hold > 0:
480
+ self._hold -= 1
481
+ return
482
+ end = total - view
483
+ x = int(self.scroll_x) + self._direction
484
+ if x >= end:
485
+ x = end
486
+ self._direction = -1
487
+ self._hold = self.HOLD
488
+ elif x <= 0:
489
+ x = 0
490
+ self._direction = 1
491
+ self._hold = self.HOLD
492
+ self.scroll_x = x
493
+
494
+
495
+ def marquee_window(text: str, width: int, offset: int) -> str:
496
+ """在 width 单元格内取 text 的循环窗口,起点为第 offset 个字符(跑马灯用)。"""
497
+ if width <= 0 or not text:
498
+ return " " * max(0, width)
499
+ s = text + " "
500
+ n = len(s)
501
+ used = 0
502
+ out: list[str] = []
503
+ i = offset % n
504
+ while used < width and len(out) <= n + width:
505
+ ch = s[i % n]
506
+ w = cell_len(ch) or 1
507
+ if used + w > width:
508
+ break
509
+ out.append(ch)
510
+ used += w
511
+ i += 1
512
+ return "".join(out) + " " * (width - used)
513
+
514
+
515
+ class MenuMarquee:
516
+ """首页 OptionList 菜单行控制器:描述右对齐,超宽时来回跑马灯。
517
+
518
+ 屏幕 on_mount 用法::
519
+
520
+ self._marquee = MenuMarquee(self, "home-list", [(label, desc), ...])
521
+ ol.add_options([Option("") for _ in self._marquee.rows])
522
+ self.call_after_refresh(self._marquee.start)
523
+ """
524
+
525
+ TICK = 0.25
526
+ HOLD = 12
527
+
528
+ def __init__(self, screen, list_id: str, rows, *, label_width: int = 14) -> None:
529
+ self.screen = screen
530
+ self.list = screen.query_one(f"#{list_id}", OptionList)
531
+ self.rows = list(rows)
532
+ self.label_width = label_width
533
+ self.offset = 0
534
+ self.direction = 1
535
+ self.hold = 0
536
+ self._timer = None
537
+
538
+ def placeholder_options(self):
539
+ """与 rows 等长的占位 Option(先 add_options,再由 sync 填充文本)。"""
540
+ return [Option("") for _ in self.rows]
541
+
542
+ def start(self) -> None:
543
+ self.sync()
544
+ if self._timer is None:
545
+ self._timer = self.screen.set_interval(self.TICK, self._tick)
546
+
547
+ def set_rows(self, rows) -> None:
548
+ self.rows = list(rows)
549
+ self.offset = 0
550
+ self.direction = 1
551
+ self.hold = 0
552
+ self.sync()
553
+
554
+ def sync(self) -> None:
555
+ node = self.list
556
+ if not node.is_mounted or node.option_count != len(self.rows):
557
+ return
558
+ for idx in range(len(self.rows)):
559
+ node.replace_option_prompt_at_index(idx, self._prompt(idx))
560
+
561
+ def _room(self) -> int:
562
+ node = self.list
563
+ width = node.content_size.width
564
+ if node.show_vertical_scrollbar:
565
+ width -= node.scrollbar_size_vertical
566
+ return max(6, width - self.label_width - 4)
567
+
568
+ def _prompt(self, idx: int) -> Text:
569
+ label, desc = self.rows[idx]
570
+ label = shorten(label, self.label_width)
571
+ pad = " " * max(0, self.label_width - cell_len(label))
572
+ prefix = f" {idx + 1:<2} {label}{pad}"
573
+ room = self._room()
574
+ if cell_len(desc) <= room:
575
+ window = " " * (room - cell_len(desc)) + desc
576
+ else:
577
+ window = marquee_window(desc, room, self.offset)
578
+ text = Text(no_wrap=True, overflow="crop")
579
+ text.append(prefix, style="bold")
580
+ text.append(window, style="dim")
581
+ return text
582
+
583
+ def _tick(self) -> None:
584
+ room = self._room()
585
+ longest = max((cell_len(d) for _, d in self.rows), default=0)
586
+ if longest <= room:
587
+ if self.offset:
588
+ self.offset = 0
589
+ self.sync()
590
+ return
591
+ if self.hold > 0:
592
+ self.hold -= 1
593
+ return
594
+ span = max(cell_len(d) + 3 for _, d in self.rows if cell_len(d) > room)
595
+ self.offset += self.direction
596
+ if self.offset >= span:
597
+ self.offset = span - 1
598
+ self.direction = -1
599
+ self.hold = self.HOLD
600
+ elif self.offset <= 0:
601
+ self.offset = 0
602
+ self.direction = 1
603
+ self.hold = self.HOLD
604
+ self.sync()
605
+
606
+
428
607
  class ClickTable(DataTable):
429
608
  """列表即操作的表格:单击数据单元格 = 移动光标并立即发 RowSelected。
430
609
 
@@ -477,6 +656,16 @@ def shorten(text: str, limit: int) -> str:
477
656
  return out + "…"
478
657
 
479
658
 
659
+ def mask_secret(v: str | None, keep: int = 4) -> str:
660
+ """密钥/口令脱敏展示:首尾各 keep 位,过短则全圆点(placeholder 专用)。"""
661
+ s = (v or "").strip()
662
+ if not s:
663
+ return ""
664
+ if len(s) <= keep * 2 + 1:
665
+ return "•" * min(8, max(6, len(s)))
666
+ return f"{s[:keep]}…{s[-keep:]}"
667
+
668
+
480
669
  def rcell(text, content_width: int) -> Text:
481
670
  """右对齐单元格:文本在 content_width 内右对齐。"""
482
671
  pad = max(0, content_width - cell_len(str(text)))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clash_controller
3
- Version: 0.2.1
3
+ Version: 0.2.2
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.1"
7
+ version = "0.2.2"
8
8
  description = "A Textual TUI for managing Clash proxy instances"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"