Skip to content

UI Screens Reference

The TUI application is built using Textual and organized into different screens for different views.

EntryListScreen

The main screen showing the list of entries.

Bases: Screen

Screen for displaying a list of feed entries with sorting.

Source code in miniflux_tui/ui/screens/entry_list.py
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
class EntryListScreen(Screen):
    """Screen for displaying a list of feed entries with sorting."""

    BINDINGS = [  # noqa: RUF012
        Binding("j", "cursor_down", "Down", show=False),
        Binding("k", "cursor_up", "Up", show=False),
        Binding("enter", "select_entry", "Open Entry"),
        Binding("m", "toggle_read", "Mark Read/Unread"),
        Binding("asterisk", "toggle_star", "Toggle Star"),
        Binding("e", "save_entry", "Save Entry"),
        Binding("s", "cycle_sort", "Cycle Sort"),
        Binding("g", "toggle_group", "Group by Feed"),
        Binding("shift+c", "toggle_category_group", "Group by Category"),
        Binding("shift+g", "expand_all", "Expand All"),
        Binding("shift+z", "collapse_all", "Collapse All"),
        Binding("o", "toggle_fold", "Fold/Unfold Feed"),
        Binding("h", "collapse_feed", "Collapse Feed"),
        Binding("l", "expand_feed", "Expand Feed"),
        Binding("left", "collapse_feed", "Collapse Feed", show=False),
        Binding("right", "expand_feed", "Expand Feed", show=False),
        Binding("r", "refresh", "Refresh Current Feed"),
        Binding("comma", "refresh", "Refresh Current Feed", show=False),
        Binding("shift+r", "refresh_all_feeds", "Refresh All Feeds"),
        Binding("u", "show_unread", "Unread"),
        Binding("t", "show_starred", "Starred"),
        Binding("slash", "search", "Search"),
        Binding("question_mark", "show_help", "Help"),
        Binding("i", "show_status", "Status"),
        Binding("q", "quit", "Quit"),
    ]

    def __init__(
        self,
        entries: list[Entry],
        categories: list[Category] | None = None,
        unread_color: str = "cyan",
        read_color: str = "gray",
        default_sort: str = "date",
        group_by_feed: bool = False,
        group_collapsed: bool = False,
        **kwargs,
    ):
        super().__init__(**kwargs)
        self.entries = entries
        self.categories = categories or []
        self.sorted_entries = entries.copy()  # Store sorted entries for navigation
        self.unread_color = unread_color
        self.read_color = read_color
        self.current_sort = default_sort
        self.group_by_feed = group_by_feed
        self.group_by_category = False  # Option to group by category instead of feed
        self.group_collapsed = group_collapsed  # Start feeds collapsed in grouped mode
        self.filter_unread_only = False  # Filter to show only unread entries
        self.filter_starred_only = False  # Filter to show only starred entries
        self.search_active = False  # Flag to indicate search is active
        self.search_term = ""  # Current search term
        self.list_view: ListView | None = None
        self.displayed_items: list[ListItem] = []  # Track items in display order
        self.refresh_optimizer = ScreenRefreshOptimizer()  # Track refresh performance
        self.entry_item_map: dict[int, EntryListItem] = {}  # Map entry IDs to list items
        self.feed_header_map: dict[str, FeedHeaderItem] = {}  # Map feed names to header items
        self.category_header_map: dict[str, CategoryHeaderItem] = {}  # Map category names to header items
        self.feed_fold_state: dict[str, bool] = {}  # Track fold state per feed (True = expanded)
        self.category_fold_state: dict[str, bool] = {}  # Track fold state per category (True = expanded)
        self.last_highlighted_feed: str | None = None  # Track last highlighted feed for position persistence
        self.last_highlighted_category: str | None = None  # Track last highlighted category for position persistence
        self.last_highlighted_entry_id: int | None = None  # Track last highlighted entry ID for position
        self.last_cursor_index: int = 0  # Track cursor position for non-grouped mode

    @property
    def app(self) -> "MinifluxTUI":
        """Get the app instance with proper type hints."""
        return cast("MinifluxTUI", super().app)

    def compose(self) -> ComposeResult:
        """Create child widgets."""
        yield Header()
        yield ListView()
        yield Footer()

    def on_mount(self) -> None:
        """Called when screen is mounted."""
        # Get reference to the ListView after it's mounted
        self.list_view = self.query_one(ListView)
        self.log(f"on_mount: list_view is now {self.list_view}")

        # Only populate if we have entries
        if self.entries:
            self.log(f"on_mount: Populating with {len(self.entries)} entries")
            self._populate_list()
            # Use call_later to defer focus and cursor restoration until ListView has updated
            if self.group_by_feed:
                self.call_later(self._restore_cursor_position_and_focus)
            else:
                self.call_later(self._ensure_focus)
        else:
            self.log("on_mount: No entries yet, skipping initial population")

    def on_screen_resume(self) -> None:
        """Called when screen is resumed (e.g., after returning from entry reader)."""
        # Refresh the list to reflect any status changes
        if self.entries and self.list_view:
            self._populate_list()
            # Use call_later to defer focus and cursor restoration until ListView has updated
            # Always restore cursor position to maintain user's navigation context
            self.call_later(self._restore_cursor_position_and_focus)
        elif self.list_view and len(self.list_view.children) > 0:
            # If no entries, just ensure focus
            self.call_later(self._ensure_focus)

    def on_list_view_selected(self, event: ListView.Selected) -> None:
        """Handle ListView selection (Enter key)."""
        # Get the selected item
        if event.item and isinstance(event.item, FeedHeaderItem):
            # When Enter is pressed on a feed header in grouped mode, open the first entry
            feed_title = event.item.feed_title
            # Find the first visible (or any) entry in this feed
            for entry in self.sorted_entries:
                if entry.feed.title == feed_title:
                    # Save the feed of the current entry for position restoration
                    self.last_highlighted_feed = feed_title
                    self.last_highlighted_entry_id = entry.id

                    # Find the index of this entry in the sorted entry list
                    entry_index = 0
                    for i, e in enumerate(self.sorted_entries):
                        if e.id == entry.id:
                            entry_index = i
                            break

                    # Open entry reader screen with navigation context
                    if isinstance(self.app, self.app.__class__) and hasattr(self.app, "push_entry_reader"):
                        self.app.push_entry_reader(entry=entry, entry_list=self.sorted_entries, current_index=entry_index)
                    return

        elif event.item and isinstance(event.item, EntryListItem):
            # Save the feed of the current entry for position restoration
            self.last_highlighted_feed = event.item.entry.feed.title
            self.last_highlighted_entry_id = event.item.entry.id

            # Save the cursor index in the list view
            if self.list_view and self.list_view.index is not None:
                self.last_cursor_index = self.list_view.index

            # Find the index of this entry in the sorted entry list
            entry_index = 0
            for i, entry in enumerate(self.sorted_entries):
                if entry.id == event.item.entry.id:
                    entry_index = i
                    break

            # Open entry reader screen with navigation context
            if isinstance(self.app, self.app.__class__) and hasattr(self.app, "push_entry_reader"):
                self.app.push_entry_reader(entry=event.item.entry, entry_list=self.sorted_entries, current_index=entry_index)

    def _populate_list(self):
        """Populate the list with sorted and filtered entries."""
        if not self._ensure_list_view():
            return

        self.list_view.clear()
        sorted_entries = self._get_sorted_entries()
        self.sorted_entries = sorted_entries
        self._display_entries(sorted_entries)
        self.refresh_optimizer.track_full_refresh()

        # Don't set initial index here - let _restore_cursor_position handle it
        # This prevents overwriting the cursor position when returning from entry reader

    def _find_entry_index_by_id(self, entry_id: int | None) -> int | None:
        """Find the index of an entry by its ID.

        Searches the list view for an EntryListItem with matching entry ID.
        Returns None if not found or if entry_id is not set.

        Args:
            entry_id: ID of the entry to find

        Returns:
            Index of the entry in list view, or None if not found
        """
        if not entry_id:
            return None

        for i, child in enumerate(self.list_view.children):
            if isinstance(child, EntryListItem) and child.entry.id == entry_id:
                return i

        return None

    def _find_feed_header_index(self, feed_title: str | None) -> int | None:
        """Find the index of a feed header by title.

        Searches the list view for a FeedHeaderItem with matching feed title.
        Returns None if not found or feed not in map.

        Args:
            feed_title: Title of the feed to find

        Returns:
            Index of the feed header in list view, or None if not found
        """
        if not feed_title or not self.group_by_feed or feed_title not in self.feed_header_map:
            return None

        feed_header = self.feed_header_map[feed_title]
        for i, child in enumerate(self.list_view.children):
            if child is feed_header:
                return i

        return None

    def _set_cursor_to_index(self, index: int) -> bool:
        """Safely set cursor to a specific index.

        Handles boundary checking and suppresses exceptions.

        Args:
            index: Target index

        Returns:
            True if successful, False otherwise
        """
        max_index = len(self.list_view.children) - 1
        if index > max_index:
            return False

        with suppress(Exception):
            self.list_view.index = index
            return True

        return False

    def _restore_cursor_position(self) -> None:
        """Restore cursor position based on mode.

        Attempts restoration in this order:
        1. Restore to the last highlighted entry by ID (all modes)
        2. Restore to the last highlighted feed header (grouped mode only)
        3. Restore to the last cursor index (fallback)

        Used after rebuilding the list to restore user's position.
        On initial mount, defaults to first item.
        """
        if not self.list_view or len(self.list_view.children) == 0:
            return

        # Try to restore to last highlighted entry by ID
        entry_index = self._find_entry_index_by_id(self.last_highlighted_entry_id)
        if entry_index is not None and self._set_cursor_to_index(entry_index):
            self.log(f"Restoring cursor to entry {self.last_highlighted_entry_id} at index {entry_index}")
            return

        # In grouped mode, try to restore to feed header
        feed_index = self._find_feed_header_index(self.last_highlighted_feed)
        if feed_index is not None and self._set_cursor_to_index(feed_index):
            self.log(f"Restoring cursor to feed header '{self.last_highlighted_feed}' at index {feed_index}")
            return

        # Fallback: restore to last cursor index
        max_index = len(self.list_view.children) - 1
        cursor_index = min(self.last_cursor_index, max_index)
        if self._set_cursor_to_index(cursor_index):
            self.log(f"Restoring cursor to last index {cursor_index}")

    def _restore_cursor_position_and_focus(self) -> None:
        """Restore cursor position and ensure focus (called after ListView update)."""
        self._restore_cursor_position()
        self._ensure_focus()

    def _ensure_focus(self) -> None:
        """Ensure ListView has focus for keyboard input."""
        if self.list_view and len(self.list_view.children) > 0:
            with suppress(Exception):
                self.list_view.focus()

    def _ensure_list_view(self) -> bool:
        """Ensure list_view is available. Returns False if unavailable."""
        if not self.list_view:
            try:
                self.list_view = self.query_one(ListView)
            except Exception as e:
                self.log(f"Failed to get list_view: {e}")
                return False
        return True

    def _get_highlighted_feed_title(self) -> str | None:
        """Extract feed title from currently highlighted list item.

        Returns the feed title from either a FeedHeaderItem or EntryListItem.
        This eliminates the repeated pattern of checking item type and
        extracting feed title across multiple methods.

        Returns:
            Feed title if found, None otherwise
        """
        if not self.list_view:
            return None

        highlighted = self.list_view.highlighted_child
        if not highlighted:
            return None

        if isinstance(highlighted, FeedHeaderItem):
            return highlighted.feed_title
        if isinstance(highlighted, EntryListItem):
            return highlighted.entry.feed.title
        return None

    def _set_feed_fold_state(self, feed_title: str, is_expanded: bool) -> None:
        """Set fold state for a feed and update UI.

        Updates the feed's fold state, toggles the header visual indicator,
        and updates the CSS visibility of feed entries. This eliminates the
        repeated pattern of state management across collapse/expand methods.

        Args:
            feed_title: Title of the feed to update
            is_expanded: True to expand feed, False to collapse
        """
        # Ensure fold state entry exists
        if feed_title not in self.feed_fold_state:
            self.feed_fold_state[feed_title] = not self.group_collapsed

        # Update fold state
        self.feed_fold_state[feed_title] = is_expanded

        # Update header visual indicator
        if feed_title in self.feed_header_map:
            self.feed_header_map[feed_title].toggle_fold()

        # Update CSS visibility
        self._update_feed_visibility(feed_title)

    def _ensure_list_view_and_grouped(self) -> bool:
        """Ensure list view is available and we're in grouped mode.

        Consolidates the common check: list_view exists and group_by_feed is True.
        This eliminates repeated `if not self.list_view or not self.group_by_feed` checks.

        Returns:
            True if list_view is available and grouped mode is enabled, False otherwise
        """
        return self._ensure_list_view() and self.group_by_feed

    def _list_view_has_items(self) -> bool:
        """Check if list view exists and has children.

        Consolidates the common check for both list view availability and
        checking if it has items. Used to determine if there are entries to work with.

        Returns:
            True if list_view exists and has children, False otherwise
        """
        return self.list_view is not None and len(self.list_view.children) > 0

    def _get_sorted_entries(self) -> list[Entry]:
        """Get entries sorted/grouped according to current settings."""
        entries = self._filter_entries(self.entries)

        if self.group_by_category:
            # When grouping by category, sort by category title then by date
            # Get category title from entry's feed's category_id
            return sorted(
                entries,
                key=lambda e: (self._get_category_title(e.feed.category_id).lower(), e.published_at),
                reverse=False,
            )
        if self.group_by_feed:
            # When grouping by feed, sort by feed name then by date
            return sorted(
                entries,
                key=lambda e: (e.feed.title.lower(), e.published_at),
                reverse=False,
            )
        return self._sort_entries(entries)

    def _display_entries(self, entries: list[Entry]):
        """Display entries in list view based on grouping setting."""
        if self.group_by_category:
            self._add_grouped_entries_by_category(entries)
        elif self.group_by_feed:
            self._add_grouped_entries(entries)
        else:
            self._add_flat_entries(entries)

    def _sort_entries(self, entries: list[Entry]) -> list[Entry]:
        """Sort entries based on current sort mode.

        Sort modes:
        - "feed": Alphabetically by feed name (A-Z), then newest entries first within each feed
        - "date": Newest entries first (most recent publication date)
        - "status": Unread entries first, then by date (oldest first)
        """
        if self.current_sort == "feed":
            # Sort by feed name (A-Z), then by date (newest first within each feed)
            # Use a tuple key with negative date for newest-first within each feed
            return sorted(
                entries,
                key=lambda e: (e.feed.title.lower(), -e.published_at.timestamp()),
                reverse=False,
            )
        if self.current_sort == "date":
            # Sort by published date (newest entries first)
            # reverse=True puts most recent at top
            return sorted(entries, key=lambda e: e.published_at, reverse=True)
        if self.current_sort == "status":
            # Sort by read status (unread first), then by date (oldest first)
            # is_read sorts False (unread) before True (read)
            # reverse=False keeps oldest first within each status group
            return sorted(
                entries,
                key=lambda e: (e.is_read, e.published_at),
                reverse=False,
            )
        return entries

    def _filter_entries(self, entries: list[Entry]) -> list[Entry]:
        """Apply active filters to entries.

        Filters are applied in order:
        1. Search filter (if active)
        2. Status filters (unread/starred - mutually exclusive)

        Args:
            entries: List of entries to filter

        Returns:
            Filtered list of entries
        """
        # Apply search filter first if active
        if self.search_active and self.search_term:
            entries = self._filter_search(entries)

        # Apply status filters (mutually exclusive - only one can be active at a time)
        if self.filter_unread_only:
            # Show only unread entries
            return [e for e in entries if e.is_unread]
        if self.filter_starred_only:
            # Show only starred entries
            return [e for e in entries if e.starred]
        # No status filters active, return all entries (after search filter if applied)
        return entries

    def _filter_search(self, entries: list[Entry]) -> list[Entry]:
        """Filter entries by search term in title and content.

        Searches across both entry titles and HTML content. Search is case-insensitive.

        Args:
            entries: List of entries to search

        Returns:
            Filtered list of matching entries
        """
        search_lower = self.search_term.lower()
        return [e for e in entries if search_lower in e.title.lower() or search_lower in e.content.lower()]

    def _add_feed_header_if_needed(self, current_feed: str, first_feed_ref: list) -> None:
        """Add a feed header if transitioning to a new feed.

        Initializes fold state and creates a FeedHeaderItem for the new feed.

        Args:
            current_feed: Title of the current feed
            first_feed_ref: List with one element to track first feed (mutable ref pattern)
        """
        # Track first feed for default positioning
        if first_feed_ref[0] is None:
            first_feed_ref[0] = current_feed
            # Set default position to first feed if not already set
            if not self.last_highlighted_feed:
                self.last_highlighted_feed = first_feed_ref[0]

        # Initialize fold state for this feed if needed
        if current_feed not in self.feed_fold_state:
            # Default: expanded if not set, unless group_collapsed is True
            self.feed_fold_state[current_feed] = not self.group_collapsed

        # Create and add a fold-aware header item
        is_expanded = self.feed_fold_state[current_feed]
        header = FeedHeaderItem(current_feed, is_expanded=is_expanded)
        self.feed_header_map[current_feed] = header
        self.list_view.append(header)

    def _add_entry_with_visibility(self, entry: Entry) -> None:
        """Add an entry item with appropriate visibility based on feed state.

        Applies "collapsed" CSS class if the entry's feed is collapsed.

        Args:
            entry: The entry to add
        """
        item = EntryListItem(entry, self.unread_color, self.read_color)
        self.displayed_items.append(item)
        self.entry_item_map[entry.id] = item

        # Apply "collapsed" class if this feed is collapsed
        # We can safely access feed_fold_state since headers are created first
        if not self.feed_fold_state.get(entry.feed.title, not self.group_collapsed):
            item.add_class("collapsed")

        self.list_view.append(item)

    def _add_grouped_entries(self, entries: list[Entry]):
        """Add entries grouped by feed with optional collapsible headers.

        All entries are added to the list, but entries in collapsed feeds
        are hidden via CSS class. This preserves cursor position during expand/collapse.
        """
        current_feed = None
        first_feed = [None]  # Use list as mutable reference for tracking first feed
        self.displayed_items = []
        self.entry_item_map.clear()
        self.feed_header_map.clear()

        for entry in entries:
            # Add feed header if this is a new feed
            if current_feed != entry.feed.title:
                current_feed = entry.feed.title
                self._add_feed_header_if_needed(current_feed, first_feed)

            # Add the entry with appropriate visibility
            self._add_entry_with_visibility(entry)

    def _add_flat_entries(self, entries: list[Entry]):
        """Add entries as a flat list."""
        self.displayed_items = []
        self.entry_item_map.clear()
        for entry in entries:
            item = EntryListItem(entry, self.unread_color, self.read_color)
            self.displayed_items.append(item)
            self.entry_item_map[entry.id] = item
            self.list_view.append(item)

    def _get_category_title(self, category_id: int | None) -> str:
        """Get category title from category ID.

        Args:
            category_id: The category ID to lookup

        Returns:
            Category title, or "Uncategorized" if not found
        """
        if category_id is None:
            return "Uncategorized"

        if not self.categories:
            return f"Category {category_id}"

        for category in self.categories:
            if category.id == category_id:
                return category.title

        return f"Category {category_id}"

    def _add_category_header_if_needed(self, category_title: str, first_category_ref: list) -> None:
        """Add a category header if transitioning to a new category.

        Initializes fold state and creates a CategoryHeaderItem for the new category.

        Args:
            category_title: Title of the current category
            first_category_ref: List with one element to track first category (mutable ref pattern)
        """
        # Track first category for default positioning
        if first_category_ref[0] is None:
            first_category_ref[0] = category_title
            # Set default position to first category if not already set
            if not self.last_highlighted_category:
                self.last_highlighted_category = first_category_ref[0]

        # Initialize fold state for this category if needed
        if category_title not in self.category_fold_state:
            # Default: expanded if not set, unless group_collapsed is True
            self.category_fold_state[category_title] = not self.group_collapsed

        # Create and add a fold-aware header item
        is_expanded = self.category_fold_state[category_title]
        header = CategoryHeaderItem(category_title, is_expanded=is_expanded)
        self.category_header_map[category_title] = header
        self.list_view.append(header)

    def _add_entry_with_category_visibility(self, entry: Entry, category_title: str) -> None:
        """Add an entry item with appropriate visibility based on category state.

        Applies "collapsed" CSS class if the entry's category is collapsed.

        Args:
            entry: The entry to add
            category_title: Title of the category this entry belongs to
        """
        item = EntryListItem(entry, self.unread_color, self.read_color)
        self.displayed_items.append(item)
        self.entry_item_map[entry.id] = item

        # Apply "collapsed" class if this category is collapsed
        if not self.category_fold_state.get(category_title, not self.group_collapsed):
            item.add_class("collapsed")

        self.list_view.append(item)

    def _add_grouped_entries_by_category(self, entries: list[Entry]):
        """Add entries grouped by category with optional collapsible headers.

        All entries are added to the list, but entries in collapsed categories
        are hidden via CSS class. This preserves cursor position during expand/collapse.
        """
        current_category = None
        first_category = [None]  # Use list as mutable reference for tracking first category
        self.displayed_items = []
        self.entry_item_map.clear()
        self.category_header_map.clear()

        for entry in entries:
            # Get category title for this entry
            category_title = self._get_category_title(entry.feed.category_id)

            # Add category header if this is a new category
            if current_category != category_title:
                current_category = category_title
                self._add_category_header_if_needed(current_category, first_category)

            # Add the entry with appropriate visibility
            self._add_entry_with_category_visibility(entry, category_title)

    def _update_single_item(self, entry: Entry) -> bool:
        """Update a single entry item in the list (incremental refresh).

        This avoids rebuilding the entire list when only one entry changes.

        Args:
            entry: The entry to update

        Returns:
            True if item was updated, False if item not found or refresh needed
        """
        # Check if item is in the current view
        if entry.id not in self.entry_item_map:
            return False

        old_item = self.entry_item_map[entry.id]

        # Create new item with updated data
        new_item = EntryListItem(entry, self.unread_color, self.read_color)
        self.entry_item_map[entry.id] = new_item

        # Find the index of the old item in the list view
        try:
            children_list = list(self.list_view.children)
            index = children_list.index(old_item)
            # Remove the old item
            old_item.remove()
            # Get the item that's now at that position (if exists)
            current_children = list(self.list_view.children)
            # Mount new item before the item that's now at that index
            if index < len(current_children):
                self.list_view.mount(new_item, before=current_children[index])
            else:
                self.list_view.mount(new_item)
            # Update displayed_items if it's in there
            if old_item in self.displayed_items:
                item_index = self.displayed_items.index(old_item)
                self.displayed_items[item_index] = new_item
            self.refresh_optimizer.track_partial_refresh()
            return True
        except (ValueError, IndexError):
            return False

    def _is_item_visible(self, item: ListItem) -> bool:
        """Check if an item is visible (not hidden by CSS class)."""
        return "collapsed" not in item.classes

    def action_cursor_down(self):
        """Move cursor down to next visible entry item, skipping collapsed entries."""
        if not self.list_view or len(self.list_view.children) == 0:
            return

        try:
            current_index = self.list_view.index
            # If index is None, start searching from -1 so range(0, ...) includes index 0
            if current_index is None:
                current_index = -1

            # Move to next item and skip hidden ones
            for i in range(current_index + 1, len(self.list_view.children)):
                widget = self.list_view.children[i]
                if isinstance(widget, ListItem) and self._is_item_visible(widget):
                    self.list_view.index = i
                    return

            # If no visible item found below, stay at current position
        except (IndexError, ValueError, TypeError):
            # Silently ignore index errors when navigating beyond list bounds
            pass

    def action_cursor_up(self):
        """Move cursor up to previous visible entry item, skipping collapsed entries."""
        if not self.list_view or len(self.list_view.children) == 0:
            return

        try:
            current_index = self.list_view.index
            # If index is None, start from len so we search backwards from end
            if current_index is None:
                current_index = len(self.list_view.children)

            # Move to previous item and skip hidden ones
            for i in range(current_index - 1, -1, -1):
                widget = self.list_view.children[i]
                if isinstance(widget, ListItem) and self._is_item_visible(widget):
                    self.list_view.index = i
                    return

            # If no visible item found above, stay at current position
        except (IndexError, ValueError, TypeError):
            # Silently ignore index errors when navigating beyond list bounds
            pass

    async def action_toggle_read(self):
        """Toggle read/unread status of current entry."""
        if not self.list_view:
            return

        highlighted = self.list_view.highlighted_child
        if highlighted and isinstance(highlighted, EntryListItem):
            # Determine new status
            new_status = "read" if highlighted.entry.is_unread else "unread"

            # Use consistent error handling context
            async with api_call(self, f"marking entry as {new_status}") as client:
                # Call API to persist change
                await client.change_entry_status(highlighted.entry.id, new_status)

                # Update local state
                highlighted.entry.status = new_status

                # Try incremental update first; fall back to full refresh if needed
                if not self._update_single_item(highlighted.entry):
                    # Fall back to full refresh if incremental update fails
                    self._populate_list()

                # Notify user of success
                self.notify(f"Entry marked as {new_status}")

    async def action_toggle_star(self):
        """Toggle star status of current entry."""
        if not self.list_view:
            return

        highlighted = self.list_view.highlighted_child
        if highlighted and isinstance(highlighted, EntryListItem):
            # Use consistent error handling context
            async with api_call(self, "toggling star status") as client:
                # Call API to toggle star
                await client.toggle_starred(highlighted.entry.id)

                # Update local state
                highlighted.entry.starred = not highlighted.entry.starred

                # Try incremental update first; fall back to full refresh if needed
                if not self._update_single_item(highlighted.entry):
                    # Fall back to full refresh if incremental update fails
                    self._populate_list()

                # Notify user of success
                status = "starred" if highlighted.entry.starred else "unstarred"
                self.notify(f"Entry {status}")

    async def action_save_entry(self):
        """Save entry to third-party service."""
        if not self.list_view:
            return

        highlighted = self.list_view.highlighted_child
        if highlighted and isinstance(highlighted, EntryListItem):
            # Use consistent error handling context
            async with api_call(self, "saving entry") as client:
                await client.save_entry(highlighted.entry.id)
                self.notify(f"Entry saved: {highlighted.entry.title}")

    def action_cycle_sort(self):
        """Cycle through sort modes."""
        current_index = SORT_MODES.index(self.current_sort)
        self.current_sort = SORT_MODES[(current_index + 1) % len(SORT_MODES)]

        # Update title to show current sort
        self.sub_title = f"Sort: {self.current_sort.title()}"

        # Re-populate list
        self._populate_list()

    def action_toggle_group(self):
        """Toggle grouping by feed."""
        self.group_by_feed = not self.group_by_feed
        self._populate_list()

    def action_toggle_category_group(self):
        """Toggle grouping by category (Issue #54 - Category support)."""
        if not self.categories:
            self.notify("No categories available", severity="warning")
            return

        # Toggle category grouping
        self.group_by_category = not self.group_by_category

        # Disable feed grouping when category grouping is enabled
        if self.group_by_category:
            self.group_by_feed = False
            self.notify("Grouping by category")
        else:
            self.notify("Category grouping disabled")

        self._populate_list()

    def action_toggle_fold(self):
        """Toggle fold state of highlighted feed (only works in grouped mode)."""
        if not self.list_view or not self.group_by_feed:
            return

        highlighted = self.list_view.highlighted_child
        if highlighted and isinstance(highlighted, FeedHeaderItem):
            feed_title = highlighted.feed_title
            # Save current position
            self.last_highlighted_feed = feed_title
            # Toggle the fold state
            self.feed_fold_state[feed_title] = not self.feed_fold_state[feed_title]
            highlighted.toggle_fold()

            # Update CSS class for entries: toggle "collapsed" class
            self._update_feed_visibility(feed_title)

    def _update_feed_visibility(self, feed_title: str) -> None:
        """Update CSS visibility for all entries of a feed based on fold state.

        If feed is collapsed, adds 'collapsed' class to hide entries.
        If feed is expanded, removes 'collapsed' class to show entries.
        """
        is_expanded = self.feed_fold_state.get(feed_title, True)

        # Find all entries for this feed and update their CSS class
        for item in self.list_view.children:
            if isinstance(item, EntryListItem) and item.entry.feed.title == feed_title:
                if is_expanded:
                    item.remove_class("collapsed")
                else:
                    item.add_class("collapsed")

    def action_collapse_feed(self):
        """Collapse the highlighted feed (h or left arrow)."""
        if not self.list_view or not self.group_by_feed:
            return

        feed_title = self._get_highlighted_feed_title()
        if not feed_title:
            return

        # Save position for return from entry reader
        self.last_highlighted_feed = feed_title

        # Only collapse if currently expanded
        is_currently_expanded = self.feed_fold_state.get(feed_title, not self.group_collapsed)
        if is_currently_expanded:
            self._set_feed_fold_state(feed_title, False)

    def action_expand_feed(self):
        """Expand the highlighted feed (l or right arrow)."""
        if not self.list_view or not self.group_by_feed:
            return

        feed_title = self._get_highlighted_feed_title()
        if not feed_title:
            return

        # Save position for return from entry reader
        self.last_highlighted_feed = feed_title

        # Only expand if currently collapsed
        is_currently_collapsed = not self.feed_fold_state.get(feed_title, not self.group_collapsed)
        if is_currently_collapsed:
            self._set_feed_fold_state(feed_title, True)

    def action_expand_all(self):
        """Expand all feeds (Shift+G).

        If not in grouped mode, enable grouped mode first.
        Then expand all collapsed feeds.
        """
        if not self.list_view:
            return

        # If not in grouped mode, enable it first
        if not self.group_by_feed:
            self.action_toggle_group()
            return

        # Expand all feeds that are currently collapsed
        for feed_title in self.feed_fold_state:
            if not self.feed_fold_state[feed_title]:
                self._set_feed_fold_state(feed_title, True)

        self.notify("All feeds expanded")

    def action_collapse_all(self):
        """Collapse all feeds (Shift+Z)."""
        if not self.list_view or not self.group_by_feed:
            return

        # Collapse all feeds that are currently expanded
        for feed_title in self.feed_fold_state:
            if self.feed_fold_state[feed_title]:
                self._set_feed_fold_state(feed_title, False)

        self.notify("All feeds collapsed")

    async def action_refresh(self):
        """Refresh the current feed on the server (Issue #55 - Feed operations)."""
        if not hasattr(self.app, "client") or not self.app.client:
            self.notify("API client not initialized", severity="error")
            return

        # Get the currently highlighted entry to determine which feed to refresh
        if not self.list_view or self.list_view.index is None:
            self.notify("No entry selected", severity="warning")
            return

        highlighted = self.list_view.highlighted_child
        if not isinstance(highlighted, EntryListItem):
            self.notify("No entry selected", severity="warning")
            return

        try:
            feed_title = highlighted.entry.feed.title
            feed_id = highlighted.entry.feed_id

            self.notify(f"Refreshing feed: {feed_title}...")
            await self.app.client.refresh_feed(feed_id)
            self.notify(f"Feed '{feed_title}' refreshed on server")

            # Reload entries after refreshing the feed
            if hasattr(self.app, "load_entries"):
                self.notify("Reloading entries...")
                await self.app.load_entries(self.app.current_view)
                self.notify("Entries reloaded")
        except (ConnectionError, TimeoutError) as e:
            self.notify(f"Network error refreshing feed: {e}", severity="error")
        except Exception as e:
            self.notify(f"Error refreshing feed: {e}", severity="error")

    async def action_refresh_all_feeds(self):
        """Refresh all feeds on the server (Issue #55 - Feed operations)."""
        if not hasattr(self.app, "client") or not self.app.client:
            self.notify("API client not initialized", severity="error")
            return

        try:
            self.notify("Refreshing all feeds...")
            await self.app.client.refresh_all_feeds()
            self.notify("All feeds refreshed on server")

            # Reload entries after refreshing all feeds
            if hasattr(self.app, "load_entries"):
                self.notify("Reloading entries...")
                await self.app.load_entries(self.app.current_view)
                self.notify("Entries reloaded")
        except (ConnectionError, TimeoutError) as e:
            self.notify(f"Network error refreshing feeds: {e}", severity="error")
        except Exception as e:
            self.notify(f"Error refreshing all feeds: {e}", severity="error")

    async def action_show_unread(self):
        """Load and show only unread entries."""
        if hasattr(self.app, "load_entries"):
            await self.app.load_entries("unread")
            self.filter_unread_only = False
            self.filter_starred_only = False
            self._populate_list()

    async def action_show_starred(self):
        """Load and show only starred entries."""
        if hasattr(self.app, "load_entries"):
            await self.app.load_entries("starred")
            self.filter_unread_only = False
            self.filter_starred_only = False
            self._populate_list()

    def action_search(self):
        """Clear current search filter.

        Toggles search mode off and refreshes the display to show all entries.
        """
        # Clear any active search
        if self.search_active or self.search_term:
            self.search_active = False
            self.search_term = ""
            self._populate_list()
            self.notify("Search cleared")
        else:
            # Notify that search feature is available
            self.notify("Search: Use set_search_term() method to filter entries")

    def set_search_term(self, search_term: str) -> None:
        """Set search term and filter entries.

        Args:
            search_term: The search term to filter entries by (title or content)
        """
        self.search_term = search_term.strip()
        self.search_active = bool(self.search_term)
        self._populate_list()

        # Notify user of search results
        if self.search_active:
            result_count = len(self._filter_entries(self.entries))
            self.notify(f"Search: {result_count} entries match '{self.search_term}'")

    def action_show_help(self):
        """Show keyboard help."""
        self.app.push_screen("help")

    def action_show_status(self):
        """Show system status and feed health."""
        self.app.push_screen("status")

    def action_quit(self):
        """Quit the application."""
        self.app.exit()

