diff --git a/src/seedpass/tui_v3/app.py b/src/seedpass/tui_v3/app.py index 8fcde6b0..5a0a85b0 100644 --- a/src/seedpass/tui_v3/app.py +++ b/src/seedpass/tui_v3/app.py @@ -930,6 +930,10 @@ class SeedPassTuiV3(App[None]): Binding("g", "show_qr", "QR", show=False), Binding("a", "toggle_archive", "Archive", show=False), Binding("c", "copy_selected", "Copy", show=False), + Binding("n", "sort_label", "Sort:Name", show=False), + Binding("r", "sort_recent", "Sort:Recent", show=False), + Binding("k", "sort_kind", "Sort:Kind", show=False), + Binding("f", "clear_filter", "Clear Filter", show=False), ] # Shared Reactive State @@ -1299,6 +1303,26 @@ class SeedPassTuiV3(App[None]): self.notify(f"Applied filter: {self.filter_kind}") self.action_refresh() + def action_sort_label(self) -> None: + """Keyboard shortcut: sort grid by label (A→Z).""" + self.action_set_search_sort("label_asc") + + def action_sort_recent(self) -> None: + """Keyboard shortcut: sort grid by most-recently modified.""" + self.action_set_search_sort("modified_desc") + + def action_sort_kind(self) -> None: + """Keyboard shortcut: sort grid by entry kind.""" + self.action_set_search_sort("kind") + + def action_clear_filter(self) -> None: + """Clear active filter, search query, and reset sort to relevance.""" + self.filter_kind = "all" + self.search_query = "" + self.search_sort = "relevance" + self.notify("Filter and sort cleared") + self.action_refresh() + def action_open_palette(self) -> None: """Toggle the command palette.""" try: diff --git a/src/seedpass/tui_v3/widgets/grid.py b/src/seedpass/tui_v3/widgets/grid.py index bac12cb2..1a8ee6df 100644 --- a/src/seedpass/tui_v3/widgets/grid.py +++ b/src/seedpass/tui_v3/widgets/grid.py @@ -79,6 +79,11 @@ class GridToolbar(Horizontal): margin-right: 1; height: 1; } + GridToolbar Button.active { + background: #007700; + color: #ffffff; + text-style: bold; + } """ def compose(self) -> ComposeResult: @@ -93,11 +98,57 @@ class GridToolbar(Horizontal): yield Button("Semantic", id="grid-mode-semantic") yield Button("Relevance", id="grid-sort-relevance") yield Button("Recent", id="grid-sort-modified_desc") - yield Button("Label", id="grid-sort-label_asc") + yield Button("Label ↑", id="grid-sort-label_asc") + yield Button("Kind", id="grid-sort-kind") yield Button("Linked", id="grid-sort-most_linked") + yield Button("[X] Clear", id="grid-clear-filter") + + def on_mount(self) -> None: + self.watch(self.app, "filter_kind", self._sync_active_states) + self.watch(self.app, "search_mode", self._sync_active_states) + self.watch(self.app, "search_sort", self._sync_active_states) + self._sync_active_states() + + def _sync_active_states(self, *_args: Any) -> None: + """Update button active CSS classes to reflect current app state.""" + try: + app = self.app + filter_kind = app.filter_kind + search_mode = app.search_mode + search_sort = app.search_sort + + filter_map = { + "grid-filter-all": filter_kind == "all", + "grid-filter-secrets": filter_kind == "secrets", + "grid-filter-docs": filter_kind == "docs", + "grid-filter-keys": filter_kind == "keys", + "grid-filter-2fa": filter_kind == "2fa", + "grid-mode-keyword": search_mode == "keyword", + "grid-mode-hybrid": search_mode == "hybrid", + "grid-mode-semantic": search_mode == "semantic", + "grid-sort-relevance": search_sort == "relevance", + "grid-sort-modified_desc": search_sort == "modified_desc", + "grid-sort-label_asc": search_sort == "label_asc", + "grid-sort-kind": search_sort == "kind", + "grid-sort-most_linked": search_sort == "most_linked", + } + for btn_id, is_active in filter_map.items(): + try: + btn = self.query_one(f"#{btn_id}", Button) + if is_active: + btn.add_class("active") + else: + btn.remove_class("active") + except Exception: + pass + except Exception: + pass def on_button_pressed(self, event: Button.Pressed) -> None: button_id = event.button.id or "" + if button_id == "grid-clear-filter": + self.app.action_clear_filter() + return if button_id.startswith("grid-filter-"): self.app.action_set_kind_filter(button_id.removeprefix("grid-filter-")) return diff --git a/src/tests/test_tui_v3_smoke.py b/src/tests/test_tui_v3_smoke.py index 1c60886b..34e2fdf8 100644 --- a/src/tests/test_tui_v3_smoke.py +++ b/src/tests/test_tui_v3_smoke.py @@ -1095,3 +1095,128 @@ async def test_tui3_inspector_utility_hints_update_by_entry_kind() -> None: await pilot.pause() hints_text = str(hints_bar.render()) assert "Actions" not in hints_text + + +@pytest.mark.anyio +async def test_tui3_keyboard_sort_shortcuts_change_sort() -> None: + app, _entry, _vault, search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + # Default sort is relevance + assert app.search_sort == "relevance" + + # n → sort by label_asc + await pilot.press("n") + await pilot.pause() + assert app.search_sort == "label_asc" + assert search.calls and search.calls[-1]["sort"] == "label_asc" + + # r → sort by modified_desc + await pilot.press("r") + await pilot.pause() + assert app.search_sort == "modified_desc" + assert search.calls[-1]["sort"] == "modified_desc" + + # k → sort by kind + await pilot.press("k") + await pilot.pause() + assert app.search_sort == "kind" + assert search.calls[-1]["sort"] == "kind" + + +@pytest.mark.anyio +async def test_tui3_clear_filter_button_resets_state() -> None: + app, _entry, _vault, search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + + # Set non-default filter/sort/query state + app.action_set_kind_filter("docs") + app.action_set_search_sort("modified_desc") + app.action_search("Email") + await pilot.pause() + assert app.filter_kind == "docs" + assert app.search_sort == "modified_desc" + assert app.search_query == "Email" + + # Press Clear button + app.screen.query_one("#grid-clear-filter", Button).press() + await pilot.pause() + assert app.filter_kind == "all" + assert app.search_sort == "relevance" + assert app.search_query == "" + + +@pytest.mark.anyio +async def test_tui3_clear_filter_keyboard_shortcut_resets_state() -> None: + app, _entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + app.action_set_kind_filter("keys") + app.action_set_search_sort("label_asc") + await pilot.pause() + assert app.filter_kind == "keys" + + await pilot.press("f") + await pilot.pause() + assert app.filter_kind == "all" + assert app.search_sort == "relevance" + + +@pytest.mark.anyio +async def test_tui3_filter_sort_state_persists_across_screen_transitions() -> None: + """Filter and sort state must survive pushing/popping overlay screens.""" + app, _entry, _vault, search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + + # Establish non-default state + app.action_set_kind_filter("secrets") + app.action_set_search_sort("modified_desc") + await pilot.pause() + assert app.filter_kind == "secrets" + assert app.search_sort == "modified_desc" + + # Push a screen (atlas wayfinder) + app.action_open_atlas_wayfinder() + await pilot.pause() + assert isinstance(app.screen, AtlasWayfinderScreen) + + # Pop back to main + app.pop_screen() + await pilot.pause() + # State must be unchanged + assert app.filter_kind == "secrets" + assert app.search_sort == "modified_desc" + + # Grid refresh triggered after pop should use persisted sort + table = app.screen.query_one("#entry-data-table", DataTable) + table._refresh_data() + await pilot.pause() + assert search.calls and search.calls[-1]["sort"] == "modified_desc" + + +@pytest.mark.anyio +async def test_tui3_toolbar_active_class_reflects_app_state() -> None: + """GridToolbar buttons must gain/lose the 'active' CSS class as state changes.""" + app, _entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + + # Default: filter=all, mode=keyword, sort=relevance + assert app.screen.query_one("#grid-filter-all", Button).has_class("active") + assert app.screen.query_one("#grid-mode-keyword", Button).has_class("active") + assert app.screen.query_one("#grid-sort-relevance", Button).has_class("active") + assert not app.screen.query_one("#grid-filter-docs", Button).has_class("active") + + # Change filter to docs + app.action_set_kind_filter("docs") + await pilot.pause() + assert app.screen.query_one("#grid-filter-docs", Button).has_class("active") + assert not app.screen.query_one("#grid-filter-all", Button).has_class("active") + + # Change sort to label_asc + app.action_set_search_sort("label_asc") + await pilot.pause() + assert app.screen.query_one("#grid-sort-label_asc", Button).has_class("active") + assert not app.screen.query_one("#grid-sort-relevance", Button).has_class("active")