diff --git a/.vscode/launch.json b/.vscode/launch.json index 294d264..0af8a7d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,6 +16,15 @@ "args": ["run", "--dev", "paint.py"], "console": "integratedTerminal", "justMyCode": true + }, + { + "name": "Open A File in App", + "type": "python", + "request": "launch", + "program": "paint.py", + "args": ["LICENSE.txt"], + "console": "integratedTerminal", + "justMyCode": true } ] } \ No newline at end of file diff --git a/README.md b/README.md index d3d7cbe..a1cb731 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ This is a TUI (Text User Interface) image editor, inspired by MS Paint, and buil ## Development -Install Textual: +Install Textual and Stransi: ```bash -pip install "textual[dev]" +pip install "textual[dev]" "stransi" ``` Run supporting live-reloading CSS: diff --git a/cspell.json b/cspell.json index dfefe2e..9ce8901 100644 --- a/cspell.json +++ b/cspell.json @@ -16,6 +16,7 @@ "Odhner", "Playscii", "pypixelart", + "stransi", "undos" ] } diff --git a/paint.py b/paint.py index a50eff4..4a229a3 100644 --- a/paint.py +++ b/paint.py @@ -1,5 +1,8 @@ +import re +import sys from enum import Enum from random import randint +import stransi from rich.segment import Segment from rich.style import Style from textual import events @@ -152,6 +155,8 @@ class ColorsBox(Container): 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.""" @@ -189,15 +194,117 @@ class AnsiArtDocument: 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;" + self.bg[y][x] + ";38;2;" + self.fg[y][x] + "m" + self.ch[y][x] - ansi += "\033[0m\r" + 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.""" @@ -317,6 +424,8 @@ class PaintApp(App): selected_tool = var(Tool.pencil) selected_color = var(palette[0]) selected_char = var(" ") + filename = var(None) + image = var(None) undos = [] redos = [] @@ -387,6 +496,27 @@ class PaintApp(App): self.undos.append(undo_action) self.canvas.refresh() + def file_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.file_save_as() + + def file_save_as(self) -> None: + """Save the image as a new file.""" + raise NotImplementedError + + # def file_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"): @@ -402,7 +532,9 @@ class PaintApp(App): def on_mount(self) -> None: """Called when the app is mounted.""" - self.image = AnsiArtDocument(80, 24) + # 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 @@ -457,6 +589,16 @@ class PaintApp(App): press(self.NAME_MAP.get(key, key)) elif key == "ctrl+q" or key == "meta+q": self.exit() + elif key == "ctrl+s": + self.file_save() + elif key == "ctrl+shift+s": + self.file_save_as() + # elif key == "ctrl+o": + # self.file_open() + # elif key == "ctrl+n": + # self.file_new() + # elif key == "ctrl+shift+n": + # self.clear_image() elif key == "ctrl+t": self.show_tools_box = not self.show_tools_box elif key == "ctrl+w": @@ -466,6 +608,8 @@ class PaintApp(App): # Ctrl+Shift+Z doesn't seem to work on Ubuntu or VS Code terminal elif key == "ctrl+shift+z" or key == "shift+ctrl+z" or key == "ctrl+y" or key == "f4": self.redo() + # elif key == "ctrl+d": + # self.action_toggle_dark() def on_button_pressed(self, event: Button.Pressed) -> None: """Called when a button is clicked or activated with the keyboard.""" @@ -480,4 +624,9 @@ class PaintApp(App): if __name__ == "__main__": - PaintApp().run() + 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() diff --git a/requirements.txt b/requirements.txt index ef653ca..48725f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ rich==13.3.3 +stransi==0.3.0 textual==0.19.1