app property

Get the app instance with proper type hints.

action_collapse_all()

Collapse all feeds (Shift+Z).

Source code in miniflux_tui/ui/screens/entry_list.py
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
def action_collapse_all(self):
    """Collapse all feeds (Shift+Z)."""
    if not self.list_view or not self.group_by_feed:
        return

    # Collapse all feeds that are currently expanded
    for feed_title in self.feed_fold_state:
        if self.feed_fold_state[feed_title]:
            self._set_feed_fold_state(feed_title, False)

    self.notify("All feeds collapsed")

action_collapse_feed()

Collapse the highlighted feed (h or left arrow).

Source code in miniflux_tui/ui/screens/entry_list.py
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
def action_collapse_feed(self):
    """Collapse the highlighted feed (h or left arrow)."""
    if not self.list_view or not self.group_by_feed:
        return

    feed_title = self._get_highlighted_feed_title()
    if not feed_title:
        return

    # Save position for return from entry reader
    self.last_highlighted_feed = feed_title

    # Only collapse if currently expanded
    is_currently_expanded = self.feed_fold_state.get(feed_title, not self.group_collapsed)
    if is_currently_expanded:
        self._set_feed_fold_state(feed_title, False)

action_cursor_down()

Move cursor down to next visible entry item, skipping collapsed entries.

