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
2023-04-14 08:15:42 +03:00
import argparse
2023-04-16 07:59:23 +03:00
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 . widget import Widget
2023-04-23 00:49:15 +03:00
from textual . widgets import Button , Static , Input , Tree , Header
2023-04-23 04:06:45 +03:00
from textual . widgets . _directory_tree import DirEntry
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
2023-04-21 04:00:43 +03:00
from windows import Window , DialogWindow , CharacterSelectorDialogWindow , MessageBox , get_warning_icon
2023-04-18 10:26:10 +03:00
from localization . i18n import get as _ , load_language
2023-04-21 05:13:02 +03:00
from enhanced_directory_tree import EnhancedDirectoryTree
2023-04-10 23:51:53 +03:00
2023-04-19 21:32:31 +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 """
2023-04-19 09:23:29 +03:00
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
2023-04-19 09:23:29 +03:00
except Exception as e :
2023-04-20 02:09:02 +03:00
print ( " Error stopping application mode. The command line may not work as expected. The `reset` command should restore it on Linux. " , e )
2023-04-19 09:23:29 +03:00
2023-04-19 21:32:31 +03:00
try :
2023-04-20 04:45:18 +03:00
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
2023-04-19 21:32:31 +03:00
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 :
2023-04-20 04:34:29 +03:00
print ( f " Error closing file descriptor ( { handler . fd } ): " , e )
2023-04-19 09:08:01 +03:00
except Exception as e :
2023-04-20 04:34:29 +03:00
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
2023-04-19 22:12:03 +03:00
print ( " Reloading due to FS change: " , event . event_type , event . src_path )
app . screen . styles . background = " red "
2023-04-23 00:29:17 +03:00
# 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 ( )
2023-04-19 22:12:03 +03:00
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 """
2023-04-19 21:32:31 +03:00
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
2023-04-18 10:21:16 +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
2023-04-18 10:21:16 +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 ' ] )
2023-04-18 10:21:16 +03:00
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 ' )
2023-04-18 10:21:16 +03:00
parser . add_argument ( ' filename ' , nargs = ' ? ' , default = None , help = ' File to open ' )
2023-04-19 19:00:34 +03:00
2023-04-18 10:21:16 +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 "
2023-04-18 10:21:16 +03:00
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:
2023-04-14 04:31:36 +03:00
# - Free-Form Select: ✂️📐🆓🕸✨⚝🫥🇫/🇸◌⁛⁘ ⢼⠮
2023-04-18 22:06:12 +03:00
# - Select: ⬚▧🔲 ⣏⣹
2023-04-17 08:57:27 +03:00
# - Eraser/Color Eraser: 🧼🧽🧹🚫👋🗑️▰▱
2023-04-13 07:30:47 +03:00
# - Fill With Color: 🌊💦💧🌈🎉🎊🪣🫗
2023-04-13 07:27:27 +03:00
# - Pick Color: 🎨💉💅💧📌📍⤤𝀃🝯🍶
2023-04-11 00:29:04 +03:00
# - Magnifier: 🔍🔎👀🔬🔭🧐🕵️♂️🕵️♀️
2023-04-13 07:27:27 +03:00
# - Pencil: ✏️✎✍️🖎🖊️🖋️✒️🖆📝🖍️
2023-04-11 00:29:04 +03:00
# - Brush: 🖌️🖌👨🎨🧑🎨💅
# - Airbrush: 💨ᖜ╔🧴🥤🫠
2023-04-13 07:27:27 +03:00
# - Text: 🆎📝📄📃🔤📜AA
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 : " 🧼 " ,
2023-04-13 07:28:57 +03:00
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 : " 💨 " ,
2023-04-13 07:27:27 +03:00
Tool . text : " A " ,
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 :
2023-04-21 23:03:30 +03:00
""" 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 {
2023-04-18 10:09:34 +03:00
Tool . free_form_select : _ ( " Free-Form Select " ) ,
2023-04-18 22:06:12 +03:00
Tool . select : _ ( " Select " ) ,
2023-04-18 10:09:34 +03:00
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
]
2023-04-11 01:13:46 +03:00
class ToolsBox ( Container ) :
""" Widget containing tool buttons """
2023-04-21 19:24:00 +03:00
class ToolSelected ( Message ) :
""" Message sent when a tool is selected. """
def __init__ ( self , tool : Tool ) - > None :
self . tool = tool
super ( ) . __init__ ( )
2023-04-11 01:13:46 +03:00
def compose ( self ) - > ComposeResult :
""" Add our buttons. """
2023-04-23 01:54:21 +03:00
self . tool_by_button : dict [ Button , Tool ] = { }
2023-04-21 18:45:10 +03:00
for tool in Tool :
# TODO: tooltip with tool.get_name()
button = Button ( tool . get_icon ( ) , classes = " tool_button " )
button . can_focus = False
2023-04-22 20:17:23 +03:00
self . tool_by_button [ button ] = tool
2023-04-21 18:45:10 +03:00
yield button
2023-04-21 19:24:00 +03:00
def on_button_pressed ( self , event : Button . Pressed ) - > None :
""" Called when a button is clicked. """
if " tool_button " in event . button . classes :
2023-04-22 20:17:23 +03:00
self . post_message ( self . ToolSelected ( self . tool_by_button [ event . button ] ) )
2023-04-11 01:13:46 +03:00
2023-04-20 07:29:14 +03:00
class CharInput ( Input ) :
""" Widget for entering a single character. """
2023-04-21 19:24:00 +03:00
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 :
2023-04-21 19:14:45 +03:00
""" Called when value changes. """
2023-04-21 19:24:00 +03:00
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. """
2023-04-21 19:24:00 +03:00
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 ] = { }
2023-04-21 18:45:10 +03:00
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
2023-04-22 20:17:23 +03:00
self . color_by_button [ button ] = color
2023-04-21 18:45:10 +03:00
yield button
2023-04-11 06:25:32 +03:00
2023-04-21 19:24:00 +03:00
def on_button_pressed ( self , event : Button . Pressed ) - > None :
""" Called when a button is clicked. """
if " color_button " in event . button . classes :
2023-04-22 20:17:23 +03:00
self . post_message ( self . ColorSelected ( self . color_by_button [ event . button ] ) )
2023-04-21 19:24:00 +03:00
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]) " )
2023-04-11 15:04:20 +03:00
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-11 15:04:20 +03:00
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
2023-04-11 15:04:20 +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 ] } "
2023-04-11 15:04:20 +03:00
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 "
2023-04-11 15:04:20 +03:00
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. """
2023-04-13 08:30:45 +03:00
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
2023-04-12 04:45:00 +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. """
2023-04-12 04:45:00 +03:00
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 ] ] :
2023-04-11 17:16:35 +03:00
""" 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 :
2023-04-11 19:01:40 +03:00
yield x0 , y0
2023-04-11 17:16:35 +03:00
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 ] ] :
2023-04-22 05:50:57 +03:00
""" 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 :
2023-04-13 08:30:45 +03:00
""" 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 ) ]
2023-04-13 08:30:45 +03:00
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 )
2023-04-11 17:16:35 +03:00
# 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.
2023-04-11 17:16:35 +03:00
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__ ( )
2023-04-16 06:45:44 +03:00
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 :
2023-04-22 02:01:31 +03:00
# 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
2023-04-22 02:01:31 +03:00
2023-04-11 17:16:35 +03:00
self . post_message ( self . ToolStart ( event ) )
2023-04-11 04:30:02 +03:00
self . pointer_active = True
2023-04-11 05:16:50 +03:00
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 :
2023-04-16 22:22:38 +03:00
# 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.
2023-04-22 02:01:31 +03:00
# I think it's due to coordinates being calculated differently during mouse capture.
2023-04-16 22:22:38 +03:00
if self . pointer_active :
2023-04-22 21:14:19 +03:00
assert isinstance ( self . parent , Widget )
2023-04-16 22:22:38 +03:00
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 :
2023-04-22 02:01:31 +03:00
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 :
2023-04-11 17:16:35 +03:00
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 :
2023-04-22 02:01:31 +03:00
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
2023-04-11 05:16:50 +03:00
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 :
2023-04-16 06:45:44 +03:00
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 ) :
2023-04-22 03:49:41 +03:00
cell_x = x / / self . magnification
cell_y = y / / self . magnification
2023-04-20 21:45:00 +03:00
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 ] ) :
2023-04-22 03:49:41 +03:00
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 :
2023-04-22 03:49:41 +03:00
bg = self . image . bg [ cell_y ] [ cell_x ]
fg = self . image . fg [ cell_y ] [ cell_x ]
ch = self . image . ch [ cell_y ] [ cell_x ]
2023-04-20 21:45:00 +03:00
except IndexError :
# This should be easier to debug visually.
bg = " #555555 "
fg = " #cccccc "
ch = " ? "
2023-04-20 05:02:31 +03:00
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 )
2023-04-23 03:46:16 +03:00
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 ,
) )
2023-04-20 05:02:31 +03:00
def big_ch ( self , ch : str , x : int , y : int ) - > str :
""" Return a character part of a meta-glyph. """
2023-04-20 05:15:25 +03:00
match ch :
case " " :
2023-04-20 05:02:31 +03:00
return " "
2023-04-20 05:15:25 +03:00
case " █ " :
2023-04-20 05:02:31 +03:00
return " █ "
2023-04-20 05:15:25 +03:00
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
2023-04-20 05:02:31 +03:00
# 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
2023-04-13 06:41:43 +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.
2023-04-13 06:41:43 +03:00
BINDINGS = [
2023-04-16 07:59:23 +03:00
# 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.
2023-04-18 10:09:34 +03:00
( " ctrl+q " , " exit " , _ ( " Quit " ) ) ,
( " meta+q " , " exit " , _ ( " Quit " ) ) ,
( " ctrl+c " , " exit " , _ ( " Quit " ) ) ,
( " ctrl+s " , " save " , _ ( " Save " ) ) ,
( " ctrl+shift+s " , " save_as " , _ ( " Save As " ) ) ,
( " ctrl+o " , " open " , _ ( " Open " ) ) ,
( " ctrl+n " , " new " , _ ( " New " ) ) ,
# ("ctrl+shift+n", "clear_image", _("Clear Image")),
( " ctrl+t " , " toggle_tools_box " , _ ( " Toggle Tools Box " ) ) ,
( " ctrl+w " , " toggle_colors_box " , _ ( " Toggle Colors Box " ) ) ,
( " ctrl+z " , " undo " , _ ( " Undo " ) ) ,
2023-04-13 06:41:43 +03:00
# Ctrl+Shift+Z doesn't seem to work on Ubuntu or VS Code terminal
2023-04-18 10:48:47 +03:00
( " ctrl+shift+z " , " redo " , _ ( " Repeat " ) ) ,
( " shift+ctrl+z " , " redo " , _ ( " Repeat " ) ) ,
( " ctrl+y " , " redo " , _ ( " Repeat " ) ) ,
( " f4 " , " redo " , _ ( " Repeat " ) ) ,
2023-04-22 04:19:05 +03:00
# TODO: don't delete textbox with delete key
2023-04-21 23:19:07 +03:00
( " delete " , " clear_selection " , _ ( " Clear Selection " ) ) ,
2023-04-21 08:32:44 +03:00
( " ctrl+pageup " , " normal_size " , _ ( " Normal Size " ) ) ,
( " ctrl+pagedown " , " large_size " , _ ( " Large Size " ) ) ,
2023-04-13 06:41:43 +03:00
# action_toggle_dark is built in to App
2023-04-18 10:09:34 +03:00
( " 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
2023-04-19 09:45:55 +03:00
# in the context of the terminal in VS Code, and not used by this app, like Ctrl+R, and detectable in the terminal.
( " f2 " , " reload " , _ ( " Reload " ) ) ,
2023-04-13 06:41:43 +03:00
]
2023-04-11 18:40:47 +03:00
show_tools_box = var ( True )
2023-04-11 18:48:08 +03:00
show_colors_box = var ( True )
2023-04-11 02:27:11 +03:00
selected_tool = var ( Tool . pencil )
2023-04-21 18:25:22 +03:00
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
2023-04-22 21:54:11 +03:00
# 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 ] = [ ]
2023-04-16 07:59:23 +03:00
# temporary undo state for brush previews
2023-04-14 03:28:58 +03:00
preview_action : Optional [ Action ] = None
2023-04-16 07:59:23 +03:00
# 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
2023-04-20 20:12:18 +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 ( )
2023-04-21 00:10:05 +03:00
2023-04-18 10:09:34 +03:00
TITLE = _ ( " Paint " )
2023-04-17 04:05:55 +03:00
def watch_filename ( self , filename : Optional [ str ] ) - > None :
""" Called when filename changes. """
if filename is None :
2023-04-18 10:09:34 +03:00
self . sub_title = _ ( " Untitled " )
2023-04-17 04:05:55 +03:00
else :
2023-04-17 04:12:44 +03:00
self . sub_title = os . path . basename ( filename )
2023-04-17 04:05:55 +03:00
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. """
2023-04-21 18:45:10 +03:00
self . query_one ( " #tools_box " , ToolsBox ) . display = show_tools_box
2023-04-11 18:48:08 +03:00
def watch_show_colors_box ( self , show_colors_box : bool ) - > None :
""" Called when show_colors_box changes. """
2023-04-21 18:45:10 +03:00
self . query_one ( " #colors_box " , ColorsBox ) . display = show_colors_box
2023-04-10 23:51:53 +03:00
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. """
2023-04-21 18:38:20 +03:00
for button in self . query ( " .tool_button " ) :
2023-04-23 03:46:16 +03:00
assert isinstance ( button , Button )
2023-04-22 20:17:23 +03:00
if selected_tool == self . query_one ( " ToolsBox " , ToolsBox ) . tool_by_button [ button ] :
2023-04-21 06:27:39 +03:00
button . add_class ( " selected " )
else :
button . remove_class ( " selected " )
2023-04-11 02:27:11 +03:00
2023-04-21 18:25:22 +03:00
def watch_selected_bg_color ( self , selected_bg_color : str ) - > None :
""" Called when selected_bg_color changes. """
2023-04-21 18:35:39 +03:00
self . query_one ( " #selected_color_char_input " , CharInput ) . styles . background = selected_bg_color
2023-04-11 06:56:47 +03:00
2023-04-21 18:25:22 +03:00
def watch_selected_fg_color ( self , selected_fg_color : str ) - > None :
""" Called when selected_fg_color changes. """
2023-04-21 18:35:39 +03:00
self . query_one ( " #selected_color_char_input " , CharInput ) . styles . color = selected_fg_color
2023-04-21 18:25:22 +03:00
def watch_selected_char ( self , selected_char : str ) - > None :
2023-04-20 07:29:14 +03:00
""" Called when selected_char changes. """
2023-04-21 18:35:39 +03:00
self . query_one ( " #selected_color_char_input " , CharInput ) . value = selected_char
2023-04-20 07:29:14 +03:00
2023-04-21 08:32:44 +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
2023-04-22 06:43:08 +03:00
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 :
2023-04-22 06:43:08 +03:00
# 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 ) :
2023-04-22 06:43:08 +03:00
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
2023-04-13 08:06:03 +03:00
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
2023-04-21 18:25:22 +03:00
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 = " "
2023-04-21 18:25:22 +03:00
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
2023-04-11 17:16:35 +03:00
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
2023-04-21 18:25:22 +03:00
self . image . bg [ y ] [ x ] = bg_color
self . image . fg [ y ] [ x ] = fg_color
2023-04-11 17:16:35 +03:00
2023-04-23 05:49:58 +03:00
def erase_region ( self , region : Region , mask : Optional [ list [ list [ bool ] ] ] = None ) - > None :
2023-04-21 23:19:07 +03:00
# 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
2023-04-23 04:57:58 +03:00
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 )
2023-04-21 23:19:07 +03:00
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
2023-04-13 06:32:30 +03:00
def action_undo ( self ) - > None :
2023-04-21 23:34:35 +03:00
self . meld_selection ( )
2023-04-11 20:21:19 +03:00
if len ( self . undos ) > 0 :
2023-04-14 09:21:12 +03:00
self . cancel_preview ( )
2023-04-11 20:21:19 +03:00
action = self . undos . pop ( )
2023-04-18 10:09:34 +03:00
redo_action = Action ( _ ( " Undo " ) + " " + action . name , self . image , action . region )
2023-04-12 04:45:00 +03:00
action . undo ( self . image )
2023-04-11 20:21:19 +03:00
self . redos . append ( redo_action )
2023-04-20 21:53:30 +03:00
self . canvas . refresh ( layout = True )
2023-04-11 20:21:19 +03:00
2023-04-13 06:32:30 +03:00
def action_redo ( self ) - > None :
2023-04-21 23:34:35 +03:00
self . meld_selection ( )
2023-04-11 20:24:32 +03:00
if len ( self . redos ) > 0 :
2023-04-14 09:21:12 +03:00
self . cancel_preview ( )
2023-04-11 20:24:32 +03:00
action = self . redos . pop ( )
2023-04-18 10:09:34 +03:00
undo_action = Action ( _ ( " Undo " ) + " " + action . name , self . image , action . region )
2023-04-12 04:45:00 +03:00
action . undo ( self . image )
2023-04-11 20:24:32 +03:00
self . undos . append ( undo_action )
2023-04-20 21:53:30 +03:00
self . canvas . refresh ( layout = True )
2023-04-11 20:21:19 +03:00
2023-04-22 20:40:11 +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 ( )
2023-04-13 06:32:30 +03:00
def action_save ( self ) - > None :
2023-04-16 07:59:23 +03:00
""" Start the save action, but don ' t wait for the Save As dialog to close if it ' s a new file. """
2023-04-21 00:10:05 +03:00
task = asyncio . create_task ( self . save ( ) )
self . background_tasks . add ( task )
task . add_done_callback ( self . background_tasks . discard )
2023-04-16 07:59:23 +03:00
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. """
2023-04-16 07:50:12 +03:00
self . cancel_preview ( )
2023-04-19 01:54:41 +03:00
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 :
2023-04-18 22:02:58 +03:00
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 )
2023-04-19 01:54:41 +03:00
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 :
2023-04-19 01:54:41 +03:00
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 :
2023-04-19 01:54:41 +03:00
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 :
2023-04-16 07:59:23 +03:00
await self . save_as ( )
2023-04-11 23:22:00 +03:00
2023-04-13 06:32:30 +03:00
def action_save_as ( self ) - > None :
2023-04-16 07:59:23 +03:00
""" 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!
2023-04-21 00:10:05 +03:00
task = asyncio . create_task ( self . save_as ( ) )
self . background_tasks . add ( task )
task . add_done_callback ( self . background_tasks . discard )
2023-04-16 07:59:23 +03:00
async def save_as ( self ) - > None :
2023-04-11 23:22:00 +03:00
""" Save the image as a new file. """
2023-04-22 20:40:11 +03:00
self . close_windows ( " #save_as_dialog, #open_dialog " )
2023-04-16 06:56:37 +03:00
2023-04-23 01:54:21 +03:00
saved_future : asyncio . Future [ None ] = asyncio . Future ( )
2023-04-16 07:59:23 +03:00
2023-04-23 01:54:21 +03:00
def handle_button ( button : Button ) - > None :
2023-04-16 07:59:23 +03:00
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
2023-04-21 05:41:45 +03:00
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 ( )
2023-04-16 06:56:37 +03:00
window = DialogWindow (
2023-04-15 06:31:05 +03:00
id = " save_as_dialog " ,
2023-04-20 23:44:11 +03:00
classes = " file_dialog_window " ,
2023-04-18 10:09:34 +03:00
title = _ ( " Save As " ) ,
2023-04-16 07:59:23 +03:00
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 (
2023-04-21 05:13:02 +03:00
EnhancedDirectoryTree ( id = " save_as_dialog_directory_tree " , path = " / " ) ,
2023-04-21 06:35:22 +03:00
Input ( classes = " filename_input " , placeholder = _ ( " Filename " ) , value = filename ) ,
2023-04-18 10:09:34 +03:00
Button ( _ ( " Save " ) , classes = " save submit " , variant = " primary " ) ,
Button ( _ ( " Cancel " ) , classes = " cancel " ) ,
2023-04-15 06:31:05 +03:00
)
self . mount ( window )
2023-04-21 05:13:02 +03:00
self . expand_directory_tree ( window . content . query_one ( " #save_as_dialog_directory_tree " , EnhancedDirectoryTree ) )
2023-04-16 07:59:23 +03:00
await saved_future
2023-04-16 03:39:00 +03:00
2023-04-21 05:13:02 +03:00
def expand_directory_tree ( self , tree : EnhancedDirectoryTree ) - > None :
2023-04-16 03:39:00 +03:00
""" Expand the directory tree to the target directory, either the folder of the open file or the current working directory. """
2023-04-20 20:12:18 +03:00
self . expanding_directory_tree = True
2023-04-16 03:39:00 +03:00
target_dir = ( self . filename or os . getcwd ( ) ) . rstrip ( os . path . sep )
2023-04-21 05:13:02 +03:00
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.
2023-04-20 20:12:18 +03:00
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-21 05:13:02 +03:00
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. \n Do you want to replace it? " ) . replace ( " % 1 " , filename )
2023-04-23 01:54:21 +03:00
def handle_button ( button : Button ) - > None :
2023-04-16 07:59:23 +03:00
if not button . has_class ( " yes " ) :
return
callback ( )
2023-04-18 10:09:34 +03:00
self . warning_message_box ( _ ( " Save As " ) , Static ( message , markup = False ) , " yes/no " , handle_button )
2023-04-16 07:59:23 +03:00
2023-04-23 01:54:21 +03:00
def prompt_save_changes ( self , filename : str , callback : Callable [ [ ] , None ] ) - > None :
2023-04-16 07:59:23 +03:00
filename = os . path . basename ( filename )
message = " Save changes to " + filename + " ? "
2023-04-23 01:54:21 +03:00
def handle_button ( button : Button ) - > None :
2023-04-16 07:59:23 +03:00
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 ) :
2023-04-16 07:59:23 +03:00
if button . has_class ( " yes " ) :
await self . save ( )
callback ( )
# https://textual.textualize.io/blog/2023/02/11/the-heisenbug-lurking-in-your-async-code/
2023-04-21 00:10:05 +03:00
task = asyncio . create_task ( async_handle_button ( button ) )
self . background_tasks . add ( task )
task . add_done_callback ( self . background_tasks . discard )
2023-04-18 10:09:34 +03:00
self . warning_message_box ( _ ( " Paint " ) , Static ( message , markup = False ) , " yes/no/cancel " , handle_button )
2023-04-16 07:59:23 +03:00
def is_document_modified ( self ) - > bool :
return len ( self . undos ) != self . saved_undo_count
def action_exit ( self ) - > None :
if self . is_document_modified ( ) :
2023-04-18 10:09:34 +03:00
self . prompt_save_changes ( self . filename or _ ( " Untitled " ) , self . exit )
2023-04-16 07:59:23 +03:00
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-16 07:59:23 +03:00
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 :
2023-04-21 01:31:35 +03:00
""" Show a warning message box with the given title, message, and buttons. """
2023-04-22 20:40:11 +03:00
self . close_windows ( " #message_box " )
2023-04-21 01:09:49 +03:00
self . bell ( )
2023-04-23 01:54:21 +03:00
def handle_button ( button : Button ) - > None :
2023-04-21 01:31:35 +03:00
# 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 ,
2023-04-21 04:00:43 +03:00
icon_widget = get_warning_icon ( ) ,
2023-04-21 01:31:35 +03:00
message_widget = message_widget ,
button_types = button_types ,
handle_button = handle_button ,
)
2023-04-21 01:09:49 +03:00
self . mount ( window )
2023-04-15 08:45:23 +03:00
2023-04-16 04:11:44 +03:00
def action_open ( self ) - > None :
2023-04-16 07:59:23 +03:00
""" 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 :
2023-04-16 07:59:23 +03:00
if not button . has_class ( " open " ) :
window . close ( )
return
2023-04-21 18:35:39 +03:00
filename = window . content . query_one ( " #open_dialog .filename_input " , Input ) . value
2023-04-21 05:35:38 +03:00
if not filename :
return
2023-04-16 04:19:07 +03:00
if self . directory_tree_selected_path :
filename = os . path . join ( self . directory_tree_selected_path , filename )
2023-04-21 05:35:38 +03:00
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
2023-04-20 19:08:03 +03:00
window . close ( )
2023-04-21 05:35:38 +03:00
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
2023-04-22 20:40:11 +03:00
self . close_windows ( " #save_as_dialog, #open_dialog " )
2023-04-16 06:56:37 +03:00
window = DialogWindow (
2023-04-16 04:11:44 +03:00
id = " open_dialog " ,
2023-04-20 23:44:11 +03:00
classes = " file_dialog_window " ,
2023-04-18 10:09:34 +03:00
title = _ ( " Open " ) ,
2023-04-16 07:59:23 +03:00
handle_button = handle_button ,
2023-04-16 04:11:44 +03:00
)
window . content . mount (
2023-04-21 05:13:02 +03:00
EnhancedDirectoryTree ( id = " open_dialog_directory_tree " , path = " / " ) ,
2023-04-21 06:35:22 +03:00
Input ( classes = " filename_input " , placeholder = _ ( " Filename " ) ) ,
2023-04-18 10:09:34 +03:00
Button ( _ ( " Open " ) , classes = " open submit " , variant = " primary " ) ,
Button ( _ ( " Cancel " ) , classes = " cancel " ) ,
2023-04-16 04:11:44 +03:00
)
self . mount ( window )
2023-04-21 05:13:02 +03:00
self . expand_directory_tree ( window . content . query_one ( " #open_dialog_directory_tree " , EnhancedDirectoryTree ) )
2023-04-11 23:22:00 +03:00
2023-04-23 03:46:16 +03:00
def action_new ( self , * , force : bool = False ) - > None :
2023-04-14 08:46:26 +03:00
""" Create a new image. """
2023-04-16 07:59:23 +03:00
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 )
2023-04-18 10:09:34 +03:00
self . prompt_save_changes ( self . filename or _ ( " Untitled " ) , go_ahead )
2023-04-16 07:59:23 +03:00
return
2023-04-14 08:46:26 +03:00
self . image = AnsiArtDocument ( 80 , 24 )
self . canvas . image = self . image
2023-04-20 21:53:30 +03:00
self . canvas . refresh ( layout = True )
2023-04-14 08:46:26 +03:00
self . filename = None
2023-04-16 07:59:23 +03:00
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.
2023-04-21 18:25:22 +03:00
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-19 01:01:37 +03:00
2023-04-20 07:29:14 +03:00
def action_open_character_selector ( self ) - > None :
""" Show dialog to select a character. """
2023-04-22 20:40:11 +03:00
self . close_windows ( " #character_selector_dialog " )
2023-04-23 03:46:16 +03:00
def handle_selected_character ( character : str ) - > None :
2023-04-20 07:29:14 +03:00
self . selected_char = character
window . close ( )
2023-04-21 00:38:18 +03:00
window = CharacterSelectorDialogWindow (
2023-04-20 18:54:42 +03:00
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 )
2023-04-19 01:01:37 +03:00
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 :
self . warning_message_box ( _ ( " Paint " ) , " Not implemented. " , " ok " )
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_toggle_status_bar ( 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 :
2023-04-21 08:32:44 +03:00
self . magnification = 1
2023-04-19 01:01:37 +03:00
def action_large_size ( self ) - > None :
2023-04-21 08:32:44 +03:00
self . magnification = 4
2023-04-19 01:01:37 +03:00
def action_custom_zoom ( self ) - > None :
2023-04-22 20:40:11 +03:00
self . close_windows ( " #zoom_dialog " )
2023-04-23 03:46:16 +03:00
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 )
2023-04-19 01:01:37 +03:00
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 " )
def action_edit_colors ( self ) - > None :
self . warning_message_box ( _ ( " Paint " ) , " Not implemented. " , " ok " )
2023-04-20 23:16:10 +03:00
2023-04-19 01:01:37 +03:00
def action_help_topics ( self ) - > None :
2023-04-22 20:40:11 +03:00
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
2023-04-19 01:01:37 +03:00
def action_about_paint ( self ) - > None :
2023-04-20 23:00:38 +03:00
""" Show the About Paint dialog. """
2023-04-22 20:40:11 +03:00
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 / 1 j01 / textual - paint / blob / main / LICENSE . txt ] MIT [ / link ]
[ b ] Source Code : [ / b ] [ link = https : / / github . com / 1 j01 / textual - paint ] github . com / 1 j01 / 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 :
2023-04-11 01:13:46 +03:00
""" Add our widgets. """
2023-04-17 04:05:55 +03:00
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 \t Ctrl+N " ) , self . action_new , 57600 ) ,
MenuItem ( _ ( " &Open... \t Ctrl+O " ) , self . action_open , 57601 ) ,
MenuItem ( _ ( " &Save \t Ctrl+S " ) , self . action_save , 57603 ) ,
MenuItem ( _ ( " Save &As... " ) , self . action_save_as , 57604 ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-23 07:49:47 +03:00
MenuItem ( _ ( " Print Pre&view " ) , self . action_print_preview , 57609 , grayed = True ) ,
MenuItem ( _ ( " Page Se&tup... " ) , self . action_page_setup , 57605 , grayed = True ) ,
MenuItem ( _ ( " &Print... \t Ctrl+P " ) , self . action_print , 57607 , grayed = True ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-23 07:49:47 +03:00
MenuItem ( _ ( " S&end... " ) , self . action_send , 37662 , grayed = True ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-23 07:49:47 +03:00
MenuItem ( _ ( " Set As &Wallpaper (Tiled) " ) , self . action_set_as_wallpaper_tiled , 57677 , grayed = True ) ,
MenuItem ( _ ( " Set As Wa&llpaper (Centered) " ) , self . action_set_as_wallpaper_centered , 57675 , grayed = True ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
MenuItem ( _ ( " Recent File " ) , self . action_recent_file , 57616 , grayed = True ) ,
Separator ( ) ,
2023-04-19 07:37:59 +03:00
# MenuItem(_("E&xit\tAlt+F4"), self.action_exit, 57665),
MenuItem ( _ ( " E&xit \t Ctrl+Q " ) , self . action_exit , 57665 ) ,
2023-04-13 03:49:16 +03:00
] ) ) ,
2023-04-19 03:43:51 +03:00
MenuItem ( _ ( " &Edit " ) , submenu = Menu ( [
MenuItem ( _ ( " &Undo \t Ctrl+Z " ) , self . action_undo , 57643 ) ,
MenuItem ( _ ( " &Repeat \t F4 " ) , self . action_redo , 57644 ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-23 07:49:47 +03:00
MenuItem ( _ ( " Cu&t \t Ctrl+X " ) , self . action_cut , 57635 , grayed = True ) ,
MenuItem ( _ ( " &Copy \t Ctrl+C " ) , self . action_copy , 57634 , grayed = True ) ,
MenuItem ( _ ( " &Paste \t Ctrl+V " ) , self . action_paste , 57637 , grayed = True ) ,
2023-04-19 03:43:51 +03:00
MenuItem ( _ ( " C&lear Selection \t Del " ) , self . action_clear_selection , 57632 ) ,
2023-04-23 07:49:47 +03:00
MenuItem ( _ ( " Select &All \t Ctrl+A " ) , self . action_select_all , 57642 , grayed = True ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-23 07:49:47 +03:00
MenuItem ( _ ( " C&opy To... " ) , self . action_copy_to , 37663 , grayed = True ) ,
MenuItem ( _ ( " Paste &From... " ) , self . action_paste_from , 37664 , grayed = True ) ,
2023-04-13 03:49:16 +03:00
] ) ) ,
2023-04-19 03:43:51 +03:00
MenuItem ( _ ( " &View " ) , submenu = Menu ( [
MenuItem ( _ ( " &Tool Box \t Ctrl+T " ) , self . action_toggle_tools_box , 59415 ) ,
MenuItem ( _ ( " &Color Box \t Ctrl+L " ) , self . action_toggle_colors_box , 59416 ) ,
2023-04-23 07:49:47 +03:00
MenuItem ( _ ( " &Status Bar " ) , self . action_toggle_status_bar , 59393 , grayed = True ) ,
MenuItem ( _ ( " T&ext Toolbar " ) , self . action_text_toolbar , 37678 , grayed = True ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-19 03:43:51 +03:00
MenuItem ( _ ( " &Zoom " ) , submenu = Menu ( [
MenuItem ( _ ( " &Normal Size \t Ctrl+PgUp " ) , self . action_normal_size , 37670 ) ,
MenuItem ( _ ( " &Large Size \t Ctrl+PgDn " ) , self . action_large_size , 37671 ) ,
MenuItem ( _ ( " C&ustom... " ) , self . action_custom_zoom , 37672 ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-23 07:49:47 +03:00
MenuItem ( _ ( " Show &Grid \t Ctrl+G " ) , self . action_show_grid , 37677 , grayed = True ) ,
MenuItem ( _ ( " Show T&humbnail " ) , self . action_show_thumbnail , 37676 , grayed = True ) ,
2023-04-19 01:01:37 +03:00
] ) ) ,
2023-04-23 07:49:47 +03:00
MenuItem ( _ ( " &View Bitmap \t Ctrl+F " ) , self . action_view_bitmap , 37673 , grayed = True ) ,
2023-04-19 01:01:37 +03:00
] ) ) ,
2023-04-19 03:43:51 +03:00
MenuItem ( _ ( " &Image " ) , submenu = Menu ( [
2023-04-23 07:49:47 +03:00
MenuItem ( _ ( " &Flip/Rotate... \t Ctrl+R " ) , self . action_flip_rotate , 37680 , grayed = True ) ,
MenuItem ( _ ( " &Stretch/Skew... \t Ctrl+W " ) , self . action_stretch_skew , 37681 , grayed = True ) ,
MenuItem ( _ ( " &Invert Colors \t Ctrl+I " ) , self . action_invert_colors , 37682 , grayed = True ) ,
MenuItem ( _ ( " &Attributes... \t Ctrl+E " ) , self . action_attributes , 37683 , grayed = True ) ,
MenuItem ( _ ( " &Clear Image \t Ctrl+Shft+N " ) , self . action_clear_image , 37684 , grayed = True ) ,
MenuItem ( _ ( " &Draw Opaque " ) , self . action_draw_opaque , 6868 , grayed = True ) ,
2023-04-19 01:01:37 +03:00
] ) ) ,
2023-04-19 03:43:51 +03:00
MenuItem ( _ ( " &Colors " ) , submenu = Menu ( [
2023-04-23 07:49:47 +03:00
MenuItem ( _ ( " &Edit Colors... " ) , self . action_edit_colors , 6869 , grayed = True ) ,
2023-04-19 01:01:37 +03:00
] ) ) ,
2023-04-19 03:43:51 +03:00
MenuItem ( _ ( " &Help " ) , submenu = Menu ( [
MenuItem ( _ ( " &Help Topics " ) , self . action_help_topics , 57670 ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-19 03:43:51 +03:00
MenuItem ( _ ( " &About Paint " ) , self . action_about_paint , 57664 ) ,
2023-04-13 03:49:16 +03:00
] ) ) ,
] )
2023-04-11 06:25:32 +03:00
yield Container (
2023-04-21 18:45:10 +03:00
ToolsBox ( id = " tools_box " ) ,
2023-04-11 15:47:47 +03:00
Container (
Canvas ( id = " canvas " ) ,
2023-04-21 00:23:29 +03:00
id = " editing_area " ,
2023-04-11 15:47:47 +03:00
) ,
2023-04-21 00:23:29 +03:00
id = " main_horizontal_split " ,
2023-04-11 06:25:32 +03:00
)
2023-04-21 18:45:10 +03:00
yield ColorsBox ( id = " colors_box " )
2023-04-10 23:51:53 +03:00
2023-04-11 17:16:35 +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
2023-04-22 21:54:11 +03:00
if not self . image_initialized :
2023-04-11 23:22:00 +03:00
self . image = AnsiArtDocument ( 80 , 24 )
2023-04-22 21:54:11 +03:00
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
2023-04-21 00:23:29 +03:00
self . editing_area = self . query_one ( " #editing_area " , Container )
2023-04-11 17:16:35 +03:00
2023-04-14 02:01:40 +03:00
def pick_color ( self , x : int , y : int ) - > None :
""" Select a color from the image. """
2023-04-21 18:25:22 +03:00
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 ( )
2023-04-20 22:23:37 +03:00
# 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
2023-04-20 22:23:37 +03:00
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 ,
)
2023-04-21 07:04:25 +03:00
# `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.
2023-04-20 22:23:37 +03:00
# 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
2023-04-11 17:16:35 +03:00
def on_canvas_tool_start ( self , event : Canvas . ToolStart ) - > None :
""" Called when the user starts drawing on the canvas. """
2023-04-13 08:30:45 +03:00
event . stop ( )
2023-04-14 03:28:58 +03:00
self . cancel_preview ( )
2023-04-13 08:30:45 +03:00
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 )
return
2023-04-21 22:24:45 +03:00
# TODO: use Offset() instead of tuple
# and I would say use event.offset, but I'm dynamically
2023-04-22 04:19:05 +03:00
# modifying x/y in fix_mouse_event so I need to use those coords for now,
2023-04-21 22:24:45 +03:00
# unless there's some getter/setter magic behind the scenes.
2023-04-22 08:12:48 +03:00
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 ) # polyline until finished
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
2023-04-23 04:03:38 +03:00
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 (
2023-04-22 08:12:48 +03:00
sel . region . x - self . mouse_at_start . x ,
sel . region . y - self . mouse_at_start . y ,
2023-04-21 22:24:45 +03:00
)
2023-04-23 04:03:38 +03:00
if sel . contained_image :
2023-04-22 01:25:29 +03:00
# Already cut out, don't replace the image data.
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 )
2023-04-21 23:34:35 +03:00
if len ( self . redos ) > 0 :
self . redos = [ ]
2023-04-21 23:03:43 +03:00
self . undos . append ( action )
2023-04-23 04:03:38 +03:00
sel . copy_from_document ( self . image )
2023-04-23 05:49:58 +03:00
self . erase_region ( sel . region , sel . mask )
2023-04-21 23:34:35 +03:00
# 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 )
2023-04-11 20:24:32 +03:00
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 )
2023-04-13 08:30:45 +03:00
affected_region = None
2023-04-12 05:24:39 +03:00
if self . selected_tool == Tool . pencil or self . selected_tool == Tool . brush :
2023-04-13 08:30:45 +03:00
affected_region = self . stamp_brush ( event . mouse_down_event . x , event . mouse_down_event . y )
elif self . selected_tool == Tool . fill :
2023-04-21 18:25:22 +03:00
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 )
2023-04-13 08:30:45 +03:00
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-11 17:16:35 +03:00
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
2023-04-22 06:33:22 +03:00
def make_preview ( self , draw_proc : Callable [ [ ] , Region ] ) :
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 )
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-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 ) # 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
2023-04-16 06:45:44 +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-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
2023-04-21 23:19:07 +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
2023-04-21 23:19:07 +03:00
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
2023-04-11 17:16:35 +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 .
"""
2023-04-13 08:30:45 +03:00
event . stop ( )
2023-04-14 03:28:58 +03:00
self . cancel_preview ( )
2023-04-13 08:30:45 +03:00
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 ] :
2023-04-13 08:30:45 +03:00
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 "
2023-04-23 04:03:38 +03:00
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 ,
)
2023-04-23 07:10:06 +03:00
# 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 )
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 )
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 ) # 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
2023-04-11 17:16:35 +03:00
mm = event . mouse_move_event
2023-04-11 21:14:23 +03:00
action = self . undos [ - 1 ]
2023-04-13 08:30:45 +03:00
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 )
2023-04-13 07:52:44 +03:00
elif self . selected_tool == Tool . line :
2023-04-22 08:12:48 +03:00
for x , y in bresenham_walk ( self . mouse_at_start . x , self . mouse_at_start . y , mm . x , mm . y ) :
2023-04-13 07:52:44 +03:00
affected_region = self . stamp_brush ( x , y , affected_region )
elif self . selected_tool == Tool . rectangle :
2023-04-22 08:12:48 +03:00
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 ) ) :
2023-04-13 07:52:44 +03:00
continue
affected_region = self . stamp_brush ( x , y , affected_region )
elif self . selected_tool == Tool . rounded_rectangle :
2023-04-22 08:12:48 +03:00
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 )
2023-04-13 07:52:44 +03:00
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 :
2023-04-22 08:12:48 +03:00
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
2023-04-13 08:30:45 +03:00
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)
2023-04-13 08:30:45 +03:00
if affected_region :
if replace_action :
affected_region = affected_region . union ( old_action . region )
2023-04-20 21:54:39 +03:00
self . canvas . refresh_scaled_region ( affected_region )
2023-04-11 17:16:35 +03:00
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. """
2023-04-22 02:01:31 +03:00
# Clear the selection preview in case the mouse has moved.
# (I don't know of any guarantee that it won't.)
2023-04-22 01:33:34 +03:00
self . cancel_preview ( )
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 :
2023-04-22 01:33:34 +03:00
# 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 )
2023-04-23 06:51:08 +03:00
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 ) ]
2023-04-22 01:33:34 +03:00
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 = [ ]
2023-04-22 06:52:23 +03:00
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
2023-04-22 08:12:48 +03:00
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 ) # polyline until finished
self . polygon_last_click_time = event . time
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 :
2023-04-23 03:53:13 +03:00
key = event . key
2023-04-23 03:46:16 +03:00
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
2023-04-22 09:19:36 +03:00
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 )
2023-04-11 14:52:26 +03:00
2023-04-13 06:32:30 +03:00
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-21 19:24:00 +03:00
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
2023-04-21 23:03:43 +03:00
self . meld_selection ( )
2023-04-22 05:51:00 +03:00
self . tool_points = [ ]
2023-04-21 19:24:00 +03:00
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
2023-04-23 04:06:45 +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 .
"""
2023-04-23 04:06:45 +03:00
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 :
2023-04-23 04:06:45 +03:00
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 )
2023-04-20 20:12:18 +03:00
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
2023-04-17 03:34:35 +03:00
def on_mouse_down ( self , event : events . MouseDown ) - > None :
""" Called when the mouse button gets pressed. """
2023-04-23 07:01:57 +03:00
leaf_widget , _ = self . get_widget_at ( * event . screen_offset )
# 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
2023-04-23 03:46:16 +03:00
self . debug_highlight : List [ Tuple [ Widget , Color , BorderDefinition , Optional [ str ] ] ] = [ ]
2023-04-23 07:01:57 +03:00
# 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 ) :
2023-04-22 20:45:56 +03:00
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 )
2023-04-17 09:46:44 +03:00
if not event . ctrl :
widget . styles . border = ( " round " , Color . from_hsl ( i / 10 , 1 , 0.5 ) )
2023-04-22 20:45:56 +03:00
widget . border_title = widget . css_identifier_styled # type: ignore
2023-04-17 03:34:35 +03:00
2023-04-16 23:00:18 +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
2023-04-16 23:00:18 +03:00
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 ( ) )
2023-04-22 21:54:11 +03:00
app . image_initialized = True
2023-04-17 04:10:55 +03:00
app . filename = os . path . abspath ( args . filename )
2023-04-17 01:54:49 +03:00
if args . clear_screen :
os . system ( " cls||clear " )
2023-04-18 08:05:40 +03:00
2023-04-17 03:26:25 +03:00
app . dark = args . theme == " dark "
2023-04-14 08:15:42 +03:00
2023-04-16 23:00:18 +03:00
if __name__ == " __main__ " :
2023-04-11 23:22:00 +03:00
app . run ( )