feedscope 0.1.0__py3-none-any.whl
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.
- feedscope/__init__.py +3 -0
- feedscope/__main__.py +4 -0
- feedscope/cli.py +154 -0
- feedscope/core/__init__.py +1 -0
- feedscope/core/browser.py +367 -0
- feedscope/core/config.py +367 -0
- feedscope/core/const.py +64 -0
- feedscope/core/display.py +62 -0
- feedscope/core/http.py +104 -0
- feedscope/core/i18n/__init__.py +202 -0
- feedscope/core/i18n/en.toml +447 -0
- feedscope/core/i18n/zh-CN.toml +444 -0
- feedscope/core/media.py +150 -0
- feedscope/core/models.py +86 -0
- feedscope/core/registry.py +53 -0
- feedscope/core/samples.py +68 -0
- feedscope/core/source.py +668 -0
- feedscope/core/text.py +54 -0
- feedscope/core/timefmt.py +53 -0
- feedscope/core/tree.py +134 -0
- feedscope/doctor.py +54 -0
- feedscope/login.py +168 -0
- feedscope/probe.py +131 -0
- feedscope/report.py +19 -0
- feedscope/sources/__init__.py +38 -0
- feedscope/sources/hn/__init__.py +66 -0
- feedscope/sources/hn/fetch.py +82 -0
- feedscope/sources/hn/parse.py +154 -0
- feedscope/sources/reddit/__init__.py +178 -0
- feedscope/sources/reddit/fetch.py +181 -0
- feedscope/sources/reddit/parse.py +318 -0
- feedscope/sources/rss/__init__.py +64 -0
- feedscope/sources/rss/fetch.py +94 -0
- feedscope/sources/rss/parse.py +116 -0
- feedscope/sources/telegram/__init__.py +61 -0
- feedscope/sources/telegram/fetch.py +79 -0
- feedscope/sources/telegram/parse.py +182 -0
- feedscope/sources/v2ex/__init__.py +64 -0
- feedscope/sources/v2ex/fetch.py +46 -0
- feedscope/sources/v2ex/parse.py +116 -0
- feedscope/sources/x/__init__.py +68 -0
- feedscope/sources/x/fetch.py +490 -0
- feedscope/sources/x/parse.py +535 -0
- feedscope/sources/zhihu/__init__.py +81 -0
- feedscope/sources/zhihu/fetch.py +402 -0
- feedscope/sources/zhihu/parse.py +235 -0
- feedscope/subscriptions.py +423 -0
- feedscope/ui/__init__.py +10 -0
- feedscope/ui/account.py +125 -0
- feedscope/ui/app.py +105 -0
- feedscope/ui/dialogs.py +127 -0
- feedscope/ui/feedback.py +64 -0
- feedscope/ui/focus_nav.py +113 -0
- feedscope/ui/labels.py +38 -0
- feedscope/ui/layout.py +66 -0
- feedscope/ui/list_render.py +42 -0
- feedscope/ui/screens/__init__.py +1 -0
- feedscope/ui/screens/answer.py +296 -0
- feedscope/ui/screens/detail.py +274 -0
- feedscope/ui/screens/language.py +59 -0
- feedscope/ui/screens/main.py +736 -0
- feedscope/ui/screens/main_setup.py +226 -0
- feedscope/ui/screens/reading.py +384 -0
- feedscope/ui/screens/source_setup.py +217 -0
- feedscope/ui/screens/subscriptions.py +196 -0
- feedscope/ui/theme.py +45 -0
- feedscope/ui/wrap.py +157 -0
- feedscope-0.1.0.dist-info/METADATA +302 -0
- feedscope-0.1.0.dist-info/RECORD +72 -0
- feedscope-0.1.0.dist-info/WHEEL +4 -0
- feedscope-0.1.0.dist-info/entry_points.txt +2 -0
- feedscope-0.1.0.dist-info/licenses/LICENSE +21 -0
feedscope/ui/dialogs.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""弹层底座与轻弹层:居中卡片 + 按钮行的骨架只写一次。
|
|
2
|
+
|
|
3
|
+
之前是三胞胎(账号框 / 确认框 / 输入框各抄一份 compose 与底座 CSS);
|
|
4
|
+
差异本来只有三件事——提示文案、确认按钮的词、要不要多一个输入框。
|
|
5
|
+
卡片 CSS 与 Esc 取消在 ArrowModalScreen 唯一实现(.modal-card +
|
|
6
|
+
action_cancel,各弹层永远同形),本模块只剩轻弹层自己的增量。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TypeVar
|
|
12
|
+
|
|
13
|
+
from textual.app import ComposeResult
|
|
14
|
+
from textual.containers import Horizontal, Vertical
|
|
15
|
+
from textual.widgets import Button, Input, Label
|
|
16
|
+
|
|
17
|
+
from feedscope.ui.focus_nav import ArrowModalScreen
|
|
18
|
+
from feedscope.core.i18n import t
|
|
19
|
+
|
|
20
|
+
_DialogT = TypeVar("_DialogT")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DialogScreen(ArrowModalScreen[_DialogT]):
|
|
24
|
+
"""居中卡片弹层底座(泛型 = dismiss 值类型):卡片宽度约束 + 提示行样式;
|
|
25
|
+
compose 与取消值留给子类(Confirm -> bool,Input -> str | None)。"""
|
|
26
|
+
|
|
27
|
+
DEFAULT_CSS = """
|
|
28
|
+
.dialog-card { max-width: 64; }
|
|
29
|
+
.dialog-prompt { padding-bottom: 1; }
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def action_cancel(self) -> None:
|
|
33
|
+
raise NotImplementedError # 子类声明自己的取消值(False / None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConfirmScreen(DialogScreen[bool]):
|
|
37
|
+
"""轻确认弹框:True=确认,Esc/取消=False。
|
|
38
|
+
|
|
39
|
+
PROMPT_ID / OK_ID 是语义化 id 的挂点(账号框覆写成 account-*,
|
|
40
|
+
测试与排错好认),按钮 id 只在屏内寻址、跨屏不撞。
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
PROMPT_ID = "confirm-prompt"
|
|
44
|
+
OK_ID = "confirm-ok"
|
|
45
|
+
|
|
46
|
+
def __init__(self, prompt: str, confirm_label: str = "") -> None:
|
|
47
|
+
# confirm_label 空串 = 默认键(构造时解析:类体默认值在 import 期,语言未定)
|
|
48
|
+
confirm_label = confirm_label or t("dialogs.confirm")
|
|
49
|
+
super().__init__()
|
|
50
|
+
self._prompt = prompt
|
|
51
|
+
self._confirm_label = confirm_label
|
|
52
|
+
|
|
53
|
+
def compose(self) -> ComposeResult:
|
|
54
|
+
with Vertical(classes="modal-card dialog-card"):
|
|
55
|
+
yield Label(self._prompt, id=self.PROMPT_ID, classes="dialog-prompt")
|
|
56
|
+
with Horizontal(classes="modal-actions"):
|
|
57
|
+
yield Button(self._confirm_label, id=self.OK_ID, variant="primary")
|
|
58
|
+
yield Button(t("dialogs.cancel"), id="confirm-cancel")
|
|
59
|
+
|
|
60
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
61
|
+
self.dismiss(event.button.id == self.OK_ID)
|
|
62
|
+
|
|
63
|
+
def action_cancel(self) -> None:
|
|
64
|
+
self.dismiss(False)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AccountScreen(ConfirmScreen):
|
|
68
|
+
"""账号操作弹框:登录 / 登出一个源。返回 True=确认、False=取消。
|
|
69
|
+
|
|
70
|
+
只在「选择」阶段可见;确认后即 dismiss,真正的有头登录 / 清 cookie 由
|
|
71
|
+
后台 worker 执行(期间 profile 锁冲突由登录进行中标志挡刷新兜底)。
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
PROMPT_ID = "account-prompt"
|
|
75
|
+
OK_ID = "account-go"
|
|
76
|
+
|
|
77
|
+
def __init__(self, label: str, action_key: str) -> None:
|
|
78
|
+
# action_key 是文案键(dialogs.login / dialogs.logout):动作词与提示语一次解析
|
|
79
|
+
action = t(action_key)
|
|
80
|
+
prompt = (
|
|
81
|
+
t("dialogs.login_prompt", label=label)
|
|
82
|
+
if action_key == "dialogs.login"
|
|
83
|
+
else t("dialogs.logout_prompt", label=label)
|
|
84
|
+
)
|
|
85
|
+
super().__init__(prompt, confirm_label=action)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class InputScreen(DialogScreen[str | None]):
|
|
89
|
+
"""单行输入弹层:Enter 确定 / Esc 取消,返回文本(空 = None)。
|
|
90
|
+
|
|
91
|
+
全应用唯一的文本输入(粘贴走终端原生 bracketed paste,从 Finder 拖
|
|
92
|
+
文件进终端即得路径);Input 自己消费字符键(←/→ 是文本光标),↑/↓
|
|
93
|
+
在输入行与按钮行间移动焦点(ArrowModalScreen)。
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
CSS = """
|
|
97
|
+
.dialog-fixed { width: 64; } /* 输入行要 1fr:卡片定宽,输入行才有得铺 */
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, title: str, *, initial: str = "", placeholder: str = "") -> None:
|
|
101
|
+
super().__init__()
|
|
102
|
+
self._title = title
|
|
103
|
+
self._initial = initial
|
|
104
|
+
self._placeholder = placeholder
|
|
105
|
+
|
|
106
|
+
def compose(self) -> ComposeResult:
|
|
107
|
+
with Vertical(classes="modal-card dialog-card dialog-fixed"):
|
|
108
|
+
yield Label(self._title, id="input-title", classes="dialog-prompt")
|
|
109
|
+
yield Input(value=self._initial, placeholder=self._placeholder, id="input-line")
|
|
110
|
+
with Horizontal(classes="modal-actions"):
|
|
111
|
+
yield Button(t("dialogs.ok"), id="input-ok", variant="primary")
|
|
112
|
+
yield Button(t("dialogs.cancel"), id="input-cancel")
|
|
113
|
+
|
|
114
|
+
def on_mount(self) -> None:
|
|
115
|
+
self.query_one("#input-line", Input).focus()
|
|
116
|
+
|
|
117
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
118
|
+
self.dismiss(event.value.strip() or None)
|
|
119
|
+
|
|
120
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
121
|
+
if event.button.id == "input-ok":
|
|
122
|
+
self.dismiss(self.query_one("#input-line", Input).value.strip() or None)
|
|
123
|
+
else:
|
|
124
|
+
self.dismiss(None)
|
|
125
|
+
|
|
126
|
+
def action_cancel(self) -> None:
|
|
127
|
+
self.dismiss(None)
|
feedscope/ui/feedback.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""用户反馈统一出口:对用户的提示措辞与级别都在这里定。
|
|
2
|
+
|
|
3
|
+
分工:notify 是瞬时反馈(数秒后消失);屏内常驻状态(列表空态、详情失败
|
|
4
|
+
文案)由各屏自己挂,但失败文案复用本模块的 failure_text,保证同义同形。
|
|
5
|
+
收件人 App 或 Screen 皆可(Textual 两者都有 notify,屏内调用直接传 self)。
|
|
6
|
+
错误分级直接用 Textual notify 自带的 severity(info/warning/error),不另造枚举。
|
|
7
|
+
|
|
8
|
+
action 是动作文案键(feedback.*):请求/添加/登出/评论加载/加载更多失败
|
|
9
|
+
各有一键,标题跟随动作——登出失败别写成「抓取失败」。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from textual.app import App
|
|
15
|
+
from textual.screen import Screen
|
|
16
|
+
|
|
17
|
+
from feedscope.core.i18n import has, t
|
|
18
|
+
from feedscope.core.source import Source, SourceError
|
|
19
|
+
|
|
20
|
+
ACTION_FETCH = "feedback.action_fetch" # 默认动作(列表/详情首载)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _action_text(action: str) -> str:
|
|
24
|
+
"""动作参数 -> 展示文本:键走 t(),非键(历史调用/特殊拼接)直通。"""
|
|
25
|
+
return t(action) if has(action) else action
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def failure_text(source: Source | None, exc: Exception, action: str = ACTION_FETCH) -> str:
|
|
29
|
+
"""面向用户的失败文案。
|
|
30
|
+
|
|
31
|
+
SourceError 的消息已是完整文案(源在边界包装时带上了源标签与原因);
|
|
32
|
+
其余技术异常(playwright 超时之类)才在这里补「{源} {动作}:」前缀。
|
|
33
|
+
source 为 None(无源语境的业务流,如订阅添加)只补「{动作}:」。
|
|
34
|
+
"""
|
|
35
|
+
if isinstance(exc, SourceError):
|
|
36
|
+
return str(exc)
|
|
37
|
+
if source is None:
|
|
38
|
+
return t("feedback.failure", action=_action_text(action), error=exc)
|
|
39
|
+
return t(
|
|
40
|
+
"feedback.failure_with_source",
|
|
41
|
+
source=source.label_text, action=_action_text(action), error=exc,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def fail(
|
|
46
|
+
app: App | Screen, source: Source | None, exc: Exception, action: str = ACTION_FETCH
|
|
47
|
+
) -> None:
|
|
48
|
+
"""失败通知(error 级)。标题跟随动作:登出/加载更多失败别都写成「抓取失败」。"""
|
|
49
|
+
title = (
|
|
50
|
+
t("feedback.title_fetch_failed")
|
|
51
|
+
if action == ACTION_FETCH
|
|
52
|
+
else _action_text(action)
|
|
53
|
+
)
|
|
54
|
+
app.notify(failure_text(source, exc, action), title=title, severity="error")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def warn(app: App | Screen, msg: str, title: str) -> None:
|
|
58
|
+
"""警告通知(warning 级),如需要登录。"""
|
|
59
|
+
app.notify(msg, title=title, severity="warning")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def info(app: App | Screen, msg: str) -> None:
|
|
63
|
+
"""普通通知(info 级),如「没有更多了」。"""
|
|
64
|
+
app.notify(msg)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""弹层的方向键焦点移动:与主屏同一套「方向键管焦点」的键位语言。
|
|
2
|
+
|
|
3
|
+
弹层结构都是「纵向列表 + 底部按钮行」:列表到底再 ↓ 移交按钮行、按钮上
|
|
4
|
+
按 ↑ 回列表、按钮行内 ←/→ 平移——不碰 Tab 也能全键盘走完任何弹层。
|
|
5
|
+
列表的 ↑/↓ 是控件级原生键(先在本列表内移动),「到底走出」必须由列表
|
|
6
|
+
子类在到边时自己移交焦点;按钮/输入框不绑方向键,屏级绑定直接命中
|
|
7
|
+
(屏级先于 App 级,主屏的导航绑定不会掺和——主屏键已随 MainScreen 归屏)。
|
|
8
|
+
|
|
9
|
+
ArrowModalScreen 的焦点链是线性的(布局顺序),←/↑ 与 →/↓ 两两同义——
|
|
10
|
+
不假称空间语义;四个方向仍各留一个 action 方法,因为 → 是覆写点
|
|
11
|
+
(F2 源管理里「列表上 → 进订阅管理」,见 source_setup 的 action_nav_right)。
|
|
12
|
+
|
|
13
|
+
ArrowModalScreen 同时承载弹层家族的共享样式(DEFAULT_CSS 沿 MRO 合并):
|
|
14
|
+
居中卡片(.modal-card)与按钮行(.modal-actions)全弹层同形、唯一实现;
|
|
15
|
+
Esc 取消也在这层声明,子类只实现 action_cancel 的取消值。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import ClassVar, TypeVar
|
|
21
|
+
|
|
22
|
+
from textual.binding import Binding
|
|
23
|
+
from textual.screen import ModalScreen
|
|
24
|
+
|
|
25
|
+
from feedscope.ui import Bindings
|
|
26
|
+
from textual.widgets import ListView, SelectionList
|
|
27
|
+
|
|
28
|
+
T = TypeVar("T")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ArrowModalScreen(ModalScreen[T]):
|
|
32
|
+
"""弹层基类:←/↑ = 焦点后移、→/↓ = 焦点前移(焦点链 = 布局顺序)+ Esc 取消。
|
|
33
|
+
|
|
34
|
+
必须是 ModalScreen 子类才能进绑定的 MRO 合并(裸 mixin 的 BINDINGS
|
|
35
|
+
会被 _merge_bindings 跳过);列表类控件自己绑 ↑/↓ 优先在列表内移动,
|
|
36
|
+
这里的屏级绑定只在聚焦控件不消费方向键(按钮/输入框空档)时命中。
|
|
37
|
+
需要给 → 留屏内语义的(F2 的管理订阅)覆写 action_nav_right 即可;
|
|
38
|
+
action_cancel 的取消值(False / None / 改动标记)由子类声明。
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
BINDINGS: ClassVar[Bindings] = [
|
|
42
|
+
Binding("left", "nav_left", show=False),
|
|
43
|
+
Binding("right", "nav_right", show=False),
|
|
44
|
+
Binding("up", "nav_up", show=False),
|
|
45
|
+
Binding("down", "nav_down", show=False),
|
|
46
|
+
Binding("escape", "cancel", "bindings.cancel", show=False),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
# 弹层卡片三件套的唯一实现(所有弹层同形):宽度约束(min/max-width)
|
|
50
|
+
# 是各弹层差异,写在各屏自己的 CSS 里
|
|
51
|
+
DEFAULT_CSS = """
|
|
52
|
+
.modal-card {
|
|
53
|
+
width: auto; height: auto;
|
|
54
|
+
background: $surface; border: ascii $primary; padding: 1 2;
|
|
55
|
+
}
|
|
56
|
+
.modal-actions { height: auto; }
|
|
57
|
+
.modal-actions Button { margin-right: 1; }
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def action_nav_left(self) -> None:
|
|
61
|
+
self.focus_previous() # focus_next/previous 是 Screen 的方法
|
|
62
|
+
|
|
63
|
+
def action_nav_right(self) -> None:
|
|
64
|
+
self.focus_next()
|
|
65
|
+
|
|
66
|
+
def action_nav_up(self) -> None:
|
|
67
|
+
self.focus_previous()
|
|
68
|
+
|
|
69
|
+
def action_nav_down(self) -> None:
|
|
70
|
+
self.focus_next()
|
|
71
|
+
|
|
72
|
+
def action_cancel(self) -> None:
|
|
73
|
+
raise NotImplementedError # 子类声明自己的取消值(False / None / 改动标记)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _exit_at_end(widget, *, at_end: bool) -> None:
|
|
77
|
+
"""列表的 ↓ 统一出口:末行(或空表)再 ↓ 出列表进按钮行,否则本列表内下移。
|
|
78
|
+
|
|
79
|
+
FocusNav 两族列表共用的「到底走出」约定——各列表只判自己的到边条件
|
|
80
|
+
(SelectionList 看 highlighted/option_count,ListView 看 index/children)。
|
|
81
|
+
"""
|
|
82
|
+
if at_end:
|
|
83
|
+
widget.screen.focus_next()
|
|
84
|
+
else:
|
|
85
|
+
widget.action_cursor_down()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class FocusNavSelectionList(SelectionList):
|
|
89
|
+
"""源勾选列表:末行再 ↓ 移交按钮行(首行 ↑ 留原生夹持——上方无可聚焦)。"""
|
|
90
|
+
|
|
91
|
+
# ↓ 绑定两族列表各声明一份:裸 mixin 的 BINDINGS 不进 Textual 的合并链
|
|
92
|
+
# (同 ArrowModalScreen docstring 的说明),共享基类省不掉这两行——
|
|
93
|
+
# 到边条件与出口统一收在 _exit_at_end,那才是可共享的部分
|
|
94
|
+
BINDINGS: ClassVar[Bindings] = [
|
|
95
|
+
Binding("down", "cursor_down_or_exit", show=False),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
def action_cursor_down_or_exit(self) -> None:
|
|
99
|
+
_exit_at_end(
|
|
100
|
+
self,
|
|
101
|
+
at_end=self.highlighted is None or self.highlighted >= self.option_count - 1,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class FocusNavListView(ListView):
|
|
106
|
+
"""订阅列表:末行再 ↓ 移交按钮行(订阅列表的 Enter 是改名,不受影响)。"""
|
|
107
|
+
|
|
108
|
+
BINDINGS: ClassVar[Bindings] = [
|
|
109
|
+
Binding("down", "cursor_down_or_exit", show=False),
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
def action_cursor_down_or_exit(self) -> None:
|
|
113
|
+
_exit_at_end(self, at_end=self.index is None or self.index >= len(self.children) - 1)
|
feedscope/ui/labels.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""键位文案的 i18n 解析:类级 BINDINGS 的 description 存文案键,挂载时翻译。
|
|
2
|
+
|
|
3
|
+
Binding 的 description 在类体里求值(import 期),那时进程语言未定(测试
|
|
4
|
+
还要在同进程里来回切),description 只能存 i18n 键、挂载时统一解析——
|
|
5
|
+
Footer 实时读 _bindings,替换即生效。这与 reading 系按源单位词 relabel
|
|
6
|
+
(_relabel_bindings,传最终文案)是同一手法的两个用法:一个是静态键、
|
|
7
|
+
一个是动态词。
|
|
8
|
+
|
|
9
|
+
约定:App 与各 Screen 的 on_mount 调 retranslate_bindings(self);带键位
|
|
10
|
+
的 widget(主屏列表:tab/shift+tab 的真实归属)由所属屏一并翻译——漏调
|
|
11
|
+
的地方 Footer 会显示原始键名(肉眼即见,en 冒烟测试兜底)。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from textual.app import App
|
|
20
|
+
from textual.widget import Widget
|
|
21
|
+
|
|
22
|
+
from feedscope.core.i18n import has, t
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def retranslate_bindings(holder: App[Any] | Widget) -> None:
|
|
26
|
+
"""把 holder 键位表里以文案键书写的 description 换成当前语言文本。
|
|
27
|
+
|
|
28
|
+
非键描述直通(has 判定):relabel 后的源单位词等最终文案不是键,不能
|
|
29
|
+
进 t()——否则落到「缺键」警告还会把文案换成键名。
|
|
30
|
+
"""
|
|
31
|
+
table = holder._bindings.key_to_bindings
|
|
32
|
+
for key, bindings in list(table.items()):
|
|
33
|
+
table[key] = [
|
|
34
|
+
dataclasses.replace(b, description=t(b.description))
|
|
35
|
+
if b.description and has(b.description)
|
|
36
|
+
else b
|
|
37
|
+
for b in bindings
|
|
38
|
+
]
|
feedscope/ui/layout.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""布局度量:列表屏与详情屏共用的可用宽度计算、元信息列与 resize 防抖间隔。
|
|
2
|
+
|
|
3
|
+
魔数与 CSS 的对应关系集中在这里——改 CSS 必须同步改这里,
|
|
4
|
+
这是两屏唯一允许出现宽度算术的地方。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from feedscope.core.i18n import t
|
|
10
|
+
from feedscope.core.models import FeedItem
|
|
11
|
+
from feedscope.core.source import Source
|
|
12
|
+
from feedscope.core.text import display_width
|
|
13
|
+
|
|
14
|
+
# 列表行的固定开销(不含元信息列):滚动条(2) + ListItem padding 0 1(2)
|
|
15
|
+
# + .item-title padding-left(1);元信息列宽随批内容自适应(见 score_col_width),
|
|
16
|
+
# 折行算术在 list_title_width 把两者合起来
|
|
17
|
+
LIST_ROW_OVERHEAD = 5
|
|
18
|
+
|
|
19
|
+
# 元信息列(.item-score)宽度算术:列宽 = 批内最长文本 + SPARE 余量(逐行
|
|
20
|
+
# inline 设置,CSS 不写死)。X 署名「名字 @handle」完整可读靠折行而不是加宽
|
|
21
|
+
# 列——正文都能折行,署名同理:超过列宽的在空格处断开,名字一行、@账号
|
|
22
|
+
# 一行(右对齐 + text-wrap: wrap,绝不缩略)。MAX 是列宽上限,保标题列有
|
|
23
|
+
# 足够宽度;18 ≈ 放得下任何 @handle(上限 15 字符)+ 常见短署名单行,
|
|
24
|
+
# 知乎热榜「2606 万热度」这类短元信息也只占实际所需。
|
|
25
|
+
META_COL_SPARE = 2
|
|
26
|
+
META_COL_MAX = 18
|
|
27
|
+
|
|
28
|
+
# 正文 markdown 可用宽 = 容器宽 - 8:
|
|
29
|
+
# 滚动条(2) + 左右 padding 0 2(4) + 2 列余量防 Rich 二次折行
|
|
30
|
+
MD_OVERHEAD = 8
|
|
31
|
+
|
|
32
|
+
# resize 防抖:拖拽改宽会连发 Resize,稳定后再整屏重排
|
|
33
|
+
RESIZE_DEBOUNCE = 0.15 # 秒
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def list_title_width(container_width: int, meta_col_width: int) -> int:
|
|
37
|
+
"""列表标题列的折行可用宽;meta_col_width = 当前批的元信息列宽。"""
|
|
38
|
+
return max(10, container_width - LIST_ROW_OVERHEAD - meta_col_width)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def md_body_width(container_width: int) -> int:
|
|
42
|
+
"""详情正文的折行可用宽。"""
|
|
43
|
+
return max(20, container_width - MD_OVERHEAD)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def meta_col_text(source: Source, it: FeedItem) -> str:
|
|
47
|
+
"""元信息列文本:所有源、所有流同一条规则——「[类型标签] 作者/热度/回复数」。
|
|
48
|
+
|
|
49
|
+
标签预告 Enter 的行为(非详情类型跳浏览器,词汇表归源,见 Source.kind_labels);
|
|
50
|
+
有标签时与元信息并列显示,不再顶掉作者。任何情况下不截断——列宽随批内
|
|
51
|
+
最长内容自适应(见 score_col_width),超上限的长署名在列内折行(换行可以,
|
|
52
|
+
缩略不行),名字与账号必须完整可见。
|
|
53
|
+
"""
|
|
54
|
+
tag = t(source.kind_labels.get(it.kind, ""))
|
|
55
|
+
return f"[{tag}] {it.meta}".strip() if tag else it.meta
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def score_col_width(source: Source, items: list[FeedItem]) -> int:
|
|
59
|
+
"""元信息列宽:批内最长文本的显示宽 + 2 余量(META_COL_MAX 封顶)。
|
|
60
|
+
|
|
61
|
+
自适应让知乎热榜「2606 万热度」这类短元信息只占实际所需;超过 MAX 的
|
|
62
|
+
长署名不加宽列(标题列优先),在列内折行——名字一行、@账号一行,完整
|
|
63
|
+
可见。空列表给 1(空态不显示列表)。
|
|
64
|
+
"""
|
|
65
|
+
longest = max((display_width(meta_col_text(source, it)) for it in items), default=1)
|
|
66
|
+
return min(longest, META_COL_MAX) + META_COL_SPARE
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""列表行的渲染件:FeedItem -> ListItem 部件(纯展示,与度量算术同族)。
|
|
2
|
+
|
|
3
|
+
从 main.py 下沉的三个纯函数(main 只留「何时重建」,「怎么建一行」在这):
|
|
4
|
+
元信息列文本、折行标题、整行部件。度量与 CSS 常量的对应关系在
|
|
5
|
+
ui/layout.py,本模块只做组装。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from rich.markup import escape
|
|
11
|
+
from textual.containers import Horizontal
|
|
12
|
+
from textual.widgets import Label, ListItem
|
|
13
|
+
|
|
14
|
+
from feedscope.core.models import FeedItem
|
|
15
|
+
from feedscope.core.source import Source
|
|
16
|
+
from feedscope.ui.layout import meta_col_text
|
|
17
|
+
from feedscope.ui.wrap import wrap_display
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def meta_col(source: Source, it: FeedItem) -> str:
|
|
21
|
+
"""元信息列的 markup 文本(规则见 layout.meta_col_text);空元信息占一格空位。"""
|
|
22
|
+
text = meta_col_text(source, it)
|
|
23
|
+
return f"[dim]{escape(text)}[/dim]" if text else " "
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_item(it: FeedItem, width: int, score_width: int, source: Source) -> ListItem:
|
|
27
|
+
"""一条 FeedItem -> 列表行部件(元信息列 + 折行标题)。
|
|
28
|
+
|
|
29
|
+
标题按 width 折行(规范版:避头尾 + 词不拆),逐行转义;元信息列宽
|
|
30
|
+
逐行 inline 设置(列宽随批内容自适应,见 layout.score_col_width),
|
|
31
|
+
右对齐与超宽折行走 CSS(.item-score / .item-title)。
|
|
32
|
+
"""
|
|
33
|
+
title = "\n".join(escape(line) for line in wrap_display(it.title, width))
|
|
34
|
+
score = Label(meta_col(source, it), classes="item-score")
|
|
35
|
+
score.styles.width = score_width
|
|
36
|
+
return ListItem(
|
|
37
|
+
Horizontal(
|
|
38
|
+
score,
|
|
39
|
+
Label(title, classes="item-title"),
|
|
40
|
+
classes="item-row",
|
|
41
|
+
)
|
|
42
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""TUI 屏幕:主屏在 main.py,问题详情屏在 detail.py,回答详情屏在 answer.py。"""
|