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.
Files changed (72) hide show
  1. feedscope/__init__.py +3 -0
  2. feedscope/__main__.py +4 -0
  3. feedscope/cli.py +154 -0
  4. feedscope/core/__init__.py +1 -0
  5. feedscope/core/browser.py +367 -0
  6. feedscope/core/config.py +367 -0
  7. feedscope/core/const.py +64 -0
  8. feedscope/core/display.py +62 -0
  9. feedscope/core/http.py +104 -0
  10. feedscope/core/i18n/__init__.py +202 -0
  11. feedscope/core/i18n/en.toml +447 -0
  12. feedscope/core/i18n/zh-CN.toml +444 -0
  13. feedscope/core/media.py +150 -0
  14. feedscope/core/models.py +86 -0
  15. feedscope/core/registry.py +53 -0
  16. feedscope/core/samples.py +68 -0
  17. feedscope/core/source.py +668 -0
  18. feedscope/core/text.py +54 -0
  19. feedscope/core/timefmt.py +53 -0
  20. feedscope/core/tree.py +134 -0
  21. feedscope/doctor.py +54 -0
  22. feedscope/login.py +168 -0
  23. feedscope/probe.py +131 -0
  24. feedscope/report.py +19 -0
  25. feedscope/sources/__init__.py +38 -0
  26. feedscope/sources/hn/__init__.py +66 -0
  27. feedscope/sources/hn/fetch.py +82 -0
  28. feedscope/sources/hn/parse.py +154 -0
  29. feedscope/sources/reddit/__init__.py +178 -0
  30. feedscope/sources/reddit/fetch.py +181 -0
  31. feedscope/sources/reddit/parse.py +318 -0
  32. feedscope/sources/rss/__init__.py +64 -0
  33. feedscope/sources/rss/fetch.py +94 -0
  34. feedscope/sources/rss/parse.py +116 -0
  35. feedscope/sources/telegram/__init__.py +61 -0
  36. feedscope/sources/telegram/fetch.py +79 -0
  37. feedscope/sources/telegram/parse.py +182 -0
  38. feedscope/sources/v2ex/__init__.py +64 -0
  39. feedscope/sources/v2ex/fetch.py +46 -0
  40. feedscope/sources/v2ex/parse.py +116 -0
  41. feedscope/sources/x/__init__.py +68 -0
  42. feedscope/sources/x/fetch.py +490 -0
  43. feedscope/sources/x/parse.py +535 -0
  44. feedscope/sources/zhihu/__init__.py +81 -0
  45. feedscope/sources/zhihu/fetch.py +402 -0
  46. feedscope/sources/zhihu/parse.py +235 -0
  47. feedscope/subscriptions.py +423 -0
  48. feedscope/ui/__init__.py +10 -0
  49. feedscope/ui/account.py +125 -0
  50. feedscope/ui/app.py +105 -0
  51. feedscope/ui/dialogs.py +127 -0
  52. feedscope/ui/feedback.py +64 -0
  53. feedscope/ui/focus_nav.py +113 -0
  54. feedscope/ui/labels.py +38 -0
  55. feedscope/ui/layout.py +66 -0
  56. feedscope/ui/list_render.py +42 -0
  57. feedscope/ui/screens/__init__.py +1 -0
  58. feedscope/ui/screens/answer.py +296 -0
  59. feedscope/ui/screens/detail.py +274 -0
  60. feedscope/ui/screens/language.py +59 -0
  61. feedscope/ui/screens/main.py +736 -0
  62. feedscope/ui/screens/main_setup.py +226 -0
  63. feedscope/ui/screens/reading.py +384 -0
  64. feedscope/ui/screens/source_setup.py +217 -0
  65. feedscope/ui/screens/subscriptions.py +196 -0
  66. feedscope/ui/theme.py +45 -0
  67. feedscope/ui/wrap.py +157 -0
  68. feedscope-0.1.0.dist-info/METADATA +302 -0
  69. feedscope-0.1.0.dist-info/RECORD +72 -0
  70. feedscope-0.1.0.dist-info/WHEEL +4 -0
  71. feedscope-0.1.0.dist-info/entry_points.txt +2 -0
  72. feedscope-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,226 @@
