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
 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
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
class EntryListScreen(Screen):
    """Screen for displaying a list of feed entries with sorting."""

    BINDINGS = [  # noqa: RUF012
        # Navigation (vim-style)
        Binding("j", "cursor_down", "Down", show=False),
        Binding("k", "cursor_up", "Up", show=False),
        Binding("n", "cursor_down", "Next Item", show=False),
        Binding("p", "cursor_up", "Previous Item", show=False),
        Binding("down", "cursor_down", "Down", show=False),
        Binding("up", "cursor_up", "Up", show=False),
        # Entry selection and actions
        Binding("enter", "select_entry", "Open Entry"),
        Binding("m", "toggle_read", "Mark Read/Unread"),
        Binding("M", "toggle_read_previous", "Mark Read/Unread (Focus Prev)", show=False),
        Binding("f", "toggle_star", "Toggle Starred"),
        Binding("e", "save_entry", "Save Entry"),
        Binding("A", "mark_all_as_read", "Mark All as Read", show=False),
        # Grouping and collapsing (note: g-prefix now used for section nav)
        Binding("s", "cycle_sort", "Cycle Sort"),
        Binding("w", "toggle_group_feed", "Group by Feed", show=False),
        Binding("C", "toggle_group_category", "Group by Category", show=False),
        Binding("shift+l", "expand_all", "Expand All", show=False),
        Binding("Z", "collapse_all", "Collapse All"),
        Binding("G", "go_to_bottom", "Go to Bottom", show=False),
        Binding("h", "collapse_fold", "Collapse Feed/Category"),
        Binding("l", "expand_fold", "Expand Feed/Category"),
        Binding("left", "collapse_fold", "Collapse Feed/Category", show=False),
        Binding("right", "expand_fold", "Expand Feed/Category", show=False),
        # Feed operations
        Binding("r", "refresh", "Refresh Current Feed"),
        Binding("R", "refresh_all_feeds", "Refresh All Feeds"),
        Binding("comma", "sync_entries", "Sync Entries", show=False),
        # Section navigation (g prefix)
        Binding("g", "g_prefix_mode", "Section Navigation", show=False),
        # Feed settings
        Binding("X", "feed_settings", "Feed Settings"),
        # Search and help
        Binding("slash", "search", "Search"),
        Binding("question_mark", "show_help", "Help"),
        # Mode-specific (these are handled by g_prefix_mode)
        # g+u = unread, g+b = starred, g+c = categories, g+f = feeds, g+h = history, g+s = settings
        # Status, settings, history (also accessible via g-prefix, but keep for compatibility)
        Binding("i", "show_status", "Status"),
        Binding("H", "show_history", "History"),
        Binding("S", "show_settings", "Settings"),
        Binding("T", "toggle_theme", "Toggle Theme"),
        Binding("q", "quit", "Quit"),
    ]

    app: "MinifluxTuiApp"

    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.filter_category_id: int | None = None  # Filter to show entries from selected category only
        self.search_active = False  # Flag to indicate search is active
        self.search_term = ""  # Current search term
        self.list_view: CollapsibleListView | 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
        self._is_initial_mount: bool = True  # Track if this is the first time mounting the screen
        self._header_widget: Header | None = None
        self._footer_widget: Footer | None = None
        # Loading animation state
        self._loading_animation_timer = None  # Timer for loading animation
        self._loading_animation_frame = 0  # Current animation frame
        self._loading_animation_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]  # Spinner frames
        self._loading_message = ""  # Message to show during loading
        self._g_prefix_mode = False  # Flag to track if waiting for g-prefix command

    def _safe_log(self, message: str) -> None:
        """Safely log a message, handling cases where app is not available."""
        # Silently ignore logging errors (e.g., in tests without app context)
        with suppress(Exception):
            self.log(message)

    def _start_loading_animation(self, message: str = "Loading") -> None:
        """Start the loading animation in the subtitle.

        Args:
            message: The message to display with the spinner
        """
        self._loading_message = message
        self._loading_animation_frame = 0
        # Update subtitle with first frame
        self._update_loading_animation()
        # Start timer to update animation every 100ms
        self._loading_animation_timer = self.set_interval(0.1, self._update_loading_animation)

    def _update_loading_animation(self) -> None:
        """Update the loading animation frame."""
        if not self._loading_message:
            return

        # Get current spinner frame
        spinner = self._loading_animation_frames[self._loading_animation_frame]
        # Update subtitle with spinner and message (safely handle if screen is unmounted)
        with suppress(Exception):
            self.sub_title = f"{spinner} {self._loading_message}"
        # Move to next frame (loop back to 0 after last frame)
        self._loading_animation_frame = (self._loading_animation_frame + 1) % len(self._loading_animation_frames)

    def _stop_loading_animation(self) -> None:
        """Stop the loading animation and clear the subtitle."""
        # Stop the timer if it exists
        if self._loading_animation_timer:
            with suppress(Exception):
                self._loading_animation_timer.stop()
            self._loading_animation_timer = None
        # Clear the subtitle (safely handle if screen is unmounted)
        with suppress(Exception):
            self.sub_title = ""
        self._loading_message = ""
        self._loading_animation_frame = 0

    def compose(self) -> ComposeResult:
        """Create child widgets."""
        header = Header()
        list_view = CollapsibleListView()
        footer = Footer()

        # Store references for later use (e.g. focus management)
        self._header_widget = header
        self.list_view = list_view
        self._footer_widget = footer

        yield header
        yield list_view
        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(CollapsibleListView)
        self._safe_log(f"on_mount: list_view is now {self.list_view}")

        # Only populate if we have entries
        if self.entries:
            self._safe_log(f"on_mount: Populating with {len(self.entries)} entries")
            # _populate_list() now handles cursor restoration via call_later
            self._populate_list()
            # Note: _is_initial_mount is cleared in on_screen_resume after first display
        else:
            self._safe_log("on_mount: No entries yet, skipping initial population")

    def on_unmount(self) -> None:
        """Called when screen is unmounted - cleanup resources."""
        # Stop loading animation timer if it's running
        self._stop_loading_animation()

    def on_screen_resume(self) -> None:
        """Called when screen is resumed (e.g., after returning from entry reader)."""
        # On first resume (after on_mount), skip population and just clear the flag
        # on_mount already populated the list
        if self._is_initial_mount:
            self._safe_log("on_screen_resume: initial mount, clearing flag and skipping population")
            self._is_initial_mount = False
            return

        # Refresh the list to reflect any status changes when returning from other screens
        if self.entries and self.list_view:
            # _populate_list() now handles cursor restoration and focus via call_later
            self._populate_list()
        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)."""
        if event.item and isinstance(event.item, FeedHeaderItem):
            # Open first entry in the selected feed
            self._open_first_entry_by_feed(event.item.feed_title)
        elif event.item and isinstance(event.item, CategoryHeaderItem):
            # Open first entry in the selected category
            self._open_first_entry_by_category(event.item.category_title)
        elif event.item and isinstance(event.item, EntryListItem):
            # Open the selected entry directly
            self._open_entry(event.item.entry)

    def _open_first_entry_by_feed(self, feed_title: str) -> None:
        """Find and open the first entry in a feed."""
        for entry in self.sorted_entries:
            if entry.feed.title == feed_title:
                self._open_entry(entry)
                return

    def _open_first_entry_by_category(self, category_title: str) -> None:
        """Find and open the first entry in a category."""
        for entry in self.sorted_entries:
            if self._get_category_title(entry.feed.category_id) == category_title:
                self._open_entry(entry)
                return

    def _build_group_info(self) -> dict[str, str | int] | None:
        """Build group info dictionary based on current grouping mode.

        Returns:
            Dictionary with 'mode' key ('feed' or 'category'), or None if not grouped
        """
        if self.group_by_feed:
            return {"mode": "feed"}
        if self.group_by_category:
            return {"mode": "category"}
        return None

    def _open_entry(self, entry: Entry) -> None:
        """Open an entry in the entry reader screen."""
        # Save the entry for position restoration
        self.last_highlighted_feed = entry.feed.title
        self.last_highlighted_entry_id = 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, e in enumerate(self.sorted_entries):
            if e.id == entry.id:
                entry_index = i
                break

        # Prepare group info if in grouped mode
        group_info = self._build_group_info()

        # 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,
                group_info=group_info,
            )

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

        # Log the current index before clearing
        self._safe_log(f"_populate_list: current index before clear = {self.list_view.index}")
        self._safe_log(f"_populate_list: current children count before clear = {len(self.list_view.children)}")

        self.list_view.clear()

        # Log after clearing
        self._safe_log(f"_populate_list: current index after clear = {self.list_view.index}")
        self._safe_log(f"_populate_list: current children count after clear = {len(self.list_view.children)}")

        # CRITICAL: Reset index to None after clearing to ensure clean state
        # This prevents issues where the old index persists after clearing
        self.list_view.index = None  # type: ignore[assignment]
        self._safe_log("_populate_list: reset index to None")

        sorted_entries = self._get_sorted_entries()
        self.sorted_entries = sorted_entries
        self._display_entries(sorted_entries)

        # Log after adding entries
        self._safe_log(f"_populate_list: current children count after display = {len(self.list_view.children)}")
        self._safe_log(f"_populate_list: current index after display = {self.list_view.index}")
        self._safe_log(f"_populate_list: highlighted_child = {self.list_view.highlighted_child}")

        self.refresh_optimizer.track_full_refresh()

        # Restore cursor position after list is updated
        # This ensures cursor is initialized even when called directly (e.g., from tests)
        # Uses call_later to defer until ListView has fully updated
        # On initial mount, use simple positioning; otherwise restore previous position
        if self._is_initial_mount:
            self.call_later(self._set_initial_position_and_focus)
        else:
            self.call_later(self._restore_cursor_position_and_focus)

    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.
        Scrolls the entry to center of viewport for better visibility.

        Args:
            index: Target index

        Returns:
            True if successful, False otherwise
        """
        self._safe_log(f"_set_cursor_to_index: Setting index to {index}")
        max_index = len(self.list_view.children) - 1
        self._safe_log(f"  max_index = {max_index}, current index = {self.list_view.index}")

        if index > max_index:
            self._safe_log(f"  Index {index} > max_index {max_index}, returning False")
            return False

        try:
            self.list_view.index = index
            self._safe_log(f"  After setting index: list_view.index = {self.list_view.index}")
            # Scroll to center the entry in the viewport for better visibility
            # This prevents the entry from appearing at the bottom of the screen
            if self.list_view.highlighted_child:
                self._safe_log(f"  highlighted_child = {self.list_view.highlighted_child}")
                self.list_view.scroll_to_center(self.list_view.highlighted_child, animate=False)
            else:
                self._safe_log("  No highlighted_child after setting index!")
            return True
        except Exception as e:
            self._safe_log(f"  Exception caught while setting index: {type(e).__name__}: {e}")
            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.
        """
        self._safe_log("_restore_cursor_position: Starting restoration")
        self._safe_log(f"  current index = {self.list_view.index if self.list_view else 'N/A'}")
        self._safe_log(f"  children count = {len(self.list_view.children) if self.list_view else 0}")
        self._safe_log(f"  last_highlighted_entry_id = {self.last_highlighted_entry_id}")
        self._safe_log(f"  last_highlighted_feed = {self.last_highlighted_feed}")
        self._safe_log(f"  last_cursor_index = {self.last_cursor_index}")
        self._safe_log(f"  group_by_feed = {self.group_by_feed}")

        if not self.list_view or len(self.list_view.children) == 0:
            self._safe_log("_restore_cursor_position: No list view or no children, returning")
            return

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

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

        # Fallback: restore to last cursor index
        max_index = len(self.list_view.children) - 1
        cursor_index = min(self.last_cursor_index, max_index)
        self._safe_log(f"_restore_cursor_position: Fallback to cursor_index = {cursor_index} (max = {max_index})")
        if self._set_cursor_to_index(cursor_index):
            self._safe_log(f"Restoring cursor to last index {cursor_index}")
            self._safe_log(f"  After restore: index = {self.list_view.index}, highlighted = {self.list_view.highlighted_child}")
        else:
            self._safe_log(f"_restore_cursor_position: Failed to set cursor to {cursor_index}")
            # Final emergency fallback: if all else fails, force index to 0
            self._safe_log("_restore_cursor_position: Emergency fallback - forcing index to 0")
            try:
                self.list_view.index = 0
                self._safe_log(f"  Emergency fallback result: index = {self.list_view.index}")
            except Exception as e:
                self._safe_log(f"  Emergency fallback failed: {type(e).__name__}: {e}")

    def _set_initial_position_and_focus(self) -> None:
        """Set cursor to first item on initial mount and ensure focus."""
        if not self.list_view or len(self.list_view.children) == 0:
            return

        # Start at the first item (index 0)
        self._set_cursor_to_index(0)
        self._ensure_focus()
        self._safe_log("Initial mount: cursor set to first item (index 0)")

    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."""
        list_view_exists = self.list_view is not None
        children_count = len(self.list_view.children) if self.list_view else 0
        self._safe_log(f"_ensure_focus: list_view={list_view_exists}, children={children_count}")
        if self.list_view and len(self.list_view.children) > 0:
            try:
                self.list_view.focus()
                self._safe_log(f"_ensure_focus: Focus set successfully, focused={self.app.focused}")
            except Exception as e:
                self._safe_log(f"_ensure_focus: Exception while setting focus: {type(e).__name__}: {e}")

    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(CollapsibleListView)
            except Exception as e:
                self._safe_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 _set_category_fold_state(self, category_title: str, is_expanded: bool) -> None:
        """Set fold state for a category and update UI.

        Updates the category's fold state, toggles the header visual indicator,
        and updates the CSS visibility of category entries.

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

        # Update fold state
        self.category_fold_state[category_title] = is_expanded

        # Update header visual indicator
        if category_title in self.category_header_map:
            self.category_header_map[category_title].toggle_fold()

        # Update CSS visibility
        self._update_category_visibility(category_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. Category filter (if set)
        2. Search filter (if active)
        3. Status filters (unread/starred - mutually exclusive)

        Args:
            entries: List of entries to filter

        Returns:
            Filtered list of entries
        """
        # Apply category filter first if set
        if self.filter_category_id is not None:
            entries = [e for e in entries if e.feed.category_id == self.filter_category_id]

        # Apply search filter 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 other filters 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, entry: Entry | None = None) -> 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)
            entry: Entry object to extract category information from
        """
        # 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

        # Get category title and error status if entry is provided
        category_title = None
        has_errors = False
        feed_disabled = False
        if entry is not None:
            category_title = self._get_category_title(entry.feed.category_id)
            has_errors = entry.feed.has_errors
            feed_disabled = entry.feed.disabled

        # Get feed statistics
        unread_count, total_count = self._get_feed_stats(current_feed, self.sorted_entries)

        # Create and add a fold-aware header item
        is_expanded = self.feed_fold_state[current_feed]
        header = FeedHeaderItem(
            current_feed,
            is_expanded=is_expanded,
            category_title=category_title,
            has_errors=has_errors,
            feed_disabled=feed_disabled,
            unread_count=unread_count,
            total_count=total_count,
        )
        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, entry)

            # 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 _get_feed_stats(self, feed_title: str, entries: list[Entry]) -> tuple[int, int]:
        """Calculate unread and total counts for a feed.

        Args:
            feed_title: Title of the feed
            entries: List of entries to count from

        Returns:
            Tuple of (unread_count, total_count) for the feed
        """
        unread = 0
        total = 0
        for entry in entries:
            if entry.feed.title == feed_title:
                total += 1
                if entry.is_unread:
                    unread += 1
        return unread, total

    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

        # Calculate entry counts for this category
        category_id = None
        for entry in self.sorted_entries:
            entry_category = self._get_category_title(entry.feed.category_id)
            if entry_category == category_title:
                category_id = entry.feed.category_id
                break

        unread_count = 0
        read_count = 0
        if category_id is not None:
            unread_count = sum(1 for e in self.sorted_entries if e.feed.category_id == category_id and e.is_unread)
            read_count = sum(1 for e in self.sorted_entries if e.feed.category_id == category_id and not e.is_unread)

        # Create and add a fold-aware header item with counts
        is_expanded = self.category_fold_state[category_title]
        header = CategoryHeaderItem(category_title, is_expanded=is_expanded, unread_count=unread_count, read_count=read_count)
        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

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

    async def _on_key(self, event: events.Key) -> None:
        """Handle key events, with special support for g-prefix commands.

        When _g_prefix_mode is True, the next key after 'g' is interpreted as a command:
        - u = show unread
        - b = show starred
        - h = show history
        - c = group entries by category
        - C = go to category management
        - f = go to feeds
        - s = go to settings
        """
        if self._g_prefix_mode:
            self._g_prefix_mode = False
            key = event.character

            if key == "u":
                self.action_show_unread()
            elif key == "b":
                self.action_show_starred()
            elif key == "h":
                self.action_show_history()
            elif key == "c":
                # g+c: Group entries by category and show counts
                self.action_toggle_group_category()
            elif key == "C":
                # g+C: Go to category management screen
                await self.action_show_categories()
            elif key == "f":
                self.action_show_feeds()
            elif key == "s":
                self.action_show_settings()
            elif key == "g":
                # Allow 'gg' to go to top (vim-style)
                self.action_go_to_top()
            else:
                # Unrecognized g-command, cancel mode
                pass
            event.prevent_default()
        else:
            await super()._on_key(event)

    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:
            self._safe_log("action_cursor_down: No list view or no children")
            return

        try:
            current_index = self.list_view.index
            self._safe_log(f"action_cursor_down: current_index = {current_index}, children count = {len(self.list_view.children)}")

            # If index is None, start searching from -1 so range(0, ...) includes index 0
            if current_index is None:
                current_index = -1
                self._safe_log("action_cursor_down: index was None, starting from -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):
                    is_visible = self._is_item_visible(widget)
                    self._safe_log(f"  Checking index {i}: type={type(widget).__name__}, visible={is_visible}")
                    if is_visible:
                        self._safe_log(f"action_cursor_down: Moving to visible item at index {i}")
                        self.list_view.index = i
                        return

            # If no visible item found below, stay at current position
            self._safe_log("action_cursor_down: No visible item found below current position")
        except (IndexError, ValueError, TypeError) as e:
            # Silently ignore index errors when navigating beyond list bounds
            self._safe_log(f"action_cursor_down: Exception: {type(e).__name__}: {e}")

    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

    def action_g_prefix_mode(self) -> None:
        """Activate g-prefix mode for section navigation.

        Sets the flag to wait for the next key, which will be interpreted as:
        - g: go to top (gg)
        - u: show unread entries
        - b: show starred entries
        - h: show history
        - c: go to categories
        - f: go to feeds
        - s: go to settings
        """
        self._g_prefix_mode = True
        self.notify("g-prefix mode (press g/u/b/h/c/f/s)", timeout=2)

    async def action_show_categories(self) -> None:
        """Go to categories (g+c)."""
        await self.action_manage_categories()

    def action_show_feeds(self) -> None:
        """Go to feeds management (g+f)."""
        if hasattr(self.app, "push_feed_management_screen"):
            self.app.push_feed_management_screen()
        else:
            self.notify("Feed management not available", severity="warning")

    def action_go_to_top(self) -> None:
        """Go to top of entry list (gg)."""
        if self.list_view and len(self.list_view.children) > 0:
            # Find first visible item
            for i, child in enumerate(self.list_view.children):
                if isinstance(child, ListItem) and self._is_item_visible(child):
                    self.list_view.index = i
                    if self.list_view.highlighted_child:
                        self.list_view.scroll_to_center(self.list_view.highlighted_child, animate=False)
                    self.notify("Jumped to top", timeout=1)
                    return

    def action_go_to_bottom(self) -> None:
        """Go to bottom of entry list (G)."""
        if self.list_view and len(self.list_view.children) > 0:
            # Find last visible item (search backwards)
            for i in range(len(self.list_view.children) - 1, -1, -1):
                child = self.list_view.children[i]
                if isinstance(child, ListItem) and self._is_item_visible(child):
                    self.list_view.index = i
                    if self.list_view.highlighted_child:
                        self.list_view.scroll_to_center(self.list_view.highlighted_child, animate=False)
                    self.notify("Jumped to bottom", timeout=1)
                    return

    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
            with api_call(self, f"marking entry as {new_status}") as client:
                if client is None:
                    return

                # 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_read_previous(self):
        """Toggle read/unread status and focus previous entry (M key)."""
        if not self.list_view:
            return

        # First toggle the read status
        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
            with api_call(self, f"marking entry as {new_status}") as client:
                if client is None:
                    return

                # 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}")

        # Then move to previous entry
        self.action_cursor_up()

    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
            with api_call(self, "toggling star status") as client:
                if client is None:
                    return

                # 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
            with api_call(self, "saving entry") as client:
                if client is None:
                    return

                await client.save_entry(highlighted.entry.id)
                self.notify(f"Entry saved: {highlighted.entry.title}")

    async def action_mark_all_as_read(self):
        """Mark all visible entries as read (A key) with confirmation."""
        if not self.list_view or len(self.list_view.children) == 0:
            self.notify("No entries to mark", severity="warning")
            return

        # Import here to avoid circular dependency
        from miniflux_tui.ui.screens.confirm_dialog import ConfirmDialog  # noqa: PLC0415

        # Count unread entries
        unread_count = sum(1 for entry in self.sorted_entries if entry.is_unread)
        if unread_count == 0:
            self.notify("No unread entries to mark", severity="warning")
            return

        def on_confirm() -> None:
            """Handle confirmation to mark all as read."""
            asyncio.create_task(self._do_mark_all_as_read())  # noqa: RUF006

        # Create confirmation dialog
        dialog = ConfirmDialog(
            title="Mark All as Read",
            message=f"Mark all {unread_count} unread entries as read?",
            confirm_label="Yes",
            cancel_label="No",
            on_confirm=on_confirm,
        )
        self.app.push_screen(dialog)

    async def _do_mark_all_as_read(self) -> None:
        """Mark all entries as read via API.

        This is called after user confirms the action.
        """
        # Use consistent error handling context
        with api_call(self, "marking all entries as read") as client:
            if client is None:
                return

            try:
                # Mark all entries as read via API
                await client.mark_all_as_read()

                # Update local state for all visible entries
                for entry in self.sorted_entries:
                    if entry.is_unread:
                        entry.status = "read"

                # Refresh the list to show updated states
                self._populate_list()

                # Notify user
                self.notify("All entries marked as read")
            except Exception as e:
                self.notify(f"Error marking all as read: {e}", severity="error")

    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_feed(self):
        """Toggle grouping by feed (g key)."""
        # Disable category grouping when enabling feed grouping
        if not self.group_by_feed and self.group_by_category:
            self.group_by_category = False

        self.group_by_feed = not self.group_by_feed

        if self.group_by_feed:
            # Clear existing fold states so new groups use config default
            self.feed_fold_state.clear()
            self.notify("Grouping by feed (use h/l to collapse/expand)")
        else:
            self.notify("Feed grouping disabled")

        self._populate_list()

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

        # Disable feed grouping when enabling category grouping
        if not self.group_by_category and self.group_by_feed:
            self.group_by_feed = False

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

        if self.group_by_category:
            # Clear existing fold states so new groups use config default
            self.category_fold_state.clear()
            self.notify("Grouping by category (use h/l to collapse/expand)")
        else:
            self.notify("Category grouping disabled")

        self._populate_list()

    def action_toggle_fold(self):
        """Toggle fold state of highlighted feed or category (o key)."""
        if not self.list_view or (not self.group_by_feed and not self.group_by_category):
            return

        highlighted = self.list_view.highlighted_child

        # Handle feed grouping mode
        if self.group_by_feed and isinstance(highlighted, FeedHeaderItem):
            feed_title = highlighted.feed_title
            self.last_highlighted_feed = feed_title
            self.feed_fold_state[feed_title] = not self.feed_fold_state[feed_title]
            highlighted.toggle_fold()
            self._update_feed_visibility(feed_title)

        # Handle category grouping mode
        elif self.group_by_category and isinstance(highlighted, CategoryHeaderItem):
            category_title = highlighted.category_title
            self.last_highlighted_category = category_title
            self.category_fold_state[category_title] = not self.category_fold_state[category_title]
            highlighted.toggle_fold()
            self._update_category_visibility(category_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 _update_category_visibility(self, category_title: str) -> None:
        """Update CSS visibility for all entries of a category based on fold state.

        If category is collapsed, adds 'collapsed' class to hide entries.
        If category is expanded, removes 'collapsed' class to show entries.
        """
        is_expanded = self.category_fold_state.get(category_title, True)

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

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

        highlighted = self.list_view.highlighted_child

        # Handle feed grouping mode
        if self.group_by_feed and isinstance(highlighted, FeedHeaderItem):
            feed_title = highlighted.feed_title
            self.last_highlighted_feed = feed_title
            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)

        # Handle category grouping mode
        elif self.group_by_category and isinstance(highlighted, CategoryHeaderItem):
            category_title = highlighted.category_title
            self.last_highlighted_category = category_title
            is_currently_expanded = self.category_fold_state.get(category_title, not self.group_collapsed)
            if is_currently_expanded:
                self._set_category_fold_state(category_title, False)

        # Fallback for entry items: collapse their parent feed/category
        elif isinstance(highlighted, EntryListItem):
            if self.group_by_feed:
                feed_title = highlighted.entry.feed.title
                self.last_highlighted_feed = feed_title
                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)
            elif self.group_by_category:
                category_title = self._get_category_title(highlighted.entry.feed.category_id)
                self.last_highlighted_category = category_title
                is_currently_expanded = self.category_fold_state.get(category_title, not self.group_collapsed)
                if is_currently_expanded:
                    self._set_category_fold_state(category_title, False)

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

        highlighted = self.list_view.highlighted_child

        # Handle feed grouping mode
        if self.group_by_feed and isinstance(highlighted, FeedHeaderItem):
            feed_title = highlighted.feed_title
            self.last_highlighted_feed = feed_title
            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)

        # Handle category grouping mode
        elif self.group_by_category and isinstance(highlighted, CategoryHeaderItem):
            category_title = highlighted.category_title
            self.last_highlighted_category = category_title
            is_currently_collapsed = not self.category_fold_state.get(category_title, not self.group_collapsed)
            if is_currently_collapsed:
                self._set_category_fold_state(category_title, True)

        # Fallback for entry items: expand their parent feed/category
        elif isinstance(highlighted, EntryListItem):
            if self.group_by_feed:
                feed_title = highlighted.entry.feed.title
                self.last_highlighted_feed = feed_title
                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)
            elif self.group_by_category:
                category_title = self._get_category_title(highlighted.entry.feed.category_id)
                self.last_highlighted_category = category_title
                is_currently_collapsed = not self.category_fold_state.get(category_title, not self.group_collapsed)
                if is_currently_collapsed:
                    self._set_category_fold_state(category_title, True)

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

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

        # If not in grouped mode, enable feed grouping first
        if not self.group_by_feed and not self.group_by_category:
            self.action_toggle_group_feed()
            return

        # Expand all feeds that are currently collapsed
        if self.group_by_feed:
            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")

        # Expand all categories that are currently collapsed
        elif self.group_by_category:
            for category_title in self.category_fold_state:
                if not self.category_fold_state[category_title]:
                    self._set_category_fold_state(category_title, True)
            self.notify("All categories expanded")

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

        # Collapse all feeds that are currently expanded
        if self.group_by_feed:
            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")

        # Collapse all categories that are currently expanded
        elif self.group_by_category:
            for category_title in self.category_fold_state:
                if self.category_fold_state[category_title]:
                    self._set_category_fold_state(category_title, False)
            self.notify("All categories collapsed")

    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 item 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
        feed_title = None
        feed_id = None

        # Handle both feed headers and entry items
        if isinstance(highlighted, FeedHeaderItem):
            # User is on a feed header - get feed info from first entry in that feed
            feed_title = highlighted.feed_title
            # Find the first entry for this feed to get the feed_id
            for entry in self.sorted_entries:
                if entry.feed.title == feed_title:
                    feed_id = entry.feed_id
                    break
            if feed_id is None:
                self.notify("No entries found for this feed", severity="warning")
                return
        elif isinstance(highlighted, EntryListItem):
            # User is on an entry - get feed info from the entry
            feed_title = highlighted.entry.feed.title
            feed_id = highlighted.entry.feed_id
        else:
            self.notify("No feed selected", severity="warning")
            return

        # Run the refresh in background to keep UI responsive
        self.run_worker(self._do_refresh(feed_id, feed_title), exclusive=True)

    async def _do_refresh(self, feed_id: int, feed_title: str):
        """Background worker for refreshing a feed."""
        try:
            # Start loading animation in header
            self._start_loading_animation(f"Refreshing {feed_title}...")

            await self.app.client.refresh_feed(feed_id)

            # Show success message
            self.notify(f"Feed '{feed_title}' refreshed. Use ',' to sync new entries.", severity="information")
        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")
        finally:
            # Always stop the loading animation
            self._stop_loading_animation()

    def action_refresh_all_feeds(self):
        """Refresh all feeds on the server (Issue #55 - Feed operations).

        This tells the Miniflux server to fetch new content from RSS feeds.
        It does NOT reload entries - use 'comma' (,) to sync entries from server.
        """
        if not hasattr(self.app, "client") or not self.app.client:
            self.notify("API client not initialized", severity="error")
            return

        # Run the refresh in background to keep UI responsive
        self.run_worker(self._do_refresh_all_feeds(), exclusive=True)

    async def _do_refresh_all_feeds(self):
        """Background worker for refreshing all feeds."""
        try:
            # Start loading animation in header
            self._start_loading_animation("Refreshing all feeds...")

            await self.app.client.refresh_all_feeds()

            # Show success message
            self.notify("All feeds refreshed successfully. Use ',' to sync new entries.", severity="information")
        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")
        finally:
            # Always stop the loading animation
            self._stop_loading_animation()

    def _remove_entry_from_ui(self, entry_id: int) -> None:
        """Remove an entry from the UI by ID.

        Args:
            entry_id: The ID of the entry to remove
        """
        self.entries = [e for e in self.entries if e.id != entry_id]
        self.sorted_entries = [e for e in self.sorted_entries if e.id != entry_id]
        if entry_id in self.entry_item_map:
            del self.entry_item_map[entry_id]

    def _add_entry_to_ui(self, entry: Entry) -> None:
        """Add an entry to the UI.

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

    async def _fetch_entries_for_sync(self) -> list[Entry] | None:
        """Fetch entries from server based on current view.

        Returns:
            List of entries or None if error occurred
        """
        try:
            if self.app.current_view == "starred":
                return await self.app.client.get_starred_entries(limit=DEFAULT_ENTRY_LIMIT)
            return await self.app.client.get_unread_entries(limit=DEFAULT_ENTRY_LIMIT)
        except Exception as e:
            self.notify(f"Error fetching entries: {e}", severity="error")
            return None

    async def _enrich_entries_with_categories(self, entries: list[Entry]) -> None:
        """Enrich entries with category information from app state.

        Args:
            entries: List of entries to enrich
        """
        # Rebuild category mapping for fresh data
        if hasattr(self.app, "_build_entry_category_mapping"):
            self.app.entry_category_map = await self.app._build_entry_category_mapping()

        # Enrich entries with category information
        if self.app.entry_category_map:
            for entry in entries:
                if entry.id in self.app.entry_category_map:
                    entry.feed.category_id = self.app.entry_category_map[entry.id]

    def _apply_entry_changes(self, added_ids: set[int], removed_ids: set[int], new_entry_map: dict[int, Entry]) -> None:
        """Apply entry changes to the UI.

        Args:
            added_ids: Set of entry IDs to add
            removed_ids: Set of entry IDs to remove
            new_entry_map: Map of entry ID to Entry object for new entries
        """
        # Remove entries that are no longer in the view
        for entry_id in removed_ids:
            self._remove_entry_from_ui(entry_id)

        # Add new entries
        for entry_id in added_ids:
            self._add_entry_to_ui(new_entry_map[entry_id])

        # Update app state and re-sort
        self.app.entries = self.entries
        self.sorted_entries = self._sort_entries(self.entries)

        # Refresh the list view
        if self.list_view:
            self._populate_list()

    async def _perform_incremental_sync(self) -> tuple[int, int, int]:
        """Perform incremental sync of entries from the server.

        Fetches new entries from the server and dynamically updates the UI by:
        - Adding new entries to the list
        - Removing entries that were marked read elsewhere
        - Preserving UI state (cursor position, sort order, etc.)

        Returns:
            Tuple of (new_count, removed_count, updated_count)
        """
        if not hasattr(self.app, "client") or not self.app.client:
            self.notify("API client not initialized", severity="error")
            return (0, 0, 0)

        # Get current entry IDs before sync
        current_ids = {entry.id for entry in self.entries}

        # Fetch fresh data from server
        new_entries = await self._fetch_entries_for_sync()
        if new_entries is None:
            return (0, 0, 0)

        # Enrich with category data
        await self._enrich_entries_with_categories(new_entries)

        # Calculate changes
        new_ids = {entry.id for entry in new_entries}
        added_ids = new_ids - current_ids
        removed_ids = current_ids - new_ids

        # If no changes, return early
        if not added_ids and not removed_ids:
            return (0, 0, 0)

        # Apply changes to UI
        new_entry_map = {entry.id: entry for entry in new_entries}
        self._apply_entry_changes(added_ids, removed_ids, new_entry_map)

        return (len(added_ids), len(removed_ids), 0)

    def action_sync_entries(self):
        """Sync/reload entries from server without refreshing feeds.

        This fetches the latest entries that already exist on the Miniflux server
        without telling the server to fetch new content from RSS feeds.
        Use this to get entries that were added elsewhere or by another client.

        Uses run_worker to execute the sync in the background, keeping UI responsive.
        """
        self.run_worker(self._do_sync_entries(), exclusive=True)

    async def _do_sync_entries(self):
        """Background worker for syncing entries.

        This runs in the background allowing the UI to remain responsive
        while the sync operation completes.
        """
        try:
            # Start loading animation in header
            self._start_loading_animation("Syncing entries...")

            # Perform incremental sync
            new_count, removed_count, _ = await self._perform_incremental_sync()

            # Show summary message
            if new_count == 0 and removed_count == 0:
                self.notify("Entries are up to date", severity="information", timeout=2)
            else:
                details = []
                if new_count > 0:
                    details.append(f"+{new_count} new")
                if removed_count > 0:
                    details.append(f"-{removed_count} removed")
                summary = ", ".join(details)
                self.notify(f"Synced entries: {summary}", severity="information")

        except (ConnectionError, TimeoutError) as e:
            self.notify(f"Network error syncing entries: {e}", severity="error")
        except Exception as e:
            self.notify(f"Error syncing entries: {e}", severity="error")
        finally:
            # Always stop the loading animation
            self._stop_loading_animation()

    def action_show_unread(self):
        """Load and show only unread entries."""
        self.run_worker(self._do_show_unread(), exclusive=True)

    async def _do_show_unread(self):
        """Background worker for loading unread entries."""
        if hasattr(self.app, "load_entries"):
            try:
                # Start loading animation in header
                self._start_loading_animation("Loading unread entries...")

                await self.app.load_entries("unread")
                self.filter_unread_only = False
                self.filter_starred_only = False
                self._populate_list()
            finally:
                # Always stop the loading animation
                self._stop_loading_animation()

    def action_show_starred(self):
        """Load and show only starred entries."""
        self.run_worker(self._do_show_starred(), exclusive=True)

    async def _do_show_starred(self):
        """Background worker for loading starred entries."""
        if hasattr(self.app, "load_entries"):
            try:
                # Start loading animation in header
                self._start_loading_animation("Loading starred entries...")

                await self.app.load_entries("starred")
                self.filter_unread_only = False
                self.filter_starred_only = False
                self._populate_list()
            finally:
                # Always stop the loading animation
                self._stop_loading_animation()

    def action_clear_filters(self) -> None:
        """Clear all active filters and show all entries.

        Clears category, search, unread, and starred filters.
        """
        self.filter_category_id = None
        self.filter_unread_only = False
        self.filter_starred_only = False
        self.search_active = False
        self.search_term = ""
        self._populate_list()
        self.notify("All filters cleared")

    def set_category_filter(self, category_id: int | None) -> None:
        """Set category filter to show entries from a specific category.

        Args:
            category_id: ID of the category to filter by, or None to show all entries
        """
        self.filter_category_id = category_id
        self.filter_unread_only = False
        self.filter_starred_only = False
        self.search_active = False
        self.search_term = ""
        self._populate_list()

        # Find category name for notification
        category_name = "All entries"
        if category_id is not None:
            for cat in self.categories:
                if cat.id == category_id:
                    category_name = cat.title
                    break

        self.notify(f"Filtered to: {category_name}")

    def action_search(self):
        """Open search dialog to filter entries by search term.

        Shows an interactive input dialog for entering search terms.
        Searches entry titles and content.
        """
        # Import InputDialog locally to avoid circular dependency
        from miniflux_tui.ui.screens.input_dialog import InputDialog  # noqa: PLC0415

        # Callback when user submits search
        def on_search_submit(search_term: str) -> None:
            self.set_search_term(search_term)

        # Show input dialog with current search term as initial value
        dialog = InputDialog(
            title="Search Entries",
            label="Search in titles and content:",
            value=self.search_term,
            on_submit=on_search_submit,
        )
        self.app.push_screen(dialog)

    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}'")
        else:
            self.notify("Search cleared")

    async def action_manage_categories(self) -> None:
        """Open the category management screen."""
        await self.app.push_category_management_screen()

    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_show_settings(self):
        """Show user settings and integrations."""
        self.app.push_screen("settings")

    def action_show_history(self):
        """Show reading history."""
        self.app.log("action_show_history called - pushing history screen")
        try:
            self.app.push_screen("history")
            self.app.log("Successfully pushed history screen")
        except Exception as e:
            self.app.log(f"Error pushing history screen: {type(e).__name__}: {e}")
            self.app.notify(f"Failed to show history: {e}", severity="error")

    async def action_feed_settings(self) -> None:
        """Open feed settings screen for selected entry's feed."""
        # Import here to avoid circular dependency
        from miniflux_tui.ui.screens.feed_settings import FeedSettingsScreen  # noqa: PLC0415

        # Get the currently selected item
        if not self.list_view or not self.list_view.highlighted_child:
            self.notify("No entry selected", severity="warning")
            return

        # Get entry from selected item
        selected_item = self.list_view.highlighted_child
        if not isinstance(selected_item, EntryListItem):
            self.notify("Please select an entry first", severity="warning")
            return

        entry = selected_item.entry

        if not self.app.client:
            self.notify("API client not available", severity="error")
            return

        # Save the entry position for restoration when returning from feed settings
        # This matches the behavior in _open_entry()
        self.last_highlighted_feed = entry.feed.title
        self.last_highlighted_entry_id = 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

        # Push feed settings screen
        screen = FeedSettingsScreen(
            feed_id=entry.feed.id,
            feed=entry.feed,
            client=self.app.client,  # type: ignore[arg-type]
        )

        self.app.push_screen(screen)  # type: ignore[arg-type]

    def action_toggle_theme(self) -> None:
        """Toggle between dark and light themes."""
        self.app.toggle_theme()

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

