diff --git a/.flake8 b/.flake8 index f2b41384..ce02b1f3 100644 --- a/.flake8 +++ b/.flake8 @@ -1,5 +1,6 @@ +[flake8] max-line-length=100 -application_import_names=projectt +application_import_names=project ignore=P102,B311,W503,E226,S311,W504,F821 exclude=__pycache__, venv, .venv, tests import-order-style=pycharm diff --git a/.gitignore b/.gitignore index 894a44cc..ee759a47 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ venv.bak/ # mypy .mypy_cache/ + +# pycharm +.idea/ diff --git a/Pipfile b/Pipfile index 72b70b6f..4a095f3c 100644 --- a/Pipfile +++ b/Pipfile @@ -7,9 +7,12 @@ verify_ssl = true flake8 = "*" [packages] +nltk = "*" +playsound = "*" [requires] python_version = "3.7" [scripts] -lint = "python -m flake8" \ No newline at end of file +lint = "python -m flake8" +start = "python -m project" \ No newline at end of file diff --git a/Pipfile.lock b/Pipfile.lock index 79354a3c..aba39d7d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "a376db0bd471e38a7080cd854c46349b46922db98afeaf83d17b84923fbe9710" + "sha256": "7c12ec604e7bc0feb6b4db66b578d28030fa5acc269531954727474297b8be0f" }, "pipfile-spec": 6, "requires": { @@ -15,7 +15,36 @@ } ] }, - "default": {}, + "default": { + "nltk": { + "hashes": [ + "sha256:286f6797204ffdb52525a1d21ec0a221ec68b8e3fa4f2d25f412ac8e63c70e8d" + ], + "index": "pypi", + "version": "==3.4" + }, + "playsound": { + "hashes": [ + "sha256:1e83750a5325cbccee03d6e751ba3e78c037ac95b95a3ba1f38d0c5aca9e1a34" + ], + "index": "pypi", + "version": "==1.2.2" + }, + "singledispatch": { + "hashes": [ + "sha256:5b06af87df13818d14f08a028e42f566640aef80805c3b50c5056b086e3c2b9c", + "sha256:833b46966687b3de7f438c761ac475213e53b306740f1abfaa86e1d1aae56aa8" + ], + "version": "==3.4.0.3" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" + } + }, "develop": { "entrypoints": { "hashes": [ @@ -26,11 +55,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 +77,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..c9222284 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,56 @@ # Code Jam IV: This app hates you! -The theme for this code jam will be **This app hates you!**. You will be creating an application using a GUI library of your choice in Python. The application must serve a real purpose, but must also fit the theme. - -You can use any GUI library that you wish to use, but you have to make _a desktop app_. For example, you may use frameworks like PySide, PyQt, tkinter, or wxPython. You can even use stuff like Kivy or PyGame, although we do not recommend that you do. You may not, however, use webframeworks like Django or Flask, and you may not use anything that turns HTML and CSS into a desktop app that runs as a browser. +# Project Information -Here are a couple of examples of what we mean by an application that "serves a real purpose but also fits the theme": -* A calculator app that calculates the right answers, but represents the answer in a way that's completely impractical. -* An image resizer where you have to specify which part of the image to resize, specify how much force to apply to the resize operation in newtons, and then manually resize the image by turning a crank. -* An alarm clock app that plays a very loud sound effect every 5 minutes reminding you that your alarm will ring in 6 hours. The closer it gets to the 6 hour mark, the lower the volume of the sound effect. When the time is up, the sound effect is virtually inaudible. +### SMH Editor -Remember that teamwork is not optional for our code jams - You must find a way to work together. For this jam, we've assigned a leader for each team based on their responses to the application form. Remember to listen to your leader, and communicate with the rest of your team! +Team Members: +- LargeKnome (Leader) +- Hanyuone +- Meta -**Remember to provide instructions on how to set up and run your app at the bottom of this README**. +## Description -# Tips +The **S**mart **M**odern **H**elpful editor delivers a text editing experience +tailored to the most discerning of connoisseurs. Its distraction free, +minimalistic interface was lovingly crafted to provide the ultimate in focus +and comfort. There is no better way to spend time with a keyboard at your +fingertips. -* Please lint your code, and listen to the linter. We recommend **flake8**, and you can use `pipenv run lint` to run it. We will be evaluating your style, and unlinted code will lead to point deductions. -* Remember to work closely with the rest of your team. We will deduct points for poor teamwork. -* Don't overcomplicate this. It's better to write a relatively simple app that is 100% feature complete than failing to finish a more ambitious project. -* For information on how the Code Jam will be judged, please see [this document](https://wiki.pythondiscord.com/wiki/jams/judging). +**_Smart_** - Its highly advanced spell checker will take your mind off of the +pesky pitfalls of the English language, leaving you free to focus solely on +sharing the light of your creativity with the world. -# Setting Up +**_Modern_** - The sleek, streamlined, look is intuitive and straightforward. +It evokes the editors of old, from a time when life was quieter and easier to +understand. No messing around with tangly ribbons and dizzying radial menus +here. -You should be using [Pipenv](https://pipenv.readthedocs.io/en/latest/). Take a look -[at the documentation](https://pipenv.readthedocs.io/en/latest/) if you've never used it before. In short: +**_Helpful_** - The inbuilt personal assistant is always on hand to help you +avoid common text editing disasters. Our highly skilled team of designers, +programmers and social psychologists have laboured tirelessly to deliver you +the true goldilocks zone of quality. -* Setting up for development: `pipenv install --dev` -* Running the application (assuming you use our project layout): `pipenv run start` +## Setup & Installation -# Project Information +- Clone repo, open root directory. +- `pipenv install` +- `pipenv run start` +- Wait a bit- it might take a while to start up. -`# TODO` +## How do I use this thing? -## Description +Same as you'd use any other text editor. Just open it up and type away. +Let the editor guide you towards new highs of creative bliss. -`# TODO` +## Credits -## Setup & Installation +A picture used in the program is an alteration of +`https://commons.wikimedia.org/wiki/File:Chestnut_horse_head,_all_excited.jpg` +released under the Creative Commons Attribution-Share Alike 2.0 Generic license. -`# TODO` +An audio file used in the program is courtesy of +`https://freesound.org/people/n_audioman/sounds/321947/` +released under the Creative Commons Attribution Noncommercial License. -## How do I use this thing? -`# TODO` diff --git a/project/__init__.py b/project/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project/__main__.py b/project/__main__.py index e69de29b..7b2b4325 100644 --- a/project/__main__.py +++ b/project/__main__.py @@ -0,0 +1,13 @@ +import tkinter as tk +from project.windows.editor_window import EditorWindow + +if __name__ == '__main__': + root = tk.Tk() + + # Hide root window. + root.withdraw() + + # Create editor window. + editor_window = EditorWindow(root) + + root.mainloop() diff --git a/project/functionality/__init__.py b/project/functionality/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project/functionality/constants.py b/project/functionality/constants.py new file mode 100644 index 00000000..5f41883d --- /dev/null +++ b/project/functionality/constants.py @@ -0,0 +1,35 @@ +from pathlib import Path + + +class Constants: + """ + This class is intended to house constant values used throughout the + program. + """ + + # The path to the resources directory of the project. + resources_path = Path(__file__).parents[1]/'resources' + + # The name of the program. + program_name = 'SMH Editor' + + # These values are used to determine whether a key press should count as + # text input or not in EditorWindow.on_key_press. They're used to check + # whether Ctrl or Alt were held during the key press. + forbidden_flags = ( + 0x04, # Ctrl + 0x20000 # Alt + ) + + # The path to the picture of Cloppy used in CloppyWindow + cloppy_picture_path = str(resources_path/'cloppy.png') + + # The path to the audio of Cloppy used in CloppyWindow + cloppy_sound_path = str( + resources_path/'321947__n-audioman__horseneigh02-03.wav' + ) + + # Cloppy's greeting to the user shown in CloppyWindow + cloppy_greeting = ( + f"Hi, I'm Cloppy, your {program_name} personal assistant!" + ) diff --git a/project/functionality/events.py b/project/functionality/events.py new file mode 100644 index 00000000..21a0e938 --- /dev/null +++ b/project/functionality/events.py @@ -0,0 +1,30 @@ +from typing import Callable + + +class Event: + """ + This class represents an event emitter to which callbacks can be assigned. + + Example: + def test_callback(value): + print(value) + + new_event = Event() + new_event.add_callback(test_callback) + new_event('test input') + + # This will output 'test input' + """ + + def __init__(self): + self.callbacks = set() + + def add_callback(self, callback: Callable): + self.callbacks.add(callback) + + def take_callback(self, callback: Callable): + self.callbacks.remove(callback) + + def __call__(self, *args, **kwargs): + for callback in self.callbacks: + callback(*args, **kwargs) diff --git a/project/functionality/utility.py b/project/functionality/utility.py new file mode 100644 index 00000000..80dff28d --- /dev/null +++ b/project/functionality/utility.py @@ -0,0 +1,24 @@ +import itertools + + +def pairwise(iterable): + """ + Steps through an iterable while looking ahead + one item every iteration. + + Modified from the implementation here: + https://stackoverflow.com/a/5434936/10444096 + + Example: + for current, next in pairwise([1,2,3,4]): + print(current, next) + + this would output: + 1 2 + 2 3 + 3 4 + 4 None + """ + a, b = itertools.tee(iterable) + next(b, None) + return itertools.zip_longest(a, b) diff --git a/project/resources/321947__n-audioman__horseneigh02-03.wav b/project/resources/321947__n-audioman__horseneigh02-03.wav new file mode 100644 index 00000000..b66a7d1b Binary files /dev/null and b/project/resources/321947__n-audioman__horseneigh02-03.wav differ diff --git a/project/resources/cloppy.png b/project/resources/cloppy.png new file mode 100644 index 00000000..1aa59224 Binary files /dev/null and b/project/resources/cloppy.png differ diff --git a/project/resources/cloppy.xcf b/project/resources/cloppy.xcf new file mode 100644 index 00000000..176e022a Binary files /dev/null and b/project/resources/cloppy.xcf differ diff --git a/project/resources/cloppy_2.png b/project/resources/cloppy_2.png new file mode 100644 index 00000000..4897f7b7 Binary files /dev/null and b/project/resources/cloppy_2.png differ diff --git a/project/resources/cloppy_2.xcf b/project/resources/cloppy_2.xcf new file mode 100644 index 00000000..cd613f0f Binary files /dev/null and b/project/resources/cloppy_2.xcf differ diff --git a/project/spelling/__init__.py b/project/spelling/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project/spelling/consonants.py b/project/spelling/consonants.py new file mode 100644 index 00000000..77ac7a85 --- /dev/null +++ b/project/spelling/consonants.py @@ -0,0 +1,255 @@ +search = { + "M": ["chm", "gm", "lm", "tm", "m", "mm", "mb", "mbe", "me", "mh", "mme", + "mn"], + + "N": ["cn", "dn", "gn", "gne", "kn", "ln", "mn", "mp", "pn", "sne", "n", + "nn", "nd", "ne", "ng", "nh", "nne", "nt"], + + "NG": ["ng", "n", "nc", "nd", "ngh", "ngue"], + + "P": ["gh", "p", "pp", "pe", "ph", "ppe"], + + "B": ["pb", "b", "bb", "be", "bh"], + + "T": ["ght", "bt", "cht", "ct", "d", "ed", "pt", "t", "tt", "te", "th", + "tte", "tw"], + + "D": ["ed", "ld", "t", "d", "dd", "de", "dh"], + + "K": ["c", "cc", "cch", "ch", "ck", "cq", "cqu", "cque", "cu", "lk", "q", + "qu", "que", "x", "k", "ke", "kh", "kk"], + + "G": ["ckg", "g", "gg", "gge", "gh", "gu", "gue"], + + "S": ["c", "cc", "ce", "ps", "ts", "tsw", "tz", "z", "s", "ss", "sc", + "sce", "se", "sse", "st", "sth", "sw"], + + "Z": ["s", "cz", "sc", "se", "sp", "ss", "sth", "ts", "tz", "x", "z", "zz", + "ze"], + + "SH": ["sh", "sch", "ti", "ch", "c", "ce", "che", "chi", "chsi", "ci", "s", + "sc", "sci", "she", "shi", "si", "ss", "ssi"], + + "ZH": ["g", "ge", "j", "s", "si", "ti", "z", "zh", "zi"], + + "F": ["lf", "phe", "pph", "f", "ph", "ff", "fe", "ffe", "gh", "v", "ve"], + + "V": ["lve", "v", "vv", "f", "ph", "ve", "w"], + + "TH": ["chth", "phth", "tth", "th", "the"], + + "DH": ["th", "the"], + + "Y": ["y", "i", "j", "ll", "r"], + + "HH": ["wh", "ch", "h", "j", "x"], + + "R": ["wr", "r", "rr", "l", "re", "rh", "rre", "rrh", "rt"], + + "L": ["l", "ll", "le", "lh", "lle"], + + "W": ["w", "ww", "ou", "u", "o", "we", "wh"], + + "CH": ["tch", "t", "tche", "te", "th", "ti", "ts", "tsch", "tsh", "tz", + "tzs", "tzsch", "ch", "c", "cc", "che", "chi", "cz"], + + "JH": ["ch", "d", "dg", "dge", "di", "dj", "g", "j", "ge", "gg", "gi", + "jj", "t"], + + "KS": ["x", "cks", "lks", "ks", "xx", "cast", "cc", "chs", "cques", "cs", + "cz", "kes", "ques", "xc", "xe", "xs", "xsc", "xsw"], + + "KW": ["cqu", "qu"] +} + +replace_start = { + 'B': {'b': 7287, 'be': 1538, 'bh': 15}, + + 'S': {'s': 9409, 'se': 1190, 'c': 370, 'ce': 279, 'ps': 53, 'sc': 45, + 'sce': 12, 'sw': 7, 'ss': 1, 'ts': 1}, + + 'K': {'c': 7032, 'k': 2933, 'ke': 604, 'cu': 364, 'ch': 231, 'kh': 40, + 'q': 15, 'qu': 12, 'que': 1}, + + 'CH': {'ch': 548, 'che': 241, 'chi': 215, 'c': 130, 'cz': 18, 'tsch': 6, + 't': 4, 'te': 3, 'tch': 1, 'ts': 1}, + + 'SH': {'sh': 816, 'sch': 694, 'she': 265, 'shi': 225, 's': 192, 'ch': 91, + 'che': 27, 'chi': 9, 'sci': 5, 'ci': 1, 'si': 1}, + + 'HH': {'h': 5773, 'wh': 36, 'j': 11, 'ch': 2}, + + 'Z': {'z': 626, 'ze': 200, 'x': 46, 'cz': 4}, + + 'D': {'d': 4464, 'de': 2355, 'ed': 153, 'dh': 14, 't': 3}, + + 'JH': {'j': 1326, 'ge': 374, 'gi': 197, 'g': 52, 'd': 13, 'dj': 4, 't': 2}, + + 'F': {'f': 4092, 'fe': 590, 'ph': 276, 'phe': 29}, + + 'G': {'g': 4023, 'gu': 445, 'gh': 51, 'gue': 50}, + + 'ZH': {'j': 28, 'ge': 15, 'g': 15, 'zh': 8, 'z': 4, 'si': 1, 'zi': 1}, + + 'N': {'n': 1994, 'ne': 753, 'kn': 208, 'gn': 27, 'pn': 6, 'mn': 2, + 'gne': 1, 'ng': 1}, + + 'Y': {'y': 642, 'j': 131, 'i': 4}, + + 'L': {'l': 4036, 'le': 1102, 'll': 14, 'lh': 3}, + + 'M': {'m': 7436, 'me': 1141, 'mm': 1}, 'NG': {'ng': 2}, + + 'W': {'w': 2435, 'we': 774, 'wh': 338, 'o': 15, 'ou': 5, 'u': 4}, + + 'P': {'p': 5724, 'pe': 1205}, + + 'T': {'t': 3633, 'te': 801, 'th': 28, 'pt': 8, 'tw': 6}, + + 'KW': {'qu': 378}, 'R': {'r': 3740, 're': 2571, 'wr': 120, 'rh': 100}, + + 'TH': {'th': 467, 'the': 123}, + + 'DH': {'the': 34, 'th': 22}, + + 'V': {'v': 1672, 've': 480, 'w': 52}, + + 'KS': {'x': 9, 'xe': 1, 'xs': 1} +} + +replace_middle = { + 'B': {'b': 7235, 'be': 2119, 'bb': 474, 'pb': 7, 'bh': 7}, + + 'K': {'c': 7507, 'k': 3617, 'ck': 1871, 'ke': 1454, 'ch': 1112, 'cc': 547, + 'cu': 349, 'lk': 81, 'que': 61, 'cch': 59, 'kk': 54, 'qu': 34, + 'kh': 25, 'q': 12, 'cque': 10, 'cqu': 2}, + + 'N': {'n': 31628, 'ne': 3955, 'nn': 748, 'nne': 488, 'kn': 80, 'gn': 72, + 'ln': 34, 'gne': 21, 'nh': 19, 'nd': 9, 'pn': 5, 'nt': 3, 'dn': 3, + 'mp': 2, 'ng': 1}, + + 'L': {'l': 22397, 'le': 5217, 'll': 3779, 'lle': 934, 'lh': 12}, + + 'S': {'s': 14531, 'se': 1606, 'c': 1318, 'ss': 1304, 'ce': 1249, + 'sse': 550, 'z': 248, 'st': 116, 'sce': 94, 'sc': 81, 'ts': 22, + 'sw': 10, 'cc': 1, 'sth': 1, 'ps': 1}, + + 'M': {'m': 11571, 'me': 2458, 'mm': 515, 'mme': 320, 'lm': 155, + 'chm': 102, 'mb': 67, 'gm': 45, 'tm': 41, 'mbe': 15, 'mn': 10, + 'mh': 2}, + + 'T': {'t': 22200, 'te': 5279, 'tt': 1174, 'tte': 605, 'ght': 378, 'd': 208, + 'bt': 29, 'th': 23, 'cht': 10, 'ct': 8, 'pt': 5}, + + 'R': {'r': 26657, 're': 4433, 'rr': 1173, 'rre': 292, 'wr': 90, 'l': 69, + 'rt': 6, 'rh': 4, 'rrh': 1}, + + 'D': {'d': 9038, 'de': 3089, 'ed': 1684, 'dd': 442, 't': 107, 'ld': 25, + 'dh': 20}, + + 'V': {'v': 3685, 've': 2715, 'w': 96, 'vv': 8, 'ph': 6, 'f': 2}, + + 'SH': {'ti': 1747, 'sh': 702, 'shi': 393, 'she': 392, 'ci': 281, 's': 205, + 'sch': 187, 'ssi': 151, 'che': 75, 'si': 57, 'ch': 48, 'ss': 41, + 'ce': 25, 'chi': 24, 'sci': 6, 'c': 5, 'sc': 3}, + + 'HH': {'h': 2297, 'ch': 40, 'wh': 18, 'j': 13, 'x': 1}, + + 'Z': {'s': 2704, 'z': 1724, 'se': 781, 'ze': 623, 'zz': 192, 'ss': 24, + 'x': 6, 'sth': 2, 'sc': 1}, + + 'F': {'f': 3606, 'fe': 729, 'ff': 635, 'ph': 563, 'ffe': 359, 'phe': 138, + 'gh': 57, 'lf': 41, 'v': 11, 'pph': 1}, + + 'TH': {'th': 1115, 'the': 310, 'tth': 11}, + + 'JH': {'ge': 970, 'gi': 720, 'j': 533, 'g': 289, 'dge': 169, 'd': 115, + 'dg': 89, 'dj': 51, 'gg': 33, 't': 8, 'di': 8, 'ch': 1}, + + 'G': {'g': 5728, 'gg': 394, 'gu': 248, 'gge': 122, 'gh': 88, 'gue': 87}, + + 'NG': {'n': 2202, 'ng': 1477, 'nc': 35, 'ngh': 11, 'ngue': 4, 'nd': 2}, + + 'Y': {'i': 579, 'y': 329, 'r': 138, 'j': 118, 'll': 3}, + + 'P': {'p': 6606, 'pe': 1765, 'pp': 631, 'ppe': 426, 'ph': 18, 'gh': 3}, + + 'CH': {'ch': 492, 't': 490, 'che': 370, 'c': 349, 'chi': 205, 'tche': 191, + 'cc': 147, 'tch': 128, 'ti': 106, 'cz': 90, 'tsch': 27, 'tsh': 19, + 'te': 13, 'ts': 7, 'th': 2, 'tzs': 1, 'tzsch': 1}, + + 'W': {'w': 1945, 'we': 515, 'u': 402, 'o': 196, 'wh': 43, 'ou': 26}, + + 'ZH': {'si': 205, 's': 82, 'ge': 29, 'j': 24, 'g': 12, 'z': 8, 'zh': 4, + 'zi': 3, 'ti': 2}, + + 'KS': {'x': 1062, 'xe': 139, 'ks': 127, 'cks': 108, 'cc': 89, 'xc': 49, + 'cs': 37, 'chs': 31, 'xs': 26, 'kes': 24, 'xx': 4, 'lks': 4, + 'cz': 2, 'cques': 1}, + + 'DH': {'the': 319, 'th': 104}, + + 'KW': {'qu': 555, 'cqu': 64} +} + +replace_end = { + 'G': {'g': 840, 'gg': 62, 'gue': 53, 'gh': 28, 'gge': 20, 'gu': 6}, + + 'N': {'n': 11812, 'ne': 1494, 'nn': 365, 'nne': 78, 'gn': 26, 'nh': 12, + 'gne': 6, 'nt': 3, 'ln': 2}, + + 'TH': {'th': 603, 'the': 16}, + + 'R': {'r': 969, 're': 685, 'rr': 54, 'rre': 30, 'rt': 1}, + + 'K': {'k': 1590, 'ck': 1151, 'c': 1148, 'ke': 541, 'ch': 393, 'que': 63, + 'lk': 36, 'q': 17, 'kh': 11, 'cq': 8, 'cque': 4, 'cu': 2, 'qu': 1, + 'kk': 1}, + + 'Z': {'s': 16349, 'z': 362, 'se': 338, 'ze': 311, 'zz': 11, 'ss': 1}, + + 'S': {'s': 5369, 'ss': 945, 'ce': 746, 'z': 481, 'se': 417, 'ts': 74, + 'sse': 67, 'ps': 26, 'c': 18, 'sce': 6, 'tz': 3}, + + 'B': {'b': 268, 'be': 118, 'bb': 28, 'bh': 1}, + + 'D': {'ed': 3901, 'd': 3079, 'de': 503, 'dd': 26, 'ld': 4, 'dh': 3}, + + 'NG': {'ng': 5048, 'ngue': 6, 'ngh': 3}, + + 'T': {'t': 5013, 'te': 1237, 'd': 896, 'tt': 644, 'tte': 298, + 'ght': 181, 'cht': 5, 'bt': 3, 'ct': 1, 'pt': 1}, + + 'L': {'l': 3343, 'le': 1875, 'll': 1355, 'lle': 338}, + + 'SH': {'sh': 453, 'sch': 147, 'shi': 51, 'che': 24, 'she': 7, 's': 1, + 'ss': 1, 'ce': 1, 'ch': 1, 'chi': 1}, + + 'KS': {'x': 403, 'ks': 263, 'cs': 244, 'cks': 180, 'kes': 126, 'chs': 21, + 'lks': 14, 'ques': 12, 'xe': 4, 'xx': 3}, + + 'M': {'m': 1895, 'me': 267, 'mm': 53, 'mb': 53, 'mme': 29, 'lm': 23, + 'mn': 7, 'mbe': 6, 'gm': 2}, + + 'V': {'ve': 580, 'v': 161, 'f': 2, 'w': 1}, + + 'P': {'p': 749, 'pe': 178, 'pp': 124, 'ppe': 29, 'gh': 1}, + + 'CH': {'ch': 333, 'tch': 105, 'cz': 55, 'chi': 46, 'che': 43, 'tsch': 39, + 'c': 8}, + + 'JH': {'ge': 399, 'dge': 160, 'gi': 15, 'j': 11, 'g': 5, 'jj': 1}, + + 'F': {'ff': 417, 'f': 329, 'ph': 63, 'fe': 57, 'ffe': 32, 'gh': 14, 'v': 8, + 'phe': 4, 'lf': 4}, + + 'ZH': {'ge': 17, 'j': 3}, + + 'HH': {'ch': 16, 'h': 1}, + + 'DH': {'the': 40, 'th': 2}, + + 'Y': {'r': 8, 'y': 8, 'i': 2}, + + 'W': {'we': 7} +} diff --git a/project/spelling/correction.py b/project/spelling/correction.py new file mode 100644 index 00000000..050e8615 --- /dev/null +++ b/project/spelling/correction.py @@ -0,0 +1,74 @@ +import project.spelling.data as data + + +words = data.frequency_list() + + +def distance1(word): + """All edits that are one edit away from `word`.""" + letters = "abcdefghijklmnopqrstuvwxyz" + splits = [(word[:i], word[i:]) for i in range(len(word) + 1)] + deletes = [L + R[1:] for L, R in splits if R] + transposes = [L + R[1] + R[0] + R[2:] for L, R in splits if len(R) > 1] + replaces = [L + c + R[1:] for L, R in splits if R for c in letters] + inserts = [L + c + R for L, R in splits for c in letters] + + return set(deletes + transposes + replaces + inserts) + + +def is_correct(word): + return word in words.keys() + + +def correction(word): + """Given a word, offers a correction to that word""" + short_coeff = 50 + long_coeff = 10 + + if is_correct(word): + dist0_freq = words[word] + else: + dist0_freq = 0 + + dist1 = distance1(word) + dist1_word = sorted(dist1, key=lambda w: words[w] if w in words.keys() else 0)[-1] + + if dist1_word in words.keys(): + dist1_freq = words[dist1_word] + else: + dist1_freq = 0 + + dist2_word = "" + dist2_freq = 0 + + for w in dist1: + dist2 = [x for x in distance1(w) if x in words.keys() and x not in dist1] + + if not dist2: + continue + + current_word = sorted(dist2, key=lambda x: words[x])[-1] + current_freq = words[current_word] + + if current_freq > dist2_freq: + dist2_word = current_word + dist2_freq = current_freq + + # No possible corrections - just return the word + if dist0_freq == dist1_freq == dist2_freq == 0: + return word + + if len(word) < 6: + if dist2_freq >= dist1_freq * short_coeff: + return dist2_word + elif dist1_freq >= dist0_freq * short_coeff: + return dist1_word + else: + return word + else: + if dist2_freq >= dist1_freq * long_coeff: + return dist2_word + elif dist1_freq >= dist0_freq * long_coeff: + return dist1_word + else: + return word diff --git a/project/spelling/data.py b/project/spelling/data.py new file mode 100644 index 00000000..983244f2 --- /dev/null +++ b/project/spelling/data.py @@ -0,0 +1,41 @@ +import nltk +import re + + +def corpus_downloaded(name: str) -> bool: + """ + Checks if NLTK contains a certain corpus. + :param name: the name of the corpus + :return: whether that corpus is downloaded or not + """ + try: + nltk.data.find(f"corpora/{name}") + return True + except LookupError: + return False + + +def arpabet(): + if not corpus_downloaded("cmudict"): + nltk.download("cmudict") + + return nltk.corpus.cmudict.dict() + + +def frequency_list(): + """ + Creates `frequency_list.txt in `spelling/data`, which is a list of + words sorted from most common to least common. + """ + # Check if the `brown` corpus is downloaded + if not corpus_downloaded("brown"): + nltk.download("brown") + + # List of words from `brown` + words = nltk.corpus.brown.words() + # Sort those words by how common they are + unfiltered = nltk.FreqDist(i.lower() for i in words).most_common() + # Remove punctuation + freq_list = {i[0]: i[1] for i in unfiltered if re.match(r"[A-Za-z]", i[0])} + + return freq_list diff --git a/project/spelling/misspell.py b/project/spelling/misspell.py new file mode 100644 index 00000000..16f0e742 --- /dev/null +++ b/project/spelling/misspell.py @@ -0,0 +1,212 @@ +import project.spelling.consonants as consonants +import project.spelling.data as data + +import math +import random +import re + + +arpabet = data.arpabet() + + +class Grapheme: + def __init__(self, letters, phoneme=None): + self.letters = letters + self.phoneme = phoneme + + def __str__(self): + return f"" + + +def phonemes(word): + final = ",".join(arpabet[word][0]) + + final = final.replace("K,S", "KS") + final = final.replace("K,W", "KW") + + return final.split(",") + + +def find_grapheme(letters, phoneme): + """ + Given letters, returns the first match of a phoneme, + as well as any "junk" that's behind it. + """ + for i in range(1, len(letters) + 1): + substring = letters[:i] + + for grapheme in consonants.search[phoneme]: + if grapheme in substring: + extra = substring[:len(substring) - len(grapheme)] + return Grapheme(extra), Grapheme(grapheme, phoneme) + + +def find_vowels(grapheme): + letters = grapheme.letters + regex = re.compile(".*?([aeioruy]+)$") + result = regex.match(letters) + + if result is None: + return grapheme, None + else: + vowels = result.group(1) + carry = letters[:len(letters) - len(vowels)] + return Grapheme(carry), Grapheme(vowels) + + +def find_consonant(letters, phoneme): + for i in range(len(letters) + 1, 1, -1): + substring = letters[:i] + + if substring in consonants.search[phoneme]: + return substring + + +def graphemes(word): + """ + Converts a word into its *graphemes* - i.e. separating + the words into letters, with each chunk of letters representing + either a consonant, a vowel or a vowel cluster. + """ + final = [] + word_phonemes = phonemes(word) + + # Iterate over every phoneme in a word + for phoneme in word_phonemes: + # If that phoneme is a consonant: + if phoneme in consonants.search.keys(): + # Find the first grapheme that matches that phoneme, + # and include the extra letters behind it + extra, grapheme = find_grapheme(word, phoneme) + + crop_length = 0 + + # Algorithm to "carry" silent/vowels to consonants to last + # consonant, since the consonant algorithm is lazy and gets the + # shortest string possible. + if len(final) > 0: + consonant = final[-1] + + # Creating a "letter pool" of possible characters that could + # be in the previous vowel + letter_pool = consonant.letters + extra.letters + new_consonant = find_consonant(letter_pool, consonant.phoneme) + + # If we *do* find a new consonant: + if new_consonant is not None: + # The amount of letters copied over from the extra letters + # to the new consonant + letters_from_extra = ( + len(new_consonant) - len(consonant.letters) + ) + # We need to delete more letters from "word" since we're + # editing "extra", so add those letters back again + crop_length += letters_from_extra + # Delete carried letters from "extra" + extra.letters = extra.letters[letters_from_extra:] + # Add the carried letters to the previous consonant + consonant.letters = new_consonant + + # If there are still any letters left in the "extra letters", + # add it to the end as a vowel cluster + if extra.letters != "": + final.append(extra) + + final.append(grapheme) + crop_length += len(extra.letters) + len(grapheme.letters) + # Crop the word since we've already processed some letters + word = word[crop_length:] + else: + continue + + # The last iteration of "carrying" + if len(final) > 0: + consonant = final[-1] + + letter_pool = consonant.letters + word + new_consonant = find_consonant(letter_pool, consonant.phoneme) + + if new_consonant is not None: + letters_from_word = len(new_consonant) - len(consonant.letters) + word = word[letters_from_word:] + consonant.letters = new_consonant + + if word != "": + final.append(Grapheme(word)) + + return final + + +def random_consonant(counter): + total = sum(counter.values()) + random_value = math.floor(random.random() * total) + current = 0 + + for key, value in counter.items(): + if random_value <= current: + return key + + current += value + + +def misspell(word, errors=1): + """ + Given a word, roughly misspell it. + :param errors: Maximum amount of errors in each word + :param word: The word that needs to be misspelled + :return: + """ + final_word = "" + + try: + # Check if the word can be mapped to graphemes + word_graphemes = graphemes(word) + except Exception: + # It can't - this either means the word doesn't have an + # entry in the dictionary or the word just behaves weirdly + # (e.g. "aaa" maps to "triple a" in pronunciation). + # Either way, return the original word. + return word + + misspellings = set() + + for _ in range(errors): + value = int( + random.random() * len([x for x in word_graphemes if x.phoneme]) + ) + misspellings.add(value) + + current_consonant = 0 + + for i in range(len(word_graphemes)): + grapheme = word_graphemes[i] + + # Vowel cluster + if grapheme.phoneme is None: + final_word += grapheme.letters + # Consonant + else: + if current_consonant not in misspellings: + final_word += grapheme.letters + current_consonant += 1 + continue + + if i == 0: + possible = consonants.replace_start[grapheme.phoneme] + elif i == len(word_graphemes) - 1: + possible = consonants.replace_end[grapheme.phoneme] + else: + possible = consonants.replace_middle[grapheme.phoneme] + + possible = { + k: v for k, v in possible.items() if k != grapheme.letters + } + try: + final_word += random_consonant(possible) + + except TypeError: + break + + current_consonant += 1 + + return final_word diff --git a/project/windows/__init__.py b/project/windows/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project/windows/cloppy_spelling_window.py b/project/windows/cloppy_spelling_window.py new file mode 100644 index 00000000..033e0eef --- /dev/null +++ b/project/windows/cloppy_spelling_window.py @@ -0,0 +1,73 @@ +import random + +from project.windows.cloppy_window import CloppyButtonWindow +from project.windows.cloppy_window_events import CloppyChoiceMadeEventData + +from project.windows.editor_window_events import NewWordEventData + +from project.spelling.correction import correction +from project.spelling.misspell import misspell + + +class CloppySpellingWindow(CloppyButtonWindow): + """ + This class represents individual Cloppy windows used to suggest spelling + corrections to the user. + """ + def __init__(self, master, word_data: NewWordEventData): + """ + :param master: The dialog's master widget. Should be an EditorWindow. + :param word_data: Object containing information regarding the starting + index, ending index and contents of the word to be + corrected. + """ + super().__init__(master) + + self.word = word_data.word.lower() + self.start = word_data.start + self.end = word_data.end + + self.set_message( + f"Looks like the word '{word_data.word}' could be a " + "misspelling.\n" + "Below are some suggested corrections.\n" + "If you don't pick one within 10 seconds I'll pick one for you,\n" + "to save you time. :)" + ) + + # Generate a correction to the word. + corrected_word = correction(self.word) + + self.suggestions = [corrected_word] + + # Generate up to 3 misspellings of the corrected word. + for i in range(3): + suggestion = misspell(corrected_word, 3) + if suggestion and suggestion not in self.suggestions: + self.suggestions.append(suggestion) + + # Add all generated suggestions as choices. + for suggestion in random.sample( + self.suggestions, len(self.suggestions) + ): + self.add_choice(suggestion) + + self.set_time_limit(10) + self.choice_made.add_callback(self.replace_word) + + def replace_word(self, choice_data: CloppyChoiceMadeEventData): + """ + Called when the user makes a choice in this dialog. + + :param choice_data: Object containing information about the user's + choice. + """ + self.master.set_text( + choice_data.choice, self.start, self.end + ) + + def time_out(self): + """ + Called when the user fails to select a choice within the time limit. + """ + self.make_choice(random.choice(self.suggestions)) diff --git a/project/windows/cloppy_window.py b/project/windows/cloppy_window.py new file mode 100644 index 00000000..41ba9a8d --- /dev/null +++ b/project/windows/cloppy_window.py @@ -0,0 +1,341 @@ +import tkinter as tk +import traceback +from typing import List +from playsound import playsound +from project.windows.cloppy_window_events import CloppyChoiceMadeEvent +from project.functionality.constants import Constants + + +class CloppyWindow(tk.Toplevel): + """ + This class represents individual 'Cloppy' dialogs. Cloppy is the um... + 'spiritual' equine successor to the infamous MS Word personal assistant + Clippy. And a fine one at that. Neigh sayers be damned. + + By default, CloppyWindow provides no options to get input from the + user. That's left up to subclasses to add themselves, by virtue of the + make_choice method. + """ + + # A reference to the picture used to represent Cloppy. + # Will be initialized upon first use. + cloppy_image = None + + def __init__(self, master): + super().__init__(master) + + # Hide window at first, before it's constructed. + self.withdraw() + + # Make the window uncloseable, unresizable and unmovable. + self.overrideredirect(True) + self.resizable(False, False) + + # Configure grid properties so the elements inside stretch out. + tk.Grid.columnconfigure(self, index=0, weight=1) + + # The message to be displayed to the user. + self.message = None + + # The choice selected by the user. + self.selected_choice = None + + # An event that is fired when the user makes a choice. Call + # choice_made.add_callback to register callbacks for this event. + # + # It sends information to callbacks using a CloppyChoiceMadeEventData + # object, which contains information about the message displayed to + # the user and the choice they made in response to it. + # + # Check cloppy_window_events.py for the definitions of these classes. + self.choice_made = CloppyChoiceMadeEvent() + + # Setting up the label containing the image of Cloppy, along with the + # image data used for this purpose. + if not CloppyWindow.cloppy_image: + CloppyWindow.cloppy_image = tk.PhotoImage( + file=Constants.cloppy_picture_path + ) + + self.cloppy_label = tk.Label(self, image=self.cloppy_image) + self.cloppy_label.grid(row=0, column=0, sticky=tk.NSEW) + + # Setting up the label containing the greeting to be shown to the user. + self.greeting_label = tk.Label(self, text=Constants.cloppy_greeting) + self.greeting_label.grid(row=1, column=0, sticky=tk.NSEW) + + # Setting up the label containing the message to be shown to the user. + self.message_label = tk.Label(self) + self.message_label.grid(row=2, column=0, sticky=tk.NSEW) + + # Setting up a frame which will contain the input gathering widgets + # for this dialog. + self.input_frame = tk.Frame(self) + self.input_frame.grid(row=3, column=0, sticky=tk.NSEW) + tk.Grid.columnconfigure(self.input_frame, index=0, weight=1) + + # Setting up the optional time limit value. + self.time_remaining = None + self.time_remaining_label: tk.Label = None + + def set_message(self, message: str): + """ + Set the message to be displayed to the user in this dialog. + + :param message: The message to display to the user. + """ + self.message = message + self.message_label.config(text=message) + + def update_time_remaining(self): + """ + Called to update the text inside the time limit label. + """ + self.time_remaining_label.config( + text=f'Time remaining: {self.time_remaining} seconds.' + ) + + def countdown_cycle(self): + """ + Called for each cycle of the time limit countdown. + """ + if self.time_remaining > 0: + self.time_remaining -= 1 + self.update_time_remaining() + self.after(1000, self.countdown_cycle) + else: + self.time_out() + + def set_time_limit(self, time_limit): + """ + Adds a time limit to this dialog. + :param time_limit: Time limit in seconds. + """ + if not self.time_remaining: + self.time_remaining = time_limit + self.time_remaining_label = tk.Label(self) + self.time_remaining_label.grid(row=4, column=0, sticky=tk.NSEW) + self.update_time_remaining() + + def time_out(self): + """ + Called when the user doesn't make a choice and the time limit runs out. + """ + try: + self.destroy() + + except tk.TclError: + pass + + def make_choice(self, choice): + """ + Called in order to register that a choice has been made in this dialog. + It will prevent a second choice from being made, as well as fire the + choice_made event and close the window afterwards. + + :param choice: The choice made from this dialog. + """ + if not self.selected_choice: + self.selected_choice = choice + + try: + self.choice_made(self.message, choice) + + except Exception: + traceback.print_exc() + + finally: + try: + self.destroy() + + except tk.TclError: + pass + + def show(self): + """ + Shows the dialog in the middle of the editor window's text box. + All input and focus is redirected to the dialog while it is open. + """ + self.grab_set() + self.focus_set() + + self.update_idletasks() + self.master.update_idletasks() + + x = ( + self.master.text_box.winfo_rootx() + + (self.master.text_box.winfo_width()-self.winfo_width()) // 2 + ) + + y = ( + self.master.text_box.winfo_rooty() + + (self.master.text_box.winfo_height()-self.winfo_height()) // 2 + ) + + self.geometry(f'+{x}+{y}') + self.deiconify() + + playsound(Constants.cloppy_sound_path, block=False) + + if self.time_remaining: + self.after(1000, self.countdown_cycle) + + +class CloppyButtonWindow(CloppyWindow): + """ + A sub-child of CloppyWindow that takes input from the user using a list of + buttons. + """ + def __init__(self, master): + super().__init__(master) + + # A list containing the buttons representing individual choices in the + # dialog. + self.choice_buttons: List[tk.Button] = [] + self.highlighted_choice = 0 + + # Setting up arrow key event bindings. + self.bind('', self.on_up_key) + self.bind('', self.on_down_key) + + # Setting up enter key event binding. + self.bind('', self.on_enter_key) + + def add_choice(self, choice): + """ + Add a new button associated with a choice to the window. + + :param choice: The choice the button represents. + """ + choice_button = tk.Button( + self.input_frame, + text=choice, + command=lambda: self.make_choice(choice) + ) + + row = len(self.choice_buttons) + + choice_button.grid( + row=row, column=0, sticky=tk.NSEW + ) + + tk.Grid.rowconfigure(self, index=row, weight=1) + + self.choice_buttons.append(choice_button) + + def show(self): + super().show() + + # Set focus to the first button in the list. + if len(self.choice_buttons): + self.choice_buttons[0].focus_set() + + def set_highlighted_choice(self, choice): + """ + Set focus to the button at a given index in the list of choices. + + :param choice: The index of the choice to be focused on. + """ + + if choice < 0: + choice = len(self.choice_buttons)-1 + + elif choice >= len(self.choice_buttons): + choice = 0 + + self.highlighted_choice = choice + self.choice_buttons[choice].focus_set() + + def on_up_key(self, event: tk.Event): + """ + Called when the user presses the Up arrow key when focused on the + Cloppy dialog. + + :param event: tkinter event data. + """ + self.set_highlighted_choice(self.highlighted_choice-1) + + def on_down_key(self, event: tk.Event): + """ + Called when the user presses the Down arrow key when focused on the + Cloppy dialog. + + :param event: tkinter event data. + """ + self.set_highlighted_choice(self.highlighted_choice+1) + + def on_enter_key(self, event: tk.Event): + """ + Called when the user presses the Enter key when focused on the + Cloppy dialog. + + :param event: tkinter event data. + """ + self.choice_buttons[self.highlighted_choice].invoke() + + +class CloppyTextInputWindow(CloppyWindow): + """ + A sub-child of CloppyWindow that takes input from the user using a text + entry. + """ + def __init__(self, master, password=False, submit_button_text='Ok'): + """ + :param password: If set to True, then input in the box will be masked + as if it's a password. + + :param submit_button_text: The text shown in the button at the bottom. + When clicked, this button will submit the + user's input as a choice. + """ + super().__init__(master) + + # Setting up the text entry box. + if password: + self.input_box = tk.Entry(self.input_frame, show='*') + else: + self.input_box = tk.Entry(self.input_frame) + + self.input_box.grid(row=0, column=0, sticky=tk.NSEW) + + self.input_box.bind( + '', + lambda e: self.make_choice(self.input_box.get()) + ) + + # Setting up the submit button. + self.submit_button = tk.Button( + self.input_frame, + text=submit_button_text, + command=lambda: self.make_choice(self.input_box.get()) + ) + + self.submit_button.grid(row=1, column=0, sticky=tk.NSEW) + + def show(self): + super().show() + + # Direct focus to the input box. + self.input_box.focus_set() + + +def cloppy_yesno(master, message, callback) -> CloppyButtonWindow: + """ + Convenience function for constructing a basic yes/no Cloppy dialog. + + :param master: The master widget of the dialog. + :param message: Message to display in the dialog. + :param callback: The callback for when a choice is made in this dialog. + """ + + dialog = CloppyButtonWindow(master) + dialog.set_message( + f'{message}\n' + "So it behooves me to ask:\n" + "Are you sure you want to do that?" + ) + dialog.add_choice('Yes') + dialog.add_choice('No') + dialog.choice_made.add_callback(callback) + + return dialog diff --git a/project/windows/cloppy_window_events.py b/project/windows/cloppy_window_events.py new file mode 100644 index 00000000..73d3b5db --- /dev/null +++ b/project/windows/cloppy_window_events.py @@ -0,0 +1,27 @@ +from project.functionality.events import Event + + +class CloppyChoiceMadeEvent(Event): + """ + This class represent an event emitter for cloppy choice events. + These are emitted every time input is received from the user in order to + make a choice in a CloppyWindow. + + It passes data to its callbacks using a CloppyChoiceMadeEventData object. + These will contain information about the question asked and the choice + made by the user in response. + """ + def __call__(self, message, choice): + super().__call__( + CloppyChoiceMadeEventData(message, choice) + ) + + +class CloppyChoiceMadeEventData: + """ + This class is used to propagate information about a choice a user has made + to callbacks registered in a CloppyChoiceMadeEvent object. + """ + def __init__(self, message, choice): + self.message = message + self.choice = choice diff --git a/project/windows/editor_window.py b/project/windows/editor_window.py new file mode 100644 index 00000000..786ab126 --- /dev/null +++ b/project/windows/editor_window.py @@ -0,0 +1,774 @@ +from typing import Tuple + +import tkinter as tk + +from tkinter import filedialog + +from project.functionality.constants import Constants +from project.functionality.utility import pairwise + +from project.windows.editor_window_events import NewWordEvent, NewWordEventData + +from project.windows.cloppy_window import cloppy_yesno, CloppyButtonWindow +from project.windows.cloppy_window_events import CloppyChoiceMadeEventData + +from project.windows.cloppy_spelling_window import CloppySpellingWindow + +from project.spelling.correction import is_correct + + +class EditorWindow(tk.Toplevel): + """ + This class houses the main text editor window. + """ + + def __init__(self, master): + super().__init__(master) + + # Setting up grid options so that the text box stretches. + tk.Grid.rowconfigure(self, index=0, weight=1) + tk.Grid.columnconfigure(self, index=0, weight=1) + + # Setting up the main text entry in the window. + self.text_box = tk.Text(self) + self.text_box.grid(row=0, column=0, sticky=tk.NSEW) + + # Setting up the menu bar at the top. + self.menu_bar = EditorMenuBar(self) + + # Set up scrollbar on the right hand side. + self.scroll_bar = tk.Scrollbar(self, command=self.text_box.yview) + self.text_box.config(yscrollcommand=self.scroll_bar.set) + self.scroll_bar.grid(row=0, column=1, sticky=tk.NS) + + # Setting up the 'new word' event. This gets emitted every time the + # user is detected to have typed a word. + # + # Callbacks can be assigned to be invoked when this event occurs using + # new_word.add_callback(). Data will be passed to the callbacks inside + # a NewWordEventData object. These objects contain information about + # the start index, end index and contents of the typed word. The class + # definitions for NewWordEvent and NewWordEventData are inside + # project/windows/editor_window_events.py. + # + # There is an example of how to work with this event in + # testing/test_editor_window.py + self.new_word = NewWordEvent() + + # Register the on_new_word callback for the new_word event. + self.new_word.add_callback(self.on_new_word) + + # Setting up key press event binding. + self.text_box.bind('', self.on_key_press) + + # Setting up right click event binding. + self.text_box.bind('', self.on_right_click) + + # Setting up editing function key event binding. + self.text_box.bind('', self.on_control_x) + self.text_box.bind('', self.on_control_c) + self.text_box.bind('', self.on_control_v) + + # Setting up saving and opening key event binding. + self.text_box.bind('', self.on_control_s) + self.text_box.bind('', self.on_control_o) + + # Setting up new file key event binding. + self.text_box.bind('', self.on_control_n) + + # Setting up backspace/delete key event binding. + self.text_box.bind('', self.on_backspace) + self.text_box.bind('', self.on_backspace) + + # Set window title. + self.wm_title(Constants.program_name) + + # Setting up window destruction event binding. + self.protocol('WM_DELETE_WINDOW', self.on_destroy) + # self.bind('', lambda e: None) + + def get_text(self, start='1.0', end=tk.END) -> str: + """ + A method other objects can use to retrieve the text inside + the editor window's text box. By default it'll get all text inside the + text box, but start and end values can also be specified. + + :param start: The starting index of the content to retrieve. + :param end: The ending index of the content to retrieve. Pass None to + get a single character. + :return: The text inside the editor window's text box. + """ + + return self.text_box.get(start, end) + + def set_text(self, value, start='1.0', end=tk.END, *tags): + """ + A method other objects can use to alter the text inside the + editor window's text box. By default it'll set all text inside the + text box, but start and end values can also be specified. + + :param value: The value to set the text to. + :param start: The starting index of the content to set. + :param end: The ending index of the content to set. + Pass None to set a single character. + :param tags: The tags to add to the changed content. + """ + + self.text_box.delete(start, end) + self.text_box.insert(start, value, *tags) + + def get_selected_text(self) -> str: + """ + A method other objects can use to retrieve the selected text inside + the editor window's text box. + + :return: The selected text inside the editor window's text box. + If no text is selected, an empty string is returned instead. + """ + + try: + return self.text_box.get(tk.SEL_FIRST, tk.SEL_LAST) + + except tk.TclError: + return '' + + def set_selected_text(self, value, set_selected=True): + """ + A method other objects can use to alter the selected text inside the + editor window's text box. + + If no text is selected, the passed value is inserted at the current + cursor position instead. + + If set_selected is True, then the set text will be selected. Otherwise + it'll be normal. + + :param value: The new value of the selected text. + :param set_selected: If true, the new text will also be selected. + """ + + try: + insert_position = self.text_box.index(tk.SEL_FIRST) + self.text_box.delete(tk.SEL_FIRST, tk.SEL_LAST) + + except tk.TclError: + insert_position = tk.INSERT + + if set_selected: + self.text_box.insert(insert_position, value, tk.SEL) + else: + self.text_box.insert(insert_position, value) + + def get_selection_indexes(self) -> Tuple[str, str]: + """ + Retrieves the start and end indexes of the current selected text. + If no text is selected, returns two empty strings. + + :return: The indexes on the current selected text. If no text is + selected, returns two empty strings. + """ + + try: + return ( + self.text_box.index(tk.SEL_FIRST), + self.text_box.index(tk.SEL_LAST) + ) + + except tk.TclError: + return '', '' + + def set_selection_indexes(self, start, end): + """ + Selects the text between two indexes. + + :param start: The starting index of the selected text. + :param end: The ending index of the selected text. + Pass None to set a single character. + """ + + self.text_box.tag_add(tk.SEL, start, end) + + def get_word_at_text_box_index(self, index) -> Tuple[str, str, str]: + """ + Returns the word containing the character located at index. + If the character is a space or not part of a word (not alphabetic) an + empty string is returned instead. + + :param index: The index of a letter in the the text box. + + :return: The word containing the character located at index, or an + empty string if the character is a space or not part of a + word (not alphabetic). + """ + + line, character = map(int, index.split('.')) + + start = character + end = character + + # Find position of first space before index. + while start > 0: + current_character: str = self.text_box.get( + f'{line}.{start - 1}' + ) + + if not (current_character.isalpha() or current_character == "'"): + break + else: + start -= 1 + + # Find position of first space after index. + while True: + current_character: str = self.text_box.get( + f'{line}.{end}' + ) + + if not (current_character.isalpha() or current_character == "'"): + break + + end += 1 + + word = self.text_box.get(f'{line}.{start}', f'{line}.{end}') + + # Correct indexes with regards to leading and trailing apostrophes. + start_offset = 0 + end_offset = 0 + + for character in word: + if character == "'": + start_offset += 1 + else: + break + + for character, next_character in pairwise(reversed(word)): + if character == "'": + if next_character: + if next_character.lower() != 's': + end_offset -= 1 + else: + end_offset -= 1 + break + else: + break + + start += start_offset + word = word[start_offset:] + + if end_offset: + end += end_offset + word = word[:end_offset] + + start = f'{line}.{start}' + end = f'{line}.{end}' + + return start, end, word + + def get_word_at_text_box_pixel_position(self, x, y): + """ + Returns the word closest to pixel position x, y in the editor window's + text box, starting from 0, 0 at the top left. + + :param x: Position on the text box along the x axis, from top to + bottom. + :param y: Position on the text box along the y axis, from up to down. + + :return: The word closest to pixel position x, y in the editor window's + text box. + """ + + return self.get_word_at_text_box_index( + self.text_box.index(f'@{x},{y}') + ) + + def get_word_under_mouse(self): + """ + Gets the current word in the editor window's text box closest to the + mouse pointer. + + If the mouse isn't over a word it will return an empty string. + + :return: A tuple containing the starting index, ending index and + content of the word in the text box closest the mouse cursor. + """ + + return self.get_word_at_text_box_index( + self.text_box.index(tk.CURRENT) + ) + + def delete_selected_text(self, backspace=True): + """ + Deletes any selected text. + If nothing is selected, and backspace is set to True, then deletes the + character behind the cursor. + + :param backspace: If True, when no text is selected, the character + behind the cursor is deleted instead. + """ + selected_start, selection_end = self.get_selection_indexes() + if selected_start and selection_end: + self.set_selected_text('', False) + + elif backspace: + if self.text_box.index(tk.INSERT) != '1.0': + self.set_text('', f'{tk.INSERT}-1c', None) + + def on_key_press(self, event): + """ + Called every time the user presses a key while focused on the editor + window's text box. + By default it handles detecting when the user types a word and firing + the new_word event to signal this. + + :param event: tkinter event data + """ + + if ( + event.char and + not event.char.isalpha() and + event.char != "'" and + event.char != '\x08' # backspace + ): + for flag in Constants.forbidden_flags: + if event.state & flag: + break + else: + previous_character: str = self.text_box.get(f'{tk.INSERT}-1c') + if previous_character.isalpha() or previous_character == "'": + start, end, word = self.get_word_at_text_box_index( + self.text_box.index(f'{tk.INSERT}-1c') + ) + + self.new_word(start, end, word) + + def on_backspace(self, event): + """ + Called when the backspace key is pressed in the editor's text box. + + :param event: tkinter event data + :return: 'break' in order to interrupt the normal event handling of + the backspace key. + """ + + def delete_text(choice_data: CloppyChoiceMadeEventData): + if choice_data.choice == 'Yes': + self.delete_selected_text() + + cloppy_yesno( + self, + "It looks like you're trying to erase some text.\n" + "The text you're erasing could be very important.", + delete_text + ).show() + + return 'break' + + def on_control_x(self, event=None): + """ + Called when Ctrl+X is pressed in the editor's text box. + Also called by the 'Cut' menu options. + + :param event: tkinter event data. None by default in case the method + is invoked artificially. + :return: 'break' in order to interrupt the normal event handling of + the backspace key. + """ + + def cut_text(choice_data: CloppyChoiceMadeEventData): + if choice_data.choice == 'Yes': + root: tk.Tk = self.master + root.clipboard_clear() + root.clipboard_append( + self.get_selected_text() + ) + self.set_selected_text('') + + cloppy_yesno( + self, + "It looks like you're trying to cut some text.\n" + "The text you're erasing could be very important.", + cut_text + ).show() + + return 'break' + + def on_control_c(self, event=None): + """ + Called when Ctrl+C is pressed in the editor's text box. + Also called by the 'Copy' menu options. + + :param event: tkinter event data. None by default in case the method + is invoked artificially. + :return: 'break' in order to interrupt the normal event handling of + the backspace key. + """ + + def copy_text(choice_data: CloppyChoiceMadeEventData): + if choice_data.choice == 'Yes': + root: tk.Tk = self.master + root.clipboard_clear() + root.clipboard_append(self.get_selected_text()) + + cloppy_yesno( + self, + "It looks like you're trying to copy some text.\n" + "You might not have highlighted the correct section to copy.\n" + "Perhaps it'd be a good idea to double check now.", + copy_text + ).show() + + return 'break' + + def on_control_v(self, event=None): + """ + Called when Ctrl+V is pressed in the editor's text box. + Also called by the 'Paste' menu options. + + :param event: tkinter event data. None by default in case the method + is invoked artificially. + :return: 'break' in order to interrupt the normal event handling of + ctrl+v. + """ + + def paste_text(choice_data: CloppyChoiceMadeEventData): + if choice_data.choice == 'Yes': + root: tk.Tk = self.master + try: + self.set_selected_text( + root.clipboard_get(), + set_selected=False + ) + + except tk.TclError: + pass + + cloppy_yesno( + self, + "It looks like you're trying to paste some text.\n" + "The text you're pasting could be replacing other important text.", + paste_text + ).show() + + return 'break' + + def on_control_s(self, event=None): + """ + Called when Ctrl+S is pressed in the editor's text box. + Also called by the 'Save' menu option. + + :param event: tkinter event data. None by default in case the method + is invoked artificially. + :return: 'break' in order to interrupt the normal event handling of + the backspace key. + """ + + # Brings up a dialog asking the user to select a location for saving + # the file. + file = filedialog.asksaveasfile( + filetypes=(('Text Files', '*.txt'), ('All Files', '*.*')) + ) + + # Check to see if the user cancelled the dialog or not. + if file: + # 'with' is used so that the file is automatically flushed/closed + # after our work is done with it. + with file: + file.write(self.get_text()) + + return 'break' + + def on_control_o(self, event=None): + """ + Called when Ctrl+O is pressed in the editor's text box. + Also called by the 'Open' menu option. + + :param event: tkinter event data. None by default in case the method + is invoked artificially. + :return: 'break' in order to interrupt the normal event handling of + the backspace key. + """ + + # Cloppy asks the user whether they want to open a file. + + def open_file(choice_data: CloppyChoiceMadeEventData): + if choice_data.choice == 'Yes': + # Brings up a dialog asking the user to select a location for + # saving the file. + file = filedialog.askopenfile( + filetypes=(('Text Files', '*.txt'), ('All Files', '*.*')) + ) + + # Check to see if the user cancelled the dialog or not. + if file: + # 'with' is used so that the file is automatically flushed + # /closed after our work is done with it. + with file: + self.set_text(file.read()) + + cloppy_yesno( + self, + "It looks like you're trying to open a file.\n" + "If you do you'll lose any unsaved work in the current file.", + open_file + ).show() + + return 'break' + + def on_control_n(self, event=None): + """ + Called when Ctrl+N is pressed in the editor's text box. + Also called by the 'New' menu option. + + :param event: tkinter event data. None by default in case the method + is invoked artificially. + :return: 'break' in order to interrupt the normal event handling of + the backspace key. + """ + + # Cloppy asks the user whether they want to create a new file. + def new_file(choice_data: CloppyChoiceMadeEventData): + if choice_data.choice == 'Yes': + self.set_text('') + + cloppy_yesno( + self, + "It looks like you're trying to create a new file.\n" + "If you do you'll lose any unsaved work in the current file.", + new_file + ).show() + + return 'break' + + def on_right_click(self, event): + """ + Called when the user right clicks over the editor window's text box. + By default it creates and shows a context menu at the position of the + mouse. + + :param event: tkinter event data + :return: 'break' in order to interrupt the normal event handling of + right click. + """ + + # Create a new context menu. + context_menu = EditorContextMenu(self) + + # If there is selected text, add Cut and Copy options to the context + # menu. + selected_text = self.get_selected_text() + if selected_text: + context_menu.add_command( + label='Cut', command=self.on_control_x + ) + + context_menu.add_command( + label='Copy', command=self.on_control_c + ) + + context_menu.add_command( + label='Paste', command=self.on_control_v + ) + + context_menu.show() + return 'break' + + def on_new_word(self, word_data: NewWordEventData): + """ + Called every time a new word is typed in the editor's text box. + :param word_data: An object containing information about the starting + index, ending index and contents of the word. + """ + word_data.word = word_data.word.lower() + if not is_correct(word_data.word.lower()): + CloppySpellingWindow(self, word_data).show() + + def on_destroy(self): + """ + Called whenever the window is about to be closed. + + :return: 'break' to interrupt the normal handling of any events this + method is bound to. + """ + def destroy_window(choice_data: CloppyChoiceMadeEventData): + if choice_data.choice == 'Yes': + try: + self.master.destroy() + + except tk.TclError: + pass + + cloppy_yesno( + self, + "It looks like you're about to exit the program.\n" + "If you do this you might lose any unsaved work.", + destroy_window + ).show() + + return 'break' + + +class EditorMenuBar(tk.Menu): + """ + This class represents the menu bar in the editor window. + """ + def __init__(self, master: EditorWindow): + super().__init__(master) + + # A named reference to the editor window containing this menu bar. + # It will be used in command callbacks to interact with the editor + # window. + self.editor_window = master + + # Adding the individual menus to the menu bar. + self.file_menu = EditorFileMenu(self) + self.edit_menu = EditorEditMenu(self) + self.help_menu = EditorHelpMenu(self) + + # Setting self as the window's menu bar. + master.config(menu=self) + + +class EditorMenu(tk.Menu): + """ + This class represents an individual menu in the editor window's menu bar. + """ + + # The name with which this menu should be added to the menu bar. + name = None + + # A type annotation for IDE auto-completion purposes. + master: EditorMenuBar + + def __init__(self, master: EditorMenuBar): + super().__init__(master, tearoff=False) + + # Adding self to the menu bar. + self.master.add_cascade(label=self.name, menu=self) + + +class EditorFileMenu(EditorMenu): + """ + This class represents the File menu in the editor window's menu bar. + """ + + name = 'File' + + def __init__(self, master: EditorMenuBar): + super().__init__(master) + + # Setting up individual commands in the menu. + self.add_command(label='Open', command=self.on_open) + self.add_command(label='Save', command=self.on_save) + # self.add_command(label='Save As', command=self.on_save_as) + self.add_separator() + self.add_command(label='Exit', command=self.on_exit) + + def on_new(self): + """ + Called when the 'New' action is selected from the File menu. + """ + + self.master.editor_window.on_control_n() + + def on_open(self): + """ + Called when the 'Open' action is selected from the File menu. + """ + + self.master.editor_window.on_control_o() + + def on_save(self): + """ + Called when the 'Save' action is selected from the File menu. + """ + + self.master.editor_window.on_control_s() + + # def on_save_as(self): + # """ + # Called when the 'Save As' action is selected from the File menu. + # """ + + # pass + + def on_exit(self): + """ + Called when the user selects the 'Exit' option in the File menu. + """ + self.master.editor_window.on_destroy() + + +class EditorEditMenu(EditorMenu): + """ + This class represents the File menu in the editor window's menu bar. + """ + + name = 'Edit' + + def __init__(self, master: EditorMenuBar): + super().__init__(master) + + # Setting up individual commands in the menu. + self.add_command(label='Cut', command=self.on_cut) + self.add_command(label='Copy', command=self.on_copy) + self.add_command(label='Paste', command=self.on_paste) + + def on_cut(self): + """ + Called when the 'Cut' action is selected from the Edit menu. + """ + + self.master.editor_window.on_control_x() + + def on_copy(self): + """ + Called when the 'Copy' action is selected from the Edit menu. + """ + + self.master.editor_window.on_control_c() + + def on_paste(self): + """ + Called when the 'Paste' action is selected from the Edit menu. + """ + + self.master.editor_window.on_control_v() + + +class EditorHelpMenu(EditorMenu): + """ + This class represents the Help menu in the editor window's menu bar. + """ + + name = 'Help' + + def __init__(self, master: EditorMenuBar): + super().__init__(master) + + # Setting up individual commands in the menu. + self.add_command(label='About', command=self.on_about) + + def on_about(self): + """ + Called when the 'About' action is selected from the Help menu. + """ + + dialog = CloppyButtonWindow(self.master.editor_window) + dialog.set_message( + f'This program was made by:\n' + f'LargeKnome, Hanyuone, and Meta\n' + ) + dialog.add_choice('Ok') + dialog.show() + + +class EditorContextMenu(tk.Menu): + """ + This class represents a context menu appearing over an editor window's + text box. + """ + + def __init__(self, master): + super().__init__(master, tearoff=False) + + def show(self): + self.tk_popup(*self.winfo_pointerxy()) diff --git a/project/windows/editor_window_events.py b/project/windows/editor_window_events.py new file mode 100644 index 00000000..9447edea --- /dev/null +++ b/project/windows/editor_window_events.py @@ -0,0 +1,28 @@ +from project.functionality.events import Event + + +class NewWordEvent(Event): + """ + This class represent an event emitter for new word events. + These are emitted every time the user is detected to have typed a word. + + It passes data to its callbacks using a NewWordEventData object. + These will contain information about the word's contents, and its + starting and ending indexes. + """ + + def __call__(self, start, end, word): + super().__call__( + NewWordEventData(start, end, word) + ) + + +class NewWordEventData: + """ + This class is used to propagate information about a word a user has typed + to callbacks registered in a NewWordEvent object. + """ + def __init__(self, start, end, word): + self.start = start + self.end = end + self.word = word