Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ The following features have been successfully implemented and are available in t
- [x] **Standalone Binary & AUR Packaging:** Availability of pre-built executables for various operating systems and packaging for Arch Linux via AUR.
- [x] **Magnet Info Preview (Seeders/Leechers before download):** Ability to display crucial torrent metadata (like seeders and leechers) before initiating a download.
- [x] **Sorting & Filtering of Search Results:** Reordering results by seeders, size, title or leechers, either from a menu or by clicking a column header, plus hiding dead torrents. Starting sort and minimum seeder count are configurable.
- [x] **Keyboard Shortcuts Overlay / Help Screen:** An in-app help screen, opened with `?`, listing every keyboard shortcut grouped by where it applies.

## Planned Features

Our future development efforts will focus on introducing the following enhancements and new functionalities:

- [ ] **Sorting by Date and Category:** Extending result sorting to publish date and category, which first requires indexers to return those fields.
- [ ] **Keyboard Shortcuts Overlay / Help Screen:** Introducing an in-app overlay or dedicated screen to display available keyboard shortcuts and general help for the TUI.
- [ ] **Support for Custom Indexers:** Allowing users to define and integrate their own custom torrent indexers beyond Jackett and Prowlarr.

We welcome community feedback and contributions to help shape the future of `torrra`. If you have suggestions or would like to contribute, please refer to the [Contributing Guide](contributing).
14 changes: 14 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ The TUI has two views, **Search** and **Downloads**, which you switch between us
| :------- | :----------------------------------------------------------- |
| `Tab` | Move focus between the search box, the sidebar and the list |
| `ctrl+t` | Open the theme switcher to change the application's appearance |
| `?` | Show all keyboard shortcuts |
| `ctrl+q` | Quit `torrra` |

### Moving around a list
Expand Down Expand Up @@ -136,6 +137,19 @@ These work in both the search results and the downloads list.
| `d` | Remove the selected torrent, keeping any downloaded files |
| `D` | Remove the selected torrent **and** delete its files |

### Discovering Shortcuts In the App

You don't need to keep this page open to remember the keys. Press `?` at any time to
open a help screen listing every shortcut, grouped by the same sections used above,
since most keys only do something in one of the two views.

Press `?` again, or `Esc`, to close it. On a short terminal the list won't fit all at
once, so the panel scrolls with the same keys as the rest of the app (`j`/`k`,
`ctrl+d`/`ctrl+u`, `gg`/`G`).

Like the sort and filter keys, `?` steps aside while you're typing in the search box, so
it never interferes with a query — press `Tab` to move focus into the list first.

### Sorting and Filtering Results

![Sorting and filtering search results](_static/sort-filter-demo.gif)
Expand Down
20 changes: 20 additions & 0 deletions src/torrra/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
from textual.binding import Binding, BindingType
from textual.reactive import Reactive
from textual.types import CSSPathType
from textual.widgets import Input
from typing_extensions import override

from torrra._types import Indexer
from torrra.core.config import get_config
from torrra.screens.help import HelpScreen
from torrra.screens.home import HomeScreen
from torrra.screens.theme_selector import ThemeSelectorScreen
from torrra.screens.welcome import WelcomeScreen
Expand All @@ -22,6 +25,7 @@ class TorrraApp(App[None]):
ENABLE_COMMAND_PALETTE: ClassVar[bool] = False
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+t", "switch_theme"),
Binding("question_mark", "show_help", priority=True),
]

