Remove TUI interface, simplify to GUI-only

This commit is contained in:
pax 2026-04-04 06:04:32 -05:00
parent b10c00d6bf
commit 4a40ccebbd
9 changed files with 3 additions and 864 deletions

View File

@ -1,10 +0,0 @@
"""TUI entry point."""
def main() -> None:
from .tui.app import run
run()
if __name__ == "__main__":
main()

View File

@ -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()

View File

@ -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))

View File

@ -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()

View File

@ -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("")

View File

@ -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)

View File

@ -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))

View File

@ -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"]