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:
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:
(width, height) = TextCanvas.get_auto_size()
return cls(width, height)
@staticmethod
def get_default_size() -> tuple[int, int]:
return 80, 24
@staticmethod
def get_auto_size() -> tuple[int, int]:
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:
return self.screen.width - 1
@property
def h(self) -> int:
return self.screen.height - 1
@property
def cx(self) -> int:
return self.screen.width // 2
@property
def cy(self) -> int:
return self.screen.height // 2
def clear(self) -> None:
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:
for x, y in self.iter_buffer():
self.buffer[y][x] = True
def invert(self) -> None:
self.is_inverted = not self.is_inverted
@property
def is_colorized(self) -> bool:
return bool(self.color_buffer)
@property
def is_textual(self) -> bool:
return bool(self.text_buffer)
def set_color(self, color: Color) -> None:
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:
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:
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:
if not self.is_textual:
self._init_text_buffer()
char_x, char_y = x, y
for char in text:
if char == "\n":
char_x, char_y = x, char_y + 1
continue
self._draw_char(char, char_x, char_y, False)
char_x += 1
def draw_text_vertical(self, text: str, x: int, y: int) -> None:
if not self.is_textual:
self._init_text_buffer()
char_x, char_y = x, y
for char in text:
if char == "\n":
char_x, char_y = char_x + 1, y
continue
self._draw_char(char, char_x, char_y, False)
char_y += 1
def merge_text(self, text: str, x: int, y: int) -> None:
if not self.is_textual:
self._init_text_buffer()
char_x, char_y = x, y
for char in text:
if char == "\n":
char_x, char_y = x, char_y + 1
continue
self._draw_char(char, char_x, char_y, True)
char_x += 1
def merge_text_vertical(self, text: str, x: int, y: int) -> None:
if not self.is_textual:
self._init_text_buffer()
char_x, char_y = x, y
for char in text:
if char == "\n":
char_x, char_y = char_x + 1, y
continue
self._draw_char(char, char_x, char_y, True)
char_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:
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
if (text_char := self._get_text_char(x, y)) != "":
res += text_char
else:
braille_char: str = self._pixel_block_to_braille_char(pixel_block)
res += self._color_pixel_char(x, y, braille_char)
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
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]
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]:
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
def stroke_line(self, x1: int, y1: int, x2: int, y2: int) -> None:
self._bresenham_line(x1, y1, x2, y2)
def _bresenham_line(self, x1: int, y1: int, x2: int, y2: int) -> None:
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
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 error = error + dy
x1 = x1 + sx
if e2 <= dx:
if y1 == y2:
break error = error + dx
y1 = y1 + sy
def stroke_rect(self, x: int, y: int, width: int, height: int) -> None:
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:
self.stroke_rect(0, 0, self.screen.width, self.screen.height)
def fill_rect(self, x: int, y: int, width: int, height: int) -> None:
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:
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:
self.stroke_triangle(x1, y1, x2, y2, x3, y3)
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:
(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:
self._bresenham_circle(x, y, radius, False)
def fill_circle(self, x: int, y: int, radius: int) -> None:
self._bresenham_circle(x, y, radius, True)
def _bresenham_circle(self, x: int, y: int, radius: int, fill: bool) -> None:
cx, cy = (x, y)
t1 = radius / 16
x = radius
y = 0
while x >= y:
if fill:
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:
self._ngon(x, y, radius, sides, angle, False)
def fill_ngon(self, x: int, y: int, radius: int, sides: int, angle: float) -> None:
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) point = (int(round(x)), int(round(y)))
vertices.append(point)
return vertices
def draw_canvas(self, canvas: Self, dx: int, dy: int) -> None:
self.draw_canvas_onto_canvas(canvas, dx, dy, False)
def merge_canvas(self, canvas: Self, dx: int, dy: int) -> None:
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():
dx, dy = (offset_x + x), (offset_y + y)
if not self._check_screen_bounds(dx, dy):
continue
pixel = canvas.buffer[y][x]
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
if canvas.is_textual:
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)