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,217 @@
|
|
|
1
|
+
"""源选择弹层:首启引导 + 日常源管理(F2)。
|
|
2
|
+
|
|
3
|
+
两屏同一套 SelectionList 勾选交互(空格切换):确认返回勾选的源 id 列表
|
|
4
|
+
(**按勾选顺序**——展示顺序 = 勾选顺序,后勾上的排末尾;勾选行实时标
|
|
5
|
+
1、2、3… 序号 = 站点排列位置),取消 / Esc 返回 None——引导里 None =
|
|
6
|
+
放弃使用(App 退出),管理里 None = 不落盘。候选与初始勾选由 MainScreen 注入,
|
|
7
|
+
屏不读写配置:选择生效路径(保存 + 重建站点栏)集中在 MainScreen(_start /
|
|
8
|
+
_manage_sources)。
|
|
9
|
+
|
|
10
|
+
可订阅源(Source.subscribable,目前 rss、reddit 与 telegram)在此层还有
|
|
11
|
+
二级入口:→ 进订阅管理屏(SubsStore 按源分派),改动即时落盘;返回后
|
|
12
|
+
同步该源勾选(有订阅 = 勾上,空 = 取消)与行文案,并回调 MainScreen 标脏。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from typing import ClassVar
|
|
19
|
+
|
|
20
|
+
from textual.app import ComposeResult
|
|
21
|
+
from textual.containers import Horizontal, Vertical
|
|
22
|
+
from textual.widgets import Button, Label, SelectionList
|
|
23
|
+
from textual.widgets.selection_list import Selection
|
|
24
|
+
|
|
25
|
+
from feedscope.core.i18n import t
|
|
26
|
+
from feedscope.core.registry import get_class
|
|
27
|
+
from feedscope.core.source import Source
|
|
28
|
+
from feedscope.subscriptions import store_for
|
|
29
|
+
from feedscope.ui import Bindings, feedback
|
|
30
|
+
from feedscope.ui.focus_nav import ArrowModalScreen, FocusNavSelectionList
|
|
31
|
+
from feedscope.ui.screens.subscriptions import SubscriptionsScreen
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def source_entry_text(source: Source) -> str:
|
|
35
|
+
"""源选择列表的一行文案;空串 = 该源无可选流且非订阅型,不列出。
|
|
36
|
+
|
|
37
|
+
文案带上各源的信息流名(超 3 个截成计数,几十个订阅不至于刷屏);
|
|
38
|
+
可订阅源空订阅也列出(→ 进订阅管理就地添加,否则零订阅的 rss 永远
|
|
39
|
+
进不了这个列表,鸡生蛋)。
|
|
40
|
+
"""
|
|
41
|
+
labels = [source.feed_label(f) for f in source.feeds]
|
|
42
|
+
if not labels and not source.subscribable:
|
|
43
|
+
return ""
|
|
44
|
+
if labels:
|
|
45
|
+
feeds = t("common.enum_comma").join(labels[:3]) + (
|
|
46
|
+
t("source_setup.n_feeds_more", n=len(labels)) if len(labels) > 3 else ""
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
feeds = t("source_setup.no_subs")
|
|
50
|
+
return f"{source.label_text} [dim]{feeds}[/dim]"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SourceSetupScreen(ArrowModalScreen[list[str] | None]):
|
|
54
|
+
"""源勾选弹层基类:样式与交互共用,标题/文案/确认键由子类给出。"""
|
|
55
|
+
|
|
56
|
+
# 类级三件是文案键(import 期求值只能存键);compose 时经 t() 解析
|
|
57
|
+
title_text: ClassVar[str] = ""
|
|
58
|
+
prompt_text: ClassVar[str] = ""
|
|
59
|
+
confirm_label: ClassVar[str] = "dialogs.confirm"
|
|
60
|
+
|
|
61
|
+
# Esc 取消继承自 ArrowModalScreen(action_cancel → dismiss(None))
|
|
62
|
+
BINDINGS: ClassVar[Bindings] = [
|
|
63
|
+
# 二级入口走 →(与主屏 → 同构):列表上按 = 高亮源的订阅管理,
|
|
64
|
+
# 按钮行上按 = 右移焦点(action_nav_right 分派)。Enter 不能用:
|
|
65
|
+
# SelectionList 把它截停转成勾选切换(widget 内 event.stop()),
|
|
66
|
+
# 屏级绑定与事件路径都压不过——留给勾选
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# 卡片三件套在 ArrowModalScreen.DEFAULT_CSS,这里只写差异(宽度约束与
|
|
70
|
+
# 勾选列表的尺寸)
|
|
71
|
+
CSS = """
|
|
72
|
+
#setup-dialog { max-width: 64; }
|
|
73
|
+
#setup-title { text-style: bold; padding-bottom: 1; }
|
|
74
|
+
#setup-prompt { padding-bottom: 1; }
|
|
75
|
+
#setup-list { width: auto; min-width: 44; height: auto; max-height: 14; margin-bottom: 1; }
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
entries: list[tuple[str, str]],
|
|
81
|
+
initial_ids: list[str],
|
|
82
|
+
on_feeds_changed: Callable[[], None] | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
super().__init__()
|
|
85
|
+
self._entries = list(entries) # (source_id, 行文案),字母正序(见 sources/__init__)
|
|
86
|
+
self._labels: dict[str, str] = dict(entries) # sid -> 不带序号的基础文案
|
|
87
|
+
self._initial = set(initial_ids)
|
|
88
|
+
self._on_feeds_changed = on_feeds_changed # 订阅管理屏改过东西:App 标脏
|
|
89
|
+
# 勾选顺序(= 展示顺序)。初始勾选不触发 SelectionToggled(Textual 只对
|
|
90
|
+
# 显式切换发消息),沿用传入顺序;之后的每次切换在 handler 里维护。
|
|
91
|
+
self._order: list[str] = list(initial_ids)
|
|
92
|
+
|
|
93
|
+
def compose(self) -> ComposeResult:
|
|
94
|
+
with Vertical(id="setup-dialog", classes="modal-card"):
|
|
95
|
+
yield Label(t(self.title_text), id="setup-title")
|
|
96
|
+
yield Label(t(self.prompt_text), id="setup-prompt")
|
|
97
|
+
yield FocusNavSelectionList(
|
|
98
|
+
# option id = source id:改行文案(编号)时按 id 定位
|
|
99
|
+
*[Selection(text, sid, sid in self._initial, id=sid) for sid, text in self._entries]
|
|
100
|
+
)
|
|
101
|
+
with Horizontal(id="setup-actions"):
|
|
102
|
+
yield Button(t(self.confirm_label), id="setup-confirm", variant="primary")
|
|
103
|
+
yield Button(t("dialogs.cancel"), id="setup-cancel")
|
|
104
|
+
|
|
105
|
+
def on_mount(self) -> None:
|
|
106
|
+
self._renumber() # 初始勾选也要带序号
|
|
107
|
+
|
|
108
|
+
def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled) -> None:
|
|
109
|
+
"""维护勾选顺序:勾上排到末尾、取消即移出(再勾上 = 挪到末尾,这就是调序动线)。
|
|
110
|
+
|
|
111
|
+
_toggle 先改状态后发消息,handler 里 selected 已是新状态,按成员关系
|
|
112
|
+
分支即可,不必依赖事件本身携带方向。
|
|
113
|
+
"""
|
|
114
|
+
sid = event.selection.value
|
|
115
|
+
if sid in event.selection_list.selected:
|
|
116
|
+
if sid not in self._order:
|
|
117
|
+
self._order.append(sid)
|
|
118
|
+
else:
|
|
119
|
+
self._order = [s for s in self._order if s != sid]
|
|
120
|
+
self._renumber()
|
|
121
|
+
|
|
122
|
+
def _renumber(self) -> None:
|
|
123
|
+
"""给勾选的行标序号(= 站点排列位置),未勾选的留空对齐。
|
|
124
|
+
|
|
125
|
+
序号随切换重排(取消 1 号后,原 2 号自动变 1);列表行本身不动
|
|
126
|
+
(字母正序稳定,好找),只变文案。
|
|
127
|
+
"""
|
|
128
|
+
sl = self.query_one(SelectionList)
|
|
129
|
+
pos = {sid: i + 1 for i, sid in enumerate(self.selected_ids)}
|
|
130
|
+
for sid, label in self._labels.items():
|
|
131
|
+
n = pos.get(sid)
|
|
132
|
+
sl.replace_option_prompt(sid, f"{n:>2} {label}" if n else f" {label}")
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def selected_ids(self) -> list[str]:
|
|
136
|
+
"""勾选的源 id,按勾选顺序返回(初始勾选沿用传入顺序)。"""
|
|
137
|
+
chosen = set(self.query_one(SelectionList).selected)
|
|
138
|
+
return [sid for sid in self._order if sid in chosen]
|
|
139
|
+
|
|
140
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
141
|
+
if event.button.id == "setup-confirm":
|
|
142
|
+
self.dismiss(self.selected_ids)
|
|
143
|
+
else:
|
|
144
|
+
self.dismiss(None) # 只有确认/取消两键,if/else 即分派表
|
|
145
|
+
|
|
146
|
+
def action_cancel(self) -> None:
|
|
147
|
+
self.dismiss(None)
|
|
148
|
+
|
|
149
|
+
# ---- →:可订阅源的二级管理(订阅增删改名)----
|
|
150
|
+
|
|
151
|
+
def action_nav_right(self) -> None:
|
|
152
|
+
"""→ 焦点感知分派:列表上 = 高亮源的订阅管理(原语义),按钮行 = 右移。"""
|
|
153
|
+
if isinstance(self.focused, SelectionList):
|
|
154
|
+
self.action_manage_feeds()
|
|
155
|
+
return
|
|
156
|
+
super().action_nav_right()
|
|
157
|
+
|
|
158
|
+
def action_manage_feeds(self) -> None:
|
|
159
|
+
"""→:进高亮源的订阅管理屏;不可订阅的源按了要给反馈,不静默。"""
|
|
160
|
+
sl = self.query_one(SelectionList)
|
|
161
|
+
idx = sl.highlighted
|
|
162
|
+
if idx is None or not 0 <= idx < len(self._entries):
|
|
163
|
+
return
|
|
164
|
+
sid = self._entries[idx][0]
|
|
165
|
+
cls = get_class(sid)
|
|
166
|
+
if cls is None or not cls.subscribable:
|
|
167
|
+
feedback.info(
|
|
168
|
+
self.app, t("source_setup.not_subscribable", label=cls().label_text if cls else sid)
|
|
169
|
+
)
|
|
170
|
+
return
|
|
171
|
+
self.run_worker(self._manage_feeds(sid))
|
|
172
|
+
|
|
173
|
+
async def _manage_feeds(self, sid: str) -> None:
|
|
174
|
+
changed = await self.app.push_screen(
|
|
175
|
+
SubscriptionsScreen(store_for(sid)), wait_for_dismiss=True
|
|
176
|
+
)
|
|
177
|
+
if not changed:
|
|
178
|
+
return
|
|
179
|
+
self._sync_row(sid)
|
|
180
|
+
if self._on_feeds_changed is not None:
|
|
181
|
+
self._on_feeds_changed()
|
|
182
|
+
|
|
183
|
+
def _sync_row(self, sid: str) -> None:
|
|
184
|
+
"""订阅管理返回后同步该源勾选与行文案:还有可选信息流 = 勾上(加订阅
|
|
185
|
+
显然想用,与 CLI 的 enabled 联动同语义);删空 = 取消勾选。"""
|
|
186
|
+
cls = get_class(sid)
|
|
187
|
+
if cls is None:
|
|
188
|
+
return
|
|
189
|
+
source = cls()
|
|
190
|
+
want = bool(source.feeds)
|
|
191
|
+
self._labels[sid] = source_entry_text(source) or self._labels[sid]
|
|
192
|
+
sl = self.query_one(SelectionList)
|
|
193
|
+
if want and sid not in sl.selected:
|
|
194
|
+
sl.select(sid)
|
|
195
|
+
if sid not in self._order:
|
|
196
|
+
self._order.append(sid)
|
|
197
|
+
elif not want and sid in sl.selected:
|
|
198
|
+
sl.deselect(sid)
|
|
199
|
+
if sid in self._order:
|
|
200
|
+
self._order.remove(sid)
|
|
201
|
+
self._renumber()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class OnboardingScreen(SourceSetupScreen):
|
|
205
|
+
"""首启引导:勾选要启用的源,确认后进入主界面。"""
|
|
206
|
+
|
|
207
|
+
title_text = "source_setup.onboarding_title"
|
|
208
|
+
prompt_text = "source_setup.onboarding_prompt"
|
|
209
|
+
confirm_label = "source_setup.onboarding_confirm"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class ManageSourcesScreen(SourceSetupScreen):
|
|
213
|
+
"""F2 源管理:勾选 = 启用、取消勾选 = 停用,保存后立即生效。"""
|
|
214
|
+
|
|
215
|
+
title_text = "source_setup.manage_title"
|
|
216
|
+
prompt_text = "source_setup.manage_prompt"
|
|
217
|
+
confirm_label = "source_setup.manage_confirm"
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""订阅管理屏:F2 里 → 进入,列表 + 增删改名,改动即时落盘(源无关)。
|
|
2
|
+
|
|
3
|
+
- 键位全非打印:↑↓ 选择、Enter 改名(走 ListView 的 Selected,与主列表
|
|
4
|
+
同一条路径)、Insert 添加、Delete 删除、Esc 返回
|
|
5
|
+
- 添加框是全应用唯一的文本输入:可输入的形态因源而异(rss 收 feed 地址 /
|
|
6
|
+
站点首页 / OPML 路径,reddit 收 subreddit 名或地址),文案与分流都在
|
|
7
|
+
各源的 SubsStore 里,本屏零源特判
|
|
8
|
+
- 改动即时落盘(config.update_feeds 原子写):订阅是数据不是待确认的草稿,
|
|
9
|
+
Esc 只退出不回滚;删除有轻确认,误删可重新添加(显示名会再拉一次)
|
|
10
|
+
- 业务编排全部复用顶层 subscriptions 模块,本屏只做展示与键位;源的差异
|
|
11
|
+
收在 SubsStore 适配器(Source.subscribable 门控入口 + store_for 分派)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import ClassVar
|
|
17
|
+
|
|
18
|
+
from rich.markup import escape
|
|
19
|
+
from textual.app import ComposeResult
|
|
20
|
+
from textual.binding import Binding
|
|
21
|
+
from textual.containers import Horizontal, Vertical
|
|
22
|
+
from textual.widgets import Button, Label, ListItem, ListView, Static
|
|
23
|
+
|
|
24
|
+
from feedscope.core.config import Subscription # 仅类型注解用(列表行的 key/label/url 三段)
|
|
25
|
+
from feedscope.subscriptions import SubscriptionError, SubsStore
|
|
26
|
+
from feedscope.core.i18n import t
|
|
27
|
+
from feedscope.ui import Bindings, feedback
|
|
28
|
+
from feedscope.ui.labels import retranslate_bindings
|
|
29
|
+
from feedscope.ui.dialogs import ConfirmScreen, InputScreen
|
|
30
|
+
from feedscope.ui.focus_nav import ArrowModalScreen, FocusNavListView
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SubscriptionsScreen(ArrowModalScreen[bool]):
|
|
34
|
+
"""订阅管理:dismiss(True) = 本次有过改动(调用方据此同步勾选与刷新)。"""
|
|
35
|
+
|
|
36
|
+
BINDINGS: ClassVar[Bindings] = [
|
|
37
|
+
Binding("escape", "back", "bindings.back"),
|
|
38
|
+
Binding("insert", "add", "bindings.add"),
|
|
39
|
+
Binding("delete", "remove", "bindings.remove"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
CSS = """
|
|
43
|
+
#subs-dialog {
|
|
44
|
+
width: auto; min-width: 64; max-width: 96; height: auto;
|
|
45
|
+
background: $surface; border: ascii $primary; padding: 1 2;
|
|
46
|
+
}
|
|
47
|
+
#subs-title { text-style: bold; padding-bottom: 1; }
|
|
48
|
+
#subs-list { width: 1fr; height: auto; max-height: 14; padding: 0 1; }
|
|
49
|
+
#subs-empty { padding: 1 0; color: $text-muted; }
|
|
50
|
+
#subs-hint { padding-top: 1; color: $text-muted; }
|
|
51
|
+
#subs-actions { height: auto; margin-top: 1; }
|
|
52
|
+
#subs-actions Button { margin-right: 1; }
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, store: SubsStore) -> None:
|
|
56
|
+
super().__init__()
|
|
57
|
+
self._store = store
|
|
58
|
+
self._changed = False
|
|
59
|
+
|
|
60
|
+
def compose(self) -> ComposeResult:
|
|
61
|
+
with Vertical(id="subs-dialog", classes="modal-card"):
|
|
62
|
+
yield Label(t("subscriptions.title", label=self._store.label), id="subs-title")
|
|
63
|
+
yield FocusNavListView(id="subs-list")
|
|
64
|
+
yield Label("", id="subs-empty")
|
|
65
|
+
yield Label(self._store.hint, id="subs-hint")
|
|
66
|
+
with Horizontal(id="subs-actions"):
|
|
67
|
+
yield Button(t("subscriptions.add"), id="subs-add", variant="primary")
|
|
68
|
+
yield Button(t("subscriptions.rename"), id="subs-rename")
|
|
69
|
+
yield Button(t("subscriptions.remove"), id="subs-remove")
|
|
70
|
+
|
|
71
|
+
async def on_mount(self) -> None:
|
|
72
|
+
retranslate_bindings(self) # 键式键位文案解析(bindings.*),见 ui/labels.py
|
|
73
|
+
await self._rebuild()
|
|
74
|
+
|
|
75
|
+
# ---- 列表 ----
|
|
76
|
+
|
|
77
|
+
async def _rebuild(self) -> None:
|
|
78
|
+
"""按当前配置重建列表(每条操作后调用),保持选中位尽量不动。"""
|
|
79
|
+
view = self.query_one("#subs-list", ListView)
|
|
80
|
+
idx = view.index
|
|
81
|
+
await view.remove_children()
|
|
82
|
+
feeds = self._store.feeds()
|
|
83
|
+
if feeds:
|
|
84
|
+
await view.mount(
|
|
85
|
+
*[
|
|
86
|
+
ListItem(
|
|
87
|
+
Static(
|
|
88
|
+
f"{escape(f.key)} [b]{escape(f.label)}[/b]\n[dim]{escape(f.url)}[/dim]"
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
for f in feeds
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
view.index = 0 if idx is None else min(idx, len(feeds) - 1)
|
|
95
|
+
view.display = True
|
|
96
|
+
self.query_one("#subs-empty", Label).update("")
|
|
97
|
+
else:
|
|
98
|
+
view.display = False
|
|
99
|
+
self.query_one("#subs-empty", Label).update(self._store.empty)
|
|
100
|
+
view.focus()
|
|
101
|
+
|
|
102
|
+
def _current(self) -> Subscription | None:
|
|
103
|
+
"""当前选中的订阅(列表顺序 = 配置顺序,索引即位)。"""
|
|
104
|
+
view = self.query_one("#subs-list", ListView)
|
|
105
|
+
feeds = self._store.feeds()
|
|
106
|
+
if view.index is None or not feeds or view.index >= len(feeds):
|
|
107
|
+
return None
|
|
108
|
+
return feeds[view.index]
|
|
109
|
+
|
|
110
|
+
# ---- 动作(键盘与按钮共用路径)----
|
|
111
|
+
|
|
112
|
+
def action_add(self) -> None:
|
|
113
|
+
self.run_worker(self._add_flow())
|
|
114
|
+
|
|
115
|
+
async def _add_flow(self) -> None:
|
|
116
|
+
text = await self.app.push_screen(
|
|
117
|
+
InputScreen(
|
|
118
|
+
t("subscriptions.add_title"), placeholder=self._store.add_placeholder
|
|
119
|
+
),
|
|
120
|
+
wait_for_dismiss=True,
|
|
121
|
+
)
|
|
122
|
+
if not text:
|
|
123
|
+
return
|
|
124
|
+
try:
|
|
125
|
+
_added, summary = await self._store.add(text)
|
|
126
|
+
except SubscriptionError as exc:
|
|
127
|
+
feedback.warn(self.app, str(exc), title=t("feedback.action_add_failed"))
|
|
128
|
+
return
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
# 网络等意外:无源语境(source=None),前缀拼接走 failure_text 同一条规则
|
|
131
|
+
feedback.warn(
|
|
132
|
+
self.app,
|
|
133
|
+
feedback.failure_text(None, exc, action="feedback.action_add_failed"),
|
|
134
|
+
title=t("feedback.action_add_failed"),
|
|
135
|
+
)
|
|
136
|
+
return
|
|
137
|
+
self._changed = True
|
|
138
|
+
await self._rebuild()
|
|
139
|
+
feedback.info(self.app, summary)
|
|
140
|
+
|
|
141
|
+
def action_remove(self) -> None:
|
|
142
|
+
feed = self._current()
|
|
143
|
+
if feed is None:
|
|
144
|
+
feedback.info(self.app, t("subscriptions.pick_first"))
|
|
145
|
+
return
|
|
146
|
+
self.run_worker(self._remove_flow(feed.key, feed.label))
|
|
147
|
+
|
|
148
|
+
async def _remove_flow(self, key: str, label: str) -> None:
|
|
149
|
+
if not await self.app.push_screen(
|
|
150
|
+
ConfirmScreen(
|
|
151
|
+
t("subscriptions.remove_confirm", label=label),
|
|
152
|
+
confirm_label=t("subscriptions.remove"),
|
|
153
|
+
),
|
|
154
|
+
wait_for_dismiss=True,
|
|
155
|
+
):
|
|
156
|
+
return
|
|
157
|
+
if self._store.remove(key) is not None:
|
|
158
|
+
self._changed = True
|
|
159
|
+
await self._rebuild()
|
|
160
|
+
feedback.info(self.app, t("subscriptions.removed", label=label))
|
|
161
|
+
|
|
162
|
+
def action_rename(self) -> None:
|
|
163
|
+
feed = self._current()
|
|
164
|
+
if feed is None:
|
|
165
|
+
feedback.info(self.app, t("subscriptions.pick_first"))
|
|
166
|
+
return
|
|
167
|
+
self.run_worker(self._rename_flow(feed.key, feed.label))
|
|
168
|
+
|
|
169
|
+
async def _rename_flow(self, key: str, label: str) -> None:
|
|
170
|
+
text = await self.app.push_screen(
|
|
171
|
+
InputScreen(t("subscriptions.rename_title"), initial=label), wait_for_dismiss=True
|
|
172
|
+
)
|
|
173
|
+
if not text:
|
|
174
|
+
return
|
|
175
|
+
if self._store.rename(key, text) is None:
|
|
176
|
+
feedback.info(self.app, t("subscriptions.rename_noop"))
|
|
177
|
+
return
|
|
178
|
+
self._changed = True
|
|
179
|
+
await self._rebuild()
|
|
180
|
+
feedback.info(self.app, t("subscriptions.renamed", name=text))
|
|
181
|
+
|
|
182
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
183
|
+
if event.list_view.id == "subs-list":
|
|
184
|
+
self.action_rename() # Enter = 改名(与主列表 Enter 进详情同一事件路径)
|
|
185
|
+
|
|
186
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
187
|
+
by_id = {
|
|
188
|
+
"subs-add": self.action_add,
|
|
189
|
+
"subs-remove": self.action_remove,
|
|
190
|
+
"subs-rename": self.action_rename,
|
|
191
|
+
}
|
|
192
|
+
if handler := by_id.get(event.button.id or ""):
|
|
193
|
+
handler()
|
|
194
|
+
|
|
195
|
+
def action_back(self) -> None:
|
|
196
|
+
self.dismiss(self._changed)
|
feedscope/ui/theme.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""克制的编辑器风深色主题:低饱和、灰阶层级,长时间阅读不刺眼。
|
|
2
|
+
|
|
3
|
+
色板对齐 VS Code Dark+ 的克制取向:
|
|
4
|
+
- 背景近黑灰(#1e1e1e)、前景浅灰(#d4d4d4),层级用 #252526/#2d2d30 的灰阶
|
|
5
|
+
- 全主题唯一强调色是低饱和编辑器蓝 #569cd6(选中/焦点/链接),选中态由
|
|
6
|
+
Textual 从 primary 派生暗化色,贴近 VS Code 列表选中背景 #264f78
|
|
7
|
+
- 语义色全部用暗色版:错误 #f48771、警告 #dcdcaa、成功 #6a9955
|
|
8
|
+
- 不引入任何高饱和/荧光色
|
|
9
|
+
|
|
10
|
+
Textual 8 的 Theme 是数据类:这些色直接喂给 ColorSystem,
|
|
11
|
+
Header/Footer/Markdown/ListView 自动取色,无需逐组件写 CSS。两处派生
|
|
12
|
+
不合意、经 variables/CSS 覆写(后者见 ui/screens/reading.py 的 READING_CSS):
|
|
13
|
+
- Markdown 标题取 primary 会整行变蓝(H5/H6 甚至 ansi_cyan)——抹平成
|
|
14
|
+
段落样式,所有源的正文同一种格式;
|
|
15
|
+
- 链接缺省取前景灰,灰底灰字认不出可点——编辑器蓝只留给链接。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from textual.theme import Theme
|
|
21
|
+
|
|
22
|
+
FEEDSCOPE_THEME = Theme(
|
|
23
|
+
name="feedscope-editor",
|
|
24
|
+
primary="#569cd6", # 编辑器蓝:选中/高亮
|
|
25
|
+
secondary="#5c6b7a", # 次级钢蓝灰
|
|
26
|
+
accent="#569cd6", # 焦点环
|
|
27
|
+
warning="#dcdcaa", # 暗黄(VS Code 警告色)
|
|
28
|
+
error="#f48771", # 暗珊瑚红(VS Code 错误色)
|
|
29
|
+
success="#6a9955", # 暗绿(VS Code 注释绿)
|
|
30
|
+
foreground="#d4d4d4", # 编辑器前景
|
|
31
|
+
background="#1e1e1e", # 编辑器背景
|
|
32
|
+
surface="#252526", # 侧栏/面板
|
|
33
|
+
panel="#2d2d30", # 边框/分隔
|
|
34
|
+
boost="#37373d", # 悬浮
|
|
35
|
+
dark=True,
|
|
36
|
+
variables={
|
|
37
|
+
"footer-key-foreground": "#9aa0a6",
|
|
38
|
+
# 链接:缺省取前景灰(灰底灰字认不出可点),编辑器蓝只留给这里
|
|
39
|
+
# (Markdown 标题的统一格式见 reading.py 的 READING_CSS,不占强调色)
|
|
40
|
+
"link-color": "#569cd6",
|
|
41
|
+
# 侧栏未聚焦选中带(VS Code 未聚焦选中同款灰):boost 字段在 CSS 变量
|
|
42
|
+
# $boost 里是 4% 半透明悬浮层(铺了看不见),实底只能走 variables
|
|
43
|
+
"nav-sel-background": "#37373d",
|
|
44
|
+
},
|
|
45
|
+
)
|
feedscope/ui/wrap.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""显示折行:UAX #14 中文场景的规范子集。
|
|
2
|
+
|
|
3
|
+
- 汉字之间任意断行
|
|
4
|
+
- ASCII 词(字母/数字/常见符号)内部不拆
|
|
5
|
+
- 避头(挪字法):句读/收尾符不落行首,连同上一行末字符一起带下来
|
|
6
|
+
- 避尾:开括号/开引号不落行尾,挪到下一行行首
|
|
7
|
+
|
|
8
|
+
wrap_display(text):纯文本折行(列表标题)
|
|
9
|
+
wrap_markdown(md):markdown 折行(正文)——内联标记(**粗体**、[链接]()、`代码`、
|
|
10
|
+
裸 URL)作为原子不拆;fenced 代码块与标题行原样保留。
|
|
11
|
+
|
|
12
|
+
终端一格一字符,做不了印刷级的标点压缩/悬挂,挪字法是规范允许的简化。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
from feedscope.core.text import display_width
|
|
20
|
+
|
|
21
|
+
# 行首禁则:句读、收尾标点(UAX #14 CL/EX 类)
|
|
22
|
+
_NO_START = set("。,、;:?!)】》」』〉…%·-~)〕")
|
|
23
|
+
# 行尾禁则:开括号、开引号(UAX #14 OP/QU 类)
|
|
24
|
+
_NO_END = set("(【《「『〈([{")
|
|
25
|
+
|
|
26
|
+
# ASCII「词」成分:字母数字 + 混排常见符号;连续出现视为不可拆词
|
|
27
|
+
_WORD_CHARS = set(
|
|
28
|
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789%+-./#@&=_"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# 内联标记原子:粗体/斜体/行内代码/链接/裸 URL
|
|
32
|
+
# 链接文本可为空(知乎图片来源的空锚点会生成 [](url)),空文本也算一个原子
|
|
33
|
+
_INLINE_RE = re.compile(
|
|
34
|
+
r"(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[[^\]]*\]\([^)\s]+\)|https?://\S+)"
|
|
35
|
+
)
|
|
36
|
+
_FENCED_RE = re.compile(r"(```.*?```)", re.DOTALL) # 带捕获组:split 才会把代码块留在结果里
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---- 折行引擎:unit = (可见文本, 完整形态),unit 内部不再拆 ----
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _split_units(visible: str, full: str, breakable: bool) -> list[tuple[str, str]]:
|
|
43
|
+
"""breakable 文本切成「单字符 / ASCII 词」unit;标记原子整体一个 unit。"""
|
|
44
|
+
if not breakable:
|
|
45
|
+
return [(visible, full)]
|
|
46
|
+
units: list[tuple[str, str]] = []
|
|
47
|
+
i, n = 0, len(visible)
|
|
48
|
+
while i < n:
|
|
49
|
+
ch = visible[i]
|
|
50
|
+
if ch in _WORD_CHARS:
|
|
51
|
+
j = i
|
|
52
|
+
while j < n and visible[j] in _WORD_CHARS:
|
|
53
|
+
j += 1
|
|
54
|
+
units.append((visible[i:j], visible[i:j]))
|
|
55
|
+
i = j
|
|
56
|
+
else:
|
|
57
|
+
units.append((ch, ch))
|
|
58
|
+
i += 1
|
|
59
|
+
return units
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _wrap_units(units: list[tuple[str, str]], width: int) -> list[str]:
|
|
63
|
+
"""贪心装箱 + 避头尾(挪字法),返回完整形态的行。
|
|
64
|
+
|
|
65
|
+
比行还宽的原子(长 URL 等)不再硬拆:整只占一行留给 Textual 自己折,
|
|
66
|
+
拆了会把 URL 断成两截,markdown-it 的 linkify 只认得前半截。
|
|
67
|
+
"""
|
|
68
|
+
if width <= 0:
|
|
69
|
+
return ["".join(f for _, f in units)]
|
|
70
|
+
|
|
71
|
+
lines: list[list[tuple[str, str]]] = [[]]
|
|
72
|
+
w = 0
|
|
73
|
+
|
|
74
|
+
def open_line() -> None:
|
|
75
|
+
nonlocal w
|
|
76
|
+
carried: list[tuple[str, str]] = []
|
|
77
|
+
# 避尾:刚关闭的行若以开括号收尾,一并带到新行行首
|
|
78
|
+
while lines[-1] and lines[-1][-1][0] in _NO_END:
|
|
79
|
+
carried.insert(0, lines[-1].pop())
|
|
80
|
+
lines.append(carried)
|
|
81
|
+
w = sum(display_width(v) for v, _ in carried)
|
|
82
|
+
|
|
83
|
+
for visible, full in units:
|
|
84
|
+
cw = display_width(visible)
|
|
85
|
+
if w > 0 and w + cw > width:
|
|
86
|
+
if visible[0] in _NO_START and lines[-1]:
|
|
87
|
+
# 避头:句读要落行首 -> 把上一行末 unit 带下来陪它
|
|
88
|
+
carry = lines[-1].pop()
|
|
89
|
+
lines.append([carry])
|
|
90
|
+
w = display_width(carry[0])
|
|
91
|
+
else:
|
|
92
|
+
open_line()
|
|
93
|
+
lines[-1].append((visible, full))
|
|
94
|
+
w += cw
|
|
95
|
+
|
|
96
|
+
return ["".join(f for _, f in ln) for ln in lines if ln] or [""]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def wrap_display(text: str, width: int) -> list[str]:
|
|
100
|
+
"""纯文本按显示宽度折行;避头尾禁则用挪字法处理。"""
|
|
101
|
+
if width <= 0 or not text:
|
|
102
|
+
return [text]
|
|
103
|
+
return _wrap_units(_split_units(text, text, True), width)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---- markdown 折行 ----
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _wrap_inline_line(line: str, width: int) -> list[str]:
|
|
110
|
+
"""一条物理行 -> 原子化后折行;内联标记原子不拆(宽度按可见文本计)。"""
|
|
111
|
+
atoms: list[tuple[str, str, bool]] = []
|
|
112
|
+
pos = 0
|
|
113
|
+
for m in _INLINE_RE.finditer(line):
|
|
114
|
+
if m.start() > pos:
|
|
115
|
+
atoms.append((line[pos:m.start()], line[pos:m.start()], True))
|
|
116
|
+
token = m.group(0)
|
|
117
|
+
if token.startswith("["):
|
|
118
|
+
inner = re.match(r"\[([^\]]+)\]", token)
|
|
119
|
+
atoms.append((inner.group(1) if inner else token, token, False))
|
|
120
|
+
else:
|
|
121
|
+
atoms.append((token, token, False))
|
|
122
|
+
pos = m.end()
|
|
123
|
+
if pos < len(line):
|
|
124
|
+
atoms.append((line[pos:], line[pos:], True))
|
|
125
|
+
|
|
126
|
+
units: list[tuple[str, str]] = []
|
|
127
|
+
for visible, full, breakable in atoms:
|
|
128
|
+
units.extend(_split_units(visible, full, breakable))
|
|
129
|
+
return _wrap_units(units, width)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def wrap_markdown(md: str, width: int) -> str:
|
|
133
|
+
"""markdown 正文折行:段落内避头尾;代码块与标题行原样保留。
|
|
134
|
+
|
|
135
|
+
段内行一律接成两空格硬换行(\" \\n\")——Textual 的 Markdown 按规范把
|
|
136
|
+
软换行折叠成空格(实测),既会抹掉源文本的真实换行(X 推文分行、引用
|
|
137
|
+
块行),也会把这里的预折行变回一行并留下「样 式」式的假空格;硬换行
|
|
138
|
+
让每一物理行原样上屏。
|
|
139
|
+
"""
|
|
140
|
+
if width <= 0 or not md:
|
|
141
|
+
return md
|
|
142
|
+
out_blocks: list[str] = []
|
|
143
|
+
for part in _FENCED_RE.split(md): # fenced 代码块原样保留
|
|
144
|
+
if part.startswith("```"):
|
|
145
|
+
out_blocks.append(part)
|
|
146
|
+
continue
|
|
147
|
+
for block in re.split(r"\n[ \t]*\n", part): # 空行分段
|
|
148
|
+
if not block.strip():
|
|
149
|
+
continue
|
|
150
|
+
wrapped: list[str] = []
|
|
151
|
+
for hard_line in block.split("\n"):
|
|
152
|
+
if not hard_line.strip() or hard_line.lstrip().startswith("#"):
|
|
153
|
+
wrapped.append(hard_line) # 标题行短,不动
|
|
154
|
+
continue
|
|
155
|
+
wrapped.extend(_wrap_inline_line(hard_line, width))
|
|
156
|
+
out_blocks.append(" \n".join(wrapped))
|
|
157
|
+
return "\n\n".join(out_blocks)
|