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
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
"""主屏(默认屏):顶部站点 tab 条 + 左侧信息流边栏 + 单栏列表。
|
|
2
|
+
|
|
3
|
+
从 app.py 下沉为主屏后,键位归属与守卫关系自然理顺:主屏专属键
|
|
4
|
+
(刷新/翻页/方向导航/F2/Ctrl+U)绑在屏级——弹层与详情屏在前台时这些
|
|
5
|
+
绑定根本不命中,原先 App 级动作里的「是否在主屏」守卫不再是必需品;
|
|
6
|
+
App 只留全局退出与生命周期(见 app.py)。主屏状态(选中位/区域焦点/
|
|
7
|
+
列表数据)也都在这里,外部(AccountFlow 等)经 app.main 访问。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
import webbrowser
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
|
16
|
+
|
|
17
|
+
from rich.markup import escape
|
|
18
|
+
from textual.app import ComposeResult
|
|
19
|
+
from textual.binding import Binding
|
|
20
|
+
from textual.containers import Horizontal, Vertical
|
|
21
|
+
from textual.events import Key, Resize
|
|
22
|
+
from textual.message import Message
|
|
23
|
+
from textual.timer import Timer
|
|
24
|
+
from textual.widget import Widget
|
|
25
|
+
from textual.widgets import Footer, Header, Label, ListView, Static
|
|
26
|
+
|
|
27
|
+
from feedscope.core.models import FeedItem
|
|
28
|
+
from feedscope.core.source import FeedLoader, LoginRequired, Source
|
|
29
|
+
from feedscope.core.i18n import t
|
|
30
|
+
from feedscope.core.text import is_cjk as _is_cjk
|
|
31
|
+
from feedscope.ui import Bindings, feedback
|
|
32
|
+
from feedscope.ui.layout import (
|
|
33
|
+
META_COL_SPARE,
|
|
34
|
+
RESIZE_DEBOUNCE,
|
|
35
|
+
list_title_width,
|
|
36
|
+
score_col_width,
|
|
37
|
+
)
|
|
38
|
+
from feedscope.ui.labels import retranslate_bindings
|
|
39
|
+
from feedscope.ui.list_render import build_item
|
|
40
|
+
from feedscope.ui.screens.detail import DetailScreen
|
|
41
|
+
from feedscope.ui.screens.main_setup import MainSetupScreen
|
|
42
|
+
|
|
43
|
+
_CJK_HINT_INTERVAL = 30.0 # 中文输入指路提示的节流间隔:连打中文只提示一次,不刷屏
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from feedscope.ui.app import FeedScopeApp # 运行时导入会与 app.py 成环
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Pane(StrEnum):
|
|
50
|
+
"""主屏的区域焦点(纯逻辑层,Textual 焦点始终留在列表)。"""
|
|
51
|
+
|
|
52
|
+
LIST = "list"
|
|
53
|
+
SIDEBAR = "sidebar"
|
|
54
|
+
TABS = "tabs"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SourceTabPressed(Message):
|
|
58
|
+
"""顶部站点 tab 被点击:与 Tab 循环共用同一条切源路径。"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, idx: int) -> None:
|
|
61
|
+
self.idx = idx
|
|
62
|
+
super().__init__()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FeedNavPressed(Message):
|
|
66
|
+
"""左侧信息流项被点击:与 ←/→ 共用同一条切流路径。"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, idx: int) -> None:
|
|
69
|
+
self.idx = idx
|
|
70
|
+
super().__init__()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SourceTab(Static):
|
|
74
|
+
"""顶部站点 tab:显示源名,点击切到该源;高亮由 CSS class 决定。
|
|
75
|
+
|
|
76
|
+
不可聚焦(can_focus=False 默认):点击不抢列表焦点,Tab 键语义完全不变。
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, idx: int, label: str, active: bool) -> None:
|
|
80
|
+
super().__init__(
|
|
81
|
+
label,
|
|
82
|
+
id=f"source-tab-{idx}",
|
|
83
|
+
classes="source-tab" + (" source-tab-active" if active else ""),
|
|
84
|
+
)
|
|
85
|
+
self.idx = idx
|
|
86
|
+
|
|
87
|
+
def on_click(self) -> None:
|
|
88
|
+
self.post_message(SourceTabPressed(self.idx))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FeedNavItem(Static):
|
|
92
|
+
"""左侧信息流项:流名,点击切流(键盘走 ←/→);高亮由 CSS class 决定。"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, idx: int, label: str, active: bool) -> None:
|
|
95
|
+
super().__init__(
|
|
96
|
+
label,
|
|
97
|
+
id=f"feed-nav-{idx}",
|
|
98
|
+
classes="feed-nav-item" + (" feed-nav-active" if active else ""),
|
|
99
|
+
)
|
|
100
|
+
self.idx = idx
|
|
101
|
+
|
|
102
|
+
def on_click(self) -> None:
|
|
103
|
+
self.post_message(FeedNavPressed(self.idx))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class FeedListView(ListView):
|
|
107
|
+
"""列表视图:Tab/Shift+Tab 切网站(widget 层绑定压过 Screen 焦点切换)。
|
|
108
|
+
|
|
109
|
+
导航键改派为裸动作名(不带 app. 前缀):沿焦点链冒泡到主屏的
|
|
110
|
+
action_nav_*(列表是主屏唯一可聚焦控件,主屏常态下必经此路)。
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
BINDINGS: ClassVar[Bindings] = [
|
|
114
|
+
# 切站进页脚(与 ←/→ 切流同款):Tab 是切站唯一键位,页脚要给出来路
|
|
115
|
+
Binding("tab", "screen.next_source", "bindings.switch_source"),
|
|
116
|
+
Binding("shift+tab", "screen.prev_source", "bindings.switch_source"),
|
|
117
|
+
# ↑/↓/Enter 同键改派主屏导航动作(压过 ListView 原生选中/移动):
|
|
118
|
+
# 焦点在列表上是主屏常态,区域焦点 _pane 决定这组键到哪——
|
|
119
|
+
# 逻辑集中在主屏,这里只负责把键活着送过去
|
|
120
|
+
Binding("up", "screen.nav_up", show=False),
|
|
121
|
+
Binding("down", "screen.nav_down", show=False),
|
|
122
|
+
Binding("enter", "screen.nav_enter", show=False),
|
|
123
|
+
# home/end/pageup/pagedown 原生是列表视口滚动:键盘在边栏/源栏时
|
|
124
|
+
# 滚一个看不见的列表违反「焦点在哪键就作用在哪」,一并改派分派
|
|
125
|
+
Binding("home", 'screen.nav_scroll("home")', show=False),
|
|
126
|
+
Binding("end", 'screen.nav_scroll("end")', show=False),
|
|
127
|
+
Binding("pageup", 'screen.nav_scroll("pageup")', show=False),
|
|
128
|
+
Binding("pagedown", 'screen.nav_scroll("pagedown")', show=False),
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
132
|
+
super().__init__(*args, **kwargs)
|
|
133
|
+
self._last_width = 0
|
|
134
|
+
self._resize_timer: Timer | None = None
|
|
135
|
+
|
|
136
|
+
def on_resize(self, event: Resize) -> None:
|
|
137
|
+
if event.size.width == self._last_width:
|
|
138
|
+
return
|
|
139
|
+
self._last_width = event.size.width
|
|
140
|
+
screen = self.screen
|
|
141
|
+
if not isinstance(screen, MainScreen) or not screen.items:
|
|
142
|
+
return # 首布局(尚无内容)不算;空列表重排也是空转
|
|
143
|
+
# 拖拽改宽连发 Resize,稳定后再整屏重排(防抖与详情屏同款);宽度
|
|
144
|
+
# 与 _last_width 比对(初值 0),若列表竟在首布局前建好(窄宽挤压
|
|
145
|
+
# 在左),首个 Resize 也会触发重排自愈——详情屏的 rendered-width
|
|
146
|
+
# 自愈同思路,这里列表无渲染态可记,用 0 初值起比对
|
|
147
|
+
if self._resize_timer is not None:
|
|
148
|
+
self._resize_timer.stop()
|
|
149
|
+
self._resize_timer = self.set_timer(RESIZE_DEBOUNCE, screen.rebuild_items)
|
|
150
|
+
|
|
151
|
+
def on_unmount(self) -> None:
|
|
152
|
+
if self._resize_timer is not None:
|
|
153
|
+
self._resize_timer.stop()
|
|
154
|
+
self._resize_timer = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class MainScreen(MainSetupScreen):
|
|
158
|
+
"""站点 tab 条 + 信息流边栏 + 条目列表;全部主屏键位与状态。
|
|
159
|
+
|
|
160
|
+
源启用编排(首启引导 / F2 管理 / 启用集生效)在父类 MainSetupScreen
|
|
161
|
+
(screens/main_setup.py),本类只留渲染、导航与取数。
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def _tui(self) -> FeedScopeApp:
|
|
166
|
+
"""self.app 的收窄视图:本应用的 App 恒为 FeedScopeApp。
|
|
167
|
+
|
|
168
|
+
账号动线(accounts)等自定义成员经它获得静态类型;不直接覆写
|
|
169
|
+
Screen.app(MessagePump.app 是可写 property,覆写签名过不了检)。
|
|
170
|
+
"""
|
|
171
|
+
# cast 用字符串形式:FeedScopeApp 只在 TYPE_CHECKING 下导入(运行时导入会成环)
|
|
172
|
+
return cast("FeedScopeApp", self.app)
|
|
173
|
+
|
|
174
|
+
# 两轴导航:Tab/Shift+Tab 切站、←/→ 切流。键位全部非打印键(输入法免疫:
|
|
175
|
+
# 中文输入法只拦字母/数字进候选框)。屏级绑定只在本屏前台时命中——
|
|
176
|
+
# 弹层/详情屏在前台时这些键归它们管,主屏动作不需要守卫。
|
|
177
|
+
# CLI 惯例的空间语义:↑/↓ 在纵向列表里移动选中;首行再 ↑ 上探源栏
|
|
178
|
+
# (顶部区域在所有内容之上;源栏 ↓ 下探边栏,与边栏首流 ↑ 互逆);
|
|
179
|
+
# 其余区域移动走横向(列表 ← 进边栏、边栏 → 回列表 / ← 去源栏);
|
|
180
|
+
# 列表 → 抵屏幕右缘无事。详情/评论屏的 ←/→ 换篇在各屏自己的绑定。
|
|
181
|
+
BINDINGS: ClassVar[Bindings] = [
|
|
182
|
+
Binding("f5", "refresh", "bindings.refresh"),
|
|
183
|
+
Binding("ctrl+o", "open_browser", "bindings.browser"),
|
|
184
|
+
Binding("f2", "manage_sources", "bindings.manage_sources"),
|
|
185
|
+
Binding("ctrl+u", "account", "bindings.account", show=False),
|
|
186
|
+
Binding("ctrl+n", "more", "bindings.more"),
|
|
187
|
+
# description 存文案键、挂载时解析(见 ui/labels.py):类体求值时语言未定
|
|
188
|
+
Binding("ctrl+l", "language", "bindings.language"),
|
|
189
|
+
Binding("left", "nav_left", "bindings.sidebar"),
|
|
190
|
+
Binding("right", "nav_right", show=False),
|
|
191
|
+
Binding("up", "nav_up", show=False),
|
|
192
|
+
Binding("down", "nav_down", show=False),
|
|
193
|
+
Binding("enter", "nav_enter", show=False),
|
|
194
|
+
Binding("escape", "nav_back", show=False),
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
# 用 DEFAULT_CSS 而非 CSS:Textual 8.x 的默认屏创建路径不装载 Screen.CSS
|
|
198
|
+
# (只有 push/switch 路径装载),自定义默认屏的 CSS 会被静默丢弃;DEFAULT_CSS
|
|
199
|
+
# 随挂件类注册、不依赖装载路径。本屏选择器与 App/其他屏零冲突,低优先级无害
|
|
200
|
+
DEFAULT_CSS = """
|
|
201
|
+
ListItem { padding: 0 1; height: auto; }
|
|
202
|
+
|
|
203
|
+
.item-row { height: auto; }
|
|
204
|
+
/* 宽度不写死:逐行 inline 设为批内最长元信息(见 score_col_width);
|
|
205
|
+
右对齐列,超上限的长署名折行而非缩略(名字与账号必须完整可见) */
|
|
206
|
+
.item-score { text-align: right; text-wrap: wrap; }
|
|
207
|
+
.item-title { width: 1fr; text-wrap: wrap; padding-left: 1; }
|
|
208
|
+
|
|
209
|
+
/* 顶部站点 tab 条:active 仿编辑器打开中的文件 tab(加粗下划线),其余降灰 */
|
|
210
|
+
#source-tabs { height: 1; background: $surface; padding: 0 1; }
|
|
211
|
+
.source-tab { width: auto; padding: 0 1; color: $text-muted; }
|
|
212
|
+
.source-tab-active { color: $text; text-style: bold underline; }
|
|
213
|
+
|
|
214
|
+
/* 左侧信息流边栏:选中与焦点分离——未聚焦的选中态是灰带(VS Code 同款:
|
|
215
|
+
未聚焦选中为灰、蓝只留给焦点,$boost=#37373d 悬浮灰),聚焦态见 pane-focus */
|
|
216
|
+
#main { height: 1fr; }
|
|
217
|
+
#feed-nav { width: 16; height: 1fr; background: $surface; padding: 0 1; }
|
|
218
|
+
/* 撑满列宽:选中带是整行(Static 默认 auto 只铺到文字宽) */
|
|
219
|
+
.feed-nav-item { height: 1; width: 1fr; }
|
|
220
|
+
.feed-nav-active { background: $nav-sel-background; color: $text; text-style: bold; }
|
|
221
|
+
|
|
222
|
+
/* 全屏方向键导航的聚焦区域(pane-focus 挂容器,见 _enter_pane):不给整
|
|
223
|
+
个区域铺色块(克制主题,层级靠灰阶)——聚焦只把该区域的选中项升满编
|
|
224
|
+
辑器蓝,与列表光标同语言;全屏任何时刻蓝色标记只有聚焦这一处 */
|
|
225
|
+
#source-tabs.pane-focus .source-tab-active { background: $primary; }
|
|
226
|
+
#feed-nav.pane-focus .feed-nav-active { background: $primary; }
|
|
227
|
+
|
|
228
|
+
/* 非列表态的列表(pane-aside):焦点标记只在聚焦区域亮一处——列表光标
|
|
229
|
+
整体隐去(选中位仍记在 index 上,回列表即恢复原位),不留第二个记号 */
|
|
230
|
+
#feed.pane-aside { background-tint: $foreground 0%; }
|
|
231
|
+
#feed.pane-aside > ListItem.-highlight {
|
|
232
|
+
background: transparent;
|
|
233
|
+
color: $foreground;
|
|
234
|
+
text-style: none;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#empty-hint { display: none; padding: 1 2; }
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
def __init__(self) -> None:
|
|
241
|
+
super().__init__()
|
|
242
|
+
self.sources = self._enabled_sources()
|
|
243
|
+
self.source_idx = 0
|
|
244
|
+
self.feed_idx = 0
|
|
245
|
+
# 主屏区域焦点(默认列表),方向键边界跃迁(见 _enter_pane)
|
|
246
|
+
self._pane = Pane.LIST
|
|
247
|
+
# source_id -> 上次选的信息流(切站回到上次看的板块)
|
|
248
|
+
self._feed_memory: dict[str, int] = {}
|
|
249
|
+
self.items: list[FeedItem] = []
|
|
250
|
+
self.feed_loader: FeedLoader | None = None # 持有活页签,切流/退出前要 close
|
|
251
|
+
# 元信息列宽(随当前批内容自适应,见 score_col_width;首渲染前是占位值)
|
|
252
|
+
self._score_width = 1 + META_COL_SPARE
|
|
253
|
+
self._subs_dirty = False # 订阅管理屏改过信息流(F2 收尾时消费,见 _manage_sources)
|
|
254
|
+
self._cjk_hint_at = 0.0 # 上次「输入法没切英文」提示的时刻(on_key 节流用)
|
|
255
|
+
|
|
256
|
+
def compose(self) -> ComposeResult:
|
|
257
|
+
yield Header()
|
|
258
|
+
yield Horizontal(id="source-tabs") # 顶部站点 tab 条(子项由 _render_source_tabs 挂载)
|
|
259
|
+
with Horizontal(id="main"):
|
|
260
|
+
yield Vertical(id="feed-nav") # 左侧信息流边栏(子项由 _render_feed_sidebar 挂载)
|
|
261
|
+
yield FeedListView(id="feed")
|
|
262
|
+
yield Label("", id="empty-hint") # 空态/失败态常驻提示
|
|
263
|
+
yield Footer()
|
|
264
|
+
|
|
265
|
+
async def on_mount(self) -> None:
|
|
266
|
+
# 键式键位文案解析(bindings.*,见 ui/labels.py):主屏自己的键 + 列表
|
|
267
|
+
# 控件的键——tab/shift+tab 切站声明在 FeedListView 上(压过 Screen 焦点
|
|
268
|
+
# 切换),列表是页脚的数据源之一,控件不翻译页脚就露原始键名
|
|
269
|
+
retranslate_bindings(self)
|
|
270
|
+
retranslate_bindings(self.query_one("#feed", FeedListView))
|
|
271
|
+
self.query_one("#feed", ListView).focus()
|
|
272
|
+
self.run_worker(self._start()) # 启动序列(首启引导 + 初始渲染)在 MainSetupMixin
|
|
273
|
+
|
|
274
|
+
# ---- 顶部 tab 条 / 左侧信息流边栏(点击与键盘共用同一条动作路径)----
|
|
275
|
+
|
|
276
|
+
async def _render_source_tabs(self) -> None:
|
|
277
|
+
"""按注册表重建顶部站点 tab 条,高亮当前源。
|
|
278
|
+
|
|
279
|
+
重建必须先等旧子项摘除(remove_children 的删除是异步落地的),否则
|
|
280
|
+
新子项带着重复 id 挂载会触发 DuplicateIds。
|
|
281
|
+
"""
|
|
282
|
+
tabs = self.query_one("#source-tabs", Horizontal)
|
|
283
|
+
await tabs.remove_children()
|
|
284
|
+
for idx, source in enumerate(self.sources):
|
|
285
|
+
await tabs.mount(SourceTab(idx, source.label_text, active=(idx == self.source_idx)))
|
|
286
|
+
|
|
287
|
+
async def _render_feed_sidebar(self) -> None:
|
|
288
|
+
"""按当前源重建左侧信息流边栏,高亮当前流(←/→ 依序切换)。"""
|
|
289
|
+
nav = self.query_one("#feed-nav", Vertical)
|
|
290
|
+
await nav.remove_children()
|
|
291
|
+
items: list[Widget] = []
|
|
292
|
+
if self.sources:
|
|
293
|
+
source = self.current_source
|
|
294
|
+
items = [
|
|
295
|
+
FeedNavItem(idx, source.feed_label(name), active=(idx == self.feed_idx))
|
|
296
|
+
for idx, name in enumerate(source.feeds)
|
|
297
|
+
]
|
|
298
|
+
if not items:
|
|
299
|
+
hint = (
|
|
300
|
+
t("main.sidebar_no_sources") if not self.sources else t("main.sidebar_no_feeds")
|
|
301
|
+
)
|
|
302
|
+
items.append(Static(f"[dim]{hint}[/dim]"))
|
|
303
|
+
await nav.mount(*items)
|
|
304
|
+
|
|
305
|
+
async def on_source_tab_pressed(self, event: SourceTabPressed) -> None:
|
|
306
|
+
await self._goto_source(event.idx)
|
|
307
|
+
|
|
308
|
+
async def on_feed_nav_pressed(self, event: FeedNavPressed) -> None:
|
|
309
|
+
await self.action_select_feed(event.idx)
|
|
310
|
+
|
|
311
|
+
# ---- 数据加载 ----
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def current_source(self) -> Source:
|
|
315
|
+
return self.sources[self.source_idx]
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def current_feed(self) -> str:
|
|
319
|
+
feeds = self.current_source.feeds
|
|
320
|
+
return feeds[self.feed_idx] if feeds else ""
|
|
321
|
+
|
|
322
|
+
def _restore_feed_idx(self) -> None:
|
|
323
|
+
"""切站后停回该站上次的信息流(_feed_memory);越界(流被隐藏/删除)回 0。"""
|
|
324
|
+
feeds = self.current_source.feeds if self.sources else []
|
|
325
|
+
mem = self._feed_memory.get(self.current_source.source_id, 0) if self.sources else 0
|
|
326
|
+
self.feed_idx = mem if feeds and 0 <= mem < len(feeds) else 0
|
|
327
|
+
|
|
328
|
+
def action_refresh(self) -> None:
|
|
329
|
+
if self._tui.accounts.in_progress:
|
|
330
|
+
feedback.info(
|
|
331
|
+
self, t("account.login_refresh_later")
|
|
332
|
+
) # 有头登录独占 profile,此时别碰浏览器
|
|
333
|
+
return
|
|
334
|
+
# 详情屏在前台时不做隐形后台刷新(会顶掉详情标题、重开隐藏列表的页签)
|
|
335
|
+
if not self.is_current:
|
|
336
|
+
return
|
|
337
|
+
# 先停在途的增量加载:旧 loader 的迟到写入会把条目混进新列表(索引错位)
|
|
338
|
+
self.workers.cancel_group(self, "more")
|
|
339
|
+
self.run_worker(self._refresh(), exclusive=True)
|
|
340
|
+
|
|
341
|
+
async def release_feed_loader(self) -> None:
|
|
342
|
+
"""关掉当前流 loader 持有的活页签并复位(切流/登录前让出资源)。"""
|
|
343
|
+
if self.feed_loader is not None:
|
|
344
|
+
await self.feed_loader.close()
|
|
345
|
+
self.feed_loader = None
|
|
346
|
+
|
|
347
|
+
async def _refresh(self) -> None:
|
|
348
|
+
await self.release_feed_loader() # 切流前关掉旧页签(await 完成再继续)
|
|
349
|
+
if not self.sources: # 全部停用:常驻空态指路(F2 再开),不退出
|
|
350
|
+
view = self.query_one("#feed", ListView)
|
|
351
|
+
view.clear()
|
|
352
|
+
self.items = []
|
|
353
|
+
self.app.sub_title = ""
|
|
354
|
+
self.show_empty(t("main.empty_no_sources"))
|
|
355
|
+
return
|
|
356
|
+
source = self.current_source
|
|
357
|
+
|
|
358
|
+
open_coro = source.open_feed(self.current_feed)
|
|
359
|
+
|
|
360
|
+
# Header 副标题 = 状态行(站点/信息流位置由 tab 条与边栏承担);纯文本
|
|
361
|
+
# 不写 markup(Header 的 Content 解析行为不确定)
|
|
362
|
+
self.app.sub_title = t("read.loading")
|
|
363
|
+
view = self.query_one("#feed", ListView)
|
|
364
|
+
view.clear()
|
|
365
|
+
self.items = []
|
|
366
|
+
try:
|
|
367
|
+
loader = await open_coro
|
|
368
|
+
except LoginRequired as exc:
|
|
369
|
+
self.app.sub_title = t("main.status_signed_out")
|
|
370
|
+
hint = t("main.hint_login", error=exc)
|
|
371
|
+
if source.source_id in self._tui.accounts.refused:
|
|
372
|
+
# 用户已拒绝过:不再自动弹框追问,常驻提示保留登录入口
|
|
373
|
+
self.show_empty(hint)
|
|
374
|
+
return
|
|
375
|
+
self.show_empty(str(exc))
|
|
376
|
+
# 撞墙即给入口:弹登录框与后续动线在 AccountFlow(_refresh 本就
|
|
377
|
+
# 在 worker 里,其 wait_for_dismiss 合法)
|
|
378
|
+
await self._tui.accounts.on_login_wall(source, hint)
|
|
379
|
+
return
|
|
380
|
+
except Exception as exc:
|
|
381
|
+
self.app.sub_title = t("read.status_failed")
|
|
382
|
+
feedback.fail(self, source, exc)
|
|
383
|
+
self.show_empty(t("main.empty_load_failed", text=feedback.failure_text(source, exc)))
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
self.feed_loader = loader
|
|
387
|
+
self.items = loader.items
|
|
388
|
+
self._score_width = score_col_width(source, self.items) # 列宽按本批最长元信息定
|
|
389
|
+
width = self._wrap_width()
|
|
390
|
+
src = source
|
|
391
|
+
await view.extend(
|
|
392
|
+
[build_item(it, width, self._score_width, src) for it in self.items]
|
|
393
|
+
) # 单次挂载 = 单次布局
|
|
394
|
+
if self.items:
|
|
395
|
+
view.index = 0 # 自动选中第一条,Enter 可直接进详情
|
|
396
|
+
self._hide_empty()
|
|
397
|
+
else:
|
|
398
|
+
self.show_empty(t("main.empty_no_content"))
|
|
399
|
+
self.app.sub_title = t("main.status_count", n=len(self.items))
|
|
400
|
+
|
|
401
|
+
def show_empty(self, text: str) -> None:
|
|
402
|
+
"""空态/失败态:列表区无内容时的常驻屏内提示(notify 是瞬时反馈,这是常驻状态)。"""
|
|
403
|
+
hint = self.query_one("#empty-hint", Label)
|
|
404
|
+
hint.update(f"[dim]{escape(text)}[/dim]")
|
|
405
|
+
hint.display = True
|
|
406
|
+
|
|
407
|
+
def _hide_empty(self) -> None:
|
|
408
|
+
self.query_one("#empty-hint", Label).display = False
|
|
409
|
+
|
|
410
|
+
async def _load_more_feed(self) -> None:
|
|
411
|
+
loader = self.feed_loader
|
|
412
|
+
if loader is None:
|
|
413
|
+
return
|
|
414
|
+
# 列表屏的加载更多没有独立加载行:进行中/结果全部落进常驻状态行
|
|
415
|
+
# (Cloudflare 顶跳最长能到数十秒,没有进行中提示会像按键失灵)
|
|
416
|
+
self.app.sub_title = t("main.status_more_loading", n=len(self.items))
|
|
417
|
+
try:
|
|
418
|
+
new = await loader.load_more()
|
|
419
|
+
except Exception as exc:
|
|
420
|
+
self.app.sub_title = t("main.status_more_failed", n=len(self.items))
|
|
421
|
+
feedback.fail(
|
|
422
|
+
self, self.current_source, exc, action="feedback.action_more_failed"
|
|
423
|
+
)
|
|
424
|
+
return
|
|
425
|
+
if loader is not self.feed_loader:
|
|
426
|
+
return # 期间切流/刷新:旧 loader 的迟到结果不能混进新列表
|
|
427
|
+
if not new:
|
|
428
|
+
if loader.done:
|
|
429
|
+
self.app.sub_title = t("main.status_no_more", n=len(self.items))
|
|
430
|
+
feedback.info(self, t("main.toast_no_more"))
|
|
431
|
+
else:
|
|
432
|
+
self.app.sub_title = t("main.status_no_new", n=len(self.items))
|
|
433
|
+
feedback.info(self, t("main.toast_no_new_retry"))
|
|
434
|
+
return
|
|
435
|
+
self.items = loader.items
|
|
436
|
+
view = self.query_one("#feed", ListView)
|
|
437
|
+
new_width = score_col_width(self.current_source, self.items)
|
|
438
|
+
if new_width > self._score_width:
|
|
439
|
+
# 新批次带来更长的元信息(更长的署名):列变宽,旧条目的列宽与
|
|
440
|
+
# 标题折行全都要重排——全量重建,不只是追加新行
|
|
441
|
+
self._score_width = new_width
|
|
442
|
+
await self.rebuild_items()
|
|
443
|
+
else:
|
|
444
|
+
width = self._wrap_width()
|
|
445
|
+
await view.extend(
|
|
446
|
+
[build_item(it, width, self._score_width, self.current_source) for it in new]
|
|
447
|
+
) # 单次挂载 = 单次布局
|
|
448
|
+
self._hide_empty()
|
|
449
|
+
self.app.sub_title = t("main.status_count", n=len(self.items))
|
|
450
|
+
|
|
451
|
+
# ---- 列表项构建与折行 ----
|
|
452
|
+
|
|
453
|
+
def _wrap_width(self) -> int:
|
|
454
|
+
"""标题可用宽:折行算术与布局常量的对应关系集中在 ui/layout.py。"""
|
|
455
|
+
return list_title_width(self.query_one("#feed", ListView).region.width, self._score_width)
|
|
456
|
+
|
|
457
|
+
async def rebuild_items(self) -> None:
|
|
458
|
+
"""终端宽度变化后按新宽度重新折行(只重排版,不重新抓取)。"""
|
|
459
|
+
if not self.items:
|
|
460
|
+
return
|
|
461
|
+
view = self.query_one("#feed", ListView)
|
|
462
|
+
idx = view.index
|
|
463
|
+
view.clear()
|
|
464
|
+
width = self._wrap_width()
|
|
465
|
+
src = self.current_source
|
|
466
|
+
await view.extend(
|
|
467
|
+
[build_item(it, width, self._score_width, src) for it in self.items]
|
|
468
|
+
)
|
|
469
|
+
view.index = 0 if idx is None else min(idx, len(self.items) - 1)
|
|
470
|
+
|
|
471
|
+
# ---- 导航多路合一:顶部 tab 点击 / Tab 循环切站,侧栏点击 / ←→ 切流 ----
|
|
472
|
+
|
|
473
|
+
async def _goto_source(self, target: int) -> None:
|
|
474
|
+
"""切到第 target 个源:Tab 循环(±1)与 tab 点击共用。
|
|
475
|
+
|
|
476
|
+
两个入口都只在主屏生效(列表绑定 / 主屏点击),不会在详情屏下触发。
|
|
477
|
+
只有一个源或已在目标源时不做任何事。
|
|
478
|
+
"""
|
|
479
|
+
if len(self.sources) < 2:
|
|
480
|
+
return
|
|
481
|
+
target %= len(self.sources)
|
|
482
|
+
if target == self.source_idx:
|
|
483
|
+
return
|
|
484
|
+
self.source_idx = target
|
|
485
|
+
self._restore_feed_idx() # 回到该站上次的信息流
|
|
486
|
+
await self._render_source_tabs() # 高亮跟随(键盘/点击都走这里)
|
|
487
|
+
await self._render_feed_sidebar()
|
|
488
|
+
if self._pane != Pane.TABS:
|
|
489
|
+
self._enter_pane(Pane.SIDEBAR) # 切站落边栏:先挑流再回列表(源栏态连切不被拽走)
|
|
490
|
+
self.action_refresh()
|
|
491
|
+
|
|
492
|
+
async def action_next_source(self) -> None:
|
|
493
|
+
await self._goto_source(self.source_idx + 1)
|
|
494
|
+
|
|
495
|
+
async def action_prev_source(self) -> None:
|
|
496
|
+
await self._goto_source(self.source_idx - 1)
|
|
497
|
+
|
|
498
|
+
async def action_select_source(self, idx: int) -> None:
|
|
499
|
+
"""顶部 tab 点击的落点:直接切到第 idx 个源。"""
|
|
500
|
+
await self._goto_source(idx)
|
|
501
|
+
|
|
502
|
+
async def action_select_feed(self, idx: int) -> None:
|
|
503
|
+
feeds = self.current_source.feeds
|
|
504
|
+
if not feeds or not 0 <= idx < len(feeds):
|
|
505
|
+
return
|
|
506
|
+
if self.feed_idx == idx:
|
|
507
|
+
return # 已在该流,不重复刷新
|
|
508
|
+
self.feed_idx = idx
|
|
509
|
+
self._feed_memory[self.current_source.source_id] = idx # 切站回来还在这个板块
|
|
510
|
+
await self._render_feed_sidebar() # 高亮跟随(←/→ /点击都走这里)
|
|
511
|
+
self.action_refresh()
|
|
512
|
+
|
|
513
|
+
async def action_nav_left(self) -> None:
|
|
514
|
+
"""← 的单点分派:源栏态切上一个站;边栏态去源栏;列表态进边栏——
|
|
515
|
+
对齐当前流、不换流(空间语义:← 永远是向左的区域移动)。"""
|
|
516
|
+
if self._pane == Pane.TABS:
|
|
517
|
+
await self.action_prev_source() # 与 Tab 同一条切站路径
|
|
518
|
+
elif self._pane == Pane.SIDEBAR:
|
|
519
|
+
self._enter_pane(Pane.TABS)
|
|
520
|
+
else:
|
|
521
|
+
self._enter_pane(Pane.SIDEBAR)
|
|
522
|
+
|
|
523
|
+
async def action_nav_right(self) -> None:
|
|
524
|
+
"""→ 的单点分派:源栏态切下一个站;边栏态回列表;列表态抵屏幕右缘,
|
|
525
|
+
静默无事(与源栏顶、列表底的同款边界行为)。"""
|
|
526
|
+
if self._pane == Pane.TABS:
|
|
527
|
+
await self.action_next_source()
|
|
528
|
+
elif self._pane == Pane.SIDEBAR:
|
|
529
|
+
self._enter_pane(Pane.LIST)
|
|
530
|
+
|
|
531
|
+
async def _step_feed(self, delta: int) -> None:
|
|
532
|
+
"""边栏 ↑/↓ 切流:末流到边给反馈;首个流再 ↑ 上探源栏(与列表首条
|
|
533
|
+
↑ 同规——顶部区域在所有内容之上;源栏 ↓ 下探与之互逆)。
|
|
534
|
+
|
|
535
|
+
列表态不再切流——← 是区域移动(进边栏停当前流),切流只发生在
|
|
536
|
+
边栏的 ↑/↓(纵向列表的本职)。
|
|
537
|
+
"""
|
|
538
|
+
if not self.sources:
|
|
539
|
+
return
|
|
540
|
+
target = self.feed_idx + delta
|
|
541
|
+
if not 0 <= target < len(self.current_source.feeds):
|
|
542
|
+
if delta < 0:
|
|
543
|
+
self._enter_pane(Pane.TABS) # 首个流再 ↑:上探源栏
|
|
544
|
+
else:
|
|
545
|
+
feedback.info(self, t("main.toast_last_feed"))
|
|
546
|
+
return
|
|
547
|
+
await self.action_select_feed(target)
|
|
548
|
+
|
|
549
|
+
# ---- 全屏方向键导航:列表 ⇄ 左侧边栏 ⇄ 顶部源栏(边界跃迁)----
|
|
550
|
+
|
|
551
|
+
def _enter_pane(self, pane: Pane) -> None:
|
|
552
|
+
"""区域焦点切换(撞到边界继续往相邻区域走,键位表见 README)。
|
|
553
|
+
|
|
554
|
+
pane 是纯逻辑层:Textual 焦点始终留在列表(主屏唯一可聚焦控件,
|
|
555
|
+
Tab 切站的 widget 绑定才能随时命中、连按不退化),非列表态给 #feed
|
|
556
|
+
挂 pane-aside 让列表光标转「暗」(仿 unfocused,见 CSS);容器
|
|
557
|
+
pane-focus 标记聚焦区域,_render_* 全量重建子项不受影响。
|
|
558
|
+
"""
|
|
559
|
+
if pane == self._pane:
|
|
560
|
+
return
|
|
561
|
+
if pane == Pane.TABS and len(self.sources) < 2:
|
|
562
|
+
return # 单源无站可切,跃迁进去只剩空操作(与 Tab 切站同款静默)
|
|
563
|
+
self._pane = pane
|
|
564
|
+
self.query_one("#source-tabs", Horizontal).set_class(pane == Pane.TABS, "pane-focus")
|
|
565
|
+
self.query_one("#feed-nav", Vertical).set_class(pane == Pane.SIDEBAR, "pane-focus")
|
|
566
|
+
self.query_one("#feed", FeedListView).set_class(pane != Pane.LIST, "pane-aside")
|
|
567
|
+
if pane != Pane.LIST:
|
|
568
|
+
feedback.info(
|
|
569
|
+
self,
|
|
570
|
+
t("main.hint_top_bar") if pane == Pane.TABS else t("main.hint_sidebar"),
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
async def action_nav_up(self) -> None:
|
|
574
|
+
"""↑ 的单点分派:边栏态切上一个流(首个流再 ↑ 上探源栏,由
|
|
575
|
+
_step_feed 分派);源栏态抵屏幕上缘;列表态移文章选中(首条再
|
|
576
|
+
↑ 上探源栏——顶部区域在所有内容之上)。焦点丢了先找回。"""
|
|
577
|
+
if self._pane == Pane.SIDEBAR:
|
|
578
|
+
await self._step_feed(-1)
|
|
579
|
+
return
|
|
580
|
+
if self._pane == Pane.TABS:
|
|
581
|
+
return
|
|
582
|
+
view = self.query_one("#feed", FeedListView)
|
|
583
|
+
if not view.has_focus:
|
|
584
|
+
view.focus() # 焦点丢失(测试置空/竞态):↑ 先把焦点找回来
|
|
585
|
+
return
|
|
586
|
+
if view.index in (None, 0):
|
|
587
|
+
self._enter_pane(Pane.TABS) # 首条再 ↑:上探源栏
|
|
588
|
+
else:
|
|
589
|
+
view.action_cursor_up()
|
|
590
|
+
|
|
591
|
+
async def action_nav_down(self) -> None:
|
|
592
|
+
"""↓ 的单点分派:边栏态切下一个流(末流到边给反馈);源栏态下探
|
|
593
|
+
边栏(唯一的纵向区域移动:顶部 → 边栏);列表态移文章选中。"""
|
|
594
|
+
if self._pane == Pane.SIDEBAR:
|
|
595
|
+
await self._step_feed(1)
|
|
596
|
+
return
|
|
597
|
+
if self._pane == Pane.TABS:
|
|
598
|
+
self._enter_pane(Pane.SIDEBAR)
|
|
599
|
+
return
|
|
600
|
+
view = self.query_one("#feed", FeedListView)
|
|
601
|
+
if not view.has_focus:
|
|
602
|
+
view.focus()
|
|
603
|
+
return
|
|
604
|
+
view.action_cursor_down()
|
|
605
|
+
|
|
606
|
+
def action_nav_enter(self) -> None:
|
|
607
|
+
"""Enter 的单点分派:边栏/源栏态回列表(流/站已随行走就地切换);
|
|
608
|
+
列表态 = 原生选中(进详情/开浏览器,与改派前同一条路径)。
|
|
609
|
+
|
|
610
|
+
详情屏 Enter 进评论层是屏级绑定(先命中),到不了这。
|
|
611
|
+
"""
|
|
612
|
+
if self._pane == Pane.LIST:
|
|
613
|
+
self.query_one("#feed", FeedListView).action_select_cursor()
|
|
614
|
+
else:
|
|
615
|
+
self._enter_pane(Pane.LIST)
|
|
616
|
+
|
|
617
|
+
def action_nav_back(self) -> None:
|
|
618
|
+
"""屏级 Esc:边栏/源栏态先回列表(逐层返回的最内层)。
|
|
619
|
+
|
|
620
|
+
弹层/详情屏的 Esc 由各自屏级绑定先命中,落不到这。
|
|
621
|
+
"""
|
|
622
|
+
if self._pane != Pane.LIST:
|
|
623
|
+
self._enter_pane(Pane.LIST)
|
|
624
|
+
|
|
625
|
+
async def action_nav_scroll(self, key: str) -> None:
|
|
626
|
+
"""home/end/pageup/pagedown:列表视口滚动,只在列表态生效——键盘
|
|
627
|
+
在边栏/源栏时去滚一个看不见的列表,违反「焦点在哪键就作用在哪」。
|
|
628
|
+
"""
|
|
629
|
+
if self._pane != Pane.LIST:
|
|
630
|
+
return
|
|
631
|
+
view = self.query_one("#feed", FeedListView)
|
|
632
|
+
fn = {
|
|
633
|
+
"home": view.action_scroll_home,
|
|
634
|
+
"end": view.action_scroll_end,
|
|
635
|
+
"pageup": view.action_page_up,
|
|
636
|
+
"pagedown": view.action_page_down,
|
|
637
|
+
}[key]
|
|
638
|
+
maybe = fn() # 新版 Textual 的滚动动作返回 AwaitComplete,可等可不等
|
|
639
|
+
if maybe is not None and hasattr(maybe, "__await__"):
|
|
640
|
+
await maybe
|
|
641
|
+
|
|
642
|
+
def action_open_browser(self) -> None:
|
|
643
|
+
item = self.current_item()
|
|
644
|
+
if item is None:
|
|
645
|
+
feedback.info(self, t("main.toast_no_selection")) # 空态/加载中按 Ctrl+O 也绝不无声
|
|
646
|
+
return
|
|
647
|
+
webbrowser.open(item.url)
|
|
648
|
+
|
|
649
|
+
def action_more(self) -> None:
|
|
650
|
+
if self.feed_loader is None:
|
|
651
|
+
feedback.info(self, t("main.toast_nothing_to_load")) # 失败/空态下按 Ctrl+N 也绝不无声
|
|
652
|
+
return
|
|
653
|
+
self.run_worker(self._load_more_feed(), group="more", exclusive=True)
|
|
654
|
+
|
|
655
|
+
# ---- 账号:Ctrl+U(动线在 ui/account.AccountFlow)----
|
|
656
|
+
|
|
657
|
+
def action_account(self) -> None:
|
|
658
|
+
"""Ctrl+U 的薄转发:分派与登录/登出过程在 AccountFlow。
|
|
659
|
+
|
|
660
|
+
走 worker 因为 push_screen 的 wait_for_dismiss 只能在 worker 里用。
|
|
661
|
+
group 独立于列表加载的 default 组,Ctrl+U 不会打断在途的 _refresh。
|
|
662
|
+
"""
|
|
663
|
+
if not self.sources:
|
|
664
|
+
feedback.info(self, t("main.toast_no_sources"))
|
|
665
|
+
return
|
|
666
|
+
self.app.run_worker(
|
|
667
|
+
self._tui.accounts.open(self.current_source), group="account", exclusive=True
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
# ---- Enter:进详情或跳浏览器 ----
|
|
671
|
+
|
|
672
|
+
def current_item(self) -> FeedItem | None:
|
|
673
|
+
if not self.items:
|
|
674
|
+
return None
|
|
675
|
+
view = self.query_one("#feed", ListView)
|
|
676
|
+
if view.index is None:
|
|
677
|
+
return None
|
|
678
|
+
return self.items[view.index]
|
|
679
|
+
|
|
680
|
+
def _opens_detail(self, item: FeedItem) -> bool:
|
|
681
|
+
"""Enter 的分派谓词:进终端详情屏还是跳浏览器,由源的类型词汇表决定
|
|
682
|
+
(Source.supports_detail + detail_kinds)。Enter 与 [/] 换篇共用。"""
|
|
683
|
+
source = self.current_source
|
|
684
|
+
return source.supports_detail and item.kind in source.detail_kinds
|
|
685
|
+
|
|
686
|
+
def step_readable_item(self, delta: int) -> FeedItem | None:
|
|
687
|
+
"""列表选中项沿 delta 方向推进到下一篇可进终端详情的条目,并同步列表选中。
|
|
688
|
+
|
|
689
|
+
详情屏 [/] 换篇的供给端:跳过 Enter 会跳浏览器的类型(阅读流要留在终端里,
|
|
690
|
+
阅读中突然弹出浏览器是事故);沿方向没有可读条目时不动选中、返回 None。
|
|
691
|
+
"""
|
|
692
|
+
view = self.query_one("#feed", ListView)
|
|
693
|
+
if not self.items or view.index is None:
|
|
694
|
+
return None
|
|
695
|
+
for idx in range(view.index + delta, len(self.items) if delta > 0 else -1, delta):
|
|
696
|
+
it = self.items[idx]
|
|
697
|
+
if self._opens_detail(it):
|
|
698
|
+
view.index = idx
|
|
699
|
+
return it
|
|
700
|
+
return None
|
|
701
|
+
|
|
702
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
703
|
+
if event.list_view.id != "feed": # 只认主列表:楼层选择弹层等也会发同名事件
|
|
704
|
+
return
|
|
705
|
+
item = self.current_item()
|
|
706
|
+
if item is None:
|
|
707
|
+
return
|
|
708
|
+
if self._opens_detail(item):
|
|
709
|
+
self.app.push_screen(DetailScreen(item, self.current_source, self.step_readable_item))
|
|
710
|
+
else:
|
|
711
|
+
webbrowser.open(item.url)
|
|
712
|
+
|
|
713
|
+
# ---- 输入法没切英文的自救指路 ----
|
|
714
|
+
|
|
715
|
+
def on_key(self, event: Key) -> None:
|
|
716
|
+
"""中文输入落地 → 提示哪些键不受输入法影响。
|
|
717
|
+
|
|
718
|
+
输入法 composing 期间按键根本到不了终端(拦在候选框里),应用侧无从
|
|
719
|
+
得知;只有用户把中文「打出来」(上屏字符经 pty 送进应用)才是输入法
|
|
720
|
+
没切英文的可靠信号。主屏没有文本输入框,中文落地到这里必是误输入
|
|
721
|
+
(订阅管理屏的输入框自己消费字符键,不会冒泡到这里)。
|
|
722
|
+
到达这里的键都已被绑定链筛过(无任何绑定命中才冒泡),节流防连打刷屏。
|
|
723
|
+
"""
|
|
724
|
+
ch = event.character or event.key # 模拟按键(测试)只有 key,真终端两者同值
|
|
725
|
+
if not any(_is_cjk(c) for c in ch):
|
|
726
|
+
return
|
|
727
|
+
now = time.monotonic()
|
|
728
|
+
if now - self._cjk_hint_at < _CJK_HINT_INTERVAL:
|
|
729
|
+
return
|
|
730
|
+
self._cjk_hint_at = now
|
|
731
|
+
feedback.info(self, t("main.toast_cjk_input"))
|
|
732
|
+
|
|
733
|
+
# ---- 清理 ----
|
|
734
|
+
|
|
735
|
+
async def on_unmount(self) -> None:
|
|
736
|
+
await self.release_feed_loader() # 浏览器会话与连接池由 App.on_unmount 收
|