action_clear_filters()

Clear all active filters and show all entries.

Clears category, search, unread, and starred filters.

Source code in miniflux_tui/ui/screens/entry_list.py
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
def action_clear_filters(self) -> None:
    """Clear all active filters and show all entries.

    Clears category, search, unread, and starred filters.
    """
    self.filter_category_id = None
    self.filter_unread_only = False
    self.filter_starred_only = False
    self.search_active = False
    self.search_term = ""
    self._populate_list()
    self.notify("All filters cleared")

action_collapse_all()

Collapse all feeds or categories (Shift+Z).

Source code in miniflux_tui/ui/screens/entry_list.py
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
def action_collapse_all(self):
    """Collapse all feeds or categories (Shift+Z)."""
    if not self.list_view or (not self.group_by_feed and not self.group_by_category):
        return

    # Collapse all feeds that are currently expanded
    if self.group_by_feed:
        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")

    # Collapse all categories that are currently expanded
    elif self.group_by_category:
        for category_title in self.category_fold_state:
            if self.category_fold_state[category_title]:
                self._set_category_fold_state(category_title, False)
        self.notify("All categories collapsed")

action_collapse_fold()

Collapse the highlighted feed or category (h or left arrow).

Source code in miniflux_tui/ui/screens/entry_list.py
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
def action_collapse_fold(self):
    """Collapse the highlighted feed or category (h or left arrow)."""
    if not self.list_view or (not self.group_by_feed and not self.group_by_category):
        return

    highlighted = self.list_view.highlighted_child

    # Handle feed grouping mode
    if self.group_by_feed and isinstance(highlighted, FeedHeaderItem):
        feed_title = highlighted.feed_title
        self.last_highlighted_feed = feed_title
        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)

    # Handle category grouping mode
    elif self.group_by_category and isinstance(highlighted, CategoryHeaderItem):
        category_title = highlighted.category_title
        self.last_highlighted_category = category_title
        is_currently_expanded = self.category_fold_state.get(category_title, not self.group_collapsed)
        if is_currently_expanded:
            self._set_category_fold_state(category_title, False)

    # Fallback for entry items: collapse their parent feed/category
    elif isinstance(highlighted, EntryListItem):
        if self.group_by_feed:
            feed_title = highlighted.entry.feed.title
            self.last_highlighted_feed = feed_title
            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)
        elif self.group_by_category:
            category_title = self._get_category_title(highlighted.entry.feed.category_id)
            self.last_highlighted_category = category_title
            is_currently_expanded = self.category_fold_state.get(category_title, not self.group_collapsed)
            if is_currently_expanded:
                self._set_category_fold_state(category_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
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
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:
        self._safe_log("action_cursor_down: No list view or no children")
        return

    try:
        current_index = self.list_view.index
        self._safe_log(f"action_cursor_down: current_index = {current_index}, children count = {len(self.list_view.children)}")

        # If index is None, start searching from -1 so range(0, ...) includes index 0
        if current_index is None:
            current_index = -1
            self._safe_log("action_cursor_down: index was None, starting from -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):
                is_visible = self._is_item_visible(widget)
                self._safe_log(f"  Checking index {i}: type={type(widget).__name__}, visible={is_visible}")
                if is_visible:
                    self._safe_log(f"action_cursor_down: Moving to visible item at index {i}")
                    self.list_view.index = i
                    return

        # If no visible item found below, stay at current position
        self._safe_log("action_cursor_down: No visible item found below current position")
    except (IndexError, ValueError, TypeError) as e:
        # Silently ignore index errors when navigating beyond list bounds
        self._safe_log(f"action_cursor_down: Exception: {type(e).__name__}: {e}")

action_cursor_up()

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

Source code in miniflux_tui/ui/screens/entry_list.py
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
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
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
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 or categories (Shift+G).

If not in grouped mode, enable feed grouping first. Then expand all collapsed items.

Source code in miniflux_tui/ui/screens/entry_list.py
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
def action_expand_all(self):
    """Expand all feeds or categories (Shift+G).

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

    # If not in grouped mode, enable feed grouping first
    if not self.group_by_feed and not self.group_by_category:
        self.action_toggle_group_feed()
        return

    # Expand all feeds that are currently collapsed
    if self.group_by_feed:
        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")

    # Expand all categories that are currently collapsed
    elif self.group_by_category:
        for category_title in self.category_fold_state:
            if not self.category_fold_state[category_title]:
                self._set_category_fold_state(category_title, True)
        self.notify("All categories expanded")

action_expand_fold()

Expand the highlighted feed or category (l or right arrow).

Source code in miniflux_tui/ui/screens/entry_list.py
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
def action_expand_fold(self):
    """Expand the highlighted feed or category (l or right arrow)."""
    if not self.list_view or (not self.group_by_feed and not self.group_by_category):
        return

    highlighted = self.list_view.highlighted_child

    # Handle feed grouping mode
    if self.group_by_feed and isinstance(highlighted, FeedHeaderItem):
        feed_title = highlighted.feed_title
        self.last_highlighted_feed = feed_title
        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)

    # Handle category grouping mode
    elif self.group_by_category and isinstance(highlighted, CategoryHeaderItem):
        category_title = highlighted.category_title
        self.last_highlighted_category = category_title
        is_currently_collapsed = not self.category_fold_state.get(category_title, not self.group_collapsed)
        if is_currently_collapsed:
            self._set_category_fold_state(category_title, True)

    # Fallback for entry items: expand their parent feed/category
    elif isinstance(highlighted, EntryListItem):
        if self.group_by_feed:
            feed_title = highlighted.entry.feed.title
            self.last_highlighted_feed = feed_title
            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)
        elif self.group_by_category:
            category_title = self._get_category_title(highlighted.entry.feed.category_id)
            self.last_highlighted_category = category_title
            is_currently_collapsed = not self.category_fold_state.get(category_title, not self.group_collapsed)
            if is_currently_collapsed:
                self._set_category_fold_state(category_title, True)

action_feed_settings() async

Open feed settings screen for selected entry's feed.

Source code in miniflux_tui/ui/screens/entry_list.py
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
async def action_feed_settings(self) -> None:
    """Open feed settings screen for selected entry's feed."""
    # Import here to avoid circular dependency
    from miniflux_tui.ui.screens.feed_settings import FeedSettingsScreen  # noqa: PLC0415

    # Get the currently selected item
    if not self.list_view or not self.list_view.highlighted_child:
        self.notify("No entry selected", severity="warning")
        return

    # Get entry from selected item
    selected_item = self.list_view.highlighted_child
    if not isinstance(selected_item, EntryListItem):
        self.notify("Please select an entry first", severity="warning")
        return

    entry = selected_item.entry

    if not self.app.client:
        self.notify("API client not available", severity="error")
        return

    # Save the entry position for restoration when returning from feed settings
    # This matches the behavior in _open_entry()
    self.last_highlighted_feed = entry.feed.title
    self.last_highlighted_entry_id = 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

    # Push feed settings screen
    screen = FeedSettingsScreen(
        feed_id=entry.feed.id,
        feed=entry.feed,
        client=self.app.client,  # type: ignore[arg-type]
    )

    self.app.push_screen(screen)  # type: ignore[arg-type]

action_g_prefix_mode()

Activate g-prefix mode for section navigation.

Sets the flag to wait for the next key, which will be interpreted as: - g: go to top (gg) - u: show unread entries - b: show starred entries - h: show history - c: go to categories - f: go to feeds - s: go to settings

Source code in miniflux_tui/ui/screens/entry_list.py
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
def action_g_prefix_mode(self) -> None:
    """Activate g-prefix mode for section navigation.

    Sets the flag to wait for the next key, which will be interpreted as:
    - g: go to top (gg)
    - u: show unread entries
    - b: show starred entries
    - h: show history
    - c: go to categories
    - f: go to feeds
    - s: go to settings
    """
    self._g_prefix_mode = True
    self.notify("g-prefix mode (press g/u/b/h/c/f/s)", timeout=2)

action_go_to_bottom()

Go to bottom of entry list (G).

Source code in miniflux_tui/ui/screens/entry_list.py
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
def action_go_to_bottom(self) -> None:
    """Go to bottom of entry list (G)."""
    if self.list_view and len(self.list_view.children) > 0:
        # Find last visible item (search backwards)
        for i in range(len(self.list_view.children) - 1, -1, -1):
            child = self.list_view.children[i]
            if isinstance(child, ListItem) and self._is_item_visible(child):
                self.list_view.index = i
                if self.list_view.highlighted_child:
                    self.list_view.scroll_to_center(self.list_view.highlighted_child, animate=False)
                self.notify("Jumped to bottom", timeout=1)
                return

action_go_to_top()

Go to top of entry list (gg).

Source code in miniflux_tui/ui/screens/entry_list.py
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
def action_go_to_top(self) -> None:
    """Go to top of entry list (gg)."""
    if self.list_view and len(self.list_view.children) > 0:
        # Find first visible item
        for i, child in enumerate(self.list_view.children):
            if isinstance(child, ListItem) and self._is_item_visible(child):
                self.list_view.index = i
                if self.list_view.highlighted_child:
                    self.list_view.scroll_to_center(self.list_view.highlighted_child, animate=False)
                self.notify("Jumped to top", timeout=1)
                return

action_manage_categories() async

Open the category management screen.

Source code in miniflux_tui/ui/screens/entry_list.py
2054
2055
2056
async def action_manage_categories(self) -> None:
    """Open the category management screen."""
    await self.app.push_category_management_screen()

action_mark_all_as_read() async

Mark all visible entries as read (A key) with confirmation.

Source code in miniflux_tui/ui/screens/entry_list.py
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
async def action_mark_all_as_read(self):
    """Mark all visible entries as read (A key) with confirmation."""
    if not self.list_view or len(self.list_view.children) == 0:
        self.notify("No entries to mark", severity="warning")
        return

    # Import here to avoid circular dependency
    from miniflux_tui.ui.screens.confirm_dialog import ConfirmDialog  # noqa: PLC0415

    # Count unread entries
    unread_count = sum(1 for entry in self.sorted_entries if entry.is_unread)
    if unread_count == 0:
        self.notify("No unread entries to mark", severity="warning")
        return

    def on_confirm() -> None:
        """Handle confirmation to mark all as read."""
        asyncio.create_task(self._do_mark_all_as_read())  # noqa: RUF006

    # Create confirmation dialog
    dialog = ConfirmDialog(
        title="Mark All as Read",
        message=f"Mark all {unread_count} unread entries as read?",
        confirm_label="Yes",
        cancel_label="No",
        on_confirm=on_confirm,
    )
    self.app.push_screen(dialog)

action_quit()

Quit the application.

Source code in miniflux_tui/ui/screens/entry_list.py
2124
2125
2126
def action_quit(self):
    """Quit the application."""
    self.app.exit()

action_refresh()

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

Source code in miniflux_tui/ui/screens/entry_list.py
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
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 item 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
    feed_title = None
    feed_id = None

    # Handle both feed headers and entry items
    if isinstance(highlighted, FeedHeaderItem):
        # User is on a feed header - get feed info from first entry in that feed
        feed_title = highlighted.feed_title
        # Find the first entry for this feed to get the feed_id
        for entry in self.sorted_entries:
            if entry.feed.title == feed_title:
                feed_id = entry.feed_id
                break
        if feed_id is None:
            self.notify("No entries found for this feed", severity="warning")
            return
    elif isinstance(highlighted, EntryListItem):
        # User is on an entry - get feed info from the entry
        feed_title = highlighted.entry.feed.title
        feed_id = highlighted.entry.feed_id
    else:
        self.notify("No feed selected", severity="warning")
        return

    # Run the refresh in background to keep UI responsive
    self.run_worker(self._do_refresh(feed_id, feed_title), exclusive=True)

action_refresh_all_feeds()

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

This tells the Miniflux server to fetch new content from RSS feeds. It does NOT reload entries - use 'comma' (,) to sync entries from server.

Source code in miniflux_tui/ui/screens/entry_list.py
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
def action_refresh_all_feeds(self):
    """Refresh all feeds on the server (Issue #55 - Feed operations).

    This tells the Miniflux server to fetch new content from RSS feeds.
    It does NOT reload entries - use 'comma' (,) to sync entries from server.
    """
    if not hasattr(self.app, "client") or not self.app.client:
        self.notify("API client not initialized", severity="error")
        return

    # Run the refresh in background to keep UI responsive
    self.run_worker(self._do_refresh_all_feeds(), exclusive=True)

action_save_entry() async

Save entry to third-party service.

Source code in miniflux_tui/ui/screens/entry_list.py
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
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
        with api_call(self, "saving entry") as client:
            if client is None:
                return

            await client.save_entry(highlighted.entry.id)
            self.notify(f"Entry saved: {highlighted.entry.title}")

Open search dialog to filter entries by search term.

Shows an interactive input dialog for entering search terms. Searches entry titles and content.

Source code in miniflux_tui/ui/screens/entry_list.py
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
def action_search(self):
    """Open search dialog to filter entries by search term.

    Shows an interactive input dialog for entering search terms.
    Searches entry titles and content.
    """
    # Import InputDialog locally to avoid circular dependency
    from miniflux_tui.ui.screens.input_dialog import InputDialog  # noqa: PLC0415

    # Callback when user submits search
    def on_search_submit(search_term: str) -> None:
        self.set_search_term(search_term)

    # Show input dialog with current search term as initial value
    dialog = InputDialog(
        title="Search Entries",
        label="Search in titles and content:",
        value=self.search_term,
        on_submit=on_search_submit,
    )
    self.app.push_screen(dialog)

action_show_categories() async

Go to categories (g+c).

Source code in miniflux_tui/ui/screens/entry_list.py
1266
1267
1268
async def action_show_categories(self) -> None:
    """Go to categories (g+c)."""
    await self.action_manage_categories()

action_show_feeds()

Go to feeds management (g+f).

Source code in miniflux_tui/ui/screens/entry_list.py
1270
1271
1272
1273
1274
1275
def action_show_feeds(self) -> None:
    """Go to feeds management (g+f)."""
    if hasattr(self.app, "push_feed_management_screen"):
        self.app.push_feed_management_screen()
    else:
        self.notify("Feed management not available", severity="warning")

action_show_help()

Show keyboard help.

Source code in miniflux_tui/ui/screens/entry_list.py
2058
2059
2060
def action_show_help(self):
    """Show keyboard help."""
    self.app.push_screen("help")

action_show_history()

Show reading history.

Source code in miniflux_tui/ui/screens/entry_list.py
2070
2071
2072
2073
2074
2075
2076
2077
2078
def action_show_history(self):
    """Show reading history."""
    self.app.log("action_show_history called - pushing history screen")
    try:
        self.app.push_screen("history")
        self.app.log("Successfully pushed history screen")
    except Exception as e:
        self.app.log(f"Error pushing history screen: {type(e).__name__}: {e}")
        self.app.notify(f"Failed to show history: {e}", severity="error")

action_show_settings()

Show user settings and integrations.

Source code in miniflux_tui/ui/screens/entry_list.py
2066
2067
2068
def action_show_settings(self):
    """Show user settings and integrations."""
    self.app.push_screen("settings")

action_show_starred()

Load and show only starred entries.

Source code in miniflux_tui/ui/screens/entry_list.py
1960
1961
1962
def action_show_starred(self):
    """Load and show only starred entries."""
    self.run_worker(self._do_show_starred(), exclusive=True)

action_show_status()

Show system status and feed health.

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

action_show_unread()

Load and show only unread entries.

Source code in miniflux_tui/ui/screens/entry_list.py
1941
1942
1943
def action_show_unread(self):
    """Load and show only unread entries."""
    self.run_worker(self._do_show_unread(), exclusive=True)

action_sync_entries()

Sync/reload entries from server without refreshing feeds.

This fetches the latest entries that already exist on the Miniflux server without telling the server to fetch new content from RSS feeds. Use this to get entries that were added elsewhere or by another client.

Uses run_worker to execute the sync in the background, keeping UI responsive.

Source code in miniflux_tui/ui/screens/entry_list.py
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
def action_sync_entries(self):
    """Sync/reload entries from server without refreshing feeds.

    This fetches the latest entries that already exist on the Miniflux server
    without telling the server to fetch new content from RSS feeds.
    Use this to get entries that were added elsewhere or by another client.

    Uses run_worker to execute the sync in the background, keeping UI responsive.
    """
    self.run_worker(self._do_sync_entries(), exclusive=True)

action_toggle_fold()

Toggle fold state of highlighted feed or category (o key).

Source code in miniflux_tui/ui/screens/entry_list.py
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
def action_toggle_fold(self):
    """Toggle fold state of highlighted feed or category (o key)."""
    if not self.list_view or (not self.group_by_feed and not self.group_by_category):
        return

    highlighted = self.list_view.highlighted_child

    # Handle feed grouping mode
    if self.group_by_feed and isinstance(highlighted, FeedHeaderItem):
        feed_title = highlighted.feed_title
        self.last_highlighted_feed = feed_title
        self.feed_fold_state[feed_title] = not self.feed_fold_state[feed_title]
        highlighted.toggle_fold()
        self._update_feed_visibility(feed_title)

    # Handle category grouping mode
    elif self.group_by_category and isinstance(highlighted, CategoryHeaderItem):
        category_title = highlighted.category_title
        self.last_highlighted_category = category_title
        self.category_fold_state[category_title] = not self.category_fold_state[category_title]
        highlighted.toggle_fold()
        self._update_category_visibility(category_title)

action_toggle_group_category()

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

Source code in miniflux_tui/ui/screens/entry_list.py
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
def action_toggle_group_category(self):
    """Toggle grouping by category (c key - Issue #54 - Category support)."""
    if not self.categories:
        self.notify("No categories available", severity="warning")
        return

    # Disable feed grouping when enabling category grouping
    if not self.group_by_category and self.group_by_feed:
        self.group_by_feed = False

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

    if self.group_by_category:
        # Clear existing fold states so new groups use config default
        self.category_fold_state.clear()
        self.notify("Grouping by category (use h/l to collapse/expand)")
    else:
        self.notify("Category grouping disabled")

    self._populate_list()

action_toggle_group_feed()

Toggle grouping by feed (g key).

Source code in miniflux_tui/ui/screens/entry_list.py
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
def action_toggle_group_feed(self):
    """Toggle grouping by feed (g key)."""
    # Disable category grouping when enabling feed grouping
    if not self.group_by_feed and self.group_by_category:
        self.group_by_category = False

    self.group_by_feed = not self.group_by_feed

    if self.group_by_feed:
        # Clear existing fold states so new groups use config default
        self.feed_fold_state.clear()
        self.notify("Grouping by feed (use h/l to collapse/expand)")
    else:
        self.notify("Feed grouping disabled")

    self._populate_list()

action_toggle_read() async

Toggle read/unread status of current entry.

Source code in miniflux_tui/ui/screens/entry_list.py
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
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
        with api_call(self, f"marking entry as {new_status}") as client:
            if client is None:
                return

            # 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_read_previous() async

Toggle read/unread status and focus previous entry (M key).

Source code in miniflux_tui/ui/screens/entry_list.py
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
async def action_toggle_read_previous(self):
    """Toggle read/unread status and focus previous entry (M key)."""
    if not self.list_view:
        return

    # First toggle the read status
    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
        with api_call(self, f"marking entry as {new_status}") as client:
            if client is None:
                return

            # 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}")

    # Then move to previous entry
    self.action_cursor_up()

action_toggle_star() async

Toggle star status of current entry.

Source code in miniflux_tui/ui/screens/entry_list.py
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
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
        with api_call(self, "toggling star status") as client:
            if client is None:
                return

            # 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}")

action_toggle_theme()

Toggle between dark and light themes.

Source code in miniflux_tui/ui/screens/entry_list.py
2120
2121
2122
def action_toggle_theme(self) -> None:
    """Toggle between dark and light themes."""
    self.app.toggle_theme()

compose()

Create child widgets.

Source code in miniflux_tui/ui/screens/entry_list.py
353
354
355
356
357
358
359
360
361
362
363
364
365
366
def compose(self) -> ComposeResult:
    """Create child widgets."""
    header = Header()
    list_view = CollapsibleListView()
    footer = Footer()

    # Store references for later use (e.g. focus management)
    self._header_widget = header
    self.list_view = list_view
    self._footer_widget = footer

    yield header
    yield list_view
    yield footer

on_list_view_selected(event)

Handle ListView selection (Enter key).

Source code in miniflux_tui/ui/screens/entry_list.py
405
406
407
408
409
410
411
412
413
414
415
def on_list_view_selected(self, event: ListView.Selected) -> None:
    """Handle ListView selection (Enter key)."""
    if event.item and isinstance(event.item, FeedHeaderItem):
        # Open first entry in the selected feed
        self._open_first_entry_by_feed(event.item.feed_title)
    elif event.item and isinstance(event.item, CategoryHeaderItem):
        # Open first entry in the selected category
        self._open_first_entry_by_category(event.item.category_title)
    elif event.item and isinstance(event.item, EntryListItem):
        # Open the selected entry directly
        self._open_entry(event.item.entry)

on_mount()

Called when screen is mounted.

Source code in miniflux_tui/ui/screens/entry_list.py
368
369
370
371
372
373
374
375
376
377
378
379
380
381
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(CollapsibleListView)
    self._safe_log(f"on_mount: list_view is now {self.list_view}")

    # Only populate if we have entries
    if self.entries:
        self._safe_log(f"on_mount: Populating with {len(self.entries)} entries")
        # _populate_list() now handles cursor restoration via call_later
        self._populate_list()
        # Note: _is_initial_mount is cleared in on_screen_resume after first display
    else:
        self._safe_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
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
def on_screen_resume(self) -> None:
    """Called when screen is resumed (e.g., after returning from entry reader)."""
    # On first resume (after on_mount), skip population and just clear the flag
    # on_mount already populated the list
    if self._is_initial_mount:
        self._safe_log("on_screen_resume: initial mount, clearing flag and skipping population")
        self._is_initial_mount = False
        return

    # Refresh the list to reflect any status changes when returning from other screens
    if self.entries and self.list_view:
        # _populate_list() now handles cursor restoration and focus via call_later
        self._populate_list()
    elif self.list_view and len(self.list_view.children) > 0:
        # If no entries, just ensure focus
        self.call_later(self._ensure_focus)

on_unmount()

Called when screen is unmounted - cleanup resources.

Source code in miniflux_tui/ui/screens/entry_list.py
383
384
385
386
def on_unmount(self) -> None:
    """Called when screen is unmounted - cleanup resources."""
    # Stop loading animation timer if it's running
    self._stop_loading_animation()

set_category_filter(category_id)

Set category filter to show entries from a specific category.

Parameters:

Name Type Description Default
category_id int | None

ID of the category to filter by, or None to show all entries

required
Source code in miniflux_tui/ui/screens/entry_list.py
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
def set_category_filter(self, category_id: int | None) -> None:
    """Set category filter to show entries from a specific category.

    Args:
        category_id: ID of the category to filter by, or None to show all entries
    """
    self.filter_category_id = category_id
    self.filter_unread_only = False
    self.filter_starred_only = False
    self.search_active = False
    self.search_term = ""
    self._populate_list()

    # Find category name for notification
    category_name = "All entries"
    if category_id is not None:
        for cat in self.categories:
            if cat.id == category_id:
                category_name = cat.title
                break

    self.notify(f"Filtered to: {category_name}")

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
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
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}'")
    else:
        self.notify("Search cleared")

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
 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
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
class EntryReaderScreen(Screen):
    """Screen for reading a single feed entry."""

    BINDINGS: list[Binding] = [  # noqa: RUF012
        # Scrolling
        Binding("j", "scroll_down", "Scroll Down", show=False),
        Binding("k", "scroll_up", "Scroll Up", show=False),
        Binding("pagedown", "page_down", "Page Down"),
        Binding("pageup", "page_up", "Page Up"),
        # Entry navigation (matches web interface)
        Binding("J", "next_entry", "Next Entry", show=True),
        Binding("K", "previous_entry", "Previous Entry", show=True),
        # Entry actions
        Binding("m", "mark_read", "Mark Read", show=False),
        Binding("u", "mark_unread", "Mark Unread"),
        Binding("f", "toggle_star", "Toggle Starred"),
        Binding("e", "save_entry", "Save Entry"),
        Binding("o", "open_browser", "Open in Browser"),
        Binding("v", "open_browser", "Open URL", show=False),
        Binding("d", "fetch_original", "Download Original"),
        # Link navigation
        Binding("tab", "next_link", "Next Link", show=True),
        Binding("shift+tab", "previous_link", "Previous Link", show=True),
        Binding("n", "next_link", "Next Link", show=False),
        Binding("p", "previous_link", "Previous Link", show=False),
        Binding("enter", "open_focused_link", "Open Link", show=True),
        Binding("c", "clear_link_focus", "Clear Link", show=True),
        # Navigation and settings
        Binding("b", "back", "Back to List"),
        Binding("escape", "back", "Back", show=False),
        Binding("X", "feed_settings", "Feed Settings"),
        # Help and status
        Binding("question_mark", "show_help", "Help"),
        Binding("i", "show_status", "Status"),
        Binding("S", "show_settings", "Settings"),
        Binding("q", "quit", "Quit"),
    ]

    app: EntryReaderAppProtocol

    DEFAULT_CSS = """
    EntryReaderScreen {
        layout: vertical;
    }

    .entry-title {
        height: auto;
    }

    .entry-meta {
        height: auto;
    }

    .entry-url {
        height: auto;
    }

    .separator {
        height: auto;
    }

    .entry-content {
        height: 1fr;
        overflow: auto;
    }

    /* Highlight focused links within Markdown */
    Markdown:focus-within {
        border: tall $accent;
    }

    #link-indicator {
        height: auto;
    }

    .link-highlight {
        background: $accent;
        color: $text;
        text-style: bold;
    }
    """

    def __init__(
        self,
        entry: Entry,
        entry_list: list | None = None,
        current_index: int = 0,
        unread_color: str = "cyan",
        read_color: str = "gray",
        group_info: dict[str, str | int] | None = None,
        link_highlight_bg: str | None = None,
        link_highlight_fg: str | None = None,
        **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.group_info = group_info  # Contains: mode, name, total, unread
        self.link_highlight_bg = link_highlight_bg or "#ff79c6"  # Default: pink/magenta
        self.link_highlight_fg = link_highlight_fg or "#282a36"  # Default: dark text
        self.scroll_container = None
        self.group_stats_widget: Static | None = None  # Reference to group stats widget for updates
        self.links: list[dict[str, str]] = []  # List of {text: str, url: str}
        self.focused_link_index: int | None = None  # Currently focused link index
        self.link_indicator: Static | None = None  # Widget to show focused link
        self.original_content: str = ""  # Store original markdown content for highlighting

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

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

        # Title and metadata (fixed height)
        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",
        )

        # Add group statistics if available
        group_stats_text = self._get_group_stats_text()
        if group_stats_text:
            group_stats_widget = Static(group_stats_text, classes="entry-meta")
            self.group_stats_widget = group_stats_widget
            yield group_stats_widget

        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)

        # Store original content for highlighting
        self.original_content = content

        # Extract links from content
        self.links = self._extract_links(content)

        # Scrollable markdown content (takes remaining height)
        yield Markdown(content, id="entry-content", classes="entry-content")

        # Link navigation indicator (fixed height)
        link_indicator = Static("", id="link-indicator", classes="link-indicator")
        self.link_indicator = link_indicator
        yield link_indicator

        yield Footer()

    async def on_mount(self) -> None:
        """Called when screen is mounted."""
        # Get reference to the Markdown widget (now the scrollable container)
        self.scroll_container = self.query_one(Markdown)

        # Set title to just the application name (no feed name or entry title)
        self.title = ""
        # Clear subtitle (remove counts from there)
        self.sub_title = ""

        # Check terminal size constraints
        self._check_terminal_size()

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

    def _check_terminal_size(self) -> None:
        """Check if terminal meets minimum size requirements.

        Validates:
        - Minimum 60 columns for readable content
        - Minimum 10 rows for Markdown content (plus ~2-3 rows for header/footer/metadata)

        Emits warning notifications if constraints aren't met.
        """
        # Get terminal size from app
        terminal_width = self.app.size.width  # type: ignore[attr-defined]
        terminal_height = self.app.size.height  # type: ignore[attr-defined]

        # Calculate available space for content (excluding header, footer, and metadata)
        # Header: 1 row
        # Footer: 1 row
        # Title: 1 row
        # Metadata: 1-2 rows
        # Separator: 1 row
        # Link indicator: 1 row
        # Total non-content rows: ~6-7 rows
        min_content_rows = 10
        min_content_width = 60
        min_total_rows = min_content_rows + 6

        warnings = []

        if terminal_height < min_total_rows:
            available_content_rows = max(1, terminal_height - 6)
            warnings.append(
                f"Terminal height ({terminal_height} rows) is below recommended minimum ({min_total_rows} rows). "
                f"Content area will have only ~{available_content_rows} rows for scrolling."
            )

        if terminal_width < min_content_width:
            warnings.append(
                f"Terminal width ({terminal_width} columns) is below recommended minimum ({min_content_width} columns). "
                f"Text may wrap awkwardly."
            )

        # Emit warnings
        for warning in warnings:
            self.notify(warning, severity="warning")

    def on_resize(self) -> None:
        """Handle terminal resize events.

        Re-checks terminal size constraints when terminal is resized.
        """
        self._check_terminal_size()

    def _resolve_app(self) -> EntryReaderAppProtocol | None:
        """Return the parent TUI app if it satisfies the expected protocol."""

        app = self.app
        if isinstance(app, EntryReaderAppProtocol):
            return app
        return None

    def _calculate_group_info(self) -> dict[str, str | int] | None:
        """Calculate group statistics from current entry and entry list.

        Returns:
            Dictionary with keys: mode, name, total, unread
            None if not in grouped mode
        """
        if not self.group_info:
            return None

        mode = self.group_info.get("mode")
        if mode not in ("feed", "category"):
            return None

        # Determine group identifier based on mode
        group_key = self.entry.feed.title if mode == "feed" else self._get_category_name()

        # Count entries in the same group
        total = 0
        unread = 0
        for entry in self.entry_list:
            entry_group = entry.feed.title if mode == "feed" else self._get_entry_category_name(entry)
            if entry_group == group_key:
                total += 1
                if entry.is_unread:
                    unread += 1

        return {
            "mode": mode,
            "name": group_key,
            "total": total,
            "unread": unread,
        }

    def _get_category_name(self) -> str:
        """Get category name for current entry.

        Returns:
            Category name or "Uncategorized"
        """
        if not hasattr(self.entry.feed, "category_id") or self.entry.feed.category_id is None:
            return "Uncategorized"

        # Try to get category name from app's categories
        app_obj = self.app
        if hasattr(app_obj, "categories"):
            # Type: ignore because protocol doesn't include categories
            for category in app_obj.categories:  # type: ignore[attr-defined]
                if category.id == self.entry.feed.category_id:
                    return category.title

        return f"Category {self.entry.feed.category_id}"

    def _get_entry_category_name(self, entry: Entry) -> str:
        """Get category name for a given entry.

        Args:
            entry: Entry to get category name for

        Returns:
            Category name or "Uncategorized"
        """
        if not hasattr(entry.feed, "category_id") or entry.feed.category_id is None:
            return "Uncategorized"

        # Try to get category name from app's categories
        app_obj = self.app
        if hasattr(app_obj, "categories"):
            # Type: ignore because protocol doesn't include categories
            for category in app_obj.categories:  # type: ignore[attr-defined]
                if category.id == entry.feed.category_id:
                    return category.title

        return f"Category {entry.feed.category_id}"

    def _update_sub_title(self) -> None:
        """Clear the sub_title (counts are now in feed header)."""
        # Subtitle is no longer used - title shows entry and feed info
        self.sub_title = ""

    def _get_group_stats_text(self) -> str:
        """Get formatted group statistics text for display in entry view.

        Returns:
            Formatted string with group statistics, or empty string if not in grouped mode
        """
        group_stats = self._calculate_group_info()
        if group_stats:
            mode = group_stats["mode"]
            unread = group_stats["unread"]
            total = group_stats["total"]
            mode_label = "Feed" if mode == "feed" else "Category"
            # Format as: "Feed: 5 unread / 20 total"
            return f"[dim]{mode_label}: {unread} unread / {total} total[/dim]"
        return ""

    async def _mark_entry_as_read(self):
        """Mark the current entry as read via API."""
        app = self._resolve_app()
        if app and app.client:
            try:
                await app.client.mark_as_read(self.entry.id)
                self.entry.status = "read"

                # Also update the entry in the entry_list if it exists there
                for entry in self.entry_list:
                    if entry.id == self.entry.id:
                        entry.status = "read"
                        break

                # Update group stats widget to reflect new unread count
                if self.group_stats_widget:
                    updated_stats = self._get_group_stats_text()
                    self.group_stats_widget.update(updated_stats)

                # Update sub_title to reflect new unread count
                self._update_sub_title()
            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")

    @staticmethod
    def _html_to_markdown(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)

    @staticmethod
    def _extract_links(markdown_content: str) -> list[dict[str, str]]:
        """Extract all links from markdown content.

        Finds both markdown-style links [text](url) and plain URLs in the content.

        Args:
            markdown_content: Markdown-formatted content

        Returns:
            List of dictionaries with 'text' and 'url' keys for each link found
        """
        links = []

        # Extract markdown links: [text](url)
        markdown_link_pattern = r"\[([^\]]+)\]\(([^)]+)\)"
        for match in re.finditer(markdown_link_pattern, markdown_content):
            text, url = match.groups()
            links.append({"text": text.strip(), "url": url.strip()})

        # Extract plain URLs (http/https) that aren't already in markdown links
        # This is a simple pattern - doesn't catch all edge cases
        plain_url_pattern = r"(?<!\()(https?://[^\s)\]]+)"
        for match in re.finditer(plain_url_pattern, markdown_content):
            url = match.group(1).strip()
            # Only add if not already in our links list
            if not any(link["url"] == url for link in links):
                links.append({"text": url, "url": url})

        return links

    def _ensure_scroll_container(self) -> Markdown:
        """Ensure markdown widget (scrollable container) is initialized and return it.

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

        Returns:
            The Markdown widget
        """
        if not self.scroll_container:
            self.scroll_container = self.query_one(Markdown)
        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."""
        app = self._resolve_app()
        if app:
            app.pop_screen()

    async def action_mark_read(self):
        """Mark entry as read."""
        await self._mark_entry_as_read()
        self.notify("Marked as read")

    async def action_mark_unread(self):
        """Mark entry as unread."""
        app = self._resolve_app()
        if app and app.client:
            try:
                await app.client.mark_as_unread(self.entry.id)
                self.entry.status = "unread"
                self.notify("Marked as unread")
                # Update sub_title to reflect new unread count
                self._update_sub_title()
            except Exception as e:
                self.notify(f"Error marking as unread: {e}", severity="error")

    async def action_toggle_star(self):
        """Toggle star status."""
        app = self._resolve_app()
        if app and app.client:
            try:
                await 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."""
        app = self._resolve_app()
        if app and app.client:
            try:
                await 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")

    @staticmethod
    def _is_safe_external_url(url: str) -> bool:
        """Return True if the URL uses an allowed scheme and has a hostname."""
        if not url:
            return False

        parsed = urlparse(url.strip())
        if parsed.scheme not in {"http", "https"}:
            return False
        if not parsed.netloc:
            return False

        return not any(ord(char) < 32 for char in url)

    def action_open_browser(self):
        """Open entry URL in web browser."""
        url = (self.entry.url or "").strip()
        if not url:
            self.notify("Entry does not contain a URL to open", severity="warning")
            return
        if not self._is_safe_external_url(url):
            self.notify("Refused to open unsafe entry URL", severity="error")
            if url:
                with suppress(Exception):
                    self.log(f"Blocked attempt to open unsafe URL: {url!r}")
            return

        try:
            webbrowser.open(url)
            self.notify(f"Opened in browser: {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."""
        app = self._resolve_app()
        if app and app.client:
            try:
                self.notify("Fetching original content...")

                # Fetch original content from API
                original_content = await 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()

        # Update sub_title with new group stats
        self._update_sub_title()

    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()

        # Update sub_title with new group stats
        self._update_sub_title()

    async def refresh_screen(self):
        """Refresh the screen with current entry.

        Updates all entry content widgets with the new entry's information
        and scrolls back to the top.
        """
        # Update title widget
        title_widgets = self.query(".entry-title")
        if title_widgets:
            star_icon = get_star_icon(self.entry.starred)
            title_widgets[0].update(f"[bold cyan]{star_icon} {self.entry.title}[/bold cyan]")  # type: ignore[union-attr]

        # Update metadata widget (feed name and date)
        meta_widgets = self.query(".entry-meta")
        if meta_widgets:
            meta_widgets[0].update(f"[dim]{self.entry.feed.title} | {self.entry.published_at.strftime('%Y-%m-%d %H:%M')}[/dim]")  # type: ignore[union-attr]

        # Update group stats if available (second meta widget if it exists)
        group_stats_text = self._get_group_stats_text()
        if group_stats_text:
            if len(meta_widgets) > 1:
                meta_widgets[1].update(group_stats_text)  # type: ignore[union-attr]
                self.group_stats_widget = meta_widgets[1]  # type: ignore[assignment]
            else:
                # Create new group stats widget if it doesn't exist
                title_widget = title_widgets[0] if title_widgets else None
                if title_widget and title_widget.parent:
                    group_stats = Static(group_stats_text, classes="entry-meta")
                    self.group_stats_widget = group_stats
                    # Insert after first meta widget
                    title_widget.parent.mount(group_stats, before=meta_widgets[0] if meta_widgets else None)  # type: ignore[union-attr]

        # Update URL widget
        url_widgets = self.query(".entry-url")
        if url_widgets:
            url_widgets[0].update(f"[dim]{self.entry.url}[/dim]")  # type: ignore[union-attr]

        # Update content (Markdown widget)
        markdown_widgets = self.query_one("#entry-content", expect_type=Markdown)  # type: ignore[arg-type]
        content = self._html_to_markdown(self.entry.content)
        markdown_widgets.update(content)

        # Extract links from new content
        self.links = self._extract_links(content)
        self.focused_link_index = None  # Reset link focus on new content

        # Update link indicator
        self._update_link_indicator()

        # Scroll back to top
        markdown_widgets.scroll_home(animate=False)

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

    async def action_feed_settings(self) -> None:
        """Open feed settings screen for current entry's feed."""
        # Import here to avoid circular dependency
        from miniflux_tui.ui.screens.feed_settings import FeedSettingsScreen  # noqa: PLC0415

        if not self.app.client:
            self.notify("API client not available", severity="error")
            return

        # Push feed settings screen
        screen = FeedSettingsScreen(
            feed_id=self.entry.feed.id,
            feed=self.entry.feed,
            client=self.app.client,  # type: ignore[arg-type]
        )
        self.app.push_screen(screen)  # type: ignore[arg-type]

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

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

    def action_show_settings(self):
        """Show user settings and integrations."""
        app = self._resolve_app()
        if app:
            app.push_screen("settings")

    def _get_markdown_link_widgets(self) -> list:
        """Get all link widgets from the Markdown widget.

        Returns:
            List of link widgets found in the Markdown content
        """
        try:
            markdown_widget = self.query_one("#entry-content", expect_type=Markdown)
            # Try to find link widgets within the Markdown widget
            # Links in Markdown might be nested in various ways
            return list(markdown_widget.query("Link"))
        except Exception:
            # If we can't query links, return empty list
            return []

    def _scroll_to_link(self, link_index: int):
        """Scroll to make the focused link visible.

        Args:
            link_index: Index of the link in self.links to scroll to
        """
        if not self.links or link_index < 0 or link_index >= len(self.links):
            return

        try:
            # Try to get link widgets from the Markdown widget
            link_widgets = self._get_markdown_link_widgets()

            if link_widgets and link_index < len(link_widgets):
                # If we have actual link widgets, focus and scroll to the specific one
                target_link = link_widgets[link_index]
                target_link.focus()
                target_link.scroll_visible(animate=True, duration=0.3, top=True)
            else:
                # Fallback: estimate position based on markdown content
                # This is an approximation since we don't have exact widget positions
                self._estimate_and_scroll_to_link(link_index)
        except Exception:  # nosec B110  # noqa: S110
            # Silently fail if scrolling isn't possible (e.g., screen not mounted)
            # This is expected in test contexts or when the widget isn't available
            # Intentional silent failure for graceful degradation
            pass

    def _estimate_and_scroll_to_link(self, link_index: int):
        """Estimate link position and scroll there (fallback method).

        Args:
            link_index: Index of the link to scroll to
        """
        try:
            markdown_widget = self.query_one("#entry-content", expect_type=Markdown)
            link = self.links[link_index]

            # Get the markdown content
            content = self._html_to_markdown(self.entry.content)

            # Find the position of the link in the content
            # For markdown links: [text](url)
            link_pattern = f"[{link['text']}]({link['url']})"
            pos = content.find(link_pattern)

            # If not found, try finding just the URL
            if pos == -1:
                pos = content.find(link["url"])

            if pos != -1:
                # Estimate the line number (rough approximation)
                # Count newlines before the link position
                lines_before = content[:pos].count("\n")

                # Get terminal height to calculate scroll position
                # Using screen size instead of app.size (which isn't in protocol)
                terminal_height = self.screen.size.height if hasattr(self.screen, "size") else 24
                content_height = markdown_widget.virtual_size.height

                # Calculate approximate Y position
                # This is rough - assumes even line distribution
                if content_height > 0:
                    y_pos = (lines_before / content.count("\n")) * content_height if content.count("\n") > 0 else 0

                    # Scroll to position (centered if possible)
                    offset = terminal_height // 3  # Show link in upper third of screen
                    scroll_y = max(0, y_pos - offset)

                    markdown_widget.scroll_to(y=scroll_y, animate=True, duration=0.3)
        except Exception:  # nosec B110  # noqa: S110
            # Silently fail if scrolling isn't possible (e.g., screen not mounted)
            # This is expected in test contexts or when the widget isn't available
            # Intentional silent failure for graceful degradation
            pass

    def _update_link_indicator(self):
        """Update the link indicator widget with current focused link info."""
        if not self.link_indicator:
            return

        if self.focused_link_index is None or not self.links:
            self.link_indicator.update("")
            return

        # Show focused link info
        link = self.links[self.focused_link_index]
        link_num = self.focused_link_index + 1
        total_links = len(self.links)

        # Truncate long URLs/text for display
        display_text = link["text"]
        if len(display_text) > 60:
            display_text = display_text[:57] + "..."

        display_url = link["url"]
        if len(display_url) > 80:
            display_url = display_url[:77] + "..."

        indicator_text = f"[bold yellow]Link {link_num}/{total_links}:[/bold yellow] [cyan]{display_text}[/cyan]\n[dim]{display_url}[/dim]"

        self.link_indicator.update(indicator_text)

    def _generate_highlighted_markdown(self, original_content: str) -> str:
        """Generate markdown with visual highlight for the focused link.

        This method re-renders the original markdown to add visual highlighting
        around the currently focused link using markdown formatting that the
        Markdown widget will interpret.

        Args:
            original_content: The original markdown content

        Returns:
            Markdown content with highlighting added for the focused link
        """
        if self.focused_link_index is None or not self.links or self.focused_link_index >= len(self.links):
            return original_content

        try:
            content = original_content
            focused_link = self.links[self.focused_link_index]
            link_url = focused_link["url"].strip()
            link_text = focused_link["text"].strip()

            # Find and replace the markdown link pattern with a highlighted version
            # Pattern: [text](url)
            # Use markdown's bold (**) and code block style for background effect
            # We'll wrap it with visual markers that work in terminal: ***text***
            markdown_pattern = rf"\[{re.escape(link_text)}\]\({re.escape(link_url)}\)"

            # Use markdown's emphasis to make it stand out: ***bold italic***
            # Or use code styling: `[text](url)` for monospace/highlight effect
            # Since we can't easily do background colors in markdown, we'll use
            # bold + inversion via combining multiple emphasis styles
            replacement = f"***[{link_text}]({link_url})***"

            return re.sub(markdown_pattern, replacement, content, count=1)
        except Exception:  # nosec B110
            # Silently fail and return original content if highlighting fails
            return original_content

    def _focus_link_widget(self) -> None:
        """Focus the Link widget corresponding to the current focused_link_index.

        This method attempts to manipulate link widgets if available, and falls back
        to re-rendering markdown with visual highlighting.
        """
        if self.focused_link_index is None or not self.links:
            return

        try:
            # Try the widget approach first (may not work in all cases)
            link_widgets = self._get_markdown_link_widgets()

            if link_widgets and self.focused_link_index < len(link_widgets):
                # Clear previous link styling
                for i, link_widget in enumerate(link_widgets):
                    if i != self.focused_link_index:
                        # Reset to default styles (remove inline styles)
                        link_widget.styles.clear()

                # Focus and style the specific link widget at the focused index
                target_link = link_widgets[self.focused_link_index]
                target_link.focus()

                # Apply inline styles with configured colors
                target_link.styles.background = self.link_highlight_bg
                target_link.styles.color = self.link_highlight_fg
                target_link.styles.text_style = "bold"

                # Scroll to make it visible
                target_link.scroll_visible(animate=True, duration=0.3, top=True)
        except Exception:  # nosec B110  # noqa: S110
            # Silently fail if focusing isn't possible (e.g., screen not mounted)
            # This is expected in test contexts or when the widget isn't available
            # Intentional silent failure for graceful degradation
            pass

    def _update_markdown_display(self):
        """Update the markdown display to highlight the currently focused link.

        This method regenerates the markdown content with visual highlighting
        for the focused link and scrolls to ensure it's visible on screen.
        """
        # Generate markdown with highlighting for the focused link
        if self.original_content:
            highlighted_md = self._generate_highlighted_markdown(self.original_content)

            try:
                markdown_widget = self.query_one("#entry-content", expect_type=Markdown)
                markdown_widget.update(highlighted_md)

                # Scroll to approximately where the link should be
                # Estimate based on link position in content
                if self.focused_link_index is not None:
                    self._scroll_to_link(self.focused_link_index)
            except Exception:  # nosec B110  # noqa: S110
                # If markdown update fails, that's okay - highlighting is optional
                pass

        # Also try the widget focusing approach as a fallback
        self._focus_link_widget()

    def action_next_link(self):
        """Navigate to the next link in the content."""
        if not self.links:
            self.notify("No links found in this entry", severity="warning")
            return

        if self.focused_link_index is None:
            # Start at first link
            self.focused_link_index = 0
        else:
            # Move to next link (wrap around)
            self.focused_link_index = (self.focused_link_index + 1) % len(self.links)

        self._update_link_indicator()
        # Update markdown display with highlighting (includes scrolling)
        self._update_markdown_display()

    def action_previous_link(self):
        """Navigate to the previous link in the content."""
        if not self.links:
            self.notify("No links found in this entry", severity="warning")
            return

        if self.focused_link_index is None:
            # Start at last link
            self.focused_link_index = len(self.links) - 1
        else:
            # Move to previous link (wrap around)
            self.focused_link_index = (self.focused_link_index - 1) % len(self.links)

        self._update_link_indicator()
        # Update markdown display with highlighting (includes scrolling)
        self._update_markdown_display()

    def action_open_focused_link(self):
        """Open the currently focused link in the browser."""
        if self.focused_link_index is None or not self.links:
            self.notify("No link focused. Use Tab to focus a link first.", severity="warning")
            return

        link = self.links[self.focused_link_index]
        url = link["url"].strip()

        if not self._is_safe_external_url(url):
            self.notify("Refused to open unsafe URL", severity="error")
            if url:
                with suppress(Exception):
                    self.log(f"Blocked attempt to open unsafe URL: {url!r}")
            return

        try:
            webbrowser.open(url)
            self.notify(f"Opened link: {link['text']}")
        except Exception as e:
            self.notify(f"Error opening link: {e}", severity="error")

    def action_clear_link_focus(self):
        """Clear the current link focus."""
        # First blur the currently focused link widget if any
        try:
            link_widgets = self._get_markdown_link_widgets()
            if link_widgets and self.focused_link_index is not None and self.focused_link_index < len(link_widgets):
                link_widgets[self.focused_link_index].blur()
        except Exception:  # nosec B110  # noqa: S110
            # Intentional silent failure for graceful degradation
            pass

        self.focused_link_index = None
        self._update_link_indicator()
        self.notify("Link focus cleared")

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

action_back()

Return to entry list.

Source code in miniflux_tui/ui/screens/entry_reader.py
461
462
463
464
465
def action_back(self):
    """Return to entry list."""
    app = self._resolve_app()
    if app:
        app.pop_screen()

Clear the current link focus.

Source code in miniflux_tui/ui/screens/entry_reader.py
966
967
968
969
970
971
972
973
974
975
976
977
978
979
def action_clear_link_focus(self):
    """Clear the current link focus."""
    # First blur the currently focused link widget if any
    try:
        link_widgets = self._get_markdown_link_widgets()
        if link_widgets and self.focused_link_index is not None and self.focused_link_index < len(link_widgets):
            link_widgets[self.focused_link_index].blur()
    except Exception:  # nosec B110  # noqa: S110
        # Intentional silent failure for graceful degradation
        pass

    self.focused_link_index = None
    self._update_link_indicator()
    self.notify("Link focus cleared")

action_feed_settings() async

Open feed settings screen for current entry's feed.

Source code in miniflux_tui/ui/screens/entry_reader.py
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
async def action_feed_settings(self) -> None:
    """Open feed settings screen for current entry's feed."""
    # Import here to avoid circular dependency
    from miniflux_tui.ui.screens.feed_settings import FeedSettingsScreen  # noqa: PLC0415

    if not self.app.client:
        self.notify("API client not available", severity="error")
        return

    # Push feed settings screen
    screen = FeedSettingsScreen(
        feed_id=self.entry.feed.id,
        feed=self.entry.feed,
        client=self.app.client,  # type: ignore[arg-type]
    )
    self.app.push_screen(screen)  # type: ignore[arg-type]

action_fetch_original() async

Fetch original content from source.

Source code in miniflux_tui/ui/screens/entry_reader.py
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
async def action_fetch_original(self):
    """Fetch original content from source."""
    app = self._resolve_app()
    if app and app.client:
        try:
            self.notify("Fetching original content...")

            # Fetch original content from API
            original_content = await 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_read() async

Mark entry as read.

Source code in miniflux_tui/ui/screens/entry_reader.py
467
468
469
470
async def action_mark_read(self):
    """Mark entry as read."""
    await self._mark_entry_as_read()
    self.notify("Marked as read")

action_mark_unread() async

Mark entry as unread.

Source code in miniflux_tui/ui/screens/entry_reader.py
472
473
474
475
476
477
478
479
480
481
482
483
async def action_mark_unread(self):
    """Mark entry as unread."""
    app = self._resolve_app()
    if app and app.client:
        try:
            await app.client.mark_as_unread(self.entry.id)
            self.entry.status = "unread"
            self.notify("Marked as unread")
            # Update sub_title to reflect new unread count
            self._update_sub_title()
        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
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
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()

    # Update sub_title with new group stats
    self._update_sub_title()

Navigate to the next link in the content.

Source code in miniflux_tui/ui/screens/entry_reader.py
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
def action_next_link(self):
    """Navigate to the next link in the content."""
    if not self.links:
        self.notify("No links found in this entry", severity="warning")
        return

    if self.focused_link_index is None:
        # Start at first link
        self.focused_link_index = 0
    else:
        # Move to next link (wrap around)
        self.focused_link_index = (self.focused_link_index + 1) % len(self.links)

    self._update_link_indicator()
    # Update markdown display with highlighting (includes scrolling)
    self._update_markdown_display()

action_open_browser()

Open entry URL in web browser.

Source code in miniflux_tui/ui/screens/entry_reader.py
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
def action_open_browser(self):
    """Open entry URL in web browser."""
    url = (self.entry.url or "").strip()
    if not url:
        self.notify("Entry does not contain a URL to open", severity="warning")
        return
    if not self._is_safe_external_url(url):
        self.notify("Refused to open unsafe entry URL", severity="error")
        if url:
            with suppress(Exception):
                self.log(f"Blocked attempt to open unsafe URL: {url!r}")
        return

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

Open the currently focused link in the browser.

Source code in miniflux_tui/ui/screens/entry_reader.py
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
def action_open_focused_link(self):
    """Open the currently focused link in the browser."""
    if self.focused_link_index is None or not self.links:
        self.notify("No link focused. Use Tab to focus a link first.", severity="warning")
        return

    link = self.links[self.focused_link_index]
    url = link["url"].strip()

    if not self._is_safe_external_url(url):
        self.notify("Refused to open unsafe URL", severity="error")
        if url:
            with suppress(Exception):
                self.log(f"Blocked attempt to open unsafe URL: {url!r}")
        return

    try:
        webbrowser.open(url)
        self.notify(f"Opened link: {link['text']}")
    except Exception as e:
        self.notify(f"Error opening link: {e}", severity="error")

action_page_down()

Scroll down one page.

Source code in miniflux_tui/ui/screens/entry_reader.py
453
454
455
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
457
458
459
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
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
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()

    # Update sub_title with new group stats
    self._update_sub_title()

Navigate to the previous link in the content.

Source code in miniflux_tui/ui/screens/entry_reader.py
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
def action_previous_link(self):
    """Navigate to the previous link in the content."""
    if not self.links:
        self.notify("No links found in this entry", severity="warning")
        return

    if self.focused_link_index is None:
        # Start at last link
        self.focused_link_index = len(self.links) - 1
    else:
        # Move to previous link (wrap around)
        self.focused_link_index = (self.focused_link_index - 1) % len(self.links)

    self._update_link_indicator()
    # Update markdown display with highlighting (includes scrolling)
    self._update_markdown_display()

action_quit()

Quit the application.

Source code in miniflux_tui/ui/screens/entry_reader.py
981
982
983
984
985
def action_quit(self):
    """Quit the application."""
    app = self._resolve_app()
    if app:
        app.exit()

action_save_entry() async

Save entry to third-party service.

Source code in miniflux_tui/ui/screens/entry_reader.py
500
501
502
503
504
505
506
507
508
async def action_save_entry(self):
    """Save entry to third-party service."""
    app = self._resolve_app()
    if app and app.client:
        try:
            await 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
445
446
447
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
449
450
451
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
673
674
675
676
677
def action_show_help(self):
    """Show keyboard help."""
    app = self._resolve_app()
    if app:
        app.push_screen("help")

action_show_settings()

Show user settings and integrations.

Source code in miniflux_tui/ui/screens/entry_reader.py
685
686
687
688
689
def action_show_settings(self):
    """Show user settings and integrations."""
    app = self._resolve_app()
    if app:
        app.push_screen("settings")

action_show_status()

Show system status and feed health.

Source code in miniflux_tui/ui/screens/entry_reader.py
679
680
681
682
683
def action_show_status(self):
    """Show system status and feed health."""
    app = self._resolve_app()
    if app:
        app.push_screen("status")

action_toggle_star() async

Toggle star status.

Source code in miniflux_tui/ui/screens/entry_reader.py
485
486
487
488
489
490
491
492
493
494
495
496
497
498
async def action_toggle_star(self):
    """Toggle star status."""
    app = self._resolve_app()
    if app and app.client:
        try:
            await 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
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
def compose(self) -> ComposeResult:
    """Create child widgets."""
    yield Header()

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

    # Title and metadata (fixed height)
    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",
    )

    # Add group statistics if available
    group_stats_text = self._get_group_stats_text()
    if group_stats_text:
        group_stats_widget = Static(group_stats_text, classes="entry-meta")
        self.group_stats_widget = group_stats_widget
        yield group_stats_widget

    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)

    # Store original content for highlighting
    self.original_content = content

    # Extract links from content
    self.links = self._extract_links(content)

    # Scrollable markdown content (takes remaining height)
    yield Markdown(content, id="entry-content", classes="entry-content")

    # Link navigation indicator (fixed height)
    link_indicator = Static("", id="link-indicator", classes="link-indicator")
    self.link_indicator = link_indicator
    yield link_indicator

    yield Footer()

on_mount() async

Called when screen is mounted.

Source code in miniflux_tui/ui/screens/entry_reader.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
async def on_mount(self) -> None:
    """Called when screen is mounted."""
    # Get reference to the Markdown widget (now the scrollable container)
    self.scroll_container = self.query_one(Markdown)

    # Set title to just the application name (no feed name or entry title)
    self.title = ""
    # Clear subtitle (remove counts from there)
    self.sub_title = ""

    # Check terminal size constraints
    self._check_terminal_size()

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

on_resize()

Handle terminal resize events.

Re-checks terminal size constraints when terminal is resized.

Source code in miniflux_tui/ui/screens/entry_reader.py
241
242
243
244
245
246
def on_resize(self) -> None:
    """Handle terminal resize events.

    Re-checks terminal size constraints when terminal is resized.
    """
    self._check_terminal_size()

refresh_screen() async

Refresh the screen with current entry.

Updates all entry content widgets with the new entry's information and scrolls back to the top.

Source code in miniflux_tui/ui/screens/entry_reader.py
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
async def refresh_screen(self):
    """Refresh the screen with current entry.

    Updates all entry content widgets with the new entry's information
    and scrolls back to the top.
    """
    # Update title widget
    title_widgets = self.query(".entry-title")
    if title_widgets:
        star_icon = get_star_icon(self.entry.starred)
        title_widgets[0].update(f"[bold cyan]{star_icon} {self.entry.title}[/bold cyan]")  # type: ignore[union-attr]

    # Update metadata widget (feed name and date)
    meta_widgets = self.query(".entry-meta")
    if meta_widgets:
        meta_widgets[0].update(f"[dim]{self.entry.feed.title} | {self.entry.published_at.strftime('%Y-%m-%d %H:%M')}[/dim]")  # type: ignore[union-attr]

    # Update group stats if available (second meta widget if it exists)
    group_stats_text = self._get_group_stats_text()
    if group_stats_text:
        if len(meta_widgets) > 1:
            meta_widgets[1].update(group_stats_text)  # type: ignore[union-attr]
            self.group_stats_widget = meta_widgets[1]  # type: ignore[assignment]
        else:
            # Create new group stats widget if it doesn't exist
            title_widget = title_widgets[0] if title_widgets else None
            if title_widget and title_widget.parent:
                group_stats = Static(group_stats_text, classes="entry-meta")
                self.group_stats_widget = group_stats
                # Insert after first meta widget
                title_widget.parent.mount(group_stats, before=meta_widgets[0] if meta_widgets else None)  # type: ignore[union-attr]

    # Update URL widget
    url_widgets = self.query(".entry-url")
    if url_widgets:
        url_widgets[0].update(f"[dim]{self.entry.url}[/dim]")  # type: ignore[union-attr]

    # Update content (Markdown widget)
    markdown_widgets = self.query_one("#entry-content", expect_type=Markdown)  # type: ignore[arg-type]
    content = self._html_to_markdown(self.entry.content)
    markdown_widgets.update(content)

    # Extract links from new content
    self.links = self._extract_links(content)
    self.focused_link_index = None  # Reset link focus on new content

    # Update link indicator
    self._update_link_indicator()

    # Scroll back to top
    markdown_widgets.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
 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
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
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:  # noqa: PLR0915
        """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("[bold white]Section Navigation (g-prefix)[/bold white]")
            yield Static("  g+u             Go to unread entries")
            yield Static("  g+b             Go to starred/bookmarked entries")
            yield Static("  g+h             Go to history")
            yield Static("  g+c             Group entries by category (with counts)")
            yield Static("  g+C             Go to category management")
            yield Static("  g+f             Go to feed management")
            yield Static("  g+s             Go to settings")
            yield Static("  gg              Go to top of list")
            yield Static("  G               Go to bottom of list")
            yield Static("")
            yield Static("[bold white]Navigation[/bold white]")
            yield Static("  ↑/↓ or k/j/p/n  Navigate entries")
            yield Static("  h or ←          Collapse individual feed/category")
            yield Static("  l or →          Expand individual feed/category")
            yield Static("  Enter           Open entry (or first in feed if on header)")
            yield Static("")
            yield Static("[bold white]Entry Actions[/bold white]")
            yield Static("  m               Toggle read/unread")
            yield Static("  M               Toggle read/unread (focus previous)")
            yield Static("  f               Toggle star/bookmark")
            yield Static("  e               Save entry to third-party service")
            yield Static("  A               Mark all entries as read")
            yield Static("")
            yield Static("[bold white]View Controls[/bold white]")
            yield Static("  s               Cycle sort mode (date/feed/status)")
            yield Static("  w               Toggle grouping by feed")
            yield Static("  C               Toggle grouping by category")
            yield Static("  Shift+L         Expand all feeds")
            yield Static("  Z               Collapse all feeds")
            yield Static("  /               Search entries (interactive dialog)")
            yield Static("  [dim]Feed headers show category and error status[/dim]")
            yield Static("")
            yield Static("[bold white]Feed Operations[/bold white]")
            yield Static("  r               Refresh current feed on server")
            yield Static("  R               Refresh all feeds on server")
            yield Static("  ,               Sync entries from server (fetch new)")
            yield Static("  [dim]Use 'r' or 'R' to tell server to fetch RSS, then ',' to sync[/dim]")
            yield Static("")
            yield Static("[bold white]Other Actions[/bold white]")
            yield Static("  X               Edit feed settings")
            yield Static("  T               Toggle theme (dark/light)")
            yield Static("  ?               Show this help")
            yield Static("  i               Show system status")
            yield Static("  H               Go to reading history (or g+h)")
            yield Static("  S               Go to settings (or g+s)")
            yield Static("  q               Quit application\n")

            yield Static("[bold yellow]Reading History View[/bold yellow]")
            yield Static("  [dim]Shows your 200 most recently read entries[/dim]")
            yield Static("  ↑/↓ or k/j      Navigate entries")
            yield Static("  Enter           Open entry")
            yield Static("  m               Toggle read/unread")
            yield Static("  *               Toggle star")
            yield Static("  H               Return to main entry list")
            yield Static("  [dim]All other entry list keys work the same[/dim]\n")

            yield Static("[bold yellow]Category Management View[/bold yellow]")
            yield Static("  ↑/↓ or k/j      Navigate categories")
            yield Static("  n               Create new category")
            yield Static("  e               Edit selected category name")
            yield Static("  d               Delete selected category")
            yield Static("  Esc or q        Return to entry list\n")

            yield Static("[bold yellow]Entry Reader View[/bold yellow]")
            yield Static("[bold white]Navigation[/bold white]")
            yield Static("  ↑/↓ or k/j      Scroll up/down")
            yield Static("  PageUp/PageDown Fast scroll")
            yield Static("  J               Next entry")
            yield Static("  K               Previous entry")
            yield Static("  b or Esc        Back to list")
            yield Static("")
            yield Static("[bold white]Entry Actions[/bold white]")
            yield Static("  m               Mark as read")
            yield Static("  u               Mark as unread")
            yield Static("  f               Toggle star/bookmark")
            yield Static("  e               Save entry to third-party service")
            yield Static("  o or v          Open in browser")
            yield Static("  d               Download/fetch original content")
            yield Static("  Tab/Shift+Tab   Navigate links in content")
            yield Static("  Enter           Open focused link")
            yield Static("  c               Clear link focus")
            yield Static("")
            yield Static("[bold white]Other Actions[/bold white]")
            yield Static("  X               Edit feed settings")
            yield Static("  ?               Show this help")
            yield Static("  i               Show system status")
            yield Static("  S               Go to settings")
            yield Static("  q               Quit application\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()

    @staticmethod
    def _get_about_text() -> 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
25
26
27
28
29
30
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
218
219
220
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
 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
def compose(self) -> ComposeResult:  # noqa: PLR0915
    """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("[bold white]Section Navigation (g-prefix)[/bold white]")
        yield Static("  g+u             Go to unread entries")
        yield Static("  g+b             Go to starred/bookmarked entries")
        yield Static("  g+h             Go to history")
        yield Static("  g+c             Group entries by category (with counts)")
        yield Static("  g+C             Go to category management")
        yield Static("  g+f             Go to feed management")
        yield Static("  g+s             Go to settings")
        yield Static("  gg              Go to top of list")
        yield Static("  G               Go to bottom of list")
        yield Static("")
        yield Static("[bold white]Navigation[/bold white]")
        yield Static("  ↑/↓ or k/j/p/n  Navigate entries")
        yield Static("  h or ←          Collapse individual feed/category")
        yield Static("  l or →          Expand individual feed/category")
        yield Static("  Enter           Open entry (or first in feed if on header)")
        yield Static("")
        yield Static("[bold white]Entry Actions[/bold white]")
        yield Static("  m               Toggle read/unread")
        yield Static("  M               Toggle read/unread (focus previous)")
        yield Static("  f               Toggle star/bookmark")
        yield Static("  e               Save entry to third-party service")
        yield Static("  A               Mark all entries as read")
        yield Static("")
        yield Static("[bold white]View Controls[/bold white]")
        yield Static("  s               Cycle sort mode (date/feed/status)")
        yield Static("  w               Toggle grouping by feed")
        yield Static("  C               Toggle grouping by category")
        yield Static("  Shift+L         Expand all feeds")
        yield Static("  Z               Collapse all feeds")
        yield Static("  /               Search entries (interactive dialog)")
        yield Static("  [dim]Feed headers show category and error status[/dim]")
        yield Static("")
        yield Static("[bold white]Feed Operations[/bold white]")
        yield Static("  r               Refresh current feed on server")
        yield Static("  R               Refresh all feeds on server")
        yield Static("  ,               Sync entries from server (fetch new)")
        yield Static("  [dim]Use 'r' or 'R' to tell server to fetch RSS, then ',' to sync[/dim]")
        yield Static("")
        yield Static("[bold white]Other Actions[/bold white]")
        yield Static("  X               Edit feed settings")
        yield Static("  T               Toggle theme (dark/light)")
        yield Static("  ?               Show this help")
        yield Static("  i               Show system status")
        yield Static("  H               Go to reading history (or g+h)")
        yield Static("  S               Go to settings (or g+s)")
        yield Static("  q               Quit application\n")

        yield Static("[bold yellow]Reading History View[/bold yellow]")
        yield Static("  [dim]Shows your 200 most recently read entries[/dim]")
        yield Static("  ↑/↓ or k/j      Navigate entries")
        yield Static("  Enter           Open entry")
        yield Static("  m               Toggle read/unread")
        yield Static("  *               Toggle star")
        yield Static("  H               Return to main entry list")
        yield Static("  [dim]All other entry list keys work the same[/dim]\n")

        yield Static("[bold yellow]Category Management View[/bold yellow]")
        yield Static("  ↑/↓ or k/j      Navigate categories")
        yield Static("  n               Create new category")
        yield Static("  e               Edit selected category name")
        yield Static("  d               Delete selected category")
        yield Static("  Esc or q        Return to entry list\n")

        yield Static("[bold yellow]Entry Reader View[/bold yellow]")
        yield Static("[bold white]Navigation[/bold white]")
        yield Static("  ↑/↓ or k/j      Scroll up/down")
        yield Static("  PageUp/PageDown Fast scroll")
        yield Static("  J               Next entry")
        yield Static("  K               Previous entry")
        yield Static("  b or Esc        Back to list")
        yield Static("")
        yield Static("[bold white]Entry Actions[/bold white]")
        yield Static("  m               Mark as read")
        yield Static("  u               Mark as unread")
        yield Static("  f               Toggle star/bookmark")
        yield Static("  e               Save entry to third-party service")
        yield Static("  o or v          Open in browser")
        yield Static("  d               Download/fetch original content")
        yield Static("  Tab/Shift+Tab   Navigate links in content")
        yield Static("  Enter           Open focused link")
        yield Static("  c               Clear link focus")
        yield Static("")
        yield Static("[bold white]Other Actions[/bold white]")
        yield Static("  X               Edit feed settings")
        yield Static("  ?               Show this help")
        yield Static("  i               Show system status")
        yield Static("  S               Go to settings")
        yield Static("  q               Quit application\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
176
177
178
async def on_mount(self) -> None:
    """Called when screen is mounted - load server information."""
    await self._load_server_info()

CategoryManagementScreen

Screen for managing categories (create, edit, delete, view).

Bases: Screen

Screen for managing categories (create, edit, delete, view).

Features: - List all categories - Create new categories - Edit category names - Delete categories with confirmation

Source code in miniflux_tui/ui/screens/category_management.py
 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
class CategoryManagementScreen(Screen):
    """Screen for managing categories (create, edit, delete, view).

    Features:
    - List all categories
    - Create new categories
    - Edit category names
    - Delete categories with confirmation
    """

    BINDINGS: ClassVar = [
        Binding("j", "cursor_down", "Down", show=False),
        Binding("k", "cursor_up", "Up", show=False),
        Binding("down", "cursor_down", "Down", show=False),
        Binding("up", "cursor_up", "Up", show=False),
        Binding("n", "create_category", "New Category"),
        Binding("e", "edit_category", "Edit"),
        Binding("d", "delete_category", "Delete"),
        Binding("escape", "back", "Back"),
    ]

    CSS = """
    CategoryManagementScreen {
        layout: vertical;
    }

    CategoryManagementScreen > Header {
        dock: top;
    }

    CategoryManagementScreen > Footer {
        dock: bottom;
    }

    CategoryManagementScreen #category-list {
        border: solid $accent;
        height: 1fr;
    }

    CategoryManagementScreen ListItem {
        padding: 0 1;
        height: 1;
    }

    CategoryManagementScreen ListItem Label {
        width: 1fr;
    }
    """

    def __init__(self, categories: list[Category] | None = None, entries: list["Entry"] | None = None, **kwargs):
        """Initialize category management screen.

        Args:
            categories: List of categories to display
            entries: List of all entries to calculate counts from
        """
        super().__init__(**kwargs)
        self.categories = categories or []
        self.entries = entries or []
        self.list_view: ListView | None = None

    def compose(self) -> ComposeResult:
        """Create child widgets."""
        yield Header()
        list_view = ListView(id="category-list")
        self.list_view = list_view
        with Container():
            yield list_view
        yield Footer()

    def on_mount(self) -> None:
        """Initialize list view with categories."""
        self._populate_list()

    def _populate_list(self) -> None:
        """Populate the list view with category items, including entry counts."""
        if self.list_view is None:
            return

        self.list_view.clear()
        for category in self.categories:
            # Calculate entry counts for this category
            unread_count = sum(1 for entry in self.entries if entry.feed.category_id == category.id and entry.is_unread)
            read_count = sum(1 for entry in self.entries if entry.feed.category_id == category.id and not entry.is_unread)

            self.list_view.append(CategoryListItem(category, unread_count, read_count))

        # Focus the list view
        if self.list_view.children:
            self.set_focus(self.list_view)

    def _get_selected_category(self) -> Category | None:
        """Get the currently selected category.

        Returns:
            The selected Category or None if nothing is selected
        """
        if self.list_view is None or self.list_view.index is None:
            return None

        try:
            highlighted_child = self.list_view.children[self.list_view.index]
            if isinstance(highlighted_child, CategoryListItem):
                return highlighted_child.category
        except (IndexError, AttributeError):
            # If the list is empty or the child cannot be accessed, return None
            pass

        return None

    async def action_create_category(self) -> None:
        """Show dialog to create a new category."""

        def on_submit(title: str) -> None:
            """Handle category creation."""
            if not title or not title.strip():
                self.app.notify("Category name cannot be empty", severity="error")
                return

            asyncio.create_task(self._do_create_category(title.strip()))  # noqa: RUF006

        dialog = InputDialog(
            title="Create Category",
            label="Category name:",
            on_submit=on_submit,
        )
        self.app.push_screen(dialog)

    async def _do_create_category(self, title: str) -> None:
        """Create a new category via API.

        Args:
            title: The name of the new category
        """
        with api_call(self, "creating category") as app:  # type: ignore
            if app is None:
                return

            try:
                new_category = await app.client.create_category(title)
                self.categories.append(new_category)
                self._populate_list()
                self.app.notify(f"Created category: {title}")
            except Exception as e:
                error_msg = sanitize_error_message(e, "creating category")
                self.app.notify(f"Failed to create category: {error_msg}", severity="error")

    async def action_edit_category(self) -> None:
        """Show dialog to edit the selected category."""
        selected = self._get_selected_category()
        if selected is None:
            self.app.notify("No category selected", severity="warning")
            return

        def on_submit(new_title: str) -> None:
            """Handle category edit."""
            if not new_title or not new_title.strip():
                self.app.notify("Category name cannot be empty", severity="error")
                return

            asyncio.create_task(self._do_edit_category(selected.id, new_title.strip()))  # noqa: RUF006

        dialog = InputDialog(
            title="Edit Category",
            label="New name:",
            value=selected.title,
            on_submit=on_submit,
        )
        self.app.push_screen(dialog)

    async def _do_edit_category(self, category_id: int, new_title: str) -> None:
        """Update a category via API.

        Args:
            category_id: ID of the category to update
            new_title: New name for the category
        """
        with api_call(self, "updating category") as app:  # type: ignore
            if app is None:
                return

            try:
                updated_category = await app.client.update_category(category_id, new_title)
                # Update in our list
                for i, cat in enumerate(self.categories):
                    if cat.id == category_id:
                        self.categories[i] = updated_category
                        break
                self._populate_list()
                self.app.notify(f"Updated category to: {new_title}")
            except Exception as e:
                error_msg = sanitize_error_message(e, "updating category")
                self.app.notify(f"Failed to update category: {error_msg}", severity="error")

    async def action_delete_category(self) -> None:
        """Show confirmation dialog to delete selected category."""
        selected = self._get_selected_category()
        if selected is None:
            self.app.notify("No category selected", severity="warning")
            return

        def on_confirm() -> None:
            """Handle deletion confirmation."""
            asyncio.create_task(self._do_delete_category(selected.id, selected.title))  # noqa: RUF006

        dialog = ConfirmDialog(
            title="Delete Category?",
            message=f"Delete category: {selected.title}?\n\n(Feeds in this category will be moved to Uncategorized)",
            confirm_label="Delete",
            cancel_label="Cancel",
            on_confirm=on_confirm,
        )
        self.app.push_screen(dialog)

    async def _do_delete_category(self, category_id: int, category_title: str) -> None:
        """Delete a category via API.

        Args:
            category_id: ID of the category to delete
            category_title: Title of the category (for notifications)
        """
        with api_call(self, "deleting category") as app:  # type: ignore
            if app is None:
                return

            try:
                await app.client.delete_category(category_id)
                self.categories = [c for c in self.categories if c.id != category_id]
                self._populate_list()
                self.app.notify(f"Deleted category: {category_title}")
            except Exception as e:
                error_msg = sanitize_error_message(e, "deleting category")
                self.app.notify(f"Failed to delete category: {error_msg}", severity="error")

    def action_cursor_down(self) -> None:
        """Move cursor down in the list."""
        if self.list_view is not None:
            self.list_view.action_cursor_down()

    def action_cursor_up(self) -> None:
        """Move cursor up in the list."""
        if self.list_view is not None:
            self.list_view.action_cursor_up()

    def action_back(self) -> None:
        """Go back to previous screen."""
        self.app.pop_screen()

__init__(categories=None, entries=None, **kwargs)

Initialize category management screen.

Parameters:

Name Type Description Default
categories list[Category] | None

List of categories to display

None
entries list[Entry] | None

List of all entries to calculate counts from

None
Source code in miniflux_tui/ui/screens/category_management.py
101
102
103
104
105
106
107
108
109
110
111
def __init__(self, categories: list[Category] | None = None, entries: list["Entry"] | None = None, **kwargs):
    """Initialize category management screen.

    Args:
        categories: List of categories to display
        entries: List of all entries to calculate counts from
    """
    super().__init__(**kwargs)
    self.categories = categories or []
    self.entries = entries or []
    self.list_view: ListView | None = None

action_back()

Go back to previous screen.

Source code in miniflux_tui/ui/screens/category_management.py
296
297
298
def action_back(self) -> None:
    """Go back to previous screen."""
    self.app.pop_screen()

action_create_category() async

Show dialog to create a new category.

Source code in miniflux_tui/ui/screens/category_management.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
async def action_create_category(self) -> None:
    """Show dialog to create a new category."""

    def on_submit(title: str) -> None:
        """Handle category creation."""
        if not title or not title.strip():
            self.app.notify("Category name cannot be empty", severity="error")
            return

        asyncio.create_task(self._do_create_category(title.strip()))  # noqa: RUF006

    dialog = InputDialog(
        title="Create Category",
        label="Category name:",
        on_submit=on_submit,
    )
    self.app.push_screen(dialog)

action_cursor_down()

Move cursor down in the list.

Source code in miniflux_tui/ui/screens/category_management.py
286
287
288
289
def action_cursor_down(self) -> None:
    """Move cursor down in the list."""
    if self.list_view is not None:
        self.list_view.action_cursor_down()

action_cursor_up()

Move cursor up in the list.

Source code in miniflux_tui/ui/screens/category_management.py
291
292
293
294
def action_cursor_up(self) -> None:
    """Move cursor up in the list."""
    if self.list_view is not None:
        self.list_view.action_cursor_up()

action_delete_category() async

Show confirmation dialog to delete selected category.

Source code in miniflux_tui/ui/screens/category_management.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
async def action_delete_category(self) -> None:
    """Show confirmation dialog to delete selected category."""
    selected = self._get_selected_category()
    if selected is None:
        self.app.notify("No category selected", severity="warning")
        return

    def on_confirm() -> None:
        """Handle deletion confirmation."""
        asyncio.create_task(self._do_delete_category(selected.id, selected.title))  # noqa: RUF006

    dialog = ConfirmDialog(
        title="Delete Category?",
        message=f"Delete category: {selected.title}?\n\n(Feeds in this category will be moved to Uncategorized)",
        confirm_label="Delete",
        cancel_label="Cancel",
        on_confirm=on_confirm,
    )
    self.app.push_screen(dialog)

action_edit_category() async

Show dialog to edit the selected category.

Source code in miniflux_tui/ui/screens/category_management.py
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
async def action_edit_category(self) -> None:
    """Show dialog to edit the selected category."""
    selected = self._get_selected_category()
    if selected is None:
        self.app.notify("No category selected", severity="warning")
        return

    def on_submit(new_title: str) -> None:
        """Handle category edit."""
        if not new_title or not new_title.strip():
            self.app.notify("Category name cannot be empty", severity="error")
            return

        asyncio.create_task(self._do_edit_category(selected.id, new_title.strip()))  # noqa: RUF006

    dialog = InputDialog(
        title="Edit Category",
        label="New name:",
        value=selected.title,
        on_submit=on_submit,
    )
    self.app.push_screen(dialog)

compose()

Create child widgets.

Source code in miniflux_tui/ui/screens/category_management.py
113
114
115
116
117
118
119
120
def compose(self) -> ComposeResult:
    """Create child widgets."""
    yield Header()
    list_view = ListView(id="category-list")
    self.list_view = list_view
    with Container():
        yield list_view
    yield Footer()

on_mount()

Initialize list view with categories.

Source code in miniflux_tui/ui/screens/category_management.py
122
123
124
def on_mount(self) -> None:
    """Initialize list view with categories."""
    self._populate_list()

Category Management Features

  • Category listing: Shows all available categories
  • Create categories: Add new categories with custom names
  • Edit categories: Modify existing category names
  • Delete categories: Remove categories (feeds are reassigned to Uncategorized)
  • Navigation: Vim-style cursor movement (j/k)

Category Management Actions

Method Binding Description
action_cursor_down j Move cursor down
action_cursor_up k Move cursor up
action_create_category n Create new category
action_edit_category e Edit selected category
action_delete_category d Delete selected category
action_back Esc Return to entry list

Category Operations

All category operations use the Miniflux API:

  • Creating: Adds a new category with validation (names cannot be empty)
  • Editing: Updates the name of an existing category
  • Deleting: Removes a category and reassigns its feeds to Uncategorized

All operations provide user feedback via notifications and handle errors gracefully.

MinifluxTuiApp (Main App)

The main application container.

Bases: App

A Textual TUI application for Miniflux.

Source code in miniflux_tui/ui/app.py
 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
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
class MinifluxTuiApp(App):
    """A Textual TUI application for Miniflux."""

    # Minimal CSS for specific layout/styling - colors come from Textual themes
    CSS = """
    Header {
        align: left top;
    }

    .entry-title {
        padding: 1 2;
    }

    .entry-meta {
        padding: 0 2;
    }

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

    .separator {
        padding: 0 2;
    }

    .entry-content {
        padding: 1 2;
    }

    ListItem {
        padding: 0 1;
    }

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

    /* Help screen logo styling */
    .help-logo {
        max-height: 10;
        width: auto;
        margin: 1 0;
        content-align: center middle;
    }
    """

    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
        """
        # Theme management - map config theme to Textual's built-in themes
        self._current_theme = config.theme_name
        theme_mapping = {
            "dark": "textual-dark",
            "light": "textual-light",
        }
        textual_theme = theme_mapping.get(config.theme_name, "textual-dark")

        super().__init__(
            driver_class=driver_class,
            css_path=css_path,
            watch_css=watch_css,
        )

        # Apply the theme after initialization
        self.theme = textual_theme
        self.config = config
        self.client: MinifluxClient | None = None
        self.entries: list[Entry] = []
        self.categories: list[Category] = []
        self.feeds: list[Feed] = []
        self.entry_category_map: dict[int, int] = {}  # Maps entry_id → category_id
        self.current_view = "unread"  # or "starred"
        self._entry_list_screen_cls: type[EntryListScreen] | None = None
        self._status_screen_cls: type[StatusScreen] | None = None
        # Runtime setting for showing info messages (can be toggled during session)
        self.show_info_messages = config.show_info_messages

    def notify_info(self, message: str) -> None:
        """Send an information notification if info messages are enabled.

        Args:
            message: The message to display
        """
        if self.show_info_messages:
            self.notify(message, severity="information")

    def toggle_info_messages(self) -> None:
        """Toggle the display of information messages during runtime."""
        self.show_info_messages = not self.show_info_messages
        status = "enabled" if self.show_info_messages else "disabled"
        self.notify(f"Information messages {status}", severity="information")

    def toggle_theme(self) -> None:
        """Toggle between dark and light themes and save preference."""
        available_themes = get_available_themes()
        current_index = available_themes.index(self._current_theme)
        next_index = (current_index + 1) % len(available_themes)
        new_theme = available_themes[next_index]
        self.set_theme(new_theme)

    def set_theme(self, theme_name: str) -> None:
        """Set the current theme and save to config with runtime theme switching.

        Args:
            theme_name: Name of the theme to set ("dark" or "light")

        Raises:
            ValueError: If theme name is invalid
        """
        # Validate theme exists
        _ = get_theme(theme_name)

        # Update current theme
        self._current_theme = theme_name
        self.config.theme_name = theme_name

        # Save theme preference to config file
        try:
            self.config.save_theme_preference()
        except Exception as e:
            self.log(f"Failed to save theme preference: {e}")

        # Map our config theme names to Textual's built-in theme names
        theme_mapping = {
            "dark": "textual-dark",
            "light": "textual-light",
        }

        # Use Textual's built-in theme switching
        textual_theme_name = theme_mapping.get(theme_name, "textual-dark")
        self.theme = textual_theme_name

        # Notify user
        theme = get_theme(theme_name)
        self.notify(
            f"Theme: {theme.display_name}",
            severity="information",
        )

    async def on_mount(self) -> None:
        """Called when app is mounted."""
        # Show loading screen immediately
        self.install_screen(LoadingScreen(), name="loading")
        self.push_screen("loading")

        # Schedule the data loading to happen after the screen is rendered
        self.call_after_refresh(self._load_data)

    async def _load_data(self) -> None:
        """Load data after loading screen is displayed."""
        try:
            # Initialize API client - this may block on password command
            self.client = MinifluxClient(
                base_url=self.config.server_url,
                api_key=self.config.api_key,
                allow_invalid_certs=self.config.allow_invalid_certs,
            )
        except RuntimeError as exc:
            # Password command failed - dismiss loading screen and show error
            self.pop_screen()
            self.notify(
                f"Failed to retrieve API token: {exc}",
                severity="error",
                timeout=None,
            )
            return

        entry_list_cls: type[EntryListScreen] = _load_entry_list_screen_cls()
        self._entry_list_screen_cls = entry_list_cls
        self.install_screen(
            entry_list_cls(
                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")

        status_cls: type[StatusScreen] = _load_status_screen_cls()
        self._status_screen_cls = status_cls
        self.install_screen(status_cls(), name="status")

        settings_cls: type[SettingsScreen] = _load_settings_screen_cls()
        self.install_screen(settings_cls(), name="settings")

        history_cls: type[EntryHistoryScreen] = _load_history_screen_cls()
        self.install_screen(history_cls(), name="history")

        # Load categories, feeds, and entries while loading screen is shown
        # Order matters: categories are needed to build entry→category mapping
        await self.load_categories()
        await self.load_feeds()

        # Build category mapping using category API (better than feed-based approach)
        # This creates a mapping of entry_id → category_id that we'll use later
        self.entry_category_map = await self._build_entry_category_mapping()

        await self.load_entries()

        # Dismiss loading screen and show entry list
        self.pop_screen()
        self.push_screen("entry_list")

    def _get_entry_list_screen(self) -> EntryListScreen | None:
        """Return the entry list screen instance if available."""
        entry_list_cls: type[EntryListScreen] = self._entry_list_screen_cls or _load_entry_list_screen_cls()
        self._entry_list_screen_cls = entry_list_cls

        if not self.is_screen_installed("entry_list"):
            return None

        try:
            screen = self.get_screen("entry_list")
            if isinstance(screen, entry_list_cls):
                return screen
        except Exception as e:
            # Can occur during widget lifecycle transitions (especially on Windows)
            self.log(f"Failed to get entry_list screen: {e}")
            return None

        self.log("entry_list screen is installed but not an EntryListScreen instance")
        return None

    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
            entry_list_screen = self._get_entry_list_screen()
            if entry_list_screen:
                entry_list_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 _build_entry_category_mapping(self) -> dict[int, int]:
        """Build a mapping of entry_id → category_id using the category API.

        Since the feeds endpoint doesn't include category_id, we use a different
        approach: fetch entries from each category and build a mapping.

        Returns:
            Dictionary mapping entry_id to category_id
        """
        if not self.client or not self.categories:
            self.log("Skipping category mapping: no client or categories")
            return {}

        entry_category_map: dict[int, int] = {}
        self.log(f"Building entry→category mapping from {len(self.categories)} categories...")

        for category in self.categories:
            try:
                # Fetch all entries in this category
                category_entries = await self.client.get_category_entries(category.id, limit=10000)
                self.log(f"  Category {category.id} ({category.title}): {len(category_entries)} entries")

                # Map each entry to this category
                for entry in category_entries:
                    entry_category_map[entry.id] = category.id
                    self.log(f"    ✓ Entry {entry.id} → Category {category.id}")

            except Exception as e:
                self.log(f"  ✗ Category {category.id}: failed to fetch entries - {e}")

        self.log(f"Built mapping for {len(entry_category_map)} entries across categories")
        return entry_category_map

    def _enrich_entries_with_category_mapping(self, entries: list, entry_category_map: dict[int, int]) -> list:
        """
        Enrich entries with category_id using a pre-built entry→category mapping.

        Args:
            entries: List of entries to enrich
            entry_category_map: Dictionary mapping entry_id to category_id

        Returns:
            List of entries with category information populated
        """
        self.log(f"Applying category mapping to {len(entries)} entries")
        self.log(f"Entry→category mapping has {len(entry_category_map)} entries")

        enriched_count = 0
        for entry in entries:
            if entry.id in entry_category_map:
                category_id = entry_category_map[entry.id]
                entry.feed.category_id = category_id
                enriched_count += 1
                self.log(f"  ✓ Entry {entry.id}: set category_id = {category_id}")
            else:
                self.log(f"  - Entry {entry.id}: not in any category")

        self.log(f"Applied category mapping to {enriched_count}/{len(entries)} entries")
        return entries

    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"
            else:
                self.entries = await self.client.get_unread_entries(limit=DEFAULT_ENTRY_LIMIT)
                self.current_view = "unread"

            # Enrich entries with category information using the mapping
            if self.entry_category_map:
                self.entries = self._enrich_entries_with_category_mapping(self.entries, self.entry_category_map)

            # Update the entry list screen if it exists
            entry_list_screen = self._get_entry_list_screen()
            if entry_list_screen:
                self.log("entry_list screen is installed")
                self.log(f"Updating screen with {len(self.entries)} entries")
                entry_list_screen.entries = self.entries
                # Only populate if screen is currently shown (mounted)
                # Otherwise, let on_mount() or on_screen_resume() handle it
                if entry_list_screen.is_current:
                    self.log("Screen is current - populating now")
                    entry_list_screen._populate_list()
                else:
                    self.log("Screen is not current - will populate on mount/resume")
            else:
                self.log("entry_list screen is NOT installed!")

            # Show single message with count (info if > 0, warning if 0)
            if len(self.entries) == 0:
                self.notify(f"No {view} entries found", severity="warning")
            else:
                self.notify_info(f"Loaded {len(self.entries)} {view} entries")

        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,
        group_info: dict[str, str | int] | None = None,
    ) -> 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
            group_info: Group/category information for display (mode, name, total, unread)
        """
        entry_reader_module = import_module("miniflux_tui.ui.screens.entry_reader")
        entry_reader_cls: type[EntryReaderScreen]
        entry_reader_cls = entry_reader_module.EntryReaderScreen

        # Get link highlight colors from config or theme
        theme = get_theme(self.config.theme_name)
        link_highlight_bg = self.config.link_highlight_bg or theme.colors.get("link-highlight-bg", "#ff79c6")
        link_highlight_fg = self.config.link_highlight_fg or theme.colors.get("link-highlight-fg", "#282a36")

        reader_screen: 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,
            group_info=group_info,
            link_highlight_bg=link_highlight_bg,
            link_highlight_fg=link_highlight_fg,
        )
        self.push_screen(reader_screen)

    async def action_refresh_entries(self) -> None:
        """Refresh entries from API."""
        # Rebuild category mapping and reload entries
        self.entry_category_map = await self._build_entry_category_mapping()
        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 load_feeds(self) -> None:
        """Load feeds from Miniflux API.

        Note: Category information is obtained via the category API, not from
        individual feeds (which don't expose category_id on all Miniflux versions).
        """
        if not self.client:
            self.notify("API client not initialized", severity="error")
            return

        try:
            self.feeds = await self.client.get_feeds()
            self.log(f"Loaded {len(self.feeds)} feeds")
            # Note: Category information will be obtained from category API via _build_entry_category_mapping()
        except Exception as e:
            error_details = traceback.format_exc()
            self.notify(f"Error loading feeds: {e}", severity="error")
            self.log(f"Full error:\n{error_details}")

    def push_feed_management_screen(self) -> None:
        """Push feed management screen."""
        feed_management_module = import_module("miniflux_tui.ui.screens.feed_management")
        feed_management_cls: type[FeedManagementScreen]
        feed_management_cls = feed_management_module.FeedManagementScreen

        management_screen: FeedManagementScreen = feed_management_cls(feeds=self.feeds)
        self.push_screen(management_screen)

    async def push_category_management_screen(self) -> None:
        """Push category management screen."""
        try:
            categories = await self.client.get_categories() if self.client else []
        except Exception:
            categories = []

        from miniflux_tui.ui.screens.category_management import (  # noqa: PLC0415
            CategoryManagementScreen,
        )

        management_screen = CategoryManagementScreen(categories=categories, entries=self.entries)
        self.push_screen(management_screen)

    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
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
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
    """
    # Theme management - map config theme to Textual's built-in themes
    self._current_theme = config.theme_name
    theme_mapping = {
        "dark": "textual-dark",
        "light": "textual-light",
    }
    textual_theme = theme_mapping.get(config.theme_name, "textual-dark")

    super().__init__(
        driver_class=driver_class,
        css_path=css_path,
        watch_css=watch_css,
    )

    # Apply the theme after initialization
    self.theme = textual_theme
    self.config = config
    self.client: MinifluxClient | None = None
    self.entries: list[Entry] = []
    self.categories: list[Category] = []
    self.feeds: list[Feed] = []
    self.entry_category_map: dict[int, int] = {}  # Maps entry_id → category_id
    self.current_view = "unread"  # or "starred"
    self._entry_list_screen_cls: type[EntryListScreen] | None = None
    self._status_screen_cls: type[StatusScreen] | None = None
    # Runtime setting for showing info messages (can be toggled during session)
    self.show_info_messages = config.show_info_messages

action_refresh_entries() async

Refresh entries from API.

Source code in miniflux_tui/ui/app.py
469
470
471
472
473
474
async def action_refresh_entries(self) -> None:
    """Refresh entries from API."""
    # Rebuild category mapping and reload entries
    self.entry_category_map = await self._build_entry_category_mapping()
    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
481
482
483
484
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
476
477
478
479
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
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
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
        entry_list_screen = self._get_entry_list_screen()
        if entry_list_screen:
            entry_list_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
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
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"
        else:
            self.entries = await self.client.get_unread_entries(limit=DEFAULT_ENTRY_LIMIT)
            self.current_view = "unread"

        # Enrich entries with category information using the mapping
        if self.entry_category_map:
            self.entries = self._enrich_entries_with_category_mapping(self.entries, self.entry_category_map)

        # Update the entry list screen if it exists
        entry_list_screen = self._get_entry_list_screen()
        if entry_list_screen:
            self.log("entry_list screen is installed")
            self.log(f"Updating screen with {len(self.entries)} entries")
            entry_list_screen.entries = self.entries
            # Only populate if screen is currently shown (mounted)
            # Otherwise, let on_mount() or on_screen_resume() handle it
            if entry_list_screen.is_current:
                self.log("Screen is current - populating now")
                entry_list_screen._populate_list()
            else:
                self.log("Screen is not current - will populate on mount/resume")
        else:
            self.log("entry_list screen is NOT installed!")

        # Show single message with count (info if > 0, warning if 0)
        if len(self.entries) == 0:
            self.notify(f"No {view} entries found", severity="warning")
        else:
            self.notify_info(f"Loaded {len(self.entries)} {view} entries")

    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}")

load_feeds() async

Load feeds from Miniflux API.

Note: Category information is obtained via the category API, not from individual feeds (which don't expose category_id on all Miniflux versions).

Source code in miniflux_tui/ui/app.py
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
async def load_feeds(self) -> None:
    """Load feeds from Miniflux API.

    Note: Category information is obtained via the category API, not from
    individual feeds (which don't expose category_id on all Miniflux versions).
    """
    if not self.client:
        self.notify("API client not initialized", severity="error")
        return

    try:
        self.feeds = await self.client.get_feeds()
        self.log(f"Loaded {len(self.feeds)} feeds")
        # Note: Category information will be obtained from category API via _build_entry_category_mapping()
    except Exception as e:
        error_details = traceback.format_exc()
        self.notify(f"Error loading feeds: {e}", severity="error")
        self.log(f"Full error:\n{error_details}")

notify_info(message)

Send an information notification if info messages are enabled.

Parameters:

Name Type Description Default
message str

The message to display

required
Source code in miniflux_tui/ui/app.py
150
151
152
153
154
155
156
157
def notify_info(self, message: str) -> None:
    """Send an information notification if info messages are enabled.

    Args:
        message: The message to display
    """
    if self.show_info_messages:
        self.notify(message, severity="information")

on_mount() async

Called when app is mounted.

Source code in miniflux_tui/ui/app.py
212
213
214
215
216
217
218
219
async def on_mount(self) -> None:
    """Called when app is mounted."""
    # Show loading screen immediately
    self.install_screen(LoadingScreen(), name="loading")
    self.push_screen("loading")

    # Schedule the data loading to happen after the screen is rendered
    self.call_after_refresh(self._load_data)

on_unmount() async

Called when app is unmounted.

Source code in miniflux_tui/ui/app.py
528
529
530
531
532
async def on_unmount(self) -> None:
    """Called when app is unmounted."""
    # Close API client
    if self.client:
        await self.client.close()

push_category_management_screen() async

Push category management screen.

Source code in miniflux_tui/ui/app.py
514
515
516
517
518
519
520
521
522
523
524
525
526
async def push_category_management_screen(self) -> None:
    """Push category management screen."""
    try:
        categories = await self.client.get_categories() if self.client else []
    except Exception:
        categories = []

    from miniflux_tui.ui.screens.category_management import (  # noqa: PLC0415
        CategoryManagementScreen,
    )

    management_screen = CategoryManagementScreen(categories=categories, entries=self.entries)
    self.push_screen(management_screen)

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

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
group_info dict[str, str | int] | None

Group/category information for display (mode, name, total, unread)

None
Source code in miniflux_tui/ui/app.py
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
def push_entry_reader(
    self,
    entry: Entry,
    entry_list: list | None = None,
    current_index: int = 0,
    group_info: dict[str, str | int] | None = None,
) -> 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
        group_info: Group/category information for display (mode, name, total, unread)
    """
    entry_reader_module = import_module("miniflux_tui.ui.screens.entry_reader")
    entry_reader_cls: type[EntryReaderScreen]
    entry_reader_cls = entry_reader_module.EntryReaderScreen

    # Get link highlight colors from config or theme
    theme = get_theme(self.config.theme_name)
    link_highlight_bg = self.config.link_highlight_bg or theme.colors.get("link-highlight-bg", "#ff79c6")
    link_highlight_fg = self.config.link_highlight_fg or theme.colors.get("link-highlight-fg", "#282a36")

    reader_screen: 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,
        group_info=group_info,
        link_highlight_bg=link_highlight_bg,
        link_highlight_fg=link_highlight_fg,
    )
    self.push_screen(reader_screen)

