booru-viewer/booru_viewer/tui/app.py

512 lines
15 KiB
Python

"""Main Textual TUI application."""
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, ScrollableContainer
from textual.css.query import NoMatches
from textual.message import Message
from textual.widgets import Header, Footer, Static, Input, Label, Button, ListView, ListItem
from ..core.db import Database
from ..core.api.base import Post
from ..core.api.detect import client_for_type
from ..core.cache import download_image
from ..core.config import GREEN, DIM_GREEN, BG, BG_LIGHT, BG_LIGHTER, BORDER
class PostList(ListView):
"""Scrollable list of posts with selection."""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._post_count = 0
async def set_posts(self, posts: list[Post], db: Database | None = None, site_id: int | None = None) -> None:
self._post_count = len(posts)
await self.clear()
for i, post in enumerate(posts):
fav = ""
if db and site_id and db.is_favorited(site_id, post.id):
fav = " [*]"
rating = (post.rating or "?")[0].upper()
label = f"#{post.id}{fav} {rating} s:{post.score:>4} {post.width}x{post.height}"
item = ListItem(Label(label), id=f"post-{i}")
await self.append(item)
@staticmethod
def _get_index(item: ListItem) -> int:
if item and item.id and item.id.startswith("post-"):
return int(item.id.split("-")[1])
return -1
@property
def selected_index(self) -> int:
if self.highlighted_child and self.highlighted_child.id:
return self._get_index(self.highlighted_child)
return -1
class InfoBar(Static):
"""Bottom info line showing selected post details."""
pass
class BooruTUI(App):
"""Booru viewer TUI application."""
TITLE = "booru-viewer"
CSS = f"""
Screen {{
background: {BG};
color: {GREEN};
}}
Header {{
background: {BG};
color: {GREEN};
}}
Footer {{
background: {BG};
color: {DIM_GREEN};
}}
#top-bar {{
height: 3;
layout: horizontal;
padding: 0 1;
}}
#top-bar Label {{
width: auto;
padding: 1 1;
color: {DIM_GREEN};
}}
#top-bar Input {{
width: 1fr;
}}
#top-bar Button {{
width: auto;
min-width: 8;
margin-left: 1;
}}
#nav-bar {{
height: 3;
layout: horizontal;
padding: 0 1;
}}
#nav-bar Button {{
width: auto;
min-width: 10;
margin-right: 1;
}}
#nav-bar .page-info {{
width: auto;
padding: 1 1;
color: {DIM_GREEN};
}}
#main {{
height: 1fr;
}}
#post-list {{
width: 1fr;
min-width: 40;
border-right: solid {BORDER};
}}
#right-panel {{
width: 1fr;
min-width: 30;
}}
#preview {{
height: 1fr;
}}
#info-bar {{
height: 3;
padding: 0 1;
color: {DIM_GREEN};
border-top: solid {BORDER};
}}
#status {{
height: 1;
padding: 0 1;
color: {DIM_GREEN};
}}
ListView {{
background: {BG};
color: {GREEN};
}}
ListView > ListItem {{
background: {BG};
color: {DIM_GREEN};
padding: 0 1;
}}
ListView > ListItem.--highlight {{
background: {BG_LIGHTER};
color: {GREEN};
}}
ListItem:hover {{
background: {BG_LIGHTER};
}}
Button {{
background: {BG_LIGHT};
color: {GREEN};
border: solid {BORDER};
}}
Button:hover {{
background: {DIM_GREEN};
color: {BG};
}}
Button.-active {{
background: {DIM_GREEN};
color: {BG};
}}
Input {{
background: {BG_LIGHT};
color: {GREEN};
border: solid {BORDER};
}}
Input:focus {{
border: solid {GREEN};
}}
"""
BINDINGS = [
Binding("q", "quit", "Quit", show=True),
Binding("slash", "focus_search", "/Search", show=True),
Binding("f", "toggle_favorite", "Fav", show=True, priority=True),
Binding("escape", "unfocus", "Back", show=True),
Binding("n", "next_page", "Next", show=True, priority=True),
Binding("p", "prev_page", "Prev", show=True, priority=True),
Binding("o", "open_in_default", "Open", show=True, priority=True),
Binding("i", "show_info", "Info", show=True, priority=True),
Binding("s", "cycle_site", "Site", show=True, priority=True),
]
def __init__(self) -> None:
super().__init__()
self._db = Database()
self._posts: list[Post] = []
self._current_page = 1
self._current_tags = ""
self._current_site = None
self._show_info = False
def compose(self) -> ComposeResult:
yield Header()
with Horizontal(id="top-bar"):
yield Label("No site", id="site-label")
yield Input(placeholder="Search tags... (/)", id="search-input")
yield Button("Go", id="search-btn")
with Horizontal(id="nav-bar"):
yield Label("Page 1", classes="page-info", id="page-info")
yield Button("Prev", id="prev-btn")
yield Button("Next", id="next-btn")
with Horizontal(id="main"):
yield PostList(id="post-list")
with Vertical(id="right-panel"):
yield Static("", id="preview")
yield InfoBar("Select a post", id="info-bar")
yield Label("Ready", id="status")
yield Footer()
def on_mount(self) -> None:
sites = self._db.get_sites()
if sites:
self._current_site = sites[0]
try:
self.query_one("#site-label", Label).update(f"[{self._current_site.name}]")
except NoMatches:
pass
self._set_status(f"Connected to {self._current_site.name}")
def _set_status(self, msg: str) -> None:
try:
self.query_one("#status", Label).update(msg)
except NoMatches:
pass
def _make_client(self):
if not self._current_site:
return None
s = self._current_site
return client_for_type(s.api_type, s.url, s.api_key, s.api_user)
# -- Events --
def on_button_pressed(self, event: Button.Pressed) -> None:
bid = event.button.id
if bid == "search-btn":
self._do_search_from_input()
elif bid == "prev-btn":
self.action_prev_page()
elif bid == "next-btn":
self.action_next_page()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "search-input":
self._do_search_from_input()
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
"""Update info when navigating the list."""
if event.item:
idx = PostList._get_index(event.item)
if idx >= 0:
self._update_info(idx)
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Enter key on a list item = load preview."""
if event.item:
idx = PostList._get_index(event.item)
if 0 <= idx < len(self._posts):
self._load_preview(idx)
# -- Search --
def _do_search_from_input(self) -> None:
try:
inp = self.query_one("#search-input", Input)
self._current_tags = inp.value.strip()
except NoMatches:
return
self._current_page = 1
self._do_search()
def _do_search(self) -> None:
if not self._current_site:
self._set_status("No site configured")
return
self._set_status("Searching...")
try:
self.query_one("#page-info", Label).update(f"Page {self._current_page}")
except NoMatches:
pass
tags = self._current_tags
page = self._current_page
blacklisted = self._db.get_blacklisted_tags()
search_tags = tags
for bt in blacklisted:
search_tags += f" -{bt}"
async def _search(self=self):
client = self._make_client()
if not client:
return
try:
posts = await client.search(tags=search_tags.strip(), page=page)
self._posts = posts
self._set_status(f"{len(posts)} results")
try:
post_list = self.query_one("#post-list", PostList)
await post_list.set_posts(posts, self._db, self._current_site.id if self._current_site else None)
except NoMatches:
pass
except Exception as e:
self._set_status(f"Error: {e}")
finally:
await client.close()
self.run_worker(_search(), exclusive=True)
# -- Info --
def _update_info(self, index: int) -> None:
if 0 <= index < len(self._posts):
post = self._posts[index]
status = f"#{post.id} {post.width}x{post.height} score:{post.score} [{post.rating}]"
self._set_status(status)
if self._show_info:
tags_preview = " ".join(post.tag_list[:15])
if len(post.tag_list) > 15:
tags_preview += "..."
info = (
f"#{post.id} {post.width}x{post.height} score:{post.score} [{post.rating}]\n"
f"Tags: {tags_preview}"
)
if post.source:
info += f"\nSource: {post.source}"
try:
self.query_one("#info-bar", InfoBar).update(info)
except NoMatches:
pass
# -- Preview --
def _load_preview(self, index: int) -> None:
if index < 0 or index >= len(self._posts):
return
post = self._posts[index]
self._set_status(f"Loading #{post.id}...")
async def _load(self=self):
try:
path = await download_image(post.file_url)
try:
from .preview import ImagePreview
preview = self.query_one("#preview", Static)
# Show image info in the preview area
info = (
f" Post #{post.id}\n"
f" Size: {post.width}x{post.height}\n"
f" Score: {post.score}\n"
f" Rating: {post.rating or '?'}\n"
f" Cached: {path}\n"
)
if post.source:
info += f" Source: {post.source}\n"
info += f"\n Tags: {' '.join(post.tag_list[:20])}"
preview.update(info)
except NoMatches:
pass
self._set_status(f"Loaded #{post.id}")
except Exception as e:
self._set_status(f"Error: {e}")
self.run_worker(_load())
# -- Actions --
def action_focus_search(self) -> None:
try:
self.query_one("#search-input", Input).focus()
except NoMatches:
pass
def action_unfocus(self) -> None:
try:
self.query_one("#post-list", PostList).focus()
except NoMatches:
pass
def action_next_page(self) -> None:
self._current_page += 1
self._do_search()
def action_prev_page(self) -> None:
if self._current_page > 1:
self._current_page -= 1
self._do_search()
async def action_toggle_favorite(self) -> None:
post_list = self.query_one("#post-list", PostList)
idx = post_list.selected_index
if idx < 0 or idx >= len(self._posts) or not self._current_site:
return
post = self._posts[idx]
site_id = self._current_site.id
if self._db.is_favorited(site_id, post.id):
self._db.remove_favorite(site_id, post.id)
self._set_status(f"Unfavorited #{post.id}")
await post_list.set_posts(self._posts, self._db, site_id)
else:
self._set_status(f"Favoriting #{post.id}...")
async def _fav(self=self):
try:
path = await download_image(post.file_url)
self._db.add_favorite(
site_id=site_id,
post_id=post.id,
file_url=post.file_url,
preview_url=post.preview_url,
tags=post.tags,
rating=post.rating,
score=post.score,
source=post.source,
cached_path=str(path),
)
self._set_status(f"Favorited #{post.id}")
try:
post_list = self.query_one("#post-list", PostList)
await post_list.set_posts(self._posts, self._db, site_id)
except NoMatches:
pass
except Exception as e:
self._set_status(f"Error: {e}")
self.run_worker(_fav())
def action_open_in_default(self) -> None:
post_list = self.query_one("#post-list", PostList)
idx = post_list.selected_index
if idx < 0 or idx >= len(self._posts):
return
post = self._posts[idx]
from ..core.cache import cached_path_for
path = cached_path_for(post.file_url)
if path.exists():
import subprocess, sys
if sys.platform == "linux":
subprocess.Popen(["xdg-open", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
elif sys.platform == "darwin":
subprocess.Popen(["open", str(path)])
else:
import os
os.startfile(str(path))
self._set_status(f"Opened #{post.id}")
else:
self._set_status("Not cached — press Enter to download first")
def action_show_info(self) -> None:
self._show_info = not self._show_info
if self._show_info:
post_list = self.query_one("#post-list", PostList)
self._update_info(post_list.selected_index)
else:
try:
self.query_one("#info-bar", InfoBar).update("Info hidden (press i)")
except NoMatches:
pass
def action_cycle_site(self) -> None:
sites = self._db.get_sites()
if not sites:
self._set_status("No sites configured")
return
if self._current_site:
ids = [s.id for s in sites]
try:
idx = ids.index(self._current_site.id)
next_site = sites[(idx + 1) % len(sites)]
except ValueError:
next_site = sites[0]
else:
next_site = sites[0]
self._current_site = next_site
try:
self.query_one("#site-label", Label).update(f"[{next_site.name}]")
except NoMatches:
pass
self._set_status(f"Switched to {next_site.name}")
def on_unmount(self) -> None:
self._db.close()
def run() -> None:
app = BooruTUI()
app.run()