textual-paint/paint.py

2557 lines
119 KiB
Python
Raw Normal View History

2023-04-23 08:46:49 +03:00
#!/usr/bin/env python3
2023-04-15 08:25:31 +03:00
import os
2023-04-11 23:22:00 +03:00
import re
import sys
2023-04-19 09:08:01 +03:00
import psutil
import argparse
import asyncio
2023-04-11 00:29:04 +03:00
from enum import Enum
2023-04-14 01:48:24 +03:00
from random import randint, random
2023-04-23 04:35:21 +03:00
from typing import Any, List, Optional, Callable, Iterator, Tuple
2023-04-19 19:00:34 +03:00
from watchdog.events import PatternMatchingEventHandler, FileSystemEvent, EVENT_TYPE_CLOSED, EVENT_TYPE_OPENED
from watchdog.observers import Observer
2023-04-11 23:22:00 +03:00
import stransi
2023-04-11 07:46:07 +03:00
from rich.segment import Segment
from rich.style import Style
2023-04-10 23:51:53 +03:00
from textual import events
2023-04-23 00:49:15 +03:00
from textual.message import Message
2023-04-10 23:51:53 +03:00
from textual.app import App, ComposeResult
2023-04-23 00:49:15 +03:00
from textual.containers import Container
2023-04-11 20:21:19 +03:00
from textual.geometry import Offset, Region, Size
2023-04-23 04:11:40 +03:00
from textual.css._style_properties import BorderDefinition
2023-04-11 02:27:11 +03:00
from textual.reactive import var, reactive
2023-04-11 07:46:07 +03:00
from textual.strip import Strip
from textual.dom import DOMNode
2023-04-11 07:46:07 +03:00
from textual.widget import Widget
2023-04-23 00:49:15 +03:00
from textual.widgets import Button, Static, Input, Tree, Header
from textual.widgets._directory_tree import DirEntry
from textual.binding import Binding
2023-04-17 03:34:35 +03:00
from textual.color import Color
2023-04-13 03:49:16 +03:00
from menus import MenuBar, Menu, MenuItem, Separator
from windows import Window, DialogWindow, CharacterSelectorDialogWindow, MessageBox, get_warning_icon
from edit_colors import EditColorsDialogWindow
2023-04-18 10:26:10 +03:00
from localization.i18n import get as _, load_language
from enhanced_directory_tree import EnhancedDirectoryTree
2023-04-10 23:51:53 +03:00
observer = None
2023-04-19 09:08:01 +03:00
def restart_program():
"""Restarts the current program, after file objects and descriptors cleanup"""
try:
app.exit()
# It's meant to eventually call this, but we need it immediately (unless we delay with asyncio perhaps)
# Otherwise the terminal will be left in a state where you can't (visibly) type anything
# if you exit the app after reloading, since the new process will pick up the old terminal state.
2023-04-23 01:54:21 +03:00
app._driver.stop_application_mode() # type: ignore
except Exception as e:
print("Error stopping application mode. The command line may not work as expected. The `reset` command should restore it on Linux.", e)
try:
try:
observer.stop()
observer.join(timeout=1)
if observer.is_alive:
print("Timed out waiting for file change observer thread to stop.")
except RuntimeError as e:
# Ignore "cannot join current thread" error
# join() might be redundant, but I'm keeping it just in case something with threading changes in the future
if str(e) != "cannot join current thread":
raise
except Exception as e:
print("Error stopping file change observer:", e)
2023-04-19 09:08:01 +03:00
try:
p = psutil.Process(os.getpid())
for handler in p.open_files() + p.connections():
try:
os.close(handler.fd)
except Exception as e:
print(f"Error closing file descriptor ({handler.fd}):", e)
2023-04-19 09:08:01 +03:00
except Exception as e:
print("Error closing file descriptors:", e)
2023-04-19 09:08:01 +03:00
# python = sys.executable
# os.execl(python, python, *sys.argv)
os.execl(sys.executable, *sys.orig_argv)
2023-04-19 19:00:34 +03:00
class RestartHandler(PatternMatchingEventHandler):
"""A handler for file changes"""
def on_any_event(self, event: FileSystemEvent):
if event.event_type in (EVENT_TYPE_CLOSED, EVENT_TYPE_OPENED):
# These seem like they'd just cause trouble... they're not changes, are they?
return
print("Reloading due to FS change:", event.event_type, event.src_path)
app.screen.styles.background = "red"
# The unsaved changes prompt seems to need call_from_thread,
# or else it gets "no running event loop",
# whereas restart_program() needs to not use it,
# or else nothing happens.
# However, when app.action_reload is called from the key binding,
# it seems to work fine with or without unsaved changes.
if app.is_document_modified():
app.call_from_thread(app.action_reload)
else:
restart_program()
app.screen.styles.background = "yellow"
2023-04-19 19:00:34 +03:00
def restart_on_changes():
"""Restarts the current program when a file is changed"""
global observer
2023-04-19 19:00:34 +03:00
observer = Observer()
observer.schedule(RestartHandler(
# Don't need to restart on changes to .css, since Textual will reload them in --dev mode
# Could include localization files, but I'm not actively localizing this app at this point.
# WET: WatchDog doesn't match zero directories for **, so we have to split up any patterns that use it.
patterns=[
"**/*.py", "*.py"
],
ignore_patterns=[
".history/**/*", ".history/*",
".vscode/**/*", ".vscode/*",
".git/**/*", ".git/*",
"node_modules/**/*", "node_modules/*",
"__pycache__/**/*", "__pycache__/*",
"venv/**/*", "venv/*",
],
ignore_directories=True,
), path='.', recursive=True)
observer.start()
2023-04-19 09:08:01 +03:00
# These can go away now that args are parsed up top
2023-04-14 08:04:07 +03:00
ascii_only_icons = False
2023-04-17 03:34:35 +03:00
inspect_layout = False
2023-04-14 08:04:07 +03:00
# Command line arguments
# Please keep in sync with the README
parser = argparse.ArgumentParser(description='Paint in the terminal.')
parser.add_argument('--theme', default='light', help='Theme to use, either "light" or "dark"', choices=['light', 'dark'])
2023-04-18 10:26:10 +03:00
parser.add_argument('--language', default='en', help='Language to use', choices=['ar', 'cs', 'da', 'de', 'el', 'en', 'es', 'fi', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'nl', 'no', 'pl', 'pt', 'pt-br', 'ru', 'sk', 'sl', 'sv', 'tr', 'zh', 'zh-simplified'])
parser.add_argument('--ascii-only-icons', action='store_true', help='Use only ASCII characters for tool icons')
parser.add_argument('--inspect-layout', action='store_true', help='Inspect the layout with middle click, for development')
# This flag is for development, because it's very confusing
# to see the error message from the previous run,
# when a problem is actually solved.
# There are enough ACTUAL "that should have worked!!" moments to deal with.
# I really don't want false ones mixed in. You want to reward your brain for finding good solutions, after all.
parser.add_argument('--clear-screen', action='store_true', help='Clear the screen before starting; useful for development, to avoid seeing fixed errors')
2023-04-19 19:00:34 +03:00
parser.add_argument('--restart-on-changes', action='store_true', help='Restart the app when the source code is changed, for development')
parser.add_argument('filename', nargs='?', default=None, help='File to open')
2023-04-19 19:00:34 +03:00
if __name__ == "<run_path>":
# Arguments have to be passed like `textual run --dev "paint.py LICENSE.txt"`
# so we need to look for an argument starting with "paint.py",
# and parse the rest of the string as arguments.
args = None
for arg in sys.argv:
if arg.startswith("paint.py"):
args = parser.parse_args(arg[len("paint.py") :].split())
break
2023-04-23 01:54:21 +03:00
assert args is not None, "Couldn't find paint.py in command line arguments"
else:
args = parser.parse_args()
2023-04-18 10:26:10 +03:00
load_language(args.language)
2023-04-19 19:00:34 +03:00
if args.restart_on_changes:
restart_on_changes()
2023-04-18 10:26:10 +03:00
# Most arguments are handled at the end of the file.
2023-04-11 00:29:04 +03:00
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: ✂️📐🆓🕸✨⚝🫥🇫/🇸◌⁛⁘ ⢼⠮
# - Select: ⬚▧🔲 ⣏⣹
# - Eraser/Color Eraser: 🧼🧽🧹🚫👋🗑️▰▱
# - Fill With Color: 🌊💦💧🌈🎉🎊🪣🫗
# - Pick Color: 🎨💉💅💧📌📍⤤𝀃🝯🍶
2023-04-11 00:29:04 +03:00
# - Magnifier: 🔍🔎👀🔬🔭🧐🕵️‍♂️🕵️‍♀️
# - Pencil: ✏️✎✍️🖎🖊️🖋️✒️🖆📝🖍️
2023-04-11 00:29:04 +03:00
# - Brush: 🖌️🖌👨‍🎨🧑‍🎨💅
# - Airbrush: 💨ᖜ╔🧴🥤🫠
# - Text: 🆎📝📄📃🔤📜A
2023-04-13 16:15:22 +03:00
# - Line: 📏📉📈⟍𝈏╲⧹\
# - Curve: ↪️🪝🌙〰️◡◠~∼≈∽∿〜〰﹋﹏≈≋~⁓
# - Rectangle: ▭▬▮▯🟥🟧🟨🟩🟦🟪🟫⬛⬜◼️◻️◾◽▪️▫️
# - Polygon: ▙𝗟𝙇﹄』⬣⬟🔶🔷🔸🔹🔺🔻△▲
# - Ellipse: ⬭⭕🔴🟠🟡🟢🔵🟣🟤⚫⚪🫧
2023-04-11 00:29:04 +03:00
# - Rounded Rectangle: ▢⬜⬛
2023-04-14 08:04:07 +03:00
if ascii_only_icons:
return {
Tool.free_form_select: "<[u]^[/]7", # "*" "<^>" "<[u]^[/]7"
Tool.select: "::", # "#" "::" ":_:" ":[u]:[/]:" ":[u]'[/]:"
Tool.eraser: "[u]/[/]7", # "47" "27" "/_/" "[u]/[/]7"
Tool.fill: "[u i]H[/]?", # "#?" "H?" "[u i]F[/]?"
Tool.pick_color: "[u i] P[/]", # "[u].[/]" "[u i]\\P[/]"
Tool.magnifier: ",O", # ",O" "o-" "O-" "o=" "O=" "Q"
Tool.pencil: "-==", # "c==>" "==-"
Tool.brush: "E)=", # "[u],h.[/u]" "[u],|.[/u]" "[u]h[/u]"
Tool.airbrush: "[u i]H[/]`<", # "H`" "H`<" "[u i]H[/]`<" "[u i]6[/]<"
Tool.text: "A", # "Abc"
Tool.line: "\\",
Tool.curve: "~", # "~" "S" "s"
Tool.rectangle: "[_]", # "[]"
Tool.polygon: "[b]L[/b]", # "L"
Tool.ellipse: "O", # "()"
Tool.rounded_rectangle: "(_)",
}[self]
2023-04-11 00:29:04 +03:00
return {
Tool.free_form_select: "",
Tool.select: "",
Tool.eraser: "🧼",
Tool.fill: "🌊", # "🫗" causes jutting out in Ubuntu terminal, "🪣" causes the opposite in VS Code terminal
2023-04-11 00:29:04 +03:00
Tool.pick_color: "💉",
Tool.magnifier: "🔍",
Tool.pencil: "✏️",
Tool.brush: "🖌️",
Tool.airbrush: "💨",
Tool.text: "",
2023-04-11 00:29:04 +03:00
Tool.line: "",
Tool.curve: "",
Tool.rectangle: "",
Tool.polygon: "𝙇",
Tool.ellipse: "",
Tool.rounded_rectangle: "",
}[self]
def get_name(self) -> str:
"""Get the localized name for this tool.
Not to be confused with tool.name, which is an identifier."""
2023-04-11 00:29:04 +03:00
return {
Tool.free_form_select: _("Free-Form Select"),
Tool.select: _("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"),
2023-04-11 00:29:04 +03:00
}[self]
2023-04-10 23:51:53 +03:00
2023-04-11 06:25:32 +03:00
palette = [
2023-04-20 02:58:07 +03:00
"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)",
2023-04-11 06:25:32 +03:00
2023-04-20 02:58:07 +03:00
"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)",
2023-04-11 06:25:32 +03:00
]
class ToolsBox(Container):
"""Widget containing tool buttons"""
class ToolSelected(Message):
"""Message sent when a tool is selected."""
def __init__(self, tool: Tool) -> None:
self.tool = tool
super().__init__()
def compose(self) -> ComposeResult:
"""Add our buttons."""
2023-04-23 01:54:21 +03:00
self.tool_by_button: dict[Button, Tool] = {}
for tool in Tool:
# TODO: tooltip with tool.get_name()
button = Button(tool.get_icon(), classes="tool_button")
button.can_focus = False
self.tool_by_button[button] = tool
yield button
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Called when a button is clicked."""
if "tool_button" in event.button.classes:
self.post_message(self.ToolSelected(self.tool_by_button[event.button]))
class CharInput(Input, inherit_bindings=False):
2023-04-20 07:29:14 +03:00
"""Widget for entering a single character."""
class CharSelected(Message):
"""Message sent when a character is selected."""
def __init__(self, char: str) -> None:
self.char = char
super().__init__()
2023-04-20 07:29:14 +03:00
def validate_value(self, value: str) -> str:
"""Limit the value to a single character."""
return value[-1] if value else " "
def watch_value(self, value: str) -> None:
"""Called when value changes."""
self.post_message(self.CharSelected(value))
2023-04-20 07:29:14 +03:00
def validate_cursor_position(self, position: int) -> int:
"""Force the cursor position to 0 so that it's over the character."""
return 0
def insert_text_at_cursor(self, text: str) -> None:
"""Override to limit the value to a single character."""
self.value = text[-1] if text else " "
last_click_time = 0
def on_click(self, event: events.Click) -> None:
"""Detect double click and open character selector dialog."""
if event.time - self.last_click_time < 0.8:
2023-04-22 21:14:19 +03:00
assert isinstance(self.app, PaintApp)
2023-04-20 07:29:14 +03:00
self.app.action_open_character_selector()
self.last_click_time = event.time
2023-04-11 06:25:32 +03:00
class ColorsBox(Container):
"""Color palette widget."""
class ColorSelected(Message):
"""Message sent when a color is selected."""
def __init__(self, color: str) -> None:
self.color = color
super().__init__()
2023-04-11 06:25:32 +03:00
def compose(self) -> ComposeResult:
2023-04-11 06:56:47 +03:00
"""Add our selected color and color well buttons."""
2023-04-23 01:54:21 +03:00
self.color_by_button: dict[Button, str] = {}
with Container(id="palette_selection_box"):
# This widget is doing double duty, showing the current color
# and showing/editing the current character.
# I haven't settled on naming for this yet.
yield CharInput(id="selected_color_char_input", classes="color_well")
with Container(id="available_colors"):
for color in palette:
button = Button("", classes="color_button color_well")
button.styles.background = color
button.can_focus = False
self.color_by_button[button] = color
yield button
2023-04-11 06:25:32 +03:00
def update_palette(self) -> None: # , palette: list[str]) -> None:
"""Update the palette with new colors."""
for button, color in zip(self.query(".color_button").nodes, palette):
assert isinstance(button, Button)
button.styles.background = color
self.color_by_button[button] = color
last_click_time = 0
last_click_button: Button | None = None
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Called when a button is clicked."""
if "color_button" in event.button.classes:
self.post_message(self.ColorSelected(self.color_by_button[event.button]))
# Detect double click and open Edit Colors dialog.
if event.time - self.last_click_time < 0.8 and event.button == self.last_click_button:
assert isinstance(self.app, PaintApp)
self.app.action_edit_colors(self.query(".color_button").nodes.index(event.button))
self.last_click_time = event.time
self.last_click_button = event.button
2023-04-11 21:37:32 +03:00
2023-04-21 22:05:41 +03:00
class Selection:
"""
A selection within an AnsiArtDocument.
AnsiArtDocument can contain a Selection, and Selection can contain an AnsiArtDocument.
However, the selection's AnsiArtDocument should never itself contain a Selection.
When a selection is created, it has no image data, but once it's dragged,
it gets a copy of the image data from the document.
The image data is stored as an AnsiArtDocument, since it's made up of characters and colors.
"""
def __init__(self, region: Region) -> None:
"""Initialize a selection."""
2023-04-22 04:19:05 +03:00
self.region: Region = region
"""The region of the selection within the outer document."""
2023-04-21 22:05:41 +03:00
self.contained_image: Optional[AnsiArtDocument] = None
2023-04-22 04:19:05 +03:00
"""The image data contained in the selection, None until dragged, except for text boxes."""
2023-04-21 22:05:41 +03:00
self.textbox_mode = False
2023-04-22 04:19:05 +03:00
"""Whether the selection is a text box. Either way it's text, but it's a different editing mode."""
self.text_selection_start = Offset(0, 0)
"""The start position of the text selection within the text box. This may be before or after the end."""""
self.text_selection_end = Offset(0, 0)
"""The end position of the text selection within the text box. This may be before or after the start."""""
2023-04-23 05:49:58 +03:00
self.mask: Optional[list[list[bool]]] = None
"""A mask of the selection to cut out, used for Free-Form Select tool. Coordinates are relative to the selection region."""
2023-04-21 22:05:41 +03:00
def copy_from_document(self, document: 'AnsiArtDocument') -> None:
"""Copy the image data from the document into the selection."""
self.contained_image = AnsiArtDocument(self.region.width, self.region.height)
self.contained_image.copy_region(source=document, source_region=self.region)
def copy_to_document(self, document: 'AnsiArtDocument') -> None:
"""Draw the selection onto the document."""
if not self.contained_image:
# raise ValueError("Selection has no image data.")
return
2023-04-21 23:03:43 +03:00
target_region = self.region.intersection(Region(0, 0, document.width, document.height))
2023-04-23 05:49:58 +03:00
document.copy_region(source=self.contained_image, target_region=target_region, mask=self.mask)
2023-04-21 22:05:41 +03:00
2023-04-11 22:20:31 +03:00
debug_region_updates = False
2023-04-11 21:37:32 +03:00
2023-04-11 23:22:00 +03:00
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)]
2023-04-21 22:05:41 +03:00
self.selection: Optional[Selection] = None
2023-04-23 05:49:58 +03:00
def copy_region(self, source: 'AnsiArtDocument', source_region: Region|None = None, target_region: Region|None = None, mask: list[list[bool]]|None = None) -> None:
2023-04-11 20:21:19 +03:00
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)
2023-04-11 21:14:23 +03:00
source_offset = source_region.offset
target_offset = target_region.offset
2023-04-22 21:24:11 +03:00
random_color: Optional[str] = None # avoid "possibly unbound"
2023-04-11 21:37:32 +03:00
if debug_region_updates:
random_color = "rgb(" + str(randint(0, 255)) + "," + str(randint(0, 255)) + "," + str(randint(0, 255)) + ")"
2023-04-11 20:21:19 +03:00
for y in range(target_region.height):
for x in range(target_region.width):
2023-04-23 05:49:58 +03:00
if source_region.contains(x + source_offset.x, y + source_offset.y) and (mask is None or mask[y][x]):
2023-04-11 21:14:23 +03:00
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]
2023-04-11 21:37:32 +03:00
if debug_region_updates:
2023-04-22 21:24:11 +03:00
assert random_color is not None
2023-04-11 21:37:32 +03:00
# 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
2023-04-11 21:14:23 +03:00
else:
2023-04-11 21:37:32 +03:00
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"
2023-04-11 20:21:19 +03:00
def get_ansi(self) -> str:
2023-04-18 09:55:11 +03:00
"""Get the ANSI representation of the document."""
# TODO: try using Rich API to generate ANSI, like how the Canvas renders to the screen
# TODO: generate more efficient ANSI, e.g. don't repeat the same color codes
2023-04-11 23:22:00 +03:00
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"
2023-04-11 23:22:00 +03:00
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
2023-04-11 23:22:00 +03:00
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 += "<span style='background-color:" + self.bg[y][x] + ";color:" + self.fg[y][x] + "'>" + self.ch[y][x] + "</span>"
html += "<br>"
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."""
2023-04-18 09:55:11 +03:00
# TODO: use Rich API to render ANSI to a virtual screen,
# and remove dependency on stransi
2023-04-11 23:22:00 +03:00
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)
2023-04-23 01:54:21 +03:00
elif isinstance(instruction, stransi.SetColor) and instruction.color is not None:
# Color (I'm not sure why instruction.color would be None, but it's typed as Optional[Color])
# (maybe just for initial state?)
2023-04-11 23:22:00 +03:00
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)
2023-04-11 20:21:19 +03:00
class Action:
"""An action that can be undone efficiently using a region update."""
2023-04-23 01:54:21 +03:00
def __init__(self, name: str, document: AnsiArtDocument, region: Region|None = None) -> None:
2023-04-11 21:14:23 +03:00
"""Initialize the action using the document state before modification."""
2023-04-11 20:21:19 +03:00
if region is None:
region = Region(0, 0, document.width, document.height)
self.name = name
self.region = region
2023-04-11 21:14:23 +03:00
self.update(document)
def update(self, document: AnsiArtDocument) -> None:
"""Grabs the image data from the current region of the document."""
if self.region:
self.sub_image_before = AnsiArtDocument(self.region.width, self.region.height)
self.sub_image_before.copy_region(document, self.region)
2023-04-11 20:21:19 +03:00
def undo(self, target_document: AnsiArtDocument) -> None:
2023-04-11 20:21:19 +03:00
"""Undo this action. Note that a canvas refresh is not performed here."""
target_document.copy_region(self.sub_image_before, target_region=self.region)
2023-04-11 20:21:19 +03:00
2023-04-22 09:27:54 +03:00
def bresenham_walk(x0: int, y0: int, x1: int, y1: int) -> Iterator[Tuple[int, int]]:
"""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
2023-04-22 05:51:00 +03:00
2023-04-22 09:27:54 +03:00
def polygon_walk(points: List[Offset]) -> Iterator[Tuple[int, int]]:
2023-04-22 08:04:19 +03:00
"""Yields points along the perimeter of a polygon."""
for i in range(len(points)):
yield from bresenham_walk(
points[i][0],
points[i][1],
points[(i + 1) % len(points)][0],
points[(i + 1) % len(points)][1]
)
2023-04-22 09:27:54 +03:00
def polyline_walk(points: List[Offset]) -> Iterator[Tuple[int, int]]:
2023-04-22 08:04:19 +03:00
"""Yields points along a polyline (unclosed polygon)."""
for i in range(len(points) - 1):
yield from bresenham_walk(
points[i][0],
points[i][1],
points[i + 1][0],
points[i + 1][1]
)
2023-04-23 05:49:58 +03:00
def is_inside_polygon(x: int, y: int, points: List[Offset]) -> bool:
"""Returns True if the point is inside the polygon."""
# https://stackoverflow.com/a/217578
n = len(points)
inside = False
p1x, p1y = points[0]
for i in range(n + 1):
p2x, p2y = points[i % n]
if y > min(p1y, p2y):
if y <= max(p1y, p2y):
if x <= max(p1x, p2x):
if p1y != p2y:
xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
if p1x == p2x or x <= xinters:
inside = not inside
p1x, p1y = p2x, p2y
return inside
# def polygon_fill(points: List[Offset]) -> Iterator[Tuple[int, int]]:
# """Yields points inside a polygon."""
# # Find the bounding box
# min_x = min(points, key=lambda p: p[0])[0]
# min_y = min(points, key=lambda p: p[1])[1]
# max_x = max(points, key=lambda p: p[0])[0]
# max_y = max(points, key=lambda p: p[1])[1]
# # Check each point in the bounding box, and yield any points that are inside the polygon
# for x in range(min_x, max_x + 1):
# for y in range(min_y, max_y + 1):
# if is_inside_polygon(x, y, points):
# yield x, y
2023-04-22 05:51:00 +03:00
# adapted from https://github.com/Pomax/bezierjs
2023-04-23 01:54:21 +03:00
def compute_bezier(t: float, start_x: float, start_y: float, control_1_x: float, control_1_y: float, control_2_x: float, control_2_y: float, end_x: float, end_y: float):
2023-04-22 05:51:00 +03:00
mt = 1 - t
mt2 = mt * mt
t2 = t * t
a = mt2 * mt
b = mt2 * t * 3
c = mt * t2 * 3
d = t * t2
return (
a * start_x + b * control_1_x + c * control_2_x + d * end_x,
a * start_y + b * control_1_y + c * control_2_y + d * end_y,
)
# It's possible to walk a bezier curve more correctly,
# but is it possible to tell the difference?
2023-04-23 01:54:21 +03:00
def bezier_curve_walk(start_x: float, start_y: float, control_1_x: float, control_1_y: float, control_2_x: float, control_2_y: float, end_x: float, end_y: float):
2023-04-22 08:04:19 +03:00
"""Yields points along a bezier curve."""
2023-04-22 05:51:00 +03:00
steps = 100
point_a = (start_x, start_y)
# TypeError: 'float' object cannot be interpreted as an integer
# for t in range(0, 1, 1 / steps):
for i in range(steps):
t = i / steps
point_b = compute_bezier(t, start_x, start_y, control_1_x, control_1_y, control_2_x, control_2_y, end_x, end_y)
yield from bresenham_walk(int(point_a[0]), int(point_a[1]), int(point_b[0]), int(point_b[1]))
point_a = point_b
2023-04-23 01:54:21 +03:00
def quadratic_curve_walk(start_x: float, start_y: float, control_x: float, control_y: float, end_x: float, end_y: float):
2023-04-22 08:04:19 +03:00
"""Yields points along a quadratic curve."""
2023-04-22 05:51:00 +03:00
return bezier_curve_walk(start_x, start_y, control_x, control_y, control_x, control_y, end_x, end_y)
2023-04-22 09:27:54 +03:00
def midpoint_ellipse(xc: int, yc: int, rx: int, ry: int) -> Iterator[Tuple[int, int]]:
"""Midpoint ellipse drawing algorithm. Yields points out of order, and thus can't legally be called a "walk", except in Britain."""
2023-04-12 05:24:39 +03:00
# 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)
2023-04-22 09:27:54 +03:00
def flood_fill(document: AnsiArtDocument, x: int, y: int, fill_ch: str, fill_fg: str, fill_bg: str) -> Region|None:
"""Flood fill algorithm."""
# Get the original value of the cell.
# This is the color to be replaced.
original_fg = document.fg[y][x]
original_bg = document.bg[y][x]
original_ch = document.ch[y][x]
# Track the region affected by the fill.
min_x = x
min_y = y
max_x = x
max_y = y
def inside(x: int, y: int) -> bool:
"""Returns true if the cell at the given coordinates matches the color to be replaced. Treats foreground color as equal if character is a space."""
if x < 0 or x >= document.width or y < 0 or y >= document.height:
return False
return (
document.ch[y][x] == original_ch and
document.bg[y][x] == original_bg and
(original_ch == " " or document.fg[y][x] == original_fg)
)
def set_cell(x: int, y: int) -> None:
"""Sets the cell at the given coordinates to the fill color, and updates the region bounds."""
document.ch[y][x] = fill_ch
document.fg[y][x] = fill_fg
document.bg[y][x] = fill_bg
nonlocal min_x, min_y, max_x, max_y
min_x = min(min_x, x)
min_y = min(min_y, y)
max_x = max(max_x, x)
max_y = max(max_y, y)
# Simple translation of the "final, combined-scan-and-fill span filler"
# pseudo-code from https://en.wikipedia.org/wiki/Flood_fill
if not inside(x, y):
return
2023-04-23 01:54:21 +03:00
stack: list[tuple[int, int, int, int]] = [(x, x, y, 1), (x, x, y - 1, -1)]
while stack:
x1, x2, y, dy = stack.pop()
x = x1
if inside(x, y):
while inside(x - 1, y):
set_cell(x - 1, y)
x = x - 1
if x < x1:
stack.append((x, x1-1, y-dy, -dy))
while x1 <= x2:
while inside(x1, y):
set_cell(x1, y)
x1 = x1 + 1
stack.append((x, x1 - 1, y+dy, dy))
if x1 - 1 > x2:
stack.append((x2 + 1, x1 - 1, y-dy, -dy))
x1 = x1 + 1
while x1 < x2 and not inside(x1, y):
x1 = x1 + 1
x = x1
# Return the affected region.
return Region(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1)
2023-04-12 05:24:39 +03:00
2023-04-22 02:44:25 +03:00
def scale_region(region: Region, scale: int) -> Region:
"""Returns the region scaled by the given factor."""
return Region(region.x * scale, region.y * scale, region.width * scale, region.height * scale)
2023-04-11 07:46:07 +03:00
class Canvas(Widget):
2023-04-11 04:25:01 +03:00
"""The image document widget."""
2023-04-20 02:18:29 +03:00
magnification = reactive(1, layout=True)
# 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.
2023-04-11 20:21:19 +03:00
# 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__()
2023-04-21 22:05:41 +03:00
class ToolStop(Message):
"""Message when releasing the mouse."""
def __init__(self, mouse_up_event: events.MouseUp) -> None:
self.mouse_up_event = mouse_up_event
super().__init__()
2023-04-14 03:28:58 +03:00
class ToolPreviewUpdate(Message):
"""Message when moving the mouse while the mouse is up."""
def __init__(self, mouse_move_event: events.MouseMove) -> None:
self.mouse_move_event = mouse_move_event
super().__init__()
class ToolPreviewStop(Message):
"""Message when the mouse leaves the canvas while previewing (not while drawing)."""
def __init__(self) -> None:
super().__init__()
2023-04-23 04:35:21 +03:00
def __init__(self, **kwargs: Any) -> None:
2023-04-11 04:25:01 +03:00
"""Initialize the canvas."""
super().__init__(**kwargs)
2023-04-22 09:27:54 +03:00
self.image: AnsiArtDocument|None = None
self.pointer_active: bool = False
2023-04-20 03:51:12 +03:00
self.magnifier_preview_region: Optional[Region] = None
2023-04-21 20:11:39 +03:00
self.select_preview_region: Optional[Region] = None
2023-04-11 04:25:01 +03:00
2023-04-23 01:54:21 +03:00
def on_mouse_down(self, event: events.MouseDown) -> None:
# self.fix_mouse_event(event) # not needed, pointer isn't captured yet.
2023-04-20 02:18:29 +03:00
event.x //= self.magnification
event.y //= self.magnification
self.post_message(self.ToolStart(event))
2023-04-11 04:30:02 +03:00
self.pointer_active = True
self.capture_mouse(True)
2023-04-11 04:30:02 +03:00
2023-04-23 01:54:21 +03:00
def fix_mouse_event(self, event: events.MouseEvent) -> None:
# Hack to fix mouse coordinates, not needed for mouse down,
# or while the mouse is up.
2023-04-11 15:48:02 +03:00
# This seems like a bug.
# I think it's due to coordinates being calculated differently during mouse capture.
if self.pointer_active:
2023-04-22 21:14:19 +03:00
assert isinstance(self.parent, Widget)
event.x += int(self.parent.scroll_x)
event.y += int(self.parent.scroll_y)
2023-04-11 15:48:02 +03:00
2023-04-23 01:54:21 +03:00
def on_mouse_move(self, event: events.MouseMove) -> None:
self.fix_mouse_event(event)
2023-04-20 02:18:29 +03:00
event.x //= self.magnification
event.y //= self.magnification
event.delta_x //= self.magnification
event.delta_y //= self.magnification
2023-04-11 04:30:02 +03:00
if self.pointer_active:
self.post_message(self.ToolUpdate(event))
2023-04-14 03:28:58 +03:00
else:
self.post_message(self.ToolPreviewUpdate(event))
2023-04-11 05:24:22 +03:00
2023-04-23 01:54:21 +03:00
def on_mouse_up(self, event: events.MouseUp) -> None:
self.fix_mouse_event(event)
event.x //= self.magnification
event.y //= self.magnification
2023-04-11 04:30:02 +03:00
self.pointer_active = False
self.capture_mouse(False)
2023-04-21 22:05:41 +03:00
self.post_message(self.ToolStop(event))
2023-04-11 04:25:01 +03:00
2023-04-23 01:54:21 +03:00
def on_leave(self, event: events.Leave) -> None:
if not self.pointer_active:
self.post_message(self.ToolPreviewStop())
2023-04-11 15:46:57 +03:00
def get_content_width(self, container: Size, viewport: Size) -> int:
2023-04-20 02:18:29 +03:00
return self.image.width * self.magnification
2023-04-11 15:46:57 +03:00
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
2023-04-20 02:18:29 +03:00
return self.image.height * self.magnification
2023-04-11 15:46:57 +03:00
2023-04-11 07:46:07 +03:00
def render_line(self, y: int) -> Strip:
"""Render a line of the widget. y is relative to the top of the widget."""
2023-04-23 01:54:21 +03:00
assert self.image is not None
2023-04-20 02:18:29 +03:00
# self.size.width/height already is multiplied by self.magnification.
if y >= self.size.height:
2023-04-11 07:46:07 +03:00
return Strip.blank(self.size.width)
2023-04-23 01:54:21 +03:00
segments: List[Segment] = []
2023-04-21 22:05:41 +03:00
sel = self.image.selection
2023-04-22 21:24:11 +03:00
# Avoiding "possibly unbound" errors.
magnifier_preview_region = None
inner_magnifier_preview_region = None
select_preview_region = None
inner_select_preview_region = None
selection_region = None
inner_selection_region = None
2023-04-20 03:51:12 +03:00
if self.magnifier_preview_region:
2023-04-22 02:44:25 +03:00
magnifier_preview_region = scale_region(self.magnifier_preview_region, self.magnification)
inner_magnifier_preview_region = magnifier_preview_region.shrink((1, 1, 1, 1))
2023-04-21 20:11:39 +03:00
if self.select_preview_region:
2023-04-22 02:44:25 +03:00
select_preview_region = scale_region(self.select_preview_region, self.magnification)
inner_select_preview_region = select_preview_region.shrink((1, 1, 1, 1))
2023-04-21 22:05:41 +03:00
if sel:
2023-04-22 02:44:25 +03:00
selection_region = scale_region(sel.region, self.magnification)
inner_selection_region = selection_region.shrink((1, 1, 1, 1))
2023-04-20 02:18:29 +03:00
for x in range(self.size.width):
cell_x = x // self.magnification
cell_y = y // self.magnification
try:
2023-04-23 05:49:58 +03:00
if sel and sel.contained_image and sel.region.contains(cell_x, cell_y) and (sel.mask is None or sel.mask[cell_y - sel.region.y][cell_x - sel.region.x]):
bg = sel.contained_image.bg[cell_y - sel.region.y][cell_x - sel.region.x]
fg = sel.contained_image.fg[cell_y - sel.region.y][cell_x - sel.region.x]
ch = sel.contained_image.ch[cell_y - sel.region.y][cell_x - sel.region.x]
2023-04-21 22:05:41 +03:00
else:
bg = self.image.bg[cell_y][cell_x]
fg = self.image.fg[cell_y][cell_x]
ch = self.image.ch[cell_y][cell_x]
except IndexError:
# This should be easier to debug visually.
bg = "#555555"
fg = "#cccccc"
ch = "?"
if self.magnification > 1:
ch = self.big_ch(ch, x % self.magnification, y % self.magnification)
2023-04-20 03:51:12 +03:00
style = Style.parse(fg+" on "+bg)
assert style.color is not None
assert style.bgcolor is not None
2023-04-22 04:19:05 +03:00
# def offset_to_text_index(offset) -> int:
# # return offset.y * sel.region.width + offset.x
# # return offset.y * self.image.width + offset.x
2023-04-21 20:11:39 +03:00
if (
2023-04-22 02:44:25 +03:00
self.magnifier_preview_region and magnifier_preview_region.contains(x, y) and not inner_magnifier_preview_region.contains(x, y) or
self.select_preview_region and select_preview_region.contains(x, y) and not inner_select_preview_region.contains(x, y) or
2023-04-22 04:19:05 +03:00
sel and selection_region.contains(x, y) and not inner_selection_region.contains(x, y) or
sel and sel.textbox_mode and (
# offset_to_text_index(sel.text_selection_start) <=
# offset_to_text_index(Offset(x, y))
# < offset_to_text_index(sel.text_selection_end)
# sel.text_selection_start.x <= cell_x - sel.region.x < sel.text_selection_end.x and
# sel.text_selection_start.y <= cell_y - sel.region.y < sel.text_selection_end.y
sel.text_selection_start.x == cell_x - sel.region.x and
sel.text_selection_start.y == cell_y - sel.region.y
)
2023-04-21 20:11:39 +03:00
):
2023-04-20 03:51:12 +03:00
# invert the colors
style = Style.parse(f"rgb({255 - style.color.triplet.red},{255 - style.color.triplet.green},{255 - style.color.triplet.blue}) on rgb({255 - style.bgcolor.triplet.red},{255 - style.bgcolor.triplet.green},{255 - style.bgcolor.triplet.blue})")
segments.append(Segment(ch, style))
2023-04-11 07:46:07 +03:00
return Strip(segments, self.size.width)
2023-04-20 02:18:29 +03:00
def refresh_scaled_region(self, region: Region) -> None:
"""Refresh a region of the widget, scaled by the magnification."""
if self.magnification == 1:
self.refresh(region)
return
2023-04-20 03:51:12 +03:00
# TODO: are these offsets needed? I added them because of a problem which I've fixed
2023-04-20 02:18:29 +03:00
self.refresh(Region(
(region.x - 1) * self.magnification,
(region.y - 1) * self.magnification,
(region.width + 2) * self.magnification,
(region.height + 2) * self.magnification,
))
def big_ch(self, ch: str, x: int, y: int) -> str:
"""Return a character part of a meta-glyph."""
match ch:
case " ":
return " "
case "":
return ""
case "":
return "" if y >= self.magnification // 2 else " "
case "":
return "" if y < self.magnification // 2 else " "
case "":
return "" if x < self.magnification // 2 else " "
case "":
return "" if x >= self.magnification // 2 else " "
2023-04-23 01:54:21 +03:00
case _: pass
# Fall back to showing the character for a single cell.
# if x == 0 and y == 0:
if x == self.magnification // 2 and y == self.magnification // 2:
return ch
else:
return " "
2023-04-11 07:46:07 +03:00
2023-04-11 04:25:01 +03:00
2023-04-23 01:54:21 +03:00
class PaintApp(App[None]):
2023-04-10 23:54:14 +03:00
"""MS Paint like image editor in the terminal."""
2023-04-10 23:51:53 +03:00
2023-04-10 23:54:14 +03:00
CSS_PATH = "paint.css"
2023-04-10 23:51:53 +03:00
# These call action_* methods on the widget.
# They can have parameters, if need be.
# https://textual.textualize.io/guide/actions/
2023-04-17 02:25:02 +03:00
#
# KEEP IN SYNC with the README.md Usage section, please.
BINDINGS = [
# There is a built-in "quit" action, but it will quit without asking to save.
# It's also bound to Ctrl+C by default, so for now I'll rebind it,
# but eventually Ctrl+C will become Edit > Copy.
Binding("ctrl+q,ctrl+c", "exit", _("Quit")),
Binding("ctrl+s", "save", _("Save")),
Binding("ctrl+shift+s", "save_as", _("Save As")),
Binding("ctrl+p", "print", _("Print")),
Binding("ctrl+o", "open", _("Open")),
Binding("ctrl+n", "new", _("New")),
Binding("ctrl+shift+n", "clear_image", _("Clear Image")),
Binding("ctrl+t", "toggle_tools_box", _("Toggle Tools Box")),
Binding("ctrl+w", "toggle_colors_box", _("Toggle Colors Box")),
Binding("ctrl+z", "undo", _("Undo")),
# Ctrl+Shift+<key> doesn't seem to work on Ubuntu or VS Code terminal,
# it ignores the Shift.
Binding("ctrl+shift+z,shift+ctrl+z,ctrl+y,f4", "redo", _("Repeat")),
Binding("ctrl+x", "cut", _("Cut")),
# Binding("ctrl+c", "copy", _("Copy")), # Quit, for now
Binding("ctrl+v", "paste", _("Paste")),
Binding("ctrl+g", "toggle_grid", _("Show Grid")),
Binding("ctrl+f", "view_bitmap", _("View Bitmap")),
Binding("ctrl+r", "flip_rotate", _("Flip/Rotate")),
Binding("ctrl+w", "stretch_skew", _("Stretch/Skew")),
Binding("ctrl+i", "invert_colors", _("Invert Colors")),
Binding("ctrl+e", "attributes", _("Attributes")),
2023-04-22 04:19:05 +03:00
# TODO: don't delete textbox with delete key
Binding("delete", "clear_selection", _("Clear Selection")),
Binding("ctrl+a", "select_all", _("Select All")),
Binding("ctrl+pageup", "normal_size", _("Normal Size")),
Binding("ctrl+pagedown", "large_size", _("Large Size")),
# action_toggle_dark is built in to App
Binding("ctrl+d", "toggle_dark", _("Toggle Dark Mode")),
2023-04-19 09:08:01 +03:00
# dev helper
# f5 would be more traditional, but I need something not bound to anything
# in the context of the terminal in VS Code, and not used by this app, like Ctrl+R, and detectable in the terminal.
Binding("f2", "reload", _("Reload")),
]
2023-04-11 18:40:47 +03:00
show_tools_box = var(True)
show_colors_box = var(True)
2023-04-24 05:22:28 +03:00
show_status_bar = var(True)
2023-04-11 02:27:11 +03:00
selected_tool = var(Tool.pencil)
return_to_tool = var(Tool.pencil)
selected_bg_color = var(palette[0])
selected_fg_color = var(palette[len(palette) // 2])
2023-04-11 22:20:31 +03:00
selected_char = var(" ")
2023-04-11 23:22:00 +03:00
filename = var(None)
2023-04-23 01:54:21 +03:00
# For Open/Save As dialogs
directory_tree_selected_path: str|None = None
# I'm avoiding allowing None for image, to avoid type checking woes.
image = var(AnsiArtDocument.from_text("Not Loaded"))
image_initialized = False
2023-04-20 02:18:29 +03:00
magnification = var(1)
return_to_magnification = var(4)
2023-04-10 23:51:53 +03:00
2023-04-12 05:19:08 +03:00
undos: List[Action] = []
redos: List[Action] = []
# temporary undo state for brush previews
2023-04-14 03:28:58 +03:00
preview_action: Optional[Action] = None
# file modification tracking
saved_undo_count = 0
2023-04-11 20:21:19 +03:00
2023-04-22 08:04:19 +03:00
# for shape tools that draw between the mouse down and up points
# (Line, Rectangle, Ellipse, Rounded Rectangle),
# Select tool (similarly), and Polygon (to detect double-click)
2023-04-21 20:11:39 +03:00
mouse_at_start = Offset(0, 0)
2023-04-21 22:24:45 +03:00
# for Select tool, indicates that the selection is being moved
# and defines the offset of the selection from the mouse
selection_drag_offset = Offset(0, 0)
2023-04-22 04:19:05 +03:00
# for Text tool
selecting_text = False
2023-04-22 05:51:00 +03:00
# for Curve, Polygon, or Free-Form Select tools
tool_points: List[Offset] = []
2023-04-22 08:04:19 +03:00
# for Polygon tool to detect double-click
polygon_last_click_time = 0
2023-04-21 20:11:39 +03:00
# flag to prevent setting the filename input when initially expanding the directory tree
expanding_directory_tree = False
2023-04-23 01:54:21 +03:00
background_tasks: set[asyncio.Task[None]] = set()
TITLE = _("Paint")
def watch_filename(self, filename: Optional[str]) -> None:
"""Called when filename changes."""
if filename is None:
self.sub_title = _("Untitled")
else:
self.sub_title = os.path.basename(filename)
2023-04-11 18:40:47 +03:00
def watch_show_tools_box(self, show_tools_box: bool) -> None:
"""Called when show_tools_box changes."""
self.query_one("#tools_box", ToolsBox).display = 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", ColorsBox).display = show_colors_box
2023-04-10 23:51:53 +03:00
2023-04-24 05:22:28 +03:00
def watch_show_status_bar(self, show_status_bar: bool) -> None:
"""Called when show_status_bar changes."""
self.query_one("#status_bar").display = show_status_bar
2023-04-11 02:27:11 +03:00
def watch_selected_tool(self, old_selected_tool: Tool, selected_tool: Tool) -> None:
"""Called when selected_tool changes."""
for button in self.query(".tool_button"):
assert isinstance(button, Button)
if selected_tool == self.query_one("ToolsBox", ToolsBox).tool_by_button[button]:
button.add_class("selected")
else:
button.remove_class("selected")
2023-04-11 02:27:11 +03:00
def watch_selected_bg_color(self, selected_bg_color: str) -> None:
"""Called when selected_bg_color changes."""
self.query_one("#selected_color_char_input", CharInput).styles.background = selected_bg_color
2023-04-11 06:56:47 +03:00
def watch_selected_fg_color(self, selected_fg_color: str) -> None:
"""Called when selected_fg_color changes."""
self.query_one("#selected_color_char_input", CharInput).styles.color = selected_fg_color
def watch_selected_char(self, selected_char: str) -> None:
2023-04-20 07:29:14 +03:00
"""Called when selected_char changes."""
self.query_one("#selected_color_char_input", CharInput).value = selected_char
2023-04-20 07:29:14 +03:00
def watch_magnification(self, old_magnification: int, magnification: int) -> None:
"""Called when magnification changes."""
self.canvas.magnification = magnification
# TODO: keep the top left corner of the viewport in the same place
# https://github.com/1j01/jspaint/blob/12a90c6bb9d36f495dc6a07114f9667c82ee5228/src/functions.js#L326-L351
# This will matter more when large documents don't freeze up the program...
2023-04-22 09:27:54 +03:00
def stamp_brush(self, x: int, y: int, affected_region_base: Optional[Region] = None) -> Region:
2023-04-11 19:24:25 +03:00
brush_diameter = 1
square = self.selected_tool == Tool.eraser
2023-04-14 01:48:24 +03:00
if self.selected_tool == Tool.brush or self.selected_tool == Tool.airbrush or self.selected_tool == Tool.eraser:
2023-04-11 19:24:25 +03:00
brush_diameter = 3
if brush_diameter == 1:
self.stamp_char(x, y)
else:
# plot points within a circle (or square)
2023-04-11 19:24:25 +03:00
for i in range(brush_diameter):
for j in range(brush_diameter):
if square or (i - brush_diameter // 2) ** 2 + (j - brush_diameter // 2) ** 2 <= (brush_diameter // 2) ** 2:
2023-04-11 19:24:25 +03:00
self.stamp_char(x + i - brush_diameter // 2, y + j - brush_diameter // 2)
2023-04-11 21:14:23 +03:00
# 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
2023-04-11 19:24:25 +03:00
def stamp_char(self, x: int, y: int) -> None:
2023-04-14 01:48:24 +03:00
char = self.selected_char
bg_color = self.selected_bg_color
fg_color = self.selected_fg_color
2023-04-14 01:48:24 +03:00
if self.selected_tool == Tool.eraser:
char = " "
bg_color = "#ffffff"
fg_color = "#000000"
2023-04-14 01:48:24 +03:00
if self.selected_tool == Tool.airbrush:
if random() < 0.7:
return
if x < self.image.width and y < self.image.height and x >= 0 and y >= 0:
2023-04-14 01:48:24 +03:00
self.image.ch[y][x] = char
self.image.bg[y][x] = bg_color
self.image.fg[y][x] = fg_color
2023-04-23 05:49:58 +03:00
def erase_region(self, region: Region, mask: Optional[list[list[bool]]] = None) -> None:
# Time to go undercover as an eraser. 🥸
# TODO: just add a parameter to stamp_char.
# Momentarily masquerading makes me mildly mad.
original_tool = self.selected_tool
self.selected_tool = Tool.eraser
for x in range(region.width):
for y in range(region.height):
2023-04-23 05:49:58 +03:00
if mask is None or mask[y][x]:
self.stamp_char(x + region.x, y + region.y)
self.selected_tool = original_tool
2023-04-22 08:04:19 +03:00
def draw_current_polyline(self) -> Region:
# TODO: DRY with draw_current_curve/draw_current_polygon
gen = polyline_walk(self.tool_points)
affected_region = Region()
for x, y in gen:
affected_region = affected_region.union(self.stamp_brush(x, y, affected_region))
return affected_region
def draw_current_polygon(self) -> Region:
# TODO: DRY with draw_current_curve/draw_current_polyline
gen = polygon_walk(self.tool_points)
affected_region = Region()
for x, y in gen:
affected_region = affected_region.union(self.stamp_brush(x, y, affected_region))
return affected_region
2023-04-22 05:51:00 +03:00
def draw_current_curve(self) -> Region:
points = self.tool_points
if len(points) == 4:
gen = bezier_curve_walk(
points[0].x, points[0].y,
points[2].x, points[2].y,
points[3].x, points[3].y,
points[1].x, points[1].y,
)
elif len(points) == 3:
gen = quadratic_curve_walk(
points[0].x, points[0].y,
points[2].x, points[2].y,
points[1].x, points[1].y,
)
elif len(points) == 2:
gen = bresenham_walk(
points[0].x, points[0].y,
points[1].x, points[1].y,
)
else:
gen = points
affected_region = Region()
for x, y in gen:
affected_region = affected_region.union(self.stamp_brush(x, y, affected_region))
return affected_region
def action_undo(self) -> None:
self.meld_selection()
2023-04-11 20:21:19 +03:00
if len(self.undos) > 0:
self.cancel_preview()
2023-04-11 20:21:19 +03:00
action = self.undos.pop()
redo_action = Action(_("Undo") + " " + action.name, self.image, action.region)
action.undo(self.image)
2023-04-11 20:21:19 +03:00
self.redos.append(redo_action)
self.canvas.refresh(layout=True)
2023-04-11 20:21:19 +03:00
def action_redo(self) -> None:
self.meld_selection()
if len(self.redos) > 0:
self.cancel_preview()
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(layout=True)
2023-04-11 20:21:19 +03:00
def close_windows(self, selector: str) -> None:
"""Close all windows matching the CSS selector."""
for window in self.query(selector).nodes:
assert isinstance(window, Window), f"Expected a Window for query '{selector}', but got {window.css_identifier}"
window.close()
def action_save(self) -> None:
"""Start the save action, but don't wait for the Save As dialog to close if it's a new file."""
task = asyncio.create_task(self.save())
self.background_tasks.add(task)
task.add_done_callback(self.background_tasks.discard)
2023-04-23 01:54:21 +03:00
async def save(self, from_save_as: bool = False) -> None:
2023-04-11 23:22:00 +03:00
"""Save the image to a file."""
self.cancel_preview()
dialog_title = _("Save As") if from_save_as else _("Save")
2023-04-11 23:22:00 +03:00
if self.filename:
2023-04-18 22:00:46 +03:00
try:
ansi = self.image.get_ansi()
2023-04-18 22:00:46 +03:00
with open(self.filename, "w") as f:
f.write(ansi)
self.saved_undo_count = len(self.undos)
except PermissionError:
self.warning_message_box(dialog_title, _("Access denied."), "ok")
except FileNotFoundError:
self.warning_message_box(dialog_title, _("%1 contains an invalid path.").replace("%1", self.filename), "ok")
2023-04-18 22:00:46 +03:00
except OSError as e:
self.warning_message_box(dialog_title, _("Failed to save document.") + "\n\n" + str(e), "ok")
2023-04-18 22:00:46 +03:00
except Exception as e:
self.warning_message_box(dialog_title, _("An unexpected error occurred while writing %1.").replace("%1", self.filename) + "\n\n" + str(e), "ok")
2023-04-15 06:31:05 +03:00
else:
await self.save_as()
2023-04-11 23:22:00 +03:00
def action_save_as(self) -> None:
"""Show the save as dialog, without waiting for it to close."""
# Action must not await the dialog closing,
# or else you'll never see the dialog in the first place!
task = asyncio.create_task(self.save_as())
self.background_tasks.add(task)
task.add_done_callback(self.background_tasks.discard)
async def save_as(self) -> None:
2023-04-11 23:22:00 +03:00
"""Save the image as a new file."""
self.close_windows("#save_as_dialog, #open_dialog")
2023-04-23 01:54:21 +03:00
saved_future: asyncio.Future[None] = asyncio.Future()
2023-04-23 01:54:21 +03:00
def handle_button(button: Button) -> None:
if not button.has_class("save"):
window.close()
return
2023-04-21 06:35:22 +03:00
name = self.query_one("#save_as_dialog .filename_input", Input).value
if not name:
return
if self.directory_tree_selected_path:
name = os.path.join(self.directory_tree_selected_path, name)
def on_save_confirmed():
async def async_on_save_confirmed():
self.filename = name
await self.save(from_save_as=True)
window.close()
saved_future.set_result(None)
# https://textual.textualize.io/blog/2023/02/11/the-heisenbug-lurking-in-your-async-code/
task = asyncio.create_task(async_on_save_confirmed())
self.background_tasks.add(task)
task.add_done_callback(self.background_tasks.discard)
if os.path.exists(name):
self.confirm_overwrite(name, on_save_confirmed)
else:
on_save_confirmed()
window = DialogWindow(
2023-04-15 06:31:05 +03:00
id="save_as_dialog",
classes="file_dialog_window",
title=_("Save As"),
handle_button=handle_button,
2023-04-15 06:31:05 +03:00
)
2023-04-23 01:54:21 +03:00
filename: str = os.path.basename(self.filename) if self.filename else _("Untitled")
2023-04-15 06:31:05 +03:00
window.content.mount(
EnhancedDirectoryTree(id="save_as_dialog_directory_tree", path="/"),
2023-04-21 06:35:22 +03:00
Input(classes="filename_input", placeholder=_("Filename"), value=filename),
Button(_("Save"), classes="save submit", variant="primary"),
Button(_("Cancel"), classes="cancel"),
2023-04-15 06:31:05 +03:00
)
self.mount(window)
self.expand_directory_tree(window.content.query_one("#save_as_dialog_directory_tree", EnhancedDirectoryTree))
await saved_future
def expand_directory_tree(self, tree: EnhancedDirectoryTree) -> None:
"""Expand the directory tree to the target directory, either the folder of the open file or the current working directory."""
self.expanding_directory_tree = True
target_dir = (self.filename or os.getcwd()).rstrip(os.path.sep)
tree.expand_to_path(target_dir)
# There are currently some timers in expand_to_path.
# In particular, it waits before selecting the target node,
# and this flag is for avoiding responding to that.
def done_expanding():
self.expanding_directory_tree = False
2023-04-20 21:32:37 +03:00
self.set_timer(0.1, done_expanding)
2023-04-23 01:54:21 +03:00
def confirm_overwrite(self, filename: str, callback: Callable[[], None]) -> None:
2023-04-18 22:06:54 +03:00
message = _("%1 already exists.\nDo you want to replace it?").replace("%1", filename)
2023-04-23 01:54:21 +03:00
def handle_button(button: Button) -> None:
if not button.has_class("yes"):
return
callback()
self.warning_message_box(_("Save As"), Static(message, markup=False), "yes/no", handle_button)
2023-04-23 01:54:21 +03:00
def prompt_save_changes(self, filename: str, callback: Callable[[], None]) -> None:
filename = os.path.basename(filename)
message = "Save changes to " + filename + "?"
2023-04-23 01:54:21 +03:00
def handle_button(button: Button) -> None:
if not button.has_class("yes") and not button.has_class("no"):
return
2023-04-23 01:54:21 +03:00
async def async_handle_button(button: Button):
if button.has_class("yes"):
await self.save()
callback()
# https://textual.textualize.io/blog/2023/02/11/the-heisenbug-lurking-in-your-async-code/
task = asyncio.create_task(async_handle_button(button))
self.background_tasks.add(task)
task.add_done_callback(self.background_tasks.discard)
self.warning_message_box(_("Paint"), Static(message, markup=False), "yes/no/cancel", handle_button)
def is_document_modified(self) -> bool:
return len(self.undos) != self.saved_undo_count
def action_exit(self) -> None:
if self.is_document_modified():
self.prompt_save_changes(self.filename or _("Untitled"), self.exit)
else:
self.exit()
2023-04-19 09:08:01 +03:00
def action_reload(self) -> None:
if self.is_document_modified():
self.prompt_save_changes(self.filename or _("Untitled"), restart_program)
else:
restart_program()
2023-04-23 01:54:21 +03:00
def warning_message_box(self,
title: str,
message_widget: Widget|str,
button_types: str = "ok",
callback: Callable[[Button], None]|None = None,
) -> None:
"""Show a warning message box with the given title, message, and buttons."""
self.close_windows("#message_box")
self.bell()
2023-04-23 01:54:21 +03:00
def handle_button(button: Button) -> None:
# TODO: this is not different or useful enough from DialogWindow's
# handle_button to justify
# It's a difference in name, and an automatic close
if callback:
callback(button)
window.close()
window = MessageBox(
id="message_box",
title=title,
icon_widget=get_warning_icon(),
message_widget=message_widget,
button_types=button_types,
handle_button=handle_button,
)
self.mount(window)
2023-04-16 04:11:44 +03:00
def action_open(self) -> None:
"""Show dialog to open an image from a file."""
2023-04-16 04:11:44 +03:00
2023-04-23 01:54:21 +03:00
def handle_button(button: Button) -> None:
if not button.has_class("open"):
window.close()
return
filename = window.content.query_one("#open_dialog .filename_input", Input).value
if not filename:
return
if self.directory_tree_selected_path:
filename = os.path.join(self.directory_tree_selected_path, filename)
try:
# Note that os.path.
# samefile can raise FileNotFoundError
if self.filename and os.path.samefile(filename, self.filename):
window.close()
return
with open(filename, "r") as f:
content = f.read() # f is out of scope in go_ahead()
def go_ahead():
try:
new_image = AnsiArtDocument.from_text(content)
except Exception as e:
# "This is not a valid bitmap file, or its format is not currently supported."
# string from MS Paint doesn't apply well here,
# at least not until we support bitmap files.
self.warning_message_box(_("Open"), Static(_("Paint cannot open this file.") + "\n\n" + str(e)), "ok")
return
self.action_new(force=True)
self.canvas.image = self.image = new_image
self.canvas.refresh(layout=True)
self.filename = filename
window.close()
if self.is_document_modified():
self.prompt_save_changes(self.filename or _("Untitled"), go_ahead)
else:
go_ahead()
except FileNotFoundError:
self.warning_message_box(_("Open"), Static(_("File not found.") + "\n" + _("Please verify that the correct path and file name are given.")), "ok")
except IsADirectoryError:
self.warning_message_box(_("Open"), Static(_("Invalid file.")), "ok")
except PermissionError:
self.warning_message_box(_("Open"), Static(_("Access denied.")), "ok")
except Exception as e:
self.warning_message_box(_("Open"), Static(_("An unexpected error occurred while reading %1.").replace("%1", filename) + "\n\n" + str(e)), "ok")
2023-04-16 04:11:44 +03:00
self.close_windows("#save_as_dialog, #open_dialog")
window = DialogWindow(
2023-04-16 04:11:44 +03:00
id="open_dialog",
classes="file_dialog_window",
title=_("Open"),
handle_button=handle_button,
2023-04-16 04:11:44 +03:00
)
window.content.mount(
EnhancedDirectoryTree(id="open_dialog_directory_tree", path="/"),
2023-04-21 06:35:22 +03:00
Input(classes="filename_input", placeholder=_("Filename")),
Button(_("Open"), classes="open submit", variant="primary"),
Button(_("Cancel"), classes="cancel"),
2023-04-16 04:11:44 +03:00
)
self.mount(window)
self.expand_directory_tree(window.content.query_one("#open_dialog_directory_tree", EnhancedDirectoryTree))
2023-04-11 23:22:00 +03:00
def action_new(self, *, force: bool = False) -> None:
2023-04-14 08:46:26 +03:00
"""Create a new image."""
if self.is_document_modified() and not force:
def go_ahead():
# Cancel doesn't call this callback.
# Yes or No has been selected.
# If Yes, a save dialog should already have been shown,
# or the open file saved.
# Go ahead and create a new image.
self.action_new(force=True)
self.prompt_save_changes(self.filename or _("Untitled"), go_ahead)
return
2023-04-14 08:46:26 +03:00
self.image = AnsiArtDocument(80, 24)
self.canvas.image = self.image
self.canvas.refresh(layout=True)
2023-04-14 08:46:26 +03:00
self.filename = None
self.saved_undo_count = 0
2023-04-14 08:46:26 +03:00
self.undos = []
self.redos = []
self.preview_action = None
# Following MS Paint's lead and resetting the color (but not the tool.)
# It probably has to do with color modes.
self.selected_bg_color = palette[0]
self.selected_fg_color = palette[len(palette) // 2]
2023-04-14 08:46:26 +03:00
self.selected_char = " "
2023-04-20 07:29:14 +03:00
def action_open_character_selector(self) -> None:
"""Show dialog to select a character."""
self.close_windows("#character_selector_dialog")
def handle_selected_character(character: str) -> None:
2023-04-20 07:29:14 +03:00
self.selected_char = character
window.close()
window = CharacterSelectorDialogWindow(
id="character_selector_dialog",
2023-04-20 07:29:14 +03:00
handle_selected_character=handle_selected_character,
selected_character=self.selected_char,
title=_("Choose Character"),
)
self.mount(window)
def action_edit_colors(self, color_palette_index: int|None = None) -> None:
"""Show dialog to edit colors."""
self.close_windows("#edit_colors_dialog")
def handle_selected_color(color: str) -> None:
self.selected_bg_color = color
if color_palette_index is not None:
palette[color_palette_index] = color
# TODO: Update the palette in a reactive way.
# I'll need to move the palette state to the app.
self.query_one(ColorsBox).update_palette()
window.close()
window = EditColorsDialogWindow(
id="edit_colors_dialog",
handle_selected_color=handle_selected_color,
selected_color=self.selected_bg_color,
title=_("Edit Colors"),
)
self.mount(window)
def action_print_preview(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_page_setup(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_print(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_send(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_set_as_wallpaper_tiled(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_set_as_wallpaper_centered(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_recent_file(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_cut(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_copy(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_paste(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_select_all(self) -> None:
2023-04-23 08:09:19 +03:00
self.meld_selection()
self.image.selection = Selection(Region(0, 0, self.image.width, self.image.height))
self.canvas.refresh()
self.selected_tool = Tool.select
def action_copy_to(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_paste_from(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_text_toolbar(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_normal_size(self) -> None:
self.magnification = 1
def action_large_size(self) -> None:
self.magnification = 4
def action_custom_zoom(self) -> None:
self.close_windows("#zoom_dialog")
def handle_button(button: Button) -> None:
2023-04-22 02:30:37 +03:00
if button.has_class("ok"):
min_zoom = 1
max_zoom = 16
try:
n = int(window.content.query_one("#zoom_input", Input).value)
if n < min_zoom or n > max_zoom:
raise ValueError
self.magnification = n
window.close()
except ValueError:
self.warning_message_box(_("Zoom"), _("Please enter an integer between %1 and %2.", str(min_zoom), str(max_zoom)), "ok")
else:
window.close()
window = DialogWindow(
id="zoom_dialog",
title=_("Custom Zoom"),
handle_button=handle_button,
)
window.content.mount(
Input(id="zoom_input", value=str(self.magnification), placeholder=_("Zoom")),
# Vertical(
# Horizontal(
# Static(_("Zoom to")),
# Input(id="zoom_input", value=str(self.magnification)),
# ),
# Horizontal(
# Static(_("Current zoom:")),
# Static(str(self.magnification)),
# ),
# ),
# Horizontal(
Button(_("OK"), classes="ok submit", variant="primary"),
Button(_("Cancel"), classes="cancel"),
# classes="buttons",
# )
)
self.mount(window)
def action_show_grid(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_show_thumbnail(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_view_bitmap(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_flip_rotate(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_stretch_skew(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_invert_colors(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_attributes(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_clear_image(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
def action_draw_opaque(self) -> None:
self.warning_message_box(_("Paint"), "Not implemented.", "ok")
2023-04-20 23:16:10 +03:00
def action_help_topics(self) -> None:
self.close_windows("#help_dialog")
2023-04-20 23:16:10 +03:00
window = DialogWindow(
id="help_dialog",
title=_("Help"), # _("Help Topics"),
handle_button=lambda button: window.close(),
)
help_text = parser.format_usage()
window.content.mount(Static(help_text, classes="help_text"))
window.content.mount(Button(_("OK"), classes="ok submit"))
self.mount(window)
2023-04-20 23:00:38 +03:00
def action_about_paint(self) -> None:
2023-04-20 23:00:38 +03:00
"""Show the About Paint dialog."""
self.close_windows("#about_paint_dialog")
2023-04-20 23:00:38 +03:00
window = DialogWindow(
id="about_paint_dialog",
title=_("About Paint"),
handle_button=lambda button: window.close(),
)
window.content.mount(Static("""🎨 [b]Textual Paint[/b]
[i]MS Paint in your terminal.[/i]
[b]Version:[/b] 0.1.0
[b]Author:[/b] [link=https://isaiahodhner.io/]Isaiah Odhner[/link]
[b]License:[/b] [link=https://github.com/1j01/textual-paint/blob/main/LICENSE.txt]MIT[/link]
[b]Source Code:[/b] [link=https://github.com/1j01/textual-paint]github.com/1j01/textual-paint[/link]
"""))
window.content.mount(Button(_("OK"), classes="ok submit"))
self.mount(window)
2023-04-14 08:46:26 +03:00
2023-04-10 23:51:53 +03:00
def compose(self) -> ComposeResult:
"""Add our widgets."""
yield Header()
2023-04-10 23:54:14 +03:00
with Container(id="paint"):
2023-04-13 03:49:16 +03:00
yield MenuBar([
2023-04-19 03:43:51 +03:00
MenuItem(_("&File"), submenu=Menu([
MenuItem(_("&New\tCtrl+N"), self.action_new, 57600, description=_("Creates a new document.")),
MenuItem(_("&Open...\tCtrl+O"), self.action_open, 57601, description=_("Opens an existing document.")),
MenuItem(_("&Save\tCtrl+S"), self.action_save, 57603, description=_("Saves the active document.")),
MenuItem(_("Save &As..."), self.action_save_as, 57604, description=_("Saves the active document with a new name.")),
Separator(),
MenuItem(_("Print Pre&view"), self.action_print_preview, 57609, grayed=True, description=_("Displays full pages.")),
MenuItem(_("Page Se&tup..."), self.action_page_setup, 57605, grayed=True, description=_("Changes the page layout.")),
MenuItem(_("&Print...\tCtrl+P"), self.action_print, 57607, grayed=True, description=_("Prints the active document and sets printing options.")),
Separator(),
MenuItem(_("S&end..."), self.action_send, 37662, grayed=True, description=_("Sends a picture by using mail or fax.")),
Separator(),
MenuItem(_("Set As &Wallpaper (Tiled)"), self.action_set_as_wallpaper_tiled, 57677, grayed=True, description=_("Tiles this bitmap as the desktop wallpaper.")),
MenuItem(_("Set As Wa&llpaper (Centered)"), self.action_set_as_wallpaper_centered, 57675, grayed=True, description=_("Centers this bitmap as the desktop wallpaper.")),
Separator(),
MenuItem(_("Recent File"), self.action_recent_file, 57616, grayed=True, description=_("Opens this document.")),
Separator(),
# MenuItem(_("E&xit\tAlt+F4"), self.action_exit, 57665, description=_("Quits Paint.")),
MenuItem(_("E&xit\tCtrl+Q"), self.action_exit, 57665, description=_("Quits Paint.")),
2023-04-13 03:49:16 +03:00
])),
2023-04-19 03:43:51 +03:00
MenuItem(_("&Edit"), submenu=Menu([
MenuItem(_("&Undo\tCtrl+Z"), self.action_undo, 57643, description=_("Undoes the last action.")),
MenuItem(_("&Repeat\tF4"), self.action_redo, 57644, description=_("Redoes the previously undone action.")),
Separator(),
MenuItem(_("Cu&t\tCtrl+X"), self.action_cut, 57635, grayed=True, description=_("Cuts the selection and puts it on the Clipboard.")),
MenuItem(_("&Copy\tCtrl+C"), self.action_copy, 57634, grayed=True, description=_("Copies the selection and puts it on the Clipboard.")),
MenuItem(_("&Paste\tCtrl+V"), self.action_paste, 57637, grayed=True, description=_("Inserts the contents of the Clipboard.")),
MenuItem(_("C&lear Selection\tDel"), self.action_clear_selection, 57632, description=_("Deletes the selection.")),
MenuItem(_("Select &All\tCtrl+A"), self.action_select_all, 57642, description=_("Selects everything.")),
Separator(),
MenuItem(_("C&opy To..."), self.action_copy_to, 37663, grayed=True, description=_("Copies the selection to a file.")),
MenuItem(_("Paste &From..."), self.action_paste_from, 37664, grayed=True, description=_("Pastes a file into the selection.")),
2023-04-13 03:49:16 +03:00
])),
2023-04-19 03:43:51 +03:00
MenuItem(_("&View"), submenu=Menu([
MenuItem(_("&Tool Box\tCtrl+T"), self.action_toggle_tools_box, 59415, description=_("Shows or hides the tool box.")),
MenuItem(_("&Color Box\tCtrl+L"), self.action_toggle_colors_box, 59416, description=_("Shows or hides the color box.")),
MenuItem(_("&Status Bar"), self.action_toggle_status_bar, 59393, description=_("Shows or hides the status bar.")),
MenuItem(_("T&ext Toolbar"), self.action_text_toolbar, 37678, grayed=True, description=_("Shows or hides the text toolbar.")),
Separator(),
2023-04-19 03:43:51 +03:00
MenuItem(_("&Zoom"), submenu=Menu([
MenuItem(_("&Normal Size\tCtrl+PgUp"), self.action_normal_size, 37670, description=_("Zooms the picture to 100%.")),
MenuItem(_("&Large Size\tCtrl+PgDn"), self.action_large_size, 37671, description=_("Zooms the picture to 400%.")),
MenuItem(_("C&ustom..."), self.action_custom_zoom, 37672, description=_("Zooms the picture.")),
Separator(),
MenuItem(_("Show &Grid\tCtrl+G"), self.action_show_grid, 37677, grayed=True, description=_("Shows or hides the grid.")),
MenuItem(_("Show T&humbnail"), self.action_show_thumbnail, 37676, grayed=True, description=_("Shows or hides the thumbnail view of the picture.")),
])),
MenuItem(_("&View Bitmap\tCtrl+F"), self.action_view_bitmap, 37673, grayed=True, description=_("Displays the entire picture.")),
])),
2023-04-19 03:43:51 +03:00
MenuItem(_("&Image"), submenu=Menu([
MenuItem(_("&Flip/Rotate...\tCtrl+R"), self.action_flip_rotate, 37680, grayed=True, description=_("Flips or rotates the picture or a selection.")),
MenuItem(_("&Stretch/Skew...\tCtrl+W"), self.action_stretch_skew, 37681, grayed=True, description=_("Stretches or skews the picture or a selection.")),
MenuItem(_("&Invert Colors\tCtrl+I"), self.action_invert_colors, 37682, grayed=True, description=_("Inverts the colors of the picture or a selection.")),
MenuItem(_("&Attributes...\tCtrl+E"), self.action_attributes, 37683, grayed=True, description=_("Changes the attributes of the picture.")),
MenuItem(_("&Clear Image\tCtrl+Shft+N"), self.action_clear_image, 37684, grayed=True, description=_("Clears the picture or selection.")),
MenuItem(_("&Draw Opaque"), self.action_draw_opaque, 6868, grayed=True, description=_("Makes the current selection either opaque or transparent.")),
])),
2023-04-19 03:43:51 +03:00
MenuItem(_("&Colors"), submenu=Menu([
MenuItem(_("&Edit Colors..."), self.action_edit_colors, 6869, description=_("Creates a new color.")),
])),
2023-04-19 03:43:51 +03:00
MenuItem(_("&Help"), submenu=Menu([
MenuItem(_("&Help Topics"), self.action_help_topics, 57670, description=_("Displays Help for the current task or command.")),
Separator(),
MenuItem(_("&About Paint"), self.action_about_paint, 57664, description=_("Displays program information, version number, and copyright.")),
2023-04-13 03:49:16 +03:00
])),
])
2023-04-11 06:25:32 +03:00
yield Container(
ToolsBox(id="tools_box"),
2023-04-11 15:47:47 +03:00
Container(
Canvas(id="canvas"),
id="editing_area",
2023-04-11 15:47:47 +03:00
),
id="main_horizontal_split",
2023-04-11 06:25:32 +03:00
)
yield ColorsBox(id="colors_box")
2023-04-24 05:22:28 +03:00
yield Container(
Static(_("For Help, click Help Topics on the Help Menu."), id="status_text"),
2023-04-24 05:22:28 +03:00
Static(id="status_coords"),
Static(id="status_dimensions"),
id="status_bar",
)
2023-04-10 23:51:53 +03:00
def on_mount(self) -> None:
"""Called when the app is mounted."""
2023-04-11 23:22:00 +03:00
# Image can be set from the outside, via CLI
if not self.image_initialized:
2023-04-11 23:22:00 +03:00
self.image = AnsiArtDocument(80, 24)
self.image_initialized = True
2023-04-20 02:18:29 +03:00
self.canvas = self.query_one("#canvas", Canvas)
2023-04-11 17:46:18 +03:00
self.canvas.image = self.image
self.editing_area = self.query_one("#editing_area", Container)
2023-04-14 02:01:40 +03:00
def pick_color(self, x: int, y: int) -> None:
"""Select a color from the image."""
if x < 0 or y < 0 or x >= self.image.width or y >= self.image.height:
return
self.selected_bg_color = self.image.bg[y][x]
self.selected_fg_color = self.image.fg[y][x]
2023-04-14 02:01:40 +03:00
self.selected_char = self.image.ch[y][x]
2023-04-22 20:11:02 +03:00
def get_prospective_magnification(self) -> int:
2023-04-20 02:18:29 +03:00
"""Returns the magnification result on click with the Magnifier tool."""
return self.return_to_magnification if self.magnification == 1 else 1
def magnifier_click(self, x: int, y: int) -> None:
"""Zooms in or out on the image."""
prev_magnification = self.magnification
prospective_magnification = self.get_prospective_magnification()
# TODO: fix flickering.
# The canvas resize and scroll each cause a repaint.
# I tried using a batch_update, but it prevented the layout recalculation
# needed for the scroll to work correctly.
# with self.batch_update():
2023-04-20 02:18:29 +03:00
self.magnification = prospective_magnification
self.canvas.magnification = self.magnification
2023-04-20 02:18:29 +03:00
if self.magnification > prev_magnification:
w = self.editing_area.size.width / self.magnification
h = self.editing_area.size.height / self.magnification
self.editing_area.scroll_to(
(x - w / 2) * self.magnification / prev_magnification,
(y - h / 2) * self.magnification / prev_magnification,
animate=False,
)
# `scroll_to` uses `call_after_refresh`.
# `_scroll_to` is the same thing but without call_after_refresh.
# But it doesn't work correctly, because the layout isn't updated yet.
# And if I call:
# self.screen._refresh_layout()
# beforehand, it's back to the flickering.
# I also tried calling:
# self.editing_area.refresh(layout=True, repaint=False)
# But it's back to the incorrect scroll position.
# self.editing_area._scroll_to(
# (x - w / 2) * self.magnification / prev_magnification,
# (y - h / 2) * self.magnification / prev_magnification,
# animate=False,
# )
2023-04-20 02:18:29 +03:00
def on_canvas_tool_start(self, event: Canvas.ToolStart) -> None:
"""Called when the user starts drawing on the canvas."""
event.stop()
2023-04-14 03:28:58 +03:00
self.cancel_preview()
2023-04-14 01:54:51 +03:00
if self.selected_tool == Tool.pick_color:
2023-04-14 02:01:40 +03:00
self.pick_color(event.mouse_down_event.x, event.mouse_down_event.y)
2023-04-14 01:54:51 +03:00
return
2023-04-20 02:18:29 +03:00
if self.selected_tool == Tool.magnifier:
self.magnifier_click(event.mouse_down_event.x, event.mouse_down_event.y)
self.selected_tool = self.return_to_tool
2023-04-20 02:18:29 +03:00
return
self.mouse_at_start = Offset(event.mouse_down_event.x, event.mouse_down_event.y)
2023-04-22 08:04:19 +03:00
if self.selected_tool in [Tool.curve, Tool.polygon]:
self.tool_points.append(Offset(event.mouse_down_event.x, event.mouse_down_event.y))
if self.selected_tool == Tool.curve:
self.make_preview(self.draw_current_curve)
else:
self.make_preview(self.draw_current_polyline, show_dimensions_in_status_bar=True) # polyline until finished
2023-04-22 08:04:19 +03:00
return
2023-04-23 05:49:58 +03:00
if self.selected_tool == Tool.free_form_select:
self.tool_points = [Offset(event.mouse_down_event.x, event.mouse_down_event.y)]
if self.selected_tool in [Tool.select, Tool.free_form_select, Tool.text]:
2023-04-21 22:24:45 +03:00
sel = self.image.selection
if sel and sel.region.contains_point(self.mouse_at_start):
2023-04-22 04:19:05 +03:00
if self.selected_tool == Tool.text:
# Place cursor at mouse position
sel.text_selection_start = Offset(*self.mouse_at_start) - sel.region.offset
sel.text_selection_end = Offset(*self.mouse_at_start) - sel.region.offset
self.canvas.refresh_scaled_region(sel.region)
2023-04-22 04:19:05 +03:00
self.selecting_text = True
return
2023-04-21 23:03:43 +03:00
# Start dragging the selection.
2023-04-21 22:24:45 +03:00
self.selection_drag_offset = Offset(
sel.region.x - self.mouse_at_start.x,
sel.region.y - self.mouse_at_start.y,
2023-04-21 22:24:45 +03:00
)
if sel.contained_image:
# Already cut out, don't replace the image data.
2023-04-23 09:14:28 +03:00
# But if you hold Ctrl, stamp the selection.
if event.mouse_down_event.ctrl:
sel.copy_to_document(self.image)
return
2023-04-21 23:03:43 +03:00
# Cut out the selected part of the image from the document to use as the selection's image data.
# TODO: DRY with the below action handling
self.image_at_start = AnsiArtDocument(self.image.width, self.image.height)
self.image_at_start.copy_region(self.image)
action = Action(self.selected_tool.get_name(), self.image)
if len(self.redos) > 0:
self.redos = []
2023-04-21 23:03:43 +03:00
self.undos.append(action)
sel.copy_from_document(self.image)
2023-04-23 09:14:28 +03:00
if not event.mouse_down_event.ctrl:
self.erase_region(sel.region, sel.mask)
# TODO: use two regions, for the cut out and the paste in, once melded.
# I could maybe give Action a sub_action property, and use it for the melding.
# Or I could make it use a list of regions.
# But for now, just save the whole image, so this action can
# simply be updated on meld.
# affected_region = sel.region
affected_region = Region(0, 0, self.image.width, self.image.height)
2023-04-21 23:03:43 +03:00
# TODO: DRY with the below action handling
action.region = affected_region
action.region = action.region.intersection(Region(0, 0, self.image.width, self.image.height))
action.update(self.image_at_start)
self.canvas.refresh_scaled_region(affected_region)
2023-04-21 22:24:45 +03:00
return
2023-04-21 22:05:41 +03:00
self.meld_selection()
2023-04-21 20:11:39 +03:00
return
2023-04-11 21:14:23 +03:00
self.image_at_start = AnsiArtDocument(self.image.width, self.image.height)
self.image_at_start.copy_region(self.image)
if len(self.redos) > 0:
self.redos = []
2023-04-11 21:14:23 +03:00
action = Action(self.selected_tool.get_name(), self.image)
self.undos.append(action)
affected_region = None
2023-04-12 05:24:39 +03:00
if self.selected_tool == Tool.pencil or self.selected_tool == Tool.brush:
affected_region = self.stamp_brush(event.mouse_down_event.x, event.mouse_down_event.y)
elif self.selected_tool == Tool.fill:
affected_region = flood_fill(self.image, event.mouse_down_event.x, event.mouse_down_event.y, self.selected_char, self.selected_fg_color, self.selected_bg_color)
if affected_region:
action.region = affected_region
2023-04-12 05:24:39 +03:00
action.region = action.region.intersection(Region(0, 0, self.image.width, self.image.height))
action.update(self.image_at_start)
2023-04-20 02:18:29 +03:00
self.canvas.refresh_scaled_region(affected_region)
2023-04-14 03:28:58 +03:00
def cancel_preview(self) -> None:
"""Revert the currently previewed action."""
if self.preview_action:
self.preview_action.undo(self.image)
2023-04-20 02:18:29 +03:00
self.canvas.refresh_scaled_region(self.preview_action.region)
2023-04-14 03:28:58 +03:00
self.preview_action = None
2023-04-20 03:51:12 +03:00
if self.canvas.magnifier_preview_region:
region = self.canvas.magnifier_preview_region
self.canvas.magnifier_preview_region = None
self.canvas.refresh_scaled_region(region)
2023-04-21 20:11:39 +03:00
if self.canvas.select_preview_region:
region = self.canvas.select_preview_region
self.canvas.select_preview_region = None
self.canvas.refresh_scaled_region(region)
2023-04-20 03:51:12 +03:00
def make_preview(self, draw_proc: Callable[[], Region], show_dimensions_in_status_bar: bool = False) -> None:
2023-04-22 06:33:22 +03:00
self.cancel_preview()
image_before = AnsiArtDocument(self.image.width, self.image.height)
image_before.copy_region(self.image)
affected_region = draw_proc()
if affected_region:
self.preview_action = Action(self.selected_tool.get_name(), self.image)
self.preview_action.region = affected_region.intersection(Region(0, 0, self.image.width, self.image.height))
self.preview_action.update(image_before)
self.canvas.refresh_scaled_region(affected_region)
if show_dimensions_in_status_bar:
self.get_widget_by_id("status_dimensions", Static).update(
f"{self.preview_action.region.width}x{self.preview_action.region.height}"
)
2023-04-14 03:28:58 +03:00
def on_canvas_tool_preview_update(self, event: Canvas.ToolPreviewUpdate) -> None:
"""Called when the user is hovering over the canvas but not drawing yet."""
event.stop()
self.cancel_preview()
2023-04-24 05:30:16 +03:00
self.get_widget_by_id("status_coords", Static).update(f"{event.mouse_move_event.x},{event.mouse_move_event.y}")
2023-04-22 08:04:19 +03:00
if self.selected_tool in [Tool.brush, Tool.pencil, Tool.eraser, Tool.curve, Tool.polygon]:
2023-04-22 06:40:31 +03:00
if self.selected_tool == Tool.curve:
2023-04-22 06:33:22 +03:00
self.make_preview(self.draw_current_curve)
2023-04-22 08:04:19 +03:00
elif self.selected_tool == Tool.polygon:
self.make_preview(self.draw_current_polyline, show_dimensions_in_status_bar=True) # polyline until finished
2023-04-22 05:51:00 +03:00
else:
2023-04-22 06:33:22 +03:00
self.make_preview(lambda: self.stamp_brush(event.mouse_move_event.x, event.mouse_move_event.y))
2023-04-20 03:51:12 +03:00
elif self.selected_tool == Tool.magnifier:
prospective_magnification = self.get_prospective_magnification()
if prospective_magnification < self.magnification:
return # hide if clicking would zoom out
# prospective viewport size in document coords
w = self.editing_area.size.width // prospective_magnification
h = self.editing_area.size.height // prospective_magnification
rect_x1 = (event.mouse_move_event.x - w // 2)
rect_y1 = (event.mouse_move_event.y - h // 2)
# try to move rect into bounds without squishing
rect_x1 = max(0, rect_x1)
rect_y1 = max(0, rect_y1)
rect_x1 = min(self.image.width - w, rect_x1)
rect_y1 = min(self.image.height - h, rect_y1)
rect_x2 = rect_x1 + w
rect_y2 = rect_y1 + h
# clamp rect to bounds (with squishing)
rect_x1 = max(0, rect_x1)
rect_y1 = max(0, rect_y1)
rect_x2 = min(self.image.width, rect_x2)
rect_y2 = min(self.image.height, rect_y2)
rect_w = rect_x2 - rect_x1
rect_h = rect_y2 - rect_y1
rect_x = rect_x1
rect_y = rect_y1
self.canvas.magnifier_preview_region = Region(rect_x, rect_y, rect_w, rect_h)
self.canvas.refresh_scaled_region(self.canvas.magnifier_preview_region)
2023-04-14 03:28:58 +03:00
def on_canvas_tool_preview_stop(self, event: Canvas.ToolPreviewStop) -> None:
"""Called when the user stops hovering over the canvas (while previewing, not drawing)."""
event.stop()
self.cancel_preview()
2023-04-24 05:30:16 +03:00
self.get_widget_by_id("status_coords", Static).update("")
2023-04-21 22:05:41 +03:00
def get_select_region(self, start: Offset, end: Offset) -> Region:
2023-04-22 03:18:58 +03:00
# Region.from_corners requires the first point to be the top left,
# and it doesn't ensure the width and height are non-zero, so it doesn't work here.
# We want to treat the inputs as cells, not points,
# so we need to add 1 to the bottom/right.
2023-04-21 22:05:41 +03:00
x1, y1 = start
x2, y2 = end
x1, x2 = min(x1, x2), max(x1, x2)
y1, y2 = min(y1, y2), max(y1, y2)
2023-04-22 03:18:58 +03:00
region = Region(x1, y1, x2 - x1 + 1, y2 - y1 + 1)
# Clamp to the document bounds.
return region.intersection(Region(0, 0, self.image.width, self.image.height))
2023-04-21 22:05:41 +03:00
def meld_selection(self) -> None:
"""Draw the selection onto the image and dissolve the selection."""
2023-04-21 22:31:38 +03:00
# I could DRY this by making clear_selection return the Selection
2023-04-21 22:05:41 +03:00
if self.image.selection:
region = self.image.selection.region
self.image.selection.copy_to_document(self.image)
self.image.selection = None
self.canvas.refresh_scaled_region(region)
2023-04-21 22:31:38 +03:00
self.selection_drag_offset = None
2023-04-22 04:19:05 +03:00
self.selecting_text = False
2023-04-21 22:05:41 +03:00
def action_clear_selection(self) -> None:
2023-04-21 22:05:41 +03:00
"""Delete the selection and its contents."""
if self.image.selection:
region = self.image.selection.region
if not self.image.selection.contained_image:
# It hasn't been cut out yet, so we need to erase it.
2023-04-23 05:49:58 +03:00
self.erase_region(region, self.image.selection.mask)
2023-04-21 22:05:41 +03:00
self.image.selection = None
self.canvas.refresh_scaled_region(region)
2023-04-21 22:31:38 +03:00
self.selection_drag_offset = None
2023-04-22 04:19:05 +03:00
self.selecting_text = False
2023-04-21 22:05:41 +03:00
def on_canvas_tool_update(self, event: Canvas.ToolUpdate) -> None:
2023-04-23 05:49:58 +03:00
"""Called when the user is drawing on the canvas.
Several tools do a preview of sorts here, even though it's not the ToolPreviewUpdate event.
TODO: rename these events to describe when they occur, ascribe less semantics to them.
"""
event.stop()
2023-04-14 03:28:58 +03:00
self.cancel_preview()
if self.selected_tool != Tool.select:
if self.selected_tool in [Tool.line, Tool.rectangle, Tool.ellipse, Tool.rounded_rectangle]: # , Tool.curve
# Display is allowed to go negative, unlike for the Select tool, handled below.
# Also, Polygon gets both coords and dimensions.
# Unlike MS Paint, Free-Form Select displays the dimensions of the resulting selection,
# (rather than the difference between the mouse position and the starting point,)
# which seems better to me.
# Also, unlike MS Paint, Curve displays mouse coords rather than dimensions,
# where "dimensions" are the difference between the mouse position and the starting point.
# I don't know that this is better, but my mouse_at_start currently is set on mouse down for in-progress curves,
# so it wouldn't match MS Paint unless I changed that or used the tool_points list.
# I don't know that anyone looks at the status bar while drawing a curve.
# If they do, they should probably be using a graphing calculator instead or something.
self.get_widget_by_id("status_dimensions", Static).update(f"{event.mouse_move_event.x - self.mouse_at_start.x}x{event.mouse_move_event.y - self.mouse_at_start.y}")
else:
self.get_widget_by_id("status_coords", Static).update(f"{event.mouse_move_event.x},{event.mouse_move_event.y}")
2023-04-14 02:01:40 +03:00
if self.selected_tool == Tool.pick_color:
self.pick_color(event.mouse_move_event.x, event.mouse_move_event.y)
return
if self.selected_tool in [Tool.fill, Tool.magnifier]:
return
2023-04-23 05:49:58 +03:00
if self.selected_tool in [Tool.select, Tool.free_form_select, Tool.text]:
2023-04-22 04:19:05 +03:00
sel = self.image.selection
if self.selecting_text:
assert sel is not None, "selecting_text should only be set if there's a selection"
sel.text_selection_end = Offset(event.mouse_move_event.x, event.mouse_move_event.y) - sel.region.offset
self.canvas.refresh_scaled_region(sel.region)
2023-04-22 04:19:05 +03:00
elif self.selection_drag_offset:
2023-04-22 01:39:18 +03:00
assert sel is not None, "selection_drag_offset should only be set if there's a selection"
2023-04-21 22:24:45 +03:00
offset = (
self.selection_drag_offset.x + event.mouse_move_event.x,
self.selection_drag_offset.y + event.mouse_move_event.y,
)
# constrain to have at least one cell in the bounds of the document
offset = (
max(1-sel.region.width, min(self.image.width - 1, offset[0])),
max(1-sel.region.height, min(self.image.height - 1, offset[1])),
)
2023-04-21 22:31:38 +03:00
old_region = sel.region
2023-04-21 22:24:45 +03:00
sel.region = Region.from_offset(offset, sel.region.size)
2023-04-21 22:31:38 +03:00
combined_region = old_region.union(sel.region)
self.canvas.refresh_scaled_region(combined_region)
2023-04-23 05:49:58 +03:00
elif self.selected_tool == Tool.free_form_select:
self.tool_points.append(Offset(event.mouse_move_event.x, event.mouse_move_event.y))
# polyline until finished, TODO: invert background, don't use selected color
self.make_preview(self.draw_current_polyline, show_dimensions_in_status_bar=True)
2023-04-21 22:24:45 +03:00
else:
self.canvas.select_preview_region = self.get_select_region(self.mouse_at_start, event.mouse_move_event.offset)
self.canvas.refresh_scaled_region(self.canvas.select_preview_region)
self.get_widget_by_id("status_dimensions", Static).update(
f"{self.canvas.select_preview_region.width}x{self.canvas.select_preview_region.height}"
)
2023-04-21 22:05:41 +03:00
return
2023-04-21 20:11:39 +03:00
2023-04-22 08:04:19 +03:00
if self.selected_tool in [Tool.curve, Tool.polygon]:
2023-04-22 05:51:00 +03:00
if len(self.tool_points) < 2:
self.tool_points.append(Offset(event.mouse_move_event.x, event.mouse_move_event.y))
self.tool_points[-1] = Offset(event.mouse_move_event.x, event.mouse_move_event.y)
2023-04-22 08:04:19 +03:00
if self.selected_tool == Tool.curve:
self.make_preview(self.draw_current_curve)
elif self.selected_tool == Tool.polygon:
self.make_preview(self.draw_current_polyline, show_dimensions_in_status_bar=True) # polyline until finished
2023-04-22 05:51:00 +03:00
return
2023-04-14 03:28:58 +03:00
if len(self.undos) == 0:
2023-04-22 05:51:00 +03:00
# Code below wants to update the last action.
# However, if you you undo while drawing,
# there may be no last action.
# FIXME: Ideally we'd stop getting events in this case.
2023-04-14 03:28:58 +03:00
# This might be buggy if there were multiple undos.
# It might replace the action instead of doing nothing.
return
mm = event.mouse_move_event
2023-04-11 21:14:23 +03:00
action = self.undos[-1]
affected_region = None
2023-04-12 05:24:39 +03:00
replace_action = self.selected_tool in [Tool.ellipse, Tool.rectangle, Tool.line, Tool.rounded_rectangle]
2023-04-22 21:24:11 +03:00
old_action: Optional[Action] = None # avoid "possibly unbound"
2023-04-12 05:24:39 +03:00
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)
2023-04-14 01:48:24 +03:00
if self.selected_tool == Tool.pencil or self.selected_tool == Tool.brush or self.selected_tool == Tool.eraser or self.selected_tool == Tool.airbrush:
2023-04-12 05:24:39 +03:00
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.x, self.mouse_at_start.y, 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.x, mm.x), max(self.mouse_at_start.x, mm.x) + 1):
for y in range(min(self.mouse_at_start.y, mm.y), max(self.mouse_at_start.y, mm.y) + 1):
if x in range(min(self.mouse_at_start.x, mm.x) + 1, max(self.mouse_at_start.x, mm.x)) and y in range(min(self.mouse_at_start.y, mm.y) + 1, max(self.mouse_at_start.y, 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.x - mm.x) // 2, abs(self.mouse_at_start.y - mm.y) // 2)
min_x = min(self.mouse_at_start.x, mm.x)
max_x = max(self.mouse_at_start.x, mm.x)
min_y = min(self.mouse_at_start.y, mm.y)
max_y = max(self.mouse_at_start.y, 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)
2023-04-12 05:24:39 +03:00
elif self.selected_tool == Tool.ellipse:
center_x = (self.mouse_at_start.x + mm.x) // 2
center_y = (self.mouse_at_start.y + mm.y) // 2
radius_x = abs(self.mouse_at_start.x - mm.x) // 2
radius_y = abs(self.mouse_at_start.y - mm.y) // 2
2023-04-12 05:24:39 +03:00
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
2023-04-11 21:14:23 +03:00
# Update action region and image data
if action.region and affected_region:
action.region = action.region.union(affected_region)
elif affected_region:
action.region = affected_region
if action.region:
action.region = action.region.intersection(Region(0, 0, self.image.width, self.image.height))
action.update(self.image_at_start)
2023-04-11 21:14:23 +03:00
2023-04-12 05:24:39 +03:00
# Only for refreshing, include replaced action region
# (The new action is allowed to shrink the region compared to the old one)
if affected_region:
if replace_action:
affected_region = affected_region.union(old_action.region)
self.canvas.refresh_scaled_region(affected_region)
2023-04-21 22:05:41 +03:00
def on_canvas_tool_stop(self, event: Canvas.ToolStop) -> None:
2023-04-21 22:31:38 +03:00
"""Called when releasing the mouse button after drawing/dragging on the canvas."""
# Clear the selection preview in case the mouse has moved.
# (I don't know of any guarantee that it won't.)
self.cancel_preview()
self.get_widget_by_id("status_dimensions", Static).update("")
2023-04-21 22:31:38 +03:00
if self.selection_drag_offset:
# Done dragging selection
self.selection_drag_offset = None
return
2023-04-22 04:19:05 +03:00
if self.selecting_text:
# Done selecting text
self.selecting_text = False
return
2023-04-23 05:49:58 +03:00
if self.selected_tool in [Tool.select, Tool.free_form_select, Tool.text] and self.mouse_at_start:
# Finish making a selection
2023-04-23 05:49:58 +03:00
if self.selected_tool == Tool.free_form_select:
# Find bounds of the polygon
min_x = min(p.x for p in self.tool_points)
max_x = max(p.x for p in self.tool_points)
min_y = min(p.y for p in self.tool_points)
max_y = max(p.y for p in self.tool_points)
select_region = Region(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1)
select_region = select_region.intersection(Region(0, 0, self.image.width, self.image.height))
2023-04-23 05:49:58 +03:00
else:
select_region = self.get_select_region(self.mouse_at_start, event.mouse_up_event.offset)
2023-04-21 22:05:41 +03:00
if self.image.selection:
# This shouldn't happen, because it should meld
# the selection on mouse down.
self.meld_selection()
self.image.selection = Selection(select_region)
2023-04-22 04:19:05 +03:00
self.image.selection.textbox_mode = self.selected_tool == Tool.text
if self.image.selection.textbox_mode:
self.image.selection.contained_image = AnsiArtDocument(self.image.selection.region.width, self.image.selection.region.height)
2023-04-23 05:49:58 +03:00
if self.selected_tool == Tool.free_form_select:
# Define the mask for the selection using the polygon
self.image.selection.mask = [[is_inside_polygon(x + select_region.x, y + select_region.y, self.tool_points) for x in range(select_region.width)] for y in range(select_region.height)]
self.canvas.refresh_scaled_region(select_region)
2023-04-22 08:04:19 +03:00
elif self.selected_tool == Tool.curve:
2023-04-22 05:51:00 +03:00
# Maybe finish drawing a curve
if len(self.tool_points) >= 4:
# TODO: DRY action handling (undo state creation)!!!
self.image_at_start = AnsiArtDocument(self.image.width, self.image.height)
self.image_at_start.copy_region(self.image)
action = Action(self.selected_tool.get_name(), self.image)
if len(self.redos) > 0:
self.redos = []
self.undos.append(action)
affected_region = self.draw_current_curve()
action.region = affected_region
action.region = action.region.intersection(Region(0, 0, self.image.width, self.image.height))
action.update(self.image_at_start)
self.canvas.refresh_scaled_region(affected_region)
self.tool_points = []
else:
# Most likely just drawing the preview we just cancelled.
self.make_preview(self.draw_current_curve)
2023-04-22 08:04:19 +03:00
elif self.selected_tool == Tool.polygon:
# Maybe finish drawing a polygon
# Check if the distance between the first and last point is small enough,
# or if the user double-clicked.
close_gap_threshold_cells = 2
double_click_threshold_seconds = 0.5
double_click_threshold_cells = 2
time_since_last_click = event.time - self.polygon_last_click_time
enough_points = len(self.tool_points) >= 3
closed_gap = (
abs(self.tool_points[0].x - event.mouse_up_event.x) <= close_gap_threshold_cells and
abs(self.tool_points[0].y - event.mouse_up_event.y) <= close_gap_threshold_cells
)
double_clicked = (
time_since_last_click < double_click_threshold_seconds and
abs(self.mouse_at_start.x - event.mouse_up_event.x) <= double_click_threshold_cells and
abs(self.mouse_at_start.y - event.mouse_up_event.y) <= double_click_threshold_cells
2023-04-22 08:04:19 +03:00
)
if enough_points and (closed_gap or double_clicked):
# Finish drawing the polygon
# TODO: DRY action handling (undo state creation)!!!
self.image_at_start = AnsiArtDocument(self.image.width, self.image.height)
self.image_at_start.copy_region(self.image)
action = Action(self.selected_tool.get_name(), self.image)
if len(self.redos) > 0:
self.redos = []
self.undos.append(action)
affected_region = self.draw_current_polygon()
action.region = affected_region
action.region = action.region.intersection(Region(0, 0, self.image.width, self.image.height))
action.update(self.image_at_start)
self.canvas.refresh_scaled_region(affected_region)
self.tool_points = []
else:
# Most likely just drawing the preview we just cancelled.
self.make_preview(self.draw_current_polyline, show_dimensions_in_status_bar=True) # polyline until finished
2023-04-22 08:04:19 +03:00
self.polygon_last_click_time = event.time
elif self.selected_tool == Tool.pick_color:
self.selected_tool = self.return_to_tool
2023-04-22 08:04:19 +03:00
2023-04-22 04:19:05 +03:00
# Not reliably unset, so might as well not rely on it. (See early returns above.)
# self.mouse_at_start = None
2023-04-21 22:05:41 +03:00
2023-04-10 23:51:53 +03:00
def on_key(self, event: events.Key) -> None:
"""Called when the user presses a key."""
2023-04-22 04:19:05 +03:00
if self.image.selection and self.image.selection.textbox_mode:
key = event.key
assert self.image.selection.contained_image is not None, "Textbox mode should always have contained_image, to edit as text."
2023-04-22 04:19:05 +03:00
# TODO: delete selected text if any, when typing
x, y = self.image.selection.text_selection_start
if key == "enter":
x = 0
y += 1
if y >= self.image.selection.contained_image.height:
y = self.image.selection.contained_image.height - 1
elif key == "left":
x = max(0, x - 1)
elif key == "right":
x = min(self.image.selection.contained_image.width - 1, x + 1)
elif key == "up":
y = max(0, y - 1)
elif key == "down":
y = min(self.image.selection.contained_image.height - 1, y + 1)
elif key == "backspace":
x = max(0, x - 1)
self.image.selection.contained_image.ch[y][x] = " "
elif key == "delete":
self.image.selection.contained_image.ch[y][x] = " "
x = min(self.image.selection.contained_image.width - 1, x + 1)
elif key == "home":
x = 0
elif key == "end":
x = self.image.selection.contained_image.width - 1
elif key == "pageup":
y = 0
elif key == "pagedown":
y = self.image.selection.contained_image.height - 1
elif event.is_printable and event.character: # Redundance for type checker
2023-04-22 04:19:05 +03:00
# Type a character into the textbox
self.image.selection.contained_image.ch[y][x] = event.character
# x = min(self.image.selection.contained_image.width - 1, x + 1)
x += 1
if x >= self.image.selection.contained_image.width:
x = 0
# y = min(self.image.selection.contained_image.height - 1, y + 1)
y += 1
if y >= self.image.selection.contained_image.height:
y = self.image.selection.contained_image.height - 1
x = self.image.selection.contained_image.width - 1
self.image.selection.text_selection_start = Offset(x, y)
self.image.selection.text_selection_end = Offset(x, y)
self.canvas.refresh_scaled_region(self.image.selection.region)
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
2023-04-24 05:22:28 +03:00
def action_toggle_status_bar(self) -> None:
self.show_status_bar = not self.show_status_bar
def on_tools_box_tool_selected(self, event: ToolsBox.ToolSelected) -> None:
"""Called when a tool is selected in the palette."""
self.selected_tool = event.tool
if self.selected_tool not in [Tool.magnifier, Tool.pick_color]:
self.return_to_tool = self.selected_tool
2023-04-21 23:03:43 +03:00
self.meld_selection()
2023-04-22 05:51:00 +03:00
self.tool_points = []
def on_char_input_char_selected(self, event: CharInput.CharSelected) -> None:
"""Called when a character is entered in the character input."""
self.selected_char = event.char
def on_colors_box_color_selected(self, event: ColorsBox.ColorSelected) -> None:
"""Called when a color well is clicked in the palette."""
# TODO: a way to select the foreground color
# if event.fg:
# self.selected_fg_color = event.color
# else:
self.selected_bg_color = event.color
2023-04-10 23:51:53 +03:00
def on_tree_node_highlighted(self, event: Tree.NodeHighlighted[DirEntry]) -> None:
2023-04-15 08:25:31 +03:00
"""
Called when a file/folder is selected in the DirectoryTree.
This message comes from Tree.
DirectoryTree gives FileSelected but only for files.
"""
assert event.node.data
2023-04-15 08:25:31 +03:00
if event.node.data.is_dir:
self.directory_tree_selected_path = event.node.data.path
elif event.node.parent:
assert event.node.parent.data
2023-04-15 08:25:31 +03:00
self.directory_tree_selected_path = event.node.parent.data.path
name = os.path.basename(event.node.data.path)
if not self.expanding_directory_tree:
2023-04-21 06:35:22 +03:00
self.query_one(".file_dialog_window .filename_input", Input).value = name
2023-04-15 08:25:31 +03:00
else:
self.directory_tree_selected_path = None
2023-04-10 23:51:53 +03:00
def on_menu_item_hovered(self, event: MenuItem.Hovered) -> None:
"""Called when a menu item is hovered."""
self.get_widget_by_id("status_text", Static).update(event.menu_item.description or "")
def on_menu_closed(self, event: Menu.Closed) -> None:
"""Called when a menu is closed."""
self.get_widget_by_id("status_text", Static).update(_("For Help, click Help Topics on the Help Menu."))
def within_menus(self, node: DOMNode) -> bool:
"""Returns True if the node is within the menus."""
# root node will never be a menu, so it doesn't need to be `while node:`
# and this makes the type checker happy, since parent can be None
while node.parent:
if isinstance(node, Menu):
return True
node = node.parent
return False
2023-04-17 03:34:35 +03:00
def on_mouse_down(self, event: events.MouseDown) -> None:
"""Called when the mouse button gets pressed."""
leaf_widget, _ = self.get_widget_at(*event.screen_offset)
# Close menus if clicking outside the menus
if not self.within_menus(leaf_widget):
self.query_one(MenuBar).close()
return
# Deselect if clicking outside the canvas
if leaf_widget is self.editing_area:
self.meld_selection()
2023-04-17 03:34:35 +03:00
# This is a dev helper to inspect the layout
# by highlighting the elements under the mouse in different colors, and labeling them on their borders.
# debug_highlight is a list of tuples of (element, original_color, original_border, original_border_title)
if not inspect_layout:
return
# Trigger only with middle mouse button.
# This is before the reset, so you have to middle click on the root element to reset.
# I didn't like it resetting on every click.
if event.button != 2:
return
if hasattr(self, "debug_highlight"):
for element, original_color, original_border, original_border_title in self.debug_highlight:
element.styles.background = original_color
element.styles.border = original_border
element.border_title = original_border_title
self.debug_highlight: List[Tuple[Widget, Color, BorderDefinition, Optional[str]]] = []
# leaf_widget, _ = self.get_widget_at(*event.screen_offset)
2023-04-17 03:34:35 +03:00
if leaf_widget and leaf_widget is not self.screen:
for i, widget in enumerate(leaf_widget.ancestors_with_self):
self.debug_highlight.append((widget, widget.styles.background, widget.styles.border, widget.border_title if hasattr(widget, "border_title") else None)) # type: ignore
2023-04-17 03:34:35 +03:00
widget.styles.background = Color.from_hsl(i / 10, 1, 0.3)
if not event.ctrl:
widget.styles.border = ("round", Color.from_hsl(i / 10, 1, 0.5))
widget.border_title = widget.css_identifier_styled # type: ignore
2023-04-17 03:34:35 +03:00
# `textual run --dev paint.py` will search for a
# global variable named `app`, and fallback to
# anything that is an instance of `App`, or
# a subclass of `App`.
# Creating the app and parsing arguments must not be within an if __name__ == "__main__" block,
# since __name__ will be "<run_path>" when running with the textual CLI,
# and it would create a new app instance, and all arguments would be ignored.
app = PaintApp()
if args.ascii_only_icons:
ascii_only_icons = True
2023-04-17 03:34:35 +03:00
if args.inspect_layout:
inspect_layout = True
if args.filename:
# if args.filename == "-" and not sys.stdin.isatty():
# app.image = AnsiArtDocument.from_text(sys.stdin.read())
# app.filename = "<stdin>"
# else:
with open(args.filename, 'r') as my_file:
app.image = AnsiArtDocument.from_text(my_file.read())
app.image_initialized = True
app.filename = os.path.abspath(args.filename)
if args.clear_screen:
os.system("cls||clear")
2023-04-17 03:26:25 +03:00
app.dark = args.theme == "dark"
if __name__ == "__main__":
2023-04-11 23:22:00 +03:00
app.run()