Jarian Cottingham 06877fb2fc feat: add TUI (Textual User Interface) with download queue system
- New TUI using Textual framework with responsive interface
- Search, results, download, and queue screens for YouTube video management
- Download queue system with sequential background downloads
- Status bar with queue count and download progress indicators
- Comprehensive test suite (97 tests) with 100% pass rate
- Modern Python packaging with pyproject.toml and uv support
- Added requirements-tui.txt for TUI-specific dependencies
- Updated setup.py and install.sh for TUI integration
- Enhanced README.md with TUI usage documentation
2026-02-25 15:59:15 -06:00

231 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""
Category Selection Modal for YouTube TUI
"""
from typing import Optional
from textual.app import ComposeResult
from textual.containers import Container, Vertical
from textual.screen import ModalScreen
from textual.widgets import (
Button,
Footer,
Header,
Input,
ListItem,
ListView,
Static,
)
from youtube_tui.services.youtube import YouTubeService
class CategorySelectionModal(ModalScreen):
"""Modal for selecting a download category"""
CSS = """
CategorySelectionModal {
align: center middle;
}
#modal-container {
width: 60%;
height: auto;
border: solid #555555;
background: $surface;
padding: 1;
}
#modal-title {
width: 100%;
height: 3;
dock: top;
background: $primary;
content-align: center middle;
color: $text;
}
#categories-container {
width: 100%;
height: 20;
margin: 1 0;
}
#custom-input {
width: 100%;
margin: 1 0;
}
#modal-actions {
width: 100%;
height: auto;
dock: bottom;
margin-top: 1;
}
Button {
width: 15;
margin: 1 1;
}
ListItem {
height: 3;
padding: 0 1;
}
ListItem:hover {
background: $primary-darken-2;
}
ListItem.--highlight {
background: $primary;
}
"""
BINDINGS = [
("escape", "close_modal", "Cancel"),
("enter", "select_category", "Select"),
("up", "cursor_up", "Cursor Up"),
("down", "cursor_down", "Cursor Down"),
]
def __init__(self) -> None:
super().__init__()
self.youtube_service = YouTubeService()
self.selected_category: Optional[str] = None
self.selected_index = 0
def compose(self) -> ComposeResult:
"""Compose the modal"""
yield Header()
yield Container(
Static("Select Download Category", id="modal-title"),
Vertical(
Static("Available Categories:", id="categories-label"),
ListView(id="categories-list"),
Static("Or type custom folder name:", id="custom-label"),
Input(placeholder="Enter custom folder name...", id="custom-input"),
id="categories-container",
),
Container(
Button("Select", id="select-btn"),
Button("Cancel", id="cancel-btn"),
id="modal-actions",
),
id="modal-container",
)
yield Footer()
def on_mount(self) -> None:
"""Called when modal is mounted"""
self.load_categories()
self.update_status("Use arrow keys to select, Enter to confirm")
def load_categories(self) -> None:
"""Load available categories into the list"""
list_view = self.query_one("#categories-list", ListView)
list_view.clear()
try:
# Note: This is called from on_mount which is sync
# In a real async context, this should be awaited
categories: list = self.youtube_service.cli.get_categories(
self.youtube_service.cli.config
)
for category in categories:
# Extract folder name for display
from pathlib import Path
folder_name = Path(category).name if Path(category).name else "Root"
# Create a custom widget for the item
item = ListItem(Static(f" {folder_name}"), id=f"category-{category}")
list_view.append(item)
# Highlight first item
if list_view.children:
list_view.children[0].add_class("--highlight")
self.selected_index = 0
except Exception as e:
list_view.append(
ListItem(Static(f"[red]Error loading categories: {e}[/red]"))
)
def update_status(self, message: str) -> None:
"""Update the modal status"""
# We could add a status line if needed
pass
def action_select_category(self) -> None:
"""Select the current category"""
list_view = self.query_one("#categories-list", ListView)
if list_view.children and 0 <= self.selected_index < len(list_view.children):
# Get the selected category
item = list_view.children[self.selected_index]
category_id = item.id
if category_id and category_id.startswith("category-"):
self.selected_category = category_id.replace("category-", "")
self.dismiss(self.selected_category)
return
# Check custom input
custom_input = self.query_one("#custom-input", Input)
custom_name = custom_input.value.strip()
if custom_name:
self.selected_category = custom_name
self.dismiss(self.selected_category)
return
# No valid selection
self.update_status("[red]Please select a category or enter a custom name[/red]")
def action_cursor_up(self) -> None:
"""Move cursor up"""
list_view = self.query_one("#categories-list", ListView)
if list_view.children:
# Remove highlight from current item
if 0 <= self.selected_index < len(list_view.children):
list_view.children[self.selected_index].remove_class("--highlight")
# Move up
self.selected_index = max(0, self.selected_index - 1)
# Highlight new item
list_view.children[self.selected_index].add_class("--highlight")
def action_cursor_down(self) -> None:
"""Move cursor down"""
list_view = self.query_one("#categories-list", ListView)
if list_view.children:
# Remove highlight from current item
if 0 <= self.selected_index < len(list_view.children):
list_view.children[self.selected_index].remove_class("--highlight")
# Move down
self.selected_index = min(
len(list_view.children) - 1, self.selected_index + 1
)
# Highlight new item
list_view.children[self.selected_index].add_class("--highlight")
def action_close_modal(self) -> None:
"""Close modal without selecting"""
self.selected_category = None
self.dismiss(None)
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses"""
if event.button.id == "select-btn":
self.action_select_category()
elif event.button.id == "cancel-btn":
self.action_close_modal()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle enter key in custom input"""
self.action_select_category()