def __init__(
Expand Down Expand Up @@ -79,6 +83,22 @@ async def on_mount(self) -> None:
def action_switch_theme(self) -> None:
self.push_screen(ThemeSelectorScreen())

@override
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
# "?" is bound with priority so it works from any list, but it is also a
# perfectly ordinary character to type into a search query, so step
# aside whenever a text box is focused
typing_a_query = action == "show_help" and isinstance(self.focused, Input)
return not typing_a_query

def action_show_help(self) -> None:
# the binding has priority, so it stays live while help is open;
# make the same key close it rather than stack a second copy
if isinstance(self.screen, HelpScreen):
self.pop_screen()
else:
self.push_screen(HelpScreen())

@work(exclusive=True)
async def _show_welcome_and_search(self) -> None:
if search_query := await self.push_screen_wait(
Expand Down
35 changes: 34 additions & 1 deletion src/torrra/app.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,41 @@ HomeScreen #main_layout {
}

/* ------------------------- */
/* THEME SWITCH SCREEN */
/* HELP SCREEN */
/* ------------------------- */
HelpScreen {
align: center middle;
#help-container {
height: auto;
max-width: 45;
max-height: 80%;
padding-left: 2;
background: $surface;
border: tall $secondary;
Label {
width: 100%;
text-align: center;
}
.help-subtitle {
color: $text-muted;
}
#help-content {
margin-top: 1;
height: 1fr;
.help-group {
height: auto;
margin-bottom: 1;
&:last-child {
margin-bottom: 0;
}
}
.help-row {
color: $text-muted;
}
}
}
}

ThemeSelectorScreen {
align: center middle;
#theme-switcher-container {
Expand Down
134 changes: 134 additions & 0 deletions src/torrra/screens/help.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import time
from typing import ClassVar

from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Label, Static
from typing_extensions import override

# (section title, [(keys, description), ...])
#
# Kept as data rather than markup so the sections stay readable next to the
# BINDINGS they document. Grouped by where a key applies, using the same
# sections as docs/usage.md, because most of these only do something in one
# of the two views.
SHORTCUTS: list[tuple[str, list[tuple[str, str]]]] = [
(
"Anywhere",
[
("tab", "move focus"),
("ctrl+t", "change theme"),
("?", "show this help"),
("ctrl+q", "quit torrra"),
],
),
(
"Lists",
[
("j / down", "move down"),
("k / up", "move up"),
("ctrl+d", "page down"),
("ctrl+u", "page up"),
("gg", "jump to top"),
("G", "jump to bottom"),
],
),
(
"Search results",
[
("enter / l", "show torrent details"),
("enter", "download (in details)"),
("esc", "close details"),
("s", "open the sort menu"),
("S", "reverse sort order"),
("f", "toggle hiding 0 seeders"),
("x", "reset to your defaults"),
],
),
(
"Downloads",
[
("enter / l", "show download details"),
("p", "pause or resume"),
("d", "remove torrent"),
("D", "remove and delete files"),
],
),
(
"Menus",
[
("j / k", "move up or down"),
("enter", "apply and close"),
("esc", "cancel"),
],
),
]


class HelpScreen(ModalScreen[None]):
"""List every keyboard shortcut, grouped by where it applies."""

BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "close_screen"),
Binding("question_mark", "close_screen"),
Binding("q", "close_screen"),
Binding("j", "scroll_down"),
Binding("k", "scroll_up"),
Binding("G", "scroll_bottom"),
Binding("ctrl+d", "page_down"),
Binding("ctrl+u", "page_up"),
]

def __init__(self) -> None:
super().__init__()
self._container: VerticalScroll
self._last_g_press: float = 0

@override
def compose(self) -> ComposeResult:
with Vertical(id="help-container"):
yield Label("[b]Keyboard Shortcuts[/b]")
yield Label("j/k: scroll - esc: close", classes="help-subtitle")
with VerticalScroll(id="help-content"):
for title, shortcuts in SHORTCUTS:
with Vertical(classes="help-group"):
yield Static(f"[b]{title}[/b]", classes="help-section")
for keys, description in shortcuts:
yield Static(
f"[$accent]{keys:<12}[/$accent] {description}",
classes="help-row",
)

def on_mount(self) -> None:
self._container = self.query_one("#help-content", VerticalScroll)

# this list only grows as bindings are added, and it already overflows on a
# short terminal, so the panel scrolls with the same keys the rest of the
# app uses instead of arrows only
def key_g(self) -> None:
current_time = time.time()
if current_time - self._last_g_press < 0.4:
self._container.action_scroll_home()
self._last_g_press = 0
else: # save for next event
self._last_g_press = current_time

def action_scroll_down(self) -> None:
self._container.action_scroll_down()

def action_scroll_up(self) -> None:
self._container.action_scroll_up()

