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/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))