1
+ """主屏基类:源启用编排(首启引导、F2 源管理、启用集变化的生效路径)。
2
+
3
+ 从 main.py 拆出的 MainSetupScreen——「配置生效流程」(引导落盘 / 勾选
4
+ 重建 / 订阅空源指引)不是主屏的渲染与导航职责,与 source_setup.py 的
5
+ 弹层天然配对(行文案 source_entry_text 就住在那边)。MainScreen 继承本
6
+ 基类拿到这组流程;编排依赖的协作者(源列表状态与两侧导航的重建、刷新)
7
+ 以抽象方法显式声明,静态检查从此不需要「mixin 靠子类兜」的约定。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from abc import abstractmethod
13
+
14
+ from textual.screen import Screen
15
+
16
+ from feedscope.core import config
17
+ from feedscope.core import i18n
18
+ from feedscope.core.i18n import t
19
+ from feedscope.core.registry import all_sources, get_class
20
+ from feedscope.core.source import Source
21
+ from feedscope.ui import feedback
22
+ from feedscope.ui.screens.language import LanguageScreen
23
+ from feedscope.ui.screens.source_setup import (
24
+ ManageSourcesScreen,
25
+ OnboardingScreen,
26
+ source_entry_text,
27
+ )
28
+
29
+
30
+ class MainSetupScreen(Screen[None]):
31
+ """源启用编排基类(MainScreen 的父类;协作者由主屏实现,见抽象声明)。"""
32
+
33
+ # 编排读写的源列表状态(MainScreen.__init__ 初始化)
34
+ sources: list[Source]
35
+ source_idx: int
36
+ _feed_memory: dict[str, int]
37
+ _subs_dirty: bool
38
+
39
+ @abstractmethod
40
+ async def _render_source_tabs(self) -> None:
41
+ """重建顶部站点 tab 条(高亮当前源;实现见 MainScreen)。"""
42
+
43
+ @abstractmethod
44
+ async def _render_feed_sidebar(self) -> None:
45
+ """重建左侧信息流边栏(高亮当前流;实现见 MainScreen)。"""
46
+
47
+ @abstractmethod
48
+ def action_refresh(self) -> None:
49
+ """刷新当前流(实现见 MainScreen)。"""
50
+
51
+ @abstractmethod
52
+ def _restore_feed_idx(self) -> None:
53
+ """切站后停回该站上次的信息流(实现见 MainScreen)。"""
54
+
55
+ def _enabled_sources(self) -> list[Source]:
56
+ """按配置取启用的源,展示顺序 = enabled 的书写顺序(= 用户勾选顺序)。
57
+
58
+ enabled 缺省 = 全部启用(注册序 = 字母正序,老配置文件只有 hide 的不受影响);
59
+ 不在注册表的 id 静默略过——卸载 / 改名后的残留条目无害,doctor 会报告
60
+ 配置概况。
61
+ """
62
+ return self._live_sources(config.enabled_ids())
63
+
64
+ @staticmethod
65
+ def _live_sources(ids: list[str] | None) -> list[Source]:
66
+ """实例化 ids(None = 全部注册源),只留当前有可选信息流的源。
67
+
68
+ 无可选信息流的源不进 Tab(零订阅的 rss / 内置流全被隐藏的源):
69
+ 空 tab 只有噪音;配置好订阅 / 取消隐藏后 F2 重勾即回来。
70
+ """
71
+ if ids is None:
72
+ made = all_sources()
73
+ else:
74
+ made = [cls() for sid in ids if (cls := get_class(sid)) is not None]
75
+ return [s for s in made if s.feeds]
76
+
77
+ async def _start(self) -> None:
78
+ """启动序列:首启(配置文件不存在)先选语言、再引导选源,确认落盘后才进正常渲染。
79
+
80
+ 语言屏在渲染前完成,选中即 set_language 立即生效(本会话后续文案
81
+ 全是新语言);Esc = 沿用缺省继续,完成首启时随 enabled 一并落定,
82
+ 之后不再问。勾选是使用源的前提:引导取消(None)= 放弃本次使用,
83
+ 不默认全开。_start 本就在 worker 里,push_screen 的 wait_for_dismiss 合法。
84
+ """
85
+ if not config.is_configured():
86
+ lang = await self.app.push_screen(
87
+ LanguageScreen(highlight=i18n.default_language()), wait_for_dismiss=True
88
+ )
89
+ if lang is not None:
90
+ i18n.set_language(lang)
91
+ chosen = await self.app.push_screen(
92
+ OnboardingScreen(self._setup_entries(), [s.source_id for s in self.sources]),
93
+ wait_for_dismiss=True,
94
+ )
95
+ if chosen is None:
96
+ self.app.exit(message=t("app.exit_onboarding_cancel"))
97
+ return
98
+ config.save_enabled(chosen)
99
+ if config.get_language() is None:
100
+ config.save_language(i18n.language()) # 语言屏被跳过也随首启落定
101
+ # 只重建状态,渲染统一交给 _bootstrap:_start 在 default 组里跑,
102
+ # 若此处走 _apply_enabled 的 action_refresh(default 组 exclusive),
103
+ # 会把还在 await 的自己取消在「旧子项已摘、新子项未挂」的中间态
104
+ self._rebuild_enabled(chosen)
105
+ self._hint_empty_subscribables(chosen)
106
+ await self._bootstrap()
107
+
108
+ async def _bootstrap(self) -> None:
109
+ """初始渲染:tab 条/边栏重建是异步的(等旧子项摘除),随后才开始抓取。"""
110
+ await self._render_source_tabs()
111
+ await self._render_feed_sidebar()
112
+ self.action_refresh()
113
+
114
+ def _setup_entries(self) -> list[tuple[str, str]]:
115
+ """源选择弹层的行数据:(source_id, 行文案),注册顺序即列表顺序。
116
+
117
+ 遍历实例而非类:feeds 是配置合成后的视图,rss 的流来自用户订阅表
118
+ (类默认为空)。文案带上各源的信息流名(超 3 个截成计数,几十个
119
+ 订阅不至于刷屏);无可选信息流的源不列出——勾上也只能得到空 tab,
120
+ 例外是可订阅源(Source.subscribable):空订阅也列出,→ 进订阅管
121
+ 理屏就地添加(否则零订阅的 rss 永远进不了这个列表,鸡生蛋)。
122
+ 行文案统一由 source_entry_text 生成(订阅管理返回后的同步也用它)。
123
+ """
124
+ return [
125
+ (source.source_id, text)
126
+ for source in all_sources()
127
+ if (text := source_entry_text(source))
128
+ ]
129
+
130
+ def _rebuild_enabled(self, ids: list[str]) -> None:
131
+ """按启用列表重建源列表与选中位(纯状态,不碰 DOM)。
132
+
133
+ 展示顺序 = ids 顺序(弹屏按勾选顺序产出,落盘 enabled 同序);尽量停
134
+ 在原来的源——前提是它还在 self.sources 里(勾了但零订阅的源会被
135
+ feeds 过滤出局,「停在原源」按过滤后的实际名单找,找不到回 0 位,
136
+ 不能按 chosen 找:否则 next() 落空直接崩,保存后的重建全断)。
137
+ """
138
+ chosen = set(ids)
139
+ prev_id = self.sources[self.source_idx].source_id if self.sources else None
140
+ self.sources = self._live_sources(ids)
141
+ self._feed_memory = {k: v for k, v in self._feed_memory.items() if k in chosen}
142
+ if prev_id is not None and any(s.source_id == prev_id for s in self.sources):
143
+ self.source_idx = next(i for i, s in enumerate(self.sources) if s.source_id == prev_id)
144
+ else:
145
+ self.source_idx = 0
146
+ self._restore_feed_idx() # 停在该站上次的信息流(越界回 0)
147
+
148
+ async def _apply_enabled(self, ids: list[str]) -> None:
149
+ """启用集变化的生效路径(F2 源管理):状态重建 + 两侧导航重渲染 + 刷新。
150
+
151
+ 只在 sources 组的 worker 里调用(该组不被 default 组的 exclusive 刷新
152
+ 取消,见 action_manage_sources)。
153
+ """
154
+ self._rebuild_enabled(ids)
155
+ self._hint_empty_subscribables(ids)
156
+ await self._render_source_tabs()
157
+ await self._render_feed_sidebar()
158
+ self.action_refresh()
159
+
160
+ def _hint_empty_subscribables(self, ids: list[str]) -> None:
161
+ """勾了零订阅的可订阅源:给下一步指引(rss/telegram 的 feeds 全来自
162
+ 订阅表,零订阅 = 保存成功也不进 Tab——没有这条提示用户只看到「没生效」)。"""
163
+ for sid in ids:
164
+ cls = get_class(sid)
165
+ if cls is not None and cls.subscribable and not cls().feeds:
166
+ feedback.info(self, t("main.hint_no_subscriptions", label=cls().label_text))
167
+
168
+ # ---- F2 源管理:勾选启用的源,保存即生效 ----
169
+
170
+ def action_manage_sources(self) -> None:
171
+ """F2 源管理:勾选启用的源,保存即生效——增加 / 删除 / 修改启用集都走这里。
172
+
173
+ 走 worker 因为 push_screen 的 wait_for_dismiss 只能在 worker 里用,
174
+ group 独立于列表加载,F2 不打断在途的 _refresh。
175
+ """
176
+ self.run_worker(self._manage_sources(), group="sources", exclusive=True)
177
+
178
+ async def _manage_sources(self) -> None:
179
+ current_ids = [s.source_id for s in self.sources]
180
+ chosen = await self.app.push_screen(
181
+ ManageSourcesScreen(self._setup_entries(), current_ids, self._mark_subs_dirty),
182
+ wait_for_dismiss=True,
183
+ )
184
+ if chosen is None:
185
+ # 取消:启用集不落盘,但订阅若在二级屏改过已即时生效——重建不欠着
186
+ if self._subs_dirty:
187
+ self._subs_dirty = False
188
+ ids = config.enabled_ids()
189
+ await self._apply_enabled(ids if ids is not None else current_ids)
190
+ return
191
+ config.save_enabled(chosen)
192
+ self._subs_dirty = False # 确认路径 _apply_enabled 全量重建,无需再消费
193
+ await self._apply_enabled(chosen)
194
+ if chosen:
195
+ feedback.info(self, t("main.saved_n_sources", n=len(chosen)))
196
+ else:
197
+ feedback.info(self, t("main.saved_zero_sources"))
198
+
199
+ def _mark_subs_dirty(self) -> None:
200
+ """订阅管理屏回调:改过信息流。F2 收尾(无论确认取消)都要重建。"""
201
+ self._subs_dirty = True
202
+
203
+ # ---- Ctrl+L 语言设置:确认落盘,重启后生效 ----
204
+
205
+ def action_language(self) -> None:
206
+ """Ctrl+L 语言设置:与首启同一屏,改的是落盘值。
207
+
208
+ 生效要重启(不是当场切):解析期已把数字/单位文案烙进数据(语言随
209
+ 进程固定是最稳的模型,见 core/i18n 模块头),当场重渲染全 UI 必漏。
210
+ 走 worker 因为 push_screen 的 wait_for_dismiss 只能在 worker 里用;
211
+ 独立分组不打断在途的列表刷新。
212
+ """
213
+ self.run_worker(self._pick_language(), group="language", exclusive=True)
214
+
215
+ async def _pick_language(self) -> None:
216
+ code = await self.app.push_screen(
217
+ LanguageScreen(highlight=i18n.language()), wait_for_dismiss=True
218
+ )
219
+ if code is None:
220
+ return # 取消:不动盘
221
+ if code == i18n.language():
222
+ # 原语言重选不落盘,但要出声:静默会让「以为切了」的用户误判成功
223
+ feedback.info(self, t("main.language_unchanged", name=i18n.name_of(code)))
224
+ return
225
+ config.save_language(code)
226
+ feedback.info(self, t("main.language_saved_restart"))
@@ -0,0 +1,384 @@
1
+ """全屏阅读屏的共享骨架,两层收口:
2
+
3
+ - ReadingScreen(所有阅读屏):Header + 正文滚动容器 + Footer 的屏级机械件
4
+ ——Esc 返回、resize 防抖重排、尾行 hint 生命周期、副标题保存恢复、
5
+ 卸载清理(停定时器 + 关 loader)。阅读屏家族的公共样式也挂在这层
6
+ (DEFAULT_CSS 沿 MRO 合并,子类 CSS 只写自己的增量,不再字符串拼接)。
7
+ - PagedReadingScreen(loader 驱动的分页阅读屏,detail.py / answer.py):
8
+ 「首批 → Ctrl+N 增量」的动线机械件——Ctrl+N 分派(首载失败态转重试)、
9
+ load_more 的陈旧 loader 守卫与尾行状态机(到头 → EOF 标记,没到头 →
10
+ 加载预告)、←/→ 换目标的取消在途 / 换出旧 loader / 清屏重载。
11
+ DetailLoader 与 CommentLoader 同形(load_more/done/close),问题屏与
12
+ 回答屏各自的翻页机械件在这里唯一实现。
13
+
14
+ 语义样式类(子类直接用,规则只此一份):
15
+ - .read-title/.read-meta/.read-main:标题行 / 元信息行 / 主正文
16
+ - .floor-head/.floor-body:楼层头 / 楼层正文(含 .sub 缩进变体)
17
+ - .tail-line:尾部状态行(加载预告 / EOF 标记 / 空态),细分
18
+ .tail-hint(Ctrl+N 预告)/ .tail-eof((没有更多…)·(完))
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import dataclasses
24
+ import webbrowser
25
+ from abc import abstractmethod
26
+ from collections.abc import Sequence
27
+ from typing import Any, ClassVar
28
+
29
+ from rich.markup import escape
30
+ from textual.binding import Binding
31
+ from textual.containers import VerticalScroll
32
+ from textual.events import Resize
33
+ from textual.screen import Screen
34
+ from textual.timer import Timer
35
+ from textual.widgets import Label, Static
36
+
37
+ from feedscope.core.i18n import t
38
+ from feedscope.core.models import FeedItem
39
+ from feedscope.core.source import CommentLoader, DetailLoader, Source
40
+ from feedscope.ui import Bindings, feedback
41
+ from feedscope.ui.labels import retranslate_bindings
42
+ from feedscope.ui.layout import RESIZE_DEBOUNCE, md_body_width
43
+
44
+
45
+ def divider() -> Static:
46
+ """全宽分隔线(dim 灰):楼层/区块之间的边界,超宽由容器裁掉。"""
47
+ return Static("[dim]" + "─" * 200 + "[/dim]", classes="answer-divider")
48
+
49
+
50
+ def dim_line(text: str, **kwargs) -> Static:
51
+ """dim 灰的一行尾注(EOF 标记 / 空态等同族):markup 只写一次。"""
52
+ return Static(f"[dim]{text}[/dim]", **kwargs)
53
+
54
+
55
+ def floor_head(
56
+ lead: str, author: str, fields: Sequence[str], *, classes: str, name: str | None = None
57
+ ) -> Static:
58
+ """楼层头部件:lead(如「回答 3」;空 = 无编号的子回复头)+ 署名 + dim 字段串。
59
+
60
+ 问题屏的楼层头 / 回答屏的评论头与子回复头三处同构,收进阅读屏家族
61
+ 共享(与 divider / dim_line 同层);fields 里的空串自动略过。
62
+ """
63
+ parts: list[str] = []
64
+ if lead:
65
+ parts.append(f"[b]{lead}[/b]")
66
+ parts.append(f"[b]{escape(author)}[/b]")
67
+ parts.extend(f"[dim]{escape(x)}[/dim]" for x in fields if x)
68
+ return Static(" ".join(parts), classes=classes, name=name)
69
+
70
+
71
+ # 正文统一格式(所有源同形):Markdown 标题抹平成段落样式——Textual 缺省
72
+ # 给 H1 居中加粗、H2 下划线、H3 加粗、H5/H6 甚至 ansi_cyan,且 Header 基类
73
+ # color $text(auto 87%,比正文亮)+ margin 2 0 1 0;统一为正文的前景色/
74
+ # 样式/对齐/边距(App 基类 color: $foreground,段落即继承它),标题不再有
75
+ # 专属版式。蓝色与下划线只留给链接(theme.variables)。
76
+ # 这条必须进各屏的 CSS 而非 DEFAULT_CSS:要压过 Markdown 组件自带的
77
+ # DEFAULT_CSS(屏级 DEFAULT_CSS 压不过 widget 自己的,CSS 才压得过),
78
+ # 两屏以常量拼接共享。
79
+ READING_CSS = """
80
+ MarkdownH1, MarkdownH2, MarkdownH3, MarkdownH4, MarkdownH5, MarkdownH6 {
81
+ color: $foreground;
82
+ background: transparent;
83
+ text-style: none;
84
+ content-align: left middle;
85
+ margin: 0 0 1 0;
86
+ }
87
+ """
88
+
89
+
90
+ class ReadingScreen(Screen[None]):
91
+ """阅读屏基类:resize 防抖整屏重排 + hint 生命周期 + 副标题/定时器/ loader 清理。
92
+
93
+ 子类契约(全部抽象):
94
+ - _body_scroll:正文滚动容器(重排/挂载的目标)
95
+ - _render_doc:整屏渲染(首批与重排共用同一条路径)
96
+ - _hint_widget:尾行动线预告
97
+ - _reset_render_state:换目标/重排前清渲染态(楼层序号等子类字段)
98
+ - _close_loader:卸载时关闭持有的 loader(幂等,出错自己吞)
99
+ 子类自管 _rendered 的置位时机(首批渲染完成才吃 resize 重排)。
100
+ """
101
+
102
+ BINDINGS: ClassVar[Bindings] = [Binding("escape", "back", "bindings.back")]
103
+
104
+ # 家族共享样式挂 DEFAULT_CSS(沿 MRO 与子类 CSS 合并):子类 CSS 只写
105
+ # 增量,原先五对逐字相同的规则(#detail-title ↔ #answer-title 等)归位
106
+ # 成语义类。例外见 READING_CSS(要压过 widget 自带 DEFAULT_CSS 的规则)
107
+ DEFAULT_CSS = """
108
+ /* 楼层/区块分隔线(divider()):两屏共用,超宽由容器裁掉 */
109
+ .answer-divider { width: 1fr; height: 1; }
110
+
111
+ /* 标题行(加粗 + ascii 边框)与元信息行 */
112
+ .read-title { text-style: bold; padding: 1 2; border: ascii; width: 1fr; text-wrap: wrap; }
113
+ .read-meta { padding: 0 2; }
114
+ /* 主正文(问题描述 / 回答全文) */
115
+ .read-main { padding: 0 2; }
116
+ /* 楼层头(窄终端长头折行不裁剪)与楼层正文 */
117
+ .floor-head { padding: 1 2 0 2; text-wrap: wrap; }
118
+ .floor-body { padding: 0 2; }
119
+ /* 子回复的缩进变体(回答屏的楼中楼) */
120
+ .floor-head.sub, .floor-body.sub { margin-left: 3; }
121
+ /* 尾部状态行(加载预告 / EOF 标记 / 空态) */
122
+ .tail-line { padding: 1; }
123
+ """
124
+
125
+ def __init__(self) -> None:
126
+ super().__init__()
127
+ self._prev_sub_title: str | None = None # 进屏前的 Header 副标题(退屏恢复)
128
+ self._rendered = False # 首批已上屏;此后 resize 才需要重排
129
+ self._rendered_width = 0 # 渲染时实际使用的容器宽(首布局前渲染 = 0,见 _render_done)
130
+ self._last_width = 0
131
+ self._resize_timer: Timer | None = None
132
+ self._hint: Static | None = None
133
+
134
+ @abstractmethod
135
+ def _body_scroll(self) -> VerticalScroll:
136
+ """正文滚动容器。"""
137
+
138
+ @abstractmethod
139
+ async def _render_doc(self) -> None:
140
+ """整屏渲染(首批与 resize 重排共用;单次 mount = 单次布局)。"""
141
+
142
+ @abstractmethod
143
+ def _hint_widget(self) -> Static:
144
+ """尾行动线预告(只留 Footer 没有的语义增量)。"""
145
+
146
+ @abstractmethod
147
+ def _reset_render_state(self) -> None:
148
+ """换目标/重排前清渲染态(楼层序号归零等)。"""
149
+
150
+ @abstractmethod
151
+ async def _close_loader(self) -> None:
152
+ """卸载时关掉持有的 loader(幂等,出错自己吞)。"""
153
+
154
+ async def _mount_body(self) -> None:
155
+ """on_mount 的屏内钩子:聚焦正文容器 + 触发首载(子类覆写,先调 focus)。"""
156
+ self._body_scroll().focus()
157
+
158
+ async def on_mount(self) -> None:
159
+ retranslate_bindings(self) # 键式键位文案解析(bindings.*),见 ui/labels.py
160
+ self._prev_sub_title = self.app.sub_title
161
+ await self._mount_body()
162
+
163
+ # ---- resize 重排:拖拽改宽连发 Resize,稳定后整屏重排(不重新抓取)----
164
+
165
+ def _render_done(self) -> None:
166
+ """首批上屏完成(子类在 _render_doc 后调用):记录渲染实际使用的容器宽。
167
+
168
+ 折行宽取自当时的 region.width——若渲染发生在首布局之前(零请求的
169
+ 详情/回答屏会这样),宽度是 0、正文被钳到最小宽挤压在左侧;resize
170
+ 重排的触发条件与这里记录的宽度比对(而非跳过「第一次」),这种
171
+ 渲染后的首个 Resize 就会整屏重排自愈,不用用户手动改窗口救。
172
+ """
173
+ self._rendered = True
174
+ self._rendered_width = self._body_scroll().region.width
175
+
176
+ def on_resize(self, event: Resize) -> None:
177
+ if event.size.width in (self._last_width, self._rendered_width):
178
+ self._last_width = event.size.width
179
+ return # 宽度没变 / 与渲染时同宽:无需重排(含首布局——渲染即按此宽)
180
+ self._last_width = event.size.width
181
+ if not self._rendered:
182
+ return
183
+ if self._resize_timer is not None:
184
+ self._resize_timer.stop()
185
+ self._resize_timer = self.set_timer(RESIZE_DEBOUNCE, self._rerender)
186
+
187
+ def _rerender(self) -> None:
188
+ if not self._rendered:
189
+ return
190
+ scroll_y = self._body_scroll().scroll_y
191
+ # 同一屏同时只跑一次重排:连续 resize 的后一次要取代前一次,避免两套
192
+ # remove_children/mount 交错出重复楼层
193
+ self.run_worker(self._rerender_body(scroll_y), group="rerender", exclusive=True)
194
+
195
+ async def _rerender_body(self, scroll_y: float) -> None:
196
+ body = self._body_scroll()
197
+ await body.remove_children()
198
+ self._hint = None
199
+ self._reset_render_state()
200
+ try:
201
+ await self._render_doc()
202
+ except Exception:
203
+ return # 屏幕正在被关闭:放弃重排
204
+ self._render_done() # 重排后的折行宽才是当前有效宽
205
+ # 重排后恢复阅读位置(内容高度要等一次刷新才更新,故延后滚动)
206
+ body.call_after_refresh(body.scroll_to, y=scroll_y, animate=False)
207
+
208
+ # ---- 尾行 hint 的挂载/摘除(随楼层增删反复挂载,class 不用 id 防撞重)----
209
+
210
+ async def _mount_hint(self) -> None:
211
+ self._remove_hint()
212
+ self._hint = self._hint_widget()
213
+ await self._body_scroll().mount(self._hint)
214
+
215
+ def _remove_hint(self) -> None:
216
+ if self._hint is not None:
217
+ w, self._hint = self._hint, None
218
+ try:
219
+ w.remove() # 同步发起摘除;重复挂载的防护靠 class 不用 id
220
+ except Exception:
221
+ pass
222
+
223
+ # ---- 键位与清理 ----
224
+
225
+ def action_back(self) -> None:
226
+ self.app.pop_screen()
227
+
228
+ async def on_unmount(self) -> None:
229
+ if self._resize_timer is not None:
230
+ self._resize_timer.stop()
231
+ self._resize_timer = None
232
+ if self._prev_sub_title is not None:
233
+ self.app.sub_title = self._prev_sub_title
234
+ await self._close_loader()
235
+
236
+
237
+ class PagedReadingScreen(ReadingScreen):
238
+ """分页阅读屏:Ctrl+N 翻页动线 / 尾行状态机 / ←/→ 换目标重载的唯一实现。
239
+
240
+ 子类注入(全部抽象或声明):
241
+ - _worker_group:本屏取数 worker 的组名(首载/重试/翻页同组互斥;换目标
242
+ 与卸载时整组取消,重排组 rerender 一并取消——在途重排不能再拿旧 doc 渲)
243
+ - _mount_batch(new):把一批新楼层渲染上屏(楼层部件是屏差异)
244
+ - _retry():首载失败后的原地重试(摘失败行重走首载)
245
+ - _next_target / _no_target_text / _adopt_target:←/→ 换目标三件套
246
+ (拿相邻目标、边界反馈文案、收下目标重走首载)
247
+
248
+ 契约:self.loader 是本屏的翻页 loader(None = 首载失败或在途,Ctrl+N
249
+ 转重试);_exhausted 只由本层的尾行状态机与子类首载置位。楼层词 _unit
250
+ 与词汇表键位文案来自源声明(Source 的 *_unit_label)。
251
+ """
252
+
253
+ _worker_group: ClassVar[str] = "page"
254
+ source: Source # 源实例(失败文案与能力判定)
255
+ item: FeedItem # 正在读的条目(Ctrl+O 与屏间传递)
256
+
257
+ loader: DetailLoader | CommentLoader | None = None
258
+ _exhausted: bool = False # 翻页对象已到头,Ctrl+N 不再触发加载
259
+
260
+ # ---- 翻页(Ctrl+N:加载更多 / 失败态重试)----
261
+
262
+ def action_more(self) -> None:
263
+ if self.loader is None:
264
+ # 首载失败(或还在途)时 Ctrl+N = 原地重试:比起 Esc 退出再进,动线不断
265
+ self.run_worker(self._retry(), group=self._worker_group, exclusive=True)
266
+ return
267
+ if self._exhausted:
268
+ # 常驻尾行之外再给瞬时反馈:按键不无声
269
+ feedback.info(self.app, t("read.no_more_plain", unit=self._unit))
270
+ return
271
+ self.run_worker(self._load_more(), group=self._worker_group, exclusive=True)
272
+
273
+ async def _load_more(self) -> None:
274
+ body = self._body_scroll()
275
+ loader = self.loader
276
+ if loader is None:
277
+ return
278
+ self._remove_hint()
279
+ loading = Label(f"[dim]{t('read.loading_more')}[/dim]", classes="tail-line more-loading")
280
+ await body.mount(loading)
281
+ try:
282
+ new = await loader.load_more()
283
+ except Exception as exc:
284
+ # 不再静默:失败要告诉用户(否则按 Ctrl+N 无反馈,误以为按键失灵)
285
+ feedback.fail(self.app, self.source, exc, action="feedback.action_more_failed")
286
+ new = []
287
+ if loader is not self.loader:
288
+ return # 期间换目标/关闭:旧 loader 的迟到结果直接丢弃
289
+ if loading.parent is not None: # 期间可能整屏重排过,loading 已被清掉
290
+ loading.remove()
291
+ # 最后一批可能正好取尽:done 当场生效,不再让用户多按一次 Ctrl+N;
292
+ # 拉空但没到头(网慢/懒加载没触发)不判死,尾行保留预告可再按
293
+ self._exhausted = loader.done
294
+ if new:
295
+ await self._mount_batch(new)
296
+ await self._mount_tail()
297
+
298
+ @abstractmethod
299
+ async def _mount_batch(self, new: list[Any]) -> None:
300
+ """把一批新楼层渲染上屏(单次挂载 = 单次布局;序号推进在子类)。"""
301
+
302
+ async def _mount_tail(self) -> None:
303
+ """尾行状态机:到头挂 EOF 标记,没到头挂 Ctrl+N 预告。"""
304
+ if self._exhausted:
305
+ self._remove_hint()
306
+ await self._body_scroll().mount(
307
+ dim_line(t("read.no_more", unit=self._unit), classes="tail-line tail-eof")
308
+ )
309
+ else:
310
+ await self._mount_hint()
311
+
312
+ @abstractmethod
313
+ async def _retry(self) -> None:
314
+ """首载失败后的原地重试(重走首载;Ctrl+N 在 loader 为 None 时进来)。"""
315
+
316
+ # ---- ←/→ 换目标(换篇 / 换楼层)----
317
+
318
+ async def _retarget(self, delta: int) -> None:
319
+ """换目标的共享机械件:拿目标 → 取消在途(取数与重排)→ 换出旧
320
+ loader → 清渲染态清屏 → 子类 _adopt_target 重走首载。
321
+
322
+ 沿方向没有目标时不动任何状态,只给反馈(不换屏、不闪黑)。
323
+ """
324
+ target = self._next_target(delta)
325
+ if target is None:
326
+ feedback.info(self.app, self._no_target_text(delta))
327
+ return
328
+ self.workers.cancel_group(self, self._worker_group)
329
+ self.workers.cancel_group(self, "rerender")
330
+ await self._close_page_loader()
331
+ self._exhausted = False
332
+ self._reset_render_state()
333
+ self._rendered = False
334
+ self._remove_hint()
335
+ await self._body_scroll().remove_children()
336
+ await self._adopt_target(target)
337
+
338
+ @abstractmethod
339
+ def _next_target(self, delta: int) -> Any | None:
340
+ """沿 delta 方向拿下一个可读目标(None = 到边,不动状态只反馈)。"""
341
+
342
+ @abstractmethod
343
+ def _no_target_text(self, delta: int) -> str:
344
+ """到边反馈文案(前后方向的措辞由子类定)。"""
345
+
346
+ @abstractmethod
347
+ async def _adopt_target(self, target: Any) -> None:
348
+ """收下新目标:更新屏内字段并重走首载(网络请求 / 同步渲染差异在子类)。"""
349
+
350
+ @property
351
+ @abstractmethod
352
+ def _unit(self) -> str:
353
+ """翻页对象的单位词(楼层/评论词,读源词汇表)。"""
354
+
355
+ # ---- 共享小件 ----
356
+
357
+ async def _close_page_loader(self) -> None:
358
+ """换出并关闭本屏翻页 loader(幂等;swap-out + close 惯用法唯一实现)。"""
359
+ loader, self.loader = self.loader, None
360
+ if loader is not None:
361
+ await loader.close()
362
+
363
+ async def _close_loader(self) -> None:
364
+ await self._close_page_loader() # 卸载清理 = 换出并关掉翻页 loader
365
+
366
+ def action_open_browser(self) -> None:
367
+ webbrowser.open(self._browser_url())
368
+
369
+ def _browser_url(self) -> str:
370
+ """Ctrl+O 的目标:缺省当前条目;回答屏覆写带楼层直达链接。"""
371
+ return self.item.url
372
+
373
+ def _md_width(self, indent: int = 0) -> int:
374
+ """正文折行宽(折行算术与 CSS 常量的对应关系集中在 ui/layout.py)。"""
375
+ return max(md_body_width(self._body_scroll().region.width) - indent, 20)
376
+
377
+ def _relabel_bindings(self, labels: dict[str, str], *, hide: str = "") -> None:
378
+ """类级占位键位文案换源词汇表;hide 指定的键整体隐藏(保留绑定,
379
+ 误按仍有反馈路径,只是 Footer 不预告)。"""
380
+ for key, label in labels.items():
381
+ self._bindings.key_to_bindings[key] = [
382
+ dataclasses.replace(b, description=label, show=b.show and key != hide)
383
+ for b in self._bindings.key_to_bindings.get(key, [])
384
+ ]