Source code in miniflux_tui/ui/screens/entry_list.py
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
def action_cursor_down(self):
    """Move cursor down to next visible entry item, skipping collapsed entries."""
    if not self.list_view or len(self.list_view.children) == 0:
        return

    try:
        current_index = self.list_view.index
        # If index is None, start searching from -1 so range(0, ...) includes index 0
        if current_index is None:
            current_index = -1

        # Move to next item and skip hidden ones
        for i in range(current_index + 1, len(self.list_view.children)):
            widget = self.list_view.children[i]
            if isinstance(widget, ListItem) and self._is_item_visible(widget):
                self.list_view.index = i
                return

        # If no visible item found below, stay at current position
    except (IndexError, ValueError, TypeError):
        # Silently ignore index errors when navigating beyond list bounds
        pass

action_cursor_up()

Move cursor up to previous visible entry item, skipping collapsed entries.

Source code in miniflux_tui/ui/screens/entry_list.py
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
def action_cursor_up(self):
    """Move cursor up to previous visible entry item, skipping collapsed entries."""
    if not self.list_view or len(self.list_view.children) == 0:
        return

    try:
        current_index = self.list_view.index
        # If index is None, start from len so we search backwards from end
        if current_index is None:
            current_index = len(self.list_view.children)

        # Move to previous item and skip hidden ones
        for i in range(current_index - 1, -1, -1):
            widget = self.list_view.children[i]
            if isinstance(widget, ListItem) and self._is_item_visible(widget):
                self.list_view.index = i
                return

        # If no visible item found above, stay at current position
    except (IndexError, ValueError, TypeError):
        # Silently ignore index errors when navigating beyond list bounds
        pass

