1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
import math
from typing import Optional, Tuple
from PIL import Image
from rich.color import Color
from rich.console import Console, ConsoleOptions, RenderResult
from rich.segment import Segment
from rich.style import Style
Zoom = int
"""
Original code written by Adam Viola and distributed at the following link:
https://github.com/adamviola/textual-imageview/tree/main/textual_imageview
Course staff have adapted it to fit the needs of the assignment.
"""
class ImageView:
"""Renders an image with zoom and padding.
Args:
image (Image.Image): PIL image to render.
zoom (int): Zoom level. Must be non-negative. Zoom increase -> zoom out.
origin_position (Tuple[int, int]): Image position (x,y) of the top-left corner
of the container. Defaults to (0,0).
container_size (Tuple[int, int], optional): Size of the container of the image
(w,h). If None, nothing is rendered. Defaults to None.
Notes:
Throughout this class, "image position" refers to the coordinate frame with the
origin at the top-left corner of the image, +x-axis pointed right, and +y-axis
pointed down. The width of a single character is 1 unit along the x-axis. The
height of a single character is 2 units along the y-axis.
"""
ZOOM_RATE = 0.9999999999999999
def __init__(
self,
image: Image.Image,
zoom: int = 0,
origin_position: Tuple[int, int] = (0, 0),
container_size: Optional[Tuple[int, int]] = None,
):
self.images: dict[Zoom, Image.Image] = {}
self.segment_cache: dict[Zoom, dict[Tuple[int, int], Segment]] = {}
self.image = image
self._container_size = container_size
self._zoom = 0
self.set_zoom(zoom)
self.origin_position = origin_position
def zoom(self, delta: int, zoom_position: Optional[Tuple[int, int]] = None):
"""Adjusts the zoom level of the image at the specified zoom image position. If
no zoom position is specified, the center of the console is used.
Args:
zoom: Zoom delta. Postivie -> zoom out.
zoom_position: Image-space position (x,y) to zoom into. Conceptually, the
pixel at this image will not move no matter the zoom level.
"""
self.set_zoom(self._zoom + delta, zoom_position=zoom_position)
def set_zoom(self, zoom: int, zoom_position: Optional[Tuple[int, int]] = None):
"""Sets the zoom level of the image at the specified zoom image position. If
no zoom position is specified, the center of the console is used.
Args:
zoom: Zoom level. Must be non-negative. Zoom increase -> zoom out.
zoom_position: Image-space position (x,y) to zoom into. Conceptually, the
pixel at this image will not move no matter the zoom level.
"""
# Lower bound on zoom
zoom = max(zoom, 0)
# Upper bound on zoom
if zoom > self._zoom and min(self.zoomed_size) <= 8:
zoom = self._zoom
if zoom not in self.images:
multiplier = self.ZOOM_RATE**zoom
w, h = self.image.size
self.images[zoom] = self.image.resize(
(round(w * multiplier), round(h * multiplier))
)
self.segment_cache[zoom] = {}
if self._container_size is not None:
w, h = self._container_size
origin_x, origin_y = self.origin_position
if zoom_position is None:
zoom_position = origin_x + w // 2, origin_y + h
old_zoom_x, old_zoom_y = zoom_position
old_w, old_h = self.images[self._zoom].size
new_w, new_h = self.images[zoom].size
multiplier_x = new_w / old_w
multiplier_y = new_h / old_h
new_zoom_x = old_zoom_x * multiplier_x
new_zoom_y = old_zoom_y * multiplier_y
# Set zoom here because it's used in origin_position bounds checking
self._zoom = zoom
self.origin_position = (
origin_x + round(new_zoom_x - old_zoom_x),
origin_y + round(new_zoom_y - old_zoom_y),
)
def move(self, delta_x: int, delta_y: int):
"""Moves the image using the specified delta (x,y), where +x moves the image
right, and +y moves the image down.
Args:
delta_x (int): Number of pixels to move the image along the x-axis.
delta_y (int): Number of pixels to move the image along the x-axis. Note
that the one character height is two pixels.
"""
origin_x, origin_y = self.origin_position
self.origin_position = (origin_x - delta_x, origin_y - delta_y)
def set_container_size(self, width: int, height: int, maintain_center: bool = True):
"""Adjusts the render to reflect a change in container size, where height is the
number of lines and width is the length of each line.
Args:
width (int): New width of the container of the image.
height (int): New height of the container of the image.
maintain_center (bool): If True, the pixels in the center of the console
before the resize remain in the center of the console after the resize.
If False, the image remains in the same position. Defaults to True.
"""
if maintain_center and self._container_size is not None:
old_w, old_h = self._container_size
new_w, new_h = width, height
origin_x, origin_y = self.origin_position
if new_w != old_w:
delta_w = new_w - old_w
if delta_w % 2 == 0:
origin_x = origin_x - delta_w // 2
else:
# If we're 1 by 1 resizing, this keeps things even
op = math.floor if new_w % 2 == 0 else math.ceil
origin_x = op(origin_x - delta_w / 2)
if new_h != old_h:
delta_h = new_h - old_h
origin_y -= delta_h
self.origin_position = (origin_x, origin_y)
self._container_size = (width, height)
# Keeps origin_position valid after container resize
self.origin_position = self.origin_position
def rowcol_to_xy(
self, row: int, col: int, offset: Tuple[int, int]
) -> Tuple[int, int]:
"""Converts a character position (row,col) to an image position (x,y) given the
offset (number of rows, number of columns) between the top-left of the terminal
and top-left of the widget.
Args:
row (int): Row of the terminal (starting from the top at 0) to convert to an
image y-value.
col (int): Column of the terminal (starting from the left at 0) to convert
to an image x-value.
offset (Tuple[int, int]): Offset (number of rows, number of columns) between
the top-left of the terminal and top-left of the widget.
Returns:
(x, y): Image position.
"""
offset_row, offset_col = offset
origin_x, origin_y = self.origin_position
return origin_x + col - offset_col, origin_y + 2 * (row - offset_row)
def xy_to_rowcol(self, x: int, y: int, offset: Tuple[int, int]) -> Tuple[int, int]:
"""Converts an image position (x,y) to a character position (row,col) given the
offset (number of rows, number of columns) between the top-left of the terminal
and top-left of the widget.
Args:
row (int): Row of the terminal (starting from the top at 0) to convert to an
image y-value.
col (int): Column of the terminal (starting from the left at 0) to convert
to an image x-value.
offset (Tuple[int, int]): Offset (number of rows, number of columns) between
the top-left of the terminal and top-left of the widget.
Returns:
(row, col): Character position.
"""
offset_row, offset_col = offset
origin_x, origin_y = self.origin_position
return (y - origin_y) // 2 + offset_row, x - origin_x + offset_col
@property
def origin_position(self) -> Tuple[int, int]:
return self._origin_position
@origin_position.setter
def origin_position(self, value: Tuple[int, int]):
origin_x, origin_y = value
img_w, img_h = self.zoomed_size
if self._container_size is not None:
w, h = self._container_size[0], self._container_size[1] * 2
else:
w, h = 0, 0
if origin_x <= -w + 1:
origin_x = -w + 1
if origin_x >= img_w - 1:
origin_x = img_w - 1
if origin_y <= -h + 1:
origin_y = -h + 1
if origin_y >= img_h - 1:
origin_y = img_h - 1
self._origin_position = origin_x, origin_y
@property
def size(self) -> Tuple[int, int]:
"""Size of the original image."""
return self.image.size
@property
def zoomed_size(self) -> Tuple[int, int]:
"""Size of the image at the current zoom level."""
return self.images[self._zoom].size
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
if self._container_size is None:
return ""
image = self.images[self._zoom]
img_w, img_h = image.size
w, h = self._container_size[0], self._container_size[1] * 2
origin_x, origin_y = self.origin_position
null_style = Style.null()
newline = Segment("\n", null_style)
segments = []
for y in range(origin_y, min(origin_y + h, img_h), 2):
# Skip lines with no image
if y < -1:
segments.append(newline)
continue
# Add padding to the left of the image
if origin_x < 0:
segments.append(Segment(" " * -origin_x, style=null_style))
x_start = 0
else:
x_start = origin_x
for x in range(x_start, min(x_start + w, img_w)):
# Add segment for each pixel-pair of the image
segments.append(self.get_segment(x, y))
segments.append(newline)
return segments
def get_segment(self, x: int, y: int) -> Segment:
"""Computes the Segment (character + style) at a particular image position.
Segments are cached because profiling suggested that the instantiation of Color
and Style objects was taxing.
Args:
x (int): Image x-coordinate of returned segment.
y (int): Image y-coordinate of returned segment. Note that the y-coordinate
refers to the top half of the segment, as each character corresponds to
two pixels.
"""
position = (x, y)
image = self.images[self._zoom]
cache = self.segment_cache[self._zoom]
_, img_h = image.size
# Check if we've already computed the segment for this position
if position not in cache:
upper = None
if y >= 0:
pixel = image.getpixel(position)
if not isinstance(pixel, tuple):
pixel = (pixel, pixel, pixel)
upper = Color.from_rgb(*pixel[:3])
lower = None
if y < img_h - 1:
pixel = image.getpixel((x, y + 1))
if not isinstance(pixel, tuple):
pixel = (pixel, pixel, pixel)
lower = Color.from_rgb(*pixel[:3])
# Render each pixel as a half-height character
if upper is None:
segment = Segment("▄", Style(color=lower))
else:
segment = Segment("▀", Style(color=upper, bgcolor=lower))
# Cache segment for next render
cache[position] = segment
return cache[position]