Created
May 20, 2026 22:14
-
-
Save promto-c/a847ccc28d52feed765b35cd6c515d14 to your computer and use it in GitHub Desktop.
ANSI 24-bit color terminal image browser with mouse selection, pan/zoom, native zoom, and sampling modes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| # Copyright (c) 2026 promto-c | |
| # SPDX-License-Identifier: MIT | |
| # Standard Library Imports | |
| # ------------------------ | |
| import argparse | |
| import curses | |
| import math | |
| import sys | |
| import time | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| # Third Party Imports | |
| # ------------------- | |
| from PIL import Image | |
| from PIL import ImageOps | |
| # Constant Definitions | |
| # -------------------- | |
| IMAGE_EXTENSIONS = { | |
| ".png", | |
| ".jpg", | |
| ".jpeg", | |
| ".bmp", | |
| ".gif", | |
| ".tif", | |
| ".tiff", | |
| ".webp", | |
| } | |
| ANSI_RESET = "\x1b[0m" | |
| ANSI_CLEAR = "\x1b[2J" | |
| ANSI_HOME = "\x1b[H" | |
| ANSI_HIDE_CURSOR = "\x1b[?25l" | |
| ANSI_SHOW_CURSOR = "\x1b[?25h" | |
| ANSI_ENTER_ALT_SCREEN = "\x1b[?1049h" | |
| ANSI_EXIT_ALT_SCREEN = "\x1b[?1049l" | |
| ANSI_ENABLE_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h" | |
| ANSI_DISABLE_MOUSE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l" | |
| MIN_ZOOM = 0.05 | |
| MAX_ZOOM = 512.0 | |
| ZOOM_STEP = 1.18 | |
| EPSILON = 0.001 | |
| TARGET_FPS = 24.0 | |
| MAX_INPUT_EVENTS_PER_TICK = 120 | |
| IDLE_SLEEP_SECONDS = 0.004 | |
| LIST_WHEEL_STEP = 1 | |
| STATUS_PREFIX = ( | |
| " ↑/↓ Select List Click/Wheel Preview Wheel Zoom " | |
| "Left-drag Pan " | |
| ) | |
| FIT_BUTTON_TEXT = "[0/F Fit]" | |
| NATIVE_BUTTON_TEXT = "[1/N Native]" | |
| SAMPLING_AUTO = "auto" | |
| SAMPLING_NEAREST = "nearest" | |
| SAMPLING_LINEAR = "linear" | |
| SAMPLING_MODES = ( | |
| SAMPLING_AUTO, | |
| SAMPLING_NEAREST, | |
| SAMPLING_LINEAR, | |
| ) | |
| # Class Definitions | |
| # ----------------- | |
| @dataclass | |
| class Layout: | |
| """Terminal layout data. | |
| """ | |
| list_x: int | |
| list_y: int | |
| list_width: int | |
| list_height: int | |
| preview_x: int | |
| preview_y: int | |
| preview_width: int | |
| preview_height: int | |
| image_x: int | |
| image_y: int | |
| image_width: int | |
| image_rows: int | |
| status_y: int | |
| screen_width: int | |
| @dataclass | |
| class ImageCache: | |
| """Cache the currently loaded preview image. | |
| """ | |
| image_path: Path | None = None | |
| image: Image.Image | None = None | |
| error: str | None = None | |
| def clear(self): | |
| """Clear cached image data. | |
| """ | |
| self.image_path = None | |
| self.image = None | |
| self.error = None | |
| def get_image(self, image_path): | |
| """Return cached image, loading it if needed. | |
| """ | |
| if self.image_path == image_path: | |
| return self.image, self.error | |
| self.image_path = image_path | |
| self.image = None | |
| self.error = None | |
| try: | |
| self.image = load_rgb_image(image_path) | |
| except Exception as error: | |
| self.error = str(error) | |
| return self.image, self.error | |
| @dataclass | |
| class PreviewState: | |
| """Preview viewport state. | |
| """ | |
| zoom: float = 1.0 | |
| pan_x: float = 0.0 | |
| pan_y: float = 0.0 | |
| image_path: Path | None = None | |
| is_dragging: bool = False | |
| last_mouse_x: int = 0 | |
| last_mouse_y: int = 0 | |
| sampling_mode: str = SAMPLING_AUTO | |
| def cycle_sampling_mode(self): | |
| """Cycle to the next sampling mode. | |
| Returns: | |
| The updated sampling mode. | |
| """ | |
| try: | |
| mode_index = SAMPLING_MODES.index(self.sampling_mode) | |
| except ValueError: | |
| mode_index = 0 | |
| self.sampling_mode = SAMPLING_MODES[(mode_index + 1) % len(SAMPLING_MODES)] | |
| return self.sampling_mode | |
| def reset(self, image_path=None): | |
| """Reset the viewport. | |
| """ | |
| self.zoom = 1.0 | |
| self.pan_x = 0.0 | |
| self.pan_y = 0.0 | |
| self.image_path = image_path | |
| self.is_dragging = False | |
| def reset_if_image_changed(self, image_path): | |
| """Reset the viewport when the selected image changes. | |
| Returns: | |
| Whether the viewport changed. | |
| """ | |
| if self.image_path == image_path: | |
| return False | |
| self.reset(image_path) | |
| return True | |
| def clamp_pan(self, image, target_width, target_height): | |
| """Clamp pan to avoid moving the image too far outside the viewport. | |
| """ | |
| fit_width, fit_height = get_fit_size(image, target_width, target_height) | |
| scaled_width = fit_width * self.zoom | |
| scaled_height = fit_height * self.zoom | |
| max_pan_x = max(0.0, (scaled_width - target_width) / 2.0) | |
| max_pan_y = max(0.0, (scaled_height - target_height) / 2.0) | |
| self.pan_x = min(max(self.pan_x, -max_pan_x), max_pan_x) | |
| self.pan_y = min(max(self.pan_y, -max_pan_y), max_pan_y) | |
| def pan_by(self, image, target_width, target_height, dx, dy): | |
| """Pan the viewport by terminal mouse delta. | |
| Returns: | |
| Whether the viewport changed. | |
| """ | |
| old_pan_x = self.pan_x | |
| old_pan_y = self.pan_y | |
| self.pan_x += dx | |
| self.pan_y += dy * 2.0 | |
| self.clamp_pan(image, target_width, target_height) | |
| return ( | |
| abs(self.pan_x - old_pan_x) > EPSILON or | |
| abs(self.pan_y - old_pan_y) > EPSILON | |
| ) | |
| def zoom_at(self, image, target_width, target_height, factor, point_x, point_y): | |
| """Zoom around a point inside the preview image area. | |
| Returns: | |
| Whether the viewport changed. | |
| """ | |
| old_zoom = self.zoom | |
| new_zoom = clamp_zoom_value(old_zoom * factor) | |
| if abs(new_zoom - old_zoom) < EPSILON: | |
| return False | |
| fit_width, fit_height = get_fit_size(image, target_width, target_height) | |
| old_scaled_width = fit_width * old_zoom | |
| old_scaled_height = fit_height * old_zoom | |
| old_left = (target_width - old_scaled_width) / 2.0 + self.pan_x | |
| old_top = (target_height - old_scaled_height) / 2.0 + self.pan_y | |
| point_x = min(max(point_x, 0), target_width - 1) | |
| point_y = min(max(point_y, 0), target_height - 1) | |
| image_u = (point_x - old_left) / max(1.0, old_scaled_width) | |
| image_v = (point_y - old_top) / max(1.0, old_scaled_height) | |
| image_u = min(max(image_u, 0.0), 1.0) | |
| image_v = min(max(image_v, 0.0), 1.0) | |
| new_scaled_width = fit_width * new_zoom | |
| new_scaled_height = fit_height * new_zoom | |
| new_base_left = (target_width - new_scaled_width) / 2.0 | |
| new_base_top = (target_height - new_scaled_height) / 2.0 | |
| self.zoom = new_zoom | |
| self.pan_x = point_x - image_u * new_scaled_width - new_base_left | |
| self.pan_y = point_y - image_v * new_scaled_height - new_base_top | |
| self.clamp_pan(image, target_width, target_height) | |
| return True | |
| def set_native_zoom(self, image, target_width, target_height, image_path=None): | |
| """Set zoom to native image pixels for the terminal viewport. | |
| Returns: | |
| Whether the viewport changed. | |
| """ | |
| old_zoom = self.zoom | |
| old_pan_x = self.pan_x | |
| old_pan_y = self.pan_y | |
| old_image_path = self.image_path | |
| self.image_path = image_path | |
| self.zoom = get_native_zoom(image, target_width, target_height) | |
| self.pan_x = 0.0 | |
| self.pan_y = 0.0 | |
| self.is_dragging = False | |
| self.clamp_pan(image, target_width, target_height) | |
| return ( | |
| old_image_path != self.image_path or | |
| abs(self.zoom - old_zoom) > EPSILON or | |
| abs(self.pan_x - old_pan_x) > EPSILON or | |
| abs(self.pan_y - old_pan_y) > EPSILON | |
| ) | |
| # Function Definitions | |
| # -------------------- | |
| def get_image_paths(folder): | |
| """Return image paths in the folder. | |
| """ | |
| return sorted( | |
| path for path in folder.iterdir() | |
| if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS | |
| ) | |
| def get_fit_size(image, target_width, target_height): | |
| """Return image size fitted inside the target area. | |
| """ | |
| image_width, image_height = image.size | |
| if image_width <= 0 or image_height <= 0: | |
| return 1, 1 | |
| scale = min(target_width / image_width, target_height / image_height) | |
| fit_width = max(1, int(image_width * scale)) | |
| fit_height = max(1, int(image_height * scale)) | |
| return fit_width, fit_height | |
| def get_fit_scale(image, target_width, target_height): | |
| """Return the exact scale used by fit view. | |
| """ | |
| image_width, image_height = image.size | |
| if image_width <= 0 or image_height <= 0: | |
| return 1.0 | |
| return min(target_width / image_width, target_height / image_height) | |
| def clamp_zoom_value(zoom): | |
| """Return zoom clamped to the supported zoom range. | |
| """ | |
| return min(max(zoom, MIN_ZOOM), MAX_ZOOM) | |
| def get_native_zoom(image, target_width, target_height): | |
| """Return zoom where one image pixel maps to one terminal render pixel. | |
| """ | |
| fit_scale = get_fit_scale(image, target_width, target_height) | |
| if fit_scale <= EPSILON: | |
| return 1.0 | |
| return clamp_zoom_value(1.0 / fit_scale) | |
| def is_native_view(preview_state, image, target_width, target_height): | |
| """Return whether the preview is at native pixel zoom. | |
| """ | |
| native_zoom = get_native_zoom(image, target_width, target_height) | |
| return abs(preview_state.zoom - native_zoom) < EPSILON | |
| def get_sampling_label(sampling_mode): | |
| """Return display label for a sampling mode. | |
| """ | |
| labels = { | |
| SAMPLING_AUTO: "Auto", | |
| SAMPLING_NEAREST: "Nearest", | |
| SAMPLING_LINEAR: "Linear", | |
| } | |
| return labels.get(sampling_mode, "Auto") | |
| def get_sampling_button_text(preview_state): | |
| """Return the sampling status button text. | |
| """ | |
| return f"[S Sampling: {get_sampling_label(preview_state.sampling_mode)}]" | |
| def get_resize_filter(preview_state, native_zoom): | |
| """Return resize filter for preview rendering. | |
| """ | |
| resampling = getattr(Image, "Resampling", Image) | |
| if preview_state.sampling_mode == SAMPLING_NEAREST: | |
| return resampling.NEAREST | |
| if preview_state.sampling_mode == SAMPLING_LINEAR: | |
| return resampling.BILINEAR | |
| if preview_state.zoom >= native_zoom - EPSILON: | |
| return resampling.NEAREST | |
| return resampling.BILINEAR | |
| def move_cursor(y, x): | |
| """Return ANSI cursor movement code. | |
| Args: | |
| y: Zero-based terminal row. | |
| x: Zero-based terminal column. | |
| Returns: | |
| ANSI cursor movement string. | |
| """ | |
| return f"\x1b[{y + 1};{x + 1}H" | |
| def truncate_text(text, max_width): | |
| """Return text truncated to fit the target width. | |
| """ | |
| text = str(text) | |
| if max_width <= 0: | |
| return "" | |
| if len(text) <= max_width: | |
| return text | |
| if max_width <= 1: | |
| return "…" | |
| return text[:max_width - 1] + "…" | |
| def padded_text(text, width): | |
| """Return text truncated and padded to exactly width characters. | |
| """ | |
| return truncate_text(text, width).ljust(max(0, width)) | |
| def load_rgb_image(image_path): | |
| """Load an image as RGB. | |
| """ | |
| image = Image.open(image_path) | |
| image = ImageOps.exif_transpose(image) | |
| return image.convert("RGB") | |
| def image_to_ansi_block_lines(image): | |
| """Convert an RGB image to ANSI truecolor half-block lines. | |
| """ | |
| width, height = image.size | |
| pixels = image.load() | |
| lines = [] | |
| for y in range(0, height, 2): | |
| line_parts = [] | |
| for x in range(width): | |
| top = pixels[x, y] | |
| bottom = pixels[x, y + 1] if y + 1 < height else (0, 0, 0) | |
| line_parts.append( | |
| "\x1b[38;2;{};{};{}m" | |
| "\x1b[48;2;{};{};{}m" | |
| "▀".format( | |
| top[0], | |
| top[1], | |
| top[2], | |
| bottom[0], | |
| bottom[1], | |
| bottom[2], | |
| ) | |
| ) | |
| line_parts.append(ANSI_RESET) | |
| lines.append("".join(line_parts)) | |
| return lines | |
| def render_viewport_image(image, width, rows, preview_state): | |
| """Render image with pan and zoom into a terminal-sized RGB canvas. | |
| """ | |
| target_width = max(1, width) | |
| target_height = max(2, rows * 2) | |
| preview_state.clamp_pan(image, target_width, target_height) | |
| image_width, image_height = image.size | |
| fit_width, fit_height = get_fit_size(image, target_width, target_height) | |
| scaled_width = max(1.0, fit_width * preview_state.zoom) | |
| scaled_height = max(1.0, fit_height * preview_state.zoom) | |
| canvas = Image.new("RGB", (target_width, target_height), (0, 0, 0)) | |
| left = (target_width - scaled_width) / 2.0 + preview_state.pan_x | |
| top = (target_height - scaled_height) / 2.0 + preview_state.pan_y | |
| dst_left = max(0, int(math.floor(left))) | |
| dst_top = max(0, int(math.floor(top))) | |
| dst_right = min(target_width, int(math.ceil(left + scaled_width))) | |
| dst_bottom = min(target_height, int(math.ceil(top + scaled_height))) | |
| if dst_right <= dst_left or dst_bottom <= dst_top: | |
| return canvas | |
| dst_width = dst_right - dst_left | |
| dst_height = dst_bottom - dst_top | |
| scale_x = image_width / scaled_width | |
| scale_y = image_height / scaled_height | |
| offset_x = (dst_left - left) * scale_x | |
| offset_y = (dst_top - top) * scale_y | |
| transform = ( | |
| scale_x, | |
| 0.0, | |
| offset_x, | |
| 0.0, | |
| scale_y, | |
| offset_y, | |
| ) | |
| transform_api = getattr(Image, "Transform", Image) | |
| native_zoom = get_native_zoom(image, target_width, target_height) | |
| viewport_crop = image.transform( | |
| (dst_width, dst_height), | |
| transform_api.AFFINE, | |
| transform, | |
| resample=get_resize_filter(preview_state, native_zoom), | |
| ) | |
| canvas.paste(viewport_crop, (dst_left, dst_top)) | |
| return canvas | |
| def draw_text(buffer, y, x, text, max_width=None, is_selected=False, pad=False): | |
| """Append positioned text to the render buffer. | |
| """ | |
| if max_width is not None: | |
| if pad: | |
| text = padded_text(text, max_width) | |
| else: | |
| text = truncate_text(text, max_width) | |
| if is_selected: | |
| text = f"\x1b[7m{text}{ANSI_RESET}" | |
| buffer.append(move_cursor(y, x)) | |
| buffer.append(text) | |
| def draw_blank_line(buffer, y, x, width): | |
| """Draw a blank line segment. | |
| """ | |
| if width <= 0: | |
| return | |
| draw_text(buffer, y, x, " " * width) | |
| def draw_horizontal_line(buffer, y, x, width, title=""): | |
| """Draw a simple horizontal section line. | |
| """ | |
| if width <= 0: | |
| return | |
| line = "─" * width | |
| if title: | |
| title_text = f" {title} " | |
| line = title_text + line[len(title_text):] | |
| draw_text(buffer, y, x, line, width, pad=True) | |
| def draw_vertical_line(buffer, x, height): | |
| """Draw a vertical divider. | |
| """ | |
| for y in range(height): | |
| draw_text(buffer, y, x, "│") | |
| def create_layout(screen_height, screen_width): | |
| """Create layout from terminal size. | |
| """ | |
| list_width = max(24, int(screen_width * 0.32)) | |
| list_width = min(list_width, max(12, screen_width - 20)) | |
| preview_x = list_width + 1 | |
| preview_width = max(1, screen_width - preview_x) | |
| content_height = max(1, screen_height - 1) | |
| image_y = 4 | |
| image_rows = max(1, content_height - image_y - 1) | |
| return Layout( | |
| list_x=0, | |
| list_y=0, | |
| list_width=list_width, | |
| list_height=content_height, | |
| preview_x=preview_x, | |
| preview_y=0, | |
| preview_width=preview_width, | |
| preview_height=content_height, | |
| image_x=preview_x, | |
| image_y=image_y, | |
| image_width=preview_width, | |
| image_rows=image_rows, | |
| status_y=screen_height - 1, | |
| screen_width=screen_width, | |
| ) | |
| def draw_file_list( | |
| buffer, | |
| image_paths, | |
| selected_index, | |
| scroll_index, | |
| layout, | |
| ): | |
| """Draw the image file list. | |
| """ | |
| draw_horizontal_line( | |
| buffer, | |
| layout.list_y, | |
| layout.list_x, | |
| layout.list_width, | |
| "Images", | |
| ) | |
| visible_height = max(0, layout.list_height - 1) | |
| for row in range(visible_height): | |
| image_index = scroll_index + row | |
| line_y = layout.list_y + row + 1 | |
| if image_index >= len(image_paths): | |
| draw_blank_line(buffer, line_y, layout.list_x, layout.list_width) | |
| continue | |
| image_path = image_paths[image_index] | |
| is_selected = image_index == selected_index | |
| name = image_path.name | |
| draw_text( | |
| buffer, | |
| line_y, | |
| layout.list_x, | |
| f" {name}", | |
| layout.list_width, | |
| is_selected=is_selected, | |
| pad=True, | |
| ) | |
| def draw_preview_info(buffer, image_path, image, layout, preview_state): | |
| """Draw preview title and metadata lines. | |
| """ | |
| draw_horizontal_line( | |
| buffer, | |
| layout.preview_y, | |
| layout.preview_x, | |
| layout.preview_width, | |
| "Preview", | |
| ) | |
| if image_path is None: | |
| draw_blank_line(buffer, layout.preview_y + 1, layout.preview_x, layout.preview_width) | |
| draw_text( | |
| buffer, | |
| layout.preview_y + 2, | |
| layout.preview_x, | |
| "No image files found.", | |
| layout.preview_width, | |
| pad=True, | |
| ) | |
| draw_blank_line(buffer, layout.preview_y + 3, layout.preview_x, layout.preview_width) | |
| return | |
| if image is None: | |
| draw_text( | |
| buffer, | |
| layout.preview_y + 1, | |
| layout.preview_x, | |
| image_path.name, | |
| layout.preview_width, | |
| pad=True, | |
| ) | |
| draw_blank_line(buffer, layout.preview_y + 2, layout.preview_x, layout.preview_width) | |
| draw_blank_line(buffer, layout.preview_y + 3, layout.preview_x, layout.preview_width) | |
| return | |
| image_width, image_height = image.size | |
| draw_text( | |
| buffer, | |
| layout.preview_y + 1, | |
| layout.preview_x, | |
| image_path.name, | |
| layout.preview_width, | |
| pad=True, | |
| ) | |
| mode_text = "Native" if is_native_view( | |
| preview_state, | |
| image, | |
| layout.image_width, | |
| layout.image_rows * 2, | |
| ) else "Fit" if is_fit_view(preview_state, image_path) else "Custom" | |
| draw_text( | |
| buffer, | |
| layout.preview_y + 2, | |
| layout.preview_x, | |
| ( | |
| f"Size: {image_width} x {image_height} " | |
| f"Zoom: {preview_state.zoom:.2f}x " | |
| f"Mode: {mode_text} " | |
| f"Sampling: {get_sampling_label(preview_state.sampling_mode)}" | |
| ), | |
| layout.preview_width, | |
| pad=True, | |
| ) | |
| draw_blank_line(buffer, layout.preview_y + 3, layout.preview_x, layout.preview_width) | |
| def draw_preview_error(buffer, image_path, error, layout): | |
| """Draw preview load error and clear the image area. | |
| """ | |
| draw_horizontal_line( | |
| buffer, | |
| layout.preview_y, | |
| layout.preview_x, | |
| layout.preview_width, | |
| "Preview", | |
| ) | |
| draw_text( | |
| buffer, | |
| layout.preview_y + 1, | |
| layout.preview_x, | |
| image_path.name, | |
| layout.preview_width, | |
| pad=True, | |
| ) | |
| draw_text( | |
| buffer, | |
| layout.preview_y + 2, | |
| layout.preview_x, | |
| f"Failed to load image: {error}", | |
| layout.preview_width, | |
| pad=True, | |
| ) | |
| draw_blank_line(buffer, layout.preview_y + 3, layout.preview_x, layout.preview_width) | |
| draw_preview_blank_area(buffer, layout) | |
| def draw_preview_blank_area(buffer, layout): | |
| """Clear the preview image area without clearing the whole screen. | |
| """ | |
| blank = " " * layout.image_width | |
| for row in range(layout.image_rows): | |
| buffer.append(move_cursor(layout.image_y + row, layout.image_x)) | |
| buffer.append(blank) | |
| def draw_preview_image_area(buffer, image, layout, preview_state): | |
| """Draw only the ANSI image viewport area. | |
| """ | |
| viewport_image = render_viewport_image( | |
| image, | |
| layout.image_width, | |
| layout.image_rows, | |
| preview_state, | |
| ) | |
| lines = image_to_ansi_block_lines(viewport_image) | |
| for index, line in enumerate(lines[:layout.image_rows]): | |
| buffer.append(move_cursor(layout.image_y + index, layout.image_x)) | |
| buffer.append(line) | |
| def draw_preview(buffer, image_path, layout, preview_state, image_cache): | |
| """Draw ANSI truecolor image preview. | |
| """ | |
| if image_path is None: | |
| draw_preview_info(buffer, None, None, layout, preview_state) | |
| draw_preview_blank_area(buffer, layout) | |
| return None | |
| preview_state.reset_if_image_changed(image_path) | |
| image, error = image_cache.get_image(image_path) | |
| if image is None: | |
| draw_preview_error(buffer, image_path, error, layout) | |
| return None | |
| draw_preview_info(buffer, image_path, image, layout, preview_state) | |
| draw_preview_image_area(buffer, image, layout, preview_state) | |
| return image | |
| def build_status_text(selected_path, preview_state): | |
| """Build the bottom status line text. | |
| """ | |
| text = ( | |
| f"{STATUS_PREFIX}{FIT_BUTTON_TEXT} {NATIVE_BUTTON_TEXT} " | |
| f"{get_sampling_button_text(preview_state)} " | |
| "Enter Choose r Refresh q Quit" | |
| ) | |
| if selected_path: | |
| text = f"{text} Selected: {selected_path}" | |
| return text | |
| def get_status_button_ranges(preview_state): | |
| """Return clickable status button ranges. | |
| """ | |
| sampling_button_text = get_sampling_button_text(preview_state) | |
| fit_start = len(STATUS_PREFIX) | |
| fit_end = fit_start + len(FIT_BUTTON_TEXT) | |
| native_start = fit_end + 1 | |
| native_end = native_start + len(NATIVE_BUTTON_TEXT) | |
| sampling_start = native_end + 1 | |
| sampling_end = sampling_start + len(sampling_button_text) | |
| return { | |
| "fit": (fit_start, fit_end), | |
| "native": (native_start, native_end), | |
| "sampling": (sampling_start, sampling_end), | |
| } | |
| def get_status_action_at_mouse(mouse_x, mouse_y, layout, preview_state): | |
| """Return clicked status action, or None. | |
| """ | |
| if mouse_y != layout.status_y: | |
| return None | |
| for action, (start_x, end_x) in get_status_button_ranges(preview_state).items(): | |
| if start_x <= mouse_x < end_x: | |
| return action | |
| return None | |
| def draw_status(buffer, layout, selected_path, preview_state): | |
| """Draw the bottom status line. | |
| """ | |
| text = padded_text( | |
| build_status_text(selected_path, preview_state), | |
| layout.screen_width, | |
| ) | |
| buffer.append(move_cursor(layout.status_y, 0)) | |
| buffer.append(f"\x1b[7m{text}{ANSI_RESET}") | |
| def is_in_preview_image(mouse_x, mouse_y, layout): | |
| """Return whether the mouse is inside the image preview area. | |
| """ | |
| return ( | |
| layout.image_x <= mouse_x < layout.image_x + layout.image_width and | |
| layout.image_y <= mouse_y < layout.image_y + layout.image_rows | |
| ) | |
| def is_in_file_list(mouse_x, mouse_y, layout): | |
| """Return whether the mouse is inside the visible file list rows. | |
| """ | |
| return ( | |
| layout.list_x <= mouse_x < layout.list_x + layout.list_width and | |
| layout.list_y + 1 <= mouse_y < layout.list_y + layout.list_height | |
| ) | |
| def get_file_index_at_mouse(mouse_y, image_paths, scroll_index, layout): | |
| """Return the file index under the mouse, or None. | |
| """ | |
| if not image_paths: | |
| return None | |
| row = mouse_y - layout.list_y - 1 | |
| if row < 0: | |
| return None | |
| image_index = scroll_index + row | |
| if image_index >= len(image_paths): | |
| return None | |
| return image_index | |
| def get_visible_list_height(layout): | |
| """Return the number of visible file rows. | |
| """ | |
| return max(1, layout.list_height - 1) | |
| def clamp_scroll_index(image_paths, scroll_index, visible_list_height): | |
| """Return scroll index clamped to the available file range. | |
| """ | |
| max_scroll_index = max(0, len(image_paths) - visible_list_height) | |
| return max(0, min(scroll_index, max_scroll_index)) | |
| def handle_preview_mouse_event( | |
| image, | |
| layout, | |
| preview_state, | |
| mouse_x, | |
| mouse_y, | |
| button_state, | |
| ): | |
| """Handle preview mouse pan and zoom. | |
| Returns: | |
| Whether the preview changed. | |
| """ | |
| if image is None: | |
| return False | |
| target_width = layout.image_width | |
| target_height = layout.image_rows * 2 | |
| scroll_up = getattr(curses, "BUTTON4_PRESSED", 0) | |
| scroll_down = getattr(curses, "BUTTON5_PRESSED", 0) | |
| left_pressed = getattr(curses, "BUTTON1_PRESSED", 0) | |
| left_released = getattr(curses, "BUTTON1_RELEASED", 0) | |
| mouse_position = getattr(curses, "REPORT_MOUSE_POSITION", 0) | |
| is_inside_preview = is_in_preview_image(mouse_x, mouse_y, layout) | |
| if button_state & scroll_up and is_inside_preview: | |
| point_x = mouse_x - layout.image_x | |
| point_y = (mouse_y - layout.image_y) * 2 + 1 | |
| return preview_state.zoom_at( | |
| image, | |
| target_width, | |
| target_height, | |
| ZOOM_STEP, | |
| point_x, | |
| point_y, | |
| ) | |
| if button_state & scroll_down and is_inside_preview: | |
| point_x = mouse_x - layout.image_x | |
| point_y = (mouse_y - layout.image_y) * 2 + 1 | |
| return preview_state.zoom_at( | |
| image, | |
| target_width, | |
| target_height, | |
| 1.0 / ZOOM_STEP, | |
| point_x, | |
| point_y, | |
| ) | |
| if button_state & left_pressed and is_inside_preview: | |
| preview_state.is_dragging = True | |
| preview_state.last_mouse_x = mouse_x | |
| preview_state.last_mouse_y = mouse_y | |
| return False | |
| if button_state & left_released: | |
| preview_state.is_dragging = False | |
| return False | |
| if button_state & mouse_position and preview_state.is_dragging: | |
| dx = mouse_x - preview_state.last_mouse_x | |
| dy = mouse_y - preview_state.last_mouse_y | |
| preview_state.last_mouse_x = mouse_x | |
| preview_state.last_mouse_y = mouse_y | |
| return preview_state.pan_by( | |
| image, | |
| target_width, | |
| target_height, | |
| dx, | |
| dy, | |
| ) | |
| return False | |
| def render_screen( | |
| stdscr, | |
| image_paths, | |
| selected_index, | |
| scroll_index, | |
| selected_path, | |
| preview_state, | |
| image_cache, | |
| clear_screen=False, | |
| ): | |
| """Render the full browser screen using ANSI escape codes. | |
| """ | |
| screen_height, screen_width = stdscr.getmaxyx() | |
| layout = create_layout(screen_height, screen_width) | |
| current_path = image_paths[selected_index] if image_paths else None | |
| buffer = [ANSI_HIDE_CURSOR] | |
| if clear_screen: | |
| buffer.extend([ANSI_HOME, ANSI_CLEAR]) | |
| draw_file_list( | |
| buffer, | |
| image_paths, | |
| selected_index, | |
| scroll_index, | |
| layout, | |
| ) | |
| draw_vertical_line(buffer, layout.list_width, layout.list_height) | |
| image = draw_preview( | |
| buffer, | |
| current_path, | |
| layout, | |
| preview_state, | |
| image_cache, | |
| ) | |
| draw_status( | |
| buffer, | |
| layout, | |
| selected_path, | |
| preview_state, | |
| ) | |
| sys.stdout.write("".join(buffer)) | |
| sys.stdout.flush() | |
| return layout, image | |
| def render_preview_interaction(layout, image_path, image, preview_state): | |
| """Render only the preview area during mouse zoom/pan. | |
| This avoids repainting the file list and bottom hint/status bar, which | |
| prevents visible flicker while dragging or wheel-zooming. | |
| """ | |
| if image is None: | |
| return | |
| buffer = [ANSI_HIDE_CURSOR] | |
| draw_preview_info(buffer, image_path, image, layout, preview_state) | |
| draw_preview_image_area(buffer, image, layout, preview_state) | |
| sys.stdout.write("".join(buffer)) | |
| sys.stdout.flush() | |
| def setup_terminal_mouse(): | |
| """Enable terminal mouse tracking. | |
| """ | |
| curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) | |
| curses.mouseinterval(0) | |
| sys.stdout.write( | |
| ANSI_ENTER_ALT_SCREEN + | |
| ANSI_HIDE_CURSOR + | |
| ANSI_ENABLE_MOUSE + | |
| ANSI_CLEAR + | |
| ANSI_HOME | |
| ) | |
| sys.stdout.flush() | |
| def flush_terminal_input(stdscr): | |
| """Flush queued input events before restoring the terminal. | |
| """ | |
| try: | |
| curses.flushinp() | |
| except curses.error: | |
| pass | |
| try: | |
| stdscr.nodelay(True) | |
| while stdscr.getch() != -1: | |
| pass | |
| except curses.error: | |
| pass | |
| def cleanup_terminal(stdscr): | |
| """Restore terminal state. | |
| """ | |
| flush_terminal_input(stdscr) | |
| sys.stdout.write( | |
| ANSI_DISABLE_MOUSE + | |
| ANSI_RESET + | |
| ANSI_SHOW_CURSOR + | |
| ANSI_CLEAR + | |
| ANSI_HOME + | |
| ANSI_EXIT_ALT_SCREEN | |
| ) | |
| sys.stdout.flush() | |
| def is_fit_view(preview_state, current_path): | |
| """Return whether the preview is already at fit view. | |
| """ | |
| return ( | |
| preview_state.image_path == current_path and | |
| abs(preview_state.zoom - 1.0) < EPSILON and | |
| abs(preview_state.pan_x) < EPSILON and | |
| abs(preview_state.pan_y) < EPSILON | |
| ) | |
| def set_fit_view(preview_state, current_path): | |
| """Set the preview to fit view. | |
| Returns: | |
| Whether the viewport changed. | |
| """ | |
| if is_fit_view(preview_state, current_path): | |
| return False | |
| preview_state.reset(current_path) | |
| return True | |
| def set_native_view(preview_state, image, layout, current_path): | |
| """Set the preview to native pixel zoom. | |
| Returns: | |
| Whether the viewport changed. | |
| """ | |
| if image is None: | |
| return False | |
| return preview_state.set_native_zoom( | |
| image, | |
| layout.image_width, | |
| layout.image_rows * 2, | |
| current_path, | |
| ) | |
| def update_scroll_index(selected_index, scroll_index, visible_list_height): | |
| """Return scroll index adjusted to keep selection visible. | |
| """ | |
| if selected_index < scroll_index: | |
| return selected_index | |
| if selected_index >= scroll_index + visible_list_height: | |
| return selected_index - visible_list_height + 1 | |
| return scroll_index | |
| def normalize_selection(image_paths, selected_index): | |
| """Return selected index clamped to the image list. | |
| """ | |
| if not image_paths: | |
| return 0 | |
| return max(0, min(selected_index, len(image_paths) - 1)) | |
| def update_browser_scroll(stdscr, image_paths, selected_index, scroll_index): | |
| """Return scroll index updated for the current terminal size. | |
| """ | |
| screen_height, _ = stdscr.getmaxyx() | |
| visible_list_height = max(1, screen_height - 2) | |
| selected_index = normalize_selection(image_paths, selected_index) | |
| return update_scroll_index( | |
| selected_index, | |
| scroll_index, | |
| visible_list_height, | |
| ) | |
| def run_browser(stdscr, folder): | |
| """Run the terminal image browser. | |
| """ | |
| try: | |
| curses.curs_set(0) | |
| except curses.error: | |
| pass | |
| stdscr.keypad(True) | |
| stdscr.nodelay(True) | |
| setup_terminal_mouse() | |
| image_paths = get_image_paths(folder) | |
| selected_index = 0 | |
| scroll_index = 0 | |
| selected_path = None | |
| preview_state = PreviewState() | |
| image_cache = ImageCache() | |
| needs_full_render = True | |
| needs_preview_render = False | |
| needs_clear_render = True | |
| layout = None | |
| image = None | |
| is_running = True | |
| last_render_time = 0.0 | |
| frame_interval = 1.0 / TARGET_FPS | |
| try: | |
| while is_running: | |
| processed_events = 0 | |
| while processed_events < MAX_INPUT_EVENTS_PER_TICK: | |
| key = stdscr.getch() | |
| if key == -1: | |
| break | |
| processed_events += 1 | |
| selected_index = normalize_selection(image_paths, selected_index) | |
| old_scroll_index = scroll_index | |
| scroll_index = update_browser_scroll( | |
| stdscr, | |
| image_paths, | |
| selected_index, | |
| scroll_index, | |
| ) | |
| if scroll_index != old_scroll_index: | |
| needs_full_render = True | |
| if key in (ord("q"), 27): | |
| is_running = False | |
| break | |
| if key == curses.KEY_MOUSE: | |
| if layout is not None: | |
| try: | |
| _, mouse_x, mouse_y, _, button_state = curses.getmouse() | |
| except curses.error: | |
| continue | |
| scroll_up = getattr(curses, "BUTTON4_PRESSED", 0) | |
| scroll_down = getattr(curses, "BUTTON5_PRESSED", 0) | |
| left_pressed = getattr(curses, "BUTTON1_PRESSED", 0) | |
| left_released = getattr(curses, "BUTTON1_RELEASED", 0) | |
| if button_state & left_released: | |
| preview_state.is_dragging = False | |
| status_action = None | |
| if button_state & left_pressed: | |
| status_action = get_status_action_at_mouse( | |
| mouse_x, | |
| mouse_y, | |
| layout, | |
| preview_state, | |
| ) | |
| if status_action == "fit": | |
| current_path = image_paths[selected_index] if image_paths else None | |
| if set_fit_view(preview_state, current_path): | |
| needs_preview_render = True | |
| elif status_action == "native": | |
| current_path = image_paths[selected_index] if image_paths else None | |
| if set_native_view( | |
| preview_state, | |
| image, | |
| layout, | |
| current_path, | |
| ): | |
| needs_preview_render = True | |
| elif status_action == "sampling": | |
| preview_state.cycle_sampling_mode() | |
| needs_full_render = True | |
| elif is_in_file_list(mouse_x, mouse_y, layout): | |
| preview_state.is_dragging = False | |
| if button_state & left_pressed: | |
| clicked_index = get_file_index_at_mouse( | |
| mouse_y, | |
| image_paths, | |
| scroll_index, | |
| layout, | |
| ) | |
| if clicked_index is not None: | |
| old_index = selected_index | |
| selected_index = clicked_index | |
| if selected_index != old_index: | |
| needs_full_render = True | |
| elif ( | |
| button_state & scroll_up or | |
| button_state & scroll_down | |
| ): | |
| old_index = selected_index | |
| old_scroll_index = scroll_index | |
| if button_state & scroll_up: | |
| selected_index -= LIST_WHEEL_STEP | |
| else: | |
| selected_index += LIST_WHEEL_STEP | |
| selected_index = normalize_selection( | |
| image_paths, | |
| selected_index, | |
| ) | |
| scroll_index = update_scroll_index( | |
| selected_index, | |
| scroll_index, | |
| get_visible_list_height(layout), | |
| ) | |
| scroll_index = clamp_scroll_index( | |
| image_paths, | |
| scroll_index, | |
| get_visible_list_height(layout), | |
| ) | |
| if ( | |
| selected_index != old_index or | |
| scroll_index != old_scroll_index | |
| ): | |
| needs_full_render = True | |
| else: | |
| preview_changed = handle_preview_mouse_event( | |
| image, | |
| layout, | |
| preview_state, | |
| mouse_x, | |
| mouse_y, | |
| button_state, | |
| ) | |
| if preview_changed: | |
| # Mouse zoom/pan changes only the preview. Keep the | |
| # list and bottom hint/status bar untouched. | |
| needs_preview_render = True | |
| elif key == curses.KEY_RESIZE: | |
| needs_full_render = True | |
| needs_clear_render = True | |
| elif key in (curses.KEY_UP, ord("k")): | |
| if image_paths: | |
| old_index = selected_index | |
| selected_index = max(0, selected_index - 1) | |
| if selected_index != old_index: | |
| needs_full_render = True | |
| elif key in (curses.KEY_DOWN, ord("j")): | |
| if image_paths: | |
| old_index = selected_index | |
| selected_index = min( | |
| len(image_paths) - 1, | |
| selected_index + 1, | |
| ) | |
| if selected_index != old_index: | |
| needs_full_render = True | |
| elif key in (curses.KEY_ENTER, 10, 13): | |
| if image_paths: | |
| selected_path = image_paths[selected_index] | |
| needs_full_render = True | |
| elif key in (ord("0"), ord("f")): | |
| current_path = image_paths[selected_index] if image_paths else None | |
| if set_fit_view(preview_state, current_path): | |
| needs_preview_render = True | |
| elif key in (ord("1"), ord("n")): | |
| current_path = image_paths[selected_index] if image_paths else None | |
| if layout is not None and set_native_view( | |
| preview_state, | |
| image, | |
| layout, | |
| current_path, | |
| ): | |
| needs_preview_render = True | |
| elif key == ord("s"): | |
| preview_state.cycle_sampling_mode() | |
| needs_full_render = True | |
| elif key == ord("r"): | |
| image_paths = get_image_paths(folder) | |
| selected_index = normalize_selection(image_paths, selected_index) | |
| scroll_index = 0 | |
| image_cache.clear() | |
| preview_state.reset( | |
| image_paths[selected_index] if image_paths else None | |
| ) | |
| needs_full_render = True | |
| needs_clear_render = True | |
| now = time.monotonic() | |
| should_render = ( | |
| (needs_full_render or needs_preview_render) and | |
| now - last_render_time >= frame_interval | |
| ) | |
| if should_render: | |
| selected_index = normalize_selection(image_paths, selected_index) | |
| old_scroll_index = scroll_index | |
| scroll_index = update_browser_scroll( | |
| stdscr, | |
| image_paths, | |
| selected_index, | |
| scroll_index, | |
| ) | |
| if scroll_index != old_scroll_index: | |
| needs_full_render = True | |
| if needs_full_render or layout is None: | |
| layout, image = render_screen( | |
| stdscr, | |
| image_paths, | |
| selected_index, | |
| scroll_index, | |
| selected_path, | |
| preview_state, | |
| image_cache, | |
| clear_screen=needs_clear_render, | |
| ) | |
| needs_full_render = False | |
| needs_preview_render = False | |
| needs_clear_render = False | |
| elif needs_preview_render: | |
| current_path = image_paths[selected_index] if image_paths else None | |
| render_preview_interaction( | |
| layout, | |
| current_path, | |
| image, | |
| preview_state, | |
| ) | |
| needs_preview_render = False | |
| last_render_time = now | |
| if processed_events == 0 and not should_render: | |
| time.sleep(IDLE_SLEEP_SECONDS) | |
| finally: | |
| cleanup_terminal(stdscr) | |
| return selected_path | |
| def main(): | |
| """Browse image files with ANSI truecolor preview. | |
| """ | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument( | |
| "folder", | |
| nargs="?", | |
| default=".", | |
| help="Folder containing image files.", | |
| ) | |
| args = parser.parse_args() | |
| folder = Path(args.folder).expanduser().resolve() | |
| if not folder.exists(): | |
| parser.error(f"Folder does not exist: {folder}") | |
| if not folder.is_dir(): | |
| parser.error(f"Path is not a folder: {folder}") | |
| selected_path = curses.wrapper(run_browser, folder) | |
| if selected_path: | |
| print(selected_path) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment