flexablelib-tui 3.4.0__tar.gz → 3.5.1__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 (24) hide show
  1. {flexablelib_tui-3.4.0/flexablelib_tui.egg-info → flexablelib_tui-3.5.1}/PKG-INFO +2 -2
  2. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/flexablelib_tui/__init__.py +5 -2
  3. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/flexablelib_tui/accessibility.py +2 -1
  4. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/flexablelib_tui/app.py +11 -2
  5. flexablelib_tui-3.5.1/flexablelib_tui/graphics.py +865 -0
  6. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/flexablelib_tui/screen.py +18 -3
  7. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1/flexablelib_tui.egg-info}/PKG-INFO +2 -2
  8. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/flexablelib_tui.egg-info/SOURCES.txt +1 -0
  9. flexablelib_tui-3.5.1/flexablelib_tui.egg-info/requires.txt +1 -0
  10. flexablelib_tui-3.4.0/flexablelib_tui.egg-info/requires.txt +0 -1
  11. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/LICENSE +0 -0
  12. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/MANIFEST.in +0 -0
  13. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/README.md +0 -0
  14. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/flexablelib_tui/capabilities.py +0 -0
  15. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/flexablelib_tui/keymap.py +0 -0
  16. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/flexablelib_tui/keys.py +0 -0
  17. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/flexablelib_tui/py.typed +0 -0
  18. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/flexablelib_tui/terminal.py +0 -0
  19. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/flexablelib_tui/widgets.py +0 -0
  20. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/flexablelib_tui/wincon.py +0 -0
  21. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/flexablelib_tui.egg-info/dependency_links.txt +0 -0
  22. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/flexablelib_tui.egg-info/top_level.txt +0 -0
  23. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/setup.cfg +0 -0
  24. {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.1}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexablelib-tui
3
- Version: 3.4.0
3
+ Version: 3.5.1
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.4.0
20
+ Requires-Dist: flexablelib>=3.5.1
21
21
  Dynamic: author
22
22
  Dynamic: author-email
23
23
  Dynamic: classifier
@@ -12,7 +12,7 @@ Everything here is also reachable as ``flexablelib.ProgressBar``,
12
12
  ``flexablelib.App`` and so on once installed.
