2023-04-23 08:46:49 +03:00
#!/usr/bin/env python3
2023-04-15 08:25:31 +03:00
import os
2023-04-11 23:22:00 +03:00
import re
import sys
2023-04-19 09:08:01 +03:00
import psutil
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-27 00:49:12 +03:00
from typing import Any , Generator , 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-28 22:07:58 +03:00
from textual . containers import Container , Vertical , Horizontal
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
2023-04-24 02:42:49 +03:00
from textual . dom import DOMNode
2023-04-11 07:46:07 +03:00
from textual . widget import Widget
2023-04-23 00:49:15 +03:00
from textual . widgets import Button , Static , Input , Tree , Header
2023-04-23 04:06:45 +03:00
from textual . widgets . _directory_tree import DirEntry
2023-04-24 00:22:12 +03:00
from textual . binding import Binding
2023-04-17 03:34:35 +03:00
from textual . color import Color
2023-04-13 03:49:16 +03:00
from menus import MenuBar , Menu , MenuItem , Separator
2023-04-21 04:00:43 +03:00
from windows import Window , DialogWindow , CharacterSelectorDialogWindow , MessageBox , get_warning_icon
2023-04-23 21:29:42 +03:00
from edit_colors import EditColorsDialogWindow
2023-04-24 21:56:21 +03:00
from localization . i18n import get as _ , load_language , remove_hotkey
2023-04-21 05:13:02 +03:00
from enhanced_directory_tree import EnhancedDirectoryTree
2023-04-27 05:30:55 +03:00
from wallpaper import get_config_dir , set_wallpaper
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-24 00:20:08 +03:00
class CharInput ( Input , inherit_bindings = False ) :
2023-04-20 07:29:14 +03:00
""" 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 " "
2023-04-24 22:28:08 +03:00
# This caused a bug where the character would oscillate between multiple values
# due to the events queuing up.
# watch_value would send CharSelected, and then on_char_input_char_selected would
# set the value to an old value, which would cause watch_value to queue up another
# CharSelected event, and it would cycle through values.
# (Usually it wasn't a problem because the key events would be processed in time.)
# async def watch_value(self, value: str) -> None:
# """Called when value changes."""
# self.post_message(self.CharSelected(value))
# Instead, we override on_key to send the message.
async def on_key ( self , event : events . Key ) - > None :
""" Called when a key is pressed. """
# await super().on_key(event)
if event . is_printable and event . character : # redundance for type checker
self . value = event . character
self . post_message ( self . CharSelected ( self . value ) )
2023-04-20 07:29:14 +03:00
Fix "incompatible" override type checker errors
Method "watch_value" overrides class "Input" in an incompatible manner
Return type mismatch: base method returns type "Coroutine[Any, Any, None]", override returns type "None"
Type "None" cannot be assigned to type "Coroutine[Any, Any, None]"PylancereportIncompatibleMethodOverride
Method "validate_cursor_position" overrides class "Input" in an incompatible manner
Parameter 2 name mismatch: base parameter is named "cursor_position", override parameter is named "position"PylancereportIncompatibleMethodOverride
These wouldn't actually be problems, due to the nature of the reactive properties system, but whatever, I can make it match, no problem.
2023-04-24 09:21:39 +03:00
def validate_cursor_position ( self , cursor_position : int ) - > int :
2023-04-20 07:29:14 +03:00
""" 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 " "
2023-04-26 06:59:32 +03:00
def render_line ( self , y : int ) - > Strip :
2023-04-26 10:01:00 +03:00
""" Overrides rendering to color the character, since Input doesn ' t seem to support the color style. """
2023-04-26 06:59:32 +03:00
assert isinstance ( self . app , PaintApp )
# return Strip([Segment(self.value * self.size.width, Style(color=self.app.selected_fg_color, bgcolor=self.app.selected_bg_color))])
2023-04-26 08:24:37 +03:00
# There's a LineFilter class that can be subclassed to do stuff like this, but I'm not sure why you'd want a class for it.
# Is it a typechecking thing? Does python not have good interfaces support?
# Anyways, this code is based on how that works,
2023-04-26 06:59:32 +03:00
super_class_strip = super ( ) . render_line ( y )
2023-04-26 08:24:37 +03:00
new_segments : list [ Segment ] = [ ]
2023-04-26 06:59:32 +03:00
style_mod : Style = Style ( color = self . app . selected_fg_color , bgcolor = self . app . selected_bg_color )
for text , style , _ in super_class_strip . _segments :
2023-04-26 08:24:37 +03:00
assert isinstance ( style , Style )
2023-04-26 06:59:32 +03:00
new_segments . append ( Segment ( text , style + style_mod , None ) )
return Strip ( new_segments )
2023-04-20 07:29:14 +03:00
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. """
2023-04-25 05:46:13 +03:00
def __init__ ( self , color : str , as_foreground : bool ) - > None :
2023-04-21 19:24:00 +03:00
self . color = color
2023-04-25 05:46:13 +03:00
self . as_foreground = as_foreground
2023-04-21 19:24:00 +03:00
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-23 21:58:17 +03:00
def update_palette ( self ) - > None : # , palette: list[str]) -> None:
""" Update the palette with new colors. """
for button , color in zip ( self . query ( " .color_button " ) . nodes , palette ) :
assert isinstance ( button , Button )
button . styles . background = color
self . color_by_button [ button ] = color
2023-04-23 21:36:05 +03:00
last_click_time = 0
2023-04-24 04:51:44 +03:00
last_click_button : Button | None = None
2023-04-25 05:46:13 +03:00
# def on_button_pressed(self, event: Button.Pressed) -> None:
# """Called when a button is clicked."""
def on_mouse_down ( self , event : events . MouseDown ) - > None :
""" Called when a mouse button is pressed. """
button , _ = self . app . get_widget_at ( * event . screen_offset )
if " color_button " in button . classes :
2023-04-26 20:19:06 +03:00
assert isinstance ( button , Button )
2023-04-25 05:46:13 +03:00
self . post_message ( self . ColorSelected ( self . color_by_button [ button ] , event . ctrl ) )
2023-04-23 21:36:05 +03:00
# Detect double click and open Edit Colors dialog.
2023-04-25 05:46:13 +03:00
if event . time - self . last_click_time < 0.8 and button == self . last_click_button :
2023-04-23 21:36:05 +03:00
assert isinstance ( self . app , PaintApp )
2023-04-25 05:46:13 +03:00
self . app . action_edit_colors ( self . query ( " .color_button " ) . nodes . index ( button ) , event . ctrl )
2023-04-23 21:36:05 +03:00
self . last_click_time = event . time
2023-04-25 05:46:13 +03:00
self . last_click_button = button
2023-04-11 21:37:32 +03:00
2023-04-21 22:05:41 +03:00
class Selection :
"""
A selection within an AnsiArtDocument .
AnsiArtDocument can contain a Selection , and Selection can contain an AnsiArtDocument .
However , the selection ' s AnsiArtDocument should never itself contain a Selection.
When a selection is created , it has no image data , but once it ' s dragged,
it gets a copy of the image data from the document .
The image data is stored as an AnsiArtDocument , since it ' s made up of characters and colors.
"""
def __init__ ( self , region : Region ) - > None :
""" Initialize a selection. """
2023-04-22 04:19:05 +03:00
self . region : Region = region
""" The region of the selection within the outer document. """
2023-04-21 22:05:41 +03:00
self . contained_image : Optional [ AnsiArtDocument ] = None
2023-04-22 04:19:05 +03:00
""" The image data contained in the selection, None until dragged, except for text boxes. """
2023-04-26 19:47:08 +03:00
self . pasted : bool = False
""" Whether the selection was pasted from the clipboard, and thus needs an undo state created for it when melding. """
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. """
2023-04-25 23:25:47 +03:00
self . textbox_edited = False
""" Whether text has been typed into the text box, ever. If not, the textbox can be deleted when clicking off. """
2023-04-22 04:19:05 +03:00
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-26 20:49:33 +03:00
# Prevent out of bounds errors (IndexError: list assignment index out of range)
# by clipping the target region to the document, and adjusting the source region accordingly.
2023-04-21 23:03:43 +03:00
target_region = self . region . intersection ( Region ( 0 , 0 , document . width , document . height ) )
2023-04-26 20:49:33 +03:00
source_region = Region ( target_region . x - self . region . x , target_region . y - self . region . y , self . contained_image . width , self . contained_image . height )
document . copy_region ( source = self . contained_image , source_region = source_region , target_region = target_region , mask = self . mask )
2023-04-21 22:05:41 +03:00
2023-04-27 04:10:08 +03:00
def offset_to_text_index ( textbox : Selection , offset : Offset ) - > int :
""" Converts an offset in the textbox to an index in the text. """
assert textbox . textbox_mode , " offset_to_text_index called on non-textbox selection "
return offset . y * textbox . region . width + offset . x
def text_index_to_offset ( textbox : Selection , index : int ) - > Offset :
""" Converts an index in the text to an offset in the textbox. """
assert textbox . textbox_mode , " text_index_to_offset called on non-textbox selection "
return Offset ( index % textbox . region . width , index / / textbox . region . width )
def selected_text_range ( textbox : Selection ) - > Generator [ Offset , None , None ] :
""" Yields all offsets within the text selection. """
assert textbox . textbox_mode , " selected_text_range called on non-textbox selection "
start = offset_to_text_index ( textbox , textbox . text_selection_start )
end = offset_to_text_index ( textbox , textbox . text_selection_end )
for i in range ( min ( start , end ) , max ( start , end ) + 1 ) :
yield text_index_to_offset ( textbox , i )
2023-04-27 04:23:16 +03:00
def selected_text ( textbox : Selection ) - > str :
""" Returns the text within the text selection. """
assert textbox . textbox_mode , " selected_text called on non-textbox selection "
assert textbox . contained_image , " textbox has no image data "
# return "".join(textbox.contained_image.ch[y][x] for x, y in selected_text_range(textbox))
text = " "
last_y = - 1
for x , y in selected_text_range ( textbox ) :
text + = textbox . contained_image . ch [ y ] [ x ]
if y != last_y :
text + = " \n "
last_y = y
return text
2023-04-27 04:10:08 +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. """
2023-04-27 04:02:32 +03:00
def __init__ ( self , width : int , height : int , default_bg : str = " #ffffff " , default_fg : str = " #000000 " ) - > None :
2023-04-11 15:04:20 +03:00
""" Initialize the document. """
self . width = width
self . height = height
self . ch = [ [ " " for _ in range ( width ) ] for _ in range ( height ) ]
2023-04-27 04:02:32 +03:00
self . bg = [ [ default_bg for _ in range ( width ) ] for _ in range ( height ) ]
self . fg = [ [ default_fg 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-26 10:01:00 +03:00
""" Copy a region from another document into this document. """
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-28 22:07:58 +03:00
def resize ( self , width : int , height : int , default_bg : str = " #ffffff " , default_fg : str = " #000000 " ) - > None :
""" Resize the document. """
if width == self . width and height == self . height :
return
new_ch = [ [ " " for _ in range ( width ) ] for _ in range ( height ) ]
new_bg = [ [ default_bg for _ in range ( width ) ] for _ in range ( height ) ]
new_fg = [ [ default_fg for _ in range ( width ) ] for _ in range ( height ) ]
for y in range ( min ( height , self . height ) ) :
for x in range ( min ( width , self . width ) ) :
new_ch [ y ] [ x ] = self . ch [ y ] [ x ]
new_bg [ y ] [ x ] = self . bg [ y ] [ x ]
new_fg [ y ] [ x ] = self . fg [ y ] [ x ]
self . width = width
self . height = height
self . ch = new_ch
self . bg = new_bg
self . fg = new_fg
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-29 02:09:59 +03:00
def get_text ( self ) - > str :
""" Get the plain text representation of the document. """
text = " "
for y in range ( self . height ) :
for x in range ( self . width ) :
text + = self . ch [ y ] [ x ]
text + = " \n "
return text
2023-04-29 02:18:35 +03:00
def get_pre_inner_xhtml ( self ) - > str :
""" Get an XHTML snippet which should be placed in a <pre> tag. """
2023-04-11 23:22:00 +03:00
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> "
2023-04-29 02:18:35 +03:00
html + = " <br/> "
2023-04-11 23:22:00 +03:00
return html
2023-04-29 02:18:35 +03:00
def get_html ( self ) - > str :
""" Get the HTML representation of the document. """
return """ <!DOCTYPE html>
< html >
< head >
< meta charset = " utf-8 " >
< style >
html ,
body ,
pre {
margin : 0 ;
padding : 0 ;
}
pre {
font - family : monospace ;
line - height : 1 ;
}
< / style >
< / head >
< body >
< pre > """ + self.get_pre_inner_xhtml() + """ < / pre >
< / body >
< / html >
"""
def get_svg ( self ) - > str :
""" Get the SVG representation of the document. """
css = """
pre {
overflow : hidden ;
margin : 0 ;
padding : 0 ;
}
svg {
font : 10 px monospace ;
line - height : 1 ;
}
span ,
font {
display : inline - block ;
}
"""
svg = f """ <?xml version= " 1.0 " encoding= " UTF-8 " ?>
< svg xmlns = " http://www.w3.org/2000/svg " width = " {self.width} ch " height = " {self.height} lh " >
< style > { css } < / style >
< foreignObject x = " 0 " y = " 0 " width = " 80ch " height = " 38lh " >
< pre xmlns = " http://www.w3.org/1999/xhtml " > { self . get_pre_inner_xhtml ( ) } < / pre >
< / foreignObject >
< / svg >
"""
return svg
2023-04-11 23:22:00 +03:00
@staticmethod
2023-04-27 04:02:32 +03:00
def from_ascii ( text : str , default_bg : str = " #ffffff " , default_fg : str = " #000000 " ) - > ' AnsiArtDocument ' :
2023-04-11 23:22:00 +03:00
""" 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 )
2023-04-27 04:02:32 +03:00
document = AnsiArtDocument ( width , height , default_bg , default_fg )
2023-04-11 23:22:00 +03:00
for y , line in enumerate ( lines ) :
for x , char in enumerate ( line ) :
document . ch [ y ] [ x ] = char
return document
@staticmethod
2023-04-27 04:02:32 +03:00
def from_ansi ( text : str , default_bg : str = " #ffffff " , default_fg : str = " #000000 " ) - > ' AnsiArtDocument ' :
2023-04-11 23:22:00 +03:00
""" 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 )
2023-04-26 01:43:28 +03:00
# Initial document is zero wide to avoid an extraneous character at (0,0),
# but needs one row to avoid IndexError.
2023-04-27 04:02:32 +03:00
document = AnsiArtDocument ( 0 , 1 , default_bg , default_fg )
2023-04-26 01:43:28 +03:00
# Ultimately, the minimum size is 1x1.
2023-04-11 23:22:00 +03:00
width = 1
height = 1
x = 0
y = 0
2023-04-27 04:02:32 +03:00
bg_color = default_bg
fg_color = default_fg
2023-04-11 23:22:00 +03:00
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
2023-04-27 04:02:32 +03:00
def from_text ( text : str , default_bg : str = " #ffffff " , default_fg : str = " #000000 " ) - > ' AnsiArtDocument ' :
2023-04-11 23:22:00 +03:00
""" Creates a document from the given text, detecting if uses ANSI or not. """
if ansi_escape_pattern . search ( text ) :
2023-04-27 04:02:32 +03:00
return AnsiArtDocument . from_ansi ( text , default_bg , default_fg )
2023-04-11 23:22:00 +03:00
else :
2023-04-27 04:02:32 +03:00
return AnsiArtDocument . from_ascii ( text , default_bg , default_fg )
2023-04-11 23:22:00 +03:00
2023-04-11 20:21:19 +03:00
class Action :
""" An action that can be undone efficiently using a region update. """
2023-04-29 04:11:46 +03:00
def __init__ ( self , name : str , 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
self . name = name
self . region = region
2023-04-29 04:11:46 +03:00
self . sub_image_before : AnsiArtDocument | None = None
2023-04-11 21:14:23 +03:00
def update ( self , document : AnsiArtDocument ) - > None :
""" Grabs the image data from the current region of the document. """
2023-04-29 04:48:15 +03:00
if self . region is None :
print ( " Warning: Action.update called without a defined region " )
return
2023-04-29 04:11:46 +03:00
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-29 04:11:46 +03:00
if not self . sub_image_before :
print ( " Warning: No undo data for Action. (Action.undo was called before any Action.update) " )
return
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
2023-04-24 09:28:52 +03:00
# Actually I just got this from Copilot, and don't know the source
2023-04-23 05:49:58 +03:00
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 :
2023-04-24 09:28:52 +03:00
x_intersection = ( y - p1y ) * ( p2x - p1x ) / ( p2y - p1y ) + p1x
if p1x == p2x or x < = x_intersection :
2023-04-23 05:49:58 +03:00
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-26 10:01:00 +03:00
""" Returns a point along a bezier curve. """
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
2023-04-25 04:58:32 +03:00
( original_ch == " " or document . fg [ y ] [ x ] == original_fg ) and
( document . ch [ y ] [ x ] != fill_ch or document . bg [ y ] [ x ] != fill_bg or document . fg [ y ] [ x ] != fill_fg )
2023-04-13 08:30:45 +03:00
)
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-26 08:30:36 +03:00
self . which_button : Optional [ int ] = 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-26 10:01:00 +03:00
""" Called when a mouse button is pressed.
Start drawing , or if both mouse buttons are pressed , cancel the current action . """
2023-04-24 08:21:18 +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-26 08:30:36 +03:00
if self . pointer_active and self . which_button != event . button :
assert isinstance ( self . app , PaintApp )
self . app . stop_action_in_progress ( )
return
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-26 08:30:36 +03:00
self . which_button = event . button
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-26 10:01:00 +03:00
""" Work around inconsistent widget-relative mouse coordinates by calculating from screen coordinates. """
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-24 08:21:18 +03:00
# if self.pointer_active:
# assert isinstance(self.parent, Widget)
# event.x += int(self.parent.scroll_x)
# event.y += int(self.parent.scroll_y)
# The above fix sometimes works but maybe sometimes shouldn't apply or isn't right.
# In order to make this robust without knowing the exact cause,
# I'm going to always calculate straight from the screen coordinates.
# This should also make it robust against the bugs in the library being fixed.
# node: DOMNode|None = self
offset = event . screen_offset
# while node:
# offset = offset - node.offset
# node = node.parent
# assert isinstance(self.parent, Widget)
offset = offset - self . region . offset #+ Offset(int(self.parent.scroll_x), int(self.parent.scroll_y))
event . x = offset . x
event . y = offset . 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-26 10:01:00 +03:00
""" Called when the mouse is moved. Update the tool action or preview. """
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
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-26 10:01:00 +03:00
""" Called when a mouse button is released. Stop the current tool. """
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-26 10:01:00 +03:00
""" Called when the mouse leaves the canvas. Stop preview if applicable. """
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-26 10:01:00 +03:00
""" Defines the intrinsic width of the widget. """
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-26 10:01:00 +03:00
""" Defines the intrinsic height of the widget. """
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-28 21:25:52 +03:00
style = Style ( color = fg , bgcolor = bg )
2023-04-23 03:46:16 +03:00
assert style . color is not None
assert style . bgcolor is not None
2023-04-26 23:47:55 +03:00
def within_text_selection_highlight ( textbox : Selection ) - > int :
2023-04-27 04:32:42 +03:00
if cell_x > = textbox . region . right or cell_x < textbox . region . x :
2023-04-26 23:47:55 +03:00
# Prevent inverting outside the textbox.
return False
def offset_to_text_index ( offset : Offset ) - > int :
return offset . y * textbox . region . width + offset . x
start_index = offset_to_text_index ( textbox . text_selection_start )
end_index = offset_to_text_index ( textbox . text_selection_end )
min_index = min ( start_index , end_index )
max_index = max ( start_index , end_index )
cell_index = offset_to_text_index ( Offset ( cell_x , cell_y ) - textbox . region . offset )
return min_index < = cell_index < = max_index
2023-04-26 02:22:57 +03:00
assert isinstance ( self . app , PaintApp )
2023-04-21 20:11:39 +03:00
if (
2023-04-26 01:18:41 +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-26 02:22:57 +03:00
( sel and ( not sel . textbox_mode ) and ( self . app . selection_drag_offset is None ) and selection_region . contains ( x , y ) and ( not inner_selection_region . contains ( x , y ) ) ) or
2023-04-26 23:47:55 +03:00
( sel and sel . textbox_mode and within_text_selection_highlight ( sel ) )
2023-04-21 20:11:39 +03:00
) :
2023-04-20 03:51:12 +03:00
# invert the colors
2023-04-28 21:32:02 +03:00
inverse_color = f " rgb( { 255 - style . color . triplet . red } , { 255 - style . color . triplet . green } , { 255 - style . color . triplet . blue } ) "
inverse_bgcolor = f " rgb( { 255 - style . bgcolor . triplet . red } , { 255 - style . bgcolor . triplet . green } , { 255 - style . bgcolor . triplet . blue } ) "
style = Style ( color = inverse_color , bgcolor = inverse_bgcolor )
2023-04-20 03:51:12 +03:00
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-26 19:26:12 +03:00
Binding ( " ctrl+q " , " exit " , _ ( " Quit " ) ) ,
2023-04-24 00:22:12 +03:00
Binding ( " ctrl+s " , " save " , _ ( " Save " ) ) ,
Binding ( " ctrl+shift+s " , " save_as " , _ ( " Save As " ) ) ,
Binding ( " ctrl+p " , " print " , _ ( " Print " ) ) ,
Binding ( " ctrl+o " , " open " , _ ( " Open " ) ) ,
Binding ( " ctrl+n " , " new " , _ ( " New " ) ) ,
Binding ( " ctrl+shift+n " , " clear_image " , _ ( " Clear Image " ) ) ,
Binding ( " ctrl+t " , " toggle_tools_box " , _ ( " Toggle Tools Box " ) ) ,
Binding ( " ctrl+w " , " toggle_colors_box " , _ ( " Toggle Colors Box " ) ) ,
Binding ( " ctrl+z " , " undo " , _ ( " Undo " ) ) ,
2023-04-23 22:25:12 +03:00
# Ctrl+Shift+<key> doesn't seem to work on Ubuntu or VS Code terminal,
# it ignores the Shift.
2023-04-24 00:22:12 +03:00
Binding ( " ctrl+shift+z,shift+ctrl+z,ctrl+y,f4 " , " redo " , _ ( " Repeat " ) ) ,
Binding ( " ctrl+x " , " cut " , _ ( " Cut " ) ) ,
2023-04-26 19:26:12 +03:00
Binding ( " ctrl+c " , " copy " , _ ( " Copy " ) ) , # Quit, for now
2023-04-24 00:22:12 +03:00
Binding ( " ctrl+v " , " paste " , _ ( " Paste " ) ) ,
Binding ( " ctrl+g " , " toggle_grid " , _ ( " Show Grid " ) ) ,
Binding ( " ctrl+f " , " view_bitmap " , _ ( " View Bitmap " ) ) ,
Binding ( " ctrl+r " , " flip_rotate " , _ ( " Flip/Rotate " ) ) ,
Binding ( " ctrl+w " , " stretch_skew " , _ ( " Stretch/Skew " ) ) ,
Binding ( " ctrl+i " , " invert_colors " , _ ( " Invert Colors " ) ) ,
Binding ( " ctrl+e " , " attributes " , _ ( " Attributes " ) ) ,
2023-04-26 18:19:16 +03:00
Binding ( " delete " , " clear_selection(True) " , _ ( " Clear Selection " ) ) ,
2023-04-24 00:22:12 +03:00
Binding ( " ctrl+a " , " select_all " , _ ( " Select All " ) ) ,
Binding ( " ctrl+pageup " , " normal_size " , _ ( " Normal Size " ) ) ,
Binding ( " ctrl+pagedown " , " large_size " , _ ( " Large Size " ) ) ,
2023-04-13 06:41:43 +03:00
# action_toggle_dark is built in to App
2023-04-24 00:22:12 +03:00
Binding ( " ctrl+d " , " toggle_dark " , _ ( " Toggle Dark Mode " ) ) ,
2023-04-26 07:17:18 +03:00
Binding ( " escape " , " cancel " , _ ( " Cancel " ) ) ,
2023-04-25 03:41:27 +03:00
Binding ( " f1 " , " help_topics " , _ ( " Help Topics " ) ) ,
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.
2023-04-24 00:22:12 +03:00
Binding ( " f2 " , " reload " , _ ( " Reload " ) ) ,
2023-04-25 03:41:27 +03:00
#
Binding ( " f3 " , " custom_zoom " , _ ( " Custom Zoom " ) )
2023-04-13 06:41:43 +03:00
]
2023-04-11 18:40:47 +03:00
show_tools_box = var ( True )
2023-04-26 10:19:23 +03:00
""" Whether to show the tools box. """
2023-04-11 18:48:08 +03:00
show_colors_box = var ( True )
2023-04-26 10:19:23 +03:00
""" Whether to show the tools box. """
2023-04-24 05:22:28 +03:00
show_status_bar = var ( True )
2023-04-26 10:19:23 +03:00
""" Whether to show the status bar. """
2023-04-11 02:27:11 +03:00
selected_tool = var ( Tool . pencil )
2023-04-26 10:19:23 +03:00
""" The currently selected tool. """
2023-04-24 02:48:36 +03:00
return_to_tool = var ( Tool . pencil )
2023-04-26 10:19:23 +03:00
""" Tool to switch to after using the Magnifier or Pick Color tools. """
2023-04-21 18:25:22 +03:00
selected_bg_color = var ( palette [ 0 ] )
2023-04-26 10:19:23 +03:00
""" The currently selected background color. Unlike MS Paint, this acts as the primary color. """
2023-04-21 18:25:22 +03:00
selected_fg_color = var ( palette [ len ( palette ) / / 2 ] )
2023-04-26 10:19:23 +03:00
""" The currently selected foreground (text) color. """
2023-04-11 22:20:31 +03:00
selected_char = var ( " " )
2023-04-26 10:19:23 +03:00
""" The character to draw with. """
2023-04-29 01:53:33 +03:00
file_path = var ( None )
""" The path to the file being edited. """
2023-04-23 01:54:21 +03:00
directory_tree_selected_path : str | None = None
2023-04-26 10:19:23 +03:00
""" Last highlighted item in Open/Save As dialogs """
2023-04-26 10:21:09 +03:00
expanding_directory_tree = False
""" Flag to prevent setting the filename input when initially expanding the directory tree """
2023-04-22 21:54:11 +03:00
image = var ( AnsiArtDocument . from_text ( " Not Loaded " ) )
2023-04-26 10:19:23 +03:00
""" The document being edited. Contains the selection, if any. """
2023-04-22 21:54:11 +03:00
image_initialized = False
2023-04-26 10:19:23 +03:00
""" Whether the image is ready. This flag exists to avoid type checking woes if I were to allow image to be None. """
2023-04-22 21:54:11 +03:00
2023-04-20 02:18:29 +03:00
magnification = var ( 1 )
2023-04-26 10:19:23 +03:00
""" Current magnification level. """
2023-04-20 02:18:29 +03:00
return_to_magnification = var ( 4 )
2023-04-26 10:19:23 +03:00
""" Saved zoomed-in magnification level. """
2023-04-10 23:51:53 +03:00
2023-04-12 05:19:08 +03:00
undos : List [ Action ] = [ ]
2023-04-26 10:19:23 +03:00
""" Past actions that can be undone """
2023-04-12 05:19:08 +03:00
redos : List [ Action ] = [ ]
2023-04-26 10:19:23 +03:00
""" Future actions that can be redone """
2023-04-14 03:28:58 +03:00
preview_action : Optional [ Action ] = None
2023-04-26 10:19:23 +03:00
""" A temporary undo state for tool previews """
2023-04-16 07:59:23 +03:00
saved_undo_count = 0
2023-04-26 10:19:23 +03:00
""" Used to determine if the document has been modified since the last save, in is_document_modified() """
2023-04-11 20:21:19 +03:00
2023-04-24 22:46:17 +03:00
mouse_gesture_cancelled = False
2023-04-26 10:19:23 +03:00
""" For Undo/Redo, to interrupt the current action """
2023-04-24 08:58:27 +03:00
mouse_at_start : Offset = Offset ( 0 , 0 )
2023-04-26 10:19:23 +03:00
""" Mouse position at mouse down.
Used for shape tools that draw between the mouse down and up points ( Line , Rectangle , Ellipse , Rounded Rectangle ) ,
the Select tool ( similarly to Rectangle ) , and used to detect double - click , for the Polygon tool . """
2023-04-24 08:58:27 +03:00
mouse_previous : Offset = Offset ( 0 , 0 )
2023-04-26 10:19:23 +03:00
""" Previous mouse position, for brush tools (Pencil, Brush, Eraser, Airbrush) """
2023-04-26 01:25:25 +03:00
selection_drag_offset : Offset | None = None
2023-04-26 10:19:23 +03:00
""" For Select tool, indicates that the selection is being moved
and defines the offset of the selection from the mouse """
2023-04-24 08:58:27 +03:00
selecting_text : bool = False
2023-04-26 10:19:23 +03:00
""" Used for Text tool """
2023-04-22 05:51:00 +03:00
tool_points : List [ Offset ] = [ ]
2023-04-26 10:19:23 +03:00
""" Used for Curve, Polygon, or Free-Form Select tools """
2023-04-24 08:58:27 +03:00
polygon_last_click_time : float = 0
2023-04-26 10:19:23 +03:00
""" Used for Polygon tool to detect double-click """
2023-04-25 20:17:44 +03:00
color_eraser_mode : bool = False
2023-04-26 10:19:23 +03:00
""" Used for Eraser/Color Eraser tool, when using the right mouse button """
2023-04-23 01:54:21 +03:00
background_tasks : set [ asyncio . Task [ None ] ] = set ( )
2023-04-26 10:19:23 +03:00
""" Stores references to Task objects so they don ' t get garbage collected. """
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
2023-04-29 01:53:33 +03:00
def watch_file_path ( self , file_path : Optional [ str ] ) - > None :
""" Called when file_path changes. """
if file_path 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-29 01:53:33 +03:00
self . sub_title = os . path . basename ( file_path )
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-24 05:22:28 +03:00
def watch_show_status_bar ( self , show_status_bar : bool ) - > None :
""" Called when show_status_bar changes. """
self . query_one ( " #status_bar " ) . display = show_status_bar
2023-04-11 02:27:11 +03:00
def watch_selected_tool ( self , old_selected_tool : Tool , selected_tool : Tool ) - > None :
""" Called when selected_tool changes. """
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-26 06:59:32 +03:00
# CharInput now handles the background style itself PARTIALLY; it doesn't affect the whole area.
2023-04-11 06:56:47 +03:00
2023-04-25 06:14:46 +03:00
if self . image . selection and self . image . selection . textbox_mode :
assert self . image . selection . contained_image is not None , " textbox_mode without contained_image "
for y in range ( self . image . selection . region . height ) :
for x in range ( self . image . selection . region . width ) :
self . image . selection . contained_image . bg [ y ] [ x ] = self . selected_bg_color
self . canvas . refresh_scaled_region ( self . image . selection . region )
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-26 06:59:32 +03:00
# self.query_one("#selected_color_char_input", CharInput).styles.color = selected_fg_color
# CharInput now handles this itself, because styles.color never worked to color the Input's text.
# Well, it still needs to be updated.
self . query_one ( " #selected_color_char_input " , CharInput ) . refresh ( )
2023-04-21 18:25:22 +03:00
2023-04-25 06:14:46 +03:00
if self . image . selection and self . image . selection . textbox_mode :
assert self . image . selection . contained_image is not None , " textbox_mode without contained_image "
for y in range ( self . image . selection . region . height ) :
for x in range ( self . image . selection . region . width ) :
self . image . selection . contained_image . fg [ y ] [ x ] = self . selected_fg_color
self . canvas . refresh_scaled_region ( self . image . selection . region )
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-26 10:01:00 +03:00
""" Draws the current brush at the given coordinates, with special handling for different tools. """
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-26 10:01:00 +03:00
""" Modifies the cell at the given coordinates, with special handling for different tools. """
2023-04-24 19:16:12 +03:00
if x > = self . image . width or y > = self . image . height or x < 0 or y < 0 :
return
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-25 20:17:44 +03:00
if self . color_eraser_mode :
char = self . image . ch [ y ] [ x ]
# fg_color = self.selected_bg_color if self.image.fg[y][x] == self.selected_fg_color else self.image.fg[y][x]
# bg_color = self.selected_bg_color if self.image.bg[y][x] == self.selected_fg_color else self.image.bg[y][x]
2023-04-25 20:51:31 +03:00
2023-04-25 20:17:44 +03:00
# Use color comparison instead of string comparison because "#000000" != "rgb(0,0,0)"
# This stuff might be simpler and more efficient if we used Color objects in the document model
2023-04-28 21:25:52 +03:00
style = Style ( color = self . image . fg [ y ] [ x ] , bgcolor = self . image . bg [ y ] [ x ] )
selected_fg_style = Style ( color = self . selected_fg_color )
2023-04-25 20:17:44 +03:00
assert style . color is not None
assert style . bgcolor is not None
assert selected_fg_style . color is not None
2023-04-25 20:51:31 +03:00
# fg_matches = style.color.triplet == selected_fg_style.color.triplet
# bg_matches = style.bgcolor.triplet == selected_fg_style.color.triplet
threshold = 5
assert style . color . triplet is not None
assert style . bgcolor . triplet is not None
assert selected_fg_style . color . triplet is not None
fg_matches = abs ( style . color . triplet [ 0 ] - selected_fg_style . color . triplet [ 0 ] ) < threshold and abs ( style . color . triplet [ 1 ] - selected_fg_style . color . triplet [ 1 ] ) < threshold and abs ( style . color . triplet [ 2 ] - selected_fg_style . color . triplet [ 2 ] ) < threshold
bg_matches = abs ( style . bgcolor . triplet [ 0 ] - selected_fg_style . color . triplet [ 0 ] ) < threshold and abs ( style . bgcolor . triplet [ 1 ] - selected_fg_style . color . triplet [ 1 ] ) < threshold and abs ( style . bgcolor . triplet [ 2 ] - selected_fg_style . color . triplet [ 2 ] ) < threshold
2023-04-25 20:17:44 +03:00
fg_color = self . selected_bg_color if fg_matches else self . image . fg [ y ] [ x ]
bg_color = self . selected_bg_color if bg_matches else self . image . bg [ y ] [ x ]
2023-04-14 01:48:24 +03:00
if self . selected_tool == Tool . airbrush :
if random ( ) < 0.7 :
return
2023-04-24 19:48:49 +03:00
if self . selected_tool == Tool . free_form_select :
# Invert the underlying colors
2023-04-28 21:25:52 +03:00
style = Style ( color = self . image . fg [ y ] [ x ] , bgcolor = self . image . bg [ y ] [ x ] )
2023-04-24 19:48:49 +03:00
assert style . color is not None
assert style . bgcolor is not None
# Why do I need these extra asserts here and not in the other place I do color inversion,
# using pyright, even though hovering over the other place shows that it also considers
# triplet to be ColorTriplet|None?
assert style . color . triplet is not None
assert style . bgcolor . triplet is not None
# self.image.bg[y][x] = f"rgb({255 - style.bgcolor.triplet.red},{255 - style.bgcolor.triplet.green},{255 - style.bgcolor.triplet.blue})"
# self.image.fg[y][x] = f"rgb({255 - style.color.triplet.red},{255 - style.color.triplet.green},{255 - style.color.triplet.blue})"
# Use hex instead, for less memory usage, theoretically
self . image . bg [ y ] [ x ] = f " # { ( 255 - style . bgcolor . triplet . red ) : 02x } { ( 255 - style . bgcolor . triplet . green ) : 02x } { ( 255 - style . bgcolor . triplet . blue ) : 02x } "
self . image . fg [ y ] [ x ] = f " # { ( 255 - style . color . triplet . red ) : 02x } { ( 255 - style . color . triplet . green ) : 02x } { ( 255 - style . color . triplet . blue ) : 02x } "
else :
self . image . ch [ y ] [ x ] = char
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-26 10:01:00 +03:00
""" Clears the given region. """
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-24 19:48:49 +03:00
def draw_current_free_form_select_polyline ( self ) - > Region :
2023-04-26 10:01:00 +03:00
""" Inverts the colors along a polyline defined by tool_points, for Free-Form Select tool preview. """
2023-04-24 19:48:49 +03:00
# TODO: DRY with draw_current_curve/draw_current_polygon/draw_current_polyline
# Also (although this may be counter to DRYING (Deduplicating Repetitive Yet Individually Nimble Generators)),
# could optimize to not use stamp_brush, since it's always a single character here.
gen = polyline_walk ( self . tool_points )
affected_region = Region ( )
already_inverted : set [ tuple [ int , int ] ] = set ( )
for x , y in gen :
if ( x , y ) not in already_inverted :
affected_region = affected_region . union ( self . stamp_brush ( x , y , affected_region ) )
already_inverted . add ( ( x , y ) )
return affected_region
2023-04-22 08:04:19 +03:00
def draw_current_polyline ( self ) - > Region :
2023-04-26 10:01:00 +03:00
""" Draws a polyline from tool_points, for Polygon tool preview. """
2023-04-22 08:04:19 +03:00
# 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 :
2023-04-26 10:01:00 +03:00
""" Draws a polygon from tool_points, for Polygon tool. """
2023-04-22 08:04:19 +03:00
# 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 :
2023-04-26 10:01:00 +03:00
""" Draws a curve (or line) from tool_points, for Curve tool. """
2023-04-22 05:51:00 +03:00
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-25 22:26:33 +03:00
def finalize_polygon_or_curve ( self ) - > None :
""" Finalizes the polygon or curve shape, creating an undo state. """
# TODO: DRY with other undo state creation
2023-04-25 22:33:53 +03:00
self . cancel_preview ( )
if self . selected_tool not in [ Tool . polygon , Tool . curve ] :
return
if self . selected_tool == Tool . polygon and len ( self . tool_points ) < 3 :
return
if self . selected_tool == Tool . curve and len ( self . tool_points ) < 2 :
return
2023-04-25 22:26:33 +03:00
self . image_at_start = AnsiArtDocument ( self . image . width , self . image . height )
self . image_at_start . copy_region ( self . image )
2023-04-29 04:11:46 +03:00
action = Action ( self . selected_tool . get_name ( ) )
2023-04-25 22:26:33 +03:00
if len ( self . redos ) > 0 :
self . redos = [ ]
self . undos . append ( action )
if self . selected_tool == Tool . polygon :
affected_region = self . draw_current_polygon ( )
else :
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-26 07:17:18 +03:00
def action_cancel ( self ) - > None :
2023-04-26 10:01:00 +03:00
""" Action to end the current tool activity, via Escape key. """
2023-04-26 07:17:18 +03:00
self . stop_action_in_progress ( )
2023-04-24 22:46:17 +03:00
def stop_action_in_progress ( self ) - > None :
""" Finalizes the selection, or cancels other tools. """
self . cancel_preview ( )
2023-04-21 23:34:35 +03:00
self . meld_selection ( )
2023-04-24 22:46:17 +03:00
self . tool_points = [ ]
self . mouse_gesture_cancelled = True
self . get_widget_by_id ( " status_coords " , Static ) . update ( " " )
self . get_widget_by_id ( " status_dimensions " , Static ) . update ( " " )
if self . selected_tool in [ Tool . pick_color , Tool . magnifier ] :
self . selected_tool = self . return_to_tool
def action_undo ( self ) - > None :
2023-04-26 10:01:00 +03:00
""" Undoes the last action. """
2023-04-24 22:46:17 +03:00
self . stop_action_in_progress ( )
2023-04-11 20:21:19 +03:00
if len ( self . undos ) > 0 :
action = self . undos . pop ( )
2023-04-29 04:11:46 +03:00
redo_action = Action ( _ ( " Undo " ) + " " + action . name , action . region )
redo_action . update ( self . image )
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-26 10:01:00 +03:00
""" Redoes the last undone action. """
2023-04-24 22:46:17 +03:00
self . stop_action_in_progress ( )
2023-04-11 20:24:32 +03:00
if len ( self . redos ) > 0 :
action = self . redos . pop ( )
2023-04-29 04:11:46 +03:00
undo_action = Action ( _ ( " Undo " ) + " " + action . name , action . region )
undo_action . update ( self . image )
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-28 21:06:14 +03:00
self . stop_action_in_progress ( )
2023-04-19 01:54:41 +03:00
dialog_title = _ ( " Save As " ) if from_save_as else _ ( " Save " )
2023-04-29 01:53:33 +03:00
if self . file_path :
2023-04-18 22:00:46 +03:00
try :
2023-04-29 02:09:59 +03:00
file_type = os . path . splitext ( self . file_path ) [ 1 ] [ 1 : ] . upper ( )
if file_type == " SVG " :
content = self . image . get_svg ( )
elif file_type == " HTML " or file_type == " HTM " :
content = self . image . get_html ( )
elif file_type == " TXT " :
content = self . image . get_text ( )
else :
content = self . image . get_ansi ( )
2023-04-29 01:53:33 +03:00
with open ( self . file_path , " w " ) as f :
2023-04-29 02:09:59 +03:00
f . write ( content )
2023-04-18 22:00:46 +03:00
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 :
2023-04-29 01:53:33 +03:00
self . warning_message_box ( dialog_title , _ ( " % 1 contains an invalid path. " , self . file_path ) , " ok " )
2023-04-18 22:00:46 +03:00
except OSError as e :
2023-04-26 02:03:50 +03:00
self . warning_message_box ( dialog_title , _ ( " Failed to save document. " ) + " \n \n " + repr ( e ) , " ok " )
2023-04-18 22:00:46 +03:00
except Exception as e :
2023-04-29 01:53:33 +03:00
self . warning_message_box ( dialog_title , _ ( " An unexpected error occurred while writing % 1. " , self . file_path ) + " \n \n " + repr ( 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-28 21:06:14 +03:00
# stop_action_in_progress() will also be called once the dialog is closed, in save()
# which is more important than here, since the dialog isn't (currently) modal.
# You could make a selection while the dialog is open, for example.
self . stop_action_in_progress ( )
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
2023-04-29 01:53:33 +03:00
# TODO: allow entering an absolute or relative path, not just a filename
2023-04-21 05:41:45 +03:00
if self . directory_tree_selected_path :
2023-04-29 01:53:33 +03:00
file_path = os . path . join ( self . directory_tree_selected_path , name )
else :
file_path = name
2023-04-21 05:41:45 +03:00
def on_save_confirmed ( ) :
async def async_on_save_confirmed ( ) :
2023-04-29 01:53:33 +03:00
self . file_path = file_path
2023-04-21 05:41:45 +03:00
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 )
2023-04-29 01:53:33 +03:00
if os . path . exists ( file_path ) :
self . confirm_overwrite ( file_path , on_save_confirmed )
2023-04-21 05:41:45 +03:00
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-29 01:53:33 +03:00
filename : str = os . path . basename ( self . file_path ) if self . file_path 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-29 01:53:33 +03:00
target_dir = ( self . file_path 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-29 01:53:33 +03:00
def confirm_overwrite ( self , file_path : str , callback : Callable [ [ ] , None ] ) - > None :
2023-04-26 10:01:00 +03:00
""" Asks the user if they want to overwrite a file. """
2023-04-29 01:53:33 +03:00
message = _ ( " % 1 already exists. \n Do you want to replace it? " , file_path )
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-29 00:23:56 +03:00
def confirm_no_undo ( self , callback : Callable [ [ ] , None ] ) - > None :
""" Asks the user to confirm that they want to continue with a permanent action. """
# We have translations for "Do you want to continue?" via MS Paint,
# but not for the rest of the message.
message = _ ( " This cannot be undone. " ) + " \n \n " + _ ( " Do you want to continue? " )
def handle_button ( button : Button ) - > None :
if not button . has_class ( " yes " ) :
return
callback ( )
self . warning_message_box ( _ ( " Paint " ) , Static ( message , markup = False ) , " yes/no " , handle_button )
2023-04-29 01:53:33 +03:00
def prompt_save_changes ( self , file_path : str , callback : Callable [ [ ] , None ] ) - > None :
2023-04-26 10:01:00 +03:00
""" Asks the user if they want to save changes to a file. """
2023-04-29 01:53:33 +03:00
filename = os . path . basename ( file_path )
2023-04-25 04:13:26 +03:00
message = _ ( " Save changes to % 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 " ) 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 :
2023-04-26 10:01:00 +03:00
""" Returns whether the document has been modified since the last save. """
2023-04-16 07:59:23 +03:00
return len ( self . undos ) != self . saved_undo_count
def action_exit ( self ) - > None :
2023-04-26 10:01:00 +03:00
""" Exit the program, prompting to save changes if necessary. """
2023-04-16 07:59:23 +03:00
if self . is_document_modified ( ) :
2023-04-29 01:53:33 +03:00
self . prompt_save_changes ( self . file_path 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 :
2023-04-26 10:01:00 +03:00
""" Reload the program, prompting to save changes if necessary. """
2023-04-19 09:08:01 +03:00
if self . is_document_modified ( ) :
2023-04-29 01:53:33 +03:00
self . prompt_save_changes ( self . file_path or _ ( " Untitled " ) , restart_program )
2023-04-19 09:08:01 +03:00
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-29 01:53:33 +03:00
# TODO: allow entering an absolute or relative path, not just a filename
2023-04-16 04:19:07 +03:00
if self . directory_tree_selected_path :
2023-04-29 01:53:33 +03:00
file_path = os . path . join ( self . directory_tree_selected_path , filename )
else :
file_path = filename
2023-04-21 05:35:38 +03:00
try :
2023-04-29 01:47:50 +03:00
# Note that os.path.samefile can raise FileNotFoundError
2023-04-29 01:53:33 +03:00
if self . file_path and os . path . samefile ( file_path , self . file_path ) :
2023-04-21 05:35:38 +03:00
window . close ( )
return
2023-04-29 01:53:33 +03:00
with open ( file_path , " r " ) as f :
2023-04-21 05:35:38 +03:00
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.
2023-04-26 02:03:50 +03:00
self . warning_message_box ( _ ( " Open " ) , Static ( _ ( " Paint cannot open this file. " ) + " \n \n " + repr ( e ) ) , " ok " )
2023-04-21 05:35:38 +03:00
return
self . action_new ( force = True )
self . canvas . image = self . image = new_image
self . canvas . refresh ( layout = True )
2023-04-29 01:53:33 +03:00
self . file_path = file_path
2023-04-20 19:08:03 +03:00
window . close ( )
2023-04-21 05:35:38 +03:00
if self . is_document_modified ( ) :
2023-04-29 01:53:33 +03:00
self . prompt_save_changes ( self . file_path or _ ( " Untitled " ) , go_ahead )
2023-04-21 05:35:38 +03:00
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 :
2023-04-29 01:53:33 +03:00
self . warning_message_box ( _ ( " Open " ) , Static ( _ ( " An unexpected error occurred while reading % 1. " , file_path ) + " \n \n " + repr ( 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-29 01:53:33 +03:00
self . prompt_save_changes ( self . file_path 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-29 01:53:33 +03:00
self . file_path = 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-25 05:46:13 +03:00
def action_edit_colors ( self , color_palette_index : int | None = None , as_foreground : bool = False ) - > None :
2023-04-23 21:29:42 +03:00
""" Show dialog to edit colors. """
self . close_windows ( " #edit_colors_dialog " )
def handle_selected_color ( color : str ) - > None :
2023-04-25 05:46:13 +03:00
if as_foreground :
self . selected_fg_color = color
else :
self . selected_bg_color = color
2023-04-23 21:58:17 +03:00
if color_palette_index is not None :
palette [ color_palette_index ] = color
# TODO: Update the palette in a reactive way.
# I'll need to move the palette state to the app.
self . query_one ( ColorsBox ) . update_palette ( )
2023-04-23 21:29:42 +03:00
window . close ( )
window = EditColorsDialogWindow (
id = " edit_colors_dialog " ,
handle_selected_color = handle_selected_color ,
selected_color = self . selected_bg_color ,
title = _ ( " Edit Colors " ) ,
)
self . mount ( window )
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 " )
2023-04-27 05:30:55 +03:00
2023-04-19 01:01:37 +03:00
def action_set_as_wallpaper_tiled ( self ) - > None :
2023-04-29 04:11:14 +03:00
""" Set the image as the wallpaper. """
2023-04-27 05:30:55 +03:00
# TODO: Differentiate between tiled and centered.
self . action_set_as_wallpaper_centered ( )
2023-04-19 01:01:37 +03:00
def action_set_as_wallpaper_centered ( self ) - > None :
2023-04-29 04:11:14 +03:00
""" Set the image as the wallpaper. """
2023-04-27 05:30:55 +03:00
try :
dir = os . path . join ( get_config_dir ( " textual-paint " ) , " wallpaper " )
os . makedirs ( dir , exist_ok = True )
2023-04-29 03:14:05 +03:00
svg = self . image . get_svg ( )
image_path = os . path . join ( dir , " wallpaper.svg " )
with open ( image_path , " w " ) as f :
f . write ( svg )
2023-04-27 05:30:55 +03:00
set_wallpaper ( image_path )
except Exception as e :
self . warning_message_box ( _ ( " Paint " ) , Static ( _ ( " Failed to set the wallpaper. " ) + " \n \n " + repr ( e ) ) , " ok " )
2023-04-19 01:01:37 +03:00
def action_recent_file ( self ) - > None :
self . warning_message_box ( _ ( " Paint " ) , " Not implemented. " , " ok " )
2023-04-26 19:26:12 +03:00
2023-04-19 01:01:37 +03:00
def action_cut ( self ) - > None :
2023-04-26 19:26:12 +03:00
""" Cut the selection to the clipboard. """
2023-04-26 19:29:32 +03:00
if self . action_copy ( ) :
self . action_clear_selection ( )
2023-04-26 19:26:12 +03:00
2023-04-26 19:29:32 +03:00
def action_copy ( self ) - > bool :
2023-04-26 19:26:12 +03:00
""" Copy the selection to the clipboard. """
sel = self . image . selection
if sel is None :
2023-04-26 19:29:32 +03:00
return False
2023-04-26 19:26:12 +03:00
had_contained_image = sel . contained_image is not None
try :
if sel . contained_image is None :
# Copy underlying image.
# Don't want to make an undo state, unlike when cutting out a selection when you drag it.
sel . copy_from_document ( self . image )
2023-04-26 20:19:06 +03:00
assert sel . contained_image is not None
2023-04-27 04:23:16 +03:00
if sel . textbox_mode :
text = selected_text ( sel )
else :
text = sel . contained_image . get_ansi ( )
2023-04-26 19:26:12 +03:00
import pyperclip
2023-04-27 04:23:16 +03:00
pyperclip . copy ( text )
2023-04-26 19:26:12 +03:00
except Exception as e :
self . warning_message_box ( _ ( " Paint " ) , _ ( " Failed to copy to the clipboard. " ) + " \n \n " + repr ( e ) , " ok " )
2023-04-26 19:29:32 +03:00
return False
2023-04-26 19:26:12 +03:00
finally :
if not had_contained_image :
sel . contained_image = None
2023-04-26 19:29:32 +03:00
return True
2023-04-26 19:26:12 +03:00
2023-04-19 01:01:37 +03:00
def action_paste ( self ) - > None :
2023-04-26 19:26:12 +03:00
""" Paste the clipboard as a selection. """
import pyperclip
2023-04-27 04:27:33 +03:00
text : str = pyperclip . paste ( )
2023-04-26 19:26:12 +03:00
if not text :
return
if self . image . selection and self . image . selection . textbox_mode :
2023-04-27 03:35:13 +03:00
# paste into textbox
2023-04-27 04:02:32 +03:00
pasted_image = AnsiArtDocument . from_text ( text , default_bg = self . selected_bg_color , default_fg = self . selected_fg_color )
2023-04-27 03:35:13 +03:00
textbox = self . image . selection
assert textbox . contained_image is not None
paste_region = Region ( * textbox . text_selection_start , pasted_image . width , pasted_image . height )
if paste_region . right > textbox . region . width or paste_region . bottom > textbox . region . height :
self . warning_message_box ( _ ( " Paint " ) , _ ( " Not enough room to paste text. " ) + " \n \n " + _ ( " Enlarge the text area and try again. " ) , " ok " )
return
textbox . contained_image . copy_region ( source = pasted_image , target_region = paste_region )
2023-04-27 04:23:24 +03:00
self . canvas . refresh_scaled_region ( textbox . region )
2023-04-26 19:26:12 +03:00
return
2023-04-27 04:02:32 +03:00
pasted_image = AnsiArtDocument . from_text ( text )
2023-04-26 19:26:12 +03:00
self . stop_action_in_progress ( )
2023-04-26 20:13:42 +03:00
# paste at top left corner of viewport
x : int = max ( 0 , min ( self . image . width - 1 , int ( self . editing_area . scroll_x / / self . magnification ) ) )
y : int = max ( 0 , min ( self . image . height - 1 , int ( self . editing_area . scroll_y / / self . magnification ) ) )
self . image . selection = Selection ( Region ( x , y , pasted_image . width , pasted_image . height ) )
2023-04-26 19:26:12 +03:00
self . image . selection . contained_image = pasted_image
2023-04-26 19:47:08 +03:00
self . image . selection . pasted = True # create undo state when finalizing selection
2023-04-26 19:26:12 +03:00
self . canvas . refresh_scaled_region ( self . image . selection . region )
self . selected_tool = Tool . select
2023-04-19 01:01:37 +03:00
def action_select_all ( self ) - > None :
2023-04-27 00:04:20 +03:00
""" Select the entire image, or in a textbox, all the text. """
if self . image . selection and self . image . selection . textbox_mode :
assert self . image . selection . contained_image is not None
self . image . selection . text_selection_start = Offset ( 0 , 0 )
2023-04-27 00:08:32 +03:00
self . image . selection . text_selection_end = Offset ( self . image . selection . contained_image . width - 1 , self . image . selection . contained_image . height - 1 )
2023-04-27 04:29:31 +03:00
self . canvas . refresh_scaled_region ( self . image . selection . region )
2023-04-27 00:04:20 +03:00
else :
self . stop_action_in_progress ( )
self . image . selection = Selection ( Region ( 0 , 0 , self . image . width , self . image . height ) )
self . canvas . refresh ( )
self . selected_tool = Tool . select
2023-04-19 01:01:37 +03:00
def action_copy_to ( self ) - > None :
self . warning_message_box ( _ ( " Paint " ) , " Not implemented. " , " ok " )
def action_paste_from ( self ) - > None :
self . warning_message_box ( _ ( " Paint " ) , " Not implemented. " , " ok " )
def action_text_toolbar ( self ) - > None :
self . warning_message_box ( _ ( " Paint " ) , " Not implemented. " , " ok " )
def action_normal_size ( self ) - > None :
2023-04-26 10:01:00 +03:00
""" Zoom to 1x. """
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-26 10:01:00 +03:00
""" Zoom to 4x. """
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-26 10:01:00 +03:00
""" Show dialog to set zoom level. """
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)),
# ),
# ),
2023-04-25 03:33:12 +03:00
Container (
Button ( _ ( " OK " ) , classes = " ok submit " , variant = " primary " ) ,
Button ( _ ( " Cancel " ) , classes = " cancel " ) ,
classes = " buttons " ,
)
2023-04-22 02:30:37 +03:00
)
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 " )
2023-04-28 22:07:58 +03:00
2023-04-19 01:01:37 +03:00
def action_attributes ( self ) - > None :
2023-04-28 22:07:58 +03:00
""" Show dialog to set the image attributes. """
self . close_windows ( " #attributes_dialog " )
def handle_button ( button : Button ) - > None :
if button . has_class ( " ok " ) :
try :
width = int ( window . content . query_one ( " #width_input " , Input ) . value )
height = int ( window . content . query_one ( " #height_input " , Input ) . value )
if width < 1 or height < 1 :
raise ValueError
2023-04-28 22:27:23 +03:00
# TODO: make this undoable
2023-04-29 00:23:56 +03:00
def do_the_resize ( ) :
self . image . resize ( width , height , default_bg = self . selected_bg_color , default_fg = self . selected_fg_color )
self . canvas . refresh ( layout = True )
2023-04-29 00:24:57 +03:00
self . undos = [ ]
self . redos = [ ]
2023-04-29 00:23:56 +03:00
window . close ( )
self . confirm_no_undo ( do_the_resize )
2023-04-28 22:07:58 +03:00
except ValueError :
self . warning_message_box ( _ ( " Attributes " ) , _ ( " Please enter a positive integer. " ) , " ok " )
else :
window . close ( )
window = DialogWindow (
id = " attributes_dialog " ,
title = _ ( " Attributes " ) ,
handle_button = handle_button ,
)
window . content . mount (
Vertical (
2023-04-28 22:27:15 +03:00
# Horizontal(
2023-04-28 22:07:58 +03:00
Static ( _ ( " Width: " ) ) ,
Input ( id = " width_input " , value = str ( self . image . width ) ) ,
2023-04-28 22:27:15 +03:00
# ),
# Horizontal(
2023-04-28 22:07:58 +03:00
Static ( _ ( " Height: " ) ) ,
Input ( id = " height_input " , value = str ( self . image . height ) ) ,
2023-04-28 22:27:15 +03:00
# ),
2023-04-28 22:07:58 +03:00
) ,
Container (
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_clear_image ( self ) - > None :
self . warning_message_box ( _ ( " Paint " ) , " Not implemented. " , " ok " )
def action_draw_opaque ( self ) - > None :
self . warning_message_box ( _ ( " Paint " ) , " Not implemented. " , " ok " )
2023-04-20 23:16:10 +03:00
2023-04-19 01:01:37 +03:00
def action_help_topics ( self ) - > None :
2023-04-26 10:01:00 +03:00
""" Show the Help Topics dialog. """
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-24 21:56:21 +03:00
# I'm not supporting hotkeys for the top level menus, because I can't detect Alt.
2023-04-13 03:49:16 +03:00
yield MenuBar ( [
2023-04-24 21:56:21 +03:00
MenuItem ( remove_hotkey ( _ ( " &File " ) ) , submenu = Menu ( [
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " &New \t Ctrl+N " ) , self . action_new , 57600 , description = _ ( " Creates a new document. " ) ) ,
MenuItem ( _ ( " &Open... \t Ctrl+O " ) , self . action_open , 57601 , description = _ ( " Opens an existing document. " ) ) ,
MenuItem ( _ ( " &Save \t Ctrl+S " ) , self . action_save , 57603 , description = _ ( " Saves the active document. " ) ) ,
MenuItem ( _ ( " Save &As... " ) , self . action_save_as , 57604 , description = _ ( " Saves the active document with a new name. " ) ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " Print Pre&view " ) , self . action_print_preview , 57609 , grayed = True , description = _ ( " Displays full pages. " ) ) ,
MenuItem ( _ ( " Page Se&tup... " ) , self . action_page_setup , 57605 , grayed = True , description = _ ( " Changes the page layout. " ) ) ,
MenuItem ( _ ( " &Print... \t Ctrl+P " ) , self . action_print , 57607 , grayed = True , description = _ ( " Prints the active document and sets printing options. " ) ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " S&end... " ) , self . action_send , 37662 , grayed = True , description = _ ( " Sends a picture by using mail or fax. " ) ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-27 05:30:55 +03:00
MenuItem ( _ ( " Set As &Wallpaper (Tiled) " ) , self . action_set_as_wallpaper_tiled , 57677 , description = _ ( " Tiles this bitmap as the desktop wallpaper. " ) ) ,
MenuItem ( _ ( " Set As Wa&llpaper (Centered) " ) , self . action_set_as_wallpaper_centered , 57675 , description = _ ( " Centers this bitmap as the desktop wallpaper. " ) ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " Recent File " ) , self . action_recent_file , 57616 , grayed = True , description = _ ( " Opens this document. " ) ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-24 07:43:50 +03:00
# MenuItem(_("E&xit\tAlt+F4"), self.action_exit, 57665, description=_("Quits Paint.")),
MenuItem ( _ ( " E&xit \t Ctrl+Q " ) , self . action_exit , 57665 , description = _ ( " Quits Paint. " ) ) ,
2023-04-13 03:49:16 +03:00
] ) ) ,
2023-04-24 21:56:21 +03:00
MenuItem ( remove_hotkey ( _ ( " &Edit " ) ) , submenu = Menu ( [
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " &Undo \t Ctrl+Z " ) , self . action_undo , 57643 , description = _ ( " Undoes the last action. " ) ) ,
MenuItem ( _ ( " &Repeat \t F4 " ) , self . action_redo , 57644 , description = _ ( " Redoes the previously undone action. " ) ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-26 19:26:12 +03:00
MenuItem ( _ ( " Cu&t \t Ctrl+X " ) , self . action_cut , 57635 , description = _ ( " Cuts the selection and puts it on the Clipboard. " ) ) ,
MenuItem ( _ ( " &Copy \t Ctrl+C " ) , self . action_copy , 57634 , description = _ ( " Copies the selection and puts it on the Clipboard. " ) ) ,
MenuItem ( _ ( " &Paste \t Ctrl+V " ) , self . action_paste , 57637 , description = _ ( " Inserts the contents of the Clipboard. " ) ) ,
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " C&lear Selection \t Del " ) , self . action_clear_selection , 57632 , description = _ ( " Deletes the selection. " ) ) ,
MenuItem ( _ ( " Select &All \t Ctrl+A " ) , self . action_select_all , 57642 , description = _ ( " Selects everything. " ) ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " C&opy To... " ) , self . action_copy_to , 37663 , grayed = True , description = _ ( " Copies the selection to a file. " ) ) ,
MenuItem ( _ ( " Paste &From... " ) , self . action_paste_from , 37664 , grayed = True , description = _ ( " Pastes a file into the selection. " ) ) ,
2023-04-13 03:49:16 +03:00
] ) ) ,
2023-04-24 21:56:21 +03:00
MenuItem ( remove_hotkey ( _ ( " &View " ) ) , submenu = Menu ( [
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " &Tool Box \t Ctrl+T " ) , self . action_toggle_tools_box , 59415 , description = _ ( " Shows or hides the tool box. " ) ) ,
MenuItem ( _ ( " &Color Box \t Ctrl+L " ) , self . action_toggle_colors_box , 59416 , description = _ ( " Shows or hides the color box. " ) ) ,
MenuItem ( _ ( " &Status Bar " ) , self . action_toggle_status_bar , 59393 , description = _ ( " Shows or hides the status bar. " ) ) ,
MenuItem ( _ ( " T&ext Toolbar " ) , self . action_text_toolbar , 37678 , grayed = True , description = _ ( " Shows or hides the text toolbar. " ) ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-19 03:43:51 +03:00
MenuItem ( _ ( " &Zoom " ) , submenu = Menu ( [
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " &Normal Size \t Ctrl+PgUp " ) , self . action_normal_size , 37670 , description = _ ( " Zooms the picture to 100 % . " ) ) ,
MenuItem ( _ ( " &Large Size \t Ctrl+PgDn " ) , self . action_large_size , 37671 , description = _ ( " Zooms the picture to 400 % . " ) ) ,
MenuItem ( _ ( " C&ustom... " ) , self . action_custom_zoom , 37672 , description = _ ( " Zooms the picture. " ) ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " Show &Grid \t Ctrl+G " ) , self . action_show_grid , 37677 , grayed = True , description = _ ( " Shows or hides the grid. " ) ) ,
MenuItem ( _ ( " Show T&humbnail " ) , self . action_show_thumbnail , 37676 , grayed = True , description = _ ( " Shows or hides the thumbnail view of the picture. " ) ) ,
2023-04-19 01:01:37 +03:00
] ) ) ,
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " &View Bitmap \t Ctrl+F " ) , self . action_view_bitmap , 37673 , grayed = True , description = _ ( " Displays the entire picture. " ) ) ,
2023-04-19 01:01:37 +03:00
] ) ) ,
2023-04-24 21:56:21 +03:00
MenuItem ( remove_hotkey ( _ ( " &Image " ) ) , submenu = Menu ( [
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " &Flip/Rotate... \t Ctrl+R " ) , self . action_flip_rotate , 37680 , grayed = True , description = _ ( " Flips or rotates the picture or a selection. " ) ) ,
MenuItem ( _ ( " &Stretch/Skew... \t Ctrl+W " ) , self . action_stretch_skew , 37681 , grayed = True , description = _ ( " Stretches or skews the picture or a selection. " ) ) ,
MenuItem ( _ ( " &Invert Colors \t Ctrl+I " ) , self . action_invert_colors , 37682 , grayed = True , description = _ ( " Inverts the colors of the picture or a selection. " ) ) ,
2023-04-28 22:07:58 +03:00
MenuItem ( _ ( " &Attributes... \t Ctrl+E " ) , self . action_attributes , 37683 , description = _ ( " Changes the attributes of the picture. " ) ) ,
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " &Clear Image \t Ctrl+Shft+N " ) , self . action_clear_image , 37684 , grayed = True , description = _ ( " Clears the picture or selection. " ) ) ,
MenuItem ( _ ( " &Draw Opaque " ) , self . action_draw_opaque , 6868 , grayed = True , description = _ ( " Makes the current selection either opaque or transparent. " ) ) ,
2023-04-19 01:01:37 +03:00
] ) ) ,
2023-04-24 21:56:21 +03:00
MenuItem ( remove_hotkey ( _ ( " &Colors " ) ) , submenu = Menu ( [
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " &Edit Colors... " ) , self . action_edit_colors , 6869 , description = _ ( " Creates a new color. " ) ) ,
2023-04-19 01:01:37 +03:00
] ) ) ,
2023-04-24 21:56:21 +03:00
MenuItem ( remove_hotkey ( _ ( " &Help " ) ) , submenu = Menu ( [
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " &Help Topics " ) , self . action_help_topics , 57670 , description = _ ( " Displays Help for the current task or command. " ) ) ,
2023-04-19 01:01:37 +03:00
Separator ( ) ,
2023-04-24 07:43:50 +03:00
MenuItem ( _ ( " &About Paint " ) , self . action_about_paint , 57664 , description = _ ( " Displays program information, version number, and copyright. " ) ) ,
2023-04-13 03:49:16 +03:00
] ) ) ,
] )
2023-04-11 06:25:32 +03:00
yield Container (
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-24 05:22:28 +03:00
yield Container (
2023-04-24 07:03:05 +03:00
Static ( _ ( " For Help, click Help Topics on the Help Menu. " ) , id = " status_text " ) ,
2023-04-24 05:22:28 +03:00
Static ( id = " status_coords " ) ,
Static ( id = " status_dimensions " ) ,
id = " status_bar " ,
)
2023-04-10 23:51:53 +03:00
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-23 20:50:41 +03:00
if x < 0 or y < 0 or x > = self . image . width or y > = self . image . height :
return
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-24 22:46:17 +03:00
self . mouse_gesture_cancelled = False
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-22 08:12:48 +03:00
self . mouse_at_start = Offset ( event . mouse_down_event . x , event . mouse_down_event . y )
2023-04-24 08:40:20 +03:00
self . mouse_previous = self . mouse_at_start
2023-04-25 20:17:44 +03:00
self . color_eraser_mode = self . selected_tool == Tool . eraser and event . mouse_down_event . button == 3
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 :
2023-04-24 06:21:10 +03:00
self . make_preview ( self . draw_current_polyline , show_dimensions_in_status_bar = True ) # polyline until finished
2023-04-22 08:04:19 +03:00
return
2023-04-23 05:49:58 +03:00
if self . selected_tool == Tool . free_form_select :
self . tool_points = [ Offset ( event . mouse_down_event . x , event . mouse_down_event . y ) ]
if self . selected_tool in [ Tool . select , Tool . free_form_select , Tool . text ] :
2023-04-21 22:24:45 +03:00
sel = self . image . selection
if sel and sel . region . contains_point ( self . mouse_at_start ) :
2023-04-22 04:19:05 +03:00
if self . selected_tool == Tool . text :
# Place cursor at mouse position
2023-04-27 00:00:54 +03:00
offset_in_textbox = Offset ( * self . mouse_at_start ) - sel . region . offset
# clamping isn't needed here, unlike while dragging
sel . text_selection_start = offset_in_textbox
sel . text_selection_end = offset_in_textbox
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.
2023-04-23 09:14:28 +03:00
# But if you hold Ctrl, stamp the selection.
if event . mouse_down_event . ctrl :
sel . copy_to_document ( self . image )
2023-04-22 01:25:29 +03:00
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 )
2023-04-29 04:11:46 +03:00
action = Action ( self . selected_tool . get_name ( ) )
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 09:14:28 +03:00
if not event . mouse_down_event . ctrl :
self . erase_region ( sel . region , sel . mask )
2023-04-26 00:07:50 +03:00
# TODO: Optimize the region storage. Right now I'm copying the whole image,
2023-04-26 00:35:44 +03:00
# for the case of selection, because later, when the selection is melded into the canvas,
# it _implicitly updates_ the undo action, by changing the document without creating a new Action.
# This is the intended behavior, in that it allows the user to undo the
# selection and any changes to it as one action. But it's not efficient for large images.
2023-04-26 00:07:50 +03:00
# I could:
# - Update the region when melding to be the union of the two rectangles.
# - Make Action support a list of regions, and add the new region on meld.
# - Make Action support a list of sub-actions (or just one), and make meld a sub-action.
# - Add a new Action on meld, but mark it for skipping when undoing, and skipping ahead to when redoing.
# `affected_region = sel.region` doesn't encompass the new region when melding
2023-04-21 23:34:35 +03:00
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-29 04:11:46 +03:00
action = Action ( self . selected_tool . get_name ( ) )
2023-04-11 21:14:23 +03:00
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-29 04:48:15 +03:00
else :
# Flood fill didn't affect anything.
# Following MS Paint, we still created an undo action.
# We need a region to avoid an error/warning when undoing.
# But we don't need to refresh the canvas.
action . region = Region ( 0 , 0 , 0 , 0 )
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-24 06:21:10 +03:00
def make_preview ( self , draw_proc : Callable [ [ ] , Region ] , show_dimensions_in_status_bar : bool = False ) - > None :
2023-04-26 10:01:00 +03:00
""" Preview the result of a draw operation, using a temporary action. Optionally preview dimensions in status bar. """
2023-04-22 06:33:22 +03:00
self . cancel_preview ( )
image_before = AnsiArtDocument ( self . image . width , self . image . height )
image_before . copy_region ( self . image )
affected_region = draw_proc ( )
if affected_region :
2023-04-29 04:11:46 +03:00
self . preview_action = Action ( self . selected_tool . get_name ( ) )
2023-04-22 06:33:22 +03:00
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-24 06:21:10 +03:00
if show_dimensions_in_status_bar :
self . get_widget_by_id ( " status_dimensions " , Static ) . update (
f " { self . preview_action . region . width } x { self . preview_action . region . height } "
)
2023-04-14 03:28:58 +03:00
def on_canvas_tool_preview_update ( self , event : Canvas . ToolPreviewUpdate ) - > None :
""" Called when the user is hovering over the canvas but not drawing yet. """
event . stop ( )
self . cancel_preview ( )
2023-04-24 05:30:16 +03:00
self . get_widget_by_id ( " status_coords " , Static ) . update ( f " { event . mouse_move_event . x } , { event . mouse_move_event . y } " )
2023-04-22 08:04:19 +03:00
if self . selected_tool in [ Tool . brush , Tool . pencil , Tool . eraser , Tool . curve , Tool . polygon ] :
2023-04-22 06:40:31 +03:00
if self . selected_tool == Tool . curve :
2023-04-22 06:33:22 +03:00
self . make_preview ( self . draw_current_curve )
2023-04-22 08:04:19 +03:00
elif self . selected_tool == Tool . polygon :
2023-04-24 06:21:10 +03:00
self . make_preview ( self . draw_current_polyline , show_dimensions_in_status_bar = True ) # polyline until finished
2023-04-22 05:51:00 +03:00
else :
2023-04-22 06:33:22 +03:00
self . make_preview ( lambda : self . stamp_brush ( event . mouse_move_event . x , event . mouse_move_event . y ) )
2023-04-20 03:51:12 +03:00
elif self . selected_tool == Tool . magnifier :
prospective_magnification = self . get_prospective_magnification ( )
if prospective_magnification < self . magnification :
return # hide if clicking would zoom out
# prospective viewport size in document coords
w = self . editing_area . size . width / / prospective_magnification
h = self . editing_area . size . height / / prospective_magnification
rect_x1 = ( event . mouse_move_event . x - w / / 2 )
rect_y1 = ( event . mouse_move_event . y - h / / 2 )
# try to move rect into bounds without squishing
rect_x1 = max ( 0 , rect_x1 )
rect_y1 = max ( 0 , rect_y1 )
rect_x1 = min ( self . image . width - w , rect_x1 )
rect_y1 = min ( self . image . height - h , rect_y1 )
rect_x2 = rect_x1 + w
rect_y2 = rect_y1 + h
# clamp rect to bounds (with squishing)
rect_x1 = max ( 0 , rect_x1 )
rect_y1 = max ( 0 , rect_y1 )
rect_x2 = min ( self . image . width , rect_x2 )
rect_y2 = min ( self . image . height , rect_y2 )
rect_w = rect_x2 - rect_x1
rect_h = rect_y2 - rect_y1
rect_x = rect_x1
rect_y = rect_y1
self . canvas . magnifier_preview_region = Region ( rect_x , rect_y , rect_w , rect_h )
self . canvas . refresh_scaled_region ( self . canvas . magnifier_preview_region )
2023-04-14 03:28:58 +03:00
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 ( )
2023-04-25 21:46:25 +03:00
# Curve and Polygon persist when the mouse leaves the canvas,
# since they're more stateful in their UI. It's confusing if
# what you started drawing disappears.
# Other tools should hide their preview, since they only preview
# what will happen if you click on the canvas.
if self . selected_tool not in [ Tool . curve , Tool . polygon ] :
self . cancel_preview ( )
2023-04-24 05:30:16 +03:00
self . get_widget_by_id ( " status_coords " , Static ) . update ( " " )
2023-04-16 06:45:44 +03:00
2023-04-21 22:05:41 +03:00
def get_select_region ( self , start : Offset , end : Offset ) - > Region :
2023-04-26 10:01:00 +03:00
""" Returns the minimum region that contains the cells at the start and end offsets. """
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
2023-04-25 22:53:40 +03:00
def meld_or_clear_selection ( self , meld : bool ) - > None :
2023-04-26 10:01:00 +03:00
""" Merges the selection into the image, or deletes it if meld is False. """
2023-04-25 22:54:34 +03:00
if not self . image . selection :
return
2023-04-25 23:07:23 +03:00
2023-04-25 23:14:29 +03:00
if self . image . selection . textbox_mode :
# The Text tool creates an undo state only when you switch tools
# or click outside the textbox, melding the textbox into the image.
# If you're deleting the textbox, an undo state doesn't need to be created.
2023-04-25 23:25:47 +03:00
# If you haven't typed anything into the textbox yet, it should be deleted
# to make it easier to start over in positioning the textbox.
# If you have typed something, it should be melded into the image,
# even if you backspaced it all, to match MS Paint.
if not self . image . selection . textbox_edited :
meld = False
2023-04-25 23:14:29 +03:00
make_undo_state = meld
else :
# The Select tool creates an undo state when you drag a selection,
2023-04-26 19:47:08 +03:00
# so we only need to create one if you haven't dragged it, unless it was pasted.
2023-04-25 23:14:29 +03:00
# Once it's dragged, it cuts out the image data, and contained_image is not None.
2023-04-26 19:47:08 +03:00
# TODO: refactor to a flag that says whether an undo state was already created
make_undo_state = ( self . image . selection . contained_image is None and not meld ) or self . image . selection . pasted
2023-04-25 23:07:23 +03:00
if make_undo_state :
# TODO: DRY with other undo state creation
self . image_at_start = AnsiArtDocument ( self . image . width , self . image . height )
self . image_at_start . copy_region ( self . image )
2023-04-29 04:11:46 +03:00
action = Action ( self . selected_tool . get_name ( ) )
2023-04-25 23:07:23 +03:00
if len ( self . redos ) > 0 :
self . redos = [ ]
self . undos . append ( action )
2023-04-25 22:54:34 +03:00
region = self . image . selection . region
if meld :
self . image . selection . copy_to_document ( self . image )
else :
2023-04-25 23:07:23 +03:00
if self . image . selection . contained_image is None :
2023-04-25 22:54:34 +03:00
# It hasn't been cut out yet, so we need to erase it.
self . erase_region ( region , self . image . selection . mask )
self . image . selection = None
self . canvas . refresh_scaled_region ( region )
self . selection_drag_offset = None
self . selecting_text = False
2023-04-21 22:05:41 +03:00
2023-04-25 23:07:23 +03:00
if make_undo_state :
action = action # type: ignore
affected_region = region
# TODO: DRY with other undo state creation
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-25 22:53:40 +03:00
def meld_selection ( self ) - > None :
""" Draw the selection onto the image and dissolve the selection. """
self . meld_or_clear_selection ( meld = True )
2023-04-26 18:19:16 +03:00
def action_clear_selection ( self , from_key_binding : bool = False ) - > None :
""" Delete the selection and its contents, or if using the Text tool, delete text. """
sel = self . image . selection
if sel is None :
return
if sel . textbox_mode :
if not from_key_binding :
self . on_key ( events . Key ( " delete " , None ) )
else :
self . meld_or_clear_selection ( meld = 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-24 22:46:17 +03:00
if self . mouse_gesture_cancelled :
return
2023-04-24 06:21:10 +03:00
if self . selected_tool != Tool . select :
if self . selected_tool in [ Tool . line , Tool . rectangle , Tool . ellipse , Tool . rounded_rectangle ] : # , Tool.curve
# Display is allowed to go negative, unlike for the Select tool, handled below.
# Also, Polygon gets both coords and dimensions.
# Unlike MS Paint, Free-Form Select displays the dimensions of the resulting selection,
# (rather than the difference between the mouse position and the starting point,)
# which seems better to me.
# Also, unlike MS Paint, Curve displays mouse coords rather than dimensions,
# where "dimensions" are the difference between the mouse position and the starting point.
# I don't know that this is better, but my mouse_at_start currently is set on mouse down for in-progress curves,
# so it wouldn't match MS Paint unless I changed that or used the tool_points list.
# I don't know that anyone looks at the status bar while drawing a curve.
# If they do, they should probably be using a graphing calculator instead or something.
self . get_widget_by_id ( " status_dimensions " , Static ) . update ( f " { event . mouse_move_event . x - self . mouse_at_start . x } x { event . mouse_move_event . y - self . mouse_at_start . y } " )
else :
self . get_widget_by_id ( " status_coords " , Static ) . update ( f " { event . mouse_move_event . x } , { event . mouse_move_event . y } " )
2023-04-14 02:01:40 +03:00
if self . selected_tool == Tool . pick_color :
self . pick_color ( event . mouse_move_event . x , event . mouse_move_event . y )
return
if self . selected_tool in [ Tool . fill , Tool . magnifier ] :
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-27 00:00:54 +03:00
offset_in_textbox = Offset ( * event . mouse_move_event . offset ) - sel . region . offset
offset_in_textbox = Offset (
min ( max ( 0 , offset_in_textbox . x ) , sel . region . width - 1 ) ,
min ( max ( 0 , offset_in_textbox . y ) , sel . region . height - 1 ) ,
)
sel . text_selection_end = offset_in_textbox
2023-04-23 04:03:38 +03:00
self . canvas . refresh_scaled_region ( sel . region )
2023-04-25 21:34:09 +03:00
elif self . selection_drag_offset is not None :
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-25 23:51:36 +03:00
# Handles constraints and canvas refresh.
self . move_selection_absolute ( * offset )
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 ) )
2023-04-24 19:48:49 +03:00
self . make_preview ( self . draw_current_free_form_select_polyline , show_dimensions_in_status_bar = True )
2023-04-21 22:24:45 +03:00
else :
self . canvas . select_preview_region = self . get_select_region ( self . mouse_at_start , event . mouse_move_event . offset )
self . canvas . refresh_scaled_region ( self . canvas . select_preview_region )
2023-04-24 06:21:10 +03:00
self . get_widget_by_id ( " status_dimensions " , Static ) . update (
f " { self . canvas . select_preview_region . width } x { self . canvas . select_preview_region . height } "
)
2023-04-21 22:05:41 +03:00
return
2023-04-21 20:11:39 +03:00
2023-04-22 08:04:19 +03:00
if self . selected_tool in [ Tool . curve , Tool . polygon ] :
2023-04-22 05:51:00 +03:00
if len ( self . tool_points ) < 2 :
self . tool_points . append ( Offset ( event . mouse_move_event . x , event . mouse_move_event . y ) )
self . tool_points [ - 1 ] = Offset ( event . mouse_move_event . x , event . mouse_move_event . y )
2023-04-22 08:04:19 +03:00
if self . selected_tool == Tool . curve :
self . make_preview ( self . draw_current_curve )
elif self . selected_tool == Tool . polygon :
2023-04-24 06:21:10 +03:00
self . make_preview ( self . draw_current_polyline , show_dimensions_in_status_bar = True ) # polyline until finished
2023-04-22 05:51:00 +03:00
return
2023-04-14 03:28:58 +03:00
if len ( self . undos ) == 0 :
2023-04-22 05:51:00 +03:00
# Code below wants to update the last action.
# However, if you you undo while drawing,
# there may be no last action.
# FIXME: Ideally we'd stop getting events in this case.
2023-04-14 03:28:58 +03:00
# This might be buggy if there were multiple undos.
# It might replace the action instead of doing nothing.
return
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 )
2023-04-29 04:11:46 +03:00
action = Action ( self . selected_tool . get_name ( ) , affected_region )
2023-04-12 05:24:39 +03:00
self . undos . append ( action )
2023-04-24 08:27:11 +03:00
if self . selected_tool in [ Tool . pencil , Tool . brush , Tool . eraser , Tool . airbrush ] :
2023-04-24 08:40:20 +03:00
for x , y in bresenham_walk ( self . mouse_previous . x , self . mouse_previous . y , mm . x , mm . y ) :
2023-04-12 05:24:39 +03:00
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-24 08:40:20 +03:00
self . mouse_previous = mm . offset
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-24 06:21:10 +03:00
self . get_widget_by_id ( " status_dimensions " , Static ) . update ( " " )
2023-04-25 20:17:44 +03:00
self . color_eraser_mode = False # reset for preview
2023-04-24 22:46:17 +03:00
if self . mouse_gesture_cancelled :
return
2023-04-25 21:34:09 +03:00
if self . selection_drag_offset is not None :
2023-04-21 22:31:38 +03:00
# Done dragging selection
self . selection_drag_offset = None
2023-04-26 02:22:57 +03:00
# Refresh to show border, which is hidden while dragging
assert self . image . selection is not None , " Dragging selection without selection "
self . canvas . refresh_scaled_region ( self . image . selection . region )
2023-04-21 22:31:38 +03:00
return
2023-04-22 04:19:05 +03:00
if self . selecting_text :
# Done selecting text
self . selecting_text = False
return
2023-04-29 03:46:24 +03:00
# TODO: FIXME: dragging from outside the canvas shouldn't make a selection
# and for Free-Form Select it gets ValueError: min() arg is an empty sequence
# - self.mouse_at_start is never unset so it can't be used to check if the mouse is down
# - self.canvas.pointer_active is always False during ToolStop currently so I can't use that
# I could make it be set to False after ToolStop, but I could also
# just make ToolStop not fire if ToolStart didn't fire, right?
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-25 06:14:46 +03:00
for y in range ( self . image . selection . region . height ) :
for x in range ( self . image . selection . region . width ) :
self . image . selection . contained_image . fg [ y ] [ x ] = self . selected_fg_color
self . image . selection . contained_image . bg [ y ] [ x ] = self . selected_bg_color
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 :
2023-04-25 22:26:33 +03:00
self . finalize_polygon_or_curve ( )
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 ) :
2023-04-25 22:26:33 +03:00
self . finalize_polygon_or_curve ( )
2023-04-22 08:04:19 +03:00
else :
# Most likely just drawing the preview we just cancelled.
2023-04-24 06:21:10 +03:00
self . make_preview ( self . draw_current_polyline , show_dimensions_in_status_bar = True ) # polyline until finished
2023-04-22 08:04:19 +03:00
self . polygon_last_click_time = event . time
2023-04-24 09:16:40 +03:00
elif self . selected_tool in [ Tool . pick_color , Tool . magnifier ] :
2023-04-24 02:48:36 +03:00
self . selected_tool = self . return_to_tool
2023-04-22 08:04:19 +03:00
2023-04-22 04:19:05 +03:00
# Not reliably unset, so might as well not rely on it. (See early returns above.)
# self.mouse_at_start = None
2023-04-21 22:05:41 +03:00
2023-04-25 23:51:36 +03:00
def move_selection_absolute ( self , x : int , y : int ) - > None :
""" Positions the selection relative to the document. """
# Constrain to have at least one row/column within the bounds of the document.
# This ensures you can always drag the selection back into the document,
# but doesn't limit you from positioning it partially outside.
# (It is useless to position it _completely_ outside, since you could just delete it.)
sel = self . image . selection
assert sel is not None , " move_selection_absolute called without a selection "
offset = Offset (
max ( 1 - sel . region . width , min ( self . image . width - 1 , x ) ) ,
max ( 1 - sel . region . height , min ( self . image . height - 1 , y ) ) ,
)
old_region = sel . region
sel . region = Region . from_offset ( offset , sel . region . size )
combined_region = old_region . union ( sel . region )
self . canvas . refresh_scaled_region ( combined_region )
def move_selection_relative ( self , delta_x : int , delta_y : int ) - > None :
""" Moves the selection relative to its current position. """
sel = self . image . selection
assert sel is not None , " move_selection_relative called without a selection "
self . move_selection_absolute ( sel . region . offset . x + delta_x , sel . region . offset . y + delta_y )
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-25 23:51:36 +03:00
key = event . key
2023-04-26 23:47:55 +03:00
shift = key . startswith ( " shift+ " )
if shift :
key = key [ len ( " shift+ " ) : ]
2023-04-27 04:23:16 +03:00
if " ctrl " in key :
# Don't interfere with Ctrl+C, Ctrl+V, etc.
return
2023-04-26 23:47:55 +03:00
2023-04-25 23:51:36 +03:00
if self . image . selection and not self . image . selection . textbox_mode :
2023-04-27 00:23:45 +03:00
# TODO: smear selection if shift is held
2023-04-25 23:51:36 +03:00
if key == " left " :
self . move_selection_relative ( - 1 , 0 )
elif key == " right " :
self . move_selection_relative ( 1 , 0 )
elif key == " up " :
self . move_selection_relative ( 0 , - 1 )
elif key == " down " :
self . move_selection_relative ( 0 , 1 )
2023-04-22 04:19:05 +03:00
if self . image . selection and self . image . selection . textbox_mode :
2023-04-27 00:24:28 +03:00
textbox = self . image . selection
assert textbox . contained_image is not None , " Textbox mode should always have contained_image, to edit as text. "
2023-04-27 00:23:45 +03:00
2023-04-27 00:49:12 +03:00
def delete_selected_text ( ) - > None :
""" Deletes the selected text, if any. """
assert textbox . contained_image is not None , " Textbox mode should always have contained_image, to edit as text. " # Come on, Pyright.
# Delete the selected text.
2023-04-27 04:10:08 +03:00
for offset in selected_text_range ( textbox ) :
2023-04-27 00:49:12 +03:00
textbox . contained_image . ch [ offset . y ] [ offset . x ] = " "
textbox . textbox_edited = True
# Move the cursor to the start of the selection.
textbox . text_selection_end = textbox . text_selection_start = min (
textbox . text_selection_start ,
textbox . text_selection_end ,
)
2023-04-22 04:19:05 +03:00
# TODO: delete selected text if any, when typing
2023-04-27 00:23:45 +03:00
2023-04-27 00:24:28 +03:00
# Note: Don't forget to set textbox.textbox_edited = True
2023-04-25 23:25:47 +03:00
# for any new actions that actually affect the text content.
2023-04-27 00:23:45 +03:00
# Whether or not shift is held, we start with the end point.
# Then once we've moved this point, we update the end point,
# and we update the start point unless shift is held.
# This way, the cursor jumps to (near) the end point if you
# hit an arrow key without shift, but with shift it will extend
# the selection.
2023-04-27 00:24:28 +03:00
x , y = textbox . text_selection_end
2023-04-27 00:23:45 +03:00
2023-04-22 04:19:05 +03:00
if key == " enter " :
x = 0
y + = 1
2023-04-27 00:24:28 +03:00
if y > = textbox . contained_image . height :
y = textbox . contained_image . height - 1
# textbox.textbox_edited = True
2023-04-22 04:19:05 +03:00
elif key == " left " :
2023-04-27 00:15:27 +03:00
x = max ( 0 , x - 1 )
2023-04-22 04:19:05 +03:00
elif key == " right " :
2023-04-27 00:24:28 +03:00
x = min ( textbox . contained_image . width - 1 , x + 1 )
2023-04-22 04:19:05 +03:00
elif key == " up " :
y = max ( 0 , y - 1 )
elif key == " down " :
2023-04-27 00:24:28 +03:00
y = min ( textbox . contained_image . height - 1 , y + 1 )
2023-04-22 04:19:05 +03:00
elif key == " backspace " :
2023-04-27 00:49:12 +03:00
if textbox . text_selection_end == textbox . text_selection_start :
x = max ( 0 , x - 1 )
textbox . contained_image . ch [ y ] [ x ] = " "
else :
delete_selected_text ( )
x , y = textbox . text_selection_end
2023-04-27 00:24:28 +03:00
textbox . textbox_edited = True
2023-04-22 04:19:05 +03:00
elif key == " delete " :
2023-04-27 00:49:12 +03:00
if textbox . text_selection_end == textbox . text_selection_start :
textbox . contained_image . ch [ y ] [ x ] = " "
x = min ( textbox . contained_image . width - 1 , x + 1 )
else :
delete_selected_text ( )
x , y = textbox . text_selection_end
2023-04-27 00:24:28 +03:00
textbox . textbox_edited = True
2023-04-22 04:19:05 +03:00
elif key == " home " :
x = 0
elif key == " end " :
2023-04-27 00:24:28 +03:00
x = textbox . contained_image . width - 1
2023-04-22 04:19:05 +03:00
elif key == " pageup " :
y = 0
elif key == " pagedown " :
2023-04-27 00:24:28 +03:00
y = textbox . 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
2023-04-27 00:24:28 +03:00
textbox . contained_image . ch [ y ] [ x ] = event . character
# x = min(textbox.contained_image.width - 1, x + 1)
2023-04-22 04:19:05 +03:00
x + = 1
2023-04-27 00:24:28 +03:00
if x > = textbox . contained_image . width :
2023-04-22 04:19:05 +03:00
x = 0
2023-04-27 00:24:28 +03:00
# y = min(textbox.contained_image.height - 1, y + 1)
2023-04-22 04:19:05 +03:00
y + = 1
2023-04-27 00:24:28 +03:00
if y > = textbox . contained_image . height :
y = textbox . contained_image . height - 1
x = textbox . contained_image . width - 1
textbox . textbox_edited = True
2023-04-26 23:47:55 +03:00
if shift :
2023-04-27 00:24:28 +03:00
textbox . text_selection_end = Offset ( x , y )
2023-04-26 23:47:55 +03:00
else :
2023-04-27 00:24:28 +03:00
textbox . text_selection_start = Offset ( x , y )
textbox . text_selection_end = Offset ( x , y )
self . canvas . refresh_scaled_region ( textbox . region )
2023-04-22 04:19:05 +03:00
2023-04-11 14:52:26 +03:00
2023-04-13 06:32:30 +03:00
def action_toggle_tools_box ( self ) - > None :
2023-04-26 10:01:00 +03:00
""" Toggles the visibility of the tools box. """
2023-04-13 06:32:30 +03:00
self . show_tools_box = not self . show_tools_box
def action_toggle_colors_box ( self ) - > None :
2023-04-26 10:01:00 +03:00
""" Toggles the visibility of the colors box. """
2023-04-13 06:32:30 +03:00
self . show_colors_box = not self . show_colors_box
2023-04-24 05:22:28 +03:00
def action_toggle_status_bar ( self ) - > None :
2023-04-26 10:01:00 +03:00
""" Toggles the visibility of the status bar. """
2023-04-24 05:22:28 +03:00
self . show_status_bar = not self . show_status_bar
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. """
2023-04-25 22:33:53 +03:00
self . finalize_polygon_or_curve ( ) # must come before setting selected_tool
self . meld_selection ( )
self . tool_points = [ ]
2023-04-21 19:24:00 +03:00
self . selected_tool = event . tool
2023-04-24 02:48:36 +03:00
if self . selected_tool not in [ Tool . magnifier , Tool . pick_color ] :
self . return_to_tool = self . selected_tool
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. """
2023-04-25 05:46:13 +03:00
if event . as_foreground :
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-24 08:12:32 +03:00
def on_menu_status_info ( self , event : Menu . StatusInfo ) - > None :
2023-04-24 07:43:50 +03:00
""" Called when a menu item is hovered. """
2023-04-24 08:12:32 +03:00
text : str = event . description or " "
if event . closed :
text = _ ( " For Help, click Help Topics on the Help Menu. " )
self . get_widget_by_id ( " status_text " , Static ) . update ( text )
2023-04-24 07:43:50 +03:00
2023-04-24 02:42:49 +03:00
def within_menus ( self , node : DOMNode ) - > bool :
""" Returns True if the node is within the menus. """
# root node will never be a menu, so it doesn't need to be `while node:`
# and this makes the type checker happy, since parent can be None
while node . parent :
if isinstance ( node , Menu ) :
return True
node = node . parent
return False
2023-04-17 03:34:35 +03:00
def on_mouse_down ( self , event : events . MouseDown ) - > None :
""" Called when the mouse button gets pressed. """
2023-04-23 07:01:57 +03:00
leaf_widget , _ = self . get_widget_at ( * event . screen_offset )
2023-04-24 02:42:49 +03:00
# Close menus if clicking outside the menus
if not self . within_menus ( leaf_widget ) :
2023-04-24 19:53:20 +03:00
if self . query_one ( MenuBar ) . any_menus_open ( ) :
self . query_one ( MenuBar ) . close ( )
return
2023-04-24 02:42:49 +03:00
2023-04-23 07:01:57 +03:00
# Deselect if clicking outside the canvas
if leaf_widget is self . editing_area :
self . meld_selection ( )
2023-04-25 06:28:36 +03:00
# Unfocus if clicking on or outside the canvas,
# so that you can type in the Text tool.
# Otherwise the CharInput gets in the way.
if leaf_widget is self . editing_area or leaf_widget is self . canvas :
self . app . set_focus ( None )
2023-04-23 07:01:57 +03:00
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-29 01:53:33 +03:00
app . file_path = 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 ( )