oldnews 0.6.0__tar.gz → 0.8.0__tar.gz

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 (40) hide show
  1. {oldnews-0.6.0 → oldnews-0.8.0}/PKG-INFO +3 -2
  2. {oldnews-0.6.0 → oldnews-0.8.0}/pyproject.toml +3 -2
  3. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/screens/main.py +20 -14
  4. oldnews-0.8.0/src/oldnews/screens/process_subscription.py +87 -0
  5. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/sync.py +21 -15
  6. oldnews-0.8.0/src/oldnews/widgets/_next_matching_option.py +52 -0
  7. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/widgets/article_list.py +12 -14
  8. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/widgets/navigation.py +111 -50
  9. oldnews-0.6.0/src/oldnews/widgets/_after_highlight.py +0 -53
  10. {oldnews-0.6.0 → oldnews-0.8.0}/README.md +0 -0
  11. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/__init__.py +0 -0
  12. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/__main__.py +0 -0
  13. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/commands/__init__.py +0 -0
  14. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/commands/main.py +0 -0
  15. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/__init__.py +0 -0
  16. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/auth.py +0 -0
  17. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/config.py +0 -0
  18. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/db.py +0 -0
  19. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/dump.py +0 -0
  20. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/last_grab.py +0 -0
  21. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/local_articles.py +0 -0
  22. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/local_folders.py +0 -0
  23. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/local_subscriptions.py +0 -0
  24. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/local_unread.py +0 -0
  25. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/locations.py +0 -0
  26. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/log.py +0 -0
  27. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/navigation_state.py +0 -0
  28. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/reset.py +0 -0
  29. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/data/tools.py +0 -0
  30. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/oldnews.py +0 -0
  31. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/providers/__init__.py +0 -0
  32. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/providers/main.py +0 -0
  33. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/py.typed +0 -0
  34. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/screens/__init__.py +0 -0
  35. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/screens/folder_input.py +0 -0
  36. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/screens/information_display.py +0 -0
  37. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/screens/login.py +0 -0
  38. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/screens/new_subscription.py +0 -0
  39. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/widgets/__init__.py +0 -0
  40. {oldnews-0.6.0 → oldnews-0.8.0}/src/oldnews/widgets/article_content.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oldnews
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: A terminal-based client for TheOldReader
5
5
  Keywords: atom,client,Google Reader,RSS,TheOldReader,terminal,news-reader,news
6
6
  Author: Dave Pearson
@@ -14,8 +14,9 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
16
  Classifier: Typing :: Typed
17
+ Requires-Dist: bagofstuff>=0.1.0
17
18
  Requires-Dist: html-to-markdown>=2.15.0
18
- Requires-Dist: oldas>=0.6.0
19
+ Requires-Dist: oldas>=0.10.0
19
20
  Requires-Dist: textual>=6.3.0
20
21
  Requires-Dist: textual-autocomplete>=4.0.6
21
22
  Requires-Dist: textual-enhanced>=1.2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oldnews"
3
- version = "0.6.0"
3
+ version = "0.8.0"
4
4
  description = "A terminal-based client for TheOldReader"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -19,8 +19,9 @@ keywords = [
19
19
  "news",
20
20
  ]
