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]
|
||||
name = "booru-viewer"
|
||||
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"
|
||||
dependencies = [
|
||||
"httpx[http2]>=0.27",
|
||||
"Pillow>=10.0",
|
||||
"PySide6>=6.6",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
gui = ["PySide6>=6.6"]
|
||||
tui = ["textual>=0.50"]
|
||||
all = ["booru-viewer[gui,tui]"]
|
||||
|
||||
[project.scripts]
|
||||
booru-gui = "booru_viewer.main_gui:main"
|
||||
booru-tui = "booru_viewer.main_tui:main"
|
||||
booru-viewer = "booru_viewer.main_gui:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["booru_viewer"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user