def action_scroll_bottom(self) -> None:
self._container.action_scroll_end()

def action_page_down(self) -> None:
self._container.action_page_down()

def action_page_up(self) -> None:
self._container.action_page_up()

def action_close_screen(self) -> None:
self.app.pop_screen()
127 changes: 127 additions & 0 deletions tests/screens/test_help.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
from typing import Any

import pytest
from textual.containers import VerticalScroll
from textual.widgets import Input

from torrra.app import TorrraApp
from torrra.screens.help import SHORTCUTS, HelpScreen


@pytest.fixture
def app(app_factory: Any) -> TorrraApp:
return app_factory()


async def test_help_opens_with_question_mark_and_closes_with_escape(app: TorrraApp):
async with app.run_test() as pilot:
app.screen.set_focus(None)

await pilot.press("question_mark")
assert isinstance(app.screen, HelpScreen)
assert len(app.screen_stack) == 3 # default + welcome + help

await pilot.press("escape")
assert len(app.screen_stack) == 2 # default + welcome screen


@pytest.mark.parametrize("key", ["question_mark", "q"])
async def test_help_closes_with_its_other_keys(app: TorrraApp, key: str):
async with app.run_test() as pilot:
app.screen.set_focus(None)

await pilot.press("question_mark")
assert isinstance(app.screen, HelpScreen)

await pilot.press(key)
assert not isinstance(app.screen, HelpScreen)


async def test_help_does_not_stack_duplicate_screens(app: TorrraApp):
async with app.run_test() as pilot:
app.screen.set_focus(None)

await pilot.press("question_mark")
depth = len(app.screen_stack)

# the binding stays active while help is open, so a second press must
# close it rather than push another copy
await pilot.press("question_mark")
assert len(app.screen_stack) < depth


async def test_question_mark_types_into_a_search_box_instead_of_opening_help(
app: TorrraApp,
):
"""The binding has priority, so it must step aside while a query is typed."""
async with app.run_test() as pilot:
search_input = app.screen.query_one(Input)
search_input.focus()
await pilot.pause()

await pilot.press("question_mark")

assert not isinstance(app.screen, HelpScreen)
assert search_input.value == "?"


async def test_help_lists_every_documented_shortcut(app: TorrraApp):
async with app.run_test() as pilot:
app.screen.set_focus(None)
await pilot.press("question_mark")
assert isinstance(app.screen, HelpScreen)

rendered = " ".join(
str(widget.render()) for widget in app.screen.query(".help-row")
)
section_titles = " ".join(
str(widget.render()) for widget in app.screen.query(".help-section")
)
for title, shortcuts in SHORTCUTS:
assert title in section_titles
for keys, description in shortcuts:
assert keys in rendered
assert description in rendered


@pytest.mark.parametrize(
("keys", "expected"),
[
(["j"], "one line down"),
(["j", "j", "k"], "one line down"),
(["down"], "one line down"),
(["G"], "bottom"),
(["ctrl+d"], "page down"),
(["G", "g", "g"], "top"),
(["G", "ctrl+u"], "page up from bottom"),
],
)
async def test_help_scrolls_with_the_same_keys_as_the_rest_of_the_app(
app: TorrraApp, keys: list[str], expected: str
):
"""The list outgrows a short terminal, and it only gets longer as bindings
are added, so arrows alone are not enough."""
async with app.run_test(size=(90, 20)) as pilot:
app.screen.set_focus(None)
await pilot.press("question_mark")
assert isinstance(app.screen, HelpScreen)

container = app.screen.query_one("#help-content", VerticalScroll)
bottom = container.max_scroll_y
assert bottom > 0, "expected the panel to overflow this size"

await pilot.press(*keys)
# the target is set synchronously; the position itself animates.
# asserted relative to the container rather than against fixed offsets
# so that adding a shortcut doesn't break this
actual = container.scroll_target_y
if expected == "one line down":
assert actual == 1
elif expected == "top":
assert actual == 0
elif expected == "bottom":
assert actual == bottom
elif expected == "page down":
assert 1 < actual <= bottom
else: # page up from bottom
assert 0 <= actual < bottom
Loading