21
21
  dependencies = [
22
+ "bagofstuff>=0.1.0",
22
23
  "html-to-markdown>=2.15.0",
23
- "oldas>=0.6.0",
24
+ "oldas>=0.10.0",
24
25
  "textual>=6.3.0",
25
26
  "textual-autocomplete>=4.0.6",
26
27
  "textual-enhanced>=1.2.0",
@@ -6,6 +6,10 @@ from dataclasses import dataclass
6
6
  from datetime import datetime, timedelta
7
7
  from webbrowser import open as open_url
8
8
 
9
+ ##############################################################################
10
+ # BagOfStuff imports.
11
+ from bagofstuff.pipe import Pipe
12
+
9
13
  ##############################################################################
10
14
  # OldAs imports.
11
15
  from oldas import (
@@ -79,11 +83,12 @@ from ..data import (
79
83
  update_configuration,
80
84
  )
81
85
  from ..providers import MainCommands
82
- from ..sync import ToRSync
86
+ from ..sync import TheOldReaderSync
83
87
  from ..widgets import ArticleContent, ArticleList, Navigation
84
88
  from .folder_input import FolderInput
85
89
  from .information_display import InformationDisplay
86
90
  from .new_subscription import NewSubscription
91
+ from .process_subscription import ProcessSubscription
87
92
 
88
93
 
89
94
  ##############################################################################
@@ -402,17 +407,19 @@ class Main(EnhancedScreen[None]):
402
407
  @work(exclusive=True)
403
408
  async def action_refresh_from_the_old_reader_command(self) -> None:
404
409
  """Load the main data from TheOldReader."""
405
- await ToRSync(
410
+ await TheOldReaderSync(
406
411
  self._session,
407
- on_new_step=lambda step: self.post_message(self.SubTitle(step)),
408
- on_new_result=lambda result: self.notify(result),
409
- on_new_folders=lambda folders: self.post_message(self.NewFolders(folders)),
410
- on_new_subscriptions=lambda subscriptions: self.post_message(
411
- self.NewSubscriptions(subscriptions)
412
+ on_new_step=Pipe[str, bool](self.SubTitle, self.post_message),
413
+ on_new_result=self.notify,
414
+ on_new_folders=Pipe[Folders, bool](self.NewFolders, self.post_message),
415
+ on_new_subscriptions=Pipe[Subscriptions, bool](
416
+ self.NewSubscriptions, self.post_message
417
+ ),
418
+ on_new_unread=Pipe[LocalUnread, bool](self.NewUnread, self.post_message),
419
+ on_sync_finished=Pipe[Pipe.Nullary, bool](
420
+ self.SyncFinished, self.post_message
412
421
  ),
413
- on_new_unread=lambda unread: self.post_message(self.NewUnread(unread)),
414
- on_sync_finished=lambda: self.post_message(self.SyncFinished()),
415
- ).refresh()
422
+ ).sync()
416
423
 
417
424
  @on(Navigation.CategorySelected)
418
425
  def _handle_navigaion_selection(self, message: Navigation.CategorySelected) -> None:
@@ -624,11 +631,10 @@ class Main(EnhancedScreen[None]):
624
631
  if subscription := await self.app.push_screen_wait(
625
632
  NewSubscription(self.folders)
626
633
  ):
627
- self.notify(
628
- subscription.feed, title="Subscription request sent to TheOldReader..."
629
- )
630
634
  if (
631
- result := await Subscriptions.add(self._session, subscription.feed)
635
+ result := await self.app.push_screen_wait(
636
+ ProcessSubscription(self._session, subscription)
637
+ )
632
638
  ).failed:
633
639
  self.notify(
634
640
  result.error or "TheOldReader did not give a reason",
@@ -0,0 +1,87 @@
1
+ """Provides a dialog for waiting for TheOldReader to add a subscription."""
2
+
3
+ ##############################################################################
4
+ # OldAS imports.
5
+ from oldas import Session, Subscriptions
6
+ from oldas.subscriptions import SubscribeResult
7
+
8
+ ##############################################################################
9
+ # Textual imports.
10
+ from textual import work
11
+ from textual.app import ComposeResult
12
+ from textual.containers import Center, Vertical
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import Label, LoadingIndicator
15
+
16
+ ##############################################################################
17
+ # Local data.
18
+ from .new_subscription import NewSubscriptionData
19
+
20
+
21
+ ##############################################################################
22
+ class ProcessSubscription(ModalScreen[SubscribeResult]):
23
+ """Dialog for waiting for TheOldReader to process a subscription."""
24
+
25
+ CSS = """
26
+ ProcessSubscription {
27
+ align: center middle;
28
+
29
+ &> Vertical {
30
+ padding: 1 2;
31
+ width: auto;
32
+ height: auto;
33
+ min-width: 60%;
34
+ max-width: 90%;
35
+ background: $panel;
36
+ border: panel $border;
37
+ LoadingIndicator {
38
+ margin-top: 1;
39
+ }
40
+ Center {
41
+ width: 100%;
42
+ }
43
+ }
44
+ }
45
+ """
46
+
47
+ def __init__(self, session: Session, new_subscription: NewSubscriptionData) -> None:
48
+ """Initialise the subscription processing dialog object.
49
+
50
+ Args:
51
+ session: The ToR API session object.
52
+ new_subscription: The new subscription.
53
+ """
54
+ self._session = session
55
+ """The API session object."""
56
+ self._feed = new_subscription.feed
57
+ """The feed to subscribe to."""
58
+ self._folder = new_subscription.folder
59
+ """The folder to place the subscription in."""
60
+ super().__init__()
61
+
62
+ def compose(self) -> ComposeResult:
63
+ """Compose the content of the screen."""
64
+ with Vertical() as dialog:
65
+ dialog.border_title = "Waiting for TheOldReader to add the feed..."
66
+ yield Label(
67
+ f"Subscribing to {self._feed}"
68
+ + (f" and adding it to {self._folder}" if self._folder else "")
69
+ + ".\n\n"
70
+ "TheOldReader can take a short while to scan and add a feed. Please wait...",
71
+ shrink=True,
72
+ markup=False,
73
+ )
74
+ with Center():
75
+ yield LoadingIndicator()
76
+
77
+ @work
78
+ async def _request_subscription(self) -> None:
79
+ """Process the request."""
80
+ self.dismiss(await Subscriptions.add(self._session, self._feed))
81
+
82
+ def on_mount(self) -> None:
83
+ """Start the work once the DOM is ready."""
84
+ self._request_subscription()
85
+
86
+
87
+ ### process_subscription.py ends here
@@ -4,7 +4,7 @@
4
4
  # Python imports.
5
5
  from dataclasses import dataclass
6
6
  from datetime import datetime, timedelta, timezone
7
- from typing import Any, AsyncIterator, Callable, Iterable
7
+ from typing import Any, AsyncIterator, Callable, Final, Iterable
8
8
 
9
9
  ##############################################################################
10
10
  # OldAS imports.
@@ -41,10 +41,14 @@ type Callback = Callable[[], Any] | None
41
41
  type CallbackWith[T] = Callable[[T], Any] | None
42
42
  """Type of callback with a single argument."""
43
43
 
44
+ ##############################################################################
45
+ BATCH_SIZE: Final[int] = 10
46
+ """Batch size for downloading articles."""
47
+
44
48
 
45
49
  ##############################################################################
46
50
  @dataclass
47
- class ToRSync:
51
+ class TheOldReaderSync:
48
52
  """Class that handles syncing data from TheOldReader."""
49
53
 
50
54
  session: Session
@@ -111,11 +115,11 @@ class ToRSync:
111
115
  # TODO: Right now I'm saving articles one at a time; perhaps I
112
116
  # should save them in small batches? This would be simple enough
113
117
  # -- perhaps same them in batches the same size as the buffer
114
- # window I'm using right now (currently 10 articles per trip to
115
- # ToR).
118
+ # window I'm using right now (currently BATCH_SIZE articles per
119
+ # trip to ToR).
116
120
  save_local_articles(Articles([article]))
117
121
  loaded += 1
118
- if (loaded % 10) == 0:
122
+ if (loaded % BATCH_SIZE) == 0:
119
123
  self._step(f"{description}: {loaded}", log=False)
120
124
  return loaded
121
125
 
@@ -147,7 +151,9 @@ class ToRSync:
147
151
  )
148
152
  for subscription in subscriptions:
149
153
  if loaded := await self._download(
150
- Articles.stream_new_since(self.session, cutoff, subscription, n=10),
154
+ Articles.stream_new_since(
155
+ self.session, cutoff, subscription, n=BATCH_SIZE
156
+ ),
151
157
  f"Downloading article backlog for {subscription.title}",
152
158
  ):
153
159
  self._result(
@@ -192,7 +198,7 @@ class ToRSync:
192
198
  new_grab - timedelta(days=load_configuration().local_history)
193
199
  )
194
200
  if loaded := await self._download(
195
- Articles.stream_new_since(self.session, last_grabbed, n=10),
201
+ Articles.stream_new_since(self.session, last_grabbed, n=BATCH_SIZE),
196
202
  "Downloading articles from TheOldReader",
197
203
  ):
198
204
  self._result(f"Articles downloaded: {loaded}")
@@ -264,17 +270,17 @@ class ToRSync:
264
270
  def _get_unread_counts(
265
271
  self, folders: Folders, subscriptions: Subscriptions
266
272
  ) -> None:
267
- """Get the updated unread counts."""
268
- unread = get_local_unread(folders, subscriptions)
269
- if self.on_new_unread:
270
- self.on_new_unread(unread)
271
-
272
- async def refresh(self) -> None:
273
- """Refresh the data from TheOldReader.
273
+ """Get the updated unread counts.
274
274
 
275
275
  Args:
276
- session: The TheOldReader API session object.
276
+ folders: The folders to get the counts for.
277
+ subscriptions: The subscriptions to get the counts for.
277
278
  """
279
+ if self.on_new_unread:
280
+ self.on_new_unread(get_local_unread(folders, subscriptions))
281
+
282
+ async def sync(self) -> None:
283
+ """Sync the data from TheOldReader."""
278
284
  folders = await self._get_folders()
279
285
  original_subscriptions, subscriptions = await self._get_subscriptions()
280
286
  await self._get_new_articles()
@@ -0,0 +1,52 @@
1
+ """Support code for OptionLists that want to find things."""
2
+
3
+ ##############################################################################
4
+ # Python imports.
5
+ from typing import Callable
6
+
7
+ ##############################################################################
8
+ # BagOfStuff imports.
9
+ from bagofstuff.itertools import Direction, starting_at
10
+
11
+
12
+ ##############################################################################
13
+ def next_matching_option[T](
14
+ options: list[T],
15
+ current_highlight: int | None,
16
+ direction: Direction,
17
+ matching: Callable[[T], bool] | None = None,
18
+ ) -> T | None:
19
+ """Return a list of `OptionList` options after the highlight.
20
+
21
+ Args:
22
+ options: The options from the given option list.
23
+ current_highlight: The current highlighted option.
24
+ direction: The direction to work in.
25
+ matching: Optional filter to apply to the list.
26
+
27
+ Returns:
28
+ The next matching option, or `None` if there isn't one.
29
+
30
+ Notes:
31
+ If there is no highlight, we default at position 0.
32
+ """
33
+ matching = matching or (lambda _: True)
34
+ if current_highlight is None:
35
+ current_highlight = 0
36
+ else:
37
+ current_highlight += 1 if direction == "forward" else -1
38
+ return next(
39
+ (
40
+ option
41
+ for option in starting_at(options, current_highlight, direction)
42
+ if matching(option)
43
+ ),
44
+ None,
45
+ )
46
+
47
+
48
+ ##############################################################################
49
+ # Re-export the direction type.
50
+ __all__ = ["Direction"]
51
+
52
+ ### _next_matching_option.py ends here
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
  ##############################################################################
8
8
  # Python imports.
9
9
  from dataclasses import dataclass
10
+ from operator import attrgetter
10
11
  from typing import cast
11
12
 
12
13
  ##############################################################################
@@ -32,7 +33,7 @@ from textual_enhanced.widgets import EnhancedOptionList
32
33
 
33
34
  ##############################################################################
34
35
  # Local imports.
35
- from ._after_highlight import HighlightDirection, options_after_highlight
36
+ from ._next_matching_option import Direction, next_matching_option
36
37
 
37
38
 
38
39
  ##############################################################################
@@ -142,7 +143,7 @@ class ArticleList(EnhancedOptionList):
142
143
  assert isinstance(message.option, ArticleView)
143
144
  self.post_message(self.ViewArticle(message.option.article))
144
145
 
145
- def _highlight_unread(self, direction: HighlightDirection) -> bool:
146
+ def _highlight_unread(self, direction: Direction) -> bool:
146
147
  """Highlight the next unread article, if there is one.
147
148
 
148
149
  Args:
@@ -152,14 +153,11 @@ class ArticleList(EnhancedOptionList):
152
153
  `True` if an unread article was found and highlighted, `False`
153
154
  if not.
154
155
  """
155
- if next_hit := next(
156
- options_after_highlight(
157
- self,
158
- cast(list[ArticleView], self.options),
159
- direction,
160
- lambda article_view: article_view.article.is_unread,
161
- ),
162
- None,
156
+ if next_hit := next_matching_option(
157
+ cast(list[ArticleView], self.options),
158
+ self.highlighted,
159
+ direction,
160
+ attrgetter("article.is_unread"),
163
161
  ):
164
162
  if next_hit.id is not None:
165
163
  self.highlighted = self.get_option_index(next_hit.id)
@@ -177,11 +175,11 @@ class ArticleList(EnhancedOptionList):
177
175
 
178
176
  def highlight_next_unread_article(self) -> None:
179
177
  """Highlight the next unread article in the list."""
180
- self._highlight_unread("next")
178
+ self._highlight_unread("forward")
181
179
 
182
180
  def highlight_previous_unread_article(self) -> None:
183
181
  """Highlight the previous unread article in the list."""
184
- self._highlight_unread("previous")
182
+ self._highlight_unread("backward")
185
183
 
186
184
  def select_next_article(self) -> None:
187
185
  """Select the next article in the list."""
@@ -195,12 +193,12 @@ class ArticleList(EnhancedOptionList):
195
193
 
196
194
  def select_next_unread_article(self) -> None:
197
195
  """Select the next unread article in the list."""
198
- if self._highlight_unread("next"):
196
+ if self._highlight_unread("forward"):
199
197
  self.call_later(self.run_action, "select")
200
198
 
201
199
  def select_previous_unread_article(self) -> None:
202
200
  """Select the next unread article in the list."""
203
- if self._highlight_unread("previous"):
201
+ if self._highlight_unread("backward"):
204
202
  self.call_later(self.run_action, "select")
205
203
 
206
204
 
@@ -7,7 +7,7 @@ from __future__ import annotations
7
7
  ##############################################################################
8
8
  # Python imports.
9
9
  from dataclasses import dataclass
10
- from typing import cast
10
+ from typing import Callable, Iterable, Iterator, cast
11
11
 
12
12
  ##############################################################################
13
13
  # OldAs imports.
@@ -35,7 +35,7 @@ from textual_enhanced.widgets import EnhancedOptionList
35
35
  ##############################################################################
36
36
  # Local imports.
37
37
  from ..data import LocalUnread, get_navigation_state, save_navigation_state
38
- from ._after_highlight import HighlightDirection, options_after_highlight
38
+ from ._next_matching_option import Direction, next_matching_option
39
39
 
40
40
 
41
41
  ##############################################################################
@@ -157,41 +157,86 @@ class Navigation(EnhancedOptionList):
157
157
  self._expanded = get_navigation_state()
158
158
  """The IDs of the folders that are expanded."""
159
159
 
160
- def _add_subscriptions(self, parent_folder: str) -> None:
161
- """Add the subscriptions for a given parent folder.
160
+ @staticmethod
161
+ def _key(attr: str) -> Callable[[object], str]:
162
+ """Create a key to use with `sorted`.
162
163
 
163
164
  Args:
164
- parent_folder: The parent folder to add the subscriptions for.
165
+ attr: The attribute to sort on.
166
+
167
+ Returns:
168
+ A function to get a `casefold` version of the attribute.
165
169
  """
166
- for subscription in self.subscriptions:
167
- if any(
168
- category.id == parent_folder for category in subscription.categories
169
- ):
170
- self.add_option(SubscriptionView(subscription, self.unread))
171
170
 
172
- def _add_folder(self, folder: Folder) -> None:
173
- """Add the given folder to the navigation.
171
+ def _casefold(value: object) -> str:
172
+ return cast(str, getattr(value, attr)).casefold()
173
+
174
+ return _casefold
175
+
176
+ def _viewable(
177
+ self, subscriptions: Iterable[Subscription]
178
+ ) -> Iterator[SubscriptionView]:
179
+ """Given a iterable of subscriptions, make them viewable.
174
180
 
175
181
  Args:
176
- folder: The folder to add.
182
+ subscriptions: The subscriptions to make viewable.
183
+
184
+ Yields:
185
+ Views of the subscriptions.
177
186
  """
178
- self.add_option(
179
- FolderView(folder, expanded := folder.id in self._expanded, self.unread)
187
+ yield from (
188
+ SubscriptionView(subscription, self.unread)
189
+ for subscription in sorted(subscriptions, key=self._key("title"))
190
+ )
191
+
192
+ def _gather_subscriptions_for_folder(
193
+ self, parent_folder: Folder
194
+ ) -> Iterator[SubscriptionView]:
195
+ """Gather the subscriptions for a given parent folder.
196
+
197
+ Args:
198
+ parent_folder: The parent folder to add the subscriptions for.
199
+
200
+ Yields:
201
+ The subscriptions within that folder.
202
+ """
203
+ yield from self._viewable(
204
+ subscription
205
+ for subscription in self.subscriptions
206
+ if parent_folder in subscription.categories
207
+ )
208
+
209
+ def _gather_folders(self) -> Iterator[FolderView | SubscriptionView]:
210
+ """Gather up all the folders and their subscriptions.
211
+
212
+ Yields:
213
+ Folder and subscription options.
214
+ """
215
+ for folder in sorted(self.folders, key=self._key("name")):
216
+ yield FolderView(
217
+ folder, expanded := folder.id in self._expanded, self.unread
218
+ )
219
+ if expanded:
220
+ yield from self._gather_subscriptions_for_folder(folder)
221
+
222
+ def _gather_folderless_subscrtiptions(self) -> Iterator[SubscriptionView]:
223
+ """Gather up all the subscriptions that don't live in a folder.
224
+
225
+ Yields:
226
+ Subscription options for folderless subscriptions.
227
+ """
228
+ yield from self._viewable(
229
+ subscription
230
+ for subscription in self.subscriptions
231
+ if not subscription.categories
180
232
  )
181
- if expanded:
182
- self._add_subscriptions(folder.id)
183
233
 
184
234
  def _refresh_navigation(self) -> None:
185
235
  """Refresh the content of the navigation widget."""
186
236
  with self.preserved_highlight:
187
- self.clear_options()
188
- # First off, add subscriptions that lack a folder.
189
- for subscription in self.subscriptions:
190
- if not subscription.categories:
191
- self.add_option(SubscriptionView(subscription, self.unread))
192
- # Now add all the subscriptions that are within folders.
193
- for folder in self.folders:
194
- self._add_folder(folder)
237
+ self.set_options(
238
+ (*self._gather_folderless_subscrtiptions(), *self._gather_folders())
239
+ )
195
240
 
196
241
  def _watch_folders(self) -> None:
197
242
  """React to the folders being updated."""
@@ -205,6 +250,25 @@ class Navigation(EnhancedOptionList):
205
250
  """React to the unread data being updated."""
206
251
  self._refresh_navigation()
207
252
 
253
+ @work(thread=True)
254
+ def _save_state(self, state: set[str]) -> None:
255
+ """Save the folder expanded/collapsed state.
256
+
257
+ Args:
258
+ state: The state to save.
259
+ """
260
+ save_navigation_state(state)
261
+
262
+ def _set_expansion(self, new_state: set[str]) -> None:
263
+ """Set the new navigation state.
264
+
265
+ Args:
266
+ new_state: The new state to set.
267
+ """
268
+ self._expanded = new_state
269
+ self._save_state(new_state)
270
+ self._refresh_navigation()
271
+
208
272
  def _action_toggle_folder(self) -> None:
209
273
  """Action that toggles the expanded state of a folder."""
210
274
  if self.highlighted is None:
@@ -215,26 +279,15 @@ class Navigation(EnhancedOptionList):
215
279
  self.notify("Only folders can be collapsed/expanded", severity="warning")
216
280
  return
217
281
  if option.folder.id is not None:
218
- self._expanded ^= {option.folder.id}
219
- self._save_state()
220
- self._refresh_navigation()
282
+ self._set_expansion(self._expanded ^ {option.folder.id})
221
283
 
222
284
  def _action_expand_all(self) -> None:
223
285
  """Action that expands all folders."""
224
- self._expanded = {folder.id for folder in self.folders}
225
- self._save_state()
226
- self._refresh_navigation()
286
+ self._set_expansion({folder.id for folder in self.folders})
227
287
 
228
288
  def _action_collapse_all(self) -> None:
229
289
  """Action that collapses all folders."""
230
- self._expanded = set()
231
- self._save_state()
232
- self._refresh_navigation()
233
-
234
- @work(thread=True)
235
- def _save_state(self) -> None:
236
- """Save the folder expanded/collapsed state."""
237
- save_navigation_state(self._expanded)
290
+ self._set_expansion(set())
238
291
 
239
292
  @on(EnhancedOptionList.OptionSelected)
240
293
  def _handle_selection(self, message: EnhancedOptionList.OptionSelected) -> None:
@@ -275,7 +328,18 @@ class Navigation(EnhancedOptionList):
275
328
  return current
276
329
  return None
277
330
 
278
- def _highlight_unread(self, direction: HighlightDirection) -> bool:
331
+ def _contains_unread(self, category: FolderView | SubscriptionView) -> bool:
332
+ """Does the given folder or subscription have unread items?
333
+
334
+ Args:
335
+ category: The folder or subscription to check.
336
+
337
+ Returns:
338
+ `True` if there are unread items, `False` if not.
339
+ """
340
+ return bool(category.id and self.unread.get(category.id))
341
+
342
+ def _highlight_unread(self, direction: Direction) -> bool:
279
343
  """Highlight the next category with unread articles, if there is one.
280
344
 
281
345
  Args:
@@ -285,14 +349,11 @@ class Navigation(EnhancedOptionList):
285
349
  `True` if an unread category was found and highlighted, `False`
286
350
  if not.
287
351
  """
288
- if next_hit := next(
289
- options_after_highlight(
290
- self,
291
- cast(list[FolderView | SubscriptionView], self.options),
292
- direction,
293
- lambda category: bool(category.id and self.unread.get(category.id)),
294
- ),
295
- None,
352
+ if next_hit := next_matching_option(
353
+ cast(list[FolderView | SubscriptionView], self.options),
354
+ self.highlighted,
355
+ direction,
356
+ self._contains_unread,
296
357
  ):
297
358
  if next_hit.id is not None:
298
359
  self.highlighted = self.get_option_index(next_hit.id)
@@ -302,11 +363,11 @@ class Navigation(EnhancedOptionList):
302
363
 
303
364
  def highlight_next_unread_category(self) -> None:
304
365
  """Highlight the next unread category."""
305
- self._highlight_unread("next")
366
+ self._highlight_unread("forward")
306
367
 
307
368
  def highlight_previous_unread_category(self) -> None:
308
369
  """Highlight the previous unread category."""
309
- self._highlight_unread("previous")
370
+ self._highlight_unread("backward")
310
371
 
311
372
 
312
373
  ### navigation.py ends here
@@ -1,53 +0,0 @@
1
- """Support code for OptionLists that want to find things."""
2
-
3
- ##############################################################################
4
- # Python imports.
5
- from typing import Callable, Iterator, Literal
6
-
7
- ##############################################################################
8
- # Textual imports.
9
- from textual.widgets import OptionList
10
-
11
- ##############################################################################
12
- type HighlightDirection = Literal["next", "previous"]
13
- """Type of a unread search direction."""
14
-
15
-
16
- ##############################################################################
17
- def options_after_highlight[T](
18
- option_list: OptionList,
19
- options: list[T],
20
- direction: HighlightDirection,
21
- option_filter: Callable[[T], bool] | None = None,
22
- ) -> Iterator[T]:
23
- """Return a list of `OptionList` options after the highlight.
24
-
25
- Args:
26
- option_list: The `OptionList` to work against.
27
- options: The options from the given option list.
28
- direction: The direction to consider as 'after'.
29
- option_filter: Optional filter to apply to the list.
30
-
31
- Returns:
32
- An iterator of options.
33
-
34
- Notes:
35
- If there is no highlight, we default at position 0.
36
-
37
- The options are taken as a parameter, rather than just been pulled
38
- out of `option_list`, so that you have a chance to `cast` the list
39
- to the desired type, which in turn ensures that the return type
40
- matches.
41
- """
42
- option_filter = option_filter or (lambda _: True)
43
- highlight = option_list.highlighted or 0
44
- options = list(reversed(options)) if direction == "previous" else options
45
- highlight = (len(options) - highlight - 1) if direction == "previous" else highlight
46
- return (
47
- option
48
- for option in [*options[highlight:], *options[0:highlight]][1:]
49
- if option_filter(option)
50
- )
51
-
52
-
53
- ### _after_highlight.py ends here
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes