densitty 0.8.2__py3-none-any.whl
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.
- densitty/__init__.py +0 -0
- densitty/ansi.py +109 -0
- densitty/ascii_art.py +24 -0
- densitty/axis.py +265 -0
- densitty/binning.py +240 -0
- densitty/detect.py +465 -0
- densitty/lineart.py +130 -0
- densitty/plot.py +201 -0
- densitty/truecolor.py +170 -0
- densitty/util.py +234 -0
- densitty-0.8.2.dist-info/METADATA +36 -0
- densitty-0.8.2.dist-info/RECORD +15 -0
- densitty-0.8.2.dist-info/WHEEL +5 -0
- densitty-0.8.2.dist-info/licenses/LICENSE +21 -0
- densitty-0.8.2.dist-info/top_level.txt +1 -0
densitty/detect.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""Utility functions to try to detect terminal/font capabilities"""
|
|
2
|
+
|
|
3
|
+
from enum import auto, Enum, Flag
|
|
4
|
+
from functools import total_ordering
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import sys
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Any, Callable, Optional, Sequence
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from . import ansi, ascii_art, binning, lineart, truecolor
|
|
13
|
+
from . import plot as plotmodule
|
|
14
|
+
from .util import FloatLike, ValueRange
|
|
15
|
+
|
|
16
|
+
if sys.platform == "win32":
|
|
17
|
+
# pylint: disable=import-error
|
|
18
|
+
import ctypes
|
|
19
|
+
from ctypes.windll.kernel32 import GetConsoleMode, GetStdHandle, SetConsoleMode
|
|
20
|
+
else:
|
|
21
|
+
# All other platforms should have TERMIOS available
|
|
22
|
+
import fcntl
|
|
23
|
+
import termios
|
|
24
|
+
|
|
25
|
+
# curses/ncurses isn't always available, so be forgiving if it isn't installed:
|
|
26
|
+
curses: Any
|
|
27
|
+
try:
|
|
28
|
+
import curses
|
|
29
|
+
except ImportError:
|
|
30
|
+
curses = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@total_ordering
|
|
34
|
+
class ColorSupport(Enum):
|
|
35
|
+
"""Varieties of terminal color support"""
|
|
36
|
+
|
|
37
|
+
NONE = auto()
|
|
38
|
+
ANSI_4BIT = auto() # i.e. 16 color palette
|
|
39
|
+
ANSI_8BIT = auto() # i.e. 256 color palette
|
|
40
|
+
ANSI_24BIT = auto() # i.e. "truecolor" RGB with 8 bits of each
|
|
41
|
+
|
|
42
|
+
def __lt__(self, a):
|
|
43
|
+
"""Give @total_ordering a comparison, and we can now use <, <=, >, >="""
|
|
44
|
+
if a.__class__ is self.__class__:
|
|
45
|
+
return self.value < a.value
|
|
46
|
+
return NotImplemented
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GlyphSupport(Flag):
|
|
50
|
+
"""Varieties of terminal/font glyph rendering support"""
|
|
51
|
+
|
|
52
|
+
ASCII = auto() # Just 7-bit ASCII characters: "- | + _ /"
|
|
53
|
+
|
|
54
|
+
BASIC = auto() # Characters included in DOS/WGL4: "┐ └ ┴ ┬ ├ ┤ ─ ┼ ┘ ┌ │"
|
|
55
|
+
|
|
56
|
+
EXTENDED = auto() # Half-lines, bottom/top horiz lines: "╴ ╵ ╶ ╷ ▁ ▔"
|
|
57
|
+
|
|
58
|
+
COMBINING = auto() # Unicode "Combining Low Line" and "Combining Overline" for "│̲ │̅"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ansi_get_cursor_pos() -> tuple[int, int]:
|
|
62
|
+
"""ANSI codes to read current cursor position"""
|
|
63
|
+
# Write ANSI escape "DSR": Device Status Report. Terminal will respond with position
|
|
64
|
+
sys.stdout.write("\x1b[6n")
|
|
65
|
+
sys.stdout.flush()
|
|
66
|
+
response = ""
|
|
67
|
+
for _ in range(1_000_000):
|
|
68
|
+
response += sys.stdin.read(1)
|
|
69
|
+
# Response should be of the form 'ESC[n;mR' where n and m are row/column
|
|
70
|
+
if response.endswith("R"):
|
|
71
|
+
break
|
|
72
|
+
else:
|
|
73
|
+
raise OSError("No ANSI response from terminal")
|
|
74
|
+
try:
|
|
75
|
+
n_str, m_str = response[2:-1].split(";")
|
|
76
|
+
return (int(m_str), int(n_str))
|
|
77
|
+
except ValueError as e:
|
|
78
|
+
raise OSError from e
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if sys.platform == "win32":
|
|
82
|
+
|
|
83
|
+
def get_code_response(
|
|
84
|
+
code: str, response_terminator: Optional[str] = None, length: Optional[int] = None
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Windows-based wrapper to avoid control code output to stdout"""
|
|
87
|
+
prev_stdin_mode = ctypes.wintypes.DWORD(0)
|
|
88
|
+
prev_stdout_mode = ctypes.wintypes.DWORD(0)
|
|
89
|
+
GetConsoleMode(GetStdHandle(-10), ctypes.byref(prev_stdin_mode))
|
|
90
|
+
SetConsoleMode(GetStdHandle(-10), 0)
|
|
91
|
+
GetConsoleMode(GetStdHandle(-11), ctypes.byref(prev_stdout_mode))
|
|
92
|
+
SetConsoleMode(GetStdHandle(-11), 7)
|
|
93
|
+
|
|
94
|
+
# On Windows, don't try to be non-blocking, just read until terminator
|
|
95
|
+
if length is None:
|
|
96
|
+
length = 1000
|
|
97
|
+
if response_terminator is None:
|
|
98
|
+
response_terminator = "NONE"
|
|
99
|
+
response = ""
|
|
100
|
+
try:
|
|
101
|
+
sys.stdout.write(code)
|
|
102
|
+
sys.stdout.flush()
|
|
103
|
+
for _ in range(length):
|
|
104
|
+
response += sys.stdin.read(1)
|
|
105
|
+
if response[-1] == response_terminator:
|
|
106
|
+
break
|
|
107
|
+
return response
|
|
108
|
+
finally:
|
|
109
|
+
SetConsoleMode(GetStdHandle(-10), ctypes.byref(prev_stdin_mode))
|
|
110
|
+
SetConsoleMode(GetStdHandle(-11), ctypes.byref(prev_stdout_mode))
|
|
111
|
+
|
|
112
|
+
else:
|
|
113
|
+
# Not Windows, so use termios/fcntl:
|
|
114
|
+
|
|
115
|
+
def get_code_response(
|
|
116
|
+
code: str, response_terminator: Optional[str] = None, length: Optional[int] = None
|
|
117
|
+
) -> str:
|
|
118
|
+
"""Termios-based wrapper to avoid control code output to stdout"""
|
|
119
|
+
timeout_ms = 100
|
|
120
|
+
prev_attr = termios.tcgetattr(sys.stdin)
|
|
121
|
+
attr = prev_attr[:]
|
|
122
|
+
attr[3] = attr[3] & ~(termios.ECHO | termios.ICANON)
|
|
123
|
+
termios.tcsetattr(sys.stdin, termios.TCSAFLUSH, attr)
|
|
124
|
+
stdin_fd = sys.stdin.fileno()
|
|
125
|
+
prev_fl = fcntl.fcntl(stdin_fd, fcntl.F_GETFL)
|
|
126
|
+
fcntl.fcntl(stdin_fd, fcntl.F_SETFL, prev_fl | os.O_NONBLOCK)
|
|
127
|
+
response = ""
|
|
128
|
+
try:
|
|
129
|
+
sys.stdout.write(code)
|
|
130
|
+
sys.stdout.flush()
|
|
131
|
+
for _ in range(timeout_ms):
|
|
132
|
+
response += sys.stdin.read(1)
|
|
133
|
+
if response_terminator and response.endswith(response_terminator):
|
|
134
|
+
break
|
|
135
|
+
if length and len(response) >= length:
|
|
136
|
+
break
|
|
137
|
+
time.sleep(0.001)
|
|
138
|
+
else:
|
|
139
|
+
# print(len(response))
|
|
140
|
+
# print(list(response))
|
|
141
|
+
raise OSError("Timeout waiting for terminal response")
|
|
142
|
+
return response
|
|
143
|
+
|
|
144
|
+
finally:
|
|
145
|
+
fcntl.fcntl(stdin_fd, fcntl.F_SETFL, prev_fl)
|
|
146
|
+
termios.tcsetattr(sys.stdin, termios.TCSAFLUSH, prev_attr)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_cursor_pos() -> tuple[int, int]:
|
|
150
|
+
"""Use the Device Status Report (DSR) sequence to get the current cursor position"""
|
|
151
|
+
response = get_code_response("\033[6n", response_terminator="R")
|
|
152
|
+
try:
|
|
153
|
+
n_str, m_str = response[2:-1].split(";")
|
|
154
|
+
return (int(m_str), int(n_str))
|
|
155
|
+
except ValueError as e:
|
|
156
|
+
raise OSError from e
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Fractional Y axis ticks with a border line will get rendered with Unicode combining characters
|
|
160
|
+
# This function will indicate if the terminal will actually attempt to combine characters,
|
|
161
|
+
# although a return value of True doesn't necessarily mean that they will be rendered _well_.
|
|
162
|
+
def combining_support(debug=False) -> bool:
|
|
163
|
+
"""Detect support for combining unicode characters by seeing how far cursor advances when
|
|
164
|
+
we output one. See ucs-detect / blessed projects for more full-featured detection"""
|
|
165
|
+
try:
|
|
166
|
+
start_pos = get_cursor_pos()
|
|
167
|
+
print("│" + lineart.COMBINING_OVERLINE, end="")
|
|
168
|
+
end_pos = get_cursor_pos()
|
|
169
|
+
if start_pos[0] + 1 == end_pos[0]:
|
|
170
|
+
print("\b \b", end="") # erase the test char we printed
|
|
171
|
+
if debug:
|
|
172
|
+
print(f"From {start_pos} to {end_pos}")
|
|
173
|
+
return True
|
|
174
|
+
print("\b\b \b\b", end="") # erase the two characters printed
|
|
175
|
+
if debug:
|
|
176
|
+
print(f"From {start_pos} to {end_pos}")
|
|
177
|
+
return False
|
|
178
|
+
except OSError as e:
|
|
179
|
+
if debug:
|
|
180
|
+
print(f"'combining_support' failed: {e}", file=sys.stderr)
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def screen_version(debug=False) -> tuple[int, int, int]:
|
|
185
|
+
"""Use Secondary Device Attributes (DA2) code to find Screen's version triple"""
|
|
186
|
+
try:
|
|
187
|
+
response = get_code_response("\033[>c", response_terminator="c")
|
|
188
|
+
version_str = response.split(";")[1]
|
|
189
|
+
major = int(version_str[0])
|
|
190
|
+
minor = int(version_str[1:3])
|
|
191
|
+
patch = int(version_str[4:])
|
|
192
|
+
return (major, minor, patch)
|
|
193
|
+
except (OSError, ValueError):
|
|
194
|
+
if debug:
|
|
195
|
+
print("Error reading DA2 from 'screen'")
|
|
196
|
+
return (0, 0, 0)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def da1_color_support(debug=False) -> ColorSupport:
|
|
200
|
+
"""Read the terminal's DA1 "Device Attributes" and check for color support"""
|
|
201
|
+
try:
|
|
202
|
+
response = get_code_response("\033[c", response_terminator="c")
|
|
203
|
+
response = response.removesuffix("c").removeprefix("\033[?")
|
|
204
|
+
codes = response.split(";")
|
|
205
|
+
if debug:
|
|
206
|
+
print(f"DA1 codes: {codes}")
|
|
207
|
+
# codes[0]: the terminal's architectural class code
|
|
208
|
+
# codes[1:]: the supported extensions, for class codes of 60+ (VT220+)
|
|
209
|
+
# extension "22" is 4b ANSI color, e.g. VT525
|
|
210
|
+
if codes[0].startswith("6") and len(codes) > 1 and "22" in codes[1:]:
|
|
211
|
+
return ColorSupport.ANSI_4BIT
|
|
212
|
+
return ColorSupport.NONE
|
|
213
|
+
except (OSError, ValueError):
|
|
214
|
+
if debug:
|
|
215
|
+
print("Error reading DA1 from terminal")
|
|
216
|
+
return ColorSupport.NONE
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def color_support(interactive=True, debug=False) -> ColorSupport:
|
|
220
|
+
"""Try to determine the terminal's color support.
|
|
221
|
+
|
|
222
|
+
Parameters
|
|
223
|
+
----------
|
|
224
|
+
interactive : bool
|
|
225
|
+
Send control codes to terminal if needed to query capability/version.
|
|
226
|
+
For a sufficiently dumb terminal, this may produce garbage on the screen.
|
|
227
|
+
debug : bool
|
|
228
|
+
Output feedback to stdout about the determination logic.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
# This logic around the environment variables mostly parallels that in
|
|
232
|
+
# https://github.com/chalk/supports-color, with various additions.
|
|
233
|
+
# I have not tested this code on all of the various platforms/configurations that it purports
|
|
234
|
+
# to detect. Bug reports and fixes are welcome.
|
|
235
|
+
|
|
236
|
+
# pylint: disable=too-many-return-statements
|
|
237
|
+
# pylint: disable=too-many-branches
|
|
238
|
+
# pylint: disable=too-many-statements
|
|
239
|
+
|
|
240
|
+
# With tmux/screen, color support must be present in both the multiplexer and the underlying
|
|
241
|
+
# terminal. So if we see a multiplexer, allow it to set a cap on the allowed colors:
|
|
242
|
+
color_cap = ColorSupport.ANSI_24BIT
|
|
243
|
+
|
|
244
|
+
if "FORCE_COLOR" in os.environ:
|
|
245
|
+
if debug:
|
|
246
|
+
print(f"Color detect: found $FORCE_COLOR: '{os.environ['FORCE_COLOR']}'")
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
force_color = min(int(os.environ["FORCE_COLOR"]), 3)
|
|
250
|
+
except ValueError:
|
|
251
|
+
if debug:
|
|
252
|
+
print(" Unexpected value, treating as 0")
|
|
253
|
+
force_color = 0
|
|
254
|
+
force_color_mapping = {
|
|
255
|
+
0: ColorSupport.NONE,
|
|
256
|
+
1: ColorSupport.ANSI_4BIT,
|
|
257
|
+
2: ColorSupport.ANSI_8BIT,
|
|
258
|
+
3: ColorSupport.ANSI_24BIT,
|
|
259
|
+
}
|
|
260
|
+
return force_color_mapping[force_color]
|
|
261
|
+
|
|
262
|
+
if "COLORTERM" in os.environ:
|
|
263
|
+
colorterm = os.environ["COLORTERM"]
|
|
264
|
+
if debug:
|
|
265
|
+
print(f"Color detect: found $COLORTERM: '{colorterm}'")
|
|
266
|
+
if "truecolor" in colorterm or "24bit" in colorterm:
|
|
267
|
+
return ColorSupport.ANSI_24BIT
|
|
268
|
+
# My understanding is that S-Lang may also just use this env var to indicate that some
|
|
269
|
+
# color support is available. In that case, we just fall through to the other detection
|
|
270
|
+
# mechanisms below
|
|
271
|
+
if debug:
|
|
272
|
+
print("$COLORTERM not matched, continuing")
|
|
273
|
+
|
|
274
|
+
if "TF_BUILD" in os.environ and "AGENT_NAME" in os.environ:
|
|
275
|
+
# Azure DevOps pipelines
|
|
276
|
+
if debug:
|
|
277
|
+
print("Color detect: found $TF_BUILD")
|
|
278
|
+
return ColorSupport.ANSI_4BIT
|
|
279
|
+
|
|
280
|
+
if sys.platform == "win32":
|
|
281
|
+
try:
|
|
282
|
+
if debug:
|
|
283
|
+
print("Color detect: from Windows version")
|
|
284
|
+
version = platform.version().split(".")
|
|
285
|
+
maj_version = int(version[0])
|
|
286
|
+
build = int(version[-1])
|
|
287
|
+
if (maj_version, build) < (10, 10586):
|
|
288
|
+
return ColorSupport.ANSI_4BIT
|
|
289
|
+
if (maj_version, build) < (10, 10586):
|
|
290
|
+
return ColorSupport.ANSI_8BIT
|
|
291
|
+
return ColorSupport.ANSI_24BIT
|
|
292
|
+
except ValueError:
|
|
293
|
+
if debug:
|
|
294
|
+
print(f" Bad platform.version() result: '{platform.version()}'")
|
|
295
|
+
return ColorSupport.ANSI_4BIT
|
|
296
|
+
|
|
297
|
+
if "CI" in os.environ:
|
|
298
|
+
if debug:
|
|
299
|
+
print("Color detect: found $CI")
|
|
300
|
+
if any(x in os.environ for x in ["CIRCLECI", "GITEA_ACTIONS", "GITHUB_ACTIONS"]):
|
|
301
|
+
return ColorSupport.ANSI_24BIT
|
|
302
|
+
if any(x in os.environ for x in ["APPVEYOR", "BUILDKITE", "DRONE", "GITLAB_CI"]):
|
|
303
|
+
return ColorSupport.ANSI_4BIT
|
|
304
|
+
return ColorSupport.NONE
|
|
305
|
+
|
|
306
|
+
env_term = os.environ.get("TERM", "")
|
|
307
|
+
print(f"$TERM is {env_term}")
|
|
308
|
+
|
|
309
|
+
truecolor_terminals = ("truecolor", "xterm-kitty", "xterm-ghostty", "wezterm")
|
|
310
|
+
if env_term in truecolor_terminals:
|
|
311
|
+
if debug:
|
|
312
|
+
print(f"Color detect: from $TERM='{env_term}'")
|
|
313
|
+
return ColorSupport.ANSI_24BIT
|
|
314
|
+
|
|
315
|
+
# Note: Gnu Screen and tmux are weird, in that they are sort of the terminal and process
|
|
316
|
+
# color codes, but the actual color display depends on the terminal that launched them.
|
|
317
|
+
# So their presence can cap the color support, but not provide it.
|
|
318
|
+
# Gnu Screen added 256-color support if built appropriately with v4.2 and later, and
|
|
319
|
+
# optional TrueColor support with v5.0.
|
|
320
|
+
# Apple's default 'screen' is 4.0 and does not have 256-color support.
|
|
321
|
+
# 'screen' v4.0 can end up with a $TERM like "screen.xterm-256color", even though it
|
|
322
|
+
# strips/mangles the 256b color codes.
|
|
323
|
+
# Also iTerm/Terminal's $TERM_PROGRAM will propagate through 'screen'
|
|
324
|
+
if env_term.startswith("screen"):
|
|
325
|
+
version = screen_version() if interactive else (0, 0, 0)
|
|
326
|
+
if version >= (5, 0, 0):
|
|
327
|
+
# This isn't exactly right, since Screen's "truecolor on" / "truecolor off" commands
|
|
328
|
+
# toggle whether it will pass 24bit colors. Not sure how to detect that, though.
|
|
329
|
+
color_cap = ColorSupport.ANSI_24BIT
|
|
330
|
+
elif version >= (4, 2, 0):
|
|
331
|
+
# Ditto: Screen may have been compiled with 256-color support, or not. Assume so.
|
|
332
|
+
color_cap = ColorSupport.ANSI_8BIT
|
|
333
|
+
else:
|
|
334
|
+
color_cap = ColorSupport.ANSI_4BIT
|
|
335
|
+
if debug:
|
|
336
|
+
print(f"GNU Screen detected: capping at {color_cap}")
|
|
337
|
+
|
|
338
|
+
if "TERM_PROGRAM" in os.environ:
|
|
339
|
+
term_program = os.environ["TERM_PROGRAM"]
|
|
340
|
+
if debug:
|
|
341
|
+
print(f"Color detect: $TERM_PROGRAM is '{term_program}'")
|
|
342
|
+
if term_program == "iTerm.app":
|
|
343
|
+
if debug:
|
|
344
|
+
print("Color detect: from iTerm version")
|
|
345
|
+
iterm_version = os.environ.get("TERM_PROGRAM_VERSION", "")
|
|
346
|
+
try:
|
|
347
|
+
maj_version = int(iterm_version.split(".")[0])
|
|
348
|
+
if maj_version < 3:
|
|
349
|
+
return min(ColorSupport.ANSI_8BIT, color_cap)
|
|
350
|
+
return min(ColorSupport.ANSI_24BIT, color_cap)
|
|
351
|
+
except ValueError:
|
|
352
|
+
if debug:
|
|
353
|
+
print(f" Bad $TERM_PROGRAM_VERSION: '{iterm_version}'")
|
|
354
|
+
if term_program == "Apple_Terminal":
|
|
355
|
+
if debug:
|
|
356
|
+
print("Color detect: Apple Terminal")
|
|
357
|
+
return ColorSupport.ANSI_8BIT
|
|
358
|
+
|
|
359
|
+
if env_term.endswith("-truecolor") or env_term.endswith("-RGB"):
|
|
360
|
+
if debug:
|
|
361
|
+
print("Color detect: $TERM suffix in 24b list")
|
|
362
|
+
return min(ColorSupport.ANSI_24BIT, color_cap)
|
|
363
|
+
|
|
364
|
+
if curses:
|
|
365
|
+
if curses.tigetflag("RGB") == 1:
|
|
366
|
+
# ncurses 6.0+ / terminfo added an "RGB" capability to indicate truecolor support
|
|
367
|
+
return min(ColorSupport.ANSI_24BIT, color_cap)
|
|
368
|
+
|
|
369
|
+
# Truecolor-supporting terminals will generally report 256 colors in terminfo, so
|
|
370
|
+
# this check is down here after we've given up on finding truecolor support:
|
|
371
|
+
if curses.tigetnum("colors") == 256:
|
|
372
|
+
return min(ColorSupport.ANSI_8BIT, color_cap)
|
|
373
|
+
|
|
374
|
+
if env_term.endswith("-256color") or env_term.endswith("-256"):
|
|
375
|
+
if debug:
|
|
376
|
+
print("Color detect: $TERM suffix in 8b list")
|
|
377
|
+
return min(ColorSupport.ANSI_8BIT, color_cap)
|
|
378
|
+
|
|
379
|
+
if any(env_term.startswith(x) for x in ("screen", "xterm", "vt100", "vt220", "rxvt")):
|
|
380
|
+
if debug:
|
|
381
|
+
print("Color detect: $TERM prefix in 4b list")
|
|
382
|
+
return ColorSupport.ANSI_4BIT
|
|
383
|
+
|
|
384
|
+
if any(x in env_term for x in ("color", "ansi", "cygwin", "linux")):
|
|
385
|
+
if debug:
|
|
386
|
+
print("Color detect: $TERM in 4b list")
|
|
387
|
+
return ColorSupport.ANSI_4BIT
|
|
388
|
+
|
|
389
|
+
if interactive:
|
|
390
|
+
if debug:
|
|
391
|
+
print("Color detect: using terminal's Device Attributes")
|
|
392
|
+
return da1_color_support(debug)
|
|
393
|
+
|
|
394
|
+
if curses and curses.tigetnum("colors") >= 16:
|
|
395
|
+
return min(ColorSupport.ANSI_4BIT, color_cap)
|
|
396
|
+
|
|
397
|
+
return ColorSupport.NONE
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
GRAYSCALE = MappingProxyType(
|
|
401
|
+
{
|
|
402
|
+
ColorSupport.NONE: ascii_art.EXTENDED,
|
|
403
|
+
ColorSupport.ANSI_4BIT: ascii_art.EXTENDED,
|
|
404
|
+
ColorSupport.ANSI_8BIT: ansi.GRAYSCALE,
|
|
405
|
+
ColorSupport.ANSI_24BIT: truecolor.GRAYSCALE,
|
|
406
|
+
}
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
FADE_IN = MappingProxyType(
|
|
410
|
+
{
|
|
411
|
+
ColorSupport.NONE: ascii_art.EXTENDED,
|
|
412
|
+
ColorSupport.ANSI_4BIT: ansi.FADE_IN_16,
|
|
413
|
+
ColorSupport.ANSI_8BIT: ansi.FADE_IN,
|
|
414
|
+
ColorSupport.ANSI_24BIT: truecolor.FADE_IN,
|
|
415
|
+
}
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def pick_colormap(maps: dict[ColorSupport, Callable]) -> Callable:
|
|
420
|
+
"""Detect color support and pick the best color map"""
|
|
421
|
+
support = color_support()
|
|
422
|
+
return maps[support]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def plot(data, colors=FADE_IN, **plotargs):
|
|
426
|
+
"""Wrapper for plot.Plot that picks colormap from dict"""
|
|
427
|
+
colormap = pick_colormap(colors)
|
|
428
|
+
return plotmodule.Plot(data, colormap, **plotargs)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def histplot2d(
|
|
432
|
+
points: Sequence[tuple[FloatLike, FloatLike]],
|
|
433
|
+
bins: (
|
|
434
|
+
int
|
|
435
|
+
| tuple[int, int]
|
|
436
|
+
| Sequence[FloatLike]
|
|
437
|
+
| tuple[Sequence[FloatLike], Sequence[FloatLike]]
|
|
438
|
+
) = 10,
|
|
439
|
+
ranges: Optional[tuple[Optional[ValueRange], Optional[ValueRange]]] = None,
|
|
440
|
+
align=True,
|
|
441
|
+
drop_outside=True,
|
|
442
|
+
colors=FADE_IN,
|
|
443
|
+
border_line=True,
|
|
444
|
+
fractional_tick_pos=False,
|
|
445
|
+
scale: bool | int = False,
|
|
446
|
+
**plotargs,
|
|
447
|
+
# pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
448
|
+
):
|
|
449
|
+
"""Wrapper for binning.histogram2d / plot.Plot to simplify 2-D histogram plotting"""
|
|
450
|
+
binned_data, x_axis, y_axis = binning.histogram2d(
|
|
451
|
+
points,
|
|
452
|
+
bins,
|
|
453
|
+
ranges=ranges,
|
|
454
|
+
align=align,
|
|
455
|
+
drop_outside=drop_outside,
|
|
456
|
+
border_line=border_line,
|
|
457
|
+
fractional_tick_pos=fractional_tick_pos,
|
|
458
|
+
)
|
|
459
|
+
p = plot(binned_data, colors, x_axis=x_axis, y_axis=y_axis, **plotargs)
|
|
460
|
+
if scale is True:
|
|
461
|
+
p.upscale()
|
|
462
|
+
elif scale:
|
|
463
|
+
p.upscale(max_expansion=(scale, scale))
|
|
464
|
+
|
|
465
|
+
return p
|
densitty/lineart.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Unicode/ASCII line-art support."""
|
|
2
|
+
|
|
3
|
+
from itertools import zip_longest
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
# For Y axis fractional tick marks when there is a border line, we can try to use Unicode combining
|
|
7
|
+
# characters to put a tick at the top/bottom of the "│" border, like:
|
|
8
|
+
#
|
|
9
|
+
# "│̅" or "│̲"
|
|
10
|
+
#
|
|
11
|
+
# Terminal emulators support for this seems poor though.
|
|
12
|
+
#
|
|
13
|
+
# iTerm2 3.5.11: A modified/combined "│" gets shifted slightly to the left
|
|
14
|
+
# so using the resulting left border look janky.
|
|
15
|
+
# Apple Terminal 2.14: Vertical parts are aligned. Combining under/over line with vertical
|
|
16
|
+
# have a different height than when combined with space, but that is likely ok
|
|
17
|
+
# for us.
|
|
18
|
+
# Alacritty 0.16.1: Vertical/Horizontal alignment is great! But low/high is shifted to the right?
|
|
19
|
+
#
|
|
20
|
+
# At present, probably not worth trying to use this in general, but leaving the code in place
|
|
21
|
+
|
|
22
|
+
COMBINING_OVERLINE = "\u0305" # Unicode Combining Overline, modifies previous character
|
|
23
|
+
COMBINING_LOWLINE = "\u0332" # Unicode Combining Low Line, modifies previous character
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Translations of Unicode line-art / block characters in case they aren't present in the font:
|
|
27
|
+
|
|
28
|
+
ascii_font = str.maketrans(
|
|
29
|
+
{
|
|
30
|
+
"─": "-",
|
|
31
|
+
# For half-lines, we could render either as full lines or as nothing
|
|
32
|
+
# Since they will only show up as overhangs on the end of an axis,
|
|
33
|
+
# just opt for nothing for now.
|
|
34
|
+
# "╴": "-",
|
|
35
|
+
# "╶": "-",
|
|
36
|
+
"╴": " ",
|
|
37
|
+
"╶": " ",
|
|
38
|
+
"│": "|",
|
|
39
|
+
# "╵": "|",
|
|
40
|
+
# "╷": "|",
|
|
41
|
+
"╵": " ",
|
|
42
|
+
"╷": " ",
|
|
43
|
+
"▁": "_",
|
|
44
|
+
"▔": "/",
|
|
45
|
+
"┼": "+",
|
|
46
|
+
"┐": "+",
|
|
47
|
+
"┌": "+",
|
|
48
|
+
"└": "+",
|
|
49
|
+
"┘": "+",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Some line-art glyphs are less common, so have a mapping that translates just them:
|
|
54
|
+
basic_font = str.maketrans({"▁": "_", "▔": "/", "╴": " ", "╶": " ", "╵": " ", "╷": " "})
|
|
55
|
+
|
|
56
|
+
extended_font: dict[Any, Any] = {} # a do-nothing translation
|
|
57
|
+
|
|
58
|
+
strip_combining = str.maketrans({COMBINING_LOWLINE: "", COMBINING_OVERLINE: ""})
|
|
59
|
+
|
|
60
|
+
flip_vertical = str.maketrans(
|
|
61
|
+
{
|
|
62
|
+
"╱": "╲",
|
|
63
|
+
"╲": "╱",
|
|
64
|
+
"┌": "└",
|
|
65
|
+
"└": "┌",
|
|
66
|
+
"┐": "┘",
|
|
67
|
+
"┘": "┐",
|
|
68
|
+
"┴": "┬",
|
|
69
|
+
"┬": "┴",
|
|
70
|
+
"╷": "╵",
|
|
71
|
+
"╵": "╷",
|
|
72
|
+
"▔": "▁",
|
|
73
|
+
"▁": "▔",
|
|
74
|
+
COMBINING_LOWLINE: COMBINING_OVERLINE,
|
|
75
|
+
COMBINING_OVERLINE: COMBINING_LOWLINE,
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
line_char_arms = { # map line-art chars to sets of Left/Up/Down/Right arms:
|
|
80
|
+
" ": frozenset(),
|
|
81
|
+
"╷": frozenset("D"),
|
|
82
|
+
"╴": frozenset("L"),
|
|
83
|
+
"╶": frozenset("R"),
|
|
84
|
+
"╵": frozenset("U"),
|
|
85
|
+
"│": frozenset("DU"),
|
|
86
|
+
"┐": frozenset("DL"),
|
|
87
|
+
"┌": frozenset("DR"),
|
|
88
|
+
"─": frozenset("LR"),
|
|
89
|
+
"┘": frozenset("LU"),
|
|
90
|
+
"└": frozenset("RU"),
|
|
91
|
+
"┴": frozenset("LRU"),
|
|
92
|
+
"┤": frozenset("LDU"),
|
|
93
|
+
"┬": frozenset("DLR"),
|
|
94
|
+
"├": frozenset("DRU"),
|
|
95
|
+
"┼": frozenset("DLRU"),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
reverse_line_char_arms = {v: k for k, v in line_char_arms.items()}
|
|
99
|
+
|
|
100
|
+
combinable = {"▁": COMBINING_LOWLINE, "▔": COMBINING_OVERLINE}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def merge_chars(a: str, b: str, use_combining_unicode: bool = False) -> str:
|
|
104
|
+
"""Merge two line-art characters into a single character"""
|
|
105
|
+
if use_combining_unicode and a in combinable:
|
|
106
|
+
return b + combinable[a]
|
|
107
|
+
|
|
108
|
+
if use_combining_unicode and b in combinable:
|
|
109
|
+
return a + combinable[b]
|
|
110
|
+
|
|
111
|
+
if b not in line_char_arms:
|
|
112
|
+
return b
|
|
113
|
+
if a not in line_char_arms:
|
|
114
|
+
return a
|
|
115
|
+
|
|
116
|
+
all_arms = line_char_arms[a] | line_char_arms[b]
|
|
117
|
+
return reverse_line_char_arms[all_arms]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def merge_lines(line_a: str, line_b: str, use_combining_unicode: bool = False) -> str:
|
|
121
|
+
"""Merge two lines with line-art characters into a single line"""
|
|
122
|
+
return "".join(
|
|
123
|
+
merge_chars(a, b, use_combining_unicode)
|
|
124
|
+
for a, b in zip_longest(line_a, line_b, fillvalue=" ")
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def display_len(line: str) -> int:
|
|
129
|
+
"""Calculate the display length of a string, ignoring combining characters"""
|
|
130
|
+
return len(line.translate(strip_combining))
|