flexablelib-components 3.4.0__tar.gz → 3.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {flexablelib_components-3.4.0/flexablelib_components.egg-info → flexablelib_components-3.5.0}/PKG-INFO +3 -3
  2. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/__init__.py +5 -3
  3. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/data.py +36 -3
  4. flexablelib_components-3.5.0/flexablelib_components/media.py +531 -0
  5. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0/flexablelib_components.egg-info}/PKG-INFO +3 -3
  6. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components.egg-info/SOURCES.txt +1 -0
  7. flexablelib_components-3.5.0/flexablelib_components.egg-info/requires.txt +2 -0
  8. flexablelib_components-3.4.0/flexablelib_components.egg-info/requires.txt +0 -2
  9. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/LICENSE +0 -0
  10. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/MANIFEST.in +0 -0
  11. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/README.md +0 -0
  12. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/base.py +0 -0
  13. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/dates.py +0 -0
  14. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/display.py +0 -0
  15. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/inputs.py +0 -0
  16. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/navigation.py +0 -0
  17. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/overlays.py +0 -0
  18. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/py.typed +0 -0
  19. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components.egg-info/dependency_links.txt +0 -0
  20. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components.egg-info/top_level.txt +0 -0
  21. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/setup.cfg +0 -0
  22. {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexablelib-components
3
- Version: 3.4.0
3
+ Version: 3.5.0
4
4
  Summary: Interactive terminal components for flexablelib: inputs, dates, tables, forms, menus, tabs, dialogs and more.
5
5
  Home-page: https://github.com/Opus-Perpetuus/flexablelib
6
6
  Author: Benjamin Ramirez
@@ -17,8 +17,8 @@ Classifier: Topic :: Software Development :: User Interfaces
17
17
  Requires-Python: >=3.8
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
- Requires-Dist: flexablelib>=3.4.0
21
- Requires-Dist: flexablelib-tui>=3.4.0
20
+ Requires-Dist: flexablelib>=3.5.0
21
+ Requires-Dist: flexablelib-tui>=3.5.0
22
22
  Dynamic: author
23
23
  Dynamic: author-email
24
24
  Dynamic: classifier
@@ -9,14 +9,15 @@ pieces (badges, alerts, links, breadcrumbs, code, Markdown/HTML, timelines,
9
9
  steppers, comments, stats, charts), navigation (tabs, menus, pagination,
10
10
  sidebar, wizard, accordion, tree, command palette, split panes, app shell),
11
11
  data (interactive and editable tables, filter bar, catalog, forms, login)
12
- and dialogs (modal, confirm, prompt, loading, popover).
12
+ dialogs (modal, confirm, prompt, loading, popover) and pictures (image,
13
+ image viewer: kitty, iTerm2, sixel, Unicode blocks, ASCII, link).
13
14
 
14
15
  Every component works inside :class:`flexablelib_tui.App` (focus with Tab,
15
16
  keys to the focused component) and also renders on its own with
16
17
  ``render(width)``. See docs/COMPONENTS.md.
17
18
  """
18
19
 
19
- __version__ = '3.4.0'
20
+ __version__ = '3.5.0'
20
21
 
21
22
  from .base import Theme, DEFAULT_THEME, Input, Box, Button, frame
22
23
  from .inputs import (TextInput, PasswordInput, NumberInput, ExpressionInput,
@@ -38,6 +39,7 @@ from .navigation import (Tabs, Menu, ContextMenu, MenuButton, Pagination,
38
39
  AppShell, SortControl)
39
40
  from .data import (DataTable, FilterBar, EditableTable, CatalogGrid, Form,
40
41
  ValidationSummary, LoginForm, FilePreview, parse_query)
42
+ from .media import Image, ImageViewer, IMAGE_EXTENSIONS
41
43
  from .overlays import (Modal, ConfirmDialog, PromptDialog, LoadingOverlay,
42
44
  Popover, HelpLine)
43
45
 
@@ -60,5 +62,5 @@ __all__ = [
60
62
  'DataTable', 'FilterBar', 'EditableTable', 'CatalogGrid', 'Form',
61
63
  'ValidationSummary', 'LoginForm', 'FilePreview', 'parse_query',
62
64
  'Modal', 'ConfirmDialog', 'PromptDialog', 'LoadingOverlay', 'Popover',
63
- 'HelpLine', '__version__',
65
+ 'HelpLine', 'Image', 'ImageViewer', 'IMAGE_EXTENSIONS', '__version__',
64
66
  ]
@@ -1045,8 +1045,10 @@ class LoginForm(Form):
1045
1045
  class FilePreview(_Themed):
1046
1046
  """
1047
1047
  A file's name, size, date and -- for text -- its first lines, with code
1048
- highlighting by extension. Images, PDFs and other binaries show their
1049
- details only (a terminal cannot draw them portably).
1048
+ highlighting by extension. Images (and the first page of a PDF or an
1049
+ SVG, when a renderer is installed) are drawn with :class:`Image` in at
1050
+ most ``max_lines`` rows; ``images=False`` keeps them as details only.
1051
+ Other binaries show their details only.
1050
1052
  """
1051
1053
 
1052
1054
  LANGUAGES = {'.py': 'python', '.js': 'javascript', '.ts': 'typescript',
@@ -1057,10 +1059,34 @@ class FilePreview(_Themed):
1057
1059
  def a11y_name(self):
1058
1060
  return self.a11y_label or os.path.basename(self.path)
1059
1061
 
1060
- def __init__(self, path, max_lines=20, **kwargs):
1062
+ def __init__(self, path, max_lines=20, images=True, method=None,
1063
+ **kwargs):
1061
1064
  super(FilePreview, self).__init__(**kwargs)
1062
1065
  self.path = path
1063
1066
  self.max_lines = max_lines
1067
+ self.images = images
1068
+ self.method = method
1069
+ self._image = None
1070
+
1071
+ @property
1072
+ def needs_graphics(self):
1073
+ return self._is_image()
1074
+
1075
+ def _is_image(self):
1076
+ from .media import IMAGE_EXTENSIONS
1077
+ return bool(self.images) and os.path.splitext(
1078
+ str(self.path))[1].lower() in IMAGE_EXTENSIONS
1079
+
1080
+ def image(self):
1081
+ """The :class:`Image` used for an image file (or None)."""
1082
+ if not self._is_image():
1083
+ return None
1084
+ if self._image is None or self._image.source != self.path:
1085
+ from .media import Image
1086
+ self._image = Image(self.path, alt=os.path.basename(self.path),
1087
+ method=self.method, theme=self._theme,
1088
+ use_colors=self.use_colors, align='left')
1089
+ return self._image
1064
1090
 
1065
1091
  def natural_width(self):
1066
1092
  return 70
@@ -1090,6 +1116,13 @@ class FilePreview(_Themed):
1090
1116
  meta = 'no encontrado'
1091
1117
  out = [self.paint(name, 'bold') + ' ' + self.paint(meta,
1092
1118
  self.theme.muted)]
1119
+ picture = self.image()
1120
+ if picture is not None and meta != 'no encontrado':
1121
+ rows = max(1, min(self.max_lines, (height - 1) if height
1122
+ else self.max_lines))
1123
+ picture.app = self.app
1124
+ out.extend(picture.render(width, rows).split('\n'))
1125
+ return out
1093
1126
  text, problem = self._content()
1094
1127
  if problem:
1095
1128
  out.append(self.paint(problem, self.theme.muted))
@@ -0,0 +1,531 @@
1
+ """
2
+ Pictures: :class:`Image` shows an image file, URL, bytes or bitmap with the
3
+ best method the terminal has (kitty graphics, iTerm2 inline images, sixel,
4
+ Unicode blocks, braille, ASCII art) and falls back to a link that opens it
5
+ in a browser -- or, with no browser or display, to a warning with the
6
+ reason. :class:`ImageViewer` pages through several images.
7
+ """
8
+
9
+ import math
10
+ import os
11
+ import time
12
+
13
+ from flexablelib import _frames as _fx
14
+ from flexablelib import _images as _img
15
+ from flexablelib_tui import accessibility as _a11y
16
+ from flexablelib_tui import graphics as _g
17
+
18
+ from .base import _Themed
19
+
20
+ __all__ = ['Image', 'ImageViewer', 'IMAGE_EXTENSIONS']
21
+
22
+ IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp',
23
+ '.ppm', '.pgm', '.pbm', '.pnm', '.tif', '.tiff', '.ico',
24
+ '.svg', '.pdf')
25
+ FITS = ('contain', 'cover', 'fill', 'none')
26
+ _IDS = {}
27
+
28
+
29
+ def _image_id(key):
30
+ """A stable 24-bit kitty image id for ``key`` (never 0)."""
31
+ ident = _IDS.get(key)
32
+ if ident is None:
33
+ import zlib
34
+ ident = (zlib.crc32(repr(key).encode()) & 0xFFFFFF) or 1
35
+ _IDS[key] = ident
36
+ return ident
37
+
38
+
39
+ class Image(_Themed):
40
+ """
41
+ An image in the terminal.
42
+
43
+ ``source``: a path, ``file://`` or ``http(s)://`` URL, bytes, a file
44
+ object, a :class:`flexablelib._images.Bitmap` or a Pillow image.
45
+ ``alt`` is the text alternative read by screen readers and shown in
46
+ linear mode -- always give one (the accessibility audit reports images
47
+ without it). ``width`` / ``height`` cap the size in cells (default: the
48
+ space the layout offers and the image's own proportions); ``fit`` is
49
+ ``'contain'`` (whole image, proportions kept), ``'cover'`` (fills the
50
+ area, cropped), ``'fill'`` (stretched) or ``'none'`` (pixels 1:1,
51
+ cropped). ``method`` forces one of ``'kitty'``, ``'iterm'``,
52
+ ``'sixel'``, ``'half'``, ``'quadrant'``, ``'sextant'``, ``'braille'``,
53
+ ``'ascii'``, ``'link'``, ``'none'`` (default: ``FLEXABLELIB_IMAGE``, then
54
+ what :func:`flexablelib_tui.graphics.detect` finds); ``blocks`` picks the
55
+ Unicode mode used when no pixel protocol is available (``'half'``).
56
+ ``caption=True`` adds the alt text (or file name) below as a link.
57
+ Enter, ``o`` or a click open the image in the browser when there is
58
+ one; ``on_open(image)`` replaces that.
59
+ """
60
+
61
+ role = 'img'
62
+ needs_graphics = True
63
+
64
+ def __init__(self, source, alt=None, width=None, height=None,
65
+ fit='contain', method=None, blocks=None, align='center',
66
+ background=None, upscale=True, animate=False, caption=False,
67
+ cell_size=None, focusable=False, on_open=None, **kwargs):
68
+ super(Image, self).__init__(**kwargs)
69
+ if fit not in FITS:
70
+ raise ValueError('fit must be one of {0}'.format(FITS))
71
+ self.source = source
72
+ self.alt = alt
73
+ self.width = width
74
+ self.height = height
75
+ self.fit = fit
76
+ self.method = method
77
+ self.blocks = blocks
78
+ self.align = align
79
+ self.background = background
80
+ self.upscale = upscale
81
+ self.animate = animate
82
+ self.caption = caption
83
+ self.cell_size = cell_size
84
+ self.focusable = focusable
85
+ self.on_open = on_open
86
+ self.error = None
87
+ self.used_method = None
88
+ self._frames = None
89
+ self._started = None
90
+ self._cache = {}
91
+
92
+ # -- source ---------------------------------------------------------------
93
+
94
+ @property
95
+ def name(self):
96
+ src = self.source
97
+ if isinstance(src, (str, os.PathLike)):
98
+ text = os.fspath(src)
99
+ if '://' in text:
100
+ return text.rstrip('/').rsplit('/', 1)[-1] or text
101
+ return os.path.basename(text.rstrip('/\\')) or text
102
+ return getattr(src, 'name', None) or 'imagen'
103
+
104
+ @property
105
+ def url(self):
106
+ """What a browser opens: the URL, or ``file://`` for a path."""
107
+ if isinstance(self.source, (str, os.PathLike)):
108
+ return _g.file_url(self.source)
109
+ return None
110
+
111
+ def reload(self):
112
+ self._frames = None
113
+ self._cache = {}
114
+ self.error = None
115
+
116
+ def _load(self):
117
+ if self._frames is None:
118
+ try:
119
+ if self.animate and not _a11y.settings.reduced_motion:
120
+ frames = _img.load_frames(self.source)
121
+ else:
122
+ frames = [_img.load(self.source)]
123
+ self._frames = frames
124
+ self.error = None
125
+ except _img.ImageError as error:
126
+ self._frames = []
127
+ self.error = str(error)
128
+ except (OSError, ValueError) as error:
129
+ self._frames = []
130
+ self.error = str(error)
131
+ self._started = time.monotonic()
132
+ return self._frames
133
+
134
+ def bitmap(self):
135
+ """The current frame (the first one unless animating), or None."""
136
+ frames = self._load()
137
+ if not frames:
138
+ return None
139
+ if len(frames) == 1 or _a11y.settings.reduced_motion:
140
+ return frames[0]
141
+ total = sum(max(20, f.delay or 100) for f in frames)
142
+ t = int((time.monotonic() - self._started) * 1000) % total
143
+ for frame in frames:
144
+ t -= max(20, frame.delay or 100)
145
+ if t < 0:
146
+ return frame
147
+ return frames[-1]
148
+
149
+ # -- accessibility -------------------------------------------------------
150
+
151
+ def a11y_name(self):
152
+ return str(self.a11y_label or self.alt or '')
153
+
154
+ def a11y_value(self):
155
+ bm = self.bitmap()
156
+ if bm is None:
157
+ return 'no se puede mostrar: {0}'.format(self.error or '')
158
+ return '{0}×{1} px'.format(bm.width, bm.height)
159
+
160
+ # -- geometry ------------------------------------------------------------
161
+
162
+ def _info(self):
163
+ return _g.detect()
164
+
165
+ def _cell(self, info):
166
+ return tuple(self.cell_size) if self.cell_size else info.cell
167
+
168
+ def natural_width(self):
169
+ if self.width:
170
+ return self.width
171
+ bm = self.bitmap()
172
+ if bm is None:
173
+ return min(60, _fx.visible_width(self.name) + 40)
174
+ cw = self._cell(self._info())[0]
175
+ from flexablelib.layout import render_width
176
+ return max(4, min(render_width(None), math.ceil(bm.width / cw)))
177
+
178
+ def _box(self, bm, width, height, cell):
179
+ """(cols, rows, crop) for the picture inside width x height cells."""
180
+ cw, ch = cell
181
+ iw, ih = bm.width, bm.height
182
+ max_w = width * cw
183
+ max_h = height * ch if height else None
184
+ crop = None
185
+ if self.fit == 'fill' and max_h:
186
+ return width, height, None
187
+ if self.fit == 'none':
188
+ cols = min(width, math.ceil(iw / cw))
189
+ rows = math.ceil(ih / ch)
190
+ if height:
191
+ rows = min(rows, height)
192
+ crop = (0, 0, min(iw, cols * cw), min(ih, rows * ch))
193
+ return cols, rows, crop
194
+ if self.fit == 'cover' and max_h:
195
+ s = max(max_w / iw, max_h / ih)
196
+ vis_w, vis_h = max_w / s, max_h / s
197
+ crop = (int((iw - vis_w) / 2), int((ih - vis_h) / 2),
198
+ max(1, int(vis_w)), max(1, int(vis_h)))
199
+ return width, height, crop
200
+ s = max_w / iw
201
+ if max_h:
202
+ s = min(s, max_h / ih)
203
+ if not self.upscale:
204
+ s = min(s, 1.0)
205
+ cols = max(1, min(width, int(round(iw * s / cw))))
206
+ rows = max(1, int(round(ih * s / ch)))
207
+ if height:
208
+ rows = min(rows, height)
209
+ return cols, rows, crop
210
+
211
+ # -- drawing -------------------------------------------------------------
212
+
213
+ def _fallback(self, width, reason, info=None):
214
+ """The link line, or the warning when nothing can open it."""
215
+ url = self.url
216
+ ok, how = _g.browser_available()
217
+ name = self.name
218
+ if url and ok:
219
+ self.used_method = 'link'
220
+ icon = '->' if _a11y.settings.ascii else '↗'
221
+ text = '{0} {1}'.format(icon, name)
222
+ hint = ' (Enter: abrir en el navegador)' if self.focusable else \
223
+ ' (abrir en el navegador)'
224
+ line = _g.hyperlink(url, self.paint(text, self.theme.accent +
225
+ ' underline'
226
+ if self._active() else None))
227
+ lines = [line + self.paint(hint, self.theme.muted)]
228
+ if reason:
229
+ lines.append(self.paint(reason, self.theme.muted))
230
+ return lines + self._focus_line(width)
231
+ self.used_method = 'warning'
232
+ symbol = _a11y.status_symbol('warning')
233
+ why = reason or 'esta terminal no muestra imágenes'
234
+ if url and not ok:
235
+ why += ' y no hay navegador para abrirla ({0})'.format(how)
236
+ elif not url:
237
+ why += '; sin archivo ni URL que abrir'
238
+ text = '{0} {1}: {2}'.format(symbol, name, why)
239
+ return [self.paint(line, self.theme.warning)
240
+ for line in _wrap(text, width)]
241
+
242
+ def _alt_lines(self, width):
243
+ self.used_method = 'alt'
244
+ text = 'Imagen: {0}'.format(self.alt or self.name)
245
+ if self.alt and self.name not in ('imagen', self.alt):
246
+ text += ' ({0})'.format(self.name)
247
+ if self.bitmap() is None:
248
+ text += ' -- no se puede mostrar: {0}'.format(self.error)
249
+ return _wrap(text, width)
250
+
251
+ def _color(self, info):
252
+ if not self._active():
253
+ return 'none'
254
+ level = info.color or 'truecolor'
255
+ return level if level in ('truecolor', '256', '16') else '16'
256
+
257
+ def draw(self, width, height):
258
+ info = self._info()
259
+ avail = width
260
+ width = min(width, self.width) if self.width else width
261
+ if self.height:
262
+ height = min(height, self.height) if height else self.height
263
+ caption = []
264
+ if self.caption:
265
+ label = self.alt or self.name
266
+ if self.url:
267
+ label = _g.hyperlink(self.url, label)
268
+ caption = [self.paint(label, self.theme.muted)]
269
+ if height:
270
+ height = max(1, height - 1)
271
+ color = self._color(info)
272
+ method, mode, reason = _g.choose(
273
+ info, force=self.method, blocks=self.blocks,
274
+ color=color if color != 'none' else 'none')
275
+ if method == 'alt':
276
+ return self._alt_lines(avail)
277
+ if method == 'none':
278
+ self.used_method = 'none'
279
+ return [self.paint('[{0}]'.format(self.alt or self.name),
280
+ self.theme.muted)][:1]
281
+ if method == 'link':
282
+ return self._fallback(avail, '') + caption
283
+ bm = self.bitmap()
284
+ if bm is None:
285
+ return self._fallback(avail, self.error) + caption
286
+ cell = self._cell(info)
287
+ if method == 'text':
288
+ cell_px = _img.CELL_PIXELS[mode]
289
+ cols, rows, crop = self._box(bm, width, height, cell)
290
+ if cols < 2 or rows < 1:
291
+ return self._fallback(avail, 'no cabe ({0}×{1})'.format(
292
+ width, height)) + caption
293
+ if crop:
294
+ bm = bm.crop(*crop)
295
+ if method == 'text':
296
+ lines = self._text(bm, cols, rows, mode, color)
297
+ else:
298
+ lines = self._pixels(bm, cols, rows, cell, method, info, color)
299
+ self.used_method = method if method != 'text' else mode
300
+ pad = max(0, width - cols)
301
+ left = pad // 2 if self.align == 'center' else \
302
+ pad if self.align == 'right' else 0
303
+ if left:
304
+ lines = [' ' * left + line for line in lines]
305
+ if caption:
306
+ lines.append(_fx.fit_line(caption[0], width,
307
+ 'c' if self.align == 'center' else 'l'))
308
+ return lines + self._focus_line(width)
309
+
310
+ def _focus_line(self, width):
311
+ if not (self.focusable and self.focused):
312
+ return []
313
+ text = '> Enter: abrir' if _a11y.settings.ascii else '▶ Enter: abrir'
314
+ return [_fx.fit_line(self.paint(text, 'reverse') if self._active()
315
+ else '[' + text + ']', width,
316
+ 'c' if self.align == 'center' else 'l')]
317
+
318
+ def _text(self, bm, cols, rows, mode, color):
319
+ if mode == 'sextant' and _fx.visible_width(_img.sextant(1)) != 1:
320
+ mode = 'quadrant' # Unicode 13 sextants unknown to this Python
321
+ key = ('text', id(bm), cols, rows, mode, color, self.background)
322
+ hit = self._cache.get(key)
323
+ if hit is not None:
324
+ return list(hit)
325
+ pw, ph = _img.CELL_PIXELS[mode]
326
+ src = bm
327
+ if self.background is not None:
328
+ src = src.flatten(_rgb(self.background))
329
+ scaled = _img.scale(src, cols * pw, rows * ph)
330
+ lines = _img.cells(scaled.data, scaled.width, scaled.height, mode,
331
+ color, 128)
332
+ self._cache = {key: lines}
333
+ return list(lines)
334
+
335
+ def _pixels(self, bm, cols, rows, cell, method, info, color):
336
+ cw, ch = cell
337
+ key = (method, id(bm), cols, rows, cell, color, self.background,
338
+ info.kitty_placeholders, info.tmux)
339
+ hit = self._cache.get(key)
340
+ if hit is not None:
341
+ mark_key, lines = hit
342
+ if mark_key in _g._PAYLOADS:
343
+ return list(lines)
344
+ # Exact pixel size inside the cell box, proportions kept.
345
+ if self.fit in ('fill', 'cover'):
346
+ dw, dh = cols * cw, rows * ch
347
+ else:
348
+ s = min(cols * cw / bm.width, rows * ch / bm.height)
349
+ dw = max(1, int(round(bm.width * s)))
350
+ dh = max(1, int(round(bm.height * s)))
351
+ src = bm
352
+ if self.background is not None:
353
+ src = src.flatten(_rgb(self.background))
354
+ if color == 'none':
355
+ src = src.grayscale()
356
+ scaled = _img.scale(src, dw, dh)
357
+ tmux = bool(info.tmux)
358
+ if method == 'kitty':
359
+ ident = _image_id((id(self), self.source if isinstance(
360
+ self.source, str) else id(self.source)))
361
+ png = _img.encode_png(scaled)
362
+ if info.kitty_placeholders or tmux:
363
+ payload = _g.kitty_sequence(png, ident, cols, rows,
364
+ placeholder=True, tmux=tmux)
365
+ mark_key = _g.register(payload, cols, rows, 'placeholder',
366
+ ident, tmux)
367
+ lines = _g.kitty_placeholder_rows(ident, cols, rows)
368
+ lines[0] = _g.mark(mark_key) + lines[0]
369
+ self._cache = {key: (mark_key, lines)}
370
+ return list(lines)
371
+ payload = _g.kitty_sequence(png, ident)
372
+ kind = 'kitty'
373
+ elif method == 'iterm':
374
+ payload = _g.iterm_sequence(_img.encode_png(scaled), dw, dh,
375
+ name=self.name)
376
+ kind = 'iterm'
377
+ ident = None
378
+ else:
379
+ colors = min(255, info.sixel_colors or 255)
380
+ payload = _g.sixel_sequence(scaled, colors)
381
+ if tmux:
382
+ payload = payload # tmux 3.4 draws sixel itself
383
+ kind = 'sixel'
384
+ ident = None
385
+ mark_key = _g.register(payload, cols, rows, kind, ident, False)
386
+ lines = [' ' * cols for _ in range(rows)]
387
+ lines[-1] = _g.mark(mark_key) + lines[-1]
388
+ self._cache = {key: (mark_key, lines)}
389
+ return list(lines)
390
+
391
+ # -- input ---------------------------------------------------------------
392
+
393
+ def open(self):
394
+ """Open the image in the browser; warns instead when impossible."""
395
+ if self.on_open is not None:
396
+ return self.on_open(self)
397
+ url = self.url
398
+ if not url:
399
+ self._warn('sin archivo ni URL que abrir')
400
+ return False
401
+ ok, how = _g.open_external(url)
402
+ if ok:
403
+ self._tell('Abriendo {0} en el navegador'.format(self.name),
404
+ 'info')
405
+ else:
406
+ self._warn('no hay navegador ni pantalla para abrir {0} ({1})'
407
+ .format(self.name, how))
408
+ return ok
409
+
410
+ def _tell(self, text, kind):
411
+ if self.app is not None:
412
+ self.app.notify(text, kind)
413
+ self.app.announce(text)
414
+
415
+ def _warn(self, text):
416
+ self._tell(_a11y.status_symbol('warning') + ' ' + text, 'warning')
417
+
418
+ def on_key(self, key):
419
+ if str(key) in ('enter', 'o'):
420
+ self.open()
421
+ return True
422
+ return False
423
+
424
+ def on_mouse(self, event):
425
+ if event.action == 'press' and event.button == 0:
426
+ self.open()
427
+ return True
428
+ return False
429
+
430
+
431
+ class ImageViewer(_Themed):
432
+ """
433
+ Several images, one at a time: Left/Right (or PgUp/PgDn) change image,
434
+ ``f`` cycles the fit (contain, cover, none), Enter or ``o`` opens the
435
+ current one in the browser. ``images`` are sources or ``(source, alt)``
436
+ pairs.
437
+ """
438
+
439
+ role = 'img'
440
+ focusable = True
441
+ needs_graphics = True
442
+
443
+ def __init__(self, images, index=0, fit='contain', method=None,
444
+ **kwargs):
445
+ super(ImageViewer, self).__init__(**kwargs)
446
+ self.items = []
447
+ for item in images:
448
+ src, alt = item if isinstance(item, tuple) else (item, None)
449
+ self.items.append(Image(src, alt=alt, fit=fit, method=method))
450
+ self.index = max(0, min(index, len(self.items) - 1))
451
+
452
+ @property
453
+ def current(self):
454
+ return self.items[self.index] if self.items else None
455
+
456
+ def a11y_name(self):
457
+ return self.a11y_label or 'Visor de imágenes'
458
+
459
+ def a11y_value(self):
460
+ cur = self.current
461
+ if cur is None:
462
+ return 'sin imágenes'
463
+ return '{0} de {1}: {2}'.format(self.index + 1, len(self.items),
464
+ cur.alt or cur.name)
465
+
466
+ def natural_width(self):
467
+ return 60
468
+
469
+ def children(self):
470
+ return []
471
+
472
+ def draw(self, width, height):
473
+ cur = self.current
474
+ if cur is None:
475
+ return [self.paint('Sin imágenes', self.theme.muted)]
476
+ body_h = (height - 1) if height else None
477
+ cur.app = self.app
478
+ body = cur.render(width, body_h).split('\n')
479
+ arrows = ('< ', ' >') if _a11y.settings.ascii else ('◀ ', ' ▶')
480
+ info = '{0}{1} · {2}/{3} · {4}{5}'.format(
481
+ arrows[0], cur.alt or cur.name, self.index + 1, len(self.items),
482
+ cur.fit, arrows[1])
483
+ spec = self.theme.accent if self.focused else self.theme.muted
484
+ if self.focused and self._marked():
485
+ info = '> ' + info
486
+ self.hot(len(body), self._prev, 0, 2)
487
+ self.hot(len(body), self._next, max(0, width - 2), None)
488
+ return body + [_fx.fit_line(self.paint(info, spec), width, 'c')]
489
+
490
+ def _prev(self):
491
+ if self.items:
492
+ self.index = (self.index - 1) % len(self.items)
493
+ self.announce(self.a11y_value())
494
+
495
+ def _next(self):
496
+ if self.items:
497
+ self.index = (self.index + 1) % len(self.items)
498
+ self.announce(self.a11y_value())
499
+
500
+ def on_key(self, key):
501
+ name = str(key)
502
+ if name in ('left', 'pageup'):
503
+ self._prev()
504
+ elif name in ('right', 'pagedown'):
505
+ self._next()
506
+ elif name == 'f' and self.current is not None:
507
+ order = ('contain', 'cover', 'none')
508
+ cur = self.current
509
+ cur.fit = order[(order.index(cur.fit) + 1) % 3] \
510
+ if cur.fit in order else 'contain'
511
+ cur._cache = {}
512
+ elif name in ('enter', 'o') and self.current is not None:
513
+ self.current.open()
514
+ else:
515
+ return False
516
+ self.refresh()
517
+ return True
518
+
519
+
520
+ def _rgb(color):
521
+ if isinstance(color, (tuple, list)):
522
+ return tuple(int(c) for c in color[:3])
523
+ text = str(color).lstrip('#')
524
+ if len(text) == 3:
525
+ text = ''.join(c * 2 for c in text)
526
+ return int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16)
527
+
528
+
529
+ def _wrap(text, width):
530
+ from flexablelib.text_width import wrap_to_width
531
+ return wrap_to_width(text, max(1, width)) or ['']
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexablelib-components
3
- Version: 3.4.0
3
+ Version: 3.5.0
4
4
  Summary: Interactive terminal components for flexablelib: inputs, dates, tables, forms, menus, tabs, dialogs and more.
5
5
  Home-page: https://github.com/Opus-Perpetuus/flexablelib
6
6
  Author: Benjamin Ramirez
@@ -17,8 +17,8 @@ Classifier: Topic :: Software Development :: User Interfaces
17
17
  Requires-Python: >=3.8
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
- Requires-Dist: flexablelib>=3.4.0
21
- Requires-Dist: flexablelib-tui>=3.4.0
20
+ Requires-Dist: flexablelib>=3.5.0
21
+ Requires-Dist: flexablelib-tui>=3.5.0
22
22
  Dynamic: author
23
23
  Dynamic: author-email
24
24
  Dynamic: classifier
@@ -8,6 +8,7 @@ flexablelib_components/data.py
8
8
  flexablelib_components/dates.py
9
9
  flexablelib_components/display.py
10
10
  flexablelib_components/inputs.py
11
+ flexablelib_components/media.py
11
12
  flexablelib_components/navigation.py
12
13
  flexablelib_components/overlays.py
13
14
  flexablelib_components/py.typed
@@ -0,0 +1,2 @@
1
+ flexablelib>=3.5.0
2
+ flexablelib-tui>=3.5.0
@@ -1,2 +0,0 @@
1
- flexablelib>=3.4.0
2
- flexablelib-tui>=3.4.0