push_feed_management_screen()

Push feed management screen.

Source code in miniflux_tui/ui/app.py
505
506
507
508
509
510
511
512
def push_feed_management_screen(self) -> None:
    """Push feed management screen."""
    feed_management_module = import_module("miniflux_tui.ui.screens.feed_management")
    feed_management_cls: type[FeedManagementScreen]
    feed_management_cls = feed_management_module.FeedManagementScreen

    management_screen: FeedManagementScreen = feed_management_cls(feeds=self.feeds)
    self.push_screen(management_screen)

set_theme(theme_name)

Set the current theme and save to config with runtime theme switching.

Parameters:

Name Type Description Default
theme_name str

Name of the theme to set ("dark" or "light")

required

Raises:

Type Description
ValueError

If theme name is invalid

Source code in miniflux_tui/ui/app.py
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
def set_theme(self, theme_name: str) -> None:
    """Set the current theme and save to config with runtime theme switching.

    Args:
        theme_name: Name of the theme to set ("dark" or "light")

    Raises:
        ValueError: If theme name is invalid
    """
    # Validate theme exists
    _ = get_theme(theme_name)

    # Update current theme
    self._current_theme = theme_name
    self.config.theme_name = theme_name

    # Save theme preference to config file
    try:
        self.config.save_theme_preference()
    except Exception as e:
        self.log(f"Failed to save theme preference: {e}")

    # Map our config theme names to Textual's built-in theme names
    theme_mapping = {
        "dark": "textual-dark",
        "light": "textual-light",
    }

    # Use Textual's built-in theme switching
    textual_theme_name = theme_mapping.get(theme_name, "textual-dark")
    self.theme = textual_theme_name

    # Notify user
    theme = get_theme(theme_name)
    self.notify(
        f"Theme: {theme.display_name}",
        severity="information",
    )

toggle_info_messages()

Toggle the display of information messages during runtime.

Source code in miniflux_tui/ui/app.py
159
160
161
162
163
def toggle_info_messages(self) -> None:
    """Toggle the display of information messages during runtime."""
    self.show_info_messages = not self.show_info_messages
    status = "enabled" if self.show_info_messages else "disabled"
    self.notify(f"Information messages {status}", severity="information")

toggle_theme()

Toggle between dark and light themes and save preference.

Source code in miniflux_tui/ui/app.py
165
166
167
168
169
170
171
def toggle_theme(self) -> None:
    """Toggle between dark and light themes and save preference."""
    available_themes = get_available_themes()
    current_index = available_themes.index(self._current_theme)
    next_index = (current_index + 1) % len(available_themes)
    new_theme = available_themes[next_index]
    self.set_theme(new_theme)

Methods

  • push_entry_reader: Opens an entry in the detailed reader view
  • load_entries: Fetches entries from the API
MinifluxTuiApp (App)
├─ EntryListScreen (main view)
│  ├─ navigate with j/k
│  ├─ press Enter → EntryReaderScreen
│  ├─ press c → CategoryManagementScreen
│  ├─ press ? → HelpScreen
│  └─ press q → exit
├─ EntryReaderScreen (detail view)
│  ├─ navigate with J/K
│  └─ press Escape → back to EntryListScreen
├─ CategoryManagementScreen (category management view)
│  ├─ navigate with j/k
│  ├─ press n to create, e to edit, d to delete
│  └─ 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.

CategoryHeaderItem

Custom ListItem for displaying a category group header.

CategoryListItem

Custom ListItem for displaying a category in the category management screen.

The list items use CSS-based hiding for collapsed feeds (via the "collapsed" class) in grouped mode.

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).