flexablelib-tui 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.
- {flexablelib_tui-3.3.1/flexablelib_tui.egg-info → flexablelib_tui-3.5.0}/PKG-INFO +2 -2
- {flexablelib_tui-3.3.1 → flexablelib_tui-3.5.0}/flexablelib_tui/__init__.py +12 -4
- flexablelib_tui-3.5.0/flexablelib_tui/accessibility.py +566 -0
- flexablelib_tui-3.5.0/flexablelib_tui/app.py +1234 -0
- flexablelib_tui-3.5.0/flexablelib_tui/capabilities.py +262 -0
- flexablelib_tui-3.5.0/flexablelib_tui/graphics.py +865 -0
- flexablelib_tui-3.5.0/flexablelib_tui/keymap.py +137 -0
- flexablelib_tui-3.5.0/flexablelib_tui/keys.py +832 -0
- {flexablelib_tui-3.3.1 → flexablelib_tui-3.5.0}/flexablelib_tui/screen.py +18 -3
- {flexablelib_tui-3.3.1 → flexablelib_tui-3.5.0}/flexablelib_tui/terminal.py +163 -26
- flexablelib_tui-3.5.0/flexablelib_tui/wincon.py +447 -0
- {flexablelib_tui-3.3.1 → flexablelib_tui-3.5.0/flexablelib_tui.egg-info}/PKG-INFO +2 -2
- {flexablelib_tui-3.3.1 → flexablelib_tui-3.5.0}/flexablelib_tui.egg-info/SOURCES.txt +5 -0
- flexablelib_tui-3.5.0/flexablelib_tui.egg-info/requires.txt +1 -0
- flexablelib_tui-3.3.1/flexablelib_tui/app.py +0 -640
- flexablelib_tui-3.3.1/flexablelib_tui/keys.py +0 -340
- flexablelib_tui-3.3.1/flexablelib_tui.egg-info/requires.txt +0 -1
- {flexablelib_tui-3.3.1 → flexablelib_tui-3.5.0}/LICENSE +0 -0
- {flexablelib_tui-3.3.1 → flexablelib_tui-3.5.0}/MANIFEST.in +0 -0
- {flexablelib_tui-3.3.1 → flexablelib_tui-3.5.0}/README.md +0 -0
- {flexablelib_tui-3.3.1 → flexablelib_tui-3.5.0}/flexablelib_tui/py.typed +0 -0
- {flexablelib_tui-3.3.1 → flexablelib_tui-3.5.0}/flexablelib_tui/widgets.py +0 -0
- {flexablelib_tui-3.3.1 → flexablelib_tui-3.5.0}/flexablelib_tui.egg-info/dependency_links.txt +0 -0
- {flexablelib_tui-3.3.1 → flexablelib_tui-3.5.0}/flexablelib_tui.egg-info/top_level.txt +0 -0
- {flexablelib_tui-3.3.1 → flexablelib_tui-3.5.0}/setup.cfg +0 -0
- {flexablelib_tui-3.3.1 → flexablelib_tui-3.5.0}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flexablelib-tui
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.5.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.
|
|
20
|
+
Requires-Dist: flexablelib>=3.5.0
|
|
21
21
|
Dynamic: author
|
|
22
22
|
Dynamic: author-email
|
|
23
23
|
Dynamic: classifier
|
|
@@ -12,16 +12,24 @@ Everything here is also reachable as ``flexablelib.ProgressBar``,
|
|
|
12
12
|
``flexablelib.App`` and so on once installed.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
-
__version__ = '3.
|
|
15
|
+
__version__ = '3.5.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
|
|
26
|
+
from . import graphics
|
|
27
|
+
from .graphics import GraphicsInfo, detect as detect_graphics
|
|
23
28
|
|
|
24
29
|
__all__ = ['ProgressBar', 'Sparkline', 'BarChart', 'StatusBar', 'KeyHints',
|
|
25
30
|
'LogView', 'Text', 'Rule', 'Screen', 'Live', 'render_height',
|
|
26
|
-
'Key', '
|
|
27
|
-
'
|
|
31
|
+
'Key', 'MouseEvent', 'KeyParser', 'WindowsKeyParser', 'parse_keys',
|
|
32
|
+
'normalize_key', 'key_label', 'KeyMap', 'Capabilities',
|
|
33
|
+
'detect_capabilities', 'Terminal',
|
|
34
|
+
'App', 'Component', 'ScrollView', 'overlay', 'graphics',
|
|
35
|
+
'GraphicsInfo', 'detect_graphics', '__version__']
|
|
@@ -0,0 +1,566 @@
|
|
|
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 alt text' if info.get(
|
|
548
|
+
'role') == 'img' else 'no label'))
|
|
549
|
+
if check_focus and getattr(comp, 'focusable', False):
|
|
550
|
+
for colors in (False, True):
|
|
551
|
+
saved = comp.use_colors
|
|
552
|
+
comp.use_colors = colors
|
|
553
|
+
was = comp.focused
|
|
554
|
+
try:
|
|
555
|
+
comp.focused = False
|
|
556
|
+
off = plain_style_signature(comp.render(width))
|
|
557
|
+
comp.focused = True
|
|
558
|
+
on = plain_style_signature(comp.render(width))
|
|
559
|
+
finally:
|
|
560
|
+
comp.focused = was
|
|
561
|
+
comp.use_colors = saved
|
|
562
|
+
if off == on:
|
|
563
|
+
issues.append(AuditIssue(
|
|
564
|
+
comp, 'focus', 'focus not visible ({0})'.format(
|
|
565
|
+
'colour' if colors else 'no colour')))
|
|
566
|
+
return issues
|