├── .gitignore ├── LICENSE.txt ├── README.md ├── pyproject.toml └── textual_imageview ├── __about__.py ├── __init__.py ├── app.py ├── img.py └── viewer.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-present Adam Viola 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # textual-imageview 2 | 3 | [![PyPI - Version](https://img.shields.io/pypi/v/textual-imageview.svg)](https://pypi.org/project/textual-imageview) 4 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/textual-imageview.svg)](https://pypi.org/project/textual-imageview) 5 | 6 | ![cat](https://user-images.githubusercontent.com/43352940/210931390-ad1f47fc-2340-435e-8851-234b5fa96d0f.gif) 7 | 8 | `textual-imageview` is a both a CLI tool and [Textual](https://github.com/textualize/textual/) widget and for viewing images in the 9 | terminal. 10 | 11 | ## Usage 12 | Use the `vimg` CLI command to quickly view an image in the terminal. 13 | 14 | ```console 15 | vimg 16 | ``` 17 | 18 | Click and drag (or press W/S/A/D) to move around the image, and scroll (or press -/+) to zoom in/out of the image. 19 | 20 | `vimg` is built on `ImageView`, a [Rich](https://github.com/textualize/rich/) renderable that renders images with padding/zoom, and `ImageViewer`, a [Textual](https://github.com/textualize/textual/) widget that adds mouse interactivity to `ImageView`. Add `textual-imageview` as a dependency to use them in your Textual app! 21 | 22 | At the highest zoom level, each character corresponds to two image pixels. I've found that `vimg` works best with a GPU-accelerated terminal like [Alacritty](https://github.com/alacritty/alacritty). 23 | 24 | ## Installation 25 | ```console 26 | pip install textual-imageview 27 | ``` 28 | 29 | ## FAQ 30 | 31 | **`vimg` works great locally, but colors aren't displaying correctly when using `vimg` over SSH. Why?** 32 | 33 | Rich [determines terminal color support](https://github.com/Textualize/rich/blob/7601290c3a2f574fa29763ed5a615767494f5013/rich/console.py#L796) by checking if the `COLORTERM` environment variable is set to `truecolor` or `24bit`. 34 | 35 | ```console 36 | echo $COLORTERM 37 | ``` 38 | 39 | If you know your terminal emulator supports truecolor - i.e., `vimg` works great locally - try setting the environment variable manually: 40 | ```console 41 | export COLORTERM=truecolor 42 | ``` 43 | 44 | ## License 45 | 46 | `textual-imageview` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "textual-imageview" 7 | description = 'A simple terminal-based image viewer.' 8 | readme = "README.md" 9 | requires-python = ">=3.7" 10 | license = "MIT" 11 | keywords = [] 12 | authors = [{ name = "Adam Viola", email = "adam@viola.dev" }] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3.7", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: Implementation :: CPython", 22 | "Programming Language :: Python :: Implementation :: PyPy", 23 | ] 24 | dependencies = ["Pillow>=9.4.0", "rich>=13.0.0", "textual>=0.9.1"] 25 | dynamic = ["version"] 26 | 27 | [project.urls] 28 | Documentation = "https://github.com/adamviola/textual-imageview#readme" 29 | Issues = "https://github.com/adamviola/textual-imageview/issues" 30 | Source = "https://github.com/adamviola/textual-imageview" 31 | 32 | [project.scripts] 33 | vimg = "textual_imageview.app:vimg" 34 | 35 | [tool.hatch.version] 36 | path = "textual_imageview/__about__.py" 37 | -------------------------------------------------------------------------------- /textual_imageview/__about__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.1" 2 | -------------------------------------------------------------------------------- /textual_imageview/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamviola/textual-imageview/9de0f07be2c632647fbead01ee8e5ddb406b954a/textual_imageview/__init__.py -------------------------------------------------------------------------------- /textual_imageview/app.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | from pathlib import Path 3 | from typing import Union 4 | 5 | from PIL import Image 6 | from textual.app import App, ComposeResult 7 | from textual.binding import Binding 8 | from textual.widgets import Footer, Header 9 | 10 | from textual_imageview.__about__ import __version__ 11 | from textual_imageview.viewer import ImageViewer 12 | 13 | 14 | class ImageViewerApp(App): 15 | """Sample Textual app that uses the ImageViewer widget to view images. This app 16 | also includes keyboard suppport for translating and scaling the image.""" 17 | 18 | TITLE = "vimg" 19 | 20 | BINDINGS = [ 21 | Binding("ctrl+c", "quit", "Quit", priority=True), 22 | # Movement 23 | Binding("w,up", "move(0, 1)", "Up", show=True, key_display="W/↑"), 24 | Binding("s,down", "move(0, -1)", "Down", show=True, key_display="S/↓"), 25 | Binding("a,left", "move(1, 0)", "Left", show=True, key_display="A/←"), 26 | Binding("d,right", "move(-1, 0)", "Right", show=True, key_display="D/→"), 27 | Binding("q,=", "zoom(-1)", "Zoom In", show=True, key_display="Q/+"), 28 | Binding("e,-", "zoom(1)", "Zoom Out", show=True, key_display="E/-"), 29 | # Faster movement 30 | Binding("W,shift+up", "move(0, 3)", "Fast Up", show=False), 31 | Binding("S,shift+down", "move(0, -3)", "Fast Dowm", show=False), 32 | Binding("A,shift+left", "move(3, 0)", "Fast Left", show=False), 33 | Binding("D,shift+right", "move(-3, 0)", "Fast Right", show=False), 34 | Binding("E,+", "zoom(-2)", "Fast Zoom In", show=False), 35 | Binding("Q,_", "zoom(2)", "Fast Zoom Out", show=False), 36 | ] 37 | 38 | def __init__(self, image_path: Union[str, Path]): 39 | """Inits vimg 40 | 41 | Args: 42 | image_path (Path or str): Path of image to view. 43 | """ 44 | super().__init__() 45 | image_path = Path(image_path) 46 | if not image_path.exists(): 47 | print(f"{image_path} does not exist.") 48 | exit() 49 | 50 | self.sub_title = image_path.name 51 | self.image = Image.open(image_path) 52 | self.image_viewer = ImageViewer(self.image) 53 | 54 | def action_move(self, delta_x: int, delta_y: int): 55 | self.image_viewer.image.move(delta_x, delta_y) 56 | self.image_viewer.refresh() 57 | self.refresh() 58 | 59 | def action_zoom(self, delta: int): 60 | self.image_viewer.image.zoom(delta) 61 | self.image_viewer.refresh() 62 | self.refresh() 63 | 64 | def compose(self) -> ComposeResult: 65 | yield Header() 66 | yield self.image_viewer 67 | yield Footer() 68 | 69 | 70 | def vimg(): 71 | """CLI entry point""" 72 | parser = ArgumentParser(description="A simple terminal-based image viewer.") 73 | parser.add_argument("image_path", help="Path of image to view.") 74 | parser.add_argument( 75 | "-v", 76 | "--version", 77 | help="Show version information.", 78 | action="version", 79 | version=__version__, 80 | ) 81 | 82 | args = parser.parse_args() 83 | 84 | app = ImageViewerApp(args.image_path) 85 | app.run() 86 | -------------------------------------------------------------------------------- /textual_imageview/img.py: -------------------------------------------------------------------------------- 1 | import math 2 | from typing import Optional, Tuple 3 | 4 | from PIL import Image 5 | from rich.color import Color 6 | from rich.console import Console, ConsoleOptions, RenderResult 7 | from rich.segment import Segment 8 | from rich.style import Style 9 | 10 | Zoom = int 11 | 12 | 13 | class ImageView: 14 | """Renders an image with zoom and padding. 15 | 16 | Args: 17 | image (Image.Image): PIL image to render. 18 | zoom (int): Zoom level. Must be non-negative. Zoom increase -> zoom out. 19 | origin_position (Tuple[int, int]): Image position (x,y) of the top-left corner 20 | of the container. Defaults to (0,0). 21 | container_size (Tuple[int, int], optional): Size of the container of the image 22 | (w,h). If None, nothing is rendered. Defaults to None. 23 | 24 | Notes: 25 | Throughout this class, "image position" refers to the coordinate frame with the 26 | origin at the top-left corner of the image, +x-axis pointed right, and +y-axis 27 | pointed down. The width of a single character is 1 unit along the x-axis. The 28 | height of a single character is 2 units along the y-axis. 29 | """ 30 | 31 | ZOOM_RATE = 0.8 32 | 33 | def __init__( 34 | self, 35 | image: Image.Image, 36 | zoom: int = 0, 37 | origin_position: Tuple[int, int] = (0, 0), 38 | container_size: Optional[Tuple[int, int]] = None, 39 | ): 40 | self.images: dict[Zoom, Image.Image] = {} 41 | self.segment_cache: dict[Zoom, dict[Tuple[int, int], Segment]] = {} 42 | 43 | self.image = image 44 | self._container_size = container_size 45 | self._zoom = 0 46 | self.set_zoom(zoom) 47 | self.origin_position = origin_position 48 | 49 | def zoom(self, delta: int, zoom_position: Optional[Tuple[int, int]] = None): 50 | """Adjusts the zoom level of the image at the specified zoom image position. If 51 | no zoom position is specified, the center of the console is used. 52 | 53 | Args: 54 | zoom: Zoom delta. Postivie -> zoom out. 55 | zoom_position: Image-space position (x,y) to zoom into. Conceptually, the 56 | pixel at this image will not move no matter the zoom level. 57 | """ 58 | self.set_zoom(self._zoom + delta, zoom_position=zoom_position) 59 | 60 | def set_zoom(self, zoom: int, zoom_position: Optional[Tuple[int, int]] = None): 61 | """Sets the zoom level of the image at the specified zoom image position. If 62 | no zoom position is specified, the center of the console is used. 63 | 64 | Args: 65 | zoom: Zoom level. Must be non-negative. Zoom increase -> zoom out. 66 | zoom_position: Image-space position (x,y) to zoom into. Conceptually, the 67 | pixel at this image will not move no matter the zoom level. 68 | """ 69 | # Lower bound on zoom 70 | zoom = max(zoom, 0) 71 | 72 | # Upper bound on zoom 73 | if zoom > self._zoom and min(self.zoomed_size) <= 8: 74 | zoom = self._zoom 75 | 76 | if zoom not in self.images: 77 | multiplier = self.ZOOM_RATE**zoom 78 | w, h = self.image.size 79 | self.images[zoom] = self.image.resize( 80 | (round(w * multiplier), round(h * multiplier)) 81 | ) 82 | self.segment_cache[zoom] = {} 83 | 84 | if self._container_size is not None: 85 | w, h = self._container_size 86 | origin_x, origin_y = self.origin_position 87 | if zoom_position is None: 88 | zoom_position = origin_x + w // 2, origin_y + h 89 | old_zoom_x, old_zoom_y = zoom_position 90 | 91 | old_w, old_h = self.images[self._zoom].size 92 | new_w, new_h = self.images[zoom].size 93 | 94 | multiplier_x = new_w / old_w 95 | multiplier_y = new_h / old_h 96 | 97 | new_zoom_x = old_zoom_x * multiplier_x 98 | new_zoom_y = old_zoom_y * multiplier_y 99 | 100 | # Set zoom here because it's used in origin_position bounds checking 101 | self._zoom = zoom 102 | self.origin_position = ( 103 | origin_x + round(new_zoom_x - old_zoom_x), 104 | origin_y + round(new_zoom_y - old_zoom_y), 105 | ) 106 | 107 | def move(self, delta_x: int, delta_y: int): 108 | """Moves the image using the specified delta (x,y), where +x moves the image 109 | right, and +y moves the image down. 110 | 111 | Args: 112 | delta_x (int): Number of pixels to move the image along the x-axis. 113 | delta_y (int): Number of pixels to move the image along the x-axis. Note 114 | that the one character height is two pixels. 115 | """ 116 | origin_x, origin_y = self.origin_position 117 | self.origin_position = (origin_x - delta_x, origin_y - delta_y) 118 | 119 | def set_container_size(self, width: int, height: int, maintain_center: bool = True): 120 | """Adjusts the render to reflect a change in container size, where height is the 121 | number of lines and width is the length of each line. 122 | 123 | Args: 124 | width (int): New width of the container of the image. 125 | height (int): New height of the container of the image. 126 | maintain_center (bool): If True, the pixels in the center of the console 127 | before the resize remain in the center of the console after the resize. 128 | If False, the image remains in the same position. Defaults to True. 129 | """ 130 | 131 | if maintain_center and self._container_size is not None: 132 | old_w, old_h = self._container_size 133 | new_w, new_h = width, height 134 | 135 | origin_x, origin_y = self.origin_position 136 | 137 | if new_w != old_w: 138 | delta_w = new_w - old_w 139 | 140 | if delta_w % 2 == 0: 141 | origin_x = origin_x - delta_w // 2 142 | else: 143 | # If we're 1 by 1 resizing, this keeps things even 144 | op = math.floor if new_w % 2 == 0 else math.ceil 145 | origin_x = op(origin_x - delta_w / 2) 146 | 147 | if new_h != old_h: 148 | delta_h = new_h - old_h 149 | origin_y -= delta_h 150 | 151 | self.origin_position = (origin_x, origin_y) 152 | 153 | self._container_size = (width, height) 154 | 155 | # Keeps origin_position valid after container resize 156 | self.origin_position = self.origin_position 157 | 158 | def rowcol_to_xy( 159 | self, row: int, col: int, offset: Tuple[int, int] 160 | ) -> Tuple[int, int]: 161 | """Converts a character position (row,col) to an image position (x,y) given the 162 | offset (number of rows, number of columns) between the top-left of the terminal 163 | and top-left of the widget. 164 | 165 | Args: 166 | row (int): Row of the terminal (starting from the top at 0) to convert to an 167 | image y-value. 168 | col (int): Column of the terminal (starting from the left at 0) to convert 169 | to an image x-value. 170 | offset (Tuple[int, int]): Offset (number of rows, number of columns) between 171 | the top-left of the terminal and top-left of the widget. 172 | 173 | Returns: 174 | (x, y): Image position. 175 | """ 176 | offset_row, offset_col = offset 177 | origin_x, origin_y = self.origin_position 178 | return origin_x + col - offset_col, origin_y + 2 * (row - offset_row) 179 | 180 | def xy_to_rowcol(self, x: int, y: int, offset: Tuple[int, int]) -> Tuple[int, int]: 181 | """Converts an image position (x,y) to a character position (row,col) given the 182 | offset (number of rows, number of columns) between the top-left of the terminal 183 | and top-left of the widget. 184 | 185 | Args: 186 | row (int): Row of the terminal (starting from the top at 0) to convert to an 187 | image y-value. 188 | col (int): Column of the terminal (starting from the left at 0) to convert 189 | to an image x-value. 190 | offset (Tuple[int, int]): Offset (number of rows, number of columns) between 191 | the top-left of the terminal and top-left of the widget. 192 | 193 | Returns: 194 | (row, col): Character position. 195 | """ 196 | offset_row, offset_col = offset 197 | origin_x, origin_y = self.origin_position 198 | return (y - origin_y) // 2 + offset_row, x - origin_x + offset_col 199 | 200 | @property 201 | def origin_position(self) -> Tuple[int, int]: 202 | return self._origin_position 203 | 204 | @origin_position.setter 205 | def origin_position(self, value: Tuple[int, int]): 206 | origin_x, origin_y = value 207 | img_w, img_h = self.zoomed_size 208 | if self._container_size is not None: 209 | w, h = self._container_size[0], self._container_size[1] * 2 210 | else: 211 | w, h = 0, 0 212 | 213 | if origin_x <= -w + 1: 214 | origin_x = -w + 1 215 | 216 | if origin_x >= img_w - 1: 217 | origin_x = img_w - 1 218 | 219 | if origin_y <= -h + 1: 220 | origin_y = -h + 1 221 | 222 | if origin_y >= img_h - 1: 223 | origin_y = img_h - 1 224 | 225 | self._origin_position = origin_x, origin_y 226 | 227 | @property 228 | def size(self) -> Tuple[int, int]: 229 | """Size of the original image.""" 230 | return self.image.size 231 | 232 | @property 233 | def zoomed_size(self) -> Tuple[int, int]: 234 | """Size of the image at the current zoom level.""" 235 | return self.images[self._zoom].size 236 | 237 | def __rich_console__( 238 | self, console: Console, options: ConsoleOptions 239 | ) -> RenderResult: 240 | if self._container_size is None: 241 | return "" 242 | 243 | image = self.images[self._zoom] 244 | img_w, img_h = image.size 245 | w, h = self._container_size[0], self._container_size[1] * 2 246 | origin_x, origin_y = self.origin_position 247 | 248 | null_style = Style.null() 249 | newline = Segment("\n", null_style) 250 | 251 | segments = [] 252 | for y in range(origin_y, min(origin_y + h, img_h), 2): 253 | # Skip lines with no image 254 | if y < -1: 255 | segments.append(newline) 256 | continue 257 | 258 | # Add padding to the left of the image 259 | if origin_x < 0: 260 | segments.append(Segment(" " * -origin_x, style=null_style)) 261 | x_start = 0 262 | else: 263 | x_start = origin_x 264 | 265 | for x in range(x_start, min(x_start + w, img_w)): 266 | # Add segment for each pixel-pair of the image 267 | segments.append(self.get_segment(x, y)) 268 | 269 | segments.append(newline) 270 | 271 | return segments 272 | 273 | def get_segment(self, x: int, y: int) -> Segment: 274 | """Computes the Segment (character + style) at a particular image position. 275 | Segments are cached because profiling suggested that the instantiation of Color 276 | and Style objects was taxing. 277 | 278 | Args: 279 | x (int): Image x-coordinate of returned segment. 280 | y (int): Image y-coordinate of returned segment. Note that the y-coordinate 281 | refers to the top half of the segment, as each character corresponds to 282 | two pixels. 283 | """ 284 | position = (x, y) 285 | image = self.images[self._zoom] 286 | cache = self.segment_cache[self._zoom] 287 | _, img_h = image.size 288 | 289 | # Check if we've already computed the segment for this position 290 | if position not in cache: 291 | upper = None 292 | if y >= 0: 293 | pixel = image.getpixel(position) 294 | if not isinstance(pixel, tuple): 295 | pixel = (pixel, pixel, pixel) 296 | upper = Color.from_rgb(*pixel[:3]) 297 | 298 | lower = None 299 | if y < img_h - 1: 300 | pixel = image.getpixel((x, y + 1)) 301 | if not isinstance(pixel, tuple): 302 | pixel = (pixel, pixel, pixel) 303 | lower = Color.from_rgb(*pixel[:3]) 304 | 305 | # Render each pixel as a half-height character 306 | if upper is None: 307 | segment = Segment("▄", Style(color=lower)) 308 | else: 309 | segment = Segment("▀", Style(color=upper, bgcolor=lower)) 310 | 311 | # Cache segment for next render 312 | cache[position] = segment 313 | 314 | return cache[position] 315 | -------------------------------------------------------------------------------- /textual_imageview/viewer.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from PIL import Image 4 | from textual import events 5 | from textual.app import RenderResult 6 | from textual.widget import Widget 7 | 8 | from textual_imageview.img import ImageView 9 | 10 | 11 | class ImageViewer(Widget): 12 | DEFAULT_CSS = """ 13 | ImageViewer{ 14 | min-width: 8; 15 | min-height: 8; 16 | } 17 | """ 18 | 19 | def __init__(self, image: Image.Image): 20 | super().__init__() 21 | if not isinstance(image, Image.Image): 22 | raise TypeError( 23 | f"Expected PIL Image, but received '{type(image).__name__}' instead." 24 | ) 25 | 26 | self.image = ImageView(image) 27 | self.mouse_down = False 28 | 29 | def on_show(self): 30 | w, h = self.size.width, self.size.height 31 | img_w, img_h = self.image.size 32 | 33 | # Compute zoom such that image fits in container 34 | zoom_w = math.log(max(w, 1) / img_w, self.image.ZOOM_RATE) 35 | zoom_h = math.log((max(h, 1) * 2) / img_h, self.image.ZOOM_RATE) 36 | zoom = max(0, math.ceil(max(zoom_w, zoom_h))) 37 | self.image.set_zoom(zoom) 38 | 39 | # Position image in center of container 40 | img_w, img_h = self.image.zoomed_size 41 | self.image.origin_position = (-round((w - img_w) / 2), -round(h - img_h / 2)) 42 | self.image.set_container_size(w, h, maintain_center=False) 43 | 44 | self.refresh() 45 | 46 | def on_mouse_scroll_down(self, event: events.MouseScrollDown): 47 | offset = self.region.offset 48 | zoom_position = self.image.rowcol_to_xy(event.y, event.x, (offset.y, offset.x)) 49 | self.image.zoom(1, zoom_position) 50 | self.refresh() 51 | event.stop() 52 | 53 | def on_mouse_scroll_up(self, event: events.MouseScrollDown): 54 | offset = self.region.offset 55 | zoom_position = self.image.rowcol_to_xy(event.y, event.x, (offset.y, offset.x)) 56 | self.image.zoom(-1, zoom_position) 57 | self.refresh() 58 | event.stop() 59 | 60 | def on_mouse_down(self, _: events.MouseDown): 61 | self.mouse_down = True 62 | self.capture_mouse(capture=True) 63 | 64 | def on_mouse_up(self, _: events.MouseDown): 65 | self.mouse_down = False 66 | self.capture_mouse(capture=False) 67 | 68 | def on_mouse_move(self, event: events.MouseMove): 69 | if self.mouse_down and (event.delta_x != 0 or event.delta_y != 0): 70 | self.image.move(event.delta_x, event.delta_y * 2) 71 | self.refresh() 72 | 73 | def on_resize(self, event: events.Resize): 74 | self.image.set_container_size(event.size.width, event.size.height) 75 | self.refresh() 76 | 77 | def render(self) -> RenderResult: 78 | return self.image 79 | --------------------------------------------------------------------------------