plotille 6.0.0__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.

Potentially problematic release.


This version of plotille might be problematic. Click here for more details.

plotille/_colors.py ADDED
@@ -0,0 +1,379 @@
1
+ # The MIT License
2
+
3
+ # Copyright (c) 2017 - 2025 Tammo Ippen, tammo.ippen@posteo.de
4
+
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+
12
+ # The above copyright notice and this permission notice shall be included in
13
+ # all copies or substantial portions of the Software.
14
+
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ # THE SOFTWARE.
22
+
23
+ import colorsys
24
+ import os
25
+ import sys
26
+ from collections.abc import Sequence
27
+ from typing import Final, Literal, Union
28
+
29
+ MAX_RGB: Final = 255
30
+ MAX_HUE: Final = 360
31
+ RGB_VALUES: Final = 3
32
+
33
+ RGB_t = tuple[int, int, int] | Sequence[int]
34
+ ColorDefinition = Union[str, int, "ColorNames", RGB_t, None]
35
+ ColorMode = Literal["names", "byte", "rgb"]
36
+
37
+
38
+ ColorNames = Literal[
39
+ "black",
40
+ "red",
41
+ "green",
42
+ "yellow",
43
+ "blue",
44
+ "magenta",
45
+ "cyan",
46
+ "white",
47
+ "bright_black",
48
+ "bright_red",
49
+ "bright_green",
50
+ "bright_yellow",
51
+ "bright_blue",
52
+ "bright_magenta",
53
+ "bright_cyan",
54
+ "bright_white",
55
+ "bright_black_old",
56
+ "bright_red_old",
57
+ "bright_green_old",
58
+ "bright_yellow_old",
59
+ "bright_blue_old",
60
+ "bright_magenta_old",
61
+ "bright_cyan_old",
62
+ "bright_white_old",
63
+ ]
64
+
65
+
66
+ def color(
67
+ text: str,
68
+ fg: ColorDefinition = None,
69
+ bg: ColorDefinition = None,
70
+ mode: ColorMode = "names",
71
+ no_color: bool = False,
72
+ full_reset: bool = True,
73
+ ) -> str:
74
+ """Surround `text` with control characters for coloring
75
+
76
+ c.f. http://en.wikipedia.org/wiki/ANSI_escape_code
77
+
78
+ There are 3 color modes possible:
79
+ - `names`: corresponds to 3/4 bit encoding; provide colors as lower case
80
+ with underscore names, e.g. 'red', 'bright_green'
81
+ - `byte`: corresponds to 8-bit encoding; provide colors as int ∈ [0, 255];
82
+ compare 256-color lookup table
83
+ - `rgb`: corresponds to 24-bit encoding; provide colors either in 3- or
84
+ 6-character hex encoding or provide as a list / tuple with three ints
85
+ (∈ [0, 255] each)
86
+
87
+ With `fg` you can specify the foreground, i.e. text color, and with `bg` you
88
+ specify the background color. The resulting `text` also gets the `RESET` signal
89
+ at the end, s.t. no coloring swaps over to following text!
90
+
91
+ Make sure to set the colors corresponding to the `mode`, otherwise you get
92
+ `ValueErrors`.
93
+
94
+ If you do not want a foreground or background color, leave the corresponding
95
+ parameter `None`. If both are `None`, you get `text` directly.
96
+
97
+ When you stick to mode `names` and only use the none `bright_` versions,
98
+ the color control characters conform to ISO 6429 and the ANSI Escape sequences
99
+ as defined in http://ascii-table.com/ansi-escape-sequences.php.
100
+
101
+ Color names for mode `names` are:
102
+ black red green yellow blue magenta cyan white <- ISO 6429
103
+ bright_black bright_red bright_green bright_yellow
104
+ bright_blue bright_magenta bright_cyan bright_white
105
+ (trying other names will raise ValueError)
106
+
107
+ If you want to use colorama (https://pypi.python.org/pypi/colorama), you should
108
+ also stick to the ISO 6429 colors.
109
+
110
+ The environment variables `NO_COLOR` (https://no-color.org/) and `FORCE_COLOR`
111
+ (only toggle; see https://nodejs.org/api/tty.html#tty_writestream_getcolordepth_env)
112
+ have some influence on color output.
113
+
114
+ If you do not run in a TTY, e.g. pipe to some other program or redirect output
115
+ into a file, color codes are stripped as well.
116
+
117
+ Parameters:
118
+ text: str Some text to surround.
119
+ fg: multiple Specify the foreground / text color.
120
+ bg: multiple Specify the background color.
121
+ color_mode: str Specify color input mode; 'names' (default), 'byte' or 'rgb'
122
+ no_color: bool Remove color optionally. default=False
123
+ full_reset: bool Reset all codes or only color codes. default=True
124
+
125
+ Returns:
126
+ str: `text` enclosed with corresponding coloring controls
127
+ """
128
+ if fg is None and bg is None:
129
+ return text
130
+
131
+ if no_color or os.environ.get("NO_COLOR"):
132
+ # https://no-color.org/
133
+ return text
134
+
135
+ # similar to https://nodejs.org/api/tty.html#tty_writestream_getcolordepth_env
136
+ # except for only on or of
137
+ force_color = os.environ.get("FORCE_COLOR")
138
+ if force_color:
139
+ force_color = force_color.strip().lower()
140
+ if force_color in ("0", "false", "none"):
141
+ return text
142
+
143
+ if not force_color and not _isatty():
144
+ # only color if tty (not a redirect / pipe)
145
+ return text
146
+
147
+ start = ""
148
+ if mode == "names":
149
+ assert fg is None or isinstance(fg, str)
150
+ assert bg is None or isinstance(bg, str)
151
+ start = _names(fg, bg)
152
+ elif mode == "byte":
153
+ assert fg is None or isinstance(fg, int)
154
+ assert bg is None or isinstance(bg, int)
155
+ start = _byte(fg, bg)
156
+ elif mode == "rgb":
157
+ if isinstance(fg, str):
158
+ fg = _hex2rgb(fg)
159
+ if isinstance(bg, str):
160
+ bg = _hex2rgb(bg)
161
+
162
+ assert fg is None or isinstance(fg, (list, tuple))
163
+ assert bg is None or isinstance(bg, (list, tuple))
164
+ start = _rgb(fg, bg)
165
+ else:
166
+ raise ValueError(f'Invalid mode "{mode}". Use one of "names", "byte" or "rgb".')
167
+
168
+ assert start
169
+ res = start + text
170
+ if full_reset:
171
+ return res + "\x1b[0m"
172
+ else:
173
+ return res + "\x1b[39;49m"
174
+
175
+
176
+ def hsl(hue: float, saturation: float, lightness: float) -> tuple[int, int, int]:
177
+ """Convert HSL color space into RGB color space.
178
+
179
+ In contrast to colorsys.hls_to_rgb, this works directly in
180
+ 360 deg Hue and give RGB values in the range of 0 to 255.
181
+
182
+ Parameters:
183
+ hue: float Position in the spectrum. 0 to 360.
184
+ saturation: float Color saturation. 0 to 1.
185
+ lightness: float Color lightness. 0 to 1.
186
+ """
187
+ assert 0 <= hue <= MAX_HUE
188
+ assert 0 <= saturation <= 1
189
+ assert 0 <= lightness <= 1
190
+
191
+ r, g, b = colorsys.hls_to_rgb(hue / 360.0, lightness, saturation)
192
+ return round(r * 255), round(g * 255), round(b * 255)
193
+
194
+
195
+ def rgb2byte(r: int, g: int, b: int) -> int:
196
+ """Convert RGB values into an index for the byte color-mode.
197
+
198
+ Parameters:
199
+ r: int Red value. Between 0 and 255.
200
+ g: int Green value. Between 0 and 255.
201
+ b: int Blue value. Between 0 and 255.
202
+
203
+ Returns
204
+ idx: int Index of approximate color in the byte color-mode.
205
+ """
206
+ assert 0 <= r <= MAX_RGB
207
+ assert 0 <= g <= MAX_RGB
208
+ assert 0 <= b <= MAX_RGB
209
+
210
+ if r == g == b < 244:
211
+ # gray:
212
+ gray_idx = _value_to_index(min(238, r), off=8, steps=10)
213
+ return gray_idx + 232
214
+
215
+ # here we also have some gray values ...
216
+ r_idx = _value_to_index(r)
217
+ g_idx = _value_to_index(g)
218
+ b_idx = _value_to_index(b)
219
+
220
+ return 16 + 36 * r_idx + 6 * g_idx + b_idx
221
+
222
+
223
+ def _value_to_index(v: int, off: int = 55, steps: int = 40) -> int:
224
+ idx = (v - off) / steps
225
+ if idx < 0:
226
+ return 0
227
+ return round(idx)
228
+
229
+
230
+ def _isatty() -> bool:
231
+ return sys.stdout.isatty()
232
+
233
+
234
+ def _names(fg: str | None, bg: str | None) -> str:
235
+ """3/4 bit encoding part
236
+
237
+ c.f. https://en.wikipedia.org/wiki/ANSI_escape_code#3.2F4_bit
238
+
239
+ Parameters:
240
+
241
+ """
242
+ if not (fg is None or fg in _FOREGROUNDS):
243
+ raise ValueError(f'Invalid color name fg = "{fg}"')
244
+ if not (bg is None or bg in _BACKGROUNDS):
245
+ raise ValueError(f'Invalid color name bg = "{bg}"')
246
+
247
+ fg_ = _FOREGROUNDS.get(fg, "")
248
+ bg_ = _BACKGROUNDS.get(bg, "")
249
+
250
+ return _join_codes(fg_, bg_)
251
+
252
+
253
+ def _byte(fg: int | None, bg: int | None) -> str:
254
+ """8-bite encoding part
255
+
256
+ c.f. https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit
257
+ """
258
+ if not (fg is None or (isinstance(fg, int) and 0 <= fg <= MAX_RGB)):
259
+ raise ValueError(f"Invalid fg = {fg}. Allowed int in [0, 255].")
260
+ if not (bg is None or (isinstance(bg, int) and 0 <= bg <= MAX_RGB)):
261
+ raise ValueError(f"Invalid bg = {bg}. Allowed int in [0, 255].")
262
+
263
+ fg_ = ""
264
+ if fg is not None:
265
+ fg_ = "38;5;" + str(fg)
266
+ bg_ = ""
267
+ if bg is not None:
268
+ bg_ = "48;5;" + str(bg)
269
+
270
+ return _join_codes(fg_, bg_)
271
+
272
+
273
+ def _hex2rgb(h: str) -> tuple[int, int, int]:
274
+ """Transform rgb hex representation into rgb tuple of ints representation"""
275
+ assert isinstance(h, str)
276
+ if h.lower().startswith("0x"):
277
+ h = h[2:]
278
+ if len(h) == 3:
279
+ return (int(h[0] * 2, base=16), int(h[1] * 2, base=16), int(h[2] * 2, base=16))
280
+ if len(h) == 6:
281
+ return (int(h[0:2], base=16), int(h[2:4], base=16), int(h[4:6], base=16))
282
+
283
+ raise ValueError("Invalid hex RGB value.")
284
+
285
+
286
+ def _rgb(fg: Sequence[int] | None, bg: Sequence[int] | None) -> str:
287
+ """24-bit encoding part
288
+
289
+ c.f. https://en.wikipedia.org/wiki/ANSI_escape_code#24-bit
290
+ """
291
+ if not (
292
+ fg is None
293
+ or (
294
+ (isinstance(fg, (list, tuple)) and len(fg) == RGB_VALUES)
295
+ and all(0 <= f <= MAX_RGB for f in fg)
296
+ )
297
+ ):
298
+ raise ValueError(f"Foreground fg either None or 3-tuple: {fg}")
299
+ if not (
300
+ bg is None
301
+ or (
302
+ (isinstance(bg, (list, tuple)) and len(bg) == RGB_VALUES)
303
+ and all(0 <= b <= MAX_RGB for b in bg)
304
+ )
305
+ ):
306
+ raise ValueError(f"Foreground fg either None or 3-tuple: {bg}")
307
+
308
+ fg_ = ""
309
+ if fg is not None:
310
+ fg_ = "38;2;" + ";".join(map(str, fg))
311
+ bg_ = ""
312
+ if bg is not None:
313
+ bg_ = "48;2;" + ";".join(map(str, bg))
314
+
315
+ return _join_codes(fg_, bg_)
316
+
317
+
318
+ def _join_codes(fg: str, bg: str) -> str:
319
+ """Join `fg` and `bg` with ; and surround with correct esc sequence."""
320
+ colors = ";".join(filter(lambda c: len(c) > 0, (fg, bg)))
321
+ if colors:
322
+ return "\x1b[" + colors + "m"
323
+
324
+ return ""
325
+
326
+
327
+ _BACKGROUNDS: dict[str | None, str] = {
328
+ "black": "40",
329
+ "red": "41",
330
+ "green": "42",
331
+ "yellow": "43",
332
+ "blue": "44",
333
+ "magenta": "45",
334
+ "cyan": "46",
335
+ "white": "47",
336
+ "bright_black": "100",
337
+ "bright_red": "101",
338
+ "bright_green": "102",
339
+ "bright_yellow": "103",
340
+ "bright_blue": "104",
341
+ "bright_magenta": "105",
342
+ "bright_cyan": "106",
343
+ "bright_white": "107",
344
+ "bright_black_old": "1;40",
345
+ "bright_red_old": "1;41",
346
+ "bright_green_old": "1;42",
347
+ "bright_yellow_old": "1;43",
348
+ "bright_blue_old": "1;44",
349
+ "bright_magenta_old": "1;45",
350
+ "bright_cyan_old": "1;46",
351
+ "bright_white_old": "1;47",
352
+ }
353
+
354
+ _FOREGROUNDS: dict[str | None, str] = {
355
+ "black": "30",
356
+ "red": "31",
357
+ "green": "32",
358
+ "yellow": "33",
359
+ "blue": "34",
360
+ "magenta": "35",
361
+ "cyan": "36",
362
+ "white": "37",
363
+ "bright_black": "90",
364
+ "bright_red": "91",
365
+ "bright_green": "92",
366
+ "bright_yellow": "93",
367
+ "bright_blue": "94",
368
+ "bright_magenta": "95",
369
+ "bright_cyan": "96",
370
+ "bright_white": "97",
371
+ "bright_black_old": "1;30",
372
+ "bright_red_old": "1;31",
373
+ "bright_green_old": "1;32",
374
+ "bright_yellow_old": "1;33",
375
+ "bright_blue_old": "1;34",
376
+ "bright_magenta_old": "1;35",
377
+ "bright_cyan_old": "1;36",
378
+ "bright_white_old": "1;37",
379
+ }
@@ -0,0 +1,103 @@
1
+ """Metadata tracking for data type conversions.
2
+
3
+ When we normalize datetime values to float (timestamps), we need to remember
4
+ that they were originally datetimes so we can format them correctly later.
5
+ """
6
+
7
+ from collections.abc import Sequence
8
+ from datetime import datetime, tzinfo
9
+ from typing import Any, final
10
+
11
+ from ._util import DataValue
12
+
13
+
14
+ @final
15
+ class DataMetadata:
16
+ """Tracks whether data was originally datetime and timezone info.
17
+
18
+ Attributes:
19
+ is_datetime: True if the original data was datetime-like
20
+ timezone: The timezone if datetime was timezone-aware, else None
21
+ """
22
+
23
+ def __init__(self, is_datetime: bool, timezone: tzinfo | None = None) -> None:
24
+ self.is_datetime = is_datetime
25
+ self.timezone = timezone
26
+
27
+ @classmethod
28
+ def from_value(cls, value: Any) -> "DataMetadata":
29
+ """Create metadata from a single value.
30
+
31
+ Args:
32
+ value: Any value (datetime, numeric, etc.)
33
+
34
+ Returns:
35
+ DataMetadata instance
36
+ """
37
+ if isinstance(value, datetime):
38
+ return cls(is_datetime=True, timezone=value.tzinfo)
39
+ # For numeric types, numpy datetime64, etc.
40
+ # Check if it has a dtype attribute (numpy)
41
+ if hasattr(value, "dtype") and "datetime" in str(value.dtype):
42
+ # numpy datetime64 - these don't have timezone in the same way
43
+ return cls(is_datetime=True, timezone=None)
44
+ return cls(is_datetime=False, timezone=None)
45
+
46
+ @classmethod
47
+ def from_sequence(cls, sequence: Sequence[Any]) -> "DataMetadata":
48
+ """Create metadata from a sequence of values.
49
+
50
+ All values in the sequence should have the same type.
51
+
52
+ Args:
53
+ sequence: Sequence of values
54
+
55
+ Returns:
56
+ DataMetadata instance
57
+
58
+ Raises:
59
+ ValueError: If sequence contains mixed timezones
60
+ """
61
+ if len(sequence) == 0:
62
+ return cls(is_datetime=False, timezone=None)
63
+
64
+ metadatas = [cls.from_value(v) for v in sequence]
65
+ datetime_flags = {m.is_datetime for m in metadatas}
66
+
67
+ if len(datetime_flags) > 1:
68
+ raise ValueError("Cannot mix numeric and datetime values.")
69
+
70
+ if not metadatas[0].is_datetime:
71
+ return DataMetadata(is_datetime=False, timezone=None)
72
+
73
+ timezones = {m.timezone for m in metadatas}
74
+ has_naive = None in timezones
75
+ has_aware = len(timezones - {None}) > 0
76
+
77
+ if has_naive and has_aware:
78
+ raise ValueError("Cannot mix timezone-naive and timezone-aware datetime.")
79
+
80
+ # Pick first encountered timezone as default
81
+ display_timezone = metadatas[0].timezone
82
+
83
+ return DataMetadata(is_datetime=True, timezone=display_timezone)
84
+
85
+ def convert_for_display(
86
+ self, value: float, tz_override: tzinfo | None = None
87
+ ) -> DataValue:
88
+ """Convert normalized float back to original type for display.
89
+
90
+ Args:
91
+ value: Normalized float value (timestamp if datetime)
92
+ tz_override: Optional timezone override for datetime display
93
+
94
+ Returns:
95
+ float for numeric data, datetime for datetime data
96
+ """
97
+ if not self.is_datetime:
98
+ # if not datetime, we assume we have some numeric value ... no conversion there
99
+ return value
100
+
101
+ display_tz = tz_override or self.timezone
102
+
103
+ return datetime.fromtimestamp(value, tz=display_tz)
plotille/_dots.py ADDED
@@ -0,0 +1,202 @@
1
+ # The MIT License
2
+
3
+ # Copyright (c) 2017 - 2025 Tammo Ippen, tammo.ippen@posteo.de
4
+
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+
12
+ # The above copyright notice and this permission notice shall be included in
13
+ # all copies or substantial portions of the Software.
14
+
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ # THE SOFTWARE.
22
+
23
+ from typing import Any
24
+
25
+ from ._colors import ColorDefinition, color
26
+
27
+ # I plot upside down, hence the different order
28
+ # fmt: off
29
+ _xy2dot = [
30
+ [1 << 6, 1 << 7],
31
+ [1 << 2, 1 << 5],
32
+ [1 << 1, 1 << 4],
33
+ [1 << 0, 1 << 3],
34
+ ]
35
+ # fmt: on
36
+
37
+
38
+ class Dots:
39
+ """A Dots object is responsible for printing requested braille dots and colors
40
+
41
+ Dot ordering: \u2800 '⠀' - \u28ff '⣿'' Coding according to ISO/TR 11548-1
42
+
43
+ Hence, each dot on or off is 8bit, i.e. 256 possibilities. With dot number
44
+ one being the lsb and 8 is msb:
45
+
46
+ idx: 8 7 6 5 4 3 2 1
47
+ bits: 0 0 0 0 0 0 0 0
48
+
49
+ Ordering of dots:
50
+
51
+ 1 4
52
+ 2 5
53
+ 3 6
54
+ 7 8
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ marker: str | None = None,
60
+ fg: ColorDefinition = None,
61
+ bg: ColorDefinition = None,
62
+ **color_kwargs: Any,
63
+ ) -> None:
64
+ """Create a Dots object
65
+
66
+ Parameters:
67
+ dots: List[int] With set dots to on; ∈ 1 - 8
68
+ marker: str Set a marker instead of braille dots.
69
+ fg: str Color of dots
70
+ bg: str Color of background
71
+ **color_kwargs: More arguments to the color-function.
72
+ See `plotille.color()`.
73
+
74
+ Returns:
75
+ Dots
76
+ """
77
+ assert marker is None or len(marker) == 1
78
+ self._dots = 0
79
+ self._marker = marker
80
+ self.fg = fg
81
+ self.bg = bg
82
+ self._color_kwargs = color_kwargs
83
+ if "mode" not in self._color_kwargs:
84
+ self._color_kwargs["mode"] = "names"
85
+
86
+ @property
87
+ def color_kwargs(self) -> dict[str, Any]:
88
+ return self._color_kwargs
89
+
90
+ @property
91
+ def dots(self) -> list[int]:
92
+ assert self._dots.bit_length() <= 8
93
+ dots = []
94
+ x = self._dots
95
+ bit = 1
96
+ while x != 0:
97
+ if x & 1 == 1:
98
+ dots.append(bit)
99
+ bit += 1
100
+ x >>= 1
101
+ return sorted(dots)
102
+
103
+ @property
104
+ def marker(self) -> str | None:
105
+ return self._marker
106
+
107
+ @marker.setter
108
+ def marker(self, value: str | None) -> None:
109
+ assert value is None or isinstance(value, str)
110
+ assert value is None or len(value) == 1
111
+ self._marker = value
112
+
113
+ def __repr__(self) -> str:
114
+ return "Dots(dots={}, marker={}, fg={}, bg={}, color_kwargs={})".format(
115
+ self.dots,
116
+ self.marker,
117
+ self.fg,
118
+ self.bg,
119
+ " ".join(f"{k}: {v}" for k, v in self.color_kwargs.items()),
120
+ )
121
+
122
+ def __str__(self) -> str:
123
+ if self.marker:
124
+ res = self.marker
125
+ else:
126
+ res = chr(0x2800 + self._dots)
127
+
128
+ return color(res, fg=self.fg, bg=self.bg, **self.color_kwargs)
129
+
130
+ def fill(self) -> None:
131
+ self._dots = 0xFF
132
+
133
+ def clear(self) -> None:
134
+ self._dots = 0
135
+ self.marker = None
136
+
137
+ def update(
138
+ self, x: int, y: int, set_: bool = True, marker: str | None = None
139
+ ) -> None:
140
+ """(Un)Set dot at position x, y, with (0, 0) is top left corner.
141
+
142
+ Parameters:
143
+ x: int x-coordinate ∈ [0, 1]
144
+ y: int y-coordinate ∈ [0, 1, 2, 3]
145
+ set_: bool True, sets dot, False, removes dot
146
+ marker: str Instead of braille dots set a marker char.
147
+ """
148
+ assert x in (0, 1)
149
+ assert y in (0, 1, 2, 3)
150
+
151
+ if set_:
152
+ self._dots |= _xy2dot[y][x]
153
+ if marker:
154
+ self.marker = marker
155
+ else:
156
+ self._dots = self._dots & (_xy2dot[y][x] ^ 0xFF)
157
+ self.marker = None
158
+
159
+
160
+ def braille_from(dots: list[int]) -> str:
161
+ """Unicode character for braille with given dots set
162
+
163
+ See https://en.wikipedia.org/wiki/Braille_Patterns#Identifying.2C_naming_and_ordering
164
+ for dot to braille encoding.
165
+
166
+ Parameters:
167
+ dots: List[int] All dots that should be set. Allowed dots are 1,2,3,4,5,6,7,8
168
+
169
+ Returns:
170
+ unicode: braille sign with given dots set. \u2800 - \u28ff
171
+ """
172
+ bin_code = ["0"] * 8
173
+ for i in dots:
174
+ bin_code[8 - i] = "1"
175
+
176
+ code = 0x2800 + int("".join(bin_code), 2)
177
+
178
+ return chr(code)
179
+
180
+
181
+ def dots_from(braille: str) -> list[int]:
182
+ """Get set dots from given
183
+
184
+ See https://en.wikipedia.org/wiki/Braille_Patterns#Identifying.2C_naming_and_ordering
185
+ for braille to dot decoding.
186
+
187
+ Parameters:
188
+ braille: unicode Braille character in \u2800 - \u28ff
189
+
190
+ Returns:
191
+ List[int]: dots that are set in braille sign
192
+ """
193
+ assert 0x2800 <= ord(braille) <= 0x28FF
194
+
195
+ code = str(bin(ord(braille) - 0x2800))[2:].rjust(8, "0")
196
+
197
+ dots = []
198
+ for i, c in enumerate(code):
199
+ if c == "1":
200
+ dots += [8 - i]
201
+
202
+ return sorted(dots)