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.
- {flexablelib_components-3.4.0/flexablelib_components.egg-info → flexablelib_components-3.5.0}/PKG-INFO +3 -3
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/__init__.py +5 -3
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/data.py +36 -3
- flexablelib_components-3.5.0/flexablelib_components/media.py +531 -0
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0/flexablelib_components.egg-info}/PKG-INFO +3 -3
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components.egg-info/SOURCES.txt +1 -0
- flexablelib_components-3.5.0/flexablelib_components.egg-info/requires.txt +2 -0
- flexablelib_components-3.4.0/flexablelib_components.egg-info/requires.txt +0 -2
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/LICENSE +0 -0
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/MANIFEST.in +0 -0
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/README.md +0 -0
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/base.py +0 -0
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/dates.py +0 -0
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/display.py +0 -0
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/inputs.py +0 -0
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/navigation.py +0 -0
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/overlays.py +0 -0
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/py.typed +0 -0
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components.egg-info/dependency_links.txt +0 -0
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components.egg-info/top_level.txt +0 -0
- {flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/setup.cfg +0 -0
- {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.
|
|
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.
|
|
21
|
-
Requires-Dist: flexablelib-tui>=3.
|
|
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
|
{flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/__init__.py
RENAMED
|
@@ -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
|
-
|
|
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.
|
|
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
|
]
|
{flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/data.py
RENAMED
|
@@ -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
|
|
1049
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
21
|
-
Requires-Dist: flexablelib-tui>=3.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/base.py
RENAMED
|
File without changes
|
{flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/dates.py
RENAMED
|
File without changes
|
{flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/display.py
RENAMED
|
File without changes
|
{flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/inputs.py
RENAMED
|
File without changes
|
{flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/navigation.py
RENAMED
|
File without changes
|
{flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/overlays.py
RENAMED
|
File without changes
|
{flexablelib_components-3.4.0 → flexablelib_components-3.5.0}/flexablelib_components/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|