Skip to content

Instantly share code, notes, and snippets.

@promto-c
Created May 20, 2026 22:14
Show Gist options
  • Select an option

  • Save promto-c/a847ccc28d52feed765b35cd6c515d14 to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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