512 lines
15 KiB
Python
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()
|