oldnews 0.1.0__tar.gz → 0.2.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 (30) hide show
  1. {oldnews-0.1.0 → oldnews-0.2.0}/PKG-INFO +2 -3
  2. {oldnews-0.1.0 → oldnews-0.2.0}/pyproject.toml +2 -3
  3. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/commands/__init__.py +2 -0
  4. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/commands/main.py +7 -0
  5. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/__init__.py +2 -0
  6. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/db.py +15 -9
  7. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/local_articles.py +18 -3
  8. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/providers/main.py +2 -0
  9. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/screens/main.py +32 -3
  10. oldnews-0.2.0/src/oldnews/widgets/_after_highlight.py +53 -0
  11. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/widgets/article_content.py +13 -6
  12. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/widgets/article_list.py +19 -37
  13. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/widgets/navigation.py +36 -1
  14. {oldnews-0.1.0 → oldnews-0.2.0}/README.md +0 -0
  15. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/__init__.py +0 -0
  16. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/__main__.py +0 -0
  17. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/auth.py +0 -0
  18. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/config.py +0 -0
  19. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/last_grab.py +0 -0
  20. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/local_folders.py +0 -0
  21. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/local_subscriptions.py +0 -0
  22. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/local_unread.py +0 -0
  23. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/locations.py +0 -0
  24. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/data/navigation_state.py +0 -0
  25. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/oldnews.py +0 -0
  26. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/providers/__init__.py +0 -0
  27. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/py.typed +0 -0
  28. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/screens/__init__.py +0 -0
  29. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/screens/login.py +0 -0
  30. {oldnews-0.1.0 → oldnews-0.2.0}/src/oldnews/widgets/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oldnews
3
- Version: 0.1.0
3
+ Version: 0.2.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
@@ -10,7 +10,6 @@ Classifier: Development Status :: 3 - Alpha
10
10
  Classifier: Operating System :: OS Independent
11
11
  Classifier: Programming Language :: Python :: 3 :: Only
12
12
  Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
16
15
  Classifier: Programming Language :: Python :: 3.14
@@ -21,7 +20,7 @@ Requires-Dist: textual>=6.3.0
21
20
  Requires-Dist: textual-enhanced>=1.2.0
22
21
  Requires-Dist: typedal>=4.2.2
23
22
  Requires-Dist: xdg-base-dirs>=6.0.2
24
- Requires-Python: >=3.11
23
+ Requires-Python: >=3.12
25
24
  Project-URL: Homepage, https://github.com/davep/oldnews
26
25
  Project-URL: Repository, https://github.com/davep/oldnews
27
26
  Project-URL: Documentation, https://oldnews.davep.dev/
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "oldnews"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "A terminal-based client for TheOldReader"
5
5
  readme = "README.md"
6
6
  authors = [
7
7
  { name = "Dave Pearson", email = "davep@davep.org" }
8
8
  ]
9
- requires-python = ">=3.11"
9
+ requires-python = ">=3.12"
10
10
  license = "GPL-3.0-or-later"