13
13
  """
14
14
 
15
- __version__ = '3.4.0'
15
+ __version__ = '3.5.1'
16
16
 
17
17
  from .widgets import (ProgressBar, Sparkline, BarChart, StatusBar, KeyHints,
18
18
  LogView, Text, Rule)
@@ -23,10 +23,13 @@ from .keymap import KeyMap
23
23
  from .capabilities import Capabilities, detect as detect_capabilities
24
24
  from .terminal import Terminal
25
25
  from .app import App, Component, ScrollView, overlay
26
+ from . import graphics
27
+ from .graphics import GraphicsInfo, detect as detect_graphics
26
28
 
27
29
  __all__ = ['ProgressBar', 'Sparkline', 'BarChart', 'StatusBar', 'KeyHints',
28
30
  'LogView', 'Text', 'Rule', 'Screen', 'Live', 'render_height',
29
31
  'Key', 'MouseEvent', 'KeyParser', 'WindowsKeyParser', 'parse_keys',
30
32
  'normalize_key', 'key_label', 'KeyMap', 'Capabilities',
31
33
  'detect_capabilities', 'Terminal',
32
- 'App', 'Component', 'ScrollView', 'overlay', '__version__']
34
+ 'App', 'Component', 'ScrollView', 'overlay', 'graphics',
35
+ 'GraphicsInfo', 'detect_graphics', '__version__']
@@ -544,7 +544,8 @@ def audit(components, width=40, check_focus=True):
544
544
  if not info.get('role'):
545
545
  issues.append(AuditIssue(comp, 'role', 'no role'))
546
546
  if not (info.get('label') or '').strip():
547
- issues.append(AuditIssue(comp, 'label', 'no label'))
547
+ issues.append(AuditIssue(comp, 'label', 'no alt text' if info.get(
548
+ 'role') == 'img' else 'no label'))
548
549
  if check_focus and getattr(comp, 'focusable', False):
549
550
  for colors in (False, True):
550
551
  saved = comp.use_colors
@@ -30,7 +30,8 @@ import time
30
30
 
31
31
  from flexablelib import _frames as _fx
32
32
  from flexablelib.colors import RESET, parse_spec, supports_color
33
- from flexablelib.layout import Grid, GridCell, _render_child, render_width
33
+ from flexablelib.layout import (Card, Grid, GridCell, _render_child,
34
+ render_width)
34
35
 
35
36
  from . import accessibility as _a11y
36
37
  from .keys import Key, MouseEvent, key_label
@@ -197,7 +198,7 @@ class Component(object):
197
198
  return '\n'.join(lines)
198
199
 
199
200
  def __str__(self):
200
- return self.render()
201
+ return _fx.expand_graphics(self.render())
201
202
 
202
203
  def children(self):
203
204
  """Components (or layouts holding components) inside this one."""
@@ -267,6 +268,10 @@ def _child_nodes(node):
267
268
  return list(node.children)
268
269
  if isinstance(node, GridCell):
269
270
  return [node.child]
271
+ if isinstance(node, Card):
272
+ body = node.body
273
+ return [body] if body is not None and not isinstance(body, str) \
274
+ else []
270
275
  if isinstance(node, App):
271
276
  return [node.screen] + list(node.layers)
272
277
  return []
@@ -936,6 +941,10 @@ class App(object):
936
941
  self.running = True
937
942
  next_tick = time.monotonic() + (self.interval or 0)
938
943
  with terminal:
944
+ if any(getattr(c, 'needs_graphics', False)
945
+ for c in self.components()):
946
+ from . import graphics as _graphics
947
+ _graphics.probe_terminal(terminal)
939
948
  caps = getattr(terminal, 'capabilities', None)
940
949
  if 'ascii' not in self.a11y_settings and caps is not None and \
941
950
  caps.unicode is False and not _a11y.settings.ascii:
@@ -0,0 +1,865 @@
1
+ """
2
+ Pictures in the terminal.
3
+
4
+ Detects what the terminal can show and turns pictures into escape
5
+ sequences, best method first:
6
+
7
+ * **kitty graphics protocol** -- kitty, Ghostty, WezTerm, Warp; with
8
+ Unicode placeholders (kitty 0.28+, Ghostty) the picture is ordinary text
9
+ that layouts, scrolling and tmux (``allow-passthrough on``) handle.
10
+ * **iTerm2 inline images** (OSC 1337) -- iTerm2, WezTerm, VS Code, Konsole,
11
+ mintty, rio.
12
+ * **sixel** -- xterm (``-ti vt340``), foot, mlterm, contour, Windows
13
+ Terminal 1.22+, Konsole, tmux 3.4+ built with sixel.
14
+ * Text: Unicode half blocks, quadrants, sextants or braille in truecolor,
15
+ 256 or 16 colours (or mono), then ASCII art.
16
+ * A link (OSC 8) that opens the picture in the browser, and with no
17
+ browser or display a warning instead.
18
+
19
+ Detection: environment variables first (:func:`guess`), then -- when both
20
+ standard streams are terminals -- one short query (:func:`query`,
21
+ :func:`probe_terminal`): kitty graphics query, cell size in pixels
22
+ (``CSI 16 t`` / ``CSI 14 t`` and ``TIOCGWINSZ``), sixel colour registers
23
+ (XTSMGRAPHICS), XTVERSION and DA1 (attribute 4 = sixel). The result is
24
+ cached. ``FLEXABLELIB_IMAGE=kitty|iterm|sixel|blocks|half|quadrant|sextant|
25
+ braille|ascii|link|none`` forces a method.
26
+
27
+ Frames never carry the (large, width-less) picture sequences: a picture is
28
+ blank cells plus a zero-width mark (``ESC ] 8971 ; id BEL``) that
29
+ :func:`expand` replaces when the frame is written, so widths, layouts and
30
+ frame diffs keep working; :func:`render_diff` redraws a picture whenever a
31
+ line it covers changes and deletes kitty images that left the screen.
32
+ """
33
+
34
+ import base64
35
+ import os
36
+ import re
37
+ import sys
38
+ import time
39
+ import zlib
40
+ from collections import OrderedDict
41
+
42
+ from flexablelib import _frames as _fx
43
+ from flexablelib import _images as _img
44
+
45
+ __all__ = ['GraphicsInfo', 'METHODS', 'TEXT_MODES', 'guess', 'query',
46
+ 'probe_terminal', 'detect', 'reset_cache', 'choose',
47
+ 'apply_responses', 'cell_size', 'kitty_sequence', 'kitty_delete',
48
+ 'kitty_placeholder_rows', 'iterm_sequence', 'sixel_sequence',
49
+ 'tmux_wrap', 'register', 'mark', 'expand', 'marks_in', 'cleanup',
50
+ 'render_diff', 'file_url', 'hyperlink', 'browser_available',
51
+ 'open_external', 'GRAPHICS_QUERY', 'DIACRITICS', 'PLACEHOLDER']
52
+
53
+ METHODS = ('kitty', 'iterm', 'sixel', 'half', 'quadrant', 'sextant',
54
+ 'braille', 'ascii', 'link', 'none')
55
+ TEXT_MODES = ('half', 'quadrant', 'sextant', 'braille', 'ascii')
56
+ MARK = _fx.GRAPHICS_MARK
57
+ _MARK_RE = re.compile(re.escape(MARK) + r'([0-9a-f]+)\x07')
58
+ #: kitty graphics query, cell size, window size, sixel colour registers.
59
+ GRAPHICS_QUERY = ('\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\'
60
+ '\x1b[16t\x1b[14t\x1b[?1;1;0S')
61
+ DEFAULT_CELL = (10, 20)
62
+ _TRUE = ('1', 'true', 'yes', 'on')
63
+
64
+ # Row/column diacritics of the kitty Unicode placeholder (U+10EEEE).
65
+ _DIACRITIC_RANGES = (
66
+ (0x305, 0x305), (0x30D, 0x30E), (0x310, 0x310), (0x312, 0x312),
67
+ (0x33D, 0x33F), (0x346, 0x346), (0x34A, 0x34C), (0x350, 0x352),
68
+ (0x357, 0x357), (0x35B, 0x35B), (0x363, 0x36F), (0x483, 0x487),
69
+ (0x592, 0x595), (0x597, 0x599), (0x59C, 0x5A1), (0x5A8, 0x5A9),
70
+ (0x5AB, 0x5AC), (0x5AF, 0x5AF), (0x5C4, 0x5C4), (0x610, 0x617),
71
+ (0x657, 0x65B), (0x65D, 0x65E), (0x6D6, 0x6DC), (0x6DF, 0x6E2),
72
+ (0x6E4, 0x6E4), (0x6E7, 0x6E8), (0x6EB, 0x6EC), (0x730, 0x730),
73
+ (0x732, 0x733), (0x735, 0x736), (0x73A, 0x73A), (0x73D, 0x73D),
74
+ (0x73F, 0x741), (0x743, 0x743), (0x745, 0x745), (0x747, 0x747),
75
+ (0x749, 0x74A), (0x7EB, 0x7F1), (0x7F3, 0x7F3), (0x816, 0x819),
76
+ (0x81B, 0x823), (0x825, 0x827), (0x829, 0x82D), (0x951, 0x951),
77
+ (0x953, 0x954), (0xF82, 0xF83), (0xF86, 0xF87), (0x135D, 0x135F),
78
+ (0x17DD, 0x17DD), (0x193A, 0x193A), (0x1A17, 0x1A17), (0x1A75, 0x1A7C),
79
+ (0x1B6B, 0x1B6B), (0x1B6D, 0x1B73), (0x1CD0, 0x1CD2), (0x1CDA, 0x1CDB),
80
+ (0x1CE0, 0x1CE0), (0x1DC0, 0x1DC1), (0x1DC3, 0x1DC9), (0x1DCB, 0x1DCC),
81
+ (0x1DD1, 0x1DE6), (0x1DFE, 0x1DFE), (0x20D0, 0x20D1), (0x20D4, 0x20D7),
82
+ (0x20DB, 0x20DC), (0x20E1, 0x20E1), (0x20E7, 0x20E7), (0x20E9, 0x20E9),
83
+ (0x20F0, 0x20F0), (0x2CEF, 0x2CF1), (0x2DE0, 0x2DFF), (0xA66F, 0xA66F),
84
+ (0xA67C, 0xA67D), (0xA6F0, 0xA6F1), (0xA8E0, 0xA8F1), (0xAAB0, 0xAAB0),
85
+ (0xAAB2, 0xAAB3), (0xAAB7, 0xAAB8), (0xAABE, 0xAABF), (0xAAC1, 0xAAC1),
86
+ (0xFE20, 0xFE26), (0x10A0F, 0x10A0F), (0x10A38, 0x10A38),
87
+ (0x1D185, 0x1D189), (0x1D1AA, 0x1D1AD), (0x1D242, 0x1D244),)
88
+
89
+
90
+ def _diacritics():
91
+ out = []
92
+ for start, end in _DIACRITIC_RANGES:
93
+ out.extend(chr(c) for c in range(start, end + 1))
94
+ return out
95
+
96
+
97
+ DIACRITICS = _diacritics()
98
+ PLACEHOLDER = chr(0x10EEEE)
99
+
100
+
101
+ # +---------------------------------------------------------------------------+
102
+ # Capabilities
103
+
104
+ class GraphicsInfo(object):
105
+ """What the terminal can show. ``order`` lists the pixel protocols to
106
+ try, best first; ``reasons`` explains each decision."""
107
+
108
+ FIELDS = ('kitty', 'kitty_placeholders', 'iterm', 'sixel',
109
+ 'sixel_colors', 'cell', 'cell_known', 'color', 'unicode',
110
+ 'terminal', 'version', 'tmux', 'passthrough', 'probed', 'tty',
111
+ 'dumb', 'order', 'reasons')
112
+
113
+ def __init__(self, **values):
114
+ for name in self.FIELDS:
115
+ setattr(self, name, values.get(name))
116
+ if self.order is None:
117
+ self.order = []
118
+ if self.reasons is None:
119
+ self.reasons = []
120
+ if self.cell is None:
121
+ self.cell = DEFAULT_CELL
122
+
123
+ @property
124
+ def pixel_methods(self):
125
+ """The pixel protocols this terminal accepts, best first."""
126
+ found = [m for m in self.order if getattr(self, m)]
127
+ for m in ('kitty', 'iterm', 'sixel'):
128
+ if getattr(self, m) and m not in found:
129
+ found.append(m)
130
+ return found
131
+
132
+ def best(self):
133
+ """The method :func:`choose` would pick without overrides."""
134
+ return choose(self)[0]
135
+
136
+ def as_dict(self):
137
+ return dict((k, getattr(self, k)) for k in self.FIELDS)
138
+
139
+ def summary(self):
140
+ def show(v):
141
+ return '?' if v is None else 'sí' if v is True else \
142
+ 'no' if v is False else str(v)
143
+ rows = [('Terminal', self.version or self.terminal),
144
+ ('kitty graphics', self.kitty),
145
+ (' marcadores Unicode', self.kitty_placeholders),
146
+ ('iTerm2 inline', self.iterm), ('sixel', self.sixel),
147
+ ('Celda (px)', '{0}×{1}{2}'.format(
148
+ self.cell[0], self.cell[1],
149
+ '' if self.cell_known else ' (supuesto)')),
150
+ ('Colores', self.color), ('Unicode', self.unicode),
151
+ ('tmux', self.tmux), ('Método', self.best())]
152
+ return ['{0}: {1}'.format(k, show(v)) for k, v in rows]
153
+
154
+ def __repr__(self):
155
+ return 'GraphicsInfo({0})'.format(', '.join(
156
+ '{0}={1!r}'.format(k, v) for k, v in self.as_dict().items()
157
+ if v not in (None, [], ())))
158
+
159
+
160
+ def _version_tuple(text):
161
+ nums = re.findall(r'\d+', text or '')
162
+ return tuple(int(n) for n in nums[:3])
163
+
164
+
165
+ def _tmux_client(env):
166
+ """(termname, features, passthrough) of the tmux client, or None."""
167
+ if not env.get('TMUX'):
168
+ return None
169
+ import shutil
170
+ import subprocess
171
+ if not shutil.which('tmux'):
172
+ return ('', '', False)
173
+ try:
174
+ out = subprocess.run(
175
+ ['tmux', 'display-message', '-p',
176
+ '#{client_termname}|#{client_termfeatures}|#{version}'],
177
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=1,
178
+ universal_newlines=True).stdout.strip()
179
+ passthrough = subprocess.run(
180
+ ['tmux', 'show', '-gv', 'allow-passthrough'],
181
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=1,
182
+ universal_newlines=True).stdout.strip()
183
+ except (OSError, subprocess.SubprocessError):
184
+ return ('', '', False)
185
+ parts = (out.split('|') + ['', '', ''])[:3]
186
+ return (parts[0], parts[1] + ' tmux=' + parts[2],
187
+ passthrough in ('on', 'all'))
188
+
189
+
190
+ def guess(env=None, platform=None, tmux=None):
191
+ """:class:`GraphicsInfo` from the environment alone (no output)."""
192
+ from . import capabilities
193
+ env = dict(os.environ) if env is None else env
194
+ platform = os.name if platform is None else platform
195
+ term = env.get('TERM', '').lower()
196
+ program = env.get('TERM_PROGRAM', '').lower()
197
+ info = GraphicsInfo(color=capabilities.color_level(env, True),
198
+ unicode=capabilities.unicode_ok(env, 'utf-8'),
199
+ terminal=env.get('TERM_PROGRAM') or env.get('TERM'),
200
+ dumb=term == 'dumb', probed=False)
201
+ why = info.reasons
202
+ if info.dumb or term == 'linux':
203
+ info.kitty = info.iterm = info.sixel = False
204
+ why.append('TERM={0}: sin gráficos'.format(term))
205
+ return info
206
+ if 'kitty' in term or env.get('KITTY_WINDOW_ID'):
207
+ info.kitty = info.kitty_placeholders = True
208
+ info.terminal = 'kitty'
209
+ why.append('kitty (TERM / KITTY_WINDOW_ID)')
210
+ if program == 'ghostty' or 'ghostty' in term or \
211
+ env.get('GHOSTTY_RESOURCES_DIR'):
212
+ info.kitty = info.kitty_placeholders = True
213
+ info.terminal = 'ghostty'
214
+ why.append('Ghostty')
215
+ if program == 'wezterm' or env.get('WEZTERM_EXECUTABLE') or \
216
+ env.get('WEZTERM_PANE'):
217
+ info.iterm = True
218
+ info.kitty = True
219
+ info.sixel = True
220
+ info.order = ['iterm', 'kitty', 'sixel']
221
+ info.terminal = 'WezTerm'
222
+ why.append('WezTerm: iTerm2, kitty y sixel')
223
+ if program == 'iterm.app':
224
+ info.iterm = True
225
+ info.sixel = True
226
+ info.order = ['iterm', 'sixel']
227
+ info.terminal = 'iTerm2'
228
+ why.append('iTerm2')
229
+ if program == 'vscode':
230
+ info.iterm = True
231
+ info.sixel = True
232
+ info.order = ['iterm', 'sixel']
233
+ info.terminal = 'VS Code'
234
+ why.append('VS Code (con terminal.integrated.enableImages)')
235
+ if program == 'mintty' or env.get('MINTTY_SHORTCUT'):
236
+ info.iterm = info.sixel = True
237
+ info.order = ['iterm', 'sixel']
238
+ info.terminal = 'mintty'
239
+ why.append('mintty')
240
+ if env.get('KONSOLE_VERSION'):
241
+ version = _version_tuple(env.get('KONSOLE_VERSION'))
242
+ ok = not version or version[0] >= 220400 or version[0] >= 22
243
+ info.iterm = info.sixel = ok
244
+ info.order = ['iterm', 'sixel']
245
+ info.terminal = 'Konsole'
246
+ why.append('Konsole {0}'.format(env.get('KONSOLE_VERSION')))
247
+ if program == 'warpterminal':
248
+ info.kitty = True
249
+ info.terminal = 'Warp'
250
+ why.append('Warp (kitty)')
251
+ if program == 'rio':
252
+ info.iterm = info.sixel = True
253
+ info.order = ['iterm', 'sixel']
254
+ info.terminal = 'rio'
255
+ if term.startswith(('foot', 'mlterm', 'contour', 'yaft')) or \
256
+ program in ('contour',):
257
+ info.sixel = True
258
+ why.append('{0}: sixel'.format(term or program))
259
+ if program == 'apple_terminal':
260
+ info.kitty = info.iterm = info.sixel = False
261
+ info.terminal = 'Terminal.app'
262
+ why.append('Terminal.app: sin protocolos de imagen')
263
+ if env.get('WT_SESSION') and info.sixel is None:
264
+ why.append('Windows Terminal: sixel solo si DA1 lo anuncia (1.22+)')
265
+ if platform == 'nt' and info.kitty:
266
+ info.kitty = False
267
+ why.append('Windows: ConPTY no deja pasar APC (kitty)')
268
+ if term.startswith('screen') or env.get('STY'):
269
+ info.kitty = info.iterm = info.sixel = False
270
+ why.append('GNU screen: sin gráficos')
271
+ client = _tmux_client(env) if tmux is None else tmux
272
+ if client is not None:
273
+ name, features, passthrough = client
274
+ info.tmux = name or True
275
+ info.passthrough = passthrough
276
+ info.iterm = False
277
+ inner_kitty = 'kitty' in name or 'ghostty' in name
278
+ info.kitty = bool(inner_kitty and passthrough)
279
+ info.kitty_placeholders = info.kitty
280
+ info.sixel = 'sixel' in features
281
+ info.order = ['kitty', 'sixel']
282
+ why.append('tmux (cliente {0!r}{1})'.format(
283
+ name, ', passthrough' if passthrough else ''))
284
+ if not info.order:
285
+ info.order = ['kitty', 'iterm', 'sixel']
286
+ return info
287
+
288
+
289
+ def apply_responses(info, keys):
290
+ """Update ``info`` from parsed ``'response'`` keys; True once DA1 seen."""
291
+ done = False
292
+ for key in keys:
293
+ if str(key) != 'response' or not key.data:
294
+ continue
295
+ kind, body = key.data[0], key.data[1]
296
+ if kind == 'apc' and body.startswith('Gi=31'):
297
+ info.kitty = body.endswith('OK')
298
+ info.reasons.append('consulta kitty: {0}'.format(
299
+ 'OK' if info.kitty else body))
300
+ elif kind == 'window':
301
+ parts = body.split(';')
302
+ if len(parts) == 3 and all(p.isdigit() for p in parts):
303
+ code, h, w = (int(p) for p in parts)
304
+ if code == 6 and w > 0 and h > 0:
305
+ info.cell = (w, h)
306
+ info.cell_known = True
307
+ elif code == 4 and w > 0 and h > 0 and not info.cell_known:
308
+ info._window_px = (w, h)
309
+ elif kind == 'csi?S':
310
+ parts = body.split(';')
311
+ if len(parts) >= 3 and parts[0] == '1' and parts[1] == '0' \
312
+ and parts[2].isdigit():
313
+ info.sixel_colors = int(parts[2])
314
+ elif kind == 'dcs' and body.startswith('>|'):
315
+ info.version = body[2:]
316
+ low = info.version.lower()
317
+ if low.startswith('kitty'):
318
+ info.kitty_placeholders = _version_tuple(low) >= (0, 28)
319
+ elif 'ghostty' in low:
320
+ info.kitty_placeholders = True
321
+ elif 'wezterm' in low:
322
+ info.iterm = True
323
+ elif 'iterm2' in low:
324
+ info.iterm = True
325
+ elif kind == 'da1':
326
+ attrs = body.split(';')
327
+ if info.sixel is None or '4' in attrs:
328
+ info.sixel = '4' in attrs
329
+ if '4' in attrs:
330
+ info.reasons.append('DA1 anuncia sixel')
331
+ done = True
332
+ return done
333
+
334
+
335
+ def cell_size(stream=None):
336
+ """``(width, height)`` of a cell in pixels from ``TIOCGWINSZ``, or
337
+ ``None`` when the terminal does not report pixels."""
338
+ try:
339
+ import fcntl
340
+ import struct
341
+ import termios
342
+ fd = (stream or sys.stdout).fileno()
343
+ rows, cols, xpix, ypix = struct.unpack('HHHH', fcntl.ioctl(
344
+ fd, termios.TIOCGWINSZ, b'\0' * 8))
345
+ except Exception:
346
+ return None
347
+ if xpix and ypix and cols and rows:
348
+ return max(1, xpix // cols), max(1, ypix // rows)
349
+ return None
350
+
351
+
352
+ def _finish(info):
353
+ if not info.cell_known:
354
+ win = getattr(info, '_window_px', None)
355
+ if win:
356
+ try:
357
+ from flexablelib.utils import get_window_size
358
+ cols, rows = get_window_size()
359
+ info.cell = (max(1, win[0] // cols), max(1, win[1] // rows))
360
+ info.cell_known = True
361
+ except Exception: # pragma: no cover - defensive
362
+ pass
363
+ if info.kitty is False:
364
+ info.kitty_placeholders = False
365
+ return info
366
+
367
+
368
+ def _query_text(info):
369
+ """The query sent to the terminal: kitty graphics, cell and window
370
+ size, sixel colour registers (not asked of kitty, which has no sixel),
371
+ XTVERSION and DA1 last (every terminal answers DA1)."""
372
+ text = GRAPHICS_QUERY
373
+ if info.terminal == 'kitty':
374
+ text = text.replace('\x1b[?1;1;0S', '')
375
+ return text + '\x1b[>0q\x1b[c'
376
+
377
+
378
+ def _query_wanted(info, env):
379
+ if info.dumb or info.tmux:
380
+ return False
381
+ if (env.get('TERM_PROGRAM') or '').lower() == 'apple_terminal':
382
+ return False # prints APC queries as text
383
+ if (env.get('TERM') or '').lower() in ('linux', 'dumb'):
384
+ return False
385
+ return True
386
+
387
+
388
+ def query(info=None, env=None, timeout=0.3, tty_path='/dev/tty'):
389
+ """
390
+ Ask the controlling terminal directly (POSIX, both standard streams on
391
+ a terminal): writes :data:`GRAPHICS_QUERY` plus ``XTVERSION`` and DA1 to
392
+ ``/dev/tty`` in raw mode and reads the answers until DA1 or
393
+ ``timeout``. Returns the updated :class:`GraphicsInfo`.
394
+ """
395
+ env = dict(os.environ) if env is None else env
396
+ info = info if info is not None else guess(env)
397
+ if os.name == 'nt' or not _query_wanted(info, env):
398
+ return info
399
+ try:
400
+ import select
401
+ import termios
402
+ import tty
403
+ fd = os.open(tty_path, os.O_RDWR | os.O_NOCTTY)
404
+ except Exception:
405
+ return info
406
+ from .keys import KeyParser
407
+ try:
408
+ old = termios.tcgetattr(fd)
409
+ except Exception:
410
+ os.close(fd)
411
+ return info
412
+ parser = KeyParser()
413
+ keys = []
414
+ try:
415
+ tty.setraw(fd)
416
+ os.write(fd, _query_text(info).encode())
417
+ deadline = time.monotonic() + timeout
418
+ done = False
419
+ while not done and time.monotonic() < deadline:
420
+ ready, _, _ = select.select([fd], [], [],
421
+ max(0, deadline - time.monotonic()))
422
+ if not ready:
423
+ break
424
+ chunk = os.read(fd, 4096)
425
+ if not chunk:
426
+ break
427
+ new = parser.feed(chunk)
428
+ keys.extend(new)
429
+ done = apply_responses(info, new)
430
+ info.probed = done
431
+ except Exception: # pragma: no cover - defensive
432
+ pass
433
+ finally:
434
+ try:
435
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
436
+ finally:
437
+ os.close(fd)
438
+ return _finish(info)
439
+
440
+
441
+ def probe_terminal(terminal, timeout=0.3, env=None):
442
+ """Ask a started :class:`~flexablelib_tui.Terminal` (used by ``App``);
443
+ keys typed meanwhile are replayed. Stores the result for :func:`detect`."""
444
+ global _CACHE
445
+ env = dict(os.environ) if env is None else env
446
+ info = guess(env)
447
+ if terminal.is_tty() and _query_wanted(info, env):
448
+ terminal.write(_query_text(info))
449
+ deadline = time.monotonic() + timeout
450
+ typed = []
451
+ done = False
452
+ while time.monotonic() < deadline and not done:
453
+ keys = terminal.read_keys(timeout=max(
454
+ 0.0, deadline - time.monotonic()), responses=True)
455
+ typed.extend(k for k in keys if str(k) != 'response')
456
+ done = apply_responses(info, [k for k in keys
457
+ if str(k) == 'response'])
458
+ terminal.replay(typed)
459
+ info.probed = done
460
+ px = cell_size(getattr(terminal, 'stdout', None))
461
+ if px and not info.cell_known:
462
+ info.cell, info.cell_known = px, True
463
+ _CACHE = (_env_key(env), _finish(info))
464
+ return info
465
+
466
+
467
+ _CACHE = None
468
+
469
+
470
+ def _env_key(env):
471
+ return tuple(env.get(k) for k in (
472
+ 'TERM', 'TERM_PROGRAM', 'KITTY_WINDOW_ID', 'WEZTERM_PANE', 'TMUX',
473
+ 'KONSOLE_VERSION', 'WT_SESSION', 'NO_COLOR', 'FORCE_COLOR',
474
+ 'COLORTERM', 'LANG', 'LC_ALL', 'FLEXABLELIB_ASCII'))
475
+
476
+
477
+ def reset_cache():
478
+ global _CACHE
479
+ _CACHE = None
480
+
481
+
482
+ def detect(env=None, probe=None, refresh=False):
483
+ """
484
+ The cached :class:`GraphicsInfo` for this process. ``probe`` (default:
485
+ when both standard streams are terminals) asks the terminal once.
486
+ """
487
+ global _CACHE
488
+ env = dict(os.environ) if env is None else env
489
+ key = _env_key(env)
490
+ if not refresh and _CACHE is not None and _CACHE[0] == key:
491
+ return _CACHE[1]
492
+ info = guess(env)
493
+ info.tty = _isatty(sys.stdout) and _isatty(sys.stdin)
494
+ if probe is None:
495
+ probe = info.tty
496
+ if probe:
497
+ info = query(info, env)
498
+ px = cell_size()
499
+ if px and not info.cell_known:
500
+ info.cell, info.cell_known = px, True
501
+ _CACHE = (key, _finish(info))
502
+ return info
503
+
504
+
505
+ def _isatty(stream):
506
+ try:
507
+ return bool(stream.isatty())
508
+ except Exception:
509
+ return False
510
+
511
+
512
+ def choose(info, force=None, env=None, screen_reader=None, ascii=None,
513
+ color=None, blocks=None):
514
+ """
515
+ ``(method, text_mode, reason)`` for showing a picture. ``method`` is
516
+ one of ``'kitty'``, ``'iterm'``, ``'sixel'``, ``'text'`` (blocks or
517
+ ASCII, with ``text_mode``), ``'link'``, ``'alt'`` (screen readers,
518
+ ``TERM=dumb``) or ``'none'``.
519
+ """
520
+ from . import accessibility as _a11y
521
+ env = os.environ if env is None else env
522
+ force = (force or env.get('FLEXABLELIB_IMAGE') or '').strip().lower()
523
+ if force in ('auto',):
524
+ force = ''
525
+ reader = _a11y.settings.screen_reader if screen_reader is None \
526
+ else screen_reader
527
+ use_ascii = _a11y.settings.ascii if ascii is None else ascii
528
+ if color is None:
529
+ color = info.color or 'truecolor'
530
+ unicode_ok = info.unicode is not False and not use_ascii
531
+ if reader or info.dumb:
532
+ return 'alt', None, 'lector de pantalla o TERM=dumb: texto alternativo'
533
+ if force == 'none':
534
+ return 'none', None, 'FLEXABLELIB_IMAGE=none'
535
+ if force == 'link':
536
+ return 'link', None, 'enlace pedido'
537
+ if force in ('kitty', 'iterm', 'sixel'):
538
+ return force, None, 'método pedido'
539
+ if force in TEXT_MODES or force == 'blocks':
540
+ mode = (blocks or 'half') if force == 'blocks' else force
541
+ if not unicode_ok and mode != 'ascii':
542
+ mode = 'ascii'
543
+ return 'text', mode, 'método pedido'
544
+ if not use_ascii:
545
+ for method in info.pixel_methods:
546
+ return method, None, info.reasons[-1] if info.reasons else method
547
+ if not unicode_ok:
548
+ return 'text', 'ascii', 'sin Unicode: ASCII'
549
+ if color == 'none':
550
+ return 'text', 'braille', 'sin color: braille'
551
+ return 'text', blocks or 'half', 'bloques Unicode'
552
+
553
+
554
+ # +---------------------------------------------------------------------------+
555
+ # Escape sequences
556
+
557
+ def tmux_wrap(seq):
558
+ """``seq`` inside a tmux passthrough (``allow-passthrough on``)."""
559
+ return '\x1bPtmux;' + seq.replace('\x1b', '\x1b\x1b') + '\x1b\\'
560
+
561
+
562
+ def kitty_sequence(png, image_id, cols=None, rows=None, placeholder=False,
563
+ tmux=False, chunk=4096):
564
+ """Transmit-and-show APC sequence(s) for PNG bytes. With
565
+ ``placeholder`` the image becomes a virtual placement shown wherever
566
+ :func:`kitty_placeholder_rows` text is drawn."""
567
+ data = base64.standard_b64encode(png).decode('ascii')
568
+ keys = ['a=T', 'f=100', 'i={0}'.format(image_id), 'q=2']
569
+ if placeholder:
570
+ keys.append('U=1')
571
+ else:
572
+ keys += ['p=1', 'C=1']
573
+ if cols:
574
+ keys.append('c={0}'.format(cols))
575
+ if rows:
576
+ keys.append('r={0}'.format(rows))
577
+ out = []
578
+ parts = [data[i:i + chunk] for i in range(0, len(data), chunk)] or ['']
579
+ for n, part in enumerate(parts):
580
+ more = 1 if n < len(parts) - 1 else 0
581
+ head = ','.join(keys) + ',' if n == 0 else ''
582
+ seq = '\x1b_G{0}m={1};{2}\x1b\\'.format(head, more, part)
583
+ out.append(tmux_wrap(seq) if tmux else seq)
584
+ return ''.join(out)
585
+
586
+
587
+ def kitty_delete(image_id, tmux=False):
588
+ seq = '\x1b_Ga=d,d=I,i={0},q=2\x1b\\'.format(image_id)
589
+ return tmux_wrap(seq) if tmux else seq
590
+
591
+
592
+ def kitty_placeholder_rows(image_id, cols, rows):
593
+ """The text that shows a placeholder image: one string per row."""
594
+ fg = '\x1b[38;2;{0};{1};{2}m'.format((image_id >> 16) & 255,
595
+ (image_id >> 8) & 255,
596
+ image_id & 255)
597
+ lines = []
598
+ for r in range(rows):
599
+ cells = ''.join(PLACEHOLDER + DIACRITICS[r] + DIACRITICS[c]
600
+ for c in range(cols))
601
+ lines.append(fg + cells + '\x1b[39m')
602
+ return lines
603
+
604
+
605
+ def iterm_sequence(data, width_px=None, height_px=None, name=None,
606
+ cols=None, rows=None):
607
+ """OSC 1337 inline image for encoded image bytes (PNG, JPEG, GIF)."""
608
+ args = ['inline=1', 'size={0}'.format(len(data))]
609
+ if name:
610
+ args.append('name=' + base64.b64encode(name.encode()).decode())
611
+ if width_px:
612
+ args.append('width={0}px'.format(width_px))
613
+ elif cols:
614
+ args.append('width={0}'.format(cols))
615
+ if height_px:
616
+ args.append('height={0}px'.format(height_px))
617
+ elif rows:
618
+ args.append('height={0}'.format(rows))
619
+ args.append('preserveAspectRatio=1')
620
+ return '\x1b]1337;File={0}:{1}\x07'.format(
621
+ ';'.join(args), base64.b64encode(data).decode('ascii'))
622
+
623
+
624
+ def sixel_sequence(bitmap, colors=255):
625
+ """DCS sixel for a :class:`~flexablelib._images.Bitmap` (transparent
626
+ where alpha < 128)."""
627
+ palette, indices = _img.quantize(bitmap.data, bitmap.width,
628
+ bitmap.height, colors, 128)
629
+ return _img.sixel_encode(indices, bitmap.width, bitmap.height, palette)
630
+
631
+
632
+ # +---------------------------------------------------------------------------+
633
+ # Marks: pictures inside plain-text frames
634
+
635
+ _PAYLOADS = OrderedDict()
636
+ _MAX_PAYLOADS = 64
637
+
638
+
639
+ def register(payload, cols, rows, kind, image_id=None, tmux=False):
640
+ """Store a picture's escape sequence; returns its mark id (hex)."""
641
+ key = '{0:x}'.format(zlib.crc32(payload.encode('utf-8', 'replace')) ^
642
+ (cols << 20) ^ (rows << 8) ^
643
+ zlib.crc32(kind.encode()))
644
+ _PAYLOADS[key] = (payload, cols, rows, kind, image_id, tmux)
645
+ _PAYLOADS.move_to_end(key)
646
+ while len(_PAYLOADS) > _MAX_PAYLOADS:
647
+ _PAYLOADS.popitem(last=False)
648
+ return key
649
+
650
+
651
+ def mark(key):
652
+ """The zero-width mark that stands for registered picture ``key``."""
653
+ return MARK + key + '\x07'
654
+
655
+
656
+ def _skip_columns(text, start, cols):
657
+ """Index in ``text`` after ``cols`` visible columns from ``start``,
658
+ keeping escape sequences out of the count; plus the escapes skipped."""
659
+ i = start
660
+ kept = []
661
+ n = len(text)
662
+ while i < n and cols > 0:
663
+ if text[i] == '\x1b':
664
+ m = _fx_seq(text, i)
665
+ if m:
666
+ kept.append(text[i:i + m])
667
+ i += m
668
+ continue
669
+ w = _fx.visible_width(text[i])
670
+ cols -= w
671
+ i += 1
672
+ return i, ''.join(kept)
673
+
674
+
675
+ def _fx_seq(text, i):
676
+ from flexablelib.text_width import _ANSI_RE
677
+ m = _ANSI_RE.match(text, i)
678
+ return m.end() - i if m else 0
679
+
680
+
681
+ def _payload_for(key):
682
+ return _PAYLOADS.get(key)
683
+
684
+
685
+ def expand(text):
686
+ """``text`` with every picture mark replaced by its escape sequence:
687
+ kitty placeholders get their image transmitted; cursor-placed pictures
688
+ (kitty, iTerm2, sixel) are drawn from the top-left cell of their area
689
+ with the cursor saved and restored, and the blank cells under the
690
+ mark are skipped (cursor forward) instead of written over the
691
+ picture. Unknown marks are dropped."""
692
+ if MARK not in text:
693
+ return text
694
+ out = []
695
+ pos = 0
696
+ for m in _MARK_RE.finditer(text):
697
+ if m.start() < pos:
698
+ continue
699
+ out.append(text[pos:m.start()])
700
+ entry = _payload_for(m.group(1))
701
+ pos = m.end()
702
+ if entry is None:
703
+ continue
704
+ payload, cols, rows, kind = entry[:4]
705
+ if kind == 'placeholder':
706
+ out.append(payload)
707
+ continue
708
+ up = '\x1b[{0}A'.format(rows - 1) if rows > 1 else ''
709
+ end, escapes = _skip_columns(text, pos, cols)
710
+ out.append('\x1b7' + up + payload + '\x1b8' + escapes +
711
+ '\x1b[{0}C'.format(cols))
712
+ pos = end
713
+ out.append(text[pos:])
714
+ return ''.join(out)
715
+
716
+
717
+ def marks_in(lines):
718
+ """``[(row, key, rows, kind)]`` for the picture marks in ``lines``."""
719
+ found = []
720
+ for i, line in enumerate(lines):
721
+ if MARK in line:
722
+ for m in _MARK_RE.finditer(line):
723
+ entry = _PAYLOADS.get(m.group(1))
724
+ rows = entry[2] if entry else 1
725
+ kind = entry[3] if entry else ''
726
+ found.append((i, m.group(1), rows, kind))
727
+ return found
728
+
729
+
730
+ def cleanup(old_lines, new_lines):
731
+ """Escape sequences that delete kitty images no longer on screen."""
732
+ old = set(k for _, k, _, kind in marks_in(old_lines or [])
733
+ if kind in ('placeholder', 'kitty'))
734
+ new = set(k for _, k, _, _ in marks_in(new_lines))
735
+ out = []
736
+ for key in sorted(old - new):
737
+ entry = _PAYLOADS.get(key)
738
+ if entry and entry[4]:
739
+ out.append(kitty_delete(entry[4], entry[5]))
740
+ return ''.join(out)
741
+
742
+
743
+ def render_diff(old, new):
744
+ """Like :func:`flexablelib._frames.render_diff`, picture-aware: a
745
+ picture is redrawn whenever any line it covers is rewritten, and marks
746
+ are expanded."""
747
+ changed = set(_fx.changed_lines(old, new))
748
+ for row, _key, rows, kind in marks_in(new):
749
+ if kind != 'placeholder' and any(
750
+ r in changed for r in range(max(0, row - rows + 1), row + 1)):
751
+ changed.add(row)
752
+ parts = [cleanup(old, new)]
753
+ for i in sorted(changed):
754
+ parts.append('\x1b[{0};1H{1}\x1b[K'.format(i + 1, expand(new[i])))
755
+ if len(new) < len(old):
756
+ parts.append('\x1b[{0};1H\x1b[J'.format(len(new) + 1))
757
+ return ''.join(parts)
758
+
759
+
760
+ _fx.set_graphics_expander(expand)
761
+
762
+
763
+ # +---------------------------------------------------------------------------+
764
+ # Browsers
765
+
766
+ def file_url(path):
767
+ """``file://`` URL for a local path (or the URL itself)."""
768
+ text = os.fspath(path)
769
+ if '://' in text:
770
+ return text
771
+ from pathlib import Path
772
+ return Path(os.path.abspath(text)).as_uri()
773
+
774
+
775
+ def hyperlink(url, text):
776
+ """OSC 8 hyperlink."""
777
+ return '\x1b]8;;{0}\x1b\\{1}\x1b]8;;\x1b\\'.format(url, text)
778
+
779
+
780
+ def _wsl(env):
781
+ if env.get('WSL_DISTRO_NAME'):
782
+ return True
783
+ try:
784
+ with open('/proc/version') as handle:
785
+ return 'microsoft' in handle.read().lower()
786
+ except OSError:
787
+ return False
788
+
789
+
790
+ def browser_available(env=None, platform=None, which=None):
791
+ """
792
+ ``(available, how_or_reason)``: whether a graphical browser can open a
793
+ link here. ``BROWSER`` is trusted; Windows always can; macOS needs
794
+ ``open`` and no bare SSH session; Linux and the BSDs need a display
795
+ (``DISPLAY`` / ``WAYLAND_DISPLAY``) and ``xdg-open``, ``gio`` or a
796
+ browser; WSL uses ``wslview`` / ``explorer.exe``.
797
+ ``FLEXABLELIB_NO_BROWSER=1`` says no.
798
+ """
799
+ import shutil
800
+ env = os.environ if env is None else env
801
+ which = which or shutil.which
802
+ platform = sys.platform if platform is None else platform
803
+ if (env.get('FLEXABLELIB_NO_BROWSER') or '').lower() in _TRUE:
804
+ return False, 'FLEXABLELIB_NO_BROWSER'
805
+ if env.get('BROWSER'):
806
+ return True, 'BROWSER'
807
+ if platform.startswith('win'):
808
+ return True, 'start'
809
+ if platform == 'darwin':
810
+ if (env.get('SSH_CONNECTION') or env.get('SSH_TTY')) and \
811
+ not env.get('DISPLAY'):
812
+ return False, 'sesión SSH sin pantalla'
813
+ return (True, 'open') if which('open') else (False, 'sin open')
814
+ if _wsl(env):
815
+ for tool in ('wslview', 'explorer.exe'):
816
+ if which(tool):
817
+ return True, tool
818
+ if not (env.get('DISPLAY') or env.get('WAYLAND_DISPLAY')):
819
+ return False, 'sin pantalla (DISPLAY/WAYLAND_DISPLAY) ni BROWSER'
820
+ for tool in ('xdg-open', 'gio', 'firefox', 'chromium', 'google-chrome',
821
+ 'chromium-browser', 'brave-browser', 'epiphany'):
822
+ if which(tool):
823
+ return True, tool
824
+ return False, 'no hay navegador instalado'
825
+
826
+
827
+ def open_external(target, env=None, platform=None, which=None, runner=None):
828
+ """
829
+ Open ``target`` (path or URL) in the browser, without blocking.
830
+ Returns ``(opened, reason)``; when :func:`browser_available` says no,
831
+ nothing is started.
832
+ """
833
+ import shutil
834
+ import subprocess
835
+ env = os.environ if env is None else env
836
+ platform = sys.platform if platform is None else platform
837
+ ok, how = browser_available(env, platform, which)
838
+ if not ok:
839
+ return False, how
840
+ url = file_url(target)
841
+ custom = runner is not None
842
+ runner = runner or (lambda cmd: subprocess.Popen(
843
+ cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
844
+ stderr=subprocess.DEVNULL, start_new_session=True))
845
+ try:
846
+ if how == 'BROWSER':
847
+ cmd = env['BROWSER'].split(os.pathsep)[0]
848
+ parts = cmd.split()
849
+ if '%s' in cmd:
850
+ parts = [p.replace('%s', url) for p in parts]
851
+ else:
852
+ parts.append(url)
853
+ runner(parts)
854
+ elif how == 'start':
855
+ if custom or not hasattr(os, 'startfile'):
856
+ runner(['cmd', '/c', 'start', '', url])
857
+ else: # pragma: no cover - Windows only
858
+ os.startfile(url)
859
+ elif how == 'gio':
860
+ runner(['gio', 'open', url])
861
+ else:
862
+ runner([shutil.which(how) or how, url])
863
+ except Exception as error:
864
+ return False, str(error)
865
+ return True, how
@@ -158,6 +158,7 @@ class Live(object):
158
158
  self.frames = 0
