from redbot.core import commands # isort:skip import discord # isort:skip import typing # isort:skip import typing_extensions # isort:skip import io from dataclasses import dataclass import numpy as np from PIL import Image, ImageDraw from .color import Color from .constants import ( COLUMN_ICONS, COLUMN_ICONS_DICT, IMAGE_EXTENSION, LB, MAIN_COLORS, MAIN_COLORS_DICT, PADDING, ROW_ICONS, ROW_ICONS_DICT, u200b, ) # NOQA @dataclass class Coords: x: int y: int def __post_init__(self) -> None: self.ix: int = self.x * -1 self.iy: int = self.y * -1 class Board: def __init__( self, cog: commands.Cog, *, height: typing.Optional[int] = 9, width: typing.Optional[int] = 9, background: typing.Optional[ typing.Literal["🟥", "🟧", "🟨", "🟩", "🟦", "🟪", "🟫", "⬛", "⬜", "transparent"] ] = MAIN_COLORS[ -1 ], # Literal[*MAIN_COLORS] ) -> None: self.cog: commands.Cog = cog self.height: int = height self.width: int = width self.background: str = background self.cursor_display: bool = True self.initial_board: np.ndarray = np.full( (self.height, self.width), self.background, dtype="object" ) self.board_history: typing.List[np.ndarray] = [self.initial_board.copy()] self.board_index: int = 0 self.set_attributes() # This is for the select tool. self.initial_coords: typing.Tuple[int, int] self.initial_row: int = 0 self.initial_col: int = 0 self.final_coords: typing.Tuple[int, int] self.final_row: int = 0 self.final_col: int = 0 self.clear_cursors() def set_attributes(self) -> None: self.row_labels: typing.Tuple[str] = ROW_ICONS[: self.height] self.col_labels: typing.Tuple[str] = COLUMN_ICONS[: self.width] self.centre: typing.Tuple[int, int] = ( len(self.row_labels) // 2, len(self.col_labels) // 2, ) self.centre_row, self.centre_col = self.centre self.cursor: str = self.background self.cursor_row, self.cursor_col = self.centre self.cursor_row_max = len(self.row_labels) - 1 self.cursor_col_max = len(self.col_labels) - 1 self.cursor_coords: typing.List[typing.Tuple[int, int]] = [ (self.cursor_row, self.cursor_col) ] def __str__(self) -> str: """Method that gives a formatted version of the board with row/col labels.""" cursor_rows = tuple(row for row, __ in self.cursor_coords) cursor_cols = tuple(col for __, col in self.cursor_coords) row_labels = [ (str(row) if idx not in cursor_rows else str(ROW_ICONS_DICT[row])) for idx, row in enumerate(self.row_labels) ] col_labels = [ (str(col) if idx not in cursor_cols else str(COLUMN_ICONS_DICT[col])) for idx, col in enumerate(self.col_labels) ] return ( f"{self.cursor}{PADDING}{u200b.join(col_labels)}\n" f"\n{LB.join([f'{row_labels[idx]}{PADDING}{u200b.join(row)}' for idx, row in enumerate(self.board)])}" ) async def to_image(self) -> Image: height, width = len(self.board), len(self.board[0]) cursor_rows = tuple(row for row, __ in self.cursor_coords) cursor_cols = tuple(col for __, col in self.cursor_coords) row_labels = [ (row if idx not in cursor_rows else ROW_ICONS_DICT[row]) for idx, row in enumerate(self.row_labels) ] col_labels = [ (col if idx not in cursor_cols else COLUMN_ICONS_DICT[col]) for idx, col in enumerate(self.col_labels) ] size = 25 sp = 1 if self.cursor_display else 0 _width = size * (width + 1) + sp * width + round(size / 4) _height = size * (height + 1) + sp * height + round(size / 4) img: Image.Image = Image.new("RGBA", (_width, _height), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) y = 0 for _y in range(0 if self.cursor_display else 2, height + 2): if _y == 1: y += round(size / 4) continue x = 0 for _x in range(0 if self.cursor_display else 2, width + 2): if _x == 1: x += round(size / 4) continue # draw.rounded_rectangle((x, y, x + size, y + size), radius=3, fill=(255, 255, 255), outline=(255, 0, 0) if (_y - 2, _x - 2) in self.cursor_coords else (0, 0, 0)) if _x == 0 and _y == 0: cursor = self.cursor if cursor == "transparent": x += size + sp continue image: Image.Image = await self.cog.get_pixel( MAIN_COLORS_DICT.get(cursor, cursor) ) elif _x == 0 and _y > 1: emoji = row_labels[_y - 2] image: Image.Image = await self.cog.get_pixel(emoji) elif _y == 0 and _x > 1: emoji = col_labels[_x - 2] image: Image.Image = await self.cog.get_pixel(emoji) else: pixel = self.board[_y - 2, _x - 2] if pixel == "transparent": if self.cursor_display: if (_y - 2, _x - 2) in self.cursor_coords: draw.rounded_rectangle( (x, y, x + size, y + size), radius=3, fill=None, outline=( (18, 18, 20, 255) if getattr( MAIN_COLORS_DICT.get(self.cursor, self.cursor), "RGBA", (0, 0, 0, 0), ) == (0, 0, 0, 255) else ( MAIN_COLORS_DICT.get(self.cursor, self.cursor).RGBA if isinstance( MAIN_COLORS_DICT.get(self.cursor, self.cursor), Color, ) and self.cursor != "transparent" else (255, 0, 0, 255) ) ), width=2, ) else: draw.rounded_rectangle( (x, y, x + size, y + size), radius=3, outline=(0, 0, 0, 255) ) x += size + sp continue image: Image.Image = await self.cog.get_pixel( MAIN_COLORS_DICT.get(pixel, pixel) ) image = image.resize((size, size)) mask = Image.new("L", image.size, 0) d = ImageDraw.Draw(mask) # if self.cursor_display and (_y - 2, _x - 2) in self.cursor_coords: # d.ellipse((0, 0, image.width, image.height), fill=255) # else: d.rounded_rectangle( (0, 0, image.width, image.height), radius=3 if self.cursor_display else 0, fill=255, ) img.paste(image, (x, y, x + size, y + size), mask=mask) if self.cursor_display and (_y - 2, _x - 2) in self.cursor_coords: draw.rounded_rectangle( (x, y, x + size, y + size), radius=3, fill=None, outline=( (18, 18, 20, 255) if getattr( MAIN_COLORS_DICT.get(self.cursor, self.cursor), "RGBA", (0, 0, 0, 0), ) == (0, 0, 0, 255) else ( MAIN_COLORS_DICT.get(self.cursor, self.cursor).RGBA if isinstance( MAIN_COLORS_DICT.get(self.cursor, self.cursor), Color ) and self.cursor != "transparent" else (255, 0, 0, 255) ) ), width=2, ) x += size + sp y += size + sp return img async def to_file(self) -> discord.File: img: Image.Image = await self.to_image() buffer = io.BytesIO() img.save(buffer, format=IMAGE_EXTENSION, optimize=True) buffer.seek(0) return discord.File(buffer, filename=f"image.{IMAGE_EXTENSION.lower()}") @property def board(self) -> np.ndarray: return self.board_history[self.board_index] @board.setter def board(self, board: np.ndarray): self.board_history.append(board) self.board_index += 1 @property def backup_board(self) -> np.ndarray: return self.board_history[self.board_index - 1] def modify( self, *, height: typing.Optional[int] = None, width: typing.Optional[int] = None, background: typing.Optional[ typing.Literal["🟥", "🟧", "🟨", "🟩", "🟦", "🟪", "🟫", "⬛", "⬜", "transparent"] ] = None, # typing.Literal[*MAIN_COLORS] ) -> None: height = height or self.height width = width or self.width background = background or self.background if all( (self.height == height, self.width == width, self.background == background) ): # the attributes haven't been changed return if np.array_equal( self.initial_board, self.board ): # Board has only background, so replace all pixels. self.__init__(cog=self.cog, height=height, width=width, background=background) return overlay = self.board base = np.full((height, width), background, dtype="object") # Coordinates of the centre of the overlay board overlay_centre = Coords(overlay.shape[1] // 2, overlay.shape[0] // 2) # Coordinates of the centre of the base board base_centre = Coords(base.shape[1] // 2, base.shape[0] // 2) # Difference between the centres centre_diff = Coords(base_centre.x - overlay_centre.x, base_centre.y - overlay_centre.y) # Coordinates where the overlay board should crop from # x = overlay's centre's width MINUS base's centre's width, if greater than 0, else 0 # y = overlay's centre's height MINUS base's centre's height, if greater than 0, else 0 # Meaning that if base is larger than overlay, it will include from the start of overlay overlay_from = Coords(max(centre_diff.ix, 0), max(centre_diff.iy, 0)) # Coordinates where the overlay board should crop to # x = base's total width MINUS its centre's x-coord PLUS overlay's centre's x-coord # y = base's total height MINUS its centre's y-coord PLUS overlay's centre's y-coord # This formula gives an optimal value to crop the overlay board *to*, for both # smaller and larger overlay boards overlay_to = Coords( (base.shape[1] - base_centre.x) + overlay_centre.x, (base.shape[0] - base_centre.y) + overlay_centre.y, ) # Coordinates where the base board should paste from # x = base's centre's width MINUS overlay's centre's width, if bigger than 0, else 0 # y = base's centre's height MINUS overlay's centre's height, if bigger than 0, else 0 # Meaning that if overlay is larger than base, it will start pasting from the start of base base_overlay_from = Coords(max(centre_diff.x, 0), max(centre_diff.y, 0)) # Coordinates where the base board should paste to # x = whichever is less b/w base board's width and overlay board's width PLUS x-coord of beginning (for respective offset) # y = whichever is less b/w base board's height and overlay board's height PLUS y-coord of beginning (for respective offset) base_overlay_to = Coords( min(overlay.shape[1], base.shape[1]) + base_overlay_from.x, min(overlay.shape[0], base.shape[0]) + base_overlay_from.y, ) # Crops overlay board if necessary (i.e. if base < overlay) overlay = overlay[overlay_from.y : overlay_to.y, overlay_from.x : overlay_to.x] # Pastes cropped overlay board on top of the selected portion of base board base[ base_overlay_from.y : base_overlay_to.y, base_overlay_from.x : base_overlay_to.x, ] = overlay # return Board.from_board(base, background=background) self.__init__(cog=self.cog, height=len(base), width=len(base[0]), background=background) self.board_history = [base] @property def cursor_pixel(self) -> typing.Any: return self.board[self.cursor_row, self.cursor_col] @cursor_pixel.setter def cursor_pixel(self, value: str) -> None: if not isinstance(value, str): raise TypeError("Value must be a string.") self.board[self.cursor_row, self.cursor_col] = value def get_pixel( self, row: typing.Optional[int] = None, col: typing.Optional[int] = None, ) -> typing.Any: row = row if row is not None else self.cursor_row col = col if col is not None else self.cursor_col return self.board[row, col] @classmethod def from_board( cls, cog: commands.Cog, board: np.ndarray, *, background: typing.Optional[str] = MAIN_COLORS[-1], ) -> typing_extensions.Self: height = len(board) width = len(board[0]) board_obj = cls(cog=cog, height=height, width=width, background=background) board_obj.board_history = [board] return board_obj @classmethod def from_str( cls, string: str, *, background: typing.Optional[str] = None ) -> typing_extensions.Self: lines = string.split("\n")[2:] board = [line.split(PADDING)[-1].split("\u200b") for line in lines] board = cls.from_board(np.array(board, dtype="object"), background=background) board.clear_cursors() return board def clear(self) -> None: self.draw(self.background, coords=np.array(np.where(self.board != self.background)).T) self.clear_cursors() def draw( self, color: typing.Optional[typing.Union[str, discord.Emoji, int, Color]] = None, *, coords: typing.Optional[typing.List[typing.Tuple[int, int]]] = None, ) -> bool: color = color or self.cursor color_pixel = getattr(color, "id", color) coords = coords if coords is not None else self.cursor_coords cursor_matches = [] for row, col in coords: if self.board[row, col] == color_pixel: cursor_matches.append(True) else: cursor_matches.append(False) if all(cursor_matches): return False self.board_history = self.board_history[: self.board_index + 1] self.board = self.board.copy() for row, col in coords: self.board[row, col] = color_pixel return True def clear_cursors(self, *, empty: typing.Optional[bool] = False) -> None: self.cursor_coords = [(self.cursor_row, self.cursor_col)] if empty is False else [] def move_cursor( self, row_move: typing.Optional[int] = 0, col_move: typing.Optional[int] = 0, select: typing.Optional[bool] = False, ) -> None: self.clear_cursors() self.cursor_row = (self.cursor_row + row_move) % (self.cursor_row_max + 1) self.cursor_col = (self.cursor_col + col_move) % (self.cursor_col_max + 1) if select is True: self.initial_col, self.initial_row = self.initial_coords self.final_coords = (self.cursor_row, self.cursor_col) self.final_row, self.final_col = self.final_coords self.cursor_coords = [ (row, col) for col in range( min(self.initial_col, self.final_col), max(self.initial_col, self.final_col) + 1, ) for row in range( min(self.initial_row, self.final_row), max(self.initial_row, self.final_row) + 1, ) ] else: self.cursor_coords = [(self.cursor_row, self.cursor_col)]