import re import sys from enum import Enum from random import randint from typing import List import stransi from rich.segment import Segment from rich.style import Style from textual import events from textual.message import Message, MessageTarget from textual.app import App, ComposeResult from textual.containers import Container, Horizontal, Vertical from textual.geometry import Offset, Region, Size from textual.css.query import NoMatches from textual.reactive import var, reactive from textual.strip import Strip from textual.widget import Widget from textual.widgets import Button, Static from menus import MenuBar, Menu, MenuItem, Separator class Tool(Enum): """The tools available in the Paint app.""" free_form_select = 1 select = 2 eraser = 3 fill = 4 pick_color = 5 magnifier = 6 pencil = 7 brush = 8 airbrush = 9 text = 10 line = 11 curve = 12 rectangle = 13 polygon = 14 ellipse = 15 rounded_rectangle = 16 def get_icon(self) -> str: """Get the icon for this tool.""" # Alternatives considered: # - Free-Form Select: โœ‚๏ธ๐Ÿ“๐Ÿ†“๐Ÿ•ธโœจโš๐Ÿซฅ๐Ÿ‡ซ/๐Ÿ‡ธโ—Œ # - Rectangular Select: โฌšโ–ง๐Ÿ”ฒ # - Eraser/Color Eraser: ๐Ÿงผ๐Ÿงฝ๐Ÿงน๐Ÿšซ๐Ÿ‘‹๐Ÿ—‘๏ธ # - Fill With Color: ๐ŸŒŠ๐Ÿ’ฆ๐Ÿ’ง๐ŸŒˆ๐ŸŽ‰๐ŸŽŠ๐Ÿชฃ๐Ÿซ— # - Pick Color: ๐ŸŽจ๐Ÿ’‰๐Ÿ’…๐Ÿ’ง๐Ÿ“Œ๐Ÿ“โคค๐€ƒ๐Ÿฏ๐Ÿถ # - Magnifier: ๐Ÿ”๐Ÿ”Ž๐Ÿ‘€๐Ÿ”ฌ๐Ÿ”ญ๐Ÿง๐Ÿ•ต๏ธโ€โ™‚๏ธ๐Ÿ•ต๏ธโ€โ™€๏ธ # - Pencil: โœ๏ธโœŽโœ๏ธ๐Ÿ–Ž๐Ÿ–Š๏ธ๐Ÿ–‹๏ธโœ’๏ธ๐Ÿ–†๐Ÿ“๐Ÿ–๏ธ # - Brush: ๐Ÿ–Œ๏ธ๐Ÿ–Œ๐Ÿ‘จโ€๐ŸŽจ๐Ÿง‘โ€๐ŸŽจ๐Ÿ’… # - Airbrush: ๐Ÿ’จแ–œโ•”๐Ÿงด๐Ÿฅค๐Ÿซ  # - Text: ๐Ÿ†Ž๐Ÿ“๐Ÿ“„๐Ÿ“ƒ๐Ÿ”ค๐Ÿ“œA๏ผก # - Line: ๐Ÿ“๐Ÿ“‰๐Ÿ“ˆโŸ๐ˆโงน # - Curve: โ†ช๏ธ๐Ÿช๐ŸŒ™ใ€ฐ๏ธโ—กโ— ~โˆผโ‰ˆโˆฝโˆฟใ€œใ€ฐ๏น‹๏นโ‰ˆโ‰‹๏ฝž # - Rectangle: โ–ญโ–ฌโ–ฎโ–ฏโ—ผ๏ธโ—ป๏ธโฌœโฌ›๐ŸŸง๐ŸŸฉ # - Polygon: โ–™๐—Ÿ๐™‡โฌฃโฌŸโ–ณโ–ฒ๐Ÿ”บ๐Ÿ”ป๐Ÿ”ท๐Ÿ”ถ # - Ellipse: โฌญ๐Ÿ”ด๐Ÿ”ต๐Ÿ”ถ๐Ÿ”ท๐Ÿ”ธ๐Ÿ”น๐ŸŸ ๐ŸŸก๐ŸŸข๐ŸŸฃ๐Ÿซง # - Rounded Rectangle: โ–ขโฌœโฌ› return { Tool.free_form_select: "โš", Tool.select: "โฌš", Tool.eraser: "๐Ÿงผ", Tool.fill: "๐ŸŒŠ", # "๐Ÿซ—" causes jutting out in Ubuntu terminal, "๐Ÿชฃ" causes the opposite in VS Code terminal Tool.pick_color: "๐Ÿ’‰", Tool.magnifier: "๐Ÿ”", Tool.pencil: "โœ๏ธ", Tool.brush: "๐Ÿ–Œ๏ธ", Tool.airbrush: "๐Ÿ’จ", Tool.text: "๏ผก", Tool.line: "โŸ", Tool.curve: "๏ฝž", Tool.rectangle: "โ–ญ", Tool.polygon: "๐™‡", Tool.ellipse: "โฌญ", Tool.rounded_rectangle: "โ–ข", }[self] def get_name(self) -> str: """Get the name of this tool.""" return { Tool.free_form_select: "Free-Form Select", Tool.select: "Rectangular Select", Tool.eraser: "Eraser/Color Eraser", Tool.fill: "Fill With Color", Tool.pick_color: "Pick Color", Tool.magnifier: "Magnifier", Tool.pencil: "Pencil", Tool.brush: "Brush", Tool.airbrush: "Airbrush", Tool.text: "Text", Tool.line: "Line", Tool.curve: "Curve", Tool.rectangle: "Rectangle", Tool.polygon: "Polygon", Tool.ellipse: "Ellipse", Tool.rounded_rectangle: "Rounded Rectangle", }[self] palette = [ "rgb(0,0,0)", # Black "rgb(128,128,128)", # Dark Gray "rgb(128,0,0)", # Dark Red "rgb(128,128,0)", # Pea Green "rgb(0,128,0)", # Dark Green "rgb(0,128,128)", # Slate "rgb(0,0,128)", # Dark Blue "rgb(128,0,128)", # Lavender "rgb(128,128,64)", "rgb(0,64,64)", "rgb(0,128,255)", "rgb(0,64,128)", "rgb(64,0,255)", "rgb(128,64,0)", "rgb(255,255,255)", # White "rgb(192,192,192)", # Light Gray "rgb(255,0,0)", # Bright Red "rgb(255,255,0)", # Yellow "rgb(0,255,0)", # Bright Green "rgb(0,255,255)", # Cyan "rgb(0,0,255)", # Bright Blue "rgb(255,0,255)", # Magenta "rgb(255,255,128)", "rgb(0,255,128)", "rgb(128,255,255)", "rgb(128,128,255)", "rgb(255,0,128)", "rgb(255,128,64)", ] class ToolsBox(Container): """Widget containing tool buttons""" def compose(self) -> ComposeResult: """Add our buttons.""" with Container(id="tools_box"): # tool buttons for tool in Tool: yield Button(tool.get_icon(), id="tool_button_" + tool.name) class ColorsBox(Container): """Color palette widget.""" def compose(self) -> ComposeResult: """Add our selected color and color well buttons.""" with Container(id="colors_box"): with Container(id="selected_colors"): yield Static(id="selected_color", classes="color_well") with Container(id="available_colors"): for color in palette: button = Button("", id="color_button_" + color, classes="color_well") button.styles.background = color yield button debug_region_updates = False ansi_escape_pattern = re.compile(r"(\N{ESC}\[[\d;]*[a-zA-Z])") class AnsiArtDocument: """A document that can be rendered as ANSI.""" def __init__(self, width: int, height: int) -> None: """Initialize the document.""" self.width = width self.height = height self.ch = [[" " for _ in range(width)] for _ in range(height)] self.bg = [["#ffffff" for _ in range(width)] for _ in range(height)] self.fg = [["#000000" for _ in range(width)] for _ in range(height)] def copy_region(self, source, source_region: Region = None, target_region: Region = None): if source_region is None: source_region = Region(0, 0, source.width, source.height) if target_region is None: target_region = Region(0, 0, source_region.width, source_region.height) source_offset = source_region.offset target_offset = target_region.offset if debug_region_updates: random_color = "rgb(" + str(randint(0, 255)) + "," + str(randint(0, 255)) + "," + str(randint(0, 255)) + ")" for y in range(target_region.height): for x in range(target_region.width): if source_region.contains(x + source_offset.x, y + source_offset.y): self.ch[y + target_offset.y][x + target_offset.x] = source.ch[y + source_offset.y][x + source_offset.x] self.bg[y + target_offset.y][x + target_offset.x] = source.bg[y + source_offset.y][x + source_offset.x] self.fg[y + target_offset.y][x + target_offset.x] = source.fg[y + source_offset.y][x + source_offset.x] if debug_region_updates: # self.bg[y + target_offset.y][x + target_offset.x] = "rgb(" + str((x + source_offset.x) * 255 // self.width) + "," + str((y + source_offset.y) * 255 // self.height) + ",0)" self.bg[y + target_offset.y][x + target_offset.x] = random_color else: if debug_region_updates: self.ch[y + target_offset.y][x + target_offset.x] = "?" self.bg[y + target_offset.y][x + target_offset.x] = "#ff00ff" self.fg[y + target_offset.y][x + target_offset.x] = "#000000" def get_ansi(self) -> str: """Get the ANSI representation of the document. Untested. This is a freebie from the AI.""" def color_to_rgb(color_code: str) -> str: """Convert a color code to the RGB values format used for ANSI escape codes.""" if color_code.startswith('#'): # Convert hex code to RGB values color_code = color_code.lstrip('#') rgb = tuple(int(color_code[i:i+2], 16) for i in (0, 2, 4)) elif color_code.startswith('rgb(') and color_code.endswith(')'): # Convert "rgb(r,g,b)" style to RGB values rgb_str = color_code[4:-1] rgb = tuple(int(x.strip()) for x in rgb_str.split(',')) else: raise ValueError("Invalid color code") return f"{rgb[0]};{rgb[1]};{rgb[2]}" ansi = "" for y in range(self.height): for x in range(self.width): if x == 0: ansi += "\033[0m" ansi += "\033[48;2;" + color_to_rgb(self.bg[y][x]) + ";38;2;" + color_to_rgb(self.fg[y][x]) + "m" + self.ch[y][x] ansi += "\033[0m\r\n" return ansi def get_html(self) -> str: """Get the HTML representation of the document.""" html = "" for y in range(self.height): for x in range(self.width): html += "" + self.ch[y][x] + "" html += "
" return html @staticmethod def from_ascii(text: str) -> 'AnsiArtDocument': """Creates a document from the given ASCII plain text.""" lines = text.splitlines() width = 0 for line in lines: width = max(len(line), width) height = len(lines) document = AnsiArtDocument(width, height) for y, line in enumerate(lines): for x, char in enumerate(line): document.ch[y][x] = char return document @staticmethod def from_ansi(text: str) -> 'AnsiArtDocument': """Creates a document from the given ANSI text.""" ansi = stransi.Ansi(text) document = AnsiArtDocument(1, 1) width = 1 height = 1 x = 0 y = 0 bg_color = "#000000" fg_color = "#ffffff" for instruction in ansi.instructions(): if isinstance(instruction, str): # Text for char in instruction: if char == '\r': x = 0 elif char == '\n': x = 0 y += 1 height = max(y, height) if len(document.ch) <= y: document.ch.append([]) document.bg.append([]) document.fg.append([]) else: x += 1 width = max(x, width) document.ch[y].append(char) document.bg[y].append(bg_color) document.fg[y].append(fg_color) elif isinstance(instruction, stransi.SetColor): # Color if instruction.role == stransi.color.ColorRole.FOREGROUND: rgb = instruction.color.rgb fg_color = "rgb(" + str(int(rgb.red * 255)) + "," + str(int(rgb.green * 255)) + "," + str(int(rgb.blue * 255)) + ")" elif instruction.role == stransi.color.ColorRole.BACKGROUND: rgb = instruction.color.rgb bg_color = "rgb(" + str(int(rgb.red * 255)) + "," + str(int(rgb.green * 255)) + "," + str(int(rgb.blue * 255)) + ")" elif isinstance(instruction, stransi.SetAttribute): # Attribute pass else: raise ValueError("Unknown instruction type") document.width = width document.height = height # Fill in the rest of the lines # just using the last color, not sure if that's correct... for y in range(document.height): for x in range(document.width - len(document.ch[y])): document.ch[y].append(' ') document.bg[y].append(bg_color) document.fg[y].append(fg_color) return document @staticmethod def from_text(text: str) -> 'AnsiArtDocument': """Creates a document from the given text, detecting if uses ANSI or not.""" if ansi_escape_pattern.search(text): return AnsiArtDocument.from_ansi(text) else: return AnsiArtDocument.from_ascii(text) class Action: """An action that can be undone efficiently using a region update.""" def __init__(self, name, document: AnsiArtDocument, region: Region = None) -> None: """Initialize the action using the document state before modification.""" if region is None: region = Region(0, 0, document.width, document.height) self.name = name self.region = region self.update(document) def update(self, document: AnsiArtDocument) -> None: """Grabs the image data from the current region of the document.""" self.sub_image_before = AnsiArtDocument(self.region.width, self.region.height) self.sub_image_before.copy_region(document, self.region) def undo(self, target_document: AnsiArtDocument) -> None: """Undo this action. Note that a canvas refresh is not performed here.""" target_document.copy_region(self.sub_image_before, target_region=self.region) def bresenham_walk(x0: int, y0: int, x1: int, y1: int) -> None: """Bresenham's line algorithm""" dx = abs(x1 - x0) dy = abs(y1 - y0) sx = 1 if x0 < x1 else -1 sy = 1 if y0 < y1 else -1 err = dx - dy while True: yield x0, y0 if x0 == x1 and y0 == y1: break e2 = 2 * err if e2 > -dy: err = err - dy x0 = x0 + sx if e2 < dx: err = err + dx y0 = y0 + sy def midpoint_ellipse(xc: int, yc: int, rx: int, ry: int) -> None: """Midpoint ellipse drawing algorithm. Yields points out of order.""" # Source: https://www.geeksforgeeks.org/midpoint-ellipse-drawing-algorithm/ x = 0 y = ry # Initial decision parameter of region 1 d1 = ((ry * ry) - (rx * rx * ry) + (0.25 * rx * rx)) dx = 2 * ry * ry * x dy = 2 * rx * rx * y # For region 1 while (dx < dy): # Yield points based on 4-way symmetry yield x + xc, y + yc yield -x + xc, y + yc yield x + xc, -y + yc yield -x + xc, -y + yc # Checking and updating value of # decision parameter based on algorithm if (d1 < 0): x += 1 dx = dx + (2 * ry * ry) d1 = d1 + dx + (ry * ry) else: x += 1 y -= 1 dx = dx + (2 * ry * ry) dy = dy - (2 * rx * rx) d1 = d1 + dx - dy + (ry * ry) # Decision parameter of region 2 d2 = (((ry * ry) * ((x + 0.5) * (x + 0.5))) + ((rx * rx) * ((y - 1) * (y - 1))) - (rx * rx * ry * ry)) # Plotting points of region 2 while (y >= 0): # Yielding points based on 4-way symmetry yield x + xc, y + yc yield -x + xc, y + yc yield x + xc, -y + yc yield -x + xc, -y + yc # Checking and updating parameter # value based on algorithm if (d2 > 0): y -= 1 dy = dy - (2 * rx * rx) d2 = d2 + (rx * rx) - dy else: y -= 1 x += 1 dx = dx + (2 * ry * ry) dy = dy - (2 * rx * rx) d2 = d2 + dx - dy + (rx * rx) class Canvas(Widget): """The image document widget.""" # Is it kosher to include an event in a message? # Is it better (and possible) to bubble up the event, even though I'm capturing the mouse? # Or would it be better to just have Canvas own duplicate state for all tool parameters? # That's what I was refactoring to avoid. So far I've made things more complicated, # but I'm betting it will be good when implementing different tools. # Maybe the PaintApp widget can capture the mouse events instead? # Not sure if that would work as nicely when implementing selections. # I'd have to think about it. # But it would make the Canvas just be a widget for rendering, which seems good. class ToolStart(Message): """Message when starting drawing.""" def __init__(self, mouse_down_event: events.MouseDown) -> None: self.mouse_down_event = mouse_down_event super().__init__() class ToolUpdate(Message): """Message when dragging on the canvas.""" def __init__(self, mouse_move_event: events.MouseMove) -> None: self.mouse_move_event = mouse_move_event super().__init__() def __init__(self, **kwargs) -> None: """Initialize the canvas.""" super().__init__(**kwargs) self.image = None self.pointer_active = False def on_mouse_down(self, event) -> None: self.post_message(self.ToolStart(event)) self.pointer_active = True self.capture_mouse(True) def on_mouse_move(self, event) -> None: # Hack to fix mouse coordinates, not needed for mouse down. # This seems like a bug. event.x += int(self.parent.scroll_x) event.y += int(self.parent.scroll_y) if self.pointer_active: self.post_message(self.ToolUpdate(event)) def on_mouse_up(self, event) -> None: self.pointer_active = False self.capture_mouse(False) def get_content_width(self, container: Size, viewport: Size) -> int: return self.image.width def get_content_height(self, container: Size, viewport: Size, width: int) -> int: return self.image.height def render_line(self, y: int) -> Strip: """Render a line of the widget. y is relative to the top of the widget.""" if y >= self.image.height: return Strip.blank(self.size.width) segments = [] for x in range(self.image.width): bg = self.image.bg[y][x] fg = self.image.fg[y][x] ch = self.image.ch[y][x] segments.append(Segment(ch, Style.parse(fg+" on "+bg))) return Strip(segments, self.size.width) class PaintApp(App): """MS Paint like image editor in the terminal.""" CSS_PATH = "paint.css" # These call action_* methods on the widget. # They can have parameters, if need be. # https://textual.textualize.io/guide/actions/ BINDINGS = [ ("ctrl+q", "quit", "Quit"), ("meta+q", "quit", "Quit"), ("ctrl+s", "save", "Save"), ("ctrl+shift+s", "save_as", "Save As"), # ("ctrl+o", "open", "Open"), # ("ctrl+n", "new", "New"), # ("ctrl+shift+n", "clear_image", "Clear Image"), ("ctrl+t", "toggle_tools_box", "Toggle Tools Box"), ("ctrl+w", "toggle_colors_box", "Toggle Colors Box"), ("ctrl+z", "undo", "Undo"), # Ctrl+Shift+Z doesn't seem to work on Ubuntu or VS Code terminal ("ctrl+shift+z", "redo", "Redo"), ("shift+ctrl+z", "redo", "Redo"), ("ctrl+y", "redo", "Redo"), ("f4", "redo", "Redo"), # action_toggle_dark is built in to App ("ctrl+d", "toggle_dark", "Toggle Dark Mode"), ] show_tools_box = var(True) show_colors_box = var(True) selected_tool = var(Tool.pencil) selected_color = var(palette[0]) selected_char = var(" ") filename = var(None) image = var(None) undos: List[Action] = [] redos: List[Action] = [] NAME_MAP = { # key to button id } def watch_show_tools_box(self, show_tools_box: bool) -> None: """Called when show_tools_box changes.""" self.query_one("#tools_box").display = show_tools_box if self.has_class("show_tools_box"): self.remove_class("show_tools_box") else: self.add_class("show_tools_box") def watch_show_colors_box(self, show_colors_box: bool) -> None: """Called when show_colors_box changes.""" self.query_one("#colors_box").display = show_colors_box if self.has_class("show_colors_box"): self.remove_class("show_colors_box") else: self.add_class("show_colors_box") def watch_selected_tool(self, old_selected_tool: Tool, selected_tool: Tool) -> None: """Called when selected_tool changes.""" self.query_one("#tool_button_" + old_selected_tool.name).classes = "tool_button" self.query_one("#tool_button_" + selected_tool.name).classes = "tool_button selected" def watch_selected_color(self, old_selected_color: str, selected_color: str) -> None: """Called when selected_color changes.""" self.query_one("#selected_color").styles.background = selected_color def stamp_brush(self, x: int, y: int, affected_region_base: Region = None) -> Region: brush_diameter = 1 if self.selected_tool == Tool.brush: brush_diameter = 3 if brush_diameter == 1: self.stamp_char(x, y) else: # plot points within a circle for i in range(brush_diameter): for j in range(brush_diameter): if (i - brush_diameter // 2) ** 2 + (j - brush_diameter // 2) ** 2 <= (brush_diameter // 2) ** 2: self.stamp_char(x + i - brush_diameter // 2, y + j - brush_diameter // 2) # expand the affected region to include the brush brush_diameter += 2 # safety margin affected_region = Region(x - brush_diameter // 2, y - brush_diameter // 2, brush_diameter, brush_diameter) if affected_region_base: return affected_region_base.union(affected_region) else: return affected_region def stamp_char(self, x: int, y: int) -> None: if x < self.image.width and y < self.image.height and x >= 0 and y >= 0: self.image.ch[y][x] = self.selected_char self.image.bg[y][x] = self.selected_color def action_undo(self) -> None: if len(self.undos) > 0: action = self.undos.pop() redo_action = Action("Undo " + action.name, self.image, action.region) action.undo(self.image) self.redos.append(redo_action) self.canvas.refresh() def action_redo(self) -> None: if len(self.redos) > 0: action = self.redos.pop() undo_action = Action("Undo " + action.name, self.image, action.region) action.undo(self.image) self.undos.append(undo_action) self.canvas.refresh() def action_save(self) -> None: """Save the image to a file.""" if self.filename: ansi = self.image.get_ansi() with open(self.filename, "w") as f: f.write(ansi) # else: # self.action_save_as() def action_save_as(self) -> None: """Save the image as a new file.""" raise NotImplementedError # def action_open(self) -> None: # """Open an image from a file.""" # filename = self.query_one("#file_open").value # if filename: # with open(filename, "r") as f: # self.image = AnsiArtDocument.from_ansi(f.read()) # self.canvas.image = self.image def compose(self) -> ComposeResult: """Add our widgets.""" with Container(id="paint"): yield MenuBar([ MenuItem("File", submenu=Menu([ # MenuItem("New", self.action_new), # MenuItem("Open", self.action_open), MenuItem("Save", self.action_save), # MenuItem("Save As", self.action_save_as), # MenuItem("Quit", self.action_quit), ])), MenuItem("Edit", submenu=Menu([ MenuItem("Undo", self.action_undo), MenuItem("Redo", self.action_redo), ])), MenuItem("View", submenu=Menu([ # MenuItem("Tools", self.toggle_tools_box), # MenuItem("Colors", self.toggle_colors_box), ])), MenuItem("Image"), MenuItem("Colors"), MenuItem("Help"), ]) yield Container( ToolsBox(), Container( Canvas(id="canvas"), id="editing-area", ), id="main-horizontal-split", ) yield ColorsBox() def on_mount(self) -> None: """Called when the app is mounted.""" # Image can be set from the outside, via CLI if self.image is None: self.image = AnsiArtDocument(80, 24) self.canvas = self.query_one("#canvas") self.canvas.image = self.image def on_canvas_tool_start(self, event: Canvas.ToolStart) -> None: """Called when the user starts drawing on the canvas.""" if self.selected_tool in [Tool.free_form_select, Tool.select, Tool.eraser, Tool.fill, Tool.pick_color, Tool.magnifier, Tool.airbrush, Tool.text, Tool.curve, Tool.polygon]: self.selected_tool = Tool.pencil # TODO: support other tools self.image_at_start = AnsiArtDocument(self.image.width, self.image.height) self.image_at_start.copy_region(self.image) self.mouse_at_start = (event.mouse_down_event.x, event.mouse_down_event.y) if len(self.redos) > 0: self.redos = [] action = Action(self.selected_tool.get_name(), self.image) self.undos.append(action) if self.selected_tool == Tool.pencil or self.selected_tool == Tool.brush: region = self.stamp_brush(event.mouse_down_event.x, event.mouse_down_event.y) action.region = region action.region = action.region.intersection(Region(0, 0, self.image.width, self.image.height)) action.update(self.image_at_start) self.canvas.refresh(region) event.stop() def on_canvas_tool_update(self, event: Canvas.ToolUpdate) -> None: """Called when the user is drawing on the canvas.""" mm = event.mouse_move_event action = self.undos[-1] affected_region = Region(mm.x, mm.y, 1, 1) replace_action = self.selected_tool in [Tool.ellipse, Tool.rectangle, Tool.line, Tool.rounded_rectangle] if replace_action: old_action = self.undos.pop() old_action.undo(self.image) action = Action(self.selected_tool.get_name(), self.image, affected_region) self.undos.append(action) if self.selected_tool == Tool.pencil or self.selected_tool == Tool.brush: for x, y in bresenham_walk(mm.x - mm.delta_x, mm.y - mm.delta_y, mm.x, mm.y): affected_region = self.stamp_brush(x, y, affected_region) elif self.selected_tool == Tool.line: for x, y in bresenham_walk(self.mouse_at_start[0], self.mouse_at_start[1], mm.x, mm.y): affected_region = self.stamp_brush(x, y, affected_region) elif self.selected_tool == Tool.rectangle: for x in range(min(self.mouse_at_start[0], mm.x), max(self.mouse_at_start[0], mm.x) + 1): for y in range(min(self.mouse_at_start[1], mm.y), max(self.mouse_at_start[1], mm.y) + 1): if x in range(min(self.mouse_at_start[0], mm.x) + 1, max(self.mouse_at_start[0], mm.x)) and y in range(min(self.mouse_at_start[1], mm.y) + 1, max(self.mouse_at_start[1], mm.y)): continue affected_region = self.stamp_brush(x, y, affected_region) elif self.selected_tool == Tool.rounded_rectangle: arc_radius = min(2, abs(self.mouse_at_start[0] - mm.x) // 2, abs(self.mouse_at_start[1] - mm.y) // 2) min_x = min(self.mouse_at_start[0], mm.x) max_x = max(self.mouse_at_start[0], mm.x) min_y = min(self.mouse_at_start[1], mm.y) max_y = max(self.mouse_at_start[1], mm.y) for x, y in midpoint_ellipse(0, 0, arc_radius, arc_radius): if x < 0: x = min_x + x + arc_radius else: x = max_x + x - arc_radius if y < 0: y = min_y + y + arc_radius else: y = max_y + y - arc_radius affected_region = self.stamp_brush(x, y, affected_region) for x in range(min_x + arc_radius, max_x - arc_radius + 1): affected_region = self.stamp_brush(x, min_y, affected_region) affected_region = self.stamp_brush(x, max_y, affected_region) for y in range(min_y + arc_radius, max_y - arc_radius + 1): affected_region = self.stamp_brush(min_x, y, affected_region) affected_region = self.stamp_brush(max_x, y, affected_region) elif self.selected_tool == Tool.ellipse: center_x = (self.mouse_at_start[0] + mm.x) // 2 center_y = (self.mouse_at_start[1] + mm.y) // 2 radius_x = abs(self.mouse_at_start[0] - mm.x) // 2 radius_y = abs(self.mouse_at_start[1] - mm.y) // 2 for x, y in midpoint_ellipse(center_x, center_y, radius_x, radius_y): affected_region = self.stamp_brush(x, y, affected_region) else: raise NotImplementedError # Update action region and image data action.region = action.region.union(affected_region) action.region = action.region.intersection(Region(0, 0, self.image.width, self.image.height)) action.update(self.image_at_start) # Only for refreshing, include replaced action region # (The new action is allowed to shrink the region compared to the old one) if replace_action: affected_region = affected_region.union(old_action.region) self.canvas.refresh(affected_region) event.stop() def on_key(self, event: events.Key) -> None: """Called when the user presses a key.""" def press(button_id: str) -> None: try: self.query_one(f"#{button_id}", Button).press() except NoMatches: pass key = event.key button_id = self.NAME_MAP.get(key) if button_id is not None: press(self.NAME_MAP.get(key, key)) def action_toggle_tools_box(self) -> None: self.show_tools_box = not self.show_tools_box def action_toggle_colors_box(self) -> None: self.show_colors_box = not self.show_colors_box def on_button_pressed(self, event: Button.Pressed) -> None: """Called when a button is clicked or activated with the keyboard.""" button_id = event.button.id assert button_id is not None if button_id.startswith("tool_button_"): self.selected_tool = Tool[button_id[len("tool_button_") :]] elif button_id.startswith("color_button_"): self.selected_color = button_id[len("color_button_") :] if __name__ == "__main__": app = PaintApp() if len(sys.argv) > 1: with open(sys.argv[1], 'r') as my_file: app.image = AnsiArtDocument.from_text(my_file.read()) app.filename = sys.argv[1] app.run()