11
11
  keywords = [
12
12
  "atom",
@@ -31,7 +31,6 @@ classifiers = [
31
31
  "Operating System :: OS Independent",
32
32
  "Programming Language :: Python :: 3 :: Only",
33
33
  "Programming Language :: Python :: 3",
34
- "Programming Language :: Python :: 3.11",
35
34
  "Programming Language :: Python :: 3.12",
36
35
  "Programming Language :: Python :: 3.13",
37
36
  "Programming Language :: Python :: 3.14",
@@ -8,6 +8,7 @@ from .main import (
8
8
  Next,
9
9
  NextUnread,
10
10
  OpenArticle,
11
+ OpenOrigin,
11
12
  Previous,
12
13
  PreviousUnread,
13
14
  RefreshFromTheOldReader,
@@ -22,6 +23,7 @@ __all__ = [
22
23
  "Next",
23
24
  "NextUnread",
24
25
  "OpenArticle",
26
+ "OpenOrigin",
25
27
  "Previous",
26
28
  "PreviousUnread",
27
29
  "RefreshFromTheOldReader",
@@ -63,6 +63,13 @@ class OpenArticle(Command):
63
63
  BINDING_KEY = "o"
64
64
 
65
65
 
66
+ ##############################################################################
67
+ class OpenOrigin(Command):
68
+ """Open the origin for the current article in the web browser"""
69
+
70
+ BINDING_KEY = "O"
71
+
72
+
66
73
  ##############################################################################
67
74
  class MarkAllRead(Command):
68
75
  """Mark all unread articles in the current category as read"""
@@ -12,6 +12,7 @@ from .config import (
12
12
  from .db import initialise_database
13
13
  from .last_grab import last_grabbed_data_at, remember_we_last_grabbed_at
14
14
  from .local_articles import (
15
+ clean_old_read_articles,
15
16
  get_local_articles,
16
17
  get_unread_article_ids,
17
18
  locally_mark_article_ids_read,
@@ -27,6 +28,7 @@ from .navigation_state import get_navigation_state, save_navigation_state
27
28
  # Exports.
28
29
  __all__ = [
29
30
  "Configuration",
31
+ "clean_old_read_articles",
30
32
  "get_auth_token",
31
33
  "get_local_articles",
32
34
  "get_local_folders",
@@ -3,11 +3,14 @@
3
3
  ##############################################################################
4
4
  # Python imports.
5
5
  from pathlib import Path
6
+ from typing import Any
6
7
 
7
8
  ##############################################################################
8
9
  # TypeDAL imports.
9
- from typedal import TypeDAL, TypedTable
10
+ from typedal import TypeDAL, TypedField, TypedTable
10
11
  from typedal.config import TypeDALConfig
12
+ from typedal.helpers import get_field
13
+ from typedal.types import Field
11
14
 
12
15
  ##############################################################################
13
16
  # Local imports.
@@ -30,7 +33,9 @@ def db_file() -> Path:
30
33
 
31
34
 
32
35
  ##############################################################################
33
- def _safely_index(table: type[TypedTable], name: str, field: str) -> None:
36
+ def _safely_index(
37
+ table: type[TypedTable], name: str, field: str | Field | TypedField[Any]
38
+ ) -> None:
34
39
  """Create an index on a type, but handle errors.
35
40
 
36
41
  Args:
@@ -45,7 +50,9 @@ def _safely_index(table: type[TypedTable], name: str, field: str) -> None:
45
50
  just missed it.
46
51
  """
47
52
  try:
48
- table.create_index(name, field)
53
+ table.create_index(
54
+ name, get_field(field) if isinstance(field, TypedField) else field
55
+ )
49
56
  except RuntimeError:
50
57
  pass
51
58
 
@@ -74,12 +81,11 @@ def initialise_database() -> TypeDAL:
74
81
  )
75
82
 
76
83
  dal.define(LocalArticleCategory)
77
- # TODO: Need to make `field` more open.
78
- # _safely_index(
79
- # LocalArticleCategory,
80
- # "idx_local_article_category_article",
81
- # LocalArticleCategory.article,
82
- # )
84
+ _safely_index(
85
+ LocalArticleCategory,
86
+ "idx_local_article_category_article",
87
+ LocalArticleCategory.article,
88
+ )
83
89
  _safely_index(
84
90
  LocalArticleCategory,
85
91
  "idx_local_article_category_category",
@@ -2,7 +2,7 @@
2
2
 
3
3
  ##############################################################################
4
4
  # Python imports.
5
- from datetime import datetime
5
+ from datetime import datetime, timedelta
6
6
  from html import unescape
7
7
  from typing import Iterable, Iterator, cast
8
8
 
@@ -85,7 +85,7 @@ class LocalArticle(TypedTable):
85
85
  class LocalArticleCategory(TypedTable):
86
86
  """A local copy of the categories associated with an article."""
87
87
 
88
- article: LocalArticle
88
+ article: TypedField[LocalArticle]
89
89
  """The article that this category belongs to."""
90
90
  category: str
91
91
  """The category."""
@@ -95,7 +95,7 @@ class LocalArticleCategory(TypedTable):
95
95
  class LocalArticleAlternate(TypedTable):
96
96
  """A local copy of the alternate URLs associated with an article."""
97
97
 
98
- article: LocalArticle
98
+ article: TypedField[LocalArticle]
99
99
  """The article that this alternate belongs to."""
100
100
  href: str
101
101
  """The URL of the alternate."""
@@ -333,4 +333,19 @@ def get_unread_article_ids() -> list[str]:
333
333
  ]
334
334
 
335
335
 
336
+ ##############################################################################
337
+ def clean_old_read_articles(cutoff: timedelta) -> int:
338
+ """Clean up articles that are older than the given cutoff time."""
339
+ assert LocalArticle._db is not None
340
+ read = get_local_read_article_ids()
341
+ retire_time = datetime.now() - cutoff
342
+ cleaned = len(
343
+ LocalArticle.where(
344
+ (LocalArticle.published < retire_time) & LocalArticle.id.belongs(read)
345
+ ).delete()
346
+ )
347
+ LocalArticle._db.commit()
348
+ return cleaned
349
+
350
+
336
351
  ### local_articles.py ends here
@@ -18,6 +18,7 @@ from ..commands import (
18
18
  Next,
19
19
  NextUnread,
20
20
  OpenArticle,
21
+ OpenOrigin,
21
22
  Previous,
22
23
  PreviousUnread,
23
24
  RefreshFromTheOldReader,
@@ -41,6 +42,7 @@ class MainCommands(CommandsProvider):
41
42
  yield from self.maybe(Previous)
42
43
  yield from self.maybe(PreviousUnread)
43
44
  yield from self.maybe(OpenArticle)
45
+ yield from self.maybe(OpenOrigin)
44
46
  yield from self.maybe(MarkAllRead)
45
47
  yield ToggleShowAll()
46
48
  yield RefreshFromTheOldReader()
@@ -44,6 +44,7 @@ from ..commands import (
44
44
  Next,
45
45
  NextUnread,
46
46
  OpenArticle,
47
+ OpenOrigin,
47
48
  Previous,
48
49
  PreviousUnread,
49
50
  RefreshFromTheOldReader,
@@ -51,6 +52,7 @@ from ..commands import (
51
52
  )
52
53
  from ..data import (
53
54
  LocalUnread,
55
+ clean_old_read_articles,
54
56
  get_local_articles,
55
57
  get_local_folders,
56
58
  get_local_subscriptions,
@@ -145,6 +147,7 @@ class Main(EnhancedScreen[None]):
145
147
  Previous,
146
148
  PreviousUnread,
147
149
  OpenArticle,
150
+ OpenOrigin,
148
151
  ChangeTheme,
149
152
  ]
150
153
 
@@ -236,7 +239,7 @@ class Main(EnhancedScreen[None]):
236
239
  # but okay let's be defensive... (when I can come up with a nice
237
240
  # little MRE I'll report it).
238
241
  return True
239
- if action == OpenArticle.action_name():
242
+ if action in (OpenArticle.action_name(), OpenOrigin.action_name()):
240
243
  return self.article is not None
241
244
  if action in (Next.action_name(), Previous.action_name()):
242
245
  return self.articles is not None
@@ -245,6 +248,12 @@ class Main(EnhancedScreen[None]):
245
248
  PreviousUnread.action_name(),
246
249
  MarkAllRead.action_name(),
247
250
  ):
251
+ # If we're inside the navigation panel...
252
+ if self.query_one(Navigation).has_focus:
253
+ # ...we just care if there's anything unread somewhere.
254
+ return any(total for total in self.unread.values())
255
+ # Otherwise we care if we can see a current list of articles and
256
+ # if there's something unread amongst them.
248
257
  return self.articles is not None and any(
249
258
  article.is_unread for article in self.articles
250
259
  )
@@ -296,6 +305,10 @@ class Main(EnhancedScreen[None]):
296
305
  self.post_message(self.NewFolders(folders))
297
306
  if subscriptions := get_local_subscriptions():
298
307
  self.post_message(self.NewSubscriptions(subscriptions))
308
+ if cleaned := clean_old_read_articles(
309
+ timedelta(days=load_configuration().local_history)
310
+ ):
311
+ self.notify(f"Old read articles cleaned from local storage: {cleaned}")
299
312
  if unread := get_local_unread(folders, subscriptions):
300
313
  self.post_message(self.NewUnread(unread))
301
314
  # If we've never grabbed data from ToR before, or if it's been long enough...
@@ -487,14 +500,18 @@ class Main(EnhancedScreen[None]):
487
500
 
488
501
  def action_next_unread_command(self) -> None:
489
502
  """Go to the next unread article in the currently-viewed category."""
490
- if self.article is None:
503
+ if (navigation := self.query_one(Navigation)).has_focus:
504
+ navigation.highlight_next_unread_category()
505
+ elif self.article is None:
491
506
  self.query_one(ArticleList).highlight_next_unread_article()
492
507
  else:
493
508
  self.query_one(ArticleList).select_next_unread_article()
494
509
 
495
510
  def action_previous_unread_command(self) -> None:
496
511
  """Go to the previous unread article in the currently-viewed category"""
497
- if self.article is None:
512
+ if (navigation := self.query_one(Navigation)).has_focus:
513
+ navigation.highlight_previous_unread_category()
514
+ elif self.article is None:
498
515
  self.query_one(ArticleList).highlight_previous_unread_article()
499
516
  else:
500
517
  self.query_one(ArticleList).select_previous_unread_article()
@@ -546,5 +563,17 @@ class Main(EnhancedScreen[None]):
546
563
  severity="error",
547
564
  )
548
565
 
566
+ def action_open_origin_command(self) -> None:
567
+ """Open the origin of the current article in the web browser."""
568
+ if self.article is not None:
569
+ if self.article.origin.html_url:
570
+ open_url(self.article.origin.html_url)
571
+ else:
572
+ self.notify(
573
+ "No URL available for the article's origin",
574
+ severity="error",
575
+ title="Can't visit",
576
+ )
577
+
549
578
 
550
579
  ### main.py ends here
@@ -0,0 +1,53 @@
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
@@ -15,7 +15,7 @@ from oldas import Article
15
15
  ##############################################################################
16
16
  # Textual imports.
17
17
  from textual.app import ComposeResult
18
- from textual.containers import Vertical, VerticalScroll
18
+ from textual.containers import Horizontal, Vertical, VerticalScroll
19
19
  from textual.reactive import var
20
20
  from textual.widgets import Label, Markdown
21
21
 
@@ -36,8 +36,13 @@ class ArticleContent(Vertical):
36
36
  height: auto;
37
37
  padding: 0 1 0 1;
38
38
 
39
+ Horizontal {
40
+ height: auto;
41
+ }
42
+
39
43
  #title {
40
44
  color: $text-accent;
45
+ width: 1fr;
41
46
  }
42
47
  #published, #link {
43
48
  color: $text-muted;
@@ -63,15 +68,15 @@ class ArticleContent(Vertical):
63
68
  def compose(self) -> ComposeResult:
64
69
  """Compose the content of the widget."""
65
70
  with Vertical(id="header"):
66
- yield Label(id="title", markup=False)
67
- yield Label(id="published")
71
+ with Horizontal():
72
+ yield Label(id="title", markup=False)
73
+ yield Label(id="published")
68
74
  yield Label(id="link", markup=False)
69
75
  with VerticalScroll():
70
76
  yield Markdown()
71
77
 
72
- def _watch_article(self) -> None:
78
+ async def _watch_article(self) -> None:
73
79
  """React to the article being updated."""
74
- self.set_class(self.article is not None, "--has-article")
75
80
  if self.article is not None:
76
81
  self.query_one("#title", Label).update(self.article.title)
77
82
  self.query_one("#published", Label).update(str(self.article.published))
@@ -81,7 +86,9 @@ class ArticleContent(Vertical):
81
86
  else:
82
87
  link.visible = True
83
88
  link.update(self.article.html_url)
84
- self.query_one(Markdown).update(convert(self.article.summary.content))
89
+ await self.query_one(Markdown).update(convert(self.article.summary.content))
90
+ self.query_one(VerticalScroll).scroll_home()
91
+ self.set_class(self.article is not None, "--has-article")
85
92
 
86
93
  def focus(self, scroll_visible: bool = True) -> Self:
87
94
  self.query_one(VerticalScroll).focus(scroll_visible)
@@ -1,9 +1,13 @@
1
1
  """Widget to show a list of articles."""
2
2
 
3
+ ##############################################################################
4
+ # Backward compatibility.
5
+ from __future__ import annotations
6
+
3
7
  ##############################################################################
4
8
  # Python imports.
5
9
  from dataclasses import dataclass
6
- from typing import Iterator, Literal, cast
10
+ from typing import cast
7
11
 
8
12
  ##############################################################################
9
13
  # OldAs imports.
@@ -26,6 +30,10 @@ from textual.widgets.option_list import Option
26
30
  # Textual enhanced imports.
27
31
  from textual_enhanced.widgets import EnhancedOptionList
28
32
 
33
+ ##############################################################################
34
+ # Local imports.
35
+ from ._after_highlight import HighlightDirection, options_after_highlight
36
+
29
37
 
30
38
  ##############################################################################
31
39
  class ArticleView(Option):
@@ -73,11 +81,6 @@ class ArticleView(Option):
73
81
  return self._article
74
82
 
75
83
 
76
- ##############################################################################
77
- UnreadSearchDirection = Literal["next", "previous"]
78
- """Type of a unread search direction."""
79
-
80
-
81
84
  ##############################################################################
82
85
  class ArticleList(EnhancedOptionList):
83
86
  """Widget for showing a list of articles."""
@@ -139,36 +142,7 @@ class ArticleList(EnhancedOptionList):
139
142
  assert isinstance(message.option, ArticleView)
140
143
  self.post_message(self.ViewArticle(message.option.article))
141
144
 
142
- def _unread_articles_after_highlight(
143
- self, direction: UnreadSearchDirection
144
- ) -> Iterator[ArticleView]:
145
- """Return a list of all articles after the highlight.
146
-
147
- Args:
148
- direction: The direction to search in.
149
-
150
- Returns:
151
- An iterator of `ArticleView` objects that show an unread article.
152
-
153
- Notes:
154
- If there is no highlight, we default at position 0.
155
- """
156
- highlight = self.highlighted or 0
157
- options = (
158
- list(reversed(self.options)) if direction == "previous" else self.options
159
- )
160
- highlight = (
161
- (len(options) - highlight - 1) if direction == "previous" else highlight
162
- )
163
- return (
164
- article_view
165
- for article_view in cast(
166
- list[ArticleView], [*options[highlight:], *options[0:highlight]]
167
- )[1:]
168
- if article_view.article.is_unread
169
- )
170
-
171
- def _highlight_unread(self, direction: UnreadSearchDirection) -> bool:
145
+ def _highlight_unread(self, direction: HighlightDirection) -> bool:
172
146
  """Highlight the next unread article, if there is one.
173
147
 
174
148
  Args:
@@ -178,7 +152,15 @@ class ArticleList(EnhancedOptionList):
178
152
  `True` if an unread article was found and highlighted, `False`
179
153
  if not.
180
154
  """
181
- if next_hit := next(self._unread_articles_after_highlight(direction), None):
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,
163
+ ):
182
164
  if next_hit.id is not None:
183
165
  self.highlighted = self.get_option_index(next_hit.id)
184
166
  return True
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
  ##############################################################################
8
8
  # Python imports.
9
9
  from dataclasses import dataclass
10
+ from typing import cast
10
11
 
11
12
  ##############################################################################
12
13
  # OldAs imports.
@@ -34,6 +35,7 @@ from textual_enhanced.widgets import EnhancedOptionList
34
35
  ##############################################################################
35
36
  # Local imports.
36
37
  from ..data import LocalUnread, get_navigation_state, save_navigation_state
38
+ from ._after_highlight import HighlightDirection, options_after_highlight
37
39
 
38
40
 
39
41
  ##############################################################################
@@ -102,7 +104,7 @@ class SubscriptionView(Option):
102
104
  "",
103
105
  str(unread) if unread else "",
104
106
  )
105
- super().__init__(prompt)
107
+ super().__init__(prompt, id=subscription.id)
106
108
 
107
109
  @property
108
110
  def subscription(self) -> Subscription:
@@ -236,5 +238,38 @@ class Navigation(EnhancedOptionList):
236
238
  return selected.subscription
237
239
  raise ValueError("Unknown category")
238
240
 
241
+ def _highlight_unread(self, direction: HighlightDirection) -> bool:
242
+ """Highlight the next category with unread articles, if there is one.
243
+
244
+ Args:
245
+ direction: The direction to search.
246
+
247
+ Returns:
248
+ `True` if an unread category was found and highlighted, `False`
249
+ if not.
250
+ """
251
+ if next_hit := next(
252
+ options_after_highlight(
253
+ self,
254
+ cast(list[FolderView | SubscriptionView], self.options),
255
+ direction,
256
+ lambda category: bool(category.id and self.unread.get(category.id)),
257
+ ),
258
+ None,
259
+ ):
260
+ if next_hit.id is not None:
261
+ self.highlighted = self.get_option_index(next_hit.id)
262
+ return True
263
+ self.notify("No more folders or subscriptions with unread articles")
264
+ return False
265
+
266
+ def highlight_next_unread_category(self) -> None:
267
+ """Highlight the next unread category."""
268
+ self._highlight_unread("next")
269
+
270
+ def highlight_previous_unread_category(self) -> None:
271
+ """Highlight the previous unread category."""
272
+ self._highlight_unread("previous")
273
+
239
274
 
240
275
  ### navigation.py ends here
File without changes
File without changes
File without changes
File without changes
File without changes