clash-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.
- {clash_controller-0.2.0 → clash_controller-0.2.2}/PKG-INFO +1 -1
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/__init__.py +1 -1
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/app.tcss +27 -5
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/screens/config.py +10 -9
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/screens/home.py +14 -20
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/screens/profiles.py +28 -28
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/screens/settings.py +10 -9
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/ui.py +16 -9
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/widgets.py +196 -7
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller.egg-info/PKG-INFO +1 -1
- {clash_controller-0.2.0 → clash_controller-0.2.2}/pyproject.toml +1 -1
- {clash_controller-0.2.0 → clash_controller-0.2.2}/README.md +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/__main__.py +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/api.py +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/app.py +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/config.py +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/deploy.py +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/main.py +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/screens/__init__.py +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/screens/logs.py +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/screens/overview.py +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller/ssh.py +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller.egg-info/SOURCES.txt +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller.egg-info/dependency_links.txt +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller.egg-info/entry_points.txt +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller.egg-info/requires.txt +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller.egg-info/top_level.txt +0 -0
- {clash_controller-0.2.0 → clash_controller-0.2.2}/setup.cfg +0 -0
|
@@ -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:
|
|
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:
|
|
124
|
+
border: round $primary;
|
|
108
125
|
}
|
|
109
126
|
|
|
110
127
|
/* ---------- 信息面板 ---------- */
|
|
@@ -155,7 +172,7 @@ Screen {
|
|
|
155
172
|
}
|
|
156
173
|
|
|
157
174
|
.kv-row Select.kv-ctl {
|
|
158
|
-
|
|
175
|
+
width: 12;
|
|
159
176
|
}
|
|
160
177
|
|
|
161
178
|
/* ---------- 首页 / 端点选择(居中 LOGO 版式,.logo-page) ---------- */
|
|
@@ -165,12 +182,17 @@ Screen {
|
|
|
165
182
|
}
|
|
166
183
|
|
|
167
184
|
#home-col {
|
|
168
|
-
width:
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
64
|
-
|
|
65
|
-
|
|
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,
|
|
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"
|
|
225
|
-
yield Button("编辑", id="edit"
|
|
226
|
-
yield Button("删除", id="delete", variant="error"
|
|
227
|
-
yield
|
|
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.
|
|
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
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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"
|
|
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"
|
|
162
|
+
yield Button("菜单 ▾", id="top-menu")
|
|
156
163
|
with Vertical(id="page"):
|
|
157
164
|
yield from self.compose_page()
|
|
158
|
-
yield
|
|
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",
|
|
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
|
|
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",
|
|
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
|
|
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",
|
|
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)))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{clash_controller-0.2.0 → clash_controller-0.2.2}/clash_controller.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|