flexablelib-tui 3.3.0__tar.gz → 3.4.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 (25) hide show
  1. {flexablelib_tui-3.3.0/flexablelib_tui.egg-info → flexablelib_tui-3.4.0}/PKG-INFO +2 -2
  2. {flexablelib_tui-3.3.0 → flexablelib_tui-3.4.0}/flexablelib_tui/__init__.py +8 -3
  3. flexablelib_tui-3.4.0/flexablelib_tui/accessibility.py +565 -0
  4. flexablelib_tui-3.4.0/flexablelib_tui/app.py +1225 -0
  5. flexablelib_tui-3.4.0/flexablelib_tui/capabilities.py +262 -0
  6. flexablelib_tui-3.4.0/flexablelib_tui/keymap.py +137 -0
  7. flexablelib_tui-3.4.0/flexablelib_tui/keys.py +832 -0
  8. {flexablelib_tui-3.3.0 → flexablelib_tui-3.4.0}/flexablelib_tui/terminal.py +163 -26
  9. flexablelib_tui-3.4.0/flexablelib_tui/wincon.py +447 -0
  10. {flexablelib_tui-3.3.0 → flexablelib_tui-3.4.0/flexablelib_tui.egg-info}/PKG-INFO +2 -2
  11. {flexablelib_tui-3.3.0 → flexablelib_tui-3.4.0}/flexablelib_tui.egg-info/SOURCES.txt +4 -0
  12. flexablelib_tui-3.4.0/flexablelib_tui.egg-info/requires.txt +1 -0
  13. flexablelib_tui-3.3.0/flexablelib_tui/app.py +0 -640
  14. flexablelib_tui-3.3.0/flexablelib_tui/keys.py +0 -340
  15. flexablelib_tui-3.3.0/flexablelib_tui.egg-info/requires.txt +0 -1
  16. {flexablelib_tui-3.3.0 → flexablelib_tui-3.4.0}/LICENSE +0 -0
  17. {flexablelib_tui-3.3.0 → flexablelib_tui-3.4.0}/MANIFEST.in +0 -0
  18. {flexablelib_tui-3.3.0 → flexablelib_tui-3.4.0}/README.md +0 -0
  19. {flexablelib_tui-3.3.0 → flexablelib_tui-3.4.0}/flexablelib_tui/py.typed +0 -0
  20. {flexablelib_tui-3.3.0 → flexablelib_tui-3.4.0}/flexablelib_tui/screen.py +0 -0
  21. {flexablelib_tui-3.3.0 → flexablelib_tui-3.4.0}/flexablelib_tui/widgets.py +0 -0
  22. {flexablelib_tui-3.3.0 → flexablelib_tui-3.4.0}/flexablelib_tui.egg-info/dependency_links.txt +0 -0
  23. {flexablelib_tui-3.3.0 → flexablelib_tui-3.4.0}/flexablelib_tui.egg-info/top_level.txt +0 -0
  24. {flexablelib_tui-3.3.0 → flexablelib_tui-3.4.0}/setup.cfg +0 -0
  25. {flexablelib_tui-3.3.0 → flexablelib_tui-3.4.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexablelib-tui
3
- Version: 3.3.0
3
+ Version: 3.4.0
4
4
  Summary: Terminal interfaces for flexablelib: widgets, full-screen layout, diff redraw, keyboard input, focus and key bindings.
5
5
  Home-page: https://github.com/Opus-Perpetuus/flexablelib
6
6
  Author: Benjamin Ramirez
@@ -17,7 +17,7 @@ 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.0
20
+ Requires-Dist: flexablelib>=3.4.0
21
21
  Dynamic: author
22
22
  Dynamic: author-email
23
23
  Dynamic: classifier
@@ -12,16 +12,21 @@ Everything here is also reachable as ``flexablelib.ProgressBar``,
12
12
  ``flexablelib.App`` and so on once installed.
13
13
  """
14
14
 
15
- __version__ = '3.3.0'
15
+ __version__ = '3.4.0'
16
16
 
17
17
  from .widgets import (ProgressBar, Sparkline, BarChart, StatusBar, KeyHints,
18
18
  LogView, Text, Rule)
19
19
  from .screen import Screen, Live, render_height
20
- from .keys import Key, KeyParser, WindowsKeyParser, parse_keys
20
+ from .keys import (Key, MouseEvent, KeyParser, WindowsKeyParser, parse_keys,
21
+ normalize_key, key_label)
22
+ from .keymap import KeyMap
23
+ from .capabilities import Capabilities, detect as detect_capabilities
21
24
  from .terminal import Terminal
22
25
  from .app import App, Component, ScrollView, overlay
23
26
 
24
27
  __all__ = ['ProgressBar', 'Sparkline', 'BarChart', 'StatusBar', 'KeyHints',
25
28
  'LogView', 'Text', 'Rule', 'Screen', 'Live', 'render_height',
26
- 'Key', 'KeyParser', 'WindowsKeyParser', 'parse_keys', 'Terminal',
29
+ 'Key', 'MouseEvent', 'KeyParser', 'WindowsKeyParser', 'parse_keys',
30
+ 'normalize_key', 'key_label', 'KeyMap', 'Capabilities',
31
+ 'detect_capabilities', 'Terminal',
27
32
  'App', 'Component', 'ScrollView', 'overlay', '__version__']
@@ -0,0 +1,565 @@
1
+ """
2
+ ACCESSIBILITY
3
+
4
+ Settings and tools that make terminal interfaces usable without a mouse,
5
+ without colour, without Unicode, with motion turned down and with a screen
6
+ reader:
7
+
8
+ - :data:`settings` -- process-wide switches, read from the environment and
9
+ overridden by :class:`~flexablelib_tui.App` arguments:
10
+
11
+ ======================== ============================== ===============
12
+ setting environment effect
13
+ ======================== ============================== ===============
14
+ ``screen_reader`` ``FLEXABLELIB_SCREEN_READER=1`` linear text
15
+ output and
16
+ announcements
17
+ ``reduced_motion`` ``FLEXABLELIB_REDUCED_MOTION=1`` static spinners,
18
+ no animation
19
+ ``ascii`` ``FLEXABLELIB_ASCII=1`` ASCII symbols
20
+ and borders
21
+ ``focus_marker`` ``FLEXABLELIB_FOCUS_MARKER=1`` focus shown with
22
+ text markers in
23
+ colour too
24
+ ``lang`` ``FLEXABLELIB_LANG=en`` role names
25
+ ``theme`` ``FLEXABLELIB_THEME=name`` component theme
26
+ (e.g.
27
+ ``high-contrast``)
28
+ ======================== ============================== ===============
29
+
30
+ ``FLEXABLELIB_A11Y=1`` turns on ``focus_marker`` and ``reduced_motion``.
31
+
32
+ - :func:`describe` -- the sentence a screen reader says for a component
33
+ (label, role, value, state, description, shortcut), from the component's
34
+ :meth:`~flexablelib_tui.Component.a11y` information;
35
+ - :class:`Announcer` -- writes announcements (focus moves, changed values,
36
+ dialogs, notifications) to stdout, stderr, a file or a callback;
37
+ - :func:`contrast_ratio`, :func:`simulate_cvd`, :func:`delta_e` -- WCAG
38
+ contrast and colour-vision-deficiency checks for themes;
39
+ - :func:`ascii_fold` -- frames redrawn with ASCII stand-ins (C-accelerated);
40
+ - :func:`audit` -- automatic checks: every component has a role and a label,
41
+ focus is visible without colour.
42
+ """
43
+
44
+ import contextlib
45
+ import math
46
+ import os
47
+ import sys
48
+
49
+ from flexablelib import _frames as _fx
50
+
51
+ __all__ = ['Settings', 'settings', 'configure', 'describe', 'Announcer',
52
+ 'ROLE_NAMES', 'STATE_NAMES', 'contrast_ratio',
53
+ 'relative_luminance', 'parse_color', 'spec_colors',
54
+ 'simulate_cvd', 'delta_e', 'ascii_fold', 'audit', 'AuditIssue',
55
+ 'STATUS_SYMBOLS', 'status_symbol', 'plain_style_signature']
56
+
57
+ _TRUE = ('1', 'true', 'yes', 'on')
58
+
59
+
60
+ def _flag(env, name):
61
+ value = env.get(name)
62
+ if value is None:
63
+ return None
64
+ return value.strip().lower() in _TRUE
65
+
66
+
67
+ class Settings(object):
68
+ """Accessibility switches (see the module docstring)."""
69
+
70
+ FIELDS = ('screen_reader', 'reduced_motion', 'ascii', 'focus_marker',
71
+ 'lang', 'theme')
72
+ _AS_IS = ('lang', 'theme')
73
+
74
+ def __init__(self, screen_reader=False, reduced_motion=False, ascii=False,
75
+ focus_marker=False, lang='es', theme=None):
76
+ self.theme = theme
77
+ self.screen_reader = bool(screen_reader)
78
+ self.reduced_motion = bool(reduced_motion)
79
+ self.ascii = bool(ascii)
80
+ self.focus_marker = bool(focus_marker)
81
+ self.lang = lang
82
+
83
+ @classmethod
84
+ def from_env(cls, env=None):
85
+ env = os.environ if env is None else env
86
+ a11y = bool(_flag(env, 'FLEXABLELIB_A11Y'))
87
+ values = {
88
+ 'screen_reader': _flag(env, 'FLEXABLELIB_SCREEN_READER'),
89
+ 'reduced_motion': _flag(env, 'FLEXABLELIB_REDUCED_MOTION'),
90
+ 'ascii': _flag(env, 'FLEXABLELIB_ASCII'),
91
+ 'focus_marker': _flag(env, 'FLEXABLELIB_FOCUS_MARKER'),
92
+ }
93
+ out = cls(lang=(env.get('FLEXABLELIB_LANG') or 'es')[:2].lower(),
94
+ theme=env.get('FLEXABLELIB_THEME') or None)
95
+ for name, value in values.items():
96
+ if value is None:
97
+ value = a11y and name in ('reduced_motion', 'focus_marker')
98
+ setattr(out, name, bool(value))
99
+ return out
100
+
101
+ def as_dict(self):
102
+ return dict((name, getattr(self, name)) for name in self.FIELDS)
103
+
104
+ def update(self, **values):
105
+ for name, value in values.items():
106
+ if name not in self.FIELDS:
107
+ raise TypeError('unknown accessibility setting ' + name)
108
+ if value is not None:
109
+ setattr(self, name, value if name in self._AS_IS
110
+ else bool(value))
111
+
112
+ def __repr__(self):
113
+ return 'Settings({0})'.format(', '.join(
114
+ '{0}={1!r}'.format(k, v) for k, v in self.as_dict().items()))
115
+
116
+
117
+ #: The process-wide settings components read.
118
+ settings = Settings.from_env()
119
+
120
+
121
+ @contextlib.contextmanager
122
+ def configure(**values):
123
+ """Change :data:`settings` inside a ``with`` block (tests, previews)."""
124
+ saved = settings.as_dict()
125
+ settings.update(**values)
126
+ try:
127
+ yield settings
128
+ finally:
129
+ for name, value in saved.items():
130
+ setattr(settings, name, value)
131
+
132
+
133
+ # -- descriptions -------------------------------------------------------------
134
+
135
+ ROLE_NAMES = {
136
+ 'es': {
137
+ 'button': 'botón', 'checkbox': 'casilla', 'switch': 'interruptor',
138
+ 'radiogroup': 'grupo de opciones', 'radio': 'opción',
139
+ 'textbox': 'campo de texto', 'password': 'contraseña',
140
+ 'spinbutton': 'campo numérico', 'combobox': 'lista desplegable',
141
+ 'listbox': 'lista', 'menu': 'menú', 'menuitem': 'opción de menú',
142
+ 'tablist': 'pestañas', 'tab': 'pestaña', 'grid': 'tabla',
143
+ 'table': 'tabla', 'tree': 'árbol', 'dialog': 'diálogo',
144
+ 'alertdialog': 'diálogo de alerta', 'alert': 'alerta',
145
+ 'status': 'estado', 'progressbar': 'barra de progreso',
146
+ 'link': 'enlace', 'navigation': 'navegación', 'form': 'formulario',
147
+ 'region': 'región', 'group': 'grupo', 'document': 'documento',
148
+ 'code': 'código', 'img': 'gráfica', 'list': 'lista',
149
+ 'calendar': 'calendario', 'toolbar': 'barra de herramientas',
150
+ 'separator': 'separador', 'scrollbar': 'desplazamiento',
151
+ 'searchbox': 'búsqueda', 'slider': 'control deslizante',
152
+ 'heading': 'encabezado', 'text': 'texto', 'timer': 'temporizador',
153
+ 'feed': 'lista de comentarios', 'figure': 'indicador',
154
+ 'application': 'aplicación', 'banner': 'barra superior',
155
+ 'splitter': 'separador ajustable', 'tooltip': 'información',
156
+ },
157
+ 'en': {
158
+ 'button': 'button', 'checkbox': 'checkbox', 'switch': 'switch',
159
+ 'radiogroup': 'radio group', 'radio': 'radio button',
160
+ 'textbox': 'edit text', 'password': 'password',
161
+ 'spinbutton': 'spin button', 'combobox': 'combo box',
162
+ 'listbox': 'list box', 'menu': 'menu', 'menuitem': 'menu item',
163
+ 'tablist': 'tab list', 'tab': 'tab', 'grid': 'table',
164
+ 'table': 'table', 'tree': 'tree', 'dialog': 'dialog',
165
+ 'alertdialog': 'alert dialog', 'alert': 'alert', 'status': 'status',
166
+ 'progressbar': 'progress bar', 'link': 'link',
167
+ 'navigation': 'navigation', 'form': 'form', 'region': 'region',
168
+ 'group': 'group', 'document': 'document', 'code': 'code',
169
+ 'img': 'chart', 'list': 'list', 'calendar': 'calendar',
170
+ 'toolbar': 'toolbar', 'separator': 'separator',
171
+ 'scrollbar': 'scroll area', 'searchbox': 'search',
172
+ 'slider': 'slider', 'heading': 'heading', 'text': 'text',
173
+ 'timer': 'timer', 'feed': 'comments', 'figure': 'indicator',
174
+ 'application': 'application', 'banner': 'banner',
175
+ 'splitter': 'splitter', 'tooltip': 'tooltip',
176
+ },
177
+ }
178
+
179
+ STATE_NAMES = {
180
+ 'es': {
181
+ 'checked': 'marcada', 'unchecked': 'sin marcar', 'on': 'encendido',
182
+ 'off': 'apagado', 'expanded': 'expandido', 'collapsed': 'contraído',
183
+ 'selected': 'seleccionado', 'disabled': 'no disponible',
184
+ 'required': 'obligatorio', 'invalid': 'con error', 'busy': 'ocupado',
185
+ 'readonly': 'solo lectura', 'focused': 'con foco',
186
+ 'pressed': 'presionado', 'modal': 'modal', 'mixed': 'parcial',
187
+ 'current': 'actual', 'empty': 'vacío', 'hidden': 'oculto',
188
+ },
189
+ 'en': {
190
+ 'checked': 'checked', 'unchecked': 'not checked', 'on': 'on',
191
+ 'off': 'off', 'expanded': 'expanded', 'collapsed': 'collapsed',
192
+ 'selected': 'selected', 'disabled': 'unavailable',
193
+ 'required': 'required', 'invalid': 'invalid entry', 'busy': 'busy',
194
+ 'readonly': 'read only', 'focused': 'focused', 'pressed': 'pressed',
195
+ 'modal': 'modal', 'mixed': 'mixed', 'current': 'current',
196
+ 'empty': 'empty', 'hidden': 'hidden',
197
+ },
198
+ }
199
+
200
+ _WORDS = {
201
+ 'es': {'shortcut': 'atajo', 'error': 'error'},
202
+ 'en': {'shortcut': 'shortcut', 'error': 'error'},
203
+ }
204
+
205
+
206
+ def describe(info, lang=None, focused_state=False):
207
+ """
208
+ The sentence for one component: ``'Guardar, botón'``,
209
+ ``'Nombre, campo de texto, Yael, obligatorio'``.
210
+
211
+ ``info`` is a component (its ``a11y()`` is used) or that dict. The
212
+ ``focused`` state is left out unless ``focused_state``.
213
+ """
214
+ if hasattr(info, 'a11y'):
215
+ info = info.a11y()
216
+ lang = lang or settings.lang
217
+ roles = ROLE_NAMES.get(lang, ROLE_NAMES['es'])
218
+ states = STATE_NAMES.get(lang, STATE_NAMES['es'])
219
+ words = _WORDS.get(lang, _WORDS['es'])
220
+ parts = []
221
+ label = (info.get('label') or '').strip()
222
+ if label:
223
+ parts.append(label)
224
+ role = info.get('role')
225
+ if role:
226
+ parts.append(roles.get(role, role))
227
+ value = info.get('value')
228
+ if value not in (None, ''):
229
+ parts.append(str(value))
230
+ for state in info.get('state') or ():
231
+ if state == 'focused' and not focused_state:
232
+ continue
233
+ parts.append(states.get(state, state))
234
+ text = ', '.join(parts)
235
+ error = info.get('error')
236
+ if error:
237
+ text += '. {0}: {1}'.format(words['error'].capitalize(), error)
238
+ description = (info.get('description') or '').strip()
239
+ if description:
240
+ text += '. ' + description
241
+ shortcut = info.get('shortcut')
242
+ if shortcut:
243
+ text += '. {0}: {1}'.format(words['shortcut'].capitalize(), shortcut)
244
+ return text
245
+
246
+
247
+ class Announcer(object):
248
+ """
249
+ Where announcements go: ``None`` (kept in ``history`` only),
250
+ ``'stdout'``, ``'stderr'``, a file path, an open stream or a callable
251
+ taking the text. Repeating the last announcement is skipped unless
252
+ ``dedupe=False``; ``prefix`` starts every line (``'» '``).
253
+ """
254
+
255
+ def __init__(self, target=None, dedupe=True, prefix=''):
256
+ self.target = target
257
+ self.dedupe = dedupe
258
+ self.prefix = prefix
259
+ self.history = []
260
+
261
+ def _write(self, line):
262
+ target = self.target
263
+ if target is None:
264
+ return
265
+ if callable(target) and not hasattr(target, 'write'):
266
+ target(line)
267
+ return
268
+ if target == 'stdout':
269
+ stream = sys.stdout
270
+ elif target == 'stderr':
271
+ stream = sys.stderr
272
+ elif isinstance(target, str):
273
+ with open(target, 'a', encoding='utf-8') as handle:
274
+ handle.write(self.prefix + line + '\n')
275
+ return
276
+ else:
277
+ stream = target
278
+ try:
279
+ stream.write(self.prefix + line + '\n')
280
+ stream.flush()
281
+ except (AttributeError, ValueError, OSError):
282
+ pass
283
+
284
+ def announce(self, text, priority='polite'):
285
+ """Say ``text``; ``'assertive'`` interrupts (always repeated)."""
286
+ text = ' '.join(_fx.strip_ansi(str(text)).split())
287
+ if not text:
288
+ return False
289
+ if self.dedupe and priority != 'assertive' and self.history and \
290
+ self.history[-1] == text:
291
+ return False
292
+ self.history.append(text)
293
+ self._write(text)
294
+ return True
295
+
296
+ __call__ = announce
297
+
298
+ def clear(self):
299
+ self.history = []
300
+
301
+
302
+ # -- status symbols -----------------------------------------------------------
303
+
304
+ #: Message kinds as symbol + word, so meaning never rests on colour alone.
305
+ STATUS_SYMBOLS = {
306
+ 'info': ('ℹ', 'i', 'Info', 'Info'),
307
+ 'success': ('✔', '+', 'Éxito', 'Success'),
308
+ 'warning': ('⚠', '!', 'Aviso', 'Warning'),
309
+ 'error': ('✖', 'x', 'Error', 'Error'),
310
+ 'neutral': ('•', '*', '', ''),
311
+ }
312
+
313
+
314
+ def status_symbol(kind, ascii=None, word=False, lang=None):
315
+ """The symbol (and with ``word`` its name) for a message kind."""
316
+ entry = STATUS_SYMBOLS.get(kind, STATUS_SYMBOLS['neutral'])
317
+ use_ascii = settings.ascii if ascii is None else ascii
318
+ symbol = entry[1] if use_ascii else entry[0]
319
+ if not word:
320
+ return symbol
321
+ name = entry[2] if (lang or settings.lang) == 'es' else entry[3]
322
+ return (symbol + ' ' + name).strip()
323
+
324
+
325
+ def ascii_fold(text):
326
+ """``text`` with box drawing and symbols as ASCII (same widths)."""
327
+ return _fx.ascii_fold(text)
328
+
329
+
330
+ # -- colour checks ------------------------------------------------------------
331
+
332
+ # xterm's default palette for the 16 named colours.
333
+ _ANSI_RGB = {
334
+ 'black': (0, 0, 0), 'red': (205, 0, 0), 'green': (0, 205, 0),
335
+ 'yellow': (205, 205, 0), 'blue': (0, 0, 238), 'magenta': (205, 0, 205),
336
+ 'cyan': (0, 205, 205), 'white': (229, 229, 229),
337
+ 'bright_black': (127, 127, 127), 'grey': (127, 127, 127),
338
+ 'gray': (127, 127, 127), 'bright_red': (255, 0, 0),
339
+ 'bright_green': (0, 255, 0), 'bright_yellow': (255, 255, 0),
340
+ 'bright_blue': (92, 92, 255), 'bright_magenta': (255, 0, 255),
341
+ 'bright_cyan': (0, 255, 255), 'bright_white': (255, 255, 255),
342
+ }
343
+ _ATTRS = ('bold', 'dim', 'italic', 'underline', 'blink', 'reverse',
344
+ 'hidden', 'strike', 'strikethrough', 'normal', 'faint')
345
+
346
+
347
+ def _xterm_rgb(index):
348
+ if index < 16:
349
+ names = ['black', 'red', 'green', 'yellow', 'blue', 'magenta',
350
+ 'cyan', 'white']
351
+ name = names[index % 8]
352
+ return _ANSI_RGB['bright_' + name if index >= 8 else name]
353
+ if index < 232:
354
+ index -= 16
355
+ steps = (0, 95, 135, 175, 215, 255)
356
+ return (steps[index // 36], steps[(index // 6) % 6], steps[index % 6])
357
+ level = 8 + 10 * (index - 232)
358
+ return (level, level, level)
359
+
360
+
361
+ def parse_color(token):
362
+ """``(r, g, b)`` for ``'#rrggbb'``, ``'#rgb'``, a colour name,
363
+ ``'color:N'`` / ``N`` (256 palette) or ``'greyNN'``; ``None`` if not."""
364
+ if token is None:
365
+ return None
366
+ if isinstance(token, tuple):
367
+ return token
368
+ token = str(token).strip().lower()
369
+ if token.startswith('#'):
370
+ digits = token[1:]
371
+ if len(digits) == 3:
372
+ digits = ''.join(c * 2 for c in digits)
373
+ if len(digits) == 6:
374
+ try:
375
+ return tuple(int(digits[k:k + 2], 16) for k in (0, 2, 4))
376
+ except ValueError:
377
+ return None
378
+ return None
379
+ if token in _ANSI_RGB:
380
+ return _ANSI_RGB[token]
381
+ if token.startswith('color:'):
382
+ token = token[6:]
383
+ if token.isdigit() and int(token) < 256:
384
+ return _xterm_rgb(int(token))
385
+ for prefix in ('grey', 'gray'):
386
+ if token.startswith(prefix) and token[len(prefix):].isdigit():
387
+ level = int(round(int(token[len(prefix):]) * 2.55))
388
+ return (level, level, level)
389
+ return None
390
+
391
+
392
+ def spec_colors(spec):
393
+ """``(fg, bg, attributes)`` of a colour spec, colours as RGB or None."""
394
+ if not spec:
395
+ return None, None, ()
396
+ text = str(spec).strip().lower()
397
+ if text.startswith('on '):
398
+ fg_part, bg_part = '', text[3:]
399
+ else:
400
+ fg_part, _, bg_part = text.partition(' on ')
401
+ fg = None
402
+ attrs = []
403
+ for token in fg_part.split():
404
+ if token in _ATTRS:
405
+ attrs.append(token)
406
+ elif fg is None:
407
+ fg = parse_color(token)
408
+ bg = None
409
+ for token in bg_part.split():
410
+ bg = parse_color(token)
411
+ if bg is not None:
412
+ break
413
+ return fg, bg, tuple(attrs)
414
+
415
+
416
+ def _linear(channel):
417
+ c = channel / 255.0
418
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
419
+
420
+
421
+ def relative_luminance(color):
422
+ """WCAG relative luminance of an RGB colour (0 black .. 1 white)."""
423
+ r, g, b = (_linear(c) for c in parse_color(color))
424
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
425
+
426
+
427
+ def contrast_ratio(fg, bg):
428
+ """WCAG 2 contrast ratio between two colours (1 .. 21)."""
429
+ l1 = relative_luminance(fg)
430
+ l2 = relative_luminance(bg)
431
+ high, low = max(l1, l2), min(l1, l2)
432
+ return (high + 0.05) / (low + 0.05)
433
+
434
+
435
+ # Machado, Oliveira & Fernandes (2009), severity 1.0, in linear RGB.
436
+ _CVD = {
437
+ 'protanopia': ((0.152286, 1.052583, -0.204868),
438
+ (0.114503, 0.786281, 0.099216),
439
+ (-0.003882, -0.048116, 1.051998)),
440
+ 'deuteranopia': ((0.367322, 0.860646, -0.227968),
441
+ (0.280085, 0.672501, 0.047413),
442
+ (-0.011820, 0.042940, 0.968881)),
443
+ 'tritanopia': ((1.255528, -0.076749, -0.178779),
444
+ (-0.078411, 0.930809, 0.147602),
445
+ (0.004733, 0.691367, 0.303900)),
446
+ }
447
+
448
+
449
+ def _gamma(c):
450
+ c = max(0.0, min(1.0, c))
451
+ v = 12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055
452
+ return int(round(v * 255))
453
+
454
+
455
+ def simulate_cvd(color, kind):
456
+ """How ``color`` looks with ``kind`` colour blindness
457
+ (``'protanopia'``, ``'deuteranopia'``, ``'tritanopia'``)."""
458
+ rgb = [_linear(c) for c in parse_color(color)]
459
+ matrix = _CVD[kind]
460
+ return tuple(_gamma(sum(m * c for m, c in zip(row, rgb)))
461
+ for row in matrix)
462
+
463
+
464
+ def _lab(color):
465
+ r, g, b = (_linear(c) for c in parse_color(color))
466
+ x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
467
+ y = (0.2126 * r + 0.7152 * g + 0.0722 * b)
468
+ z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
469
+
470
+ def f(t):
471
+ return t ** (1 / 3.0) if t > 0.008856 else 7.787 * t + 16 / 116.0
472
+ fx, fy, fz = f(x), f(y), f(z)
473
+ return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz))
474
+
475
+
476
+ def delta_e(a, b):
477
+ """CIE76 colour difference (about 2.3 is just noticeable)."""
478
+ la, lb = _lab(a), _lab(b)
479
+ return math.sqrt(sum((p - q) ** 2 for p, q in zip(la, lb)))
480
+
481
+
482
+ # -- audit ----------------------------------------------------------------------
483
+
484
+ class AuditIssue(object):
485
+ def __init__(self, component, check, message):
486
+ self.component = component
487
+ self.check = check
488
+ self.message = message
489
+
490
+ def __repr__(self):
491
+ return 'AuditIssue({0}, {1!r}, {2!r})'.format(
492
+ type(self.component).__name__, self.check, self.message)
493
+
494
+
495
+ _OFF = {21: (1,), 22: (1, 2), 23: (3,), 24: (4,), 25: (5, 6), 27: (7,),
496
+ 28: (8,), 29: (9,)}
497
+
498
+
499
+ def plain_style_signature(text):
500
+ """``text`` with colour codes removed but bold / reverse / underline
501
+ kept: what someone who cannot tell colours apart still sees. Only
502
+ changes of those attributes are written, so a coloured span that ends
503
+ with a reset looks the same as plain text."""
504
+ import re
505
+ state = [frozenset()]
506
+
507
+ def keep(match):
508
+ attrs = set(state[0])
509
+ params = match.group(1).split(';')
510
+ k = 0
511
+ while k < len(params):
512
+ p = params[k]
513
+ if p in ('38', '48'):
514
+ k += 3 if k + 1 < len(params) and params[k + 1] == '5' \
515
+ else 5
516
+ continue
517
+ n = int(p) if p.isdigit() else 0
518
+ if n == 0:
519
+ attrs.clear()
520
+ elif n in _OFF:
521
+ attrs.difference_update(_OFF[n])
522
+ elif 1 <= n <= 9:
523
+ attrs.add(n)
524
+ k += 1
525
+ attrs = frozenset(attrs)
526
+ if attrs == state[0]:
527
+ return ''
528
+ state[0] = attrs
529
+ return '\x1b[' + ';'.join(str(a) for a in sorted(attrs)) + 'm'
530
+ return re.sub(r'\x1b\[([0-9;]*)m', keep, text)
531
+
532
+
533
+ def audit(components, width=40, check_focus=True):
534
+ """
535
+ Accessibility problems of ``components`` (instances): a missing role or
536
+ label, and -- for focusable ones -- focus that only colour shows (the
537
+ focused and unfocused renders must differ once colours are removed;
538
+ bold, reverse and underline count). Returns a list of
539
+ :class:`AuditIssue` (empty when everything passes).
540
+ """
541
+ issues = []
542
+ for comp in components:
543
+ info = comp.a11y()
544
+ if not info.get('role'):
545
+ issues.append(AuditIssue(comp, 'role', 'no role'))
546
+ if not (info.get('label') or '').strip():
547
+ issues.append(AuditIssue(comp, 'label', 'no label'))
548
+ if check_focus and getattr(comp, 'focusable', False):
549
+ for colors in (False, True):
550
+ saved = comp.use_colors
551
+ comp.use_colors = colors
552
+ was = comp.focused
553
+ try:
554
+ comp.focused = False
555
+ off = plain_style_signature(comp.render(width))
556
+ comp.focused = True
557
+ on = plain_style_signature(comp.render(width))
558
+ finally:
559
+ comp.focused = was
560
+ comp.use_colors = saved
561
+ if off == on:
562
+ issues.append(AuditIssue(
563
+ comp, 'focus', 'focus not visible ({0})'.format(
564
+ 'colour' if colors else 'no colour')))
565
+ return issues