159
159
  self._last = None
160
160
  self._started = False
161
+ self._pictures = False
161
162
 
162
163
  @property
163
164
  def is_terminal(self):
@@ -215,10 +216,13 @@ class Live(object):
215
216
  if self.is_terminal:
216
217
  codes = (CURSOR_SHOW if self.hide_cursor else '') + \
217
218
  (ALT_SCREEN_OFF if self.screen else '')
219
+ if self._pictures and self._lines:
220
+ from . import graphics as _graphics
221
+ codes = _graphics.cleanup(self._lines, []) + codes
218
222
  if codes:
219
223
  self._write(codes)
220
224
  if self.screen and not self.transient and self._last:
221
- self._write(self._last + '\n')
225
+ self._write(_fx.expand_graphics(self._last) + '\n')
222
226
 
223
227
  def update(self, renderable=None):
224
228
  """Swap in ``renderable`` (if given) and redraw; returns the frame."""
@@ -231,17 +235,28 @@ class Live(object):
231
235
  lines = frame.split('\n')
232
236
  size = get_window_size() if self.width is None or \
233
237
  self.height is None else None
238
+ pictures = _fx.GRAPHICS_MARK in frame or (
239
+ self._lines is not None and self._pictures)
234
240
  if self.diff and self._lines is not None and size == self._size:
235
- out = _fx.render_diff(self._lines, lines)
241
+ if pictures:
242
+ from . import graphics as _graphics
243
+ out = _graphics.render_diff(self._lines, lines)
244
+ else:
245
+ out = _fx.render_diff(self._lines, lines)
236
246
  if out:
237
247
  self._write(out)
238
248
  else:
239
249
  body = (CLEAR_LINE + '\n').join(lines)
250
+ if pictures:
251
+ from . import graphics as _graphics
252
+ body = _graphics.cleanup(self._lines, lines) + \
253
+ _graphics.expand(body)
240
254
  self._write(HOME + body + CLEAR_LINE + CLEAR_BELOW)
255
+ self._pictures = _fx.GRAPHICS_MARK in frame
241
256
  self._lines = lines
242
257
  self._size = size
243
258
  else:
244
- self._write(frame + '\n')
259
+ self._write(_fx.expand_graphics(frame) + '\n')
245
260
  self._last = frame
246
261
  self.frames += 1
247
262
  return frame
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexablelib-tui
3
- Version: 3.4.0
3
+ Version: 3.5.1
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.4.0
20
+ Requires-Dist: flexablelib>=3.5.1
21
21
  Dynamic: author
22
22
  Dynamic: author-email
23
23
  Dynamic: classifier
@@ -6,6 +6,7 @@ flexablelib_tui/__init__.py
6
6
  flexablelib_tui/accessibility.py
7
7
  flexablelib_tui/app.py
8
8
  flexablelib_tui/capabilities.py
9
+ flexablelib_tui/graphics.py
9
10
  flexablelib_tui/keymap.py
10
11
  flexablelib_tui/keys.py
11
12
  flexablelib_tui/py.typed
@@ -0,0 +1 @@
1
+ flexablelib>=3.5.1
@@ -1 +0,0 @@
1
- flexablelib>=3.4.0
File without changes