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,296 @@
|
|
|
1
|
+
"""回答详情屏:多层递进的第三层(列表 -> 问题 -> 回答+评论)。
|
|
2
|
+
|
|
3
|
+
从问题屏 Enter 进「视口正在阅读」的那个回答(问题屏 detail.py 定位)。
|
|
4
|
+
评论按需抓取:进这一屏才调问题 loader 的 open_comments(用户不进就不花
|
|
5
|
+
一次请求);正文秒显(数据已在问题 loader 的 doc 里),评论异步补上。
|
|
6
|
+
评论层与问题层共享页签:评论 loader 的 close() 只收评论区,不关底座。
|
|
7
|
+
翻页动线 / 尾行状态机 / ←/→ 换楼层的机械件在 PagedReadingScreen,本屏
|
|
8
|
+
只剩首载编排(评论按需)与整屏渲染。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import ClassVar
|
|
14
|
+
|
|
15
|
+
from rich.markup import escape
|
|
16
|
+
from textual.app import ComposeResult
|
|
17
|
+
from textual.binding import Binding
|
|
18
|
+
from textual.containers import VerticalScroll
|
|
19
|
+
from textual.widget import Widget
|
|
20
|
+
from textual.widgets import Footer, Header, Label, Markdown, Static
|
|
21
|
+
|
|
22
|
+
from feedscope.core.models import Comment, FeedItem
|
|
23
|
+
from feedscope.core.source import CommentLoader, DetailLoader, Source
|
|
24
|
+
from feedscope.core.i18n import t
|
|
25
|
+
from feedscope.ui import Bindings, feedback
|
|
26
|
+
from feedscope.ui.screens.reading import (
|
|
27
|
+
PagedReadingScreen,
|
|
28
|
+
READING_CSS,
|
|
29
|
+
dim_line,
|
|
30
|
+
divider,
|
|
31
|
+
floor_head,
|
|
32
|
+
)
|
|
33
|
+
from feedscope.ui.wrap import wrap_markdown
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AnswerScreen(PagedReadingScreen):
|
|
37
|
+
"""单条回答全文 + 它的评论区(评论此刻才请求)。
|
|
38
|
+
|
|
39
|
+
Esc 返回问题屏(阅读位置不变);Ctrl+N 加载更多评论(评论多时一层层翻);
|
|
40
|
+
←/→ 在已加载的回答间换(要更多回答回问题屏按 Ctrl+N)。
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
BINDINGS: ClassVar[Bindings] = [
|
|
44
|
+
# 键位全部非打印键(输入法免疫):↑/↓ 滚动走焦点容器(VerticalScroll)
|
|
45
|
+
# 原生键,无需声明。标签是占位(空串):__init__ 按源词汇表换成
|
|
46
|
+
# 「更多{评论词}/下一{楼层词}」(知乎渲染与本列表逐字相同);
|
|
47
|
+
# Esc 返回继承自 ReadingScreen
|
|
48
|
+
Binding("ctrl+n", "more", ""),
|
|
49
|
+
Binding("ctrl+o", "open_browser", "bindings.browser"),
|
|
50
|
+
Binding("right", "next_answer", ""),
|
|
51
|
+
Binding("left", "prev_answer", ""),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# Markdown 标题规则必须 CSS 级(见 reading.READING_CSS 的说明),其余
|
|
55
|
+
# 共享样式走 ReadingScreen.DEFAULT_CSS
|
|
56
|
+
CSS = READING_CSS + """
|
|
57
|
+
.comments-header { padding: 1 2; text-style: bold; }
|
|
58
|
+
#cm-loading { padding: 1 2; }
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
_worker_group = "comments" # 评论首载/重试/翻页同组互斥(换楼层整组取消)
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self, source: Source, item: FeedItem, qloader: DetailLoader, index: int
|
|
65
|
+
) -> None:
|
|
66
|
+
super().__init__()
|
|
67
|
+
self.source = source
|
|
68
|
+
self.item = item # 问题条目(Ctrl+O 兜底用它)
|
|
69
|
+
self.qloader = qloader # 问题 loader(共享页签的底座)
|
|
70
|
+
self.index = index
|
|
71
|
+
self.loader: CommentLoader | None = None # 评论 loader(契约见 core.source.CommentLoader)
|
|
72
|
+
# 词汇表归源:本屏两层各有单位词——楼层(←/→ 切换的对象,知乎「回答」、
|
|
73
|
+
# HN「评论」)与评论(Ctrl+N 翻的对象,知乎「评论」、HN「回复」);键位
|
|
74
|
+
# 文案随词换(类级 BINDINGS 是占位,知乎渲染与内置文案逐字相同)
|
|
75
|
+
self._floor_unit = source.detail_unit
|
|
76
|
+
self._relabel_bindings(
|
|
77
|
+
{
|
|
78
|
+
"ctrl+n": t("read.binding_more_unit", unit=self._unit),
|
|
79
|
+
"right": t("read.binding_next_unit", unit=self._floor_unit),
|
|
80
|
+
"left": t("read.binding_prev_unit", unit=self._floor_unit),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
self._floor_count = 0 # 评论楼层序号,跨 load_more 递增
|
|
84
|
+
self._comments_error: str | None = None # 首载失败文案(resize 重排时保持失败态)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def _answer(self):
|
|
88
|
+
return self.qloader.doc.answers[self.index]
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def _unit(self) -> str:
|
|
92
|
+
"""Ctrl+N 翻页对象的单位词(评论词,源词汇表直读,与问题屏对称)。"""
|
|
93
|
+
return self.source.comment_unit
|
|
94
|
+
|
|
95
|
+
def compose(self) -> ComposeResult:
|
|
96
|
+
yield Header()
|
|
97
|
+
yield VerticalScroll(id="answer-body-scroll")
|
|
98
|
+
yield Footer()
|
|
99
|
+
|
|
100
|
+
def _body_scroll(self) -> VerticalScroll:
|
|
101
|
+
return self.query_one("#answer-body-scroll", VerticalScroll)
|
|
102
|
+
|
|
103
|
+
async def _mount_body(self) -> None:
|
|
104
|
+
self._body_scroll().focus()
|
|
105
|
+
await self._render_doc()
|
|
106
|
+
self._render_done()
|
|
107
|
+
self.run_worker(self._load_comments(), group="comments", exclusive=True)
|
|
108
|
+
|
|
109
|
+
def _reset_render_state(self) -> None:
|
|
110
|
+
self._floor_count = 0
|
|
111
|
+
|
|
112
|
+
# 卸载清理继承 PagedReadingScreen:只换出并关评论 loader(问题页签由问题屏管)
|
|
113
|
+
|
|
114
|
+
def _hint_widget(self) -> Static:
|
|
115
|
+
# class 不用 id:hint 会随楼层增删反复挂载,id 会在摘除延迟时撞重。
|
|
116
|
+
# 只留 Footer 没有的语义增量(加载什么);换楼层/返回是 Footer 的键位职责
|
|
117
|
+
return Static(
|
|
118
|
+
f"[dim]── {t('read.hint_more', unit=self._unit)} ──[/dim]",
|
|
119
|
+
classes="tail-line tail-hint",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# ---- 渲染 ----
|
|
123
|
+
|
|
124
|
+
async def _render_doc(self) -> None:
|
|
125
|
+
"""整个回答详情上屏(单次 mount = 单次布局);评论未到先占位。"""
|
|
126
|
+
ans = self._answer
|
|
127
|
+
body = self._body_scroll()
|
|
128
|
+
title = Static(
|
|
129
|
+
escape(t("read.answer_title", author=ans.author, unit=self._floor_unit)),
|
|
130
|
+
id="answer-title",
|
|
131
|
+
classes="read-title",
|
|
132
|
+
)
|
|
133
|
+
widgets: list[Widget] = [title]
|
|
134
|
+
meta = " · ".join(x for x in (ans.votes, ans.comment_count, ans.time) if x)
|
|
135
|
+
if meta:
|
|
136
|
+
widgets.append(
|
|
137
|
+
Static(f"[dim]{escape(meta)}[/dim]", id="answer-meta", classes="read-meta")
|
|
138
|
+
)
|
|
139
|
+
widgets.append(
|
|
140
|
+
Markdown(wrap_markdown(ans.content_md, self._md_width()), id="answer-content",
|
|
141
|
+
classes="read-main")
|
|
142
|
+
)
|
|
143
|
+
widgets.extend([divider(), Static(f"[b]{self._unit}[/b]", classes="comments-header")])
|
|
144
|
+
if self.loader is not None:
|
|
145
|
+
if self.loader.comments:
|
|
146
|
+
for c in self.loader.comments:
|
|
147
|
+
widgets.extend(self._comment_floor(c))
|
|
148
|
+
else:
|
|
149
|
+
# 首批已回来但为空:到没到头都显示空态,别退回「加载中」(resize 重排
|
|
150
|
+
# 走的是同一渲染路径,状态必须与首次上屏一致)
|
|
151
|
+
widgets.append(
|
|
152
|
+
dim_line(t("read.none", unit=self._unit), classes="tail-line tail-empty")
|
|
153
|
+
)
|
|
154
|
+
if self._exhausted:
|
|
155
|
+
widgets.append(
|
|
156
|
+
dim_line(
|
|
157
|
+
t("read.no_more", unit=self._unit), classes="tail-line tail-eof"
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
self._hint = self._hint_widget()
|
|
162
|
+
widgets.append(self._hint)
|
|
163
|
+
elif self._comments_error is not None:
|
|
164
|
+
# 首载失败:保留失败态与 Ctrl+N 重试入口(错误行本身已说明动线)
|
|
165
|
+
widgets.append(
|
|
166
|
+
Static(
|
|
167
|
+
f"[red]{t('read.comments_failed_retry', text=escape(self._comments_error))}[/red]", # noqa: E501
|
|
168
|
+
id="cm-loading",
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
widgets.append(Label(t("read.unit_loading", unit=self._unit), id="cm-loading"))
|
|
173
|
+
self._hint = self._hint_widget()
|
|
174
|
+
widgets.append(self._hint)
|
|
175
|
+
await body.mount(*widgets)
|
|
176
|
+
|
|
177
|
+
def _comment_floor(self, c: Comment) -> list[Widget]:
|
|
178
|
+
"""一条顶层评论的楼层部件(头部 + 内容 + 缩进的子回复)。"""
|
|
179
|
+
self._floor_count += 1
|
|
180
|
+
widgets = [
|
|
181
|
+
divider(),
|
|
182
|
+
floor_head(
|
|
183
|
+
t("read.floor_head", unit=self._unit, n=self._floor_count),
|
|
184
|
+
c.author,
|
|
185
|
+
(c.votes, c.time),
|
|
186
|
+
classes="floor-head",
|
|
187
|
+
),
|
|
188
|
+
Markdown(wrap_markdown(c.content_md, self._md_width()), classes="floor-body"),
|
|
189
|
+
]
|
|
190
|
+
for r in c.replies:
|
|
191
|
+
widgets.extend(
|
|
192
|
+
[
|
|
193
|
+
floor_head("", r.author, (r.votes, r.time), classes="floor-head sub"),
|
|
194
|
+
Markdown(
|
|
195
|
+
wrap_markdown(r.content_md, self._md_width(3)),
|
|
196
|
+
classes="floor-body sub",
|
|
197
|
+
),
|
|
198
|
+
]
|
|
199
|
+
)
|
|
200
|
+
return widgets
|
|
201
|
+
|
|
202
|
+
# ---- 评论加载(按需)----
|
|
203
|
+
|
|
204
|
+
async def _load_comments(self) -> None:
|
|
205
|
+
"""此刻才请求:问题 loader 点开该回答的评论区(共享页签)。
|
|
206
|
+
|
|
207
|
+
sub_title 在这设而非 on_mount:worker 晚于 Header 挂载,同步设
|
|
208
|
+
不会踩 HeaderTitle 未挂的时序坑。
|
|
209
|
+
"""
|
|
210
|
+
self.app.sub_title = t(
|
|
211
|
+
"read.answer_title", author=self._answer.author, unit=self._floor_unit
|
|
212
|
+
)[:50]
|
|
213
|
+
self._comments_error = None
|
|
214
|
+
try:
|
|
215
|
+
self.loader = await self.qloader.open_comments(self.index)
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
# 失败文案存下来:resize 重排也要还原成失败态,不能变回「加载中」
|
|
218
|
+
self._comments_error = feedback.failure_text(self.source, exc)
|
|
219
|
+
feedback.fail(self.app, self.source, exc, action="feedback.action_comments_failed")
|
|
220
|
+
self._swap_status(
|
|
221
|
+
"cm-loading",
|
|
222
|
+
f"[red]{t('read.comments_failed_retry', text=escape(self._comments_error))}[/red]",
|
|
223
|
+
)
|
|
224
|
+
return
|
|
225
|
+
# done 与有没有评论无关:最后一页即使非空也已到头,尾行该换 EOF 标记
|
|
226
|
+
self._exhausted = self.loader.done
|
|
227
|
+
for w in self.query("#cm-loading"):
|
|
228
|
+
await w.remove()
|
|
229
|
+
if self.loader.comments:
|
|
230
|
+
await self._mount_batch(self.loader.comments)
|
|
231
|
+
await self._mount_tail()
|
|
232
|
+
else:
|
|
233
|
+
await self._mount_empty_tail()
|
|
234
|
+
|
|
235
|
+
async def _mount_empty_tail(self) -> None:
|
|
236
|
+
"""首批为空的空态尾行:「(没有X)」+ EOF 标记或重试预告。"""
|
|
237
|
+
self._remove_hint()
|
|
238
|
+
tail: list[Widget] = [
|
|
239
|
+
dim_line(t("read.none", unit=self._unit), classes="tail-line tail-empty")
|
|
240
|
+
]
|
|
241
|
+
if self._exhausted:
|
|
242
|
+
tail.append(dim_line(t("read.no_more", unit=self._unit), classes="tail-line tail-eof"))
|
|
243
|
+
else:
|
|
244
|
+
# 首批拉空但没到头(网慢):不判死,尾行保留 Ctrl+N 重试预告
|
|
245
|
+
self._hint = self._hint_widget()
|
|
246
|
+
tail.append(self._hint)
|
|
247
|
+
await self._body_scroll().mount(*tail)
|
|
248
|
+
|
|
249
|
+
async def _mount_batch(self, comments: list[Comment]) -> None:
|
|
250
|
+
widgets: list[Widget] = []
|
|
251
|
+
for c in comments:
|
|
252
|
+
widgets.extend(self._comment_floor(c))
|
|
253
|
+
await self._body_scroll().mount(*widgets)
|
|
254
|
+
# 与问题屏同一取舍:新楼层接在底部,视口停在原阅读断点不跳
|
|
255
|
+
|
|
256
|
+
def _swap_status(self, sel: str, text: str) -> None:
|
|
257
|
+
for w in self.query(sel):
|
|
258
|
+
if isinstance(w, Static):
|
|
259
|
+
w.update(text)
|
|
260
|
+
|
|
261
|
+
async def _retry(self) -> None:
|
|
262
|
+
"""评论首载失败后的原地重试(与问题屏首载重试同动线)。"""
|
|
263
|
+
for w in self.query("#cm-loading"):
|
|
264
|
+
if isinstance(w, Static):
|
|
265
|
+
w.update(t("read.unit_loading", unit=self._unit))
|
|
266
|
+
await self._load_comments()
|
|
267
|
+
|
|
268
|
+
# ---- ←/→ 换楼层(机械件在 PagedReadingScreen._retarget)----
|
|
269
|
+
|
|
270
|
+
async def action_next_answer(self) -> None:
|
|
271
|
+
await self._retarget(1)
|
|
272
|
+
|
|
273
|
+
async def action_prev_answer(self) -> None:
|
|
274
|
+
await self._retarget(-1)
|
|
275
|
+
|
|
276
|
+
def _next_target(self, delta: int) -> int | None:
|
|
277
|
+
"""同问题内相邻楼层(更多楼层回问题屏按 Ctrl+N 加载)。"""
|
|
278
|
+
nxt = self.index + delta
|
|
279
|
+
return nxt if 0 <= nxt < len(self.qloader.doc.answers) else None
|
|
280
|
+
|
|
281
|
+
def _no_target_text(self, delta: int) -> str:
|
|
282
|
+
return (
|
|
283
|
+
t("read.no_next_loaded", unit=self._floor_unit)
|
|
284
|
+
if delta > 0
|
|
285
|
+
else t("read.no_prev_unit", unit=self._floor_unit)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
async def _adopt_target(self, index: int) -> None:
|
|
289
|
+
self.index = index
|
|
290
|
+
self._comments_error = None
|
|
291
|
+
await self._render_doc()
|
|
292
|
+
self._render_done()
|
|
293
|
+
self.run_worker(self._load_comments(), group="comments", exclusive=True)
|
|
294
|
+
|
|
295
|
+
def _browser_url(self) -> str:
|
|
296
|
+
return self._answer.url or self.item.url
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""问题详情屏:题目 + 描述 + 回答流,纯终端阅读。
|
|
2
|
+
|
|
3
|
+
多层递进的第二层:列表 -> 这里(回答流)-> 回答详情屏(评论层)。
|
|
4
|
+
Enter 进「视口正在阅读」的那个回答(读到哪进哪);评论不随首批抓取
|
|
5
|
+
(首载要快),楼层头部只带评论数引导。翻页动线 / 尾行状态机 / ←/→ 换篇
|
|
6
|
+
的机械件在 PagedReadingScreen,本屏只剩首载编排、整屏渲染与 Enter 分派。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import ClassVar
|
|
13
|
+
|
|
14
|
+
from rich.markup import escape
|
|
15
|
+
from textual.app import ComposeResult
|
|
16
|
+
from textual.binding import Binding
|
|
17
|
+
from textual.containers import VerticalScroll
|
|
18
|
+
from textual.widget import Widget
|
|
19
|
+
from textual.widgets import Footer, Header, Label, Markdown, Static
|
|
20
|
+
|
|
21
|
+
from feedscope.core.models import Answer, DetailDoc, FeedItem
|
|
22
|
+
from feedscope.core.source import DetailLoader, Source
|
|
23
|
+
from feedscope.core.i18n import t
|
|
24
|
+
from feedscope.ui import Bindings, feedback
|
|
25
|
+
from feedscope.ui.screens.answer import AnswerScreen
|
|
26
|
+
from feedscope.ui.screens.reading import (
|
|
27
|
+
PagedReadingScreen,
|
|
28
|
+
READING_CSS,
|
|
29
|
+
dim_line,
|
|
30
|
+
divider,
|
|
31
|
+
floor_head,
|
|
32
|
+
)
|
|
33
|
+
from feedscope.ui.wrap import wrap_markdown
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DetailScreen(PagedReadingScreen):
|
|
37
|
+
"""全屏阅读一个问题:Esc 返回(列表位置不变),Ctrl+N 加载更多回答,←/→ 换篇。"""
|
|
38
|
+
|
|
39
|
+
BINDINGS: ClassVar[Bindings] = [
|
|
40
|
+
# 键位全部非打印键(输入法免疫):↑/↓ 滚动走焦点容器(VerticalScroll)
|
|
41
|
+
# 原生键,无需声明;Esc 返回继承自 ReadingScreen
|
|
42
|
+
Binding("ctrl+n", "more", "bindings.more"),
|
|
43
|
+
# Enter 的标签是占位(空串):__init__ 换源词汇表;无评论层的源隐藏该键位
|
|
44
|
+
Binding("enter", "pick_answer", ""),
|
|
45
|
+
Binding("ctrl+o", "open_browser", "bindings.browser"),
|
|
46
|
+
Binding("right", "next_article", "bindings.next"),
|
|
47
|
+
Binding("left", "prev_article", "bindings.prev"),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Markdown 标题规则必须 CSS 级(见 reading.READING_CSS 的说明),其余
|
|
51
|
+
# 共享样式走 ReadingScreen.DEFAULT_CSS
|
|
52
|
+
CSS = READING_CSS + """
|
|
53
|
+
.answers-header { padding: 0 2; text-style: bold; }
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
_worker_group = "load" # 首载/重试/翻页同组互斥(换篇整组取消)
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
item: FeedItem,
|
|
61
|
+
source: Source,
|
|
62
|
+
step_item: Callable[[int], FeedItem | None],
|
|
63
|
+
) -> None:
|
|
64
|
+
super().__init__()
|
|
65
|
+
self.item = item
|
|
66
|
+
self.source = source
|
|
67
|
+
# Enter 的键位文案随源词汇表(知乎「评论」、HN「回复」);无评论层的源
|
|
68
|
+
# (supports_comments=False,如 X/RSS)Footer 不预告——键位隐藏但保留,
|
|
69
|
+
# 误按仍走 action_pick_answer 的「暂不支持」反馈,不无声(与尾行预告
|
|
70
|
+
# 同一能力判定)。
|
|
71
|
+
self._relabel_bindings(
|
|
72
|
+
{"enter": source.comment_unit},
|
|
73
|
+
hide="" if source.supports_comments else "enter",
|
|
74
|
+
)
|
|
75
|
+
self._step_item = step_item # 向 App 要相邻的可读条目(←/→ 换篇,同步推进列表选中)
|
|
76
|
+
self.loader: DetailLoader | None = None
|
|
77
|
+
self._answer_count = 0 # 回答序号,跨 load_more 递增
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def _unit(self) -> str:
|
|
81
|
+
"""楼层单位词:源词汇表(知乎「回答」、X「回复」),详情屏不硬编码。"""
|
|
82
|
+
return self.source.detail_unit
|
|
83
|
+
|
|
84
|
+
def compose(self) -> ComposeResult:
|
|
85
|
+
yield Header()
|
|
86
|
+
yield VerticalScroll(id="detail-body")
|
|
87
|
+
yield Footer()
|
|
88
|
+
|
|
89
|
+
def _body_scroll(self) -> VerticalScroll:
|
|
90
|
+
return self.query_one("#detail-body", VerticalScroll)
|
|
91
|
+
|
|
92
|
+
async def _mount_body(self) -> None:
|
|
93
|
+
self._body_scroll().focus()
|
|
94
|
+
# 组内互斥:换篇/重试可打断在途首载
|
|
95
|
+
self.run_worker(self._load(), group="load", exclusive=True)
|
|
96
|
+
|
|
97
|
+
def _reset_render_state(self) -> None:
|
|
98
|
+
self._answer_count = 0
|
|
99
|
+
|
|
100
|
+
# ---- 首载与整屏渲染 ----
|
|
101
|
+
|
|
102
|
+
async def _load(self) -> None:
|
|
103
|
+
body = self._body_scroll()
|
|
104
|
+
loading = Label(t("read.loading"), id="detail-loading")
|
|
105
|
+
await body.mount(loading)
|
|
106
|
+
try:
|
|
107
|
+
self.loader = await self.source.open_detail(self.item)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
# notify 给细节(瞬时),屏内留一行常驻失败态:notify 会消失,屏幕不能空着
|
|
110
|
+
feedback.fail(self.app, self.source, exc)
|
|
111
|
+
self.app.sub_title = t("read.status_failed") # 常驻失败标记(与列表屏面包屑同一手法)
|
|
112
|
+
failure = t(
|
|
113
|
+
"read.load_failed_retry",
|
|
114
|
+
text=escape(feedback.failure_text(self.source, exc)),
|
|
115
|
+
)
|
|
116
|
+
loading.update(f"[red]{failure}[/red]")
|
|
117
|
+
return
|
|
118
|
+
loading.remove()
|
|
119
|
+
# 首批即到头(V2EX 一页全量 / HN·Reddit 楼层不足一页):立刻按完成态渲染,
|
|
120
|
+
# 尾行给 EOF 标记而不是「Ctrl+N 加载更多」——按了也只会空跑一次
|
|
121
|
+
self._exhausted = self.loader.done
|
|
122
|
+
await self._render_doc(self.loader.doc)
|
|
123
|
+
self._render_done()
|
|
124
|
+
self.app.sub_title = self.loader.doc.title[:50]
|
|
125
|
+
|
|
126
|
+
async def _retry(self) -> None:
|
|
127
|
+
"""首载失败后的原地重试:摘掉失败行,重走一遍 _load。"""
|
|
128
|
+
for w in self.query("#detail-loading"):
|
|
129
|
+
await w.remove()
|
|
130
|
+
await self._load()
|
|
131
|
+
|
|
132
|
+
async def _render_doc(self, doc: DetailDoc | None = None) -> None:
|
|
133
|
+
"""把整个文档渲染上屏(单次 mount = 单次布局);重排(resize)时也走这里。"""
|
|
134
|
+
loader = self.loader
|
|
135
|
+
if loader is None:
|
|
136
|
+
return # 未加载完成(换篇/关闭的竞态窗口):无内容可渲染
|
|
137
|
+
if doc is None:
|
|
138
|
+
doc = loader.doc # 重排路径:doc 即已加载的全部内容
|
|
139
|
+
widgets: list[Widget] = [Static(escape(doc.title), id="detail-title", classes="read-title")]
|
|
140
|
+
if doc.meta or doc.time:
|
|
141
|
+
meta = " · ".join(x for x in (doc.meta, doc.time) if x) # 正文发布时间并入元信息行
|
|
142
|
+
widgets.append(
|
|
143
|
+
Static(f"[dim]{escape(meta)}[/dim]", id="detail-meta", classes="read-meta")
|
|
144
|
+
)
|
|
145
|
+
if doc.description_md:
|
|
146
|
+
widgets.append(
|
|
147
|
+
Markdown(
|
|
148
|
+
wrap_markdown(doc.description_md, self._md_width()),
|
|
149
|
+
id="detail-desc",
|
|
150
|
+
classes="read-main",
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
if doc.answers:
|
|
154
|
+
# 正文与楼层之间的边界:分隔线 + 区块标题
|
|
155
|
+
widgets.extend(
|
|
156
|
+
[divider(), Static(f"[b]{self._unit}[/b]", classes="answers-header")]
|
|
157
|
+
)
|
|
158
|
+
for ans in doc.answers:
|
|
159
|
+
widgets.extend(self._answer_widgets(ans))
|
|
160
|
+
if loader.done and not doc.answers:
|
|
161
|
+
# 天生无楼层的完整文档(RSS 全文 / Telegram 帖):没有「更多」
|
|
162
|
+
# 也没有评论层,末尾只放纯 EOF 标记「(完)」——键位预告是常驻
|
|
163
|
+
# Footer 的职责,尾行复读键位会被短帖读者当成正文的一部分;
|
|
164
|
+
# 置 exhausted 让 Ctrl+N 走「没有更多」反馈而不是空跑 load_more
|
|
165
|
+
self._exhausted = True
|
|
166
|
+
widgets.append(dim_line(t("read.eof"), classes="tail-line tail-eof"))
|
|
167
|
+
elif self._exhausted:
|
|
168
|
+
widgets.append(
|
|
169
|
+
dim_line(t("read.no_more", unit=self._unit), classes="tail-line tail-eof")
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
self._hint = self._hint_widget()
|
|
173
|
+
widgets.append(self._hint)
|
|
174
|
+
await self._body_scroll().mount(*widgets)
|
|
175
|
+
|
|
176
|
+
def _hint_widget(self) -> Static:
|
|
177
|
+
"""尾行动线预告:只留 Footer 没有的语义增量(按了会加载/进入什么);
|
|
178
|
+
纯键位复读(换篇/返回)是常驻 Footer 的职责——尾行复读会被短帖
|
|
179
|
+
读者当成正文的一部分。Enter 评论段只在源有评论层时列出。"""
|
|
180
|
+
segs = [t("read.hint_more", unit=self._unit)]
|
|
181
|
+
if self.source.supports_comments:
|
|
182
|
+
segs.append(t("read.hint_comments", unit=self._unit, cunit=self.source.comment_unit))
|
|
183
|
+
return Static(f"[dim]── {' · '.join(segs)} ──[/dim]", classes="tail-line tail-hint")
|
|
184
|
+
|
|
185
|
+
def _answer_widgets(self, ans: Answer) -> list[Widget]:
|
|
186
|
+
"""一个楼层的完整部件(分隔线 + 头部 + 正文);楼层序号在此推进。
|
|
187
|
+
|
|
188
|
+
头部 name 记楼层号:Enter 按可视位置定位楼层时用它取回。
|
|
189
|
+
"""
|
|
190
|
+
self._answer_count += 1
|
|
191
|
+
head = floor_head(
|
|
192
|
+
t("read.floor_head", unit=self._unit, n=self._answer_count),
|
|
193
|
+
ans.author,
|
|
194
|
+
(ans.votes, ans.comment_count, ans.time),
|
|
195
|
+
classes="floor-head",
|
|
196
|
+
name=str(self._answer_count - 1),
|
|
197
|
+
)
|
|
198
|
+
return [
|
|
199
|
+
divider(),
|
|
200
|
+
head,
|
|
201
|
+
Markdown(wrap_markdown(ans.content_md, self._md_width()), classes="floor-body"),
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
async def _mount_batch(self, new: list[Answer]) -> None:
|
|
205
|
+
widgets: list[Widget] = []
|
|
206
|
+
for ans in new:
|
|
207
|
+
widgets.extend(self._answer_widgets(ans))
|
|
208
|
+
await self._body_scroll().mount(*widgets) # 单次挂载 = 单次布局
|
|
209
|
+
# 不主动滚动:新楼层就追加在旧内容正下方,视口停在原阅读断点不动,
|
|
210
|
+
# 「衔接」靠内容连续而非视口跳转。滚动到底(scroll_end)或顶对齐
|
|
211
|
+
# (scroll_to_widget top=True)都会把刚读到的楼层顶出视野、整屏跳动,
|
|
212
|
+
# 逼用户再滚回去找位置——踩过的坑,别再引入。
|
|
213
|
+
|
|
214
|
+
# ---- Enter -> 回答详情屏(多层递进的第三层入口)----
|
|
215
|
+
|
|
216
|
+
def action_pick_answer(self) -> None:
|
|
217
|
+
"""Enter:进「视口正在阅读」的那个回答的评论层(读到哪进哪,评论那时才请求)。"""
|
|
218
|
+
if self.loader is None or not self._rendered:
|
|
219
|
+
feedback.info(self.app, t("read.still_loading"))
|
|
220
|
+
return
|
|
221
|
+
if not self.source.supports_comments:
|
|
222
|
+
# 键位已隐藏,这里只剩误按兜底:给一句反馈而不是无声(X/RSS 的详情没有评论层)
|
|
223
|
+
feedback.info(
|
|
224
|
+
self.app,
|
|
225
|
+
t(
|
|
226
|
+
"read.single_floor_unsupported",
|
|
227
|
+
label=self.source.label_text,
|
|
228
|
+
unit=self._unit,
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
return
|
|
232
|
+
if not self.loader.doc.answers:
|
|
233
|
+
feedback.info(self.app, t("read.no_floors_yet"))
|
|
234
|
+
return
|
|
235
|
+
self.open_answer(self._visible_answer_index())
|
|
236
|
+
|
|
237
|
+
def _visible_answer_index(self) -> int:
|
|
238
|
+
"""视口顶部所在的楼层:首个底部越过视口顶的楼层。
|
|
239
|
+
|
|
240
|
+
视口还在标题/描述区时,第一个楼层自然命中(Enter 即进回答 1,
|
|
241
|
+
新手按键就有反馈,不用先滚)。
|
|
242
|
+
"""
|
|
243
|
+
top = self._body_scroll().region.y
|
|
244
|
+
for head in self.query(".floor-head"):
|
|
245
|
+
if head.region.bottom > top:
|
|
246
|
+
return int(head.name or 0)
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
def open_answer(self, index: int) -> None:
|
|
250
|
+
"""进第 index 条回答(问题屏保持在栈底,Esc 回来阅读位置不变)。"""
|
|
251
|
+
loader = self.loader
|
|
252
|
+
if loader is None:
|
|
253
|
+
return # 竞态窗口(换篇在途):action 已拦,这里兜底不炸
|
|
254
|
+
self.app.push_screen(AnswerScreen(self.source, self.item, loader, index))
|
|
255
|
+
|
|
256
|
+
# ---- ←/→ 换篇(机械件在 PagedReadingScreen._retarget)----
|
|
257
|
+
|
|
258
|
+
async def action_next_article(self) -> None:
|
|
259
|
+
await self._retarget(1)
|
|
260
|
+
|
|
261
|
+
async def action_prev_article(self) -> None:
|
|
262
|
+
await self._retarget(-1)
|
|
263
|
+
|
|
264
|
+
def _next_target(self, delta: int) -> FeedItem | None:
|
|
265
|
+
return self._step_item(delta)
|
|
266
|
+
|
|
267
|
+
def _no_target_text(self, delta: int) -> str:
|
|
268
|
+
return t("read.no_next_readable") if delta > 0 else t("read.no_prev_readable")
|
|
269
|
+
|
|
270
|
+
async def _adopt_target(self, target: FeedItem) -> None:
|
|
271
|
+
"""收下相邻条目原地重载;列表选中已由 step_readable_item 同步推进,
|
|
272
|
+
Esc 回列表时正好停在读过的那一篇。"""
|
|
273
|
+
self.item = target
|
|
274
|
+
self.run_worker(self._load(), group="load", exclusive=True)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""语言选择弹层:首启设置与日常修改(Ctrl+L)共用一屏。
|
|
2
|
+
|
|
3
|
+
语言未定时无法翻译「选择语言」这件事本身——标题与提示是全 app 唯一的
|
|
4
|
+
双语文案(两种语言文件里同值,见 core/i18n/*.toml 的 language 段),
|
|
5
|
+
列表行用各语言的自称([meta] name,永远以该语言书写:简体中文 / English)。
|
|
6
|
+
Enter / 点击返回语言代码,Esc 返回 None:首启流程 None = 沿用缺省继续
|
|
7
|
+
(完成首启时随 enabled 一并落定,不再反复问),日常修改 None = 不动。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.containers import Vertical
|
|
14
|
+
from textual.widgets import Label, OptionList
|
|
15
|
+
from textual.widgets.option_list import Option
|
|
16
|
+
|
|
17
|
+
from feedscope.core import i18n
|
|
18
|
+
from feedscope.core.i18n import t
|
|
19
|
+
from feedscope.ui.focus_nav import ArrowModalScreen
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LanguageScreen(ArrowModalScreen[str | None]):
|
|
23
|
+
"""语言选择:dismiss 值为语言代码(如 "zh-CN" / "en"),Esc / 取消 = None。"""
|
|
24
|
+
|
|
25
|
+
CSS = """
|
|
26
|
+
#language-dialog { max-width: 56; }
|
|
27
|
+
#language-title { text-style: bold; padding-bottom: 1; }
|
|
28
|
+
#language-list { width: auto; min-width: 36; height: auto; }
|
|
29
|
+
#language-hint { padding-top: 1; color: $text-muted; }
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, highlight: str = "") -> None:
|
|
33
|
+
super().__init__()
|
|
34
|
+
self._langs = i18n.available()
|
|
35
|
+
self._highlight = highlight # 预选项:首启传系统猜测,日常修改传当前语言
|
|
36
|
+
|
|
37
|
+
def compose(self) -> ComposeResult:
|
|
38
|
+
with Vertical(id="language-dialog", classes="modal-card"):
|
|
39
|
+
yield Label(t("language.title"), id="language-title")
|
|
40
|
+
# option id = 语言代码(dismiss 直接回传;代码唯一,id 不撞)
|
|
41
|
+
yield OptionList(
|
|
42
|
+
*[Option(lang.name, id=lang.code) for lang in self._langs],
|
|
43
|
+
id="language-list",
|
|
44
|
+
)
|
|
45
|
+
yield Label(t("language.hint"), id="language-hint")
|
|
46
|
+
|
|
47
|
+
def on_mount(self) -> None:
|
|
48
|
+
ol = self.query_one(OptionList)
|
|
49
|
+
codes = [lang.code for lang in self._langs]
|
|
50
|
+
if self._highlight in codes:
|
|
51
|
+
ol.highlighted = codes.index(self._highlight)
|
|
52
|
+
ol.focus()
|
|
53
|
+
|
|
54
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
55
|
+
if event.option.id is not None:
|
|
56
|
+
self.dismiss(event.option.id)
|
|
57
|
+
|
|
58
|
+
def action_cancel(self) -> None:
|
|
59
|
+
self.dismiss(None)
|