flexablelib-components 3.3.1__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.3.1/flexablelib_components.egg-info → flexablelib_components-3.5.0}/PKG-INFO +3 -3
  2. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/flexablelib_components/__init__.py +5 -3
  3. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/flexablelib_components/base.py +239 -10
  4. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/flexablelib_components/data.py +226 -17
  5. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/flexablelib_components/dates.py +143 -3
  6. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/flexablelib_components/display.py +220 -3
  7. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/flexablelib_components/inputs.py +196 -10
  8. flexablelib_components-3.5.0/flexablelib_components/media.py +531 -0
  9. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/flexablelib_components/navigation.py +237 -24
  10. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/flexablelib_components/overlays.py +29 -1
  11. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0/flexablelib_components.egg-info}/PKG-INFO +3 -3
  12. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/flexablelib_components.egg-info/SOURCES.txt +1 -0
  13. flexablelib_components-3.5.0/flexablelib_components.egg-info/requires.txt +2 -0
  14. flexablelib_components-3.3.1/flexablelib_components.egg-info/requires.txt +0 -2
  15. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/LICENSE +0 -0
  16. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/MANIFEST.in +0 -0
  17. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/README.md +0 -0
  18. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/flexablelib_components/py.typed +0 -0
  19. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/flexablelib_components.egg-info/dependency_links.txt +0 -0
  20. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/flexablelib_components.egg-info/top_level.txt +0 -0
  21. {flexablelib_components-3.3.1 → flexablelib_components-3.5.0}/setup.cfg +0 -0
  22. {flexablelib_components-3.3.1 → 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.1
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.3.1
21
- Requires-Dist: flexablelib-tui>=3.3.1
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.3.1'
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
  ]
@@ -7,8 +7,8 @@ from flexablelib import _frames as _fx
7
7
  from flexablelib.colors import RESET, parse_spec
8
8
  from flexablelib_tui import Component
9
9
 
10
- __all__ = ['Theme', 'DEFAULT_THEME', 'Input', 'frame', 'join_columns',
11
- 'Box', 'Button']
10
+ __all__ = ['Theme', 'DEFAULT_THEME', 'THEMES', 'Input', 'frame',
11
+ 'join_columns', 'Box', 'Button']
12
12
 
13
13
 
14
14
  class Theme(object):
@@ -19,12 +19,25 @@ class Theme(object):
19
19
  ``selection`` the highlighted row of a list or table, and ``info`` /
20
20
  ``success`` / ``warning`` / ``error`` the message kinds, ``field`` the
21
21
  background of text fields.