action_cycle_sort()

Cycle through sort modes.

Source code in miniflux_tui/ui/screens/entry_list.py
877
878
879
880
881
882
883
884
885
886
def action_cycle_sort(self):
    """Cycle through sort modes."""
    current_index = SORT_MODES.index(self.current_sort)
    self.current_sort = SORT_MODES[(current_index + 1) % len(SORT_MODES)]

    # Update title to show current sort
    self.sub_title = f"Sort: {self.current_sort.title()}"

    # Re-populate list
    self._populate_list()

action_expand_all()

Expand all feeds (Shift+G).

If not in grouped mode, enable grouped mode first. Then expand all collapsed feeds.

Source code in miniflux_tui/ui/screens/entry_list.py
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
def action_expand_all(self):
    """Expand all feeds (Shift+G).

    If not in grouped mode, enable grouped mode first.
    Then expand all collapsed feeds.
    """
    if not self.list_view:
        return

    # If not in grouped mode, enable it first
    if not self.group_by_feed:
        self.action_toggle_group()
        return

    # Expand all feeds that are currently collapsed
    for feed_title in self.feed_fold_state:
        if not self.feed_fold_state[feed_title]:
            self._set_feed_fold_state(feed_title, True)

    self.notify("All feeds expanded")

action_expand_feed()

Expand the highlighted feed (l or right arrow).

Source code in miniflux_tui/ui/screens/entry_list.py
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
def action_expand_feed(self):
    """Expand the highlighted feed (l or right arrow)."""
    if not self.list_view or not self.group_by_feed:
        return

    feed_title = self._get_highlighted_feed_title()
    if not feed_title:
        return

    # Save position for return from entry reader
    self.last_highlighted_feed = feed_title

    # Only expand if currently collapsed
    is_currently_collapsed = not self.feed_fold_state.get(feed_title, not self.group_collapsed)
    if is_currently_collapsed:
        self._set_feed_fold_state(feed_title, True)

action_quit()

Quit the application.

Source code in miniflux_tui/ui/screens/entry_list.py
1120
1121
1122
def action_quit(self):
    """Quit the application."""
    self.app.exit()

action_refresh() async

