diff --git a/booru_viewer/main_tui.py b/booru_viewer/main_tui.py deleted file mode 100644 index 4b16e9e..0000000 --- a/booru_viewer/main_tui.py +++ /dev/null @@ -1,10 +0,0 @@ -"""TUI entry point.""" - - -def main() -> None: - from .tui.app import run - run() - - -if __name__ == "__main__": - main() diff --git a/booru_viewer/tui/__init__.py b/booru_viewer/tui/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/booru_viewer/tui/app.py b/booru_viewer/tui/app.py deleted file mode 100644 index 7ebafd3..0000000 --- a/booru_viewer/tui/app.py +++ /dev/null @@ -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() diff --git a/booru_viewer/tui/favorites.py b/booru_viewer/tui/favorites.py deleted file mode 100644 index 5ebb78f..0000000 --- a/booru_viewer/tui/favorites.py +++ /dev/null @@ -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)) diff --git a/booru_viewer/tui/grid.py b/booru_viewer/tui/grid.py deleted file mode 100644 index 691160f..0000000 --- a/booru_viewer/tui/grid.py +++ /dev/null @@ -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() diff --git a/booru_viewer/tui/preview.py b/booru_viewer/tui/preview.py deleted file mode 100644 index 0ac5ee2..0000000 --- a/booru_viewer/tui/preview.py +++ /dev/null @@ -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("") diff --git a/booru_viewer/tui/search.py b/booru_viewer/tui/search.py deleted file mode 100644 index 3c585aa..0000000 --- a/booru_viewer/tui/search.py +++ /dev/null @@ -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) diff --git a/booru_viewer/tui/sites.py b/booru_viewer/tui/sites.py deleted file mode 100644 index 0b94572..0000000 --- a/booru_viewer/tui/sites.py +++ /dev/null @@ -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)) diff --git a/pyproject.toml b/pyproject.toml index 8c1e3f2..fee2271 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]