oldnews 0.0.2__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.0.2 → oldnews-0.2.0}/PKG-INFO +5 -6
  2. {oldnews-0.0.2 → oldnews-0.2.0}/pyproject.toml +2 -3
  3. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/commands/__init__.py +8 -0
  4. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/commands/main.py +28 -0
  5. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/__init__.py +2 -0
  6. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/db.py +15 -9
  7. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/local_articles.py +18 -3
  8. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/providers/main.py +8 -0
  9. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/screens/main.py +103 -13
  10. oldnews-0.2.0/src/oldnews/widgets/_after_highlight.py +53 -0
  11. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/widgets/article_content.py +13 -6
  12. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/widgets/article_list.py +41 -41
  13. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/widgets/navigation.py +36 -1
  14. {oldnews-0.0.2 → oldnews-0.2.0}/README.md +0 -0
  15. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/__init__.py +0 -0
  16. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/__main__.py +0 -0
  17. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/auth.py +0 -0
  18. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/config.py +0 -0
  19. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/last_grab.py +0 -0
  20. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/local_folders.py +0 -0
  21. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/local_subscriptions.py +0 -0
  22. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/local_unread.py +0 -0
  23. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/locations.py +0 -0
  24. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/data/navigation_state.py +0 -0
  25. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/oldnews.py +0 -0
  26. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/providers/__init__.py +0 -0
  27. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/py.typed +0 -0
  28. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/screens/__init__.py +0 -0
  29. {oldnews-0.0.2 → oldnews-0.2.0}/src/oldnews/screens/login.py +0 -0
  30. {oldnews-0.0.2 → 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.0.2
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,13 +20,13 @@ 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
25
- Project-URL: Discussions, https://github.com/davep/oldnews/discussions
26
- Project-URL: Documentation, https://oldnews.davep.dev/
23
+ Requires-Python: >=3.12
27
24
  Project-URL: Homepage, https://github.com/davep/oldnews
28
- Project-URL: Issues, https://github.com/davep/oldnews/issues
29
25
  Project-URL: Repository, https://github.com/davep/oldnews
26
+ Project-URL: Documentation, https://oldnews.davep.dev/
30
27
  Project-URL: Source, https://github.com/davep/oldnews
28
+ Project-URL: Issues, https://github.com/davep/oldnews/issues
29
+ Project-URL: Discussions, https://github.com/davep/oldnews/discussions
31
30
  Description-Content-Type: text/markdown
32
31
 
33
32
  # OldNews - A TheOldReader client for the terminal
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "oldnews"
3
- version = "0.0.2"
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",
@@ -4,8 +4,12 @@
4
4
  # Local imports.
5
5
  from .main import (
6
6
  Escape,
7
+ MarkAllRead,
8
+ Next,
7
9
  NextUnread,
8
10
  OpenArticle,
11
+ OpenOrigin,
12
+ Previous,
9
13
  PreviousUnread,
10
14
  RefreshFromTheOldReader,
11
15
  ToggleShowAll,
@@ -15,8 +19,12 @@ from .main import (
15
19
  # Exports.
16
20
  __all__ = [
17
21
  "Escape",
22
+ "MarkAllRead",
23
+ "Next",
18
24
  "NextUnread",
19
25
  "OpenArticle",
26
+ "OpenOrigin",
27
+ "Previous",
20
28
  "PreviousUnread",
21
29
  "RefreshFromTheOldReader",
22
30
  "ToggleShowAll",
@@ -35,6 +35,13 @@ class NextUnread(Command):
35
35
  BINDING_KEY = "n"
36
36
 
37
37
 
38
+ ##############################################################################
39
+ class Next(Command):
40
+ """Navigate to the next article regardless of read status"""
41
+
42
+ BINDING_KEY = "N"
43
+
44
+
38
45
  ##############################################################################
39
46
  class PreviousUnread(Command):
40
47
  """Navigate to the previous unread article in the currently-selected category"""
@@ -42,6 +49,13 @@ class PreviousUnread(Command):
42
49
  BINDING_KEY = "p"
43
50
 
44
51
 
52
+ ##############################################################################
53
+ class Previous(Command):
54
+ """Navigate to the next article regardless of read status"""
55
+
56
+ BINDING_KEY = "P"
57
+
58
+
45
59
  ##############################################################################
46
60
  class OpenArticle(Command):
47
61
  """Open the current article in the web browser"""
@@ -49,4 +63,18 @@ class OpenArticle(Command):
49
63
  BINDING_KEY = "o"
50
64
 
51
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
+
73
+ ##############################################################################
74
+ class MarkAllRead(Command):
75
+ """Mark all unread articles in the current category as read"""
76
+
77
+ BINDING_KEY = "R"
78
+
79
+
52
80
  ### main.py ends here
@@ -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
@@ -14,8 +14,12 @@ from textual_enhanced.commands import (
14
14
  # Local imports.
15
15
  from ..commands import (
16
16
  Escape,
17
+ MarkAllRead,
18
+ Next,
17
19
  NextUnread,
18
20
  OpenArticle,
21
+ OpenOrigin,
22
+ Previous,
19
23
  PreviousUnread,
20
24
  RefreshFromTheOldReader,
21
25
  ToggleShowAll,
@@ -33,9 +37,13 @@ class MainCommands(CommandsProvider):
33
37
  The commands for the command palette.
34
38
  """
35
39
  yield Escape()
40
+ yield from self.maybe(Next)
36
41
  yield from self.maybe(NextUnread)
42
+ yield from self.maybe(Previous)
37
43
  yield from self.maybe(PreviousUnread)
38
44
  yield from self.maybe(OpenArticle)
45
+ yield from self.maybe(OpenOrigin)
46
+ yield from self.maybe(MarkAllRead)
39
47
  yield ToggleShowAll()
40
48
  yield RefreshFromTheOldReader()
41
49
  yield ChangeTheme()
@@ -15,6 +15,7 @@ from oldas import (
15
15
  Folder,
16
16
  Folders,
17
17
  Session,
18
+ State,
18
19
  Subscription,
19
20
  Subscriptions,
20
21
  )
@@ -31,6 +32,7 @@ from textual.widgets import Footer, Header
31
32
  ##############################################################################
32
33
  # Textual enhanced imports.
33
34
  from textual_enhanced.commands import ChangeTheme, Command, Help, Quit
35
+ from textual_enhanced.dialogs import Confirm
34
36
  from textual_enhanced.screen import EnhancedScreen
35
37
 
36
38
  ##############################################################################
@@ -38,14 +40,19 @@ from textual_enhanced.screen import EnhancedScreen
38
40
  from .. import __version__
39
41
  from ..commands import (
40
42
  Escape,
43
+ MarkAllRead,
44
+ Next,
41
45
  NextUnread,
42
46
  OpenArticle,
47
+ OpenOrigin,
48
+ Previous,
43
49
  PreviousUnread,
44
50
  RefreshFromTheOldReader,
45
51
  ToggleShowAll,
46
52
  )
47
53
  from ..data import (
48
54
  LocalUnread,
55
+ clean_old_read_articles,
49
56
  get_local_articles,
50
57
  get_local_folders,
51
58
  get_local_subscriptions,
@@ -134,9 +141,13 @@ class Main(EnhancedScreen[None]):
134
141
  RefreshFromTheOldReader,
135
142
  # Everything else.
136
143
  Escape,
144
+ MarkAllRead,
145
+ Next,
137
146
  NextUnread,
147
+ Previous,
138
148
  PreviousUnread,
139
149
  OpenArticle,
150
+ OpenOrigin,
140
151
  ChangeTheme,
141
152
  ]
142
153
 
@@ -228,9 +239,21 @@ class Main(EnhancedScreen[None]):
228
239
  # but okay let's be defensive... (when I can come up with a nice
229
240
  # little MRE I'll report it).
230
241
  return True
231
- if action == OpenArticle.action_name():
242
+ if action in (OpenArticle.action_name(), OpenOrigin.action_name()):
232
243
  return self.article is not None
233
- if action in (NextUnread.action_name(), PreviousUnread.action_name()):
244
+ if action in (Next.action_name(), Previous.action_name()):
245
+ return self.articles is not None
246
+ if action in (
247
+ NextUnread.action_name(),
248
+ PreviousUnread.action_name(),
249
+ MarkAllRead.action_name(),
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.
234
257
  return self.articles is not None and any(
235
258
  article.is_unread for article in self.articles
236
259
  )
@@ -282,6 +305,10 @@ class Main(EnhancedScreen[None]):
282
305
  self.post_message(self.NewFolders(folders))
283
306
  if subscriptions := get_local_subscriptions():
284
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}")
285
312
  if unread := get_local_unread(folders, subscriptions):
286
313
  self.post_message(self.NewUnread(unread))
287
314
  # If we've never grabbed data from ToR before, or if it's been long enough...
@@ -336,12 +363,10 @@ class Main(EnhancedScreen[None]):
336
363
  self.post_message(self.SubTitle("Comparing against locally-read articles"))
337
364
  local_unread_articles = set(get_unread_article_ids())
338
365
  if mark_as_read := local_unread_articles - remote_unread_articles:
339
- self.post_message(
340
- self.SubTitle(
341
- f"Articles found read elsewhere on TheOldReader: {len(mark_as_read)}"
342
- )
343
- )
344
366
  locally_mark_article_ids_read(mark_as_read)
367
+ self.notify(
368
+ f"Articles found read elsewhere on TheOldReader: {len(mark_as_read)}"
369
+ )
345
370
 
346
371
  @on(RefreshFromTheOldReader)
347
372
  @work(exclusive=True)
@@ -459,19 +484,37 @@ class Main(EnhancedScreen[None]):
459
484
  elif self.focused is self.query_one(Navigation):
460
485
  self.app.exit()
461
486
 
487
+ def action_next_command(self) -> None:
488
+ """Go to the next article in the currently-viewed category."""
489
+ if self.article is None:
490
+ self.query_one(ArticleList).highlight_next_article()
491
+ else:
492
+ self.query_one(ArticleList).select_next_article()
493
+
494
+ def action_previous_command(self) -> None:
495
+ """Go to the previous article in the currently-viewed category."""
496
+ if self.article is None:
497
+ self.query_one(ArticleList).highlight_previous_article()
498
+ else:
499
+ self.query_one(ArticleList).select_previous_article()
500
+
462
501
  def action_next_unread_command(self) -> None:
463
502
  """Go to the next unread article in the currently-viewed category."""
464
- if self.article is None:
465
- self.query_one(ArticleList).highlight_next_unread()
503
+ if (navigation := self.query_one(Navigation)).has_focus:
504
+ navigation.highlight_next_unread_category()
505
+ elif self.article is None:
506
+ self.query_one(ArticleList).highlight_next_unread_article()
466
507
  else:
467
- self.query_one(ArticleList).select_next_unread()
508
+ self.query_one(ArticleList).select_next_unread_article()
468
509
 
469
510
  def action_previous_unread_command(self) -> None:
470
511
  """Go to the previous unread article in the currently-viewed category"""
471
- if self.article is None:
472
- self.query_one(ArticleList).highlight_previous_unread()
512
+ if (navigation := self.query_one(Navigation)).has_focus:
513
+ navigation.highlight_previous_unread_category()
514
+ elif self.article is None:
515
+ self.query_one(ArticleList).highlight_previous_unread_article()
473
516
  else:
474
- self.query_one(ArticleList).select_previous_unread()
517
+ self.query_one(ArticleList).select_previous_unread_article()
475
518
 
476
519
  def action_open_article_command(self) -> None:
477
520
  """Open the current article in a web browser."""
@@ -485,5 +528,52 @@ class Main(EnhancedScreen[None]):
485
528
  title="Can't visit",
486
529
  )
487
530
 
531
+ @work
532
+ async def action_mark_all_read_command(self) -> None:
533
+ """Mark all unread articles in the current category as read."""
534
+ if (current_category := self.query_one(Navigation).current_category) is None:
535
+ return
536
+ if not (
537
+ ids_to_mark_read := [
538
+ article.id for article in self.articles if article.is_unread
539
+ ]
540
+ ):
541
+ return
542
+ category_description = f"{current_category.__class__.__name__.lower()} '{current_category.name if isinstance(current_category, Folder) else current_category.title}'"
543
+ plural = "s" if len(ids_to_mark_read) > 1 else ""
544
+ if await self.app.push_screen_wait(
545
+ Confirm(
546
+ "Mark all read",
547
+ f"Are you sure you want to mark all unread articles in the {category_description} as read?\n\n"
548
+ f"This will mark {len(ids_to_mark_read)} article{plural} as read.",
549
+ )
550
+ ):
551
+ if await self._session.add_tag(ids_to_mark_read, State.READ):
552
+ locally_mark_article_ids_read(ids_to_mark_read)
553
+ self.post_message(
554
+ self.NewUnread(get_local_unread(self.folders, self.subscriptions))
555
+ )
556
+ self._refresh_article_list()
557
+ self.notify(
558
+ f"{len(ids_to_mark_read)} article{plural} marked read for {category_description}"
559
+ )
560
+ else:
561
+ self.notify(
562
+ "Failed to mark as read on TheOldReader",
563
+ severity="error",
564
+ )
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
+
488
578
 
489
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,27 +152,53 @@ 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
185
167
  self.notify("No more unread articles")
186
168
  return False
187
169
 
188
- def highlight_next_unread(self) -> None:
170
+ def highlight_next_article(self) -> None:
171
+ """Highlight the next article in the list."""
172
+ self.call_later(self.run_action, "cursor_down")
173
+
174
+ def highlight_previous_article(self) -> None:
175
+ """Highlight the previous article in the list."""
176
+ self.call_later(self.run_action, "cursor_up")
177
+
178
+ def highlight_next_unread_article(self) -> None:
189
179
  """Highlight the next unread article in the list."""
190
180
  self._highlight_unread("next")
191
181
 
192
- def highlight_previous_unread(self) -> None:
182
+ def highlight_previous_unread_article(self) -> None:
193
183
  """Highlight the previous unread article in the list."""
194
184
  self._highlight_unread("previous")
195
185
 
196
- def select_next_unread(self) -> None:
186
+ def select_next_article(self) -> None:
187
+ """Select the next article in the list."""
188
+ self.call_later(self.run_action, "cursor_down")
189
+ self.call_later(self.run_action, "select")
190
+
191
+ def select_previous_article(self) -> None:
192
+ """Select the previous article in the list."""
193
+ self.call_later(self.run_action, "cursor_up")
194
+ self.call_later(self.run_action, "select")
195
+
196
+ def select_next_unread_article(self) -> None:
197
197
  """Select the next unread article in the list."""
198
198
  if self._highlight_unread("next"):
199
199
  self.call_later(self.run_action, "select")
200
200
 
201
- def select_previous_unread(self) -> None:
201
+ def select_previous_unread_article(self) -> None:
202
202
  """Select the next unread article in the list."""
203
203
  if self._highlight_unread("previous"):
204
204
  self.call_later(self.run_action, "select")
@@ -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