diff --git a/.flake8 b/.flake8 index f2b41384..b1663a2f 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,4 @@ +[flake8] max-line-length=100 application_import_names=projectt ignore=P102,B311,W503,E226,S311,W504,F821 diff --git a/.gitignore b/.gitignore index 894a44cc..432fe26e 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ venv.bak/ # mypy .mypy_cache/ + +#Visual Studio Code Workplace settings +.vscode diff --git a/JUDGEINFO.md b/JUDGEINFO.md new file mode 100644 index 00000000..58428462 --- /dev/null +++ b/JUDGEINFO.md @@ -0,0 +1,61 @@ +# To the judges + +Thank you for all the time you take helping run the PyDis code jams and server. + +Although our team would **love** to see you suffer from our program, we do not wanna be marked down for stuff so here's is a list of *(hopefully)* everything that is so-called "evil" with our program. + +**Since we would love for you to experience this blind, please only read this once you've seen the app for the first time so you may see the evilness we managed to achieve.** + + +## The Main Program + +As you have likely already seen, this paint tool isn't your average program. The canvas is completely non-interactive. You are required to use the sidebar to enter the values. + +For the *X* and *Y* entries (see **The Language** to translate the labels), you must enter the X and Y value of the pixel you would like to colour, with `(0,0)` being the top-left corner. + +The *Colour* entry is a bit special. It works like this + +* Grab the colour you would like to enter +* Find its hex value +* Swap the last two digits and the first two digits around (so it's in BGR format instead of RGB) +* Convert it into denary / base 10 +* Enter your final answer into the Entry box + +## The Language + +The entire project is written in Katakana, a Japanese syllabary, which when pronounced sounds similar to the English equivalent. This was in order to make the program more difficult to use. + +However, as an easter egg, we included a method to translate the entire program into English and to do this, you need to enter the Konami code. + +Entering **Up, Up, Down, Down, Left, Right, Left, Right, B, A, ~~Start~~ Return** in quick succession will translate the entire program from Katakana to English. + +## Saving + +The "Save Processor Time" button destroys the program. Having already gone through the application, you may have discovered this. +In order to save, use `Alt + F4` (or the X button) to save your file to a file select window. However, I suggest you save small files since a button needs to be pressed for every byte saved. For example, a 2x2 image contains 75 bytes, meaning that you'd need to press the button 75 times. + +## Small, slightly inconvenient things +It's the small things that everyone hates :D + +### Undo & Redo + +To fit the theme, the *Undo* and *Redo* buttons have been switched around, so clicking one will trigger the other. This also applies to keybinds. Using `Ctrl + Z` will redo the previous action and Using `Ctrl + Y` or `Ctrl + Shift + Z` will undo the action. + +### Open File + +To open a file, navigate to the *Close* button (ironically) and choose a file. There's no saying what will happen to your file though... Maybe it will: +* Become super pixelated +* Have completely different colours to the original image +* Become fragmented +* Have a complete shuffle of pixels +* Be absolutely normal + +Which one will happen? The computer will decide... you have no control. +Not only that but the larger the image, the longer it'll take to load, ensuring that any haste is completely obliterated. +Also, in massive folders, it doesn't scroll so good luck finding that one file at the bottom! + + +### New File + +To create a new file, use the *New File* button and specify a height and width. Don't think you're off the hook because the label is correct. Legends say that your height and width may be switched around... + diff --git a/Pipfile b/Pipfile index 72b70b6f..d92c0691 100644 --- a/Pipfile +++ b/Pipfile @@ -7,9 +7,14 @@ verify_ssl = true flake8 = "*" [packages] +flake8 = "*" +pillow = "*" +asynctk = "*" +aiofiles = "*" [requires] python_version = "3.7" [scripts] -lint = "python -m flake8" \ No newline at end of file +lint = "python -m flake8" +start = "python -m project" diff --git a/Pipfile.lock b/Pipfile.lock index 79354a3c..3f89ef06 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "a376db0bd471e38a7080cd854c46349b46922db98afeaf83d17b84923fbe9710" + "sha256": "6922c5483fed31f54b3cd08ae47928dfcb4495f4fb0684f1e019df40ff996acd" }, "pipfile-spec": 6, "requires": { @@ -15,7 +15,104 @@ } ] }, - "default": {}, + "default": { + "aiofiles": { + "hashes": [ + "sha256:021ea0ba314a86027c166ecc4b4c07f2d40fc0f4b3a950d1868a0f2571c2bbee", + "sha256:1e644c2573f953664368de28d2aa4c89dfd64550429d0c27c4680ccd3aa4985d" + ], + "index": "pypi", + "version": "==0.4.0" + }, + "asyncio": { + "hashes": [ + "sha256:83360ff8bc97980e4ff25c964c7bd3923d333d177aa4f7fb736b019f26c7cb41", + "sha256:b62c9157d36187eca799c378e572c969f0da87cd5fc42ca372d92cdb06e7e1de", + "sha256:c46a87b48213d7464f22d9a497b9eef8c1928b68320a2fa94240f969f6fec08c", + "sha256:c4d18b22701821de07bd6aea8b53d21449ec0ec5680645e5317062ea21817d2d" + ], + "version": "==3.4.3" + }, + "asynctk": { + "hashes": [ + "sha256:9ce8379b2fb11819f7c2390dbd6b5c395b9946f022949f2124ba68e1d5077836" + ], + "index": "pypi", + "version": "==2019.3.3.post4" + }, + "entrypoints": { + "hashes": [ + "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", + "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" + ], + "version": "==0.3" + }, + "flake8": { + "hashes": [ + "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", + "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8" + ], + "index": "pypi", + "version": "==3.7.7" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "pillow": { + "hashes": [ + "sha256:051de330a06c99d6f84bcf582960487835bcae3fc99365185dc2d4f65a390c0e", + "sha256:0ae5289948c5e0a16574750021bd8be921c27d4e3527800dc9c2c1d2abc81bf7", + "sha256:0b1efce03619cdbf8bcc61cfae81fcda59249a469f31c6735ea59badd4a6f58a", + "sha256:163136e09bd1d6c6c6026b0a662976e86c58b932b964f255ff384ecc8c3cefa3", + "sha256:18e912a6ccddf28defa196bd2021fe33600cbe5da1aa2f2e2c6df15f720b73d1", + "sha256:24ec3dea52339a610d34401d2d53d0fb3c7fd08e34b20c95d2ad3973193591f1", + "sha256:267f8e4c0a1d7e36e97c6a604f5b03ef58e2b81c1becb4fccecddcb37e063cc7", + "sha256:3273a28734175feebbe4d0a4cde04d4ed20f620b9b506d26f44379d3c72304e1", + "sha256:4c678e23006798fc8b6f4cef2eaad267d53ff4c1779bd1af8725cc11b72a63f3", + "sha256:4d4bc2e6bb6861103ea4655d6b6f67af8e5336e7216e20fff3e18ffa95d7a055", + "sha256:505738076350a337c1740a31646e1de09a164c62c07db3b996abdc0f9d2e50cf", + "sha256:5233664eadfa342c639b9b9977190d64ad7aca4edc51a966394d7e08e7f38a9f", + "sha256:5d95cb9f6cced2628f3e4de7e795e98b2659dfcc7176ab4a01a8b48c2c2f488f", + "sha256:7eda4c737637af74bac4b23aa82ea6fbb19002552be85f0b89bc27e3a762d239", + "sha256:801ddaa69659b36abf4694fed5aa9f61d1ecf2daaa6c92541bbbbb775d97b9fe", + "sha256:825aa6d222ce2c2b90d34a0ea31914e141a85edefc07e17342f1d2fdf121c07c", + "sha256:9c215442ff8249d41ff58700e91ef61d74f47dfd431a50253e1a1ca9436b0697", + "sha256:a3d90022f2202bbb14da991f26ca7a30b7e4c62bf0f8bf9825603b22d7e87494", + "sha256:a631fd36a9823638fe700d9225f9698fb59d049c942d322d4c09544dc2115356", + "sha256:a6523a23a205be0fe664b6b8747a5c86d55da960d9586db039eec9f5c269c0e6", + "sha256:a756ecf9f4b9b3ed49a680a649af45a8767ad038de39e6c030919c2f443eb000", + "sha256:b117287a5bdc81f1bac891187275ec7e829e961b8032c9e5ff38b70fd036c78f", + "sha256:ba04f57d1715ca5ff74bb7f8a818bf929a204b3b3c2c2826d1e1cc3b1c13398c", + "sha256:cd878195166723f30865e05d87cbaf9421614501a4bd48792c5ed28f90fd36ca", + "sha256:cee815cc62d136e96cf76771b9d3eb58e0777ec18ea50de5cfcede8a7c429aa8", + "sha256:d1722b7aa4b40cf93ac3c80d3edd48bf93b9208241d166a14ad8e7a20ee1d4f3", + "sha256:d7c1c06246b05529f9984435fc4fa5a545ea26606e7f450bdbe00c153f5aeaad", + "sha256:e9c8066249c040efdda84793a2a669076f92a301ceabe69202446abb4c5c5ef9", + "sha256:f227d7e574d050ff3996049e086e1f18c7bd2d067ef24131e50a1d3fe5831fbc", + "sha256:fc9a12aad714af36cf3ad0275a96a733526571e52710319855628f476dcb144e" + ], + "index": "pypi", + "version": "==5.4.1" + }, + "pycodestyle": { + "hashes": [ + "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", + "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" + ], + "version": "==2.5.0" + }, + "pyflakes": { + "hashes": [ + "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", + "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" + ], + "version": "==2.1.1" + } + }, "develop": { "entrypoints": { "hashes": [ @@ -26,11 +123,11 @@ }, "flake8": { "hashes": [ - "sha256:6d8c66a65635d46d54de59b027a1dda40abbe2275b3164b634835ac9c13fd048", - "sha256:6eab21c6e34df2c05416faa40d0c59963008fff29b6f0ccfe8fa28152ab3e383" + "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", + "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8" ], "index": "pypi", - "version": "==3.7.6" + "version": "==3.7.7" }, "mccabe": { "hashes": [ @@ -48,10 +145,10 @@ }, "pyflakes": { "hashes": [ - "sha256:5e8c00e30c464c99e0b501dc160b13a14af7f27d4dffb529c556e30a159e231d", - "sha256:f277f9ca3e55de669fba45b7393a1449009cff5a37d1af10ebb76c52765269cd" + "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", + "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" ], - "version": "==2.1.0" + "version": "==2.1.1" } } } diff --git a/README.md b/README.md index 697c2bf7..d7955e6a 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,39 @@ You should be using [Pipenv](https://pipenv.readthedocs.io/en/latest/). Take a l # Project Information -`# TODO` +Flamboyant Flamingos jam project. + +* Team: + * Members: + * Starwort + * Location: UK + * Computer Science student in secondary education + * Leader + * Suhail + * Location: UK + * Computer Science student in secondary education + * Martmists + * Location: NL + * High School student, entering University later this year +* This paint tool is your worst enemy. ## Description -`# TODO` +A perverse editor that: + +* Has the entire UI in katakana +* Makes you click a button once per byte during saving +* Has unintuitive shortcuts ## Setup & Installation -`# TODO` +* `pipenv sync` +* `pipenv run start` ## How do I use this thing? -`# TODO` +* Start it and begin creating +* If you're a judge, please use JUDGEINFO.md for information on how this +* **Only use the JUDGEINFO.md once visited the app once blindly** + +Note - due to some errors and bugs in the tkinter module itself, some platforms may be unsupported or may error in some instances. Some may be solved by commenting line 68 of `__main__.py`. \ No newline at end of file diff --git a/icon.ico b/icon.ico new file mode 100644 index 00000000..a5ce6b98 Binary files /dev/null and b/icon.ico differ diff --git a/large test.jpg b/large test.jpg new file mode 100644 index 00000000..6dea678d Binary files /dev/null and b/large test.jpg differ diff --git a/project/__main__.py b/project/__main__.py index e69de29b..cbe45382 100644 --- a/project/__main__.py +++ b/project/__main__.py @@ -0,0 +1,306 @@ +import os +import pathlib +import typing + +import asyncio +import asynctk as tk + +""" +AsyncTK is an asychronous wrapper for Tkinter using AsyncIO. +This allows many methods to run as coroutines, allowing them to interact asynchronously +with the Tk window +""" + + +from . import locale +from .canvas import Canvas, EntrySection, FileToplevel + + +def nothing(*i): + """Used for buttons and binded keys in order to overwrite or remove their function""" + pass + + +class Framed(tk.AsyncTk): + + """ + The main tkinter window, subclassed from asynctk.AsyncTk + + Attributes + ---------- + canvas: .canvas.Canvas + The canvas containing the current painting (subclass of asynctk.AsyncCanvas) + entry: .canvas.EntrySection + The frame containing the buttons to add a pixel (subclass of asynctk.AsyncFrame) + cur_locale: .locale.eng or .locale.kata + The current locale that is used around the window + konami_code: int + Position in the Konami Code (to allow the translation into English) + + Methods + ------- + file_select: coroutine + Opens up the file select window and allows the choosing of a file. + Returns the path to the file + save: coroutine + Runs file_select and saves the current image to that file + open_file: coroutine + Runs file_select and recreates the canvas with the new image + new_file: coroutine + Creates a new canvas based on a given width and height + open_new: coroutine + Creates the Top Level for the new file button + set_en: method + Translates the entire window into English + set_konami: coroutine + Furthers along the position in the Konami key + check_konami: coroutine + Checks to see if the konami has reached the end + """ + + def __init__(self): + super().__init__() + + self.cur_locale = locale.kata + self.konami_code = 0 + + self.wm_title(self.cur_locale.general.title) + self.iconbitmap(bitmap="icon.ico", default="icon.ico") + + self._setupMenu() + + self.canvas = Canvas( + self, height=600, width=600 + ) # Temporary - will make them settable + self.entry = EntrySection(self) + + self.protocol("WM_DELETE_WINDOW", lambda: asyncio.ensure_future(self.save())) + self.bind("", lambda i: asyncio.ensure_future(self.destroy())) + self.bind("", lambda i: asyncio.ensure_future(self.destroy())) + self.bind("", lambda i: asyncio.ensure_future(self.open_new())) + self.bind("", lambda i: asyncio.ensure_future(self.canvas.redo())) + self.bind("", lambda i: asyncio.ensure_future(self.canvas.undo())) + self.bind("", lambda i: asyncio.ensure_future(self.canvas.undo())) + + self._konami_bind(self) + + def _konami_bind(self, item): + item.bind("", lambda i: asyncio.ensure_future(self.set_konami(1, 2))) + item.bind("", lambda i: asyncio.ensure_future(self.set_konami(3, 4))) + item.bind("", lambda i: asyncio.ensure_future(self.set_konami(5, 7))) + item.bind("", lambda i: asyncio.ensure_future(self.set_konami(6, 8))) + item.bind("b", lambda i: asyncio.ensure_future(self.set_konami(9))) + item.bind("a", lambda i: asyncio.ensure_future(self.set_konami(10))) + item.bind("", lambda i: asyncio.ensure_future(self.check_konami())) + + async def set_konami(self, *numbers): + print([n - 1 for n in numbers]) + if self.konami_code in [n - 1 for n in numbers]: + self.konami_code += 1 + print(self.konami_code) + new_num = self.konami_code + await asyncio.sleep(1) + if self.konami_code == new_num: + self.konami_code = 0 + else: + self.konami_code = 0 + print(self.konami_code) + + async def check_konami(self): + if self.konami_code == 10: + self.set_en() + + def set_en(self): + self.cur_locale = locale.eng + self.wm_title(self.cur_locale.general.title) + self._setupMenu() + self.entry.reset(remember_values=True) + + async def save(self): + file = await self.file_select() + if file: + await self.canvas.save(file) + + async def new_file(self, height: int, width: int): + """Creates new canvas based on height and width""" + self.canvas.forget() + del self.canvas + self.canvas = Canvas(self, height=height, width=width) + self.entry.reset() + + async def open_file(self): + file = await self.file_select(new_file=False) + if file: + self.canvas.forget() + self.canvas = await self.canvas.from_image(self, file) + self.entry.reset() + + def _setupMenu(self): + menu = tk.AsyncMenu(self) + self.config(menu=menu) + + file_menu = tk.AsyncMenu(menu) + file_menu.add_command( + label=self.cur_locale.menu.new.name, + command=lambda: asyncio.ensure_future(self.open_new()), + ) + file_menu.add_command( + label=self.cur_locale.menu.unhelpful.nothing, command=nothing + ) + file_menu.add_command( + label=self.cur_locale.menu.unhelpful.save, + command=lambda: asyncio.ensure_future(self.destroy()), + ) + file_menu.add_separator() + file_menu.add_command( + label=self.cur_locale.menu.unhelpful.close, + command=lambda: asyncio.ensure_future(self.open_file()), + ) + + edit_menu = tk.AsyncMenu(menu) + + edit_menu.add_command( + label=self.cur_locale.menu.edit.undo, + command=lambda: asyncio.ensure_future( + self.canvas.redo() + ), # intentionally switched + ) + edit_menu.add_command( + label=self.cur_locale.menu.edit.redo, + command=lambda: asyncio.ensure_future( + self.canvas.undo() + ), # intentionally switched + ) + + menu.add_cascade(label=self.cur_locale.menu.unhelpful.name, menu=file_menu) + menu.add_cascade(label=self.cur_locale.menu.edit.name, menu=edit_menu) + + async def open_new(self): + """Toplevel for picking width & height""" + FileToplevel(self) + + async def file_select(self, *, new_file: bool = True): + """File select dialogue""" + manager = tk.AsyncToplevel(self) + manager.title( + self.cur_locale.menu.fileselect.saveas + if new_file + else self.cur_locale.menu.fileselect.open + ) + manager.protocol( + "WM_DELETE_WINDOW", lambda: asyncio.ensure_future(manager.destroy()) + ) + dir = pathlib.Path() + dirbox = tk.AsyncEntry(manager) + dirbox.grid(row=0, column=0) + foldermap = tk.AsyncFrame(manager) + foldermap.grid(row=1, column=0) + + def populate_folder(folder: pathlib.Path): + """Internally manages the save dialogue.""" + nonlocal dir + dir = manager.dir + for i in [".."] + os.listdir(folder): + if (dir / i).is_file(): + + async def cb(i=i): # i=i prevents late binding + # Late binding causes an undetectable error that + # causes all buttons to utilise the same callback + manager.file = dir / i + await manager.destroy() + + tk.AsyncButton( + foldermap, + text=f"{i} {self.cur_locale.menu.fileselect.file}", + callback=cb, + ).pack(fill=tk.X) + elif (dir / i).is_dir(): + + async def cb(i=i): # i=i prevents late binding + # Late binding causes an undetectable error that + # causes all buttons to utilise the same callback + manager.dir = dir / i + change_dir(manager.dir) + + tk.AsyncButton( + foldermap, + text=f"{i} {self.cur_locale.menu.fileselect.folder}", + callback=cb, + ).pack(fill=tk.X) + + async def new(): + """Internal coroutine used to create the new file dialogue.""" + dialogue = tk.AsyncToplevel(manager) + dialogue.title(self.cur_locale.menu.fileselect.new) + dialogue.protocol("WM_DELETE_WINDOW", nothing) + filename = tk.AsyncEntry(dialogue) + filename.pack() + + async def cb(): + if filename.get() != len(filename.get()) * ".": + for i in r'\/:*?"<>|': + if i in filename.get(): + button.config( + text=self.cur_locale.menu.fileselect.button.invalid + ) + break + else: + manager.file = manager.dir / filename.get() + await manager.destroy() + else: + button.config( + text=self.cur_locale.menu.fileselect.button.special + ) + + # Confirm button + button = tk.AsyncButton( + dialogue, + text=self.cur_locale.menu.fileselect.button.default, + callback=cb, + ) + button.pack(fill=tk.X) + + # Cancel button + tk.AsyncButton( + dialogue, + text=self.cur_locale.menu.fileselect.button.cancel, + callback=dialogue.destroy, + ).pack(fill=tk.X) + await manager.wait_window(dialogue) + + if new_file: + # New File button + tk.AsyncButton( + foldermap, text=self.cur_locale.menu.fileselect.new, callback=new + ).pack(fill=tk.X) + + def boxcallback(*i): + """Internal function called to change the directory to what is typed in dirbox.""" + change_dir(dirbox.get()) + + def change_dir(path: typing.Union[str, pathlib.Path]): + """Internal function to load a path into the file select menu.""" + nonlocal dir, foldermap + dir = pathlib.Path(path) + manager.dir = dir + asyncio.ensure_future(foldermap.destroy()) + foldermap = tk.AsyncFrame(manager) + foldermap.grid(row=1, column=0) + populate_folder(dir) + # Cancel button + tk.AsyncButton( + foldermap, + text=self.cur_locale.menu.fileselect.button.cancel, + callback=manager.destroy, + ).pack(fill=tk.X) + + dirbox.bind("", boxcallback) + change_dir(".") + await self.wait_window(manager) + if hasattr(manager, "file"): + return manager.file + + +if __name__ == "__main__": + root = Framed() + root.mainloop() diff --git a/project/canvas.py b/project/canvas.py new file mode 100644 index 00000000..bdb12ca2 --- /dev/null +++ b/project/canvas.py @@ -0,0 +1,650 @@ +""" +This module contains the canvas and entry section items that fill +the normal tk window (located in __main__.py) +""" + +from io import BytesIO +import typing +import pathlib +import random + +import aiofiles +import asynctk as tk +from tkinter.ttk import Progressbar +from PIL import Image, ImageDraw, ImageTk +import asyncio + + +async def start(bar): + while 1: + bar.step() + await asyncio.sleep(0.05) + + +class Colour: + + """ + The colour class - used to unify all representations of colour as needed + by third-party modules. + + This class also switches the colour around to fit the theme of the code jam. + + + Parameters + ---------- + colour: int or str + The colour inputted (given by the text box Entry) + + All examples are with the Colour initialised with Colour("15715755") + + Attributes + ---------- + fake_colour: str + The colour in hex before reformatting + e.g. "efcdab" + r: str + The amount of red in hex format. + e.g. "ab" + g: str + The amount of green in hex format. + e.g. "cd" + b: str + The amount of blue in hex format. + e.g. "ef" + colour: str + The colour in hex after the format is switched. + e.g. "abcdef" + as_hex: str + The colour prefixed with # + This is the most common way to represent a colour, and the main one + used by TK/TCL. + e.g. "#abcdef" + as_int: int + The colour in an integer with the hex converted into denary. + e.g. 11259375 + as_rgb: tuple[int] + The colour in an (r, g, b) tuple. + e.g. (171, 205, 239) + + Methods + ------- + from_rgb: classmethod + Creates class from an (r, g, b) tuple. + + """ + + def __init__(self, colour: typing.Union[str, int]): + try: + int(colour) + except ValueError: + raise TypeError + if int(colour) not in range(16_777_216): + raise ValueError + self.fake_colour = hex(int(colour))[2:] + self.fake_colour = "0" * (6 - len(self.fake_colour)) + self.fake_colour + self.b = self.fake_colour[0:2] + self.g = self.fake_colour[2:4] + self.r = self.fake_colour[4:6] + self.colour = self.r + self.g + self.b + self.as_hex = "#" + self.colour + self.as_int = int(self.colour, 16) + + @property + def as_rgb(self): + return (int(self.r, 16), int(self.g, 16), int(self.b, 16)) + + @classmethod + def from_rgb(cls, colour: typing.Tuple[int, int, int]): + r, g, b = map(lambda x: hex(x)[2:], colour) + fake = b + g + r + fake_int = int(fake, 16) + return cls(fake_int) + + +class Canvas(tk.AsyncCanvas): + + """ + The Canvas class located on the main tk window, subclassed from asynctk.AsyncCanvas + + Parameters + ---------- + height: int + The height of the canvas (in pixels) + width: int + The width of the canvas (in pixels) + + Attributes + ---------- + height: int + The height of the canvas (in pixels) + width: int + The width of the canvas (in pixels) + undo_list: list[tuple[int, int, Colour]] + The list of tuples containing the pixels that have been edited in chronological order + in the format [x, y, old_colour] where old_colour is the colour of the original pixel + before it was overwritten + redo_list: list[tuple[int, int, Colour]] + The list of tuples containing the pixels that have been undone in reverse chronological + order in the format [x, y, new_colour] where new_colour is the new colour of the pixel + that was shown before the undo button was pressed + frame: asynctk.AsyncFrame or None + The frame created that the canvas is in, if any. This is only created if the image + surpasses the 600x600 grid, in which case a frame is added for scrollbars. + + Methods + ------- + add_pixel: coroutine + save: coroutine + undo: coroutine + redo: coroutine + forget: method + from_image: classmethod coroutine + + """ + + def __init__( + self, + master: typing.Union[tk.AsyncTk, tk.AsyncFrame], + *, + height: int, + width: int, + ): + self.frame = None + self.read_nums = 1 + + if height > 600 or width > 600: + + true_height = 600 if height > 600 else height + true_width = 600 if width > 600 else width + + self.frame = tk.AsyncFrame(master) + self.frame.pack(side=tk.LEFT) + super().__init__( + self.frame, + height=true_height, + width=true_width, + bg="white", + scrollregion=(0, 0, width, height), + ) + + if height > 600: + hbar = tk.AsyncScrollbar(self.frame, orient=tk.HORIZONTAL) + hbar.pack(side=tk.BOTTOM, fill=tk.X) + hbar.config(command=self.yview) + self.config(yscrollcommand=hbar.set) # intentional switch + + if width > 600: + vbar = tk.AsyncScrollbar(self.frame, orient=tk.VERTICAL) + vbar.pack(side=tk.RIGHT, fill=tk.Y) + vbar.config(command=self.xview) + self.config(xscrollcommand=vbar.set) # intentional switch + self.pack(side=tk.LEFT, expand=True, fill=tk.BOTH) + + else: + super().__init__(master, height=height, width=width, bg="white") + self.pack(side=tk.LEFT) + + self.width, self.height = width, height + + self.pil_image = Image.new("RGB", (width, height), (255, 255, 255)) + self.pil_draw = ImageDraw.Draw(self.pil_image) + + self.undo_list = [] + self.redo_list = [] + + self._master = master + + async def add_pixel(self, x: int, y: int, colour: Colour): + """ + Adds pixel to both the displayed canvas and the backend PIL image + + Parameters + ---------- + x: int + The x coordinate of the pixel + y: int + The y coordinate of the pixel + colour: Colour + The fill colour of the pixel + """ + + await self.create_line(x, y, x+1, y, fill=colour.as_hex) + self.pil_draw.point([(x - 1, y - 1)], fill=colour.as_rgb) + + async def undo(self): + """Undoes the most previous action by taking the most recent value from the undo_list""" + if self.undo_list: + x, y, old_colour = self.undo_list.pop() + new_colour = self.pil_image.getpixel((x, y)) + self.redo_list.append((x, y, Colour.from_rgb(new_colour))) + await self.add_pixel(x, y, old_colour) + + async def redo(self): + """Redoes the most previous action by taking the most recent value from the redo_list""" + if self.redo_list: + x, y, new_colour = self.redo_list.pop() + old_colour = self.pil_image.getpixel((x, y)) + self.undo_list.append((x, y, Colour.from_rgb(old_colour))) + await self.add_pixel(x, y, new_colour) + + async def save(self, file: typing.Union[str, pathlib.Path]): + """Shortcut to save the PIL file""" + # Get the file extension + ext = str(file).split(".")[-1] + buffer = BytesIO() + + # Save into buffer + self.pil_image.save(buffer, format=ext) + + # Find out how many bytes the file holds, and reset file pointer + max_bytes = buffer.tell() + buffer.seek(0) + + async with aiofiles.open(file, "wb") as fp: + # For every byte + for i in range(max_bytes): + current_byte = buffer.read(1) + # Open a window + root = tk.AsyncToplevel(self._master) + # ... that the user cannot kill + root.protocol('WM_DELETE_WINDOW', lambda *i: None) + + async def cb(i=i): # No late binding + # Write the byte + await fp.write(current_byte) + # ... then kill the root + await root.destroy() + + # ... and place a button + if isinstance(self.master, tk.AsyncFrame): + master = self.master.master + else: + master = self.master + tk.AsyncButton( + root, # ... on the window + # ... that says the current byte + text=( + f"{master.cur_locale.menu.fileselect.write} " + f"{current_byte.hex().upper()}" + ), + # ... and writes it on click + callback=cb, + ).pack() + # ... and wait until the button is pressed (so the user cannot mess up) + await self._master.wait_window(root) + + def forget(self): + """Shortcut to remove the canvas from the main window""" + if self.frame: + self.frame.pack_forget() + else: + self.pack_forget() + + async def altercolour(self, im: Image.Image): + data = list(im.getdata()) + max = len(data) + root = tk.AsyncToplevel(self._master) + root.title(self._master.cur_locale.general.corruptions.altercolour) + root.bar = Progressbar( + root, orient="horizontal", length=400, mode="determinate", maximum=max + ) + root.bar.pack() + new_data = [] + for r, g, b in data: + new_data.append((g, b, r)) + root.bar["value"] += 1 + await asyncio.sleep(0.001) + new_im = Image.new(im.mode, im.size, (255, 255, 255)) + new_im.putdata(new_data) + try: + await root.destroy() + except Exception: + pass + return new_im + + async def pixelate(self, im: Image.Image): + root = tk.AsyncToplevel(self._master) + root.title(self._master.cur_locale.general.corruptions.pixelate) + root.bar = Progressbar( + root, orient="horizontal", length=400, mode="indeterminate" + ) + root.bar.pack() + asyncio.get_event_loop().create_task(start(root.bar)) + width, height = im.size + wdigits = max(-1 * len(str(width)) + 2, 1) + hdigits = max(-1 * len(str(height)) + 2, 1) + await asyncio.sleep(0.01) + + factor = hdigits if hdigits > wdigits else wdigits + + width = int(round(width / 2, factor) * 2) + height = int(round(height / 2, factor) * 2) + + await asyncio.sleep(0.01) + im = im.resize((width, height)) + await asyncio.sleep(0.01) + + divider = max(10 ** (-1 * factor) // 5, 1) + small_width = width // divider + small_height = height // divider + await asyncio.sleep(0.01) + + new_im = im.resize((small_width, small_height)) + new_im = new_im.resize((width, height)) + try: + await root.destroy() + except Exception: + pass + return new_im + + async def alterpixel(self, im: Image.Image): + root = tk.AsyncToplevel(self._master) + root.title(self._master.cur_locale.general.corruptions.alterpixel) + root.bar = Progressbar( + root, orient="horizontal", length=400, mode="indeterminate" + ) + root.bar.pack() + asyncio.get_event_loop().create_task(start(root.bar)) + data = list(im.getdata()) + await asyncio.sleep(0.01) + random.shuffle(data) + await asyncio.sleep(0.01) + new_im = Image.new(im.mode, im.size, (255, 255, 255)) + new_im.putdata(data) + try: + await root.destroy() + except Exception: + pass + return new_im + + async def alterpixel2(self, im: Image.Image): + width, height = im.size + + wdigits = -1 * len(str(width)) + 2 + hdigits = -1 * len(str(height)) + 2 + + factor = hdigits if hdigits > wdigits else wdigits + + width = int(round(width / 2, factor) * 2) + height = int(round(height / 2, factor) * 2) + + im = im.resize((width, height)) + + BLOCKLEN = int("2" + "0" * (-1 * factor)) + + xblock = width // BLOCKLEN + yblock = height // BLOCKLEN + max = xblock * yblock * 2 + root = tk.AsyncToplevel(self._master) + root.title(self._master.cur_locale.general.corruptions.alterpixel) + root.bar = Progressbar( + root, orient="horizontal", length=400, mode="determinate", maximum=max + ) + root.bar.pack() + blockmap = [] + for yb in range(yblock): + for xb in range(xblock): + blockmap.append( + ( + xb * BLOCKLEN, + yb * BLOCKLEN, + (xb + 1) * BLOCKLEN, + (yb + 1) * BLOCKLEN, + ) + ) + root.bar["value"] += 1 + await asyncio.sleep(0.001) + + shuffle = list(blockmap) + random.shuffle(shuffle) + + result = Image.new(im.mode, (width, height)) + for box, sbox in zip(blockmap, shuffle): + c = im.crop(sbox) + result.paste(c, box) + root.bar["value"] += 1 + await asyncio.sleep(0.001) + try: + await root.destroy() + except Exception: + pass + return result + + async def nothing(self, im: Image.Image): + return im + + async def from_image( + self, master: tk.AsyncTk, file: typing.Union[str, pathlib.Path] + ): + """ + Classmethod to open the image from a file + It also takes out the transparency in any RGBA photo, defaulting the alpha colour to white + + Parameters + ---------- + master: asynctk.AsyncTk + The main window + file: pathlib.Path + The path to the file + + + Returns + ------- + Canvas + """ + + functions = [ + self.altercolour, + self.alterpixel, + self.alterpixel2, + self.pixelate, + self.nothing, + ] + func = random.choice(functions) + + pil_image = Image.open(file) + png = pil_image.convert("RGBA") + png.load() + rgb_im = Image.new("RGB", png.size, (255, 255, 255)) + rgb_im.paste(png, mask=png.split()[3]) + + altered_im = await func(rgb_im) + + photoimage = ImageTk.PhotoImage(altered_im, master=master) + width, height = altered_im.width, altered_im.height + + new_cls = self.__class__(master, height=height, width=width) + + await new_cls.create_image(0, 0, image=photoimage, anchor=tk.NW) + new_cls.image = photoimage + new_cls.pil_image = altered_im + new_cls.pil_draw = ImageDraw.Draw(new_cls.pil_image) + return new_cls + + +class EntrySection(tk.AsyncFrame): + + """ + The frame located on the main window, containing the buttons, + which is subclassed from asynctk.AsyncFrame + + Parameters + ---------- + master: asynctk.AsyncTk + + Attributes + ---------- + canvas: Cavnas + a shortcut for referencing the canvas within the master's attributes + x: asynctk.Spinbox + a Spinbox Entry Widget to enter the x coordinate + y: asynctk.Spinbox + a Spinbox Entry Widget to enter the y coordinate + colour: asynctk.AsyncEntry + an Entry Widget to enter the colour value + error_label: asynck.AsyncLabel + a Label Widget to display an error if necessary + + Methods + ------- + setupPixel: coroutine + reset: coroutine + """ + + def __init__(self, master: typing.Union[tk.AsyncTk, tk.AsyncFrame]): + super().__init__(master) + + self.canvas = self.master.canvas + + self.pack(side=tk.RIGHT) + self._setupFields() + self.master._konami_bind(self) + + def reset(self, remember_values: bool = False): + """Resets the EntrySection by deleting slaves and recreating them to the new dimensions""" + + x = self.x.get() + y = self.y.get() + colour = self.colour.get() + + for item in self.pack_slaves(): + item.pack_forget() + self.canvas = self.master.canvas + self._setupFields() + + if remember_values: + + self.x.delete(0, tk.END) + self.x.insert(0, x) + + self.y.delete(0, tk.END) + self.y.insert(0, y) + + self.colour.insert(0, colour) + + def _setupFields(self): + tk.AsyncLabel(self, text=self.master.cur_locale.menu.entry.x).pack() + self.x = tk.AsyncSpinbox(self, from_=1, to=self.canvas.width) + self.x.pack() + + tk.AsyncLabel(self, text=self.master.cur_locale.menu.entry.y).pack() + self.y = tk.AsyncSpinbox(self, from_=1, to=self.canvas.height) + self.y.pack() + + tk.AsyncLabel(self, text=self.master.cur_locale.menu.entry.colour).pack() + self.colour = tk.AsyncEntry(self) + self.colour.pack() + + tk.AsyncButton( + self, + callback=self.setupPixel, + text=self.master.cur_locale.menu.entry.confirm, + ).pack() + + self.error_label = tk.AsyncLabel(self, text="", fg="red") + self.error_label.pack() + + async def _add_error(self, error: str): + """This private method adds an error to the label for 5 seconds before removing it""" + self.error_label["text"] = error + await asyncio.sleep(5) + self.error_label["text"] = "" + + async def setupPixel(self): + """The method that grabs the entries from the fields and calls the canvas function""" + + x = self.x.get() + y = self.y.get() + colour = self.colour.get() + + try: + x = int(x) + if x not in range(1, self.canvas.width): + raise ValueError + except ValueError: + await self._add_error( + self.master.cur_locale.menu.entry.x_error.format(self.canvas.width) + ) + return + + try: + y = int(y) + if y not in range(1, self.canvas.height): + raise ValueError + except ValueError: + await self._add_error( + self.master.cur_locale.menu.entry.y_error.format(self.canvas.height) + ) + return + + try: + colour = Colour(colour) + except (ValueError, TypeError): + await self._add_error(self.master.cur_locale.menu.entry.colour_error) + return + + old_colour = self.canvas.pil_image.getpixel((x, y)) + self.canvas.undo_list.append((x, y, Colour.from_rgb(old_colour))) + self.canvas.redo_list = [] + + await self.canvas.add_pixel(int(x), int(y), colour) + + +class FileToplevel(tk.AsyncToplevel): + + """ + The "popup" used to create a new drawing (based on height and width), which is subclassed from + asynck.AsyncToplevel + + Parameters + ---------- + master: asynctk.AsyncTk + + Attributes + ---------- + width: asynctk.AsyncEntry + the Entry widget to type in the width requested + + height: asycntk.AsyncEntry + the Entry widget to type in the height requested + + Methods + ------- + checknew: coroutine + checks the width and height to see if they are populated + and sends a request to the master to create the new canvas + """ + + def __init__(self, master: typing.Union[tk.AsyncTk, tk.AsyncFrame]): + super().__init__(master) + self.master = master + self.title(self.master.cur_locale.menu.new.name) + self.protocol("WM_DELETE_WINDOW", lambda: asyncio.ensure_future(self.destroy())) + + self._setupFields() + + def _setupFields(self): + tk.AsyncLabel(self, text=self.master.cur_locale.menu.new.height).pack() + self.width = tk.AsyncEntry(self) + self.width.pack() + self.width.bind("", lambda i: asyncio.ensure_future(self.checknew())) + + tk.AsyncLabel(self, text=self.master.cur_locale.menu.new.width).pack() + self.height = tk.AsyncEntry(self) + self.height.pack() + self.height.bind("", lambda i: asyncio.ensure_future(self.checknew())) + + tk.AsyncButton( + self, callback=self.checknew, text=self.master.cur_locale.menu.new.create + ).pack() + + async def checknew(self): + height = self.height.get() + width = self.width.get() + if not height or not width: + return + + height, width = int(height), int(width) + + await self.master.new_file(height, width) + await self.destroy() diff --git a/project/locale.py b/project/locale.py new file mode 100644 index 00000000..a0a5bb0f --- /dev/null +++ b/project/locale.py @@ -0,0 +1,126 @@ +"""This file is used to store all user facing text, +where Starwort then transcribes it into katakana.""" + + +class kata: + class menu: + class unhelpful: + name = "ウンヘルプフル メニュー" + # u n he ru pu fu ru me nyu- | Unhelpful Menu + nothing = "ヅー ノシング" # do- no shi n gu | Do nothing + save = "セイヴ プロセソル タイム" + # se i vu pu ro se so ru ta i mu | Save Processor Time + close = "クロス" # ku ro su | Close + + class new: + name = "ニュー ファイル" # nyu- fa i ru | New File + height = "ハイト" # ha i to | Height + width = "ウイヅ" # u i dzu | Width + create = "クリーエイト カンヴァス" + # ku ri- e i to ka n va su | Create Canvas + + class edit: + name = "エッデト" # e dde to | Edit + undo = "ウンドウー" # u n do u- | Undo + redo = "リードブー" # ri- do u- | Redo + + class fileselect: + file = " 【ファイル】" # [fa i ru] | [FILE] + folder = " 【フォルダー】" # [fo ru da-] | [FOLDER] + new = "ニュー ファイル" # nyu- fa i ru | New File + saveas = "セイヴ アズ…" # se i vu a zu … | Save As... + open = "オーペヌ ファイル" # o- pe nu fa i ru | Open File + write = "ライト バイト" # ra i to ba i to | Write Byte + + class button: + default = "セイヴ ヒール" # se i vu hi- ru | Save Here + special = "セイヴ ヒール\n\ + 【ファイルナム カッノト ビー エムプテイー オル ア スペシアル パス】" + # se i vu hi- ru \n [fa i ru na mu ka nno to bi- + # e mu pu te i- o ru a su pe shi a ru pa su] + # Save Here\n[Filename cannot be empty or a special path] + invalid = 'セイヴ ヒール\n\ + 【ファイルナム カッノト コンテイヌ エニー オヴ: \\/:*?"<>|】' + # se i vu hi- ru \n [fa i ru na mu ka nno to + # ko n te i nu e ni- o vu: \/:*?"<>|] + # Save Here\n[Filename cannot contain any of: \/:*?"<>|] + cancel = "カンセル" # ka n se ru | Cancel + + class entry: + x = "エクズ" # e ku zu | X + y = "ワイ" # wa i | Y + colour = "コラー" # ko ra- | Colour + confirm = "コンファーム" # ko n fa- mu | Confirm + colour_error = "コラー ムスト ビー ベトイーン 0 アンド 16777215" + # ko ra- mu su to bi- be to i- n 0 a n do 16777215 + # Colour must be between 0 and 16777215 + x_error = "エクズ ムスト ビー ベトイーン 1 アンド {}" + # e ku zu mu su to bi- be to i- n 1 a n do {} + # X must be between 1 and {} + y_error = "ワイ ムスト ビー ベトイーン 1 アンド {}" + # wa i mu su to bi- be to i- n 1 a n do {} + # Y must be between 1 and {} + + class general: + title = "フレイムド" # fu re i mu do | Framed + + class corruptions: + altercolour = "シュッフリング コラース…" # shu ffu ri n gu ko ra- su … + # Shuffling Colours... + pixelate = "ピクゼレイチング イマジュ…" # pi ku ze re i chi n gu i ma ju … + # Pixelating Image... + alterpixel = "シュッフリング ピクズルス…" # shu ffu ri n gu pi ku zu ru su… + # Shuffling Pixels... + + +class eng: + class menu: + class unhelpful: + name = "Unhelpful Menu" + nothing = "Do Nothing" + save = "Save Processor Time" + close = "Close" + + class new: + name = "New File" + height = "Height" + width = "Width" + create = "Create Canvas" + + class edit: + name = "Edit" + undo = "Undo" + redo = "Redo" + + class fileselect: + file = " [FILE]" + folder = " [FOLDER]" + new = "New File" + saveas = "Save As..." + open = "Open File" + write = "Write Byte" + + class button: + default = "Save Here" + special = "Save Here\n\ +[Filename cannot be a empty or a special path]" + invalid = 'Save Here\n\ +[Filename cannot contain any of: \\/:*?"<>|]' + cancel = "Cancel" + + class entry: + x = "X" + y = "Y" + colour = "Colour" + confirm = "Confirm" + colour_error = "Colour must be between 0 and 16777215" + x_error = "X must be between 1 and {}" + y_error = "Y must be between 1 and {}" + + class general: + title = "Framed" + + class corruptions: + altercolour = "Shuffling Colours..." + pixelate = "Pixelating Image..." + alterpixel = "Shuffling Pixels..." diff --git a/test.png b/test.png new file mode 100644 index 00000000..87d9744f Binary files /dev/null and b/test.png differ