"""TextCanvas.
TextCanvas is an HTML Canvas-like surface that can be used to draw to
the terminal. Other use cases include visual checks for mathematical
computations (i.e. does the graph at least look correct?), or snapshot
testing (may not be the most accurate, but can have great documentation
value).
It is inspired by drawille[^1], which uses Braille Unicode characters to
increase the resolution of the terminal by a factor of 8 (8 Braille dots
in one terminal character).
The API is inspired by JavaScript Canvas's API, but has barely any
features.
# How It Works
Braille characters start at Unicode offset `U2800` (hexadecimal), and
work by addition (binary flags really, just like chmod):
2800 + (1 + 2) = 2803 <=> U2801 (⠁) + U2802 (⠂) = U2803 (⠃)
2800 + (3 + 4) = 2807 <=> U2803 (⠃) + U2804 (⠄) = U2807 (⠇)
One character is 8 pixels, and we individually turn pixels on or off by
adding or subtracting the value of the dot we want.
Each dot has its value (again, this is hexadecimal):
┌──────┐ ┌────────────┐
│ • • │ │ 0x1 0x8 │
│ • • │ │ 0x2 0x10 │
│ • • │ │ 0x4 0x20 │
│ • • │ │ 0x40 0x80 │
└──────┘ └────────────┘
For example, to turn off the right pixel from the second row:
0x28FF (⣿) - 0x10 (⠐) = 0x28ef (⣯)
Or the whole second row:
0x28FF (⣿) - 0x12 (⠒) = 0x28ed (⣭)
This works in binary as well:
┌──────┐ ┌──────┐
│ • • │ │ 1 4 │
│ • • │ │ 2 5 │
│ • • │ │ 3 6 │
│ • • │ │ 7 8 │
└──────┘ └──────┘
These numbers define how dots are mapped to a bit array (ordering is
historical, 7 and 8 were added later):
Bits: 0 0 0 0 0 0 0 0
Dots: 8 7 6 5 4 3 2 1
For example, to turn on the first two rows, we would activate bit 1, 4,
2, and 5:
0 0 0 1 1 0 1 1
Note that: 0b11011 = 0x1b = 0x1 + 0x8 + 0x2 + 0x10 (see hex chart)
Carrying on with this example, we could turn off the first row and turn
on the last row like so:
Current pattern: 00011011
First row (1, 4): 00001001
Last row (7, 8): 11000000
0b11011 - 0b1001 + 0b11000000 = 0b11010010
0x1b - 0x9 + 0xc0 = 0xd2
0x2800 + 0b11010010 = 0x28d2 (⣒)
# See Also
- https://en.wikipedia.org/wiki/Braille_Patterns
- https://www.unicode.org/charts/PDF/U2800.pdf
[^1]: https://github.com/asciimoo/drawille
"""
import math
import os
from dataclasses import dataclass
from typing import Generator, Self
from .color import Color
type PixelBuffer = list[list[bool]]
type ColorBuffer = list[list[Color]]
type TextBuffer = list[list[str]]
type BrailleChar = int
type PixelBlock = tuple[
tuple[bool, bool],
tuple[bool, bool],
tuple[bool, bool],
tuple[bool, bool],
]
type BrailleMap = tuple[
tuple[int, int],
tuple[int, int],
tuple[int, int],
tuple[int, int],
]
ON: bool = True
OFF: bool = False
BRAILLE_UNICODE_0: int = 0x2800
BRAILLE_UNICODE_OFFSET_MAP: BrailleMap = (
(0x1, 0x8),
(0x2, 0x10),
(0x4, 0x20),
(0x40, 0x80),
)
@dataclass
class Surface:
width: int
height: int
class TextCanvas:
"""Draw to the terminal like an HTML Canvas.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> repr(canvas)
'Canvas(output=(15×5), screen=(30×20)))'
>>> canvas.w, canvas.h, canvas.cx, canvas.cy
(29, 19, 15, 10)
>>> canvas.stroke_line(0, 0, canvas.w, canvas.h)
>>> canvas.draw_text("hello, world", 1, 2)
>>> print(canvas, end="")
⠑⠢⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠑⠢⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀hello,⠢world⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠢⢄⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠢⢄
Attributes:
output (Surface): Properties of the output surface, whose size
is given as parameter to the constructor. One unit in width
and in height represents exactly one character on the
terminal.
screen (Surface): Properties of the virtual output surface. This
surface benefits from the increase in resolution. One unit
in width and in height represents one Braille dot. There are
2 dots per character in width, and 4 per character in
height. So this surface is 2× wider and 4× higher than the
output surface.
buffer (PixelBuffer): The in-memory pixel buffer. This maps
1-to-1 to the virtual screen. Each pixel is this buffer is
either _on_ or _off_.
color_buffer (ColorBuffer): The in-memory color buffer. This
maps 1-to-1 to the output buffer (and so to the physical
screen). This contains color data for characters. Screen
dots, being part of one output character, cannot be colored
individually. Color is thus less precise than screen pixels.
Note that a call to `set_color()` is valid for both pixels
and text, but text embeds color in its own buffer, and does
not use this buffer at all. Note also that this buffer is
empty until the first call to `set_color()`. The first call
to `set_color()` initializes the buffer and sets
`is_colorized` to `True`.
text_buffer (TextBuffer): The in-memory text buffer. This maps
1-to-1 to the output buffer (and so to the physical screen).
This contains regular text characters. Text is drawn on top
of the pixel buffer on a separate layer. Drawing text does
not affect pixels. Pixels and text do not share the same
color buffer either. Color info is embedded in the text
buffer with each character directly. Note also that this
buffer is empty until the first call to `draw_text()`. The
first call to `draw_text()` initializes the buffer and sets
`is_textual` to `True`.
is_inverted (bool): Inverted drawing mode. In inverted mode,
functions which usually turn pixels _on_, will turn them
_off_, and vice-versa.
Raises:
ValueError: If width and height of canvas are < 1×1.
"""
def __init__(self, width: int = 80, height: int = 24) -> None:
self._check_canvas_size(width, height)
self.output: Surface = Surface(width, height)
self.screen: Surface = Surface(width * 2, height * 4)
self.buffer: PixelBuffer
self.color_buffer: ColorBuffer = []
self.text_buffer: TextBuffer = []
self.is_inverted: bool = False
self._color: Color = Color()
self._init_buffer()
@staticmethod
def _check_canvas_size(width: int, height: int) -> None:
if width <= 0 or height <= 0:
raise ValueError("TextCanvas' minimal size is 1×1.")
def _check_output_bounds(self, x: int, y: int) -> bool:
return 0 <= x < self.output.width and 0 <= y < self.output.height
def _check_screen_bounds(self, x: int, y: int) -> bool:
return 0 <= x < self.screen.width and 0 <= y < self.screen.height
def _init_buffer(self) -> None:
self.buffer = [
[OFF for _ in range(self.screen.width)] for _ in range(self.screen.height)
]
@classmethod
def auto(cls) -> Self:
"""Create new `TextCanvas` by reading size from environment.
Raises:
LookupError: If either or both `WIDTH` and `HEIGHT`
variables cannot be read from the environment.
"""
(width, height) = TextCanvas.get_auto_size()
return cls(width, height)
@staticmethod
def get_default_size() -> tuple[int, int]:
"""Default canvas size.
This value is used by `TextCanvas()` if no size is provided to
the constructor, but it may be useful to query it separately.
"""
return 80, 24
@staticmethod
def get_auto_size() -> tuple[int, int]:
"""Read canvas size from `WIDTH` and `HEIGHT` env variables.
This value is used by `TextCanvas.auto()`, but it may be useful
to query it separately.
Raises:
LookupError: If either or both `WIDTH` and `HEIGHT`
variables cannot be read from the environment.
"""
try:
width: int = int(os.environ.get("WIDTH", ""))
except ValueError:
raise LookupError("Cannot read terminal width from environment.")
try:
height: int = int(os.environ.get("HEIGHT", ""))
except ValueError:
raise LookupError("Cannot read terminal height from environment.")
return width, height
def __repr__(self) -> str:
out_w: int = self.output.width
out_h: int = self.output.height
screen_w: int = self.screen.width
screen_h: int = self.screen.height
return f"Canvas(output=({out_w}×{out_h}), screen=({screen_w}×{screen_h})))"
def __str__(self) -> str:
return self.to_string()
@property
def w(self) -> int:
"""Shortcut for width of pixel screen (index of last column)."""
return self.screen.width - 1
@property
def h(self) -> int:
"""Shortcut for height of pixel screen (index of last row)."""
return self.screen.height - 1
@property
def cx(self) -> int:
"""Shortcut for center-X of pixel screen."""
return self.screen.width // 2
@property
def cy(self) -> int:
"""Shortcut for center-Y of pixel screen."""
return self.screen.height // 2
def clear(self) -> None:
"""Turn all pixels off and remove color and text.
Note:
This method does not drop the color and text buffers, it
only clears them. No memory is freed, and all references
remain valid (buffers are cleared in-place, not replaced).
Note:
`clear()` is not affected by inverted mode, it works on a
lower level.
"""
self._clear_buffer()
self._clear_color_buffer()
self._clear_text_buffer()
def _clear_buffer(self) -> None:
for x, y in self.iter_buffer():
self.buffer[y][x] = False
def _clear_color_buffer(self) -> None:
if self.color_buffer:
for y, _ in enumerate(self.color_buffer):
for x, _ in enumerate(self.color_buffer[y]):
self.color_buffer[y][x] = Color()
def _clear_text_buffer(self) -> None:
if self.text_buffer:
for y, _ in enumerate(self.text_buffer):
for x, _ in enumerate(self.text_buffer[y]):
self.text_buffer[y][x] = ""
def fill(self) -> None:
"""Turn all pixels on.
This does not affect the color and text buffers.
Note:
`fill()` is not affected by inverted mode, it works on a
lower level.
"""
for x, y in self.iter_buffer():
self.buffer[y][x] = True
def invert(self) -> None:
"""Invert drawing mode.
In inverted mode, functions that usually turn pixels _on_, will
turn them _off_, and vice versa. This can be used to cut out
shapes for instance.
"""
self.is_inverted = not self.is_inverted
@property
def is_colorized(self) -> bool:
"""Whether the canvas can contain colors.
Note:
This does not mean that any colors are displayed. This only
means the color buffer is active.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> canvas.is_colorized
False
>>> canvas.set_color(Color()) # Buffer is initialized.
>>> canvas.is_colorized
True
"""
return bool(self.color_buffer)
@property
def is_textual(self) -> bool:
"""Whether the canvas can contain text.
Note:
This does not mean that any text is displayed. This only
means the text buffer is active.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> canvas.is_textual
False
>>> canvas.draw_text("", 0, 0) # Buffer is initialized.
>>> canvas.is_textual
True
"""
return bool(self.text_buffer)
def set_color(self, color: Color) -> None:
"""Set context color.
Examples:
>>> canvas = TextCanvas(3, 1)
>>> canvas.set_color(Color().bright_green())
>>> canvas.is_colorized
True
>>> canvas.draw_text("foo", 0, 0)
>>> print(canvas, end="")
\x1b[0;92mf\x1b[0m\x1b[0;92mo\x1b[0m\x1b[0;92mo\x1b[0m
"""
if not self.is_colorized:
self._init_color_buffer()
self._color = color
def _init_color_buffer(self) -> None:
self.color_buffer = [
[Color() for _ in range(self.output.width)]
for _ in range(self.output.height)
]
def get_pixel(self, x: int, y: int) -> bool | None:
"""Get the state of a screen pixel.
Args:
x (int): Screen X (high resolution).
y (int): Screen Y (high resolution).
Returns:
`True` if the pixel is turned _on_, `False` if it is turned
_off_, and `None` if the coordinates are outside the bounds
of the buffer.
"""
if not self._check_screen_bounds(x, y):
return None
return self.buffer[y][x]
def set_pixel(self, x: int, y: int, state: bool) -> None:
"""Set the state of a screen pixel.
Note:
Coordinates outside the screen bounds are ignored.
Note:
Turning a pixel _off_ also removes color. This side effect
does not affect text, as text has a separate color buffer.
Args:
x (int): Screen X (high resolution).
y (int): Screen Y (high resolution).
state (bool): `True` means _on_, `False` means _off_.
"""
if not self._check_screen_bounds(x, y):
return
if self.is_inverted:
state = not state
self.buffer[y][x] = state
if self.is_colorized:
if state is True:
self._color_pixel(x, y)
else:
self._decolor_pixel(x, y)
def _color_pixel(self, x: int, y: int) -> None:
self.color_buffer[y // 4][x // 2] = self._color
def _decolor_pixel(self, x: int, y: int) -> None:
self.color_buffer[y // 4][x // 2] = Color()
def draw_text(self, text: str, x: int, y: int) -> None:
"""Draw text onto the canvas.
Note:
Note: Spaces are transparent (you see pixels through). But
drawing spaces over text erases the text beneath. If you
want to keep the text, use the `merge_text()` method.
Note:
Coordinates outside the screen bounds are ignored.
Note:
Text is rendered on top of pixels, as a separate layer.
Note:
`set_color()` works for text as well, but text does not
share its color buffer with pixels.
"""
if not self.is_textual:
self._init_text_buffer()
for char in text:
self._draw_char(char, x, y, False)
x += 1
def draw_text_vertical(self, text: str, x: int, y: int) -> None:
if not self.is_textual:
self._init_text_buffer()
for char in text:
self._draw_char(char, x, y, False)
y += 1
def merge_text(self, text: str, x: int, y: int) -> None:
"""Merge text onto the canvas.
This is the same as `draw_text()`, but spaces do not erase text
underneath.
"""
if not self.is_textual:
self._init_text_buffer()
for char in text:
self._draw_char(char, x, y, True)
x += 1
def merge_text_vertical(self, text: str, x: int, y: int) -> None:
if not self.is_textual:
self._init_text_buffer()
for char in text:
self._draw_char(char, x, y, True)
y += 1
def _draw_char(self, char: str, x: int, y: int, merge: bool) -> None:
if not self._check_output_bounds(x, y):
return
if char == " ":
if merge:
return
char = ""
else:
char = self._color.format(char)
self.text_buffer[y][x] = char
def _init_text_buffer(self) -> None:
self.text_buffer = [
["" for _ in range(self.output.width)] for _ in range(self.output.height)
]
def to_string(self) -> str:
"""Render canvas as a `print()`-able string.
Note:
This is used by `str()`, and so is also what's printed to
the screen by a call to `print(canvas)`.
Returns:
Rendered canvas, with pixels, text and colors. Each canvas
row becomes a line of text (lines are separated by `\n`s),
and each canvas column becomes a single character in each
line. What you would expect. It can be printed as-is.
"""
res: str = ""
for i, pixel_block in enumerate(self._iter_buffer_by_blocks_lrtb()):
x: int = i % self.output.width
y: int = i // self.output.width
# Text layer.
if (text_char := self._get_text_char(x, y)) != "":
res += text_char
# Pixel layer.
else:
braille_char: str = self._pixel_block_to_braille_char(pixel_block)
res += self._color_pixel_char(x, y, braille_char)
# If end of line is reached, go to next line.
if (i + 1) % self.output.width == 0:
res += "\n"
return res
def _get_text_char(self, x: int, y: int) -> str:
if self.is_textual:
return self.text_buffer[y][x]
return ""
@staticmethod
def _pixel_block_to_braille_char(pixel_block: PixelBlock) -> str:
braille_char: BrailleChar = BRAILLE_UNICODE_0
# Iterate over individual pixels to turn them on or off.
for y, _ in enumerate(pixel_block):
for x, _ in enumerate(pixel_block[y]):
if pixel_block[y][x] is ON:
braille_char += BRAILLE_UNICODE_OFFSET_MAP[y][x]
# Convert Unicode integer value to string.
return chr(braille_char)
def _color_pixel_char(self, x: int, y: int, pixel_char: str) -> str:
if self.is_colorized:
color: Color = self.color_buffer[y][x]
return color.format(pixel_char)
return pixel_char
def _iter_buffer_by_blocks_lrtb(self) -> Generator[PixelBlock, None, None]:
"""Advance block by block (2x4), left-right, top-bottom."""
for y in range(0, self.screen.height, 4):
for x in range(0, self.screen.width, 2):
yield (
(self.buffer[y + 0][x + 0], self.buffer[y + 0][x + 1]),
(self.buffer[y + 1][x + 0], self.buffer[y + 1][x + 1]),
(self.buffer[y + 2][x + 0], self.buffer[y + 2][x + 1]),
(self.buffer[y + 3][x + 0], self.buffer[y + 3][x + 1]),
)
def iter_buffer(self) -> Generator[tuple[int, int], None, None]:
for y in range(self.screen.height):
for x in range(self.screen.width):
yield x, y
# Implementation of drawing primitives.
def stroke_line(self, x1: int, y1: int, x2: int, y2: int) -> None:
"""Stroke line.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> canvas.stroke_line(5, 5, 25, 15)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠐⠤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠉⠒⠤⣀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠒⠤⣀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
"""
self._bresenham_line(x1, y1, x2, y2)
def _bresenham_line(self, x1: int, y1: int, x2: int, y2: int) -> None:
"""Stroke line using Bresenham's line algorithm."""
dx = abs(x2 - x1)
sx = 1 if x1 < x2 else -1
dy = -abs(y2 - y1)
sy = 1 if y1 < y2 else -1
error = dx + dy
# Treat vertical and horizontal lines as special cases.
if dx == 0:
x = x1
from_y = min(y1, y2)
to_y = max(y1, y2)
for y in range(from_y, to_y + 1):
self.set_pixel(x, y, True)
return
elif dy == 0:
y = y1
from_x = min(x1, x2)
to_x = max(x1, x2)
for x in range(from_x, to_x + 1):
self.set_pixel(x, y, True)
return
while True:
self.set_pixel(x1, y1, True)
if x1 == x2 and y1 == y2:
break
e2 = 2 * error
if e2 >= dy:
if x1 == x2:
break # pragma: no cover
error = error + dy
x1 = x1 + sx
if e2 <= dx:
if y1 == y2:
break # pragma: no cover
error = error + dx
y1 = y1 + sy
def stroke_rect(self, x: int, y: int, width: int, height: int) -> None:
"""Stroke rectangle.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> canvas.stroke_rect(5, 5, 20, 10)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⢰⠒⠒⠒⠒⠒⠒⠒⠒⠒⡆⠀⠀
⠀⠀⢸⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀
⠀⠀⠸⠤⠤⠤⠤⠤⠤⠤⠤⠤⠇⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
"""
width, height = width - 1, height - 1
self.stroke_line(x, y, x + width, y)
self.stroke_line(x + width, y, x + width, y + height)
self.stroke_line(x + width, y + height, x, y + height)
self.stroke_line(x, y + height, x, y)
def frame(self) -> None:
"""Draw a border around the canvas.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> canvas.frame()
>>> print(canvas, end="")
⡏⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⢹
⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸
⣇⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣸
"""
self.stroke_rect(0, 0, self.screen.width, self.screen.height)
def fill_rect(self, x: int, y: int, width: int, height: int) -> None:
"""Stroke rectangle.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> canvas.fill_rect(5, 5, 20, 10)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⢰⣶⣶⣶⣶⣶⣶⣶⣶⣶⡆⠀⠀
⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀
⠀⠀⠸⠿⠿⠿⠿⠿⠿⠿⠿⠿⠇⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
"""
for y in range(y, y + height):
self.stroke_line(x, y, x + width - 1, y)
def stroke_triangle(
self, x1: int, y1: int, x2: int, y2: int, x3: int, y3: int
) -> None:
"""Stroke triangle.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> canvas.stroke_triangle(5, 5, 20, 10, 4, 17)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⢰⠢⠤⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⢸⠀⠀⠀⠈⠉⢒⡢⠄⠀⠀⠀⠀
⠀⠀⡇⠀⣀⠤⠔⠊⠁⠀⠀⠀⠀⠀⠀
⠀⠀⠓⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
"""
self.stroke_line(x1, y1, x2, y2)
self.stroke_line(x2, y2, x3, y3)
self.stroke_line(x3, y3, x1, y1)
def fill_triangle(
self, x1: int, y1: int, x2: int, y2: int, x3: int, y3: int
) -> None:
"""Fill triangle.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> canvas.fill_triangle(5, 5, 20, 10, 4, 17)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⢰⣦⣤⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⢸⣿⣿⣿⣿⣿⣶⡦⠄⠀⠀⠀⠀
⠀⠀⣿⣿⣿⠿⠟⠋⠁⠀⠀⠀⠀⠀⠀
⠀⠀⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
"""
# This makes for neater edges.
self.stroke_triangle(x1, y1, x2, y2, x3, y3)
# Barycentric Algorithm: Compute the bounding box of the
# triangle. Then for each point in the box, determine if it
# lies inside or outside the triangle.
# Bounding box.
min_x: int = min(x1, x2, x3)
max_x: int = max(x1, x2, x3)
min_y: int = min(y1, y2, y3)
max_y: int = max(y1, y2, y3)
p1: tuple[float, float] = (x1, y1)
p2: tuple[float, float] = (x2, y2)
p3: tuple[float, float] = (x3, y3)
triangle: tuple = (p1, p2, p3)
for x in range(min_x, max_x + 1):
for y in range(min_y, max_y + 1):
point: tuple[float, float] = (x, y)
if self._is_point_in_triangle(point, triangle):
self.set_pixel(x, y, True)
@staticmethod
def _is_point_in_triangle(
point: tuple[float, float],
triangle: tuple[tuple[float, float], tuple[float, float], tuple[float, float]],
) -> bool:
# This version correctly handles triangles specified in either
# winding direction (clockwise vs. counterclockwise).
# https://stackoverflow.com/a/20861130 — Glenn Slayden
(px, py) = point
((p0x, p0y), (p1x, p1y), (p2x, p2y)) = triangle
s = (p0x - p2x) * (py - p2y) - (p0y - p2y) * (px - p2x)
t = (p1x - p0x) * (py - p0y) - (p1y - p0y) * (px - p0x)
if (s < 0.0) != (t < 0.0) and s != 0.0 and t != 0.0:
return False
d = (p2x - p1x) * (py - p1y) - (p2y - p1y) * (px - p1x)
return d == 0.0 or (d < 0.0) == (s + t <= 0.0)
def stroke_circle(self, x: int, y: int, radius: int) -> None:
"""Fill triangle.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> canvas.stroke_circle(canvas.cx, canvas.cy, 7)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⣀⣀⣀⡀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⡠⠊⠀⠀⠀⠈⠢⡀⠀⠀⠀
⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀
⠀⠀⠀⠀⠣⡀⠀⠀⠀⠀⡠⠃⠀⠀⠀
⠀⠀⠀⠀⠀⠈⠒⠒⠒⠊⠀⠀⠀⠀⠀
"""
self._bresenham_circle(x, y, radius, False)
def fill_circle(self, x: int, y: int, radius: int) -> None:
"""Fill triangle.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> canvas.fill_circle(canvas.cx, canvas.cy, 7)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⣀⣀⣀⡀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣦⡀⠀⠀⠀
⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀
⠀⠀⠀⠀⠻⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀
⠀⠀⠀⠀⠀⠈⠛⠛⠛⠋⠀⠀⠀⠀⠀
"""
self._bresenham_circle(x, y, radius, True)
def _bresenham_circle(self, x: int, y: int, radius: int, fill: bool) -> None:
"""Draw circle using Jesko's Method of the Bresenham's circle
algorithm.
"""
cx, cy = (x, y)
t1 = radius / 16
x = radius
y = 0
while x >= y:
if fill:
# Connect each pair of points with the same `y`.
self.stroke_line(cx - x, cy - y, cx + x, cy - y)
self.stroke_line(cx + x, cy + y, cx - x, cy + y)
self.stroke_line(cx - y, cy - x, cx + y, cy - x)
self.stroke_line(cx + y, cy + x, cx - y, cy + x)
else:
self.set_pixel(cx - x, cy - y, True)
self.set_pixel(cx + x, cy - y, True)
self.set_pixel(cx + x, cy + y, True)
self.set_pixel(cx - x, cy + y, True)
self.set_pixel(cx - y, cy - x, True)
self.set_pixel(cx + y, cy - x, True)
self.set_pixel(cx + y, cy + x, True)
self.set_pixel(cx - y, cy + x, True)
y += 1
t1 += y
t2 = t1 - x
if t2 >= 0:
t1 = t2
x -= 1
def stroke_ngon(
self, x: int, y: int, radius: int, sides: int, angle: float
) -> None:
"""Stroke n-gon.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> canvas.stroke_ngon(canvas.cx, canvas.cy, 7, 5, math.pi / 2.0)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⠀⢀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢀⡠⠊⠁⠉⠢⣀⠀⠀⠀⠀
⠀⠀⠀⠀⢣⠀⠀⠀⠀⠀⢠⠃⠀⠀⠀
⠀⠀⠀⠀⠀⢇⠀⠀⠀⢀⠎⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠈⠉⠉⠉⠉⠀⠀⠀⠀⠀
Raises:
ValueError: If `sides` < 3.
"""
self._ngon(x, y, radius, sides, angle, False)
def fill_ngon(self, x: int, y: int, radius: int, sides: int, angle: float) -> None:
"""Fill n-gon.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> canvas.fill_ngon(canvas.cx, canvas.cy, 7, 4, 0.0)
>>> print(canvas, end="")
⠀⠀⠀⠀⠀⠀⠀⢀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⢀⣴⣿⣷⣄⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢴⣿⣿⣿⣿⣿⣷⠄⠀⠀⠀
⠀⠀⠀⠀⠀⠙⢿⣿⣿⠟⠁⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠙⠁⠀⠀⠀⠀⠀⠀
Raises:
ValueError: If `sides` < 3.
"""
self._ngon(x, y, radius, sides, angle, True)
def _ngon(
self, x: int, y: int, radius: int, sides: int, angle: float, fill: bool
) -> None:
if sides < 3:
raise ValueError(
f"Minimum 3 sides needed to draw an n-gon, but only {sides} requested."
)
def join_vertices(from_: tuple[int, int], to: tuple[int, int]) -> None:
if fill:
self.fill_triangle(self.cx, self.cy, from_[0], from_[1], to[0], to[1])
else:
self.stroke_line(from_[0], from_[1], to[0], to[1])
vertices: list[tuple[int, int]] = self._compute_ngon_vertices(
x, y, radius, sides, angle
)
first: tuple[int, int] = vertices[0]
previous = first
for vertex in vertices[1:]:
join_vertices(previous, vertex)
previous = vertex
join_vertices(previous, first)
@staticmethod
def _compute_ngon_vertices(
cx: int, cy: int, radius: int, sides: int, angle: float
) -> list[tuple[int, int]]:
slice_: float = (2.0 * math.pi) / sides
vertices: list[tuple[int, int]] = []
for vertex in range(sides):
theta: float = vertex * slice_ + angle
x = cx + (math.cos(theta) * radius)
y = cy - (math.sin(theta) * radius) # Screen Y coordinates are inverted.
point = (int(round(x)), int(round(y)))
vertices.append(point)
return vertices
def draw_canvas(self, canvas: Self, dx: int, dy: int) -> None:
"""Draw another canvas onto the current canvas.
The other canvas completely overrides the current canvas where
it is drawn (but it does not affect the portions where it is
_not_ drawn).
Note:
Inverted mode has no effect here, this is a low level
copy-paste.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> canvas.stroke_line(0, 0, canvas.w, canvas.h)
>>> canvas.stroke_line(0, canvas.h, canvas.w, 0)
>>> overlay = TextCanvas(7, 3)
>>> overlay.frame()
>>> canvas.draw_canvas(overlay, 8, 4)
>>> print(canvas, end="")
⠑⠢⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠊
⠀⠀⠀⠑⡏⠉⠉⠉⠉⠉⢹⠊⠀⠀⠀
⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⢸⠀⠀⠀⠀
⠀⠀⠀⡠⣇⣀⣀⣀⣀⣀⣸⢄⠀⠀⠀
⡠⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠢⢄
"""
self.draw_canvas_onto_canvas(canvas, dx, dy, False)
def merge_canvas(self, canvas: Self, dx: int, dy: int) -> None:
"""Merge another canvas with the current canvas.
The other canvas is merged with the current canvas. That is,
pixels that are turned on get draw, but those that are off are
ignored.
Note:
Inverted mode has no effect here, this is a low level
copy-paste.
Examples:
>>> canvas = TextCanvas(15, 5)
>>> canvas.stroke_line(0, 0, canvas.w, canvas.h)
>>> canvas.stroke_line(0, canvas.h, canvas.w, 0)
>>> overlay = TextCanvas(7, 3)
>>> overlay.frame()
>>> canvas.merge_canvas(overlay, 8, 4)
>>> print(canvas, end="")
⠑⠢⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠊
⠀⠀⠀⠑⡯⣉⠉⠉⠉⣉⢽⠊⠀⠀⠀
⠀⠀⠀⠀⡇⠀⡱⠶⢎⠀⢸⠀⠀⠀⠀
⠀⠀⠀⡠⣗⣉⣀⣀⣀⣉⣺⢄⠀⠀⠀
⡠⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠢⢄
"""
self.draw_canvas_onto_canvas(canvas, dx, dy, True)
def draw_canvas_onto_canvas(
self, canvas: Self, dx: int, dy: int, merge: bool
) -> None:
if not self.is_colorized and canvas.is_colorized:
self._init_color_buffer()
if not self.is_textual and canvas.is_textual:
self._init_text_buffer()
offset_x, offset_y = dx, dy
for x, y in canvas.iter_buffer():
# Source coordinates of pixel.
# x, y
# Destination coordinates of pixel.
dx, dy = (offset_x + x), (offset_y + y)
if not self._check_screen_bounds(dx, dy):
continue
# Pixels.
pixel = canvas.buffer[y][x]
# In merge mode, only draw if pixel is on, treating off
# pixels as transparent.
if not merge or pixel == ON:
self.buffer[dy][dx] = pixel
if canvas.is_colorized:
color = canvas.color_buffer[y // 4][x // 2]
self.color_buffer[dy // 4][dx // 2] = color
# Text.
if canvas.is_textual:
# Text buffer has color embedded into the string.
text = canvas.text_buffer[y // 4][x // 2]
if not merge or text:
self.text_buffer[dy // 4][dx // 2] = text
if __name__ == "__main__":
canvas = TextCanvas(15, 5)
top_left = (0, 0)
top_right = (canvas.w, 0)
bottom_right = (canvas.w, canvas.h)
bottom_left = (0, canvas.h)
center = (canvas.cx, canvas.cy)
center_top = (canvas.cx, 0)
center_right = (canvas.w, canvas.cy)
center_bottom = (canvas.cx, canvas.h)
center_left = (0, canvas.cy)
canvas.set_color(Color().bright_red())
canvas.stroke_line(*center, *top_left)
canvas.set_color(Color().bright_yellow())
canvas.stroke_line(*center, *top_right)
canvas.set_color(Color().bright_green())
canvas.stroke_line(*center, *bottom_right)
canvas.set_color(Color().bright_blue())
canvas.stroke_line(*center, *bottom_left)
canvas.set_color(Color().bright_cyan())
canvas.stroke_line(*center, *center_top)
canvas.set_color(Color().bright_magenta())
canvas.stroke_line(*center, *center_right)
canvas.set_color(Color().bright_gray())
canvas.stroke_line(*center, *center_bottom)
canvas.set_color(Color())
canvas.stroke_line(*center, *center_left)
print(canvas)