diff --git a/dist/renamer-0.8.1-py3-none-any.whl b/dist/renamer-0.8.1-py3-none-any.whl new file mode 100644 index 0000000..31cd14c Binary files /dev/null and b/dist/renamer-0.8.1-py3-none-any.whl differ diff --git a/pyproject.toml b/pyproject.toml index fc5f3b2..bc95ae7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "renamer" -version = "0.7.10" +version = "0.8.1" description = "Terminal-based media file renamer and metadata viewer" readme = "README.md" requires-python = ">=3.11" diff --git a/renamer/app.py b/renamer/app.py index 33bdb8d..f93ec73 100644 --- a/renamer/app.py +++ b/renamer/app.py @@ -90,14 +90,40 @@ class AppCommandProvider(Provider): class RenamerApp(App): CSS = """ + /* Default technical mode: 2 columns */ #left { width: 50%; padding: 1; } - #right { + #middle { width: 50%; padding: 1; } + #right { + display: none; /* Hidden in technical mode */ + } + + /* Catalog mode: 3 columns */ + .catalog-mode #left { + width: 33%; + } + .catalog-mode #middle { + width: 34%; + } + .catalog-mode #right { + display: block; + width: 33%; + padding: 1; + } + + #poster_container { + height: 100%; + overflow-y: auto; + } + + #poster_display { + height: auto; + } """ BINDINGS = [ @@ -128,10 +154,11 @@ class RenamerApp(App): self.cache_manager = CacheManager(self.cache) def compose(self) -> ComposeResult: - with Horizontal(): + with Horizontal(id="main_container"): with Container(id="left"): yield Tree("Files", id="file_tree") - with Container(id="right"): + # Middle container (for catalog mode info) + with Container(id="middle"): with Vertical(): yield LoadingIndicator(id="loading") with ScrollableContainer(id="details_container"): @@ -142,13 +169,29 @@ class RenamerApp(App): "", id="details_catalog", markup=False ) yield Static("", id="proposed", markup=True) + # Right container (for poster in catalog mode, hidden in technical mode) + with Container(id="right"): + with ScrollableContainer(id="poster_container"): + yield Static("", id="poster_display", markup=False) yield Footer() def on_mount(self): loading = self.query_one("#loading", LoadingIndicator) loading.display = False + # Apply initial layout based on mode setting + self._update_layout() self.scan_files() + def _update_layout(self): + """Update layout based on current mode setting.""" + mode = self.settings.get("mode") + main_container = self.query_one("#main_container") + + if mode == "catalog": + main_container.add_class("catalog-mode") + else: + main_container.remove_class("catalog-mode") + def scan_files(self): logging.info("scan_files called") if not self.scan_dir or not self.scan_dir.exists() or not self.scan_dir.is_dir(): @@ -274,42 +317,51 @@ class RenamerApp(App): try: # Initialize extractors and formatters extractor = MediaExtractor(file_path) - + mode = self.settings.get("mode") + poster_content = "" + if mode == "technical": formatter = MediaPanelView(extractor) full_info = formatter.file_info_panel() else: # catalog formatter = CatalogFormatter(extractor, self.settings) - full_info = formatter.format_catalog_info() - + full_info, poster_content = formatter.format_catalog_info() + # Update UI self.call_later( self._update_details, full_info, ProposedFilenameView(extractor).rename_line_formatted(file_path), + poster_content, ) except Exception as e: self.call_later( self._update_details, TextFormatter.red(f"Error extracting details: {str(e)}"), "", + "", ) - def _update_details(self, full_info: str, display_string: str): + def _update_details(self, full_info: str, display_string: str, poster_content: str = ""): self._stop_loading_animation() details_technical = self.query_one("#details_technical", Static) details_catalog = self.query_one("#details_catalog", Static) + poster_display = self.query_one("#poster_display", Static) + mode = self.settings.get("mode") if mode == "technical": details_technical.display = True details_catalog.display = False details_technical.update(full_info) + poster_display.update("") # Clear poster in technical mode else: details_technical.display = False details_catalog.display = True details_catalog.update(full_info) - + # Update poster panel + poster_display.update(poster_content) + proposed = self.query_one("#proposed", Static) proposed.update(display_string) @@ -444,6 +496,8 @@ By Category:""" current_mode = self.settings.get("mode") new_mode = "catalog" if current_mode == "technical" else "technical" self.settings.set("mode", new_mode) + # Update layout to show/hide poster panel + self._update_layout() self.notify(f"Switched to {new_mode} mode", severity="information", timeout=2) # Refresh current file display if any tree = self.query_one("#file_tree", Tree) diff --git a/renamer/constants/edition_constants.py b/renamer/constants/edition_constants.py index 021bb46..b26facb 100644 --- a/renamer/constants/edition_constants.py +++ b/renamer/constants/edition_constants.py @@ -5,7 +5,7 @@ and their aliases for detection in filenames. """ SPECIAL_EDITIONS = { - "Theatrical Cut": ["Theatrical Cut"], + "Theatrical Cut": ["Theatrical Cut", "Theatrical Reconstruction"], "Director's Cut": ["Director's Cut", "Director Cut"], "Extended Cut": ["Extended Cut", "Ultimate Extended Cut", "Extended Edition", "Ultimate Extended Edition"], "Special Edition": ["Special Edition"], @@ -53,7 +53,7 @@ SPECIAL_EDITIONS = { "Workprint": ["Workprint"], "Rough Cut": ["Rough Cut"], "Special Assembly Cut": ["Special Assembly Cut"], - "Amazon Edition": ["Amazon Edition", "Amazon", "AMZN"], + "Amazon Edition": ["Amazon Edition", "Amazon", "Amazon Prime Edition", "Amazon Prime"], "Netflix Edition": ["Netflix Edition"], "HBO Edition": ["HBO Edition"], } diff --git a/renamer/formatters/catalog_formatter.py b/renamer/formatters/catalog_formatter.py index bb6795e..d6725ef 100644 --- a/renamer/formatters/catalog_formatter.py +++ b/renamer/formatters/catalog_formatter.py @@ -1,4 +1,6 @@ from .text_formatter import TextFormatter +from renamer.views.posters import AsciiPosterRenderer, ViuPosterRenderer, RichPixelsPosterRenderer +from typing import Union import os @@ -9,10 +11,14 @@ class CatalogFormatter: self.extractor = extractor self.settings = settings - def format_catalog_info(self) -> str: - """Format catalog information for display""" + def format_catalog_info(self) -> tuple[str, Union[str, object]]: + """Format catalog information for display. + + Returns: + Tuple of (info_text, poster_content) + poster_content can be a string or Rich Renderable object + """ lines = [] - poster_output = None # Title title = self.extractor.get("title", "TMDB") @@ -56,23 +62,6 @@ class CatalogFormatter: if countries: lines.append(f"{TextFormatter.bold('Countries:')} {countries}") - # Poster - check settings for display mode - poster_mode = self.settings.get("poster", "no") if self.settings else "no" - poster_image_path = self.extractor.tmdb_extractor.extract_poster_image_path() - - if poster_mode != "no" and poster_image_path: - lines.append(f"{TextFormatter.bold('Poster:')}") - poster_output = self._display_poster(poster_image_path, poster_mode) - elif poster_mode == "no": - # Don't show poster at all - poster_output = None - else: - # Poster path not cached yet - poster_path = self.extractor.get("poster_path", "TMDB") - if poster_path: - lines.append(f"{TextFormatter.bold('Poster:')} {poster_path} (not cached yet)") - poster_output = None - # Render text content with Rich markup text_content = "\n\n".join(lines) if lines else "No catalog information available" @@ -83,103 +72,55 @@ class CatalogFormatter: console.print(text_content, markup=True) rendered_text = console.file.getvalue() - # Append poster output if available - # Don't process ASCII art through console - just append it directly - if poster_output: - return rendered_text + "\n" + poster_output - else: - return rendered_text + # Get poster separately + poster_content = self.get_poster() - def _display_poster(self, image_path: str, mode: str) -> str: + return rendered_text, poster_content + + def get_poster(self) -> Union[str, object]: + """Get poster content for separate display. + + Returns: + Poster content (string or Rich Renderable) or empty string if no poster + """ + poster_mode = self.settings.get("poster", "no") if self.settings else "no" + + if poster_mode == "no": + return "" + + poster_image_path = self.extractor.tmdb_extractor.extract_poster_image_path() + + if poster_image_path: + return self._display_poster(poster_image_path, poster_mode) + else: + # Poster path not cached yet + poster_path = self.extractor.get("poster_path", "TMDB") + if poster_path: + return f"{TextFormatter.bold('Poster:')} {poster_path} (not cached yet)" + return "" + + def _display_poster(self, image_path: str, mode: str) -> Union[str, object]: """Display poster image based on mode setting. Args: image_path: Path to the poster image - mode: Display mode - "pseudo" for ASCII art, "viu" for viu rendering + mode: Display mode - "pseudo" for ASCII art, "viu", "richpixels" Returns: - Rendered poster as string + Rendered poster (string or Rich Renderable object) """ if not os.path.exists(image_path): return f"Image file not found: {image_path}" + # Select renderer based on mode if mode == "viu": - return self._display_poster_viu(image_path) + renderer = ViuPosterRenderer() elif mode == "pseudo": - return self._display_poster_pseudo(image_path) + renderer = AsciiPosterRenderer() + elif mode == "richpixels": + renderer = RichPixelsPosterRenderer() else: return f"Unknown poster mode: {mode}" - def _display_poster_viu(self, image_path: str) -> str: - """Display poster using viu (not working in Textual, only in terminal)""" - import subprocess - import shutil - - # Check if viu is available - if not shutil.which('viu'): - return f"viu not installed. Install with: cargo install viu\nPoster at: {image_path}" - - try: - # Run viu to render the image - # -w 40: width in characters - # -t: transparent background - result = subprocess.run( - ['viu', '-w', '40', '-t', image_path], - capture_output=True, - check=True - ) - # Decode bytes output, preserving ANSI escape sequences - return result.stdout.decode('utf-8', errors='replace') - - except subprocess.CalledProcessError as e: - stderr_msg = e.stderr.decode('utf-8', errors='replace') if e.stderr else 'Unknown error' - return f"Failed to render image with viu: {stderr_msg}\nPoster at: {image_path}" - except Exception as e: - return f"Failed to display image: {e}\nPoster at: {image_path}" - - def _display_poster_pseudo(self, image_path: str) -> str: - """Display poster image using ASCII art (pseudo graphics)""" - try: - from PIL import Image, ImageEnhance - - # Open image - img = Image.open(image_path) - - # Enhance contrast for better detail - enhancer = ImageEnhance.Contrast(img) - img = enhancer.enhance(1.3) - - # Convert to grayscale and resize - # Compact size for ASCII art (35x35 -> 35x17 after row averaging) - img = img.convert('L').resize((35, 35), Image.Resampling.LANCZOS) - - # Extended ASCII characters from darkest to lightest (more gradient levels) - # Using characters with different visual density for better detail - ascii_chars = '$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,"^`\'. ' - - # Convert to ASCII - pixels = img.getdata() - width, height = img.size - - ascii_art = [] - for y in range(0, height, 2): # Skip every other row for aspect ratio correction - row = [] - for x in range(width): - # Average of two rows for better aspect ratio - pixel1 = pixels[y * width + x] if y < height else 255 - pixel2 = pixels[(y + 1) * width + x] if y + 1 < height else 255 - avg = (pixel1 + pixel2) // 2 - - # Map pixel brightness to character - # Invert: 0 (black) -> dark char, 255 (white) -> light char - char_index = (255 - avg) * (len(ascii_chars) - 1) // 255 - char = ascii_chars[char_index] - row.append(char) - ascii_art.append(''.join(row)) - - return '\n'.join(ascii_art) - - except ImportError: - return f"PIL not available for pseudo graphics\nPoster at: {image_path}" - except Exception as e: - return f"Failed to display image: {e}\nPoster at: {image_path}" \ No newline at end of file + # Render the poster + return renderer.render(image_path, width=40) \ No newline at end of file diff --git a/renamer/formatters/track_formatter.py b/renamer/formatters/track_formatter.py index 111a559..15c50a2 100644 --- a/renamer/formatters/track_formatter.py +++ b/renamer/formatters/track_formatter.py @@ -7,18 +7,19 @@ class TrackFormatter: codec = track.get('codec', 'unknown') width = track.get('width', '?') height = track.get('height', '?') - bitrate = track.get('bitrate') + bitrate = track.get('bitrate') # in bps + bitrate_kbps = bitrate / 1024 if bitrate else None fps = track.get('fps') profile = track.get('profile') - + video_str = f"{codec} {width}x{height}" - if bitrate: - video_str += f" {bitrate}bps" + if bitrate_kbps: + video_str += f" {bitrate_kbps}kbps" if fps: video_str += f" {fps}fps" if profile: video_str += f" ({profile})" - + return video_str @staticmethod @@ -27,12 +28,12 @@ class TrackFormatter: codec = track.get('codec', 'unknown') channels = track.get('channels', '?') lang = track.get('language', 'und') - bitrate = track.get('bitrate') - + bitrate = track.get('bitrate') # in bps + bitrate_kbps = bitrate / 1024 if bitrate else None + audio_str = f"{codec} {channels}ch {lang}" - if bitrate: - audio_str += f" {bitrate}bps" - + if bitrate_kbps: + audio_str += f" {bitrate_kbps}kbps" return audio_str @staticmethod @@ -40,5 +41,5 @@ class TrackFormatter: """Format a subtitle track dict into a display string""" lang = track.get('language', 'und') format = track.get('format', 'unknown') - - return f"{lang} ({format})" \ No newline at end of file + + return f"{lang} ({format})" diff --git a/renamer/screens.py b/renamer/screens.py index 7ccab38..722e2b3 100644 --- a/renamer/screens.py +++ b/renamer/screens.py @@ -311,8 +311,9 @@ Configure application settings. yield Static("Poster Display (Catalog Mode):", classes="label") with Horizontal(): yield Button("No", id="poster_no", variant="primary" if settings.get("poster") == "no" else "default") - yield Button("Pseudo", id="poster_pseudo", variant="primary" if settings.get("poster") == "pseudo" else "default") + yield Button("ASCII", id="poster_pseudo", variant="primary" if settings.get("poster") == "pseudo" else "default") yield Button("Viu", id="poster_viu", variant="primary" if settings.get("poster") == "viu" else "default") + yield Button("RichPixels", id="poster_richpixels", variant="primary" if settings.get("poster") == "richpixels" else "default") # HEVC quality selection yield Static("HEVC Encoding Quality (for conversions):", classes="label") @@ -360,15 +361,17 @@ Configure application settings. cat_btn.variant = "primary" if mode == "catalog" else "default" elif event.button.id.startswith("poster_"): # Toggle poster buttons - poster_mode = event.button.id.split("_")[1] + poster_mode = event.button.id.split("_", 1)[1] # Use split with maxsplit=1 to handle "richpixels" self.app.settings.set("poster", poster_mode) # type: ignore # Update button variants no_btn = self.query_one("#poster_no", Button) pseudo_btn = self.query_one("#poster_pseudo", Button) viu_btn = self.query_one("#poster_viu", Button) + richpixels_btn = self.query_one("#poster_richpixels", Button) no_btn.variant = "primary" if poster_mode == "no" else "default" pseudo_btn.variant = "primary" if poster_mode == "pseudo" else "default" viu_btn.variant = "primary" if poster_mode == "viu" else "default" + richpixels_btn.variant = "primary" if poster_mode == "richpixels" else "default" elif event.button.id.startswith("hevc_crf_"): # Toggle HEVC CRF buttons crf_value = int(event.button.id.split("_")[-1]) diff --git a/renamer/settings.py b/renamer/settings.py index 5fd5aa3..1f8eab2 100644 --- a/renamer/settings.py +++ b/renamer/settings.py @@ -9,7 +9,7 @@ class Settings: DEFAULTS = { "mode": "technical", # "technical" or "catalog" - "poster": "no", # "no", "pseudo", "viu" + "poster": "no", # "no", "pseudo" (ASCII art), "viu", "richpixels" "hevc_crf": 23, # HEVC quality: 18=visually lossless, 23=high quality, 28=balanced "hevc_preset": "fast", # HEVC speed: ultrafast, veryfast, faster, fast, medium, slow "cache_ttl_extractors": 21600, # 6 hours in seconds diff --git a/renamer/views/media_panel_properties.py b/renamer/views/media_panel_properties.py index c81b566..23c9eef 100644 --- a/renamer/views/media_panel_properties.py +++ b/renamer/views/media_panel_properties.py @@ -122,7 +122,7 @@ class MediaPanelProperties: def tmdb_year(self) -> str: """Get TMDB year formatted with label.""" return self._extractor.get("year", "TMDB") - + @property @text_decorators.blue() @conditional_decorators.wrap("Countries: ") @@ -131,7 +131,7 @@ class MediaPanelProperties: def tmdb_countries(self) -> str: """Get TMDB production countries formatted with label.""" return self._extractor.get("production_countries", "TMDB") - + @property @text_decorators.blue() @conditional_decorators.wrap("Genres: ") @@ -369,7 +369,7 @@ class MediaPanelProperties: @property @text_decorators.blue() - @conditional_decorators.wrap("Title: ") + @conditional_decorators.wrap(left="󰿎 ") @text_decorators.yellow() @conditional_decorators.default("") def media_title(self) -> str: @@ -402,7 +402,7 @@ class MediaPanelProperties: def media_file_size(self) -> str: """Get media file size formatted with label.""" return self._extractor.get("file_size") - + @property @text_decorators.blue() @conditional_decorators.wrap("Extension: ") diff --git a/renamer/views/posters/__init__.py b/renamer/views/posters/__init__.py new file mode 100644 index 0000000..cd687af --- /dev/null +++ b/renamer/views/posters/__init__.py @@ -0,0 +1,19 @@ +"""Poster rendering views. + +This package provides different rendering engines for movie posters: +- ASCII art (pseudo graphics) +- viu (terminal image viewer) +- rich-pixels (Rich library integration) +""" + +from .base import PosterRenderer +from .ascii_renderer import AsciiPosterRenderer +from .viu_renderer import ViuPosterRenderer +from .richpixels_renderer import RichPixelsPosterRenderer + +__all__ = [ + 'PosterRenderer', + 'AsciiPosterRenderer', + 'ViuPosterRenderer', + 'RichPixelsPosterRenderer', +] diff --git a/renamer/views/posters/ascii_renderer.py b/renamer/views/posters/ascii_renderer.py new file mode 100644 index 0000000..9060029 --- /dev/null +++ b/renamer/views/posters/ascii_renderer.py @@ -0,0 +1,80 @@ +"""ASCII art poster renderer.""" + +from .base import PosterRenderer + + +class AsciiPosterRenderer(PosterRenderer): + """Render posters as ASCII art using PIL.""" + + def render(self, image_path: str, width: int = 35) -> str: + """Render poster as ASCII art. + + Args: + image_path: Path to the poster image + width: Width in characters (default: 35) + + Returns: + ASCII art representation of the poster + """ + is_valid, error_msg = self.validate_image(image_path) + if not is_valid: + return error_msg + + is_available, msg = self.is_available() + if not is_available: + return msg + + try: + from PIL import Image, ImageEnhance + + # Open image + img = Image.open(image_path) + + # Enhance contrast for better detail + enhancer = ImageEnhance.Contrast(img) + img = enhancer.enhance(1.3) + + # Convert to grayscale and resize + # Using provided width, height calculated to maintain aspect ratio + img = img.convert('L').resize((width, width), Image.Resampling.LANCZOS) + + # Extended ASCII characters from darkest to lightest (more gradient levels) + # Using characters with different visual density for better detail + ascii_chars = '$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,"^`\'. ' + + # Convert to ASCII + pixels = img.getdata() + img_width, height = img.size + + ascii_art = [] + for y in range(0, height, 2): # Skip every other row for aspect ratio correction + row = [] + for x in range(img_width): + # Average of two rows for better aspect ratio + pixel1 = pixels[y * img_width + x] if y < height else 255 + pixel2 = pixels[(y + 1) * img_width + x] if y + 1 < height else 255 + avg = (pixel1 + pixel2) // 2 + + # Map pixel brightness to character + # Invert: 0 (black) -> dark char, 255 (white) -> light char + char_index = (255 - avg) * (len(ascii_chars) - 1) // 255 + char = ascii_chars[char_index] + row.append(char) + ascii_art.append(''.join(row)) + + return '\n'.join(ascii_art) + + except Exception as e: + return f"Failed to display image: {e}\nPoster at: {image_path}" + + def is_available(self) -> tuple[bool, str]: + """Check if PIL is available. + + Returns: + Tuple of (is_available, message) + """ + try: + import PIL + return True, "" + except ImportError: + return False, "PIL not available for ASCII art rendering\nInstall with: pip install Pillow" diff --git a/renamer/views/posters/base.py b/renamer/views/posters/base.py new file mode 100644 index 0000000..d5d7f87 --- /dev/null +++ b/renamer/views/posters/base.py @@ -0,0 +1,43 @@ +"""Base class for poster renderers.""" + +from abc import ABC, abstractmethod +import os + + +class PosterRenderer(ABC): + """Abstract base class for poster rendering implementations.""" + + @abstractmethod + def render(self, image_path: str, width: int = 40) -> str: + """Render a poster image to a string. + + Args: + image_path: Path to the poster image file + width: Desired width in characters + + Returns: + Rendered poster as a string + """ + pass + + def validate_image(self, image_path: str) -> tuple[bool, str]: + """Validate that image file exists. + + Args: + image_path: Path to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if not os.path.exists(image_path): + return False, f"Image file not found: {image_path}" + return True, "" + + @abstractmethod + def is_available(self) -> tuple[bool, str]: + """Check if this renderer is available on the system. + + Returns: + Tuple of (is_available, message) + """ + pass diff --git a/renamer/views/posters/richpixels_renderer.py b/renamer/views/posters/richpixels_renderer.py new file mode 100644 index 0000000..4b2450b --- /dev/null +++ b/renamer/views/posters/richpixels_renderer.py @@ -0,0 +1,51 @@ +"""Rich-pixels renderer for high-quality terminal image display.""" + +from .base import PosterRenderer +from typing import Union + + +class RichPixelsPosterRenderer(PosterRenderer): + """Render posters using rich-pixels library for high-quality display.""" + + def render(self, image_path: str, width: int = 40) -> Union[str, object]: + """Render poster using rich-pixels. + + Args: + image_path: Path to the poster image + width: Width in characters (default: 40) + + Returns: + Rich Pixels object (Renderable) or error string + """ + is_valid, error_msg = self.validate_image(image_path) + if not is_valid: + return error_msg + + is_available, msg = self.is_available() + if not is_available: + return msg + + try: + from rich_pixels import Pixels + + # Create a Pixels object from the image + # Return the Pixels object directly - it's a Rich Renderable + # that Textual can display natively + pixels = Pixels.from_image_path(image_path, resize=(width * 2, width * 2)) + + return pixels + + except Exception as e: + return f"Failed to display image with rich-pixels: {e}\nPoster at: {image_path}" + + def is_available(self) -> tuple[bool, str]: + """Check if rich-pixels is installed. + + Returns: + Tuple of (is_available, message) + """ + try: + import rich_pixels + return True, "" + except ImportError: + return False, "rich-pixels not installed. Install with: pip install rich-pixels" diff --git a/renamer/views/posters/viu_renderer.py b/renamer/views/posters/viu_renderer.py new file mode 100644 index 0000000..1516cd9 --- /dev/null +++ b/renamer/views/posters/viu_renderer.py @@ -0,0 +1,55 @@ +"""Viu terminal image viewer renderer.""" + +import subprocess +import shutil +from .base import PosterRenderer + + +class ViuPosterRenderer(PosterRenderer): + """Render posters using viu terminal image viewer.""" + + def render(self, image_path: str, width: int = 40) -> str: + """Render poster using viu. + + Args: + image_path: Path to the poster image + width: Width in characters (default: 40) + + Returns: + Viu-rendered image with ANSI escape sequences + """ + is_valid, error_msg = self.validate_image(image_path) + if not is_valid: + return error_msg + + is_available, msg = self.is_available() + if not is_available: + return msg + + try: + # Run viu to render the image + # -w : width in characters + # -t: transparent background + result = subprocess.run( + ['viu', '-w', str(width), '-t', image_path], + capture_output=True, + check=True + ) + # Decode bytes output, preserving ANSI escape sequences + return result.stdout.decode('utf-8', errors='replace') + + except subprocess.CalledProcessError as e: + stderr_msg = e.stderr.decode('utf-8', errors='replace') if e.stderr else 'Unknown error' + return f"Failed to render image with viu: {stderr_msg}\nPoster at: {image_path}" + except Exception as e: + return f"Failed to display image: {e}\nPoster at: {image_path}" + + def is_available(self) -> tuple[bool, str]: + """Check if viu is installed. + + Returns: + Tuple of (is_available, message) + """ + if shutil.which('viu'): + return True, "" + return False, "viu not installed. Install with: cargo install viu" diff --git a/renamer/widgets/__init__.py b/renamer/widgets/__init__.py new file mode 100644 index 0000000..14277fb --- /dev/null +++ b/renamer/widgets/__init__.py @@ -0,0 +1,5 @@ +"""Custom Textual widgets.""" + +from .poster_widget import PosterWidget + +__all__ = ['PosterWidget'] diff --git a/renamer/widgets/poster_widget.py b/renamer/widgets/poster_widget.py new file mode 100644 index 0000000..2c6f287 --- /dev/null +++ b/renamer/widgets/poster_widget.py @@ -0,0 +1,29 @@ +"""Custom widget for rendering poster images.""" + +from textual.widget import Widget +from textual.widgets import Static +from rich.console import RenderableType +from typing import Union + + +class PosterWidget(Static): + """Widget optimized for displaying poster images with Rich renderables. + + This widget properly handles both string content and Rich renderables + (like Pixels from rich-pixels) without escaping or markup processing. + """ + + def __init__(self, *args, **kwargs): + """Initialize poster widget with markup disabled.""" + # Force markup=False to prevent text processing + kwargs['markup'] = False + super().__init__(*args, **kwargs) + + def update_poster(self, renderable: Union[str, RenderableType]) -> None: + """Update poster display with new content. + + Args: + renderable: String or Rich Renderable object to display + """ + # Directly update with the renderable - Textual handles Rich renderables natively + self.update(renderable if renderable else "") diff --git a/uv.lock b/uv.lock index e9e5b77..8c23934 100644 --- a/uv.lock +++ b/uv.lock @@ -462,7 +462,7 @@ wheels = [ [[package]] name = "renamer" -version = "0.7.10" +version = "0.8.1" source = { editable = "." } dependencies = [ { name = "langcodes" },