flexablelib-tui 3.4.0__tar.gz → 3.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {flexablelib_tui-3.4.0/flexablelib_tui.egg-info → flexablelib_tui-3.5.0}/PKG-INFO +2 -2
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui/__init__.py +5 -2
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui/accessibility.py +2 -1
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui/app.py +11 -2
- flexablelib_tui-3.5.0/flexablelib_tui/graphics.py +865 -0
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui/screen.py +18 -3
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0/flexablelib_tui.egg-info}/PKG-INFO +2 -2
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui.egg-info/SOURCES.txt +1 -0
- flexablelib_tui-3.5.0/flexablelib_tui.egg-info/requires.txt +1 -0
- flexablelib_tui-3.4.0/flexablelib_tui.egg-info/requires.txt +0 -1
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/LICENSE +0 -0
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/MANIFEST.in +0 -0
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/README.md +0 -0
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui/capabilities.py +0 -0
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui/keymap.py +0 -0
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui/keys.py +0 -0
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui/py.typed +0 -0
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui/terminal.py +0 -0
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui/widgets.py +0 -0
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui/wincon.py +0 -0
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui.egg-info/dependency_links.txt +0 -0
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui.egg-info/top_level.txt +0 -0
- {flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/setup.cfg +0 -0
- {flexablelib_tui-3.4.0 → 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,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.
|
|
15
|
+
__version__ = '3.5.0'
|
|
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', '
|
|
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
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flexablelib>=3.5.0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
flexablelib>=3.4.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flexablelib_tui-3.4.0 → flexablelib_tui-3.5.0}/flexablelib_tui.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|