Refresh the current feed on the server (Issue #55 - Feed operations).

Source code in miniflux_tui/ui/screens/entry_list.py
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
async def action_refresh(self):
    """Refresh the current feed on the server (Issue #55 - Feed operations)."""
    if not hasattr(self.app, "client") or not self.app.client:
        self.notify("API client not initialized", severity="error")
        return

    # Get the currently highlighted entry to determine which feed to refresh
    if not self.list_view or self.list_view.index is None:
        self.notify("No entry selected", severity="warning")
        return

    highlighted = self.list_view.highlighted_child
    if not isinstance(highlighted, EntryListItem):
        self.notify("No entry selected", severity="warning")
        return

    try:
        feed_title = highlighted.entry.feed.title
        feed_id = highlighted.entry.feed_id

        self.notify(f"Refreshing feed: {feed_title}...")
        await self.app.client.refresh_feed(feed_id)
        self.notify(f"Feed '{feed_title}' refreshed on server")

        # Reload entries after refreshing the feed
        if hasattr(self.app, "load_entries"):
            self.notify("Reloading entries...")
            await self.app.load_entries(self.app.current_view)
            self.notify("Entries reloaded")
    except (ConnectionError, TimeoutError) as e:
        self.notify(f"Network error refreshing feed: {e}", severity="error")
    except Exception as e:
        self.notify(f"Error refreshing feed: {e}", severity="error")

action_refresh_all_feeds() async

Refresh all feeds on the server (Issue #55 - Feed operations).

Source code in miniflux_tui/ui/screens/entry_list.py
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
async def action_refresh_all_feeds(self):
    """Refresh all feeds on the server (Issue #55 - Feed operations)."""
    if not hasattr(self.app, "client") or not self.app.client:
        self.notify("API client not initialized", severity="error")
        return

    try:
        self.notify("Refreshing all feeds...")
        await self.app.client.refresh_all_feeds()
        self.notify("All feeds refreshed on server")

        # Reload entries after refreshing all feeds
        if hasattr(self.app, "load_entries"):
            self.notify("Reloading entries...")
            await self.app.load_entries(self.app.current_view)
            self.notify("Entries reloaded")
    except (ConnectionError, TimeoutError) as e:
        self.notify(f"Network error refreshing feeds: {e}", severity="error")
    except Exception as e:
        self.notify(f"Error refreshing all feeds: {e}", severity="error")

action_save_entry() async

Save entry to third-party service.

Source code in miniflux_tui/ui/screens/entry_list.py
865
866
867
868
869
870
871
872
873
874
875
async def action_save_entry(self):
    """Save entry to third-party service."""
    if not self.list_view:
        return

    highlighted = self.list_view.highlighted_child
    if highlighted and isinstance(highlighted, EntryListItem):
        # Use consistent error handling context
        async with api_call(self, "saving entry") as client:
            await client.save_entry(highlighted.entry.id)
            self.notify(f"Entry saved: {highlighted.entry.title}")

Clear current search filter.

Toggles search mode off and refreshes the display to show all entries.

Source code in miniflux_tui/ui/screens/entry_list.py
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
def action_search(self):
    """Clear current search filter.

    Toggles search mode off and refreshes the display to show all entries.
    """
    # Clear any active search
    if self.search_active or self.search_term:
        self.search_active = False
        self.search_term = ""
        self._populate_list()
        self.notify("Search cleared")
    else:
        # Notify that search feature is available
        self.notify("Search: Use set_search_term() method to filter entries")

action_show_help()

Show keyboard help.

Source code in miniflux_tui/ui/screens/entry_list.py
1112
1113
1114
def action_show_help(self):
    """Show keyboard help."""
    self.app.push_screen("help")

action_show_starred() async

Load and show only starred entries.

Source code in miniflux_tui/ui/screens/entry_list.py
1074
1075
1076
1077
1078
1079
1080
async def action_show_starred(self):
    """Load and show only starred entries."""
    if hasattr(self.app, "load_entries"):
        await self.app.load_entries("starred")
        self.filter_unread_only = False
        self.filter_starred_only = False
        self._populate_list()

action_show_status()

Show system status and feed health.

Source code in miniflux_tui/ui/screens/entry_list.py
1116
1117
1118
def action_show_status(self):
    """Show system status and feed health."""
    self.app.push_screen("status")

action_show_unread() async

Load and show only unread entries.

Source code in miniflux_tui/ui/screens/entry_list.py
1066
1067
1068
1069
1070
1071
1072
async def action_show_unread(self):
    """Load and show only unread entries."""
    if hasattr(self.app, "load_entries"):
        await self.app.load_entries("unread")
        self.filter_unread_only = False
        self.filter_starred_only = False
        self._populate_list()

action_toggle_category_group()

Toggle grouping by category (Issue #54 - Category support).

Source code in miniflux_tui/ui/screens/entry_list.py
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
def action_toggle_category_group(self):
    """Toggle grouping by category (Issue #54 - Category support)."""
    if not self.categories:
        self.notify("No categories available", severity="warning")
        return

    # Toggle category grouping
    self.group_by_category = not self.group_by_category

    # Disable feed grouping when category grouping is enabled
    if self.group_by_category:
        self.group_by_feed = False
        self.notify("Grouping by category")
    else:
        self.notify("Category grouping disabled")

    self._populate_list()

action_toggle_fold()

Toggle fold state of highlighted feed (only works in grouped mode).

Source code in miniflux_tui/ui/screens/entry_list.py
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
def action_toggle_fold(self):
    """Toggle fold state of highlighted feed (only works in grouped mode)."""
    if not self.list_view or not self.group_by_feed:
        return

    highlighted = self.list_view.highlighted_child
    if highlighted and isinstance(highlighted, FeedHeaderItem):
        feed_title = highlighted.feed_title
        # Save current position
        self.last_highlighted_feed = feed_title
        # Toggle the fold state
        self.feed_fold_state[feed_title] = not self.feed_fold_state[feed_title]
        highlighted.toggle_fold()

        # Update CSS class for entries: toggle "collapsed" class
        self._update_feed_visibility(feed_title)

action_toggle_group()

Toggle grouping by feed.

Source code in miniflux_tui/ui/screens/entry_list.py
888
889
890
891
def action_toggle_group(self):
    """Toggle grouping by feed."""
    self.group_by_feed = not self.group_by_feed
    self._populate_list()

action_toggle_read() async

Toggle read/unread status of current entry.

Source code in miniflux_tui/ui/screens/entry_list.py
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
async def action_toggle_read(self):
    """Toggle read/unread status of current entry."""
    if not self.list_view:
        return

    highlighted = self.list_view.highlighted_child
    if highlighted and isinstance(highlighted, EntryListItem):
        # Determine new status
        new_status = "read" if highlighted.entry.is_unread else "unread"

        # Use consistent error handling context
        async with api_call(self, f"marking entry as {new_status}") as client:
            # Call API to persist change
            await client.change_entry_status(highlighted.entry.id, new_status)

            # Update local state
            highlighted.entry.status = new_status

            # Try incremental update first; fall back to full refresh if needed
            if not self._update_single_item(highlighted.entry):
                # Fall back to full refresh if incremental update fails
                self._populate_list()

            # Notify user of success
            self.notify(f"Entry marked as {new_status}")

action_toggle_star() async

Toggle star status of current entry.

Source code in miniflux_tui/ui/screens/entry_list.py
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
async def action_toggle_star(self):
    """Toggle star status of current entry."""
    if not self.list_view:
        return

    highlighted = self.list_view.highlighted_child
    if highlighted and isinstance(highlighted, EntryListItem):
        # Use consistent error handling context
        async with api_call(self, "toggling star status") as client:
            # Call API to toggle star
            await client.toggle_starred(highlighted.entry.id)

            # Update local state
            highlighted.entry.starred = not highlighted.entry.starred

            # Try incremental update first; fall back to full refresh if needed
            if not self._update_single_item(highlighted.entry):
                # Fall back to full refresh if incremental update fails
                self._populate_list()

            # Notify user of success
            status = "starred" if highlighted.entry.starred else "unstarred"
            self.notify(f"Entry {status}")

compose()

Create child widgets.

Source code in miniflux_tui/ui/screens/entry_list.py
170
171
172
173
174
def compose(self) -> ComposeResult:
    """Create child widgets."""
    yield Header()
    yield ListView()
    yield Footer()

on_list_view_selected(event)

Handle ListView selection (Enter key).

Source code in miniflux_tui/ui/screens/entry_list.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
def on_list_view_selected(self, event: ListView.Selected) -> None:
    """Handle ListView selection (Enter key)."""
    # Get the selected item
    if event.item and isinstance(event.item, FeedHeaderItem):
        # When Enter is pressed on a feed header in grouped mode, open the first entry
        feed_title = event.item.feed_title
        # Find the first visible (or any) entry in this feed
        for entry in self.sorted_entries:
            if entry.feed.title == feed_title:
                # Save the feed of the current entry for position restoration
                self.last_highlighted_feed = feed_title
                self.last_highlighted_entry_id = entry.id

                # Find the index of this entry in the sorted entry list
                entry_index = 0
                for i, e in enumerate(self.sorted_entries):
                    if e.id == entry.id:
                        entry_index = i
                        break

                # Open entry reader screen with navigation context
                if isinstance(self.app, self.app.__class__) and hasattr(self.app, "push_entry_reader"):
                    self.app.push_entry_reader(entry=entry, entry_list=self.sorted_entries, current_index=entry_index)
                return

    elif event.item and isinstance(event.item, EntryListItem):
        # Save the feed of the current entry for position restoration
        self.last_highlighted_feed = event.item.entry.feed.title
        self.last_highlighted_entry_id = event.item.entry.id

        # Save the cursor index in the list view
        if self.list_view and self.list_view.index is not None:
            self.last_cursor_index = self.list_view.index

        # Find the index of this entry in the sorted entry list
        entry_index = 0
        for i, entry in enumerate(self.sorted_entries):
            if entry.id == event.item.entry.id:
                entry_index = i
                break

        # Open entry reader screen with navigation context
        if isinstance(self.app, self.app.__class__) and hasattr(self.app, "push_entry_reader"):
            self.app.push_entry_reader(entry=event.item.entry, entry_list=self.sorted_entries, current_index=entry_index)

on_mount()

Called when screen is mounted.

Source code in miniflux_tui/ui/screens/entry_list.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
def on_mount(self) -> None:
    """Called when screen is mounted."""
    # Get reference to the ListView after it's mounted
    self.list_view = self.query_one(ListView)
    self.log(f"on_mount: list_view is now {self.list_view}")

    # Only populate if we have entries
    if self.entries:
        self.log(f"on_mount: Populating with {len(self.entries)} entries")
        self._populate_list()
        # Use call_later to defer focus and cursor restoration until ListView has updated
        if self.group_by_feed:
            self.call_later(self._restore_cursor_position_and_focus)
        else:
            self.call_later(self._ensure_focus)
    else:
        self.log("on_mount: No entries yet, skipping initial population")

on_screen_resume()

Called when screen is resumed (e.g., after returning from entry reader).

Source code in miniflux_tui/ui/screens/entry_list.py
194
195
196
197
198
199
200
201
202
203
204
def on_screen_resume(self) -> None:
    """Called when screen is resumed (e.g., after returning from entry reader)."""
    # Refresh the list to reflect any status changes
    if self.entries and self.list_view:
        self._populate_list()
        # Use call_later to defer focus and cursor restoration until ListView has updated
        # Always restore cursor position to maintain user's navigation context
        self.call_later(self._restore_cursor_position_and_focus)
    elif self.list_view and len(self.list_view.children) > 0:
        # If no entries, just ensure focus
        self.call_later(self._ensure_focus)

set_search_term(search_term)

Set search term and filter entries.

Parameters:

Name Type Description Default
search_term str

The search term to filter entries by (title or content)

required
Source code in miniflux_tui/ui/screens/entry_list.py
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
def set_search_term(self, search_term: str) -> None:
    """Set search term and filter entries.

    Args:
        search_term: The search term to filter entries by (title or content)
    """
    self.search_term = search_term.strip()
    self.search_active = bool(self.search_term)
    self._populate_list()

    # Notify user of search results
    if self.search_active:
        result_count = len(self._filter_entries(self.entries))
        self.notify(f"Search: {result_count} entries match '{self.search_term}'")

Key Features

  • Entry display: Shows entries with status icons and formatting
  • Sorting: Multiple sort modes (date, feed, status)
  • Grouping: Optional grouping by feed with expand/collapse
  • Filtering: Filter to unread or starred entries only
  • Navigation: Vim-style cursor movement (j/k)

Actions

Method Binding Description
action_cursor_down j Move cursor down
action_cursor_up k Move cursor up
action_select_entry Enter Open entry in reader
action_toggle_read m Toggle read/unread
action_toggle_star * Toggle star status
action_cycle_sort s Cycle sort modes
action_toggle_group g Toggle grouping
action_expand_feed l Expand feed
action_collapse_feed h Collapse feed
action_refresh r Refresh entries

EntryReaderScreen

The detailed view for reading a single entry.

Bases: Screen

Screen for reading a single feed entry.

Source code in miniflux_tui/ui/screens/entry_reader.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
class EntryReaderScreen(Screen):
    """Screen for reading a single feed entry."""

    BINDINGS: list[Binding] = [  # noqa: RUF012
        Binding("j", "scroll_down", "Scroll Down", show=False),
        Binding("k", "scroll_up", "Scroll Up", show=False),
        Binding("J", "next_entry", "Next Entry", show=True),
        Binding("K", "previous_entry", "Previous Entry", show=True),
        Binding("pagedown", "page_down", "Page Down"),
        Binding("pageup", "page_up", "Page Up"),
        Binding("b", "back", "Back to List"),
        Binding("u", "mark_unread", "Mark Unread"),
        Binding("asterisk", "toggle_star", "Toggle Star"),
        Binding("e", "save_entry", "Save Entry"),
        Binding("o", "open_browser", "Open in Browser"),
        Binding("f", "fetch_original", "Fetch Original"),
        Binding("question_mark", "show_help", "Help"),
        Binding("i", "show_status", "Status"),
        Binding("q", "quit", "Quit"),
        Binding("escape", "back", "Back", show=False),
    ]

    def __init__(
        self,
        entry: Entry,
        entry_list: list | None = None,
        current_index: int = 0,
        unread_color: str = "cyan",
        read_color: str = "gray",
        **kwargs,
    ):
        super().__init__(**kwargs)
        self.entry = entry
        self.entry_list = entry_list or []
        self.current_index = current_index
        self.unread_color = unread_color
        self.read_color = read_color
        self.scroll_container = None

    @property
    def app(self) -> "MinifluxTUI":
        """Get the app instance with proper type hints."""
        return cast("MinifluxTUI", super().app)

    def compose(self) -> ComposeResult:
        """Create child widgets."""
        yield Header()

        # Entry metadata
        star_icon = get_star_icon(self.entry.starred)

        # Create scrollable container with entry content
        with VerticalScroll():
            # Title and metadata
            yield Static(
                f"[bold cyan]{star_icon} {self.entry.title}[/bold cyan]",
                classes="entry-title",
            )
            yield Static(
                f"[dim]{self.entry.feed.title} | {self.entry.published_at.strftime('%Y-%m-%d %H:%M')}[/dim]",
                classes="entry-meta",
            )
            yield Static(f"[dim]{self.entry.url}[/dim]", classes="entry-url")
            yield Static(CONTENT_SEPARATOR, classes="separator")

            # Convert HTML content to markdown for better display
            content = self._html_to_markdown(self.entry.content)
            yield Markdown(content, classes="entry-content")

        yield Footer()

    async def on_mount(self) -> None:
        """Called when screen is mounted."""
        # Get reference to the scroll container after mount
        self.scroll_container = self.query_one(VerticalScroll)

        # Mark entry as read when opened
        if self.entry.is_unread:
            await self._mark_entry_as_read()

    async def _mark_entry_as_read(self):
        """Mark the current entry as read via API."""
        if hasattr(self.app, "client") and self.app.client:
            try:
                await self.app.client.mark_as_read(self.entry.id)
                self.entry.status = "read"
            except Exception as e:
                self.log(f"Error marking as read: {e}")
                self.log(traceback.format_exc())
                self.notify(f"Error marking as read: {e}", severity="error")

    def _html_to_markdown(self, html_content: str) -> str:
        """Convert HTML content to markdown for display.

        Converts HTML from RSS feed entries to markdown format for better
        terminal display. Preserves links, images, and formatting information.

        Args:
            html_content: Raw HTML content from the entry

        Returns:
            Markdown-formatted string suitable for terminal display
        """
        h = html2text.HTML2Text()
        # Preserve links, images, and emphasis in the output
        h.ignore_links = False
        h.ignore_images = False
        h.ignore_emphasis = False
        # Disable body width wrapping - let Textual handle terminal wrapping
        h.body_width = 0
        return h.handle(html_content)

    def _ensure_scroll_container(self) -> VerticalScroll:
        """Ensure scroll container is initialized and return it.

        Lazily initializes the scroll container reference if not already set.
        This eliminates the repeated pattern of checking and initializing
        the scroll container across multiple scroll action methods.

        Returns:
            The VerticalScroll container widget
        """
        if not self.scroll_container:
            self.scroll_container = self.query_one(VerticalScroll)
        return self.scroll_container

    def action_scroll_down(self):
        """Scroll down one line."""
        self._ensure_scroll_container().scroll_down()

    def action_scroll_up(self):
        """Scroll up one line."""
        self._ensure_scroll_container().scroll_up()

    def action_page_down(self):
        """Scroll down one page."""
        self._ensure_scroll_container().scroll_page_down()

    def action_page_up(self):
        """Scroll up one page."""
        self._ensure_scroll_container().scroll_page_up()

    def action_back(self):
        """Return to entry list."""
        self.app.pop_screen()

    async def action_mark_unread(self):
        """Mark entry as unread."""
        if hasattr(self.app, "client") and self.app.client:
            try:
                await self.app.client.mark_as_unread(self.entry.id)
                self.entry.status = "unread"
                self.notify("Marked as unread")
            except Exception as e:
                self.notify(f"Error marking as unread: {e}", severity="error")

    async def action_toggle_star(self):
        """Toggle star status."""
        if hasattr(self.app, "client") and self.app.client:
            try:
                await self.app.client.toggle_starred(self.entry.id)
                self.entry.starred = not self.entry.starred
                status = "starred" if self.entry.starred else "unstarred"
                self.notify(f"Entry {status}")

                # Refresh display to update star icon
                await self.refresh_screen()
            except Exception as e:
                self.notify(f"Error toggling star: {e}", severity="error")

    async def action_save_entry(self):
        """Save entry to third-party service."""
        if hasattr(self.app, "client") and self.app.client:
            try:
                await self.app.client.save_entry(self.entry.id)
                self.notify(f"Entry saved: {self.entry.title}")
            except Exception as e:
                self.notify(f"Failed to save entry: {e}", severity="error")

    def action_open_browser(self):
        """Open entry URL in web browser."""
        try:
            webbrowser.open(self.entry.url)
            self.notify(f"Opened in browser: {self.entry.url}")
        except Exception as e:
            self.notify(f"Error opening browser: {e}", severity="error")

    async def action_fetch_original(self):
        """Fetch original content from source."""
        if hasattr(self.app, "client") and self.app.client:
            try:
                self.notify("Fetching original content...")

                # Fetch original content from API
                original_content = await self.app.client.fetch_original_content(self.entry.id)

                if original_content:
                    # Update the entry's content
                    self.entry.content = original_content

                    # Refresh the screen to show new content
                    await self.refresh_screen()

                    self.notify("Original content loaded")
                else:
                    self.notify("No original content available", severity="warning")
            except Exception as e:
                self.log(f"Error fetching original content: {e}")
                self.log(traceback.format_exc())
                self.notify(f"Error fetching content: {e}", severity="error")

    async def action_next_entry(self):
        """Navigate to next entry."""
        if not self.entry_list or self.current_index >= len(self.entry_list) - 1:
            self.notify("No next entry", severity="warning")
            return

        # Move to next entry
        self.current_index += 1
        self.entry = self.entry_list[self.current_index]

        # Refresh the screen with new entry
        await self.refresh_screen()

    async def action_previous_entry(self):
        """Navigate to previous entry."""
        if not self.entry_list or self.current_index <= 0:
            self.notify("No previous entry", severity="warning")
            return

        # Move to previous entry
        self.current_index -= 1
        self.entry = self.entry_list[self.current_index]

        # Refresh the screen with new entry
        await self.refresh_screen()

    async def refresh_screen(self):
        """Refresh the screen with current entry."""
        scroll = self._get_scroll_container()
        self._clear_scroll_content(scroll)
        self._mount_entry_content(scroll)
        scroll.scroll_home(animate=False)

        # Mark as read after displaying
        if self.entry.is_unread:
            await self._mark_entry_as_read()

    def _get_scroll_container(self) -> VerticalScroll:
        """Get scroll container widget.

        Deprecated: Use _ensure_scroll_container() instead. This method
        is kept for backward compatibility and delegates to the new helper.
        """
        return self._ensure_scroll_container()

    def _clear_scroll_content(self, scroll: VerticalScroll):
        """Remove all children from scroll container."""
        for child in scroll.children:
            child.remove()

    def _mount_entry_content(self, scroll: VerticalScroll):
        """Mount entry content widgets (title, metadata, URL, content)."""
        self._mount_title(scroll)
        self._mount_metadata(scroll)
        self._mount_url(scroll)
        self._mount_separator(scroll)
        self._mount_content(scroll)

    def _mount_title(self, scroll: VerticalScroll):
        """Mount entry title widget with star icon."""
        star_icon = get_star_icon(self.entry.starred)
        scroll.mount(
            Static(
                f"[bold cyan]{star_icon} {self.entry.title}[/bold cyan]",
                classes="entry-title",
            )
        )

    def _mount_metadata(self, scroll: VerticalScroll):
        """Mount entry metadata widget (feed name and published date)."""
        scroll.mount(
            Static(
                f"[dim]{self.entry.feed.title} | {self.entry.published_at.strftime('%Y-%m-%d %H:%M')}[/dim]",
                classes="entry-meta",
            )
        )

    def _mount_url(self, scroll: VerticalScroll):
        """Mount entry URL widget."""
        scroll.mount(Static(f"[dim]{self.entry.url}[/dim]", classes="entry-url"))

    def _mount_separator(self, scroll: VerticalScroll):
        """Mount visual separator widget."""
        scroll.mount(Static(CONTENT_SEPARATOR, classes="separator"))

    def _mount_content(self, scroll: VerticalScroll):
        """Mount entry content widget (converted HTML to Markdown)."""
        content = self._html_to_markdown(self.entry.content)
        scroll.mount(Markdown(content, classes="entry-content"))

    def action_show_help(self):
        """Show keyboard help."""
        self.app.push_screen("help")

    def action_show_status(self):
        """Show system status and feed health."""
        self.app.push_screen("status")

    def action_quit(self):
        """Quit the application."""
        self.app.exit()

app property

Get the app instance with proper type hints.

action_back()

Return to entry list.

Source code in miniflux_tui/ui/screens/entry_reader.py
164
165
166
def action_back(self):
    """Return to entry list."""
    self.app.pop_screen()

action_fetch_original() async

Fetch original content from source.

Source code in miniflux_tui/ui/screens/entry_reader.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
async def action_fetch_original(self):
    """Fetch original content from source."""
    if hasattr(self.app, "client") and self.app.client:
        try:
            self.notify("Fetching original content...")

            # Fetch original content from API
            original_content = await self.app.client.fetch_original_content(self.entry.id)

            if original_content:
                # Update the entry's content
                self.entry.content = original_content

                # Refresh the screen to show new content
                await self.refresh_screen()

                self.notify("Original content loaded")
            else:
                self.notify("No original content available", severity="warning")
        except Exception as e:
            self.log(f"Error fetching original content: {e}")
            self.log(traceback.format_exc())
            self.notify(f"Error fetching content: {e}", severity="error")

action_mark_unread() async

Mark entry as unread.

Source code in miniflux_tui/ui/screens/entry_reader.py
168
169
170
171
172
173
174
175
176
async def action_mark_unread(self):
    """Mark entry as unread."""
    if hasattr(self.app, "client") and self.app.client:
        try:
            await self.app.client.mark_as_unread(self.entry.id)
            self.entry.status = "unread"
            self.notify("Marked as unread")
        except Exception as e:
            self.notify(f"Error marking as unread: {e}", severity="error")

action_next_entry() async

Navigate to next entry.

Source code in miniflux_tui/ui/screens/entry_reader.py
233
234
235
236
237
238
239
240
241
242
243
244
async def action_next_entry(self):
    """Navigate to next entry."""
    if not self.entry_list or self.current_index >= len(self.entry_list) - 1:
        self.notify("No next entry", severity="warning")
        return

    # Move to next entry
    self.current_index += 1
    self.entry = self.entry_list[self.current_index]

    # Refresh the screen with new entry
    await self.refresh_screen()

action_open_browser()

Open entry URL in web browser.

Source code in miniflux_tui/ui/screens/entry_reader.py
201
202
203
204
205
206
207
def action_open_browser(self):
    """Open entry URL in web browser."""
    try:
        webbrowser.open(self.entry.url)
        self.notify(f"Opened in browser: {self.entry.url}")
    except Exception as e:
        self.notify(f"Error opening browser: {e}", severity="error")

action_page_down()

Scroll down one page.

Source code in miniflux_tui/ui/screens/entry_reader.py
156
157
158
def action_page_down(self):
    """Scroll down one page."""
    self._ensure_scroll_container().scroll_page_down()

action_page_up()

Scroll up one page.

Source code in miniflux_tui/ui/screens/entry_reader.py
160
161
162
def action_page_up(self):
    """Scroll up one page."""
    self._ensure_scroll_container().scroll_page_up()

action_previous_entry() async

Navigate to previous entry.

Source code in miniflux_tui/ui/screens/entry_reader.py
246
247
248
249
250
251
252
253
254
255
256
257
async def action_previous_entry(self):
    """Navigate to previous entry."""
    if not self.entry_list or self.current_index <= 0:
        self.notify("No previous entry", severity="warning")
        return

    # Move to previous entry
    self.current_index -= 1
    self.entry = self.entry_list[self.current_index]

    # Refresh the screen with new entry
    await self.refresh_screen()

action_quit()

Quit the application.

Source code in miniflux_tui/ui/screens/entry_reader.py
331
332
333
def action_quit(self):
    """Quit the application."""
    self.app.exit()

action_save_entry() async

Save entry to third-party service.

Source code in miniflux_tui/ui/screens/entry_reader.py
192
193
194
195
196
197
198
199
async def action_save_entry(self):
    """Save entry to third-party service."""
    if hasattr(self.app, "client") and self.app.client:
        try:
            await self.app.client.save_entry(self.entry.id)
            self.notify(f"Entry saved: {self.entry.title}")
        except Exception as e:
            self.notify(f"Failed to save entry: {e}", severity="error")

action_scroll_down()

Scroll down one line.

Source code in miniflux_tui/ui/screens/entry_reader.py
148
149
150
def action_scroll_down(self):
    """Scroll down one line."""
    self._ensure_scroll_container().scroll_down()

action_scroll_up()

Scroll up one line.

Source code in miniflux_tui/ui/screens/entry_reader.py
152
153
154
def action_scroll_up(self):
    """Scroll up one line."""
    self._ensure_scroll_container().scroll_up()

action_show_help()

Show keyboard help.

Source code in miniflux_tui/ui/screens/entry_reader.py
323
324
325
def action_show_help(self):
    """Show keyboard help."""
    self.app.push_screen("help")

action_show_status()

Show system status and feed health.

Source code in miniflux_tui/ui/screens/entry_reader.py
327
328
329
def action_show_status(self):
    """Show system status and feed health."""
    self.app.push_screen("status")

action_toggle_star() async

Toggle star status.

Source code in miniflux_tui/ui/screens/entry_reader.py
178
179
180
181
182
183
184
185
186
187
188
189
190
async def action_toggle_star(self):
    """Toggle star status."""
    if hasattr(self.app, "client") and self.app.client:
        try:
            await self.app.client.toggle_starred(self.entry.id)
            self.entry.starred = not self.entry.starred
            status = "starred" if self.entry.starred else "unstarred"
            self.notify(f"Entry {status}")

            # Refresh display to update star icon
            await self.refresh_screen()
        except Exception as e:
            self.notify(f"Error toggling star: {e}", severity="error")

compose()

Create child widgets.

Source code in miniflux_tui/ui/screens/entry_reader.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def compose(self) -> ComposeResult:
    """Create child widgets."""
    yield Header()

    # Entry metadata
    star_icon = get_star_icon(self.entry.starred)

    # Create scrollable container with entry content
    with VerticalScroll():
        # Title and metadata
        yield Static(
            f"[bold cyan]{star_icon} {self.entry.title}[/bold cyan]",
            classes="entry-title",
        )
        yield Static(
            f"[dim]{self.entry.feed.title} | {self.entry.published_at.strftime('%Y-%m-%d %H:%M')}[/dim]",
            classes="entry-meta",
        )
        yield Static(f"[dim]{self.entry.url}[/dim]", classes="entry-url")
        yield Static(CONTENT_SEPARATOR, classes="separator")

        # Convert HTML content to markdown for better display
        content = self._html_to_markdown(self.entry.content)
        yield Markdown(content, classes="entry-content")

    yield Footer()

on_mount() async

Called when screen is mounted.

Source code in miniflux_tui/ui/screens/entry_reader.py
 93
 94
 95
 96
 97
 98
 99
100
async def on_mount(self) -> None:
    """Called when screen is mounted."""
    # Get reference to the scroll container after mount
    self.scroll_container = self.query_one(VerticalScroll)

    # Mark entry as read when opened
    if self.entry.is_unread:
        await self._mark_entry_as_read()

refresh_screen() async

Refresh the screen with current entry.

Source code in miniflux_tui/ui/screens/entry_reader.py
259
260
261
262
263
264
265
266
267
268
async def refresh_screen(self):
    """Refresh the screen with current entry."""
    scroll = self._get_scroll_container()
    self._clear_scroll_content(scroll)
    self._mount_entry_content(scroll)
    scroll.scroll_home(animate=False)

    # Mark as read after displaying
    if self.entry.is_unread:
        await self._mark_entry_as_read()

Features

  • Full entry display: Shows title, content, metadata
  • Navigation: Move between entries in the current list (J/K)
  • Content: HTML is converted to readable Markdown
  • Actions: Mark read/unread, star, save, open in browser

Available Actions

Method Binding Description
action_next_entry J Move to next entry
action_prev_entry K Move to previous entry
action_toggle_read m Toggle read/unread
action_toggle_star * Toggle star
action_save_entry e Save entry
action_open_in_browser o Open URL in browser

HelpScreen

Shows keyboard shortcuts and help information.

Bases: Screen

Screen displaying keyboard shortcuts and help information.

Source code in miniflux_tui/ui/screens/help.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
class HelpScreen(Screen):
    """Screen displaying keyboard shortcuts and help information."""

    BINDINGS: list[Binding] = [  # noqa: RUF012
        Binding("escape", "close", "Close"),
        Binding("q", "close", "Close"),
    ]

    def __init__(self, **kwargs):
        """Initialize help screen with server info placeholders."""
        super().__init__(**kwargs)
        self.server_version: str = "Loading..."
        self.api_version: str = "Loading..."
        self.username: str = "Loading..."

    def compose(self) -> ComposeResult:
        """Create child widgets."""
        yield Header()

        with VerticalScroll():
            yield Static("[bold cyan]Miniflux TUI - Keyboard Shortcuts[/bold cyan]\n")

            yield Static("[bold yellow]Entry List View[/bold yellow]")
            yield Static("  ↑/↓ or k/j      Navigate entries")
            yield Static("  Enter           Open entry (or first in feed if on header)")
            yield Static("  m               Toggle read/unread")
            yield Static("  *               Toggle star")
            yield Static("  e               Save entry to third-party service")
            yield Static("  s               Cycle sort mode (date/feed/status)")
            yield Static("  g               Toggle grouping by feed")
            yield Static("  Shift+G         Enable grouping and expand all feeds")
            yield Static("  Shift+Z         Collapse all feeds")
            yield Static("  o               Toggle fold/unfold on feed header")
            yield Static("  h or ←          Collapse individual feed")
            yield Static("  l or →          Expand individual feed")
            yield Static("  r or ,          Refresh entries")
            yield Static("  u               Show unread entries")
            yield Static("  t               Show starred entries")
            yield Static("  ?               Show this help")
            yield Static("  i               Show system status")
            yield Static("  q               Quit application\n")

            yield Static("[bold yellow]Entry Reader View[/bold yellow]")
            yield Static("  ↑/↓ or k/j      Scroll up/down")
            yield Static("  PageUp/PageDown Fast scroll")
            yield Static("  b or Esc        Back to list")
            yield Static("  u               Mark as unread")
            yield Static("  *               Toggle star")
            yield Static("  e               Save entry to third-party service")
            yield Static("  o               Open in browser")
            yield Static("  f               Fetch original content")
            yield Static("  J               Next entry")
            yield Static("  K               Previous entry")
            yield Static("  ?               Show this help")
            yield Static("  i               Show system status\n")

            yield Static("[bold yellow]About[/bold yellow]")
            yield Static(self._get_about_text())
            yield Static()

            yield Static("[bold yellow]System Information[/bold yellow]")
            # Use id for easier reference and initial placeholder
            yield Static(id="system-info-widget")
            yield Static()

            yield Static("[dim]Press Esc or q to close this help screen[/dim]")

        yield Footer()

    def _get_about_text(self) -> str:
        """Generate about section text with application information.

        Returns:
            Formatted text with app info
        """
        app_version = get_app_version()
        return (
            f"  Application:     Miniflux TUI\n"
            f"  Version:         {app_version}\n"
            f"  Repository:      github.com/reuteras/miniflux-tui-py\n"
            f"  License:         MIT"
        )

    def _get_system_info_text(self) -> str:
        """Generate system information text.

        Returns:
            Formatted text with system and server info
        """
        python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
        platform_name = platform.system()

        return (
            f"  Python:          {python_version}\n"
            f"  Platform:        {platform_name}\n"
            f"  Textual:         {textual.__version__}\n"
            f"  Miniflux API:    {self.api_version}\n"
            f"  Miniflux Server: {self.server_version}\n"
            f"  Username:        {self.username}"
        )

    async def on_mount(self) -> None:
        """Called when screen is mounted - load server information."""
        await self._load_server_info()

    async def _load_server_info(self) -> None:
        """Load server version and user information from API."""
        if not hasattr(self.app, "client") or not getattr(self.app, "client", None):
            self.api_version = "unavailable"
            self.server_version = "unavailable"
            self.username = "unavailable"
            return

        try:
            client = getattr(self.app, "client", None)
            # Get version info
            version_info = await client.get_version()
            self.api_version = version_info.get("version", "unknown")

            # Get user info
            user_info = await client.get_user_info()
            self.username = user_info.get("username", "unknown")
            self.server_version = version_info.get("version", "unknown")

            # Update the screen to show new info
            self._update_system_info()
        except Exception as e:
            self.app.log(f"Error loading server info: {e}")
            self.api_version = f"error: {type(e).__name__}"
            self.server_version = "error"
            self.username = "error"
            self._update_system_info()

    def _update_system_info(self) -> None:
        """Update the system information display."""
        # Update the system info widget by ID
        try:
            widget = self.query_one("#system-info-widget", Static)
            widget.update(self._get_system_info_text())
        except Exception as e:
            # If widget not found, silently fail (widget might not be mounted yet)
            self.app.log(f"Could not update system info widget: {e}")

    def action_close(self):
        """Close the help screen."""
        self.app.pop_screen()

__init__(**kwargs)

Initialize help screen with server info placeholders.

Source code in miniflux_tui/ui/screens/help.py
24
25
26
27
28
29
def __init__(self, **kwargs):
    """Initialize help screen with server info placeholders."""
    super().__init__(**kwargs)
    self.server_version: str = "Loading..."
    self.api_version: str = "Loading..."
    self.username: str = "Loading..."

action_close()

Close the help screen.

Source code in miniflux_tui/ui/screens/help.py
159
160
161
def action_close(self):
    """Close the help screen."""
    self.app.pop_screen()

compose()

Create child widgets.

Source code in miniflux_tui/ui/screens/help.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def compose(self) -> ComposeResult:
    """Create child widgets."""
    yield Header()

    with VerticalScroll():
        yield Static("[bold cyan]Miniflux TUI - Keyboard Shortcuts[/bold cyan]\n")

        yield Static("[bold yellow]Entry List View[/bold yellow]")
        yield Static("  ↑/↓ or k/j      Navigate entries")
        yield Static("  Enter           Open entry (or first in feed if on header)")
        yield Static("  m               Toggle read/unread")
        yield Static("  *               Toggle star")
        yield Static("  e               Save entry to third-party service")
        yield Static("  s               Cycle sort mode (date/feed/status)")
        yield Static("  g               Toggle grouping by feed")
        yield Static("  Shift+G         Enable grouping and expand all feeds")
        yield Static("  Shift+Z         Collapse all feeds")
        yield Static("  o               Toggle fold/unfold on feed header")
        yield Static("  h or ←          Collapse individual feed")
        yield Static("  l or →          Expand individual feed")
        yield Static("  r or ,          Refresh entries")
        yield Static("  u               Show unread entries")
        yield Static("  t               Show starred entries")
        yield Static("  ?               Show this help")
        yield Static("  i               Show system status")
        yield Static("  q               Quit application\n")

        yield Static("[bold yellow]Entry Reader View[/bold yellow]")
        yield Static("  ↑/↓ or k/j      Scroll up/down")
        yield Static("  PageUp/PageDown Fast scroll")
        yield Static("  b or Esc        Back to list")
        yield Static("  u               Mark as unread")
        yield Static("  *               Toggle star")
        yield Static("  e               Save entry to third-party service")
        yield Static("  o               Open in browser")
        yield Static("  f               Fetch original content")
        yield Static("  J               Next entry")
        yield Static("  K               Previous entry")
        yield Static("  ?               Show this help")
        yield Static("  i               Show system status\n")

        yield Static("[bold yellow]About[/bold yellow]")
        yield Static(self._get_about_text())
        yield Static()

        yield Static("[bold yellow]System Information[/bold yellow]")
        # Use id for easier reference and initial placeholder
        yield Static(id="system-info-widget")
        yield Static()

        yield Static("[dim]Press Esc or q to close this help screen[/dim]")

    yield Footer()

on_mount() async

Called when screen is mounted - load server information.

Source code in miniflux_tui/ui/screens/help.py
117
118
119
async def on_mount(self) -> None:
    """Called when screen is mounted - load server information."""
    await self._load_server_info()

MinifluxTUI (Main App)

The main application container.

Bases: App

A Textual TUI application for Miniflux.

Source code in miniflux_tui/ui/app.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
class MinifluxTUI(App):
    """A Textual TUI application for Miniflux."""

    CSS = """
    Screen {
        background: $surface;
    }

    .entry-title {
        padding: 1 2;
        background: $boost;
    }

    .entry-meta {
        padding: 0 2;
    }

    .entry-url {
        padding: 0 2 1 2;
    }

    .separator {
        padding: 0 2;
        color: $border;
    }

    .entry-content {
        padding: 1 2;
    }

    ListView {
        background: $surface;
        color: $text;
    }

    ListItem {
        padding: 0 1;
    }

    ListItem:hover {
        background: $boost;
    }

    ListItem.-active {
        background: $accent;
    }

    /* Hide collapsed entries */
    ListItem.collapsed {
        display: none;
    }
    """

    def __init__(
        self,
        config: Config,
        driver_class: type[Driver] | None = None,
        css_path: str | None = None,
        watch_css: bool = False,
    ):
        """
        Initialize the Miniflux TUI application.

        Args:
            config: Application configuration
            driver_class: Textual driver class
            css_path: Path to custom CSS file
            watch_css: Whether to watch CSS file for changes
        """
        super().__init__(
            driver_class=driver_class,
            css_path=css_path,
            watch_css=watch_css,
        )
        self.config = config
        self.client: MinifluxClient | None = None
        self.entries: list[Entry] = []
        self.categories: list[Category] = []
        self.current_view = "unread"  # or "starred"

    async def on_mount(self) -> None:
        """Called when app is mounted."""
        # Initialize API client
        self.client = MinifluxClient(
            base_url=self.config.server_url,
            api_key=self.config.api_key,
            allow_invalid_certs=self.config.allow_invalid_certs,
        )

        # Install screens first
        self.install_screen(
            EntryListScreen(
                entries=self.entries,
                categories=self.categories,
                unread_color=self.config.unread_color,
                read_color=self.config.read_color,
                default_sort=self.config.default_sort,
                group_by_feed=self.config.default_group_by_feed,
                group_collapsed=self.config.group_collapsed,
            ),
            name="entry_list",
        )

        self.install_screen(HelpScreen(), name="help")
        self.install_screen(StatusScreen(), name="status")

        # Push initial screen
        self.push_screen("entry_list")

        # Load categories and entries after screen is shown
        self.notify("Loading data...")
        await self.load_categories()
        await self.load_entries()

    async def load_categories(self) -> None:
        """Load categories from Miniflux API."""
        if not self.client:
            self.notify("API client not initialized", severity="error")
            return

        try:
            self.categories = await self.client.get_categories()
            self.log(f"Loaded {len(self.categories)} categories")

            # Update the entry list screen if it exists
            if self.is_screen_installed("entry_list"):
                screen = self.get_screen("entry_list")
                if isinstance(screen, EntryListScreen):
                    screen.categories = self.categories
        except Exception as e:
            error_details = traceback.format_exc()
            self.notify(f"Error loading categories: {e}", severity="error")
            self.log(f"Full error:\n{error_details}")

    async def load_entries(self, view: str = "unread") -> None:
        """
        Load entries from Miniflux API.

        Args:
            view: View type - "unread" or "starred"
        """
        if not self.client:
            self.notify("API client not initialized", severity="error")
            return

        try:
            if view == "starred":
                self.entries = await self.client.get_starred_entries(limit=DEFAULT_ENTRY_LIMIT)
                self.current_view = "starred"
                self.notify(f"Loaded {len(self.entries)} starred entries")
            else:
                self.entries = await self.client.get_unread_entries(limit=DEFAULT_ENTRY_LIMIT)
                self.current_view = "unread"
                self.notify(f"Loaded {len(self.entries)} unread entries")

            # Update the entry list screen if it exists
            if self.is_screen_installed("entry_list"):
                self.log("entry_list screen is installed")
                screen = self.get_screen("entry_list")
                self.log(f"Got screen: {type(screen)}")
                if isinstance(screen, EntryListScreen):
                    self.log(f"Updating screen with {len(self.entries)} entries")
                    screen.entries = self.entries
                    screen._populate_list()
                else:
                    self.log("Screen is not EntryListScreen!")
            else:
                self.log("entry_list screen is NOT installed!")

            # Show message if no entries
            if len(self.entries) == 0:
                self.notify(f"No {view} entries found", severity="warning")

        except Exception as e:
            error_details = traceback.format_exc()
            self.notify(f"Error loading entries: {e}", severity="error")
            # Log full error for debugging
            self.log(f"Full error:\n{error_details}")

    def push_entry_reader(self, entry: Entry, entry_list: list | None = None, current_index: int = 0) -> None:
        """
        Push entry reader screen for a specific entry.

        Args:
            entry: Entry to display
            entry_list: Full list of entries for navigation
            current_index: Current position in the entry list
        """
        entry_reader_module = import_module("miniflux_tui.ui.screens.entry_reader")
        entry_reader_cls: type[entry_reader_types.EntryReaderScreen]
        entry_reader_cls = entry_reader_module.EntryReaderScreen

        reader_screen: entry_reader_types.EntryReaderScreen = entry_reader_cls(
            entry=entry,
            entry_list=entry_list or self.entries,
            current_index=current_index,
            unread_color=self.config.unread_color,
            read_color=self.config.read_color,
        )
        self.push_screen(reader_screen)

    async def action_refresh_entries(self) -> None:
        """Refresh entries from API."""
        await self.load_entries(self.current_view)
        self.notify("Entries refreshed")

    async def action_show_unread(self) -> None:
        """Show unread entries."""
        await self.load_entries("unread")
        self.notify("Showing unread entries")

    async def action_show_starred(self) -> None:
        """Show starred entries."""
        await self.load_entries("starred")
        self.notify("Showing starred entries")

    async def on_unmount(self) -> None:
        """Called when app is unmounted."""
        # Close API client
        if self.client:
            await self.client.close()

__init__(config, driver_class=None, css_path=None, watch_css=False)

Initialize the Miniflux TUI application.

Parameters:

Name Type Description Default
config Config

Application configuration

required
driver_class type[Driver] | None

Textual driver class

None
css_path str | None

Path to custom CSS file

None
watch_css bool

Whether to watch CSS file for changes

False
Source code in miniflux_tui/ui/app.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def __init__(
    self,
    config: Config,
    driver_class: type[Driver] | None = None,
    css_path: str | None = None,
    watch_css: bool = False,
):
    """
    Initialize the Miniflux TUI application.

    Args:
        config: Application configuration
        driver_class: Textual driver class
        css_path: Path to custom CSS file
        watch_css: Whether to watch CSS file for changes
    """
    super().__init__(
        driver_class=driver_class,
        css_path=css_path,
        watch_css=watch_css,
    )
    self.config = config
    self.client: MinifluxClient | None = None
    self.entries: list[Entry] = []
    self.categories: list[Category] = []
    self.current_view = "unread"  # or "starred"

action_refresh_entries() async

Refresh entries from API.

Source code in miniflux_tui/ui/app.py
224
225
226
227
async def action_refresh_entries(self) -> None:
    """Refresh entries from API."""
    await self.load_entries(self.current_view)
    self.notify("Entries refreshed")

action_show_starred() async

Show starred entries.

Source code in miniflux_tui/ui/app.py
234
235
236
237
async def action_show_starred(self) -> None:
    """Show starred entries."""
    await self.load_entries("starred")
    self.notify("Showing starred entries")

action_show_unread() async

Show unread entries.

Source code in miniflux_tui/ui/app.py
229
230
231
232
async def action_show_unread(self) -> None:
    """Show unread entries."""
    await self.load_entries("unread")
    self.notify("Showing unread entries")

load_categories() async

Load categories from Miniflux API.

Source code in miniflux_tui/ui/app.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
async def load_categories(self) -> None:
    """Load categories from Miniflux API."""
    if not self.client:
        self.notify("API client not initialized", severity="error")
        return

    try:
        self.categories = await self.client.get_categories()
        self.log(f"Loaded {len(self.categories)} categories")

        # Update the entry list screen if it exists
        if self.is_screen_installed("entry_list"):
            screen = self.get_screen("entry_list")
            if isinstance(screen, EntryListScreen):
                screen.categories = self.categories
    except Exception as e:
        error_details = traceback.format_exc()
        self.notify(f"Error loading categories: {e}", severity="error")
        self.log(f"Full error:\n{error_details}")

load_entries(view='unread') async

Load entries from Miniflux API.

Parameters:

Name Type Description Default
view str

View type - "unread" or "starred"

'unread'
Source code in miniflux_tui/ui/app.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
async def load_entries(self, view: str = "unread") -> None:
    """
    Load entries from Miniflux API.

    Args:
        view: View type - "unread" or "starred"
    """
    if not self.client:
        self.notify("API client not initialized", severity="error")
        return

    try:
        if view == "starred":
            self.entries = await self.client.get_starred_entries(limit=DEFAULT_ENTRY_LIMIT)
            self.current_view = "starred"
            self.notify(f"Loaded {len(self.entries)} starred entries")
        else:
            self.entries = await self.client.get_unread_entries(limit=DEFAULT_ENTRY_LIMIT)
            self.current_view = "unread"
            self.notify(f"Loaded {len(self.entries)} unread entries")

        # Update the entry list screen if it exists
        if self.is_screen_installed("entry_list"):
            self.log("entry_list screen is installed")
            screen = self.get_screen("entry_list")
            self.log(f"Got screen: {type(screen)}")
            if isinstance(screen, EntryListScreen):
                self.log(f"Updating screen with {len(self.entries)} entries")
                screen.entries = self.entries
                screen._populate_list()
            else:
                self.log("Screen is not EntryListScreen!")
        else:
            self.log("entry_list screen is NOT installed!")

        # Show message if no entries
        if len(self.entries) == 0:
            self.notify(f"No {view} entries found", severity="warning")

    except Exception as e:
        error_details = traceback.format_exc()
        self.notify(f"Error loading entries: {e}", severity="error")
        # Log full error for debugging
        self.log(f"Full error:\n{error_details}")

on_mount() async

Called when app is mounted.

Source code in miniflux_tui/ui/app.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
async def on_mount(self) -> None:
    """Called when app is mounted."""
    # Initialize API client
    self.client = MinifluxClient(
        base_url=self.config.server_url,
        api_key=self.config.api_key,
        allow_invalid_certs=self.config.allow_invalid_certs,
    )

    # Install screens first
    self.install_screen(
        EntryListScreen(
            entries=self.entries,
            categories=self.categories,
            unread_color=self.config.unread_color,
            read_color=self.config.read_color,
            default_sort=self.config.default_sort,
            group_by_feed=self.config.default_group_by_feed,
            group_collapsed=self.config.group_collapsed,
        ),
        name="entry_list",
    )

    self.install_screen(HelpScreen(), name="help")
    self.install_screen(StatusScreen(), name="status")

    # Push initial screen
    self.push_screen("entry_list")

    # Load categories and entries after screen is shown
    self.notify("Loading data...")
    await self.load_categories()
    await self.load_entries()

on_unmount() async

Called when app is unmounted.

Source code in miniflux_tui/ui/app.py
239
240
241
242
243
async def on_unmount(self) -> None:
    """Called when app is unmounted."""
    # Close API client
    if self.client:
        await self.client.close()

push_entry_reader(entry, entry_list=None, current_index=0)

Push entry reader screen for a specific entry.

Parameters:

Name Type Description Default
entry Entry

Entry to display

required
entry_list list | None

Full list of entries for navigation

None
current_index int

Current position in the entry list

0
Source code in miniflux_tui/ui/app.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
def push_entry_reader(self, entry: Entry, entry_list: list | None = None, current_index: int = 0) -> None:
    """
    Push entry reader screen for a specific entry.

    Args:
        entry: Entry to display
        entry_list: Full list of entries for navigation
        current_index: Current position in the entry list
    """
    entry_reader_module = import_module("miniflux_tui.ui.screens.entry_reader")
    entry_reader_cls: type[entry_reader_types.EntryReaderScreen]
    entry_reader_cls = entry_reader_module.EntryReaderScreen

    reader_screen: entry_reader_types.EntryReaderScreen = entry_reader_cls(
        entry=entry,
        entry_list=entry_list or self.entries,
        current_index=current_index,
        unread_color=self.config.unread_color,
        read_color=self.config.read_color,
    )
    self.push_screen(reader_screen)

Methods

  • push_entry_reader: Opens an entry in the detailed reader view
  • load_entries: Fetches entries from the API
MinifluxTUI (App)
├─ EntryListScreen (main view)
│  ├─ navigate with j/k
│  ├─ press Enter → EntryReaderScreen
│  ├─ press ? → HelpScreen
│  └─ press q → exit
├─ EntryReaderScreen (detail view)
│  ├─ navigate with J/K
│  └─ press Escape → back to EntryListScreen
└─ HelpScreen (help view)
  └─ press any key → back to previous screen

Widget Classes

EntryListItem

Custom ListItem for displaying an entry in the list.

FeedHeaderItem

Custom ListItem for displaying a feed group header.

Both use CSS-based hiding for collapsed feeds (via the "collapsed" class).

Styling

The application uses Textual's CSS system. Main styles are defined in: - miniflux_tui/ui/app.py - Application-wide styles - Screen CSS in individual screen files

Color customization is available through configuration (theme colors).