22
+
23
+ ``background`` / ``foreground`` are the terminal colours the theme is
24
+ meant for (used by contrast checks), ``marker=True`` shows focus and
25
+ selection with text markers in colour too. Ready-made themes:
26
+ ``Theme.preset(name)`` with ``'default'``, ``'high-contrast'``,
27
+ ``'high-contrast-light'``, ``'deuteranopia'``, ``'protanopia'``,
28
+ ``'tritanopia'`` and ``'monochrome'`` (see :data:`THEMES`).
22
29
  """
23
30
 
24
31
  def __init__(self, accent='cyan', muted='bright_black', text=None,
25
- selection='black on cyan', border='bright_black',
32
+ selection='bold black on cyan', border='bright_black',
26
33
  info='blue', success='green', warning='yellow',
27
- error='red', title='bold', field='on #262626'):
34
+ error='red', title='bold', field='on #262626',
35
+ background='#000000', foreground='#e5e5e5', marker=False,
36
+ name='custom'):
37
+ self.name = name
38
+ self.background = background
39
+ self.foreground = foreground
40
+ self.marker = bool(marker)
28
41
  self.field = field
29
42
  self.accent = accent
30
43
  self.muted = muted
@@ -41,8 +54,85 @@ class Theme(object):
41
54
  """The colour for a message kind ('info', 'success', ...)."""
42
55
  return getattr(self, name, None) or self.info
43
56
 
57
+ ROLES = ('accent', 'muted', 'text', 'selection', 'border', 'info',
58
+ 'success', 'warning', 'error', 'title', 'field')
59
+
60
+ @classmethod
61
+ def preset(cls, name):
62
+ """A copy of one of :data:`THEMES` (``KeyError`` lists the names)."""
63
+ try:
64
+ values = THEMES[name]
65
+ except KeyError:
66
+ raise KeyError('unknown theme {0!r}; choose from {1}'.format(
67
+ name, ', '.join(sorted(THEMES))))
68
+ return cls(name=name, **values)
69
+
70
+ def contrast(self):
71
+ """``{role: contrast ratio}`` of each role's text against its
72
+ background (the theme's ``background`` when the role sets none)."""
73
+ from flexablelib_tui.accessibility import spec_colors, contrast_ratio
74
+ out = {}
75
+ for role in self.ROLES:
76
+ spec = getattr(self, role)
77
+ fg, bg, attrs = spec_colors(spec)
78
+ if fg is None and bg is None:
79
+ continue
80
+ if 'reverse' in attrs:
81
+ fg, bg = bg, fg
82
+ fg = fg or self.foreground
83
+ bg = bg or self.background
84
+ out[role] = round(contrast_ratio(fg, bg), 2)
85
+ return out
44
86
 
45
- DEFAULT_THEME = Theme()
87
+ def __repr__(self):
88
+ return 'Theme({0!r})'.format(self.name)
89
+
90
+
91
+ #: The ready-made themes (``Theme.preset(name)``). The high-contrast ones
92
+ #: reach WCAG AAA (7:1) for text; the colour-blind ones keep every message
93
+ #: kind distinct under that deficiency (checked with the Machado 2009
94
+ #: simulation) at 7:1 or more on a dark background, and all of them show
95
+ #: focus with text markers as well as colour.
96
+ THEMES = {
97
+ 'default': {},
98
+ 'high-contrast': dict(
99
+ accent='#ffff00', muted='#d0d0d0', text='#ffffff',
100
+ selection='bold black on #ffff00', border='#ffffff', info='#00ffff',
101
+ success='#00ff00', warning='#ffff00', error='#ff6060',
102
+ title='bold #ffffff', field='#ffffff on #1c1c1c',
103
+ background='#000000', foreground='#ffffff', marker=True),
104
+ 'high-contrast-light': dict(
105
+ accent='#0000c0', muted='#404040', text='#000000',
106
+ selection='bold #ffffff on #0000c0', border='#000000', info='#00008b',
107
+ success='#005a00', warning='#6b4000', error='#b00000',
108
+ title='bold #000000', field='#000000 on #ececec',
109
+ background='#ffffff', foreground='#000000', marker=True),
110
+ 'deuteranopia': dict(
111
+ accent='#56b4e9', muted='#c0c0c0', text='#ffffff',
112
+ selection='bold black on #56b4e9', border='#a0a0a0', info='#ffffff',
113
+ success='#56b4e9', warning='#ffb000', error='#ff6e6e',
114
+ title='bold #ffffff', field='#ffffff on #1c1c1c',
115
+ background='#000000', foreground='#ffffff', marker=True),
116
+ 'protanopia': dict(
117
+ accent='#4da6ff', muted='#c0c0c0', text='#ffffff',
118
+ selection='bold black on #4da6ff', border='#a0a0a0', info='#ffffff',
119
+ success='#4da6ff', warning='#f0e442', error='#ff6e6e',
120
+ title='bold #ffffff', field='#ffffff on #1c1c1c',
121
+ background='#000000', foreground='#ffffff', marker=True),
122
+ 'tritanopia': dict(
123
+ accent='#00e0ff', muted='#c0c0c0', text='#ffffff',
124
+ selection='bold black on #00e0ff', border='#a0a0a0', info='#a0a0ff',
125
+ success='#00e0ff', warning='#f0e442', error='#ff6e6e',
126
+ title='bold #ffffff', field='#ffffff on #1c1c1c',
127
+ background='#000000', foreground='#ffffff', marker=True),
128
+ 'monochrome': dict(
129
+ accent='bold', muted='dim', text=None, selection='bold reverse',
130
+ border=None, info='bold', success='bold', warning='bold underline',
131
+ error='bold underline', title='bold', field='underline',
132
+ background='#000000', foreground='#e5e5e5', marker=True),
133
+ }
134
+
135
+ DEFAULT_THEME = Theme(name='default')
46
136
 
47
137
  _SPEC_CACHE = {}
48
138
 
@@ -55,12 +145,91 @@ _BOXES = {
55
145
  }
56
146
 
57
147
 
148
+ _PRESETS = {}
149
+
150
+
151
+ def active_theme():
152
+ """The theme for components that did not choose one: the accessibility
153
+ setting ``theme`` (``App(theme=...)`` or ``FLEXABLELIB_THEME``) or
154
+ :data:`DEFAULT_THEME`."""
155
+ from flexablelib_tui import accessibility as _a11y
156
+ choice = _a11y.settings.theme
157
+ if not choice:
158
+ return DEFAULT_THEME
159
+ if isinstance(choice, Theme):
160
+ return choice
161
+ theme = _PRESETS.get(choice)
162
+ if theme is None:
163
+ try:
164
+ theme = Theme.preset(choice)
165
+ except KeyError:
166
+ theme = DEFAULT_THEME
167
+ _PRESETS[choice] = theme
168
+ return theme
169
+
170
+
58
171
  class _Themed(Component):
59
172
  """A component with a theme and cached colour specs."""
60
173
 
61
174
  def __init__(self, theme=None, **kwargs):
62
175
  super(_Themed, self).__init__(**kwargs)
63
- self.theme = theme or DEFAULT_THEME
176
+ self.theme = theme
177
+
178
+ @property
179
+ def theme(self):
180
+ """The component's own theme, else the one the app (``App(theme=)``)
181
+ or ``FLEXABLELIB_THEME`` sets, else :data:`DEFAULT_THEME`."""
182
+ if self._theme is not None:
183
+ return self._theme
184
+ return active_theme()
185
+
186
+ @theme.setter
187
+ def theme(self, value):
188
+ if isinstance(value, str):
189
+ value = Theme.preset(value)
190
+ self._theme = value
191
+
192
+ def _marked(self):
193
+ return Component._marked(self) or getattr(self.theme, 'marker', False)
194
+
195
+ # -- mouse: clickable spots registered while drawing --------------------
196
+
197
+ _hot = ()
198
+ _hot_base = 0
199
+
200
+ def render(self, width=None, height=None):
201
+ self._hot = []
202
+ self._hot_base = 0
203
+ return Component.render(self, width, height)
204
+
205
+ def hot(self, row, action, x0=0, x1=None):
206
+ """Make columns ``[x0, x1)`` of output row ``row`` clickable: a left
207
+ press there calls ``action()`` (``False`` from it means unused)."""
208
+ if isinstance(self._hot, tuple):
209
+ self._hot = []
210
+ self._hot.append((self._hot_base + row, x0, x1, action))
211
+
212
+ def hot_parts(self, row, parts, gap=1, start=0, actions=()):
213
+ """Register ``actions[i]`` for the columns ``parts[i]`` take on
214
+ ``row`` when the parts are joined with ``gap`` columns between them
215
+ (``None`` actions are skipped)."""
216
+ x = start
217
+ for part, action in zip(parts, actions):
218
+ w = _fx.visible_width(_fx.strip_ansi(part))
219
+ if action is not None:
220
+ self.hot(row, action, x, x + w)
221
+ x += w + gap
222
+
223
+ def on_mouse(self, event):
224
+ if event.action != 'press' or event.button != 0:
225
+ return False
226
+ for row, x0, x1, action in reversed(self._hot):
227
+ if row == event.y and event.x >= x0 and \
228
+ (x1 is None or event.x < x1):
229
+ result = action()
230
+ self.refresh()
231
+ return result is not False
232
+ return False
64
233
 
65
234
  def paint(self, text, spec):
66
235
  if not text or not spec or not self._active():
@@ -72,6 +241,17 @@ class _Themed(Component):
72
241
  return text
73
242
  return prefix + text.replace(RESET, RESET + prefix) + RESET
74
243
 
244
+ def mark_current(self, text, spec=None):
245
+ """A highlighted row (the current item of a list): the selection
246
+ colour, and a ``▸`` marker (``>`` without colour) when focus markers
247
+ are on, so it never depends on colour alone."""
248
+ if self._marked() and text:
249
+ text = ('▸' if self._active() else '>') + \
250
+ _fx.slice_columns(text, 1, max(0, _fx.visible_width(text) - 1))
251
+ if self._active():
252
+ text = self.paint(text, spec or self.theme.selection)
253
+ return text
254
+
75
255
  def cursor(self, char):
76
256
  """``char`` drawn as the text cursor."""
77
257
  if not self._active():
@@ -119,6 +299,7 @@ class Box(_Themed):
119
299
  """
120
300
 
121
301
  expand = True
302
+ role = 'region'
122
303
 
123
304
  def __init__(self, child, title=None, style='rounded', padding=1,
124
305
  height=None, footer=None, **kwargs):
@@ -163,7 +344,12 @@ class Box(_Themed):
163
344
  lines = [pad + _fx.fit_line(line, inner_w) + pad for line in lines]
164
345
  focus = self._has_focus()
165
346
  color = self.theme.accent if focus else self.theme.border
166
- return frame(lines, width, self.title, color, self.paint, self.style,
347
+ style, title = self.style, self.title
348
+ if focus and self._marked():
349
+ # Focus without colour: a heavy (or double) frame and a marker.
350
+ style = 'double' if style == 'heavy' else 'heavy'
351
+ title = '▸ ' + (title or '')
352
+ return frame(lines, width, title, color, self.paint, style,
167
353
  title_color=('bold ' + self.theme.accent) if focus
168
354
  else self.theme.title, footer=self.footer)
169
355
 
@@ -259,13 +445,44 @@ class Input(_Themed):
259
445
  mark = '*' if self.required else ''
260
446
  spec = self.theme.accent if self.focused else self.theme.title
261
447
  out.append(self.paint(self.label + mark, spec))
448
+ self._hot_base = len(out)
262
449
  out.extend(self.control(width, height))
450
+ self._hot_base = 0
263
451
  if self.help and not self.error:
264
452
  out.append(self.paint(self.help, self.theme.muted))
265
453
  if self.error:
266
454
  out.append(self.paint('✖ ' + self.error, self.theme.error))
267
455
  return out
268
456
 
457
+ role = 'textbox'
458
+
459
+ def a11y_value(self):
460
+ value = self.value
461
+ if value is None:
462
+ return ''
463
+ if isinstance(value, bool):
464
+ return ''
465
+ return str(value)
466
+
467
+ def a11y_state(self):
468
+ state = []
469
+ if self.required:
470
+ state.append('required')
471
+ if self.error:
472
+ state.append('invalid')
473
+ if self.is_empty() and not isinstance(self.value, bool):
474
+ state.append('empty')
475
+ return state
476
+
477
+ def a11y_name(self):
478
+ if self.a11y_label:
479
+ return str(self.a11y_label)
480
+ for text in (self.label, getattr(self, 'placeholder', None),
481
+ (self.name or '').replace('_', ' ')):
482
+ if isinstance(text, str) and text.strip():
483
+ return text.strip()
484
+ return ''
485
+
269
486
  def marker(self):
270
487
  """The gutter that shows focus: a bar when focused."""
271
488
  if self.focused:
@@ -282,6 +499,7 @@ class Button(_Themed):
282
499
  """
283
500
 
284
501
  focusable = True
502
+ role = 'button'
285
503
 
286
504
  def __init__(self, label, on_press=None, kind='default', shortcut=None,
287
505
  **kwargs):
@@ -319,11 +537,22 @@ class Button(_Themed):
319
537
 
320
538
  def draw(self, width, height):
321
539
  label = self.text()
322
- if not self._active() and self.focused:
323
- label = '[>' + label[2:-2] + '<]'
324
- return [self.paint(label, self.style())]
540
+ if self._marked() and self.focused:
541
+ label = ('[>' + label[2:-2] + '<]') if not self._active() else \
542
+ ('▸' + label[1:-1] + '◂')
543
+ style = self.style()
544
+ if self.hovered and self._active() and not self.disabled:
545
+ style += ' underline'
546
+ return [self.paint(label, style)]
325
547
 
326
548
  def on_key(self, key):
327
549
  if str(key) in ('enter', 'space'):
328
550
  return self.press()
329
551
  return False
552
+
553
+ def on_mouse(self, event):
554
+ if event.action == 'release' and event.button == 0 and \
555
+ not self.disabled and 0 <= event.y < 1 and \
556
+ 0 <= event.x < self.natural_width():
557
+ return self.press()
558
+ return event.action == 'press' and event.button == 0