Remove TUI interface, simplify to GUI-only
This commit is contained in:
parent
b10c00d6bf
commit
4a40ccebbd
@ -1,10 +0,0 @@
|
|||||||
"""TUI entry point."""
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
from .tui.app import run
|
|
||||||
run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,511 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
"""Favorites browser panel for the TUI."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from textual.widgets import Static, Input
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
|
|
||||||
from ..core.db import Database, Favorite
|
|
||||||
from ..core.config import GREEN, DIM_GREEN
|
|
||||||
|
|
||||||
|
|
||||||
class FavoritesPanel(Static):
|
|
||||||
"""Browse local favorites."""
|
|
||||||
|
|
||||||
def __init__(self, db: Database, **kwargs) -> None:
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._db = db
|
|
||||||
self._favorites: list[Favorite] = []
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.refresh_list()
|
|
||||||
|
|
||||||
def refresh_list(self, search: str | None = None) -> None:
|
|
||||||
self._favorites = self._db.get_favorites(search=search, limit=100)
|
|
||||||
total = self._db.favorite_count()
|
|
||||||
|
|
||||||
if not self._favorites:
|
|
||||||
self.update(" No favorites yet.\n Press 'f' on a post to favorite it.")
|
|
||||||
return
|
|
||||||
|
|
||||||
lines = [f" Favorites ({len(self._favorites)}/{total}):\n"]
|
|
||||||
for fav in self._favorites:
|
|
||||||
cached = "cached" if fav.cached_path and Path(fav.cached_path).exists() else "remote"
|
|
||||||
tags_preview = " ".join(fav.tags.split()[:5])
|
|
||||||
if len(fav.tags.split()) > 5:
|
|
||||||
tags_preview += "..."
|
|
||||||
lines.append(
|
|
||||||
f" #{fav.post_id} [{cached}] {tags_preview}"
|
|
||||||
)
|
|
||||||
self.update("\n".join(lines))
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
"""Thumbnail grid widget for the Textual TUI."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.binding import Binding
|
|
||||||
from textual.widgets import Static
|
|
||||||
from textual.reactive import reactive
|
|
||||||
|
|
||||||
from ..core.api.base import Post
|
|
||||||
from ..core.db import Database
|
|
||||||
from ..core.config import GREEN, DIM_GREEN, BG
|
|
||||||
|
|
||||||
|
|
||||||
class ThumbnailCell(Static):
|
|
||||||
"""A single post cell in the grid."""
|
|
||||||
|
|
||||||
def __init__(self, index: int, post: Post, favorited: bool = False) -> None:
|
|
||||||
self._index = index
|
|
||||||
self._post = post
|
|
||||||
self._favorited = favorited
|
|
||||||
self._selected = False
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def compose_content(self) -> str:
|
|
||||||
fav = " *" if self._favorited else ""
|
|
||||||
rating = self._post.rating or "?"
|
|
||||||
return (
|
|
||||||
f"#{self._post.id}{fav}\n"
|
|
||||||
f"[{rating}] s:{self._post.score}\n"
|
|
||||||
f"{self._post.width}x{self._post.height}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.update(self.compose_content())
|
|
||||||
self._apply_style()
|
|
||||||
|
|
||||||
def set_selected(self, selected: bool) -> None:
|
|
||||||
self._selected = selected
|
|
||||||
self._apply_style()
|
|
||||||
|
|
||||||
def set_favorited(self, favorited: bool) -> None:
|
|
||||||
self._favorited = favorited
|
|
||||||
self.update(self.compose_content())
|
|
||||||
|
|
||||||
def _apply_style(self) -> None:
|
|
||||||
if self._selected:
|
|
||||||
self.styles.background = DIM_GREEN
|
|
||||||
self.styles.color = BG
|
|
||||||
self.styles.border = ("solid", GREEN)
|
|
||||||
else:
|
|
||||||
self.styles.background = BG
|
|
||||||
self.styles.color = GREEN if self._favorited else DIM_GREEN
|
|
||||||
self.styles.border = ("solid", DIM_GREEN)
|
|
||||||
|
|
||||||
def on_click(self) -> None:
|
|
||||||
self.post_message(CellClicked(self._index))
|
|
||||||
|
|
||||||
|
|
||||||
class CellClicked:
|
|
||||||
"""Message sent when a cell is clicked."""
|
|
||||||
def __init__(self, index: int) -> None:
|
|
||||||
self.index = index
|
|
||||||
|
|
||||||
|
|
||||||
class ThumbnailGrid(Static):
|
|
||||||
"""Grid of post cells with keyboard navigation."""
|
|
||||||
|
|
||||||
BINDINGS = [
|
|
||||||
Binding("j", "move_down", "Down", show=False),
|
|
||||||
Binding("k", "move_up", "Up", show=False),
|
|
||||||
Binding("h", "move_left", "Left", show=False),
|
|
||||||
Binding("l", "move_right", "Right", show=False),
|
|
||||||
Binding("down", "move_down", "Down", show=False),
|
|
||||||
Binding("up", "move_up", "Up", show=False),
|
|
||||||
Binding("left", "move_left", "Left", show=False),
|
|
||||||
Binding("right", "move_right", "Right", show=False),
|
|
||||||
]
|
|
||||||
|
|
||||||
selected_index: int = reactive(-1, init=False)
|
|
||||||
|
|
||||||
def __init__(self, **kwargs) -> None:
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._cells: list[ThumbnailCell] = []
|
|
||||||
self._posts: list[Post] = []
|
|
||||||
self._cols = 4
|
|
||||||
self.can_focus = True
|
|
||||||
|
|
||||||
def set_posts(
|
|
||||||
self, posts: list[Post], db: Database | None = None, site_id: int | None = None
|
|
||||||
) -> None:
|
|
||||||
self._posts = posts
|
|
||||||
# Remove old cells
|
|
||||||
for cell in self._cells:
|
|
||||||
cell.remove()
|
|
||||||
self._cells.clear()
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
for i, post in enumerate(posts):
|
|
||||||
fav = False
|
|
||||||
if db and site_id:
|
|
||||||
fav = db.is_favorited(site_id, post.id)
|
|
||||||
|
|
||||||
fav_marker = " *" if fav else ""
|
|
||||||
rating = post.rating or "?"
|
|
||||||
selected = " >> " if i == 0 else " "
|
|
||||||
lines.append(
|
|
||||||
f"{selected}#{post.id}{fav_marker} [{rating}] "
|
|
||||||
f"s:{post.score} {post.width}x{post.height}"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.selected_index = 0 if posts else -1
|
|
||||||
self.update("\n".join(lines) if lines else "No results. Search for tags above.")
|
|
||||||
|
|
||||||
def update_favorite_status(self, index: int, favorited: bool) -> None:
|
|
||||||
"""Refresh the display for a single post's favorite status."""
|
|
||||||
if 0 <= index < len(self._posts):
|
|
||||||
self.set_posts(self._posts) # Simple refresh
|
|
||||||
|
|
||||||
def _render_list(self) -> None:
|
|
||||||
lines = []
|
|
||||||
for i, post in enumerate(self._posts):
|
|
||||||
selected = " >> " if i == self.selected_index else " "
|
|
||||||
lines.append(
|
|
||||||
f"{selected}#{post.id} [{post.rating or '?'}] "
|
|
||||||
f"s:{post.score} {post.width}x{post.height}"
|
|
||||||
)
|
|
||||||
self.update("\n".join(lines) if lines else "No results.")
|
|
||||||
|
|
||||||
def action_move_down(self) -> None:
|
|
||||||
if self._posts and self.selected_index < len(self._posts) - 1:
|
|
||||||
self.selected_index += 1
|
|
||||||
self._render_list()
|
|
||||||
|
|
||||||
def action_move_up(self) -> None:
|
|
||||||
if self._posts and self.selected_index > 0:
|
|
||||||
self.selected_index -= 1
|
|
||||||
self._render_list()
|
|
||||||
|
|
||||||
def action_move_right(self) -> None:
|
|
||||||
self.action_move_down()
|
|
||||||
|
|
||||||
def action_move_left(self) -> None:
|
|
||||||
self.action_move_up()
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
"""Image preview widget with Kitty graphics protocol support."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
from ..core.config import GREEN, DIM_GREEN, BG
|
|
||||||
|
|
||||||
|
|
||||||
def _supports_kitty() -> bool:
|
|
||||||
"""Check if the terminal likely supports the Kitty graphics protocol."""
|
|
||||||
term = os.environ.get("TERM", "")
|
|
||||||
term_program = os.environ.get("TERM_PROGRAM", "")
|
|
||||||
return "kitty" in term or "kitty" in term_program
|
|
||||||
|
|
||||||
|
|
||||||
def _kitty_display(path: str, cols: int = 80, rows: int = 24) -> str:
|
|
||||||
"""Generate Kitty graphics protocol escape sequence for an image."""
|
|
||||||
try:
|
|
||||||
data = Path(path).read_bytes()
|
|
||||||
b64 = base64.standard_b64encode(data).decode("ascii")
|
|
||||||
|
|
||||||
# Send in chunks (Kitty protocol requires chunked transfer for large images)
|
|
||||||
chunks = [b64[i:i + 4096] for i in range(0, len(b64), 4096)]
|
|
||||||
output = ""
|
|
||||||
for i, chunk in enumerate(chunks):
|
|
||||||
is_last = i == len(chunks) - 1
|
|
||||||
m = 0 if is_last else 1
|
|
||||||
if i == 0:
|
|
||||||
output += f"\033_Ga=T,f=100,m={m},c={cols},r={rows};{chunk}\033\\"
|
|
||||||
else:
|
|
||||||
output += f"\033_Gm={m};{chunk}\033\\"
|
|
||||||
return output
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
class ImagePreview(Static):
|
|
||||||
"""Image preview panel. Uses Kitty graphics protocol on supported terminals,
|
|
||||||
otherwise shows image metadata."""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs) -> None:
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._path: str | None = None
|
|
||||||
self._info: str = ""
|
|
||||||
self._use_kitty = _supports_kitty()
|
|
||||||
|
|
||||||
def show_image(self, path: str, info: str = "") -> None:
|
|
||||||
self._path = path
|
|
||||||
self._info = info
|
|
||||||
|
|
||||||
if self._use_kitty and self._path:
|
|
||||||
# Write Kitty escape directly to terminal, show info in widget
|
|
||||||
size = self.size
|
|
||||||
kitty_seq = _kitty_display(path, cols=size.width, rows=size.height - 2)
|
|
||||||
if kitty_seq:
|
|
||||||
sys.stdout.write(kitty_seq)
|
|
||||||
sys.stdout.flush()
|
|
||||||
self.update(f"\n{info}")
|
|
||||||
else:
|
|
||||||
# Fallback: show file info
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
with Image.open(path) as img:
|
|
||||||
w, h = img.size
|
|
||||||
fmt = img.format or "unknown"
|
|
||||||
size_kb = Path(path).stat().st_size / 1024
|
|
||||||
text = (
|
|
||||||
f" Image: {Path(path).name}\n"
|
|
||||||
f" Size: {w}x{h} ({size_kb:.0f} KB)\n"
|
|
||||||
f" Format: {fmt}\n"
|
|
||||||
f"\n {info}\n"
|
|
||||||
f"\n (Kitty graphics protocol not detected;\n"
|
|
||||||
f" run in Kitty terminal for inline preview)"
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
text = f" {info}\n\n (Cannot read image)"
|
|
||||||
self.update(text)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
self._path = None
|
|
||||||
self._info = ""
|
|
||||||
if self._use_kitty:
|
|
||||||
# Clear Kitty images
|
|
||||||
sys.stdout.write("\033_Ga=d;\033\\")
|
|
||||||
sys.stdout.flush()
|
|
||||||
self.update("")
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
"""Search input widget for the TUI."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from textual.widgets import Input
|
|
||||||
|
|
||||||
|
|
||||||
class SearchInput(Input):
|
|
||||||
"""Tag search input with styling."""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs) -> None:
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
"""Site manager panel for the TUI."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from textual.widgets import Static, Input, Button, Label
|
|
||||||
from textual.containers import Vertical
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
|
|
||||||
from ..core.db import Database
|
|
||||||
from ..core.api.detect import detect_site_type
|
|
||||||
from ..core.config import GREEN, DIM_GREEN, BG
|
|
||||||
|
|
||||||
|
|
||||||
class SitePanel(Static):
|
|
||||||
"""Site management panel."""
|
|
||||||
|
|
||||||
def __init__(self, db: Database, **kwargs) -> None:
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._db = db
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.refresh_list()
|
|
||||||
|
|
||||||
def refresh_list(self) -> None:
|
|
||||||
sites = self._db.get_sites(enabled_only=False)
|
|
||||||
if not sites:
|
|
||||||
self.update(
|
|
||||||
" No sites configured.\n\n"
|
|
||||||
" Use the GUI (booru-gui) to add sites,\n"
|
|
||||||
" or add them via Python:\n\n"
|
|
||||||
" from booru_viewer.core.db import Database\n"
|
|
||||||
" db = Database()\n"
|
|
||||||
" db.add_site('Danbooru', 'https://danbooru.donmai.us', 'danbooru')\n"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
lines = [" Sites:\n"]
|
|
||||||
for site in sites:
|
|
||||||
status = "ON" if site.enabled else "OFF"
|
|
||||||
lines.append(
|
|
||||||
f" [{status}] {site.name} ({site.api_type}) {site.url}"
|
|
||||||
)
|
|
||||||
lines.append("\n (Manage sites via GUI or Python API)")
|
|
||||||
self.update("\n".join(lines))
|
|
||||||
@ -5,21 +5,16 @@ build-backend = "hatchling.build"
|
|||||||
[project]
|
[project]
|
||||||
name = "booru-viewer"
|
name = "booru-viewer"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Local booru image browser with Qt6 GUI and Textual TUI"
|
description = "Local booru image browser with Qt6 GUI"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"httpx[http2]>=0.27",
|
"httpx[http2]>=0.27",
|
||||||
"Pillow>=10.0",
|
"Pillow>=10.0",
|
||||||
|
"PySide6>=6.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
gui = ["PySide6>=6.6"]
|
|
||||||
tui = ["textual>=0.50"]
|
|
||||||
all = ["booru-viewer[gui,tui]"]
|
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
booru-gui = "booru_viewer.main_gui:main"
|
booru-viewer = "booru_viewer.main_gui:main"
|
||||||
booru-tui = "booru_viewer.main_tui:main"
|
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["booru_viewer"]
|
packages = ["booru_viewer"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user