diff --git a/.gitattributes b/.gitattributes index a0f45762..2f54066a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,7 +5,7 @@ # to native line endings on checkout. *.c text *.h text -*.py text +*.py text eol=lf # Declare files that will always have CRLF line endings on checkout. *.sln text eol=crlf diff --git a/examples_node_pack/.gitignore b/examples_node_pack/.gitignore new file mode 100644 index 00000000..e4da9281 --- /dev/null +++ b/examples_node_pack/.gitignore @@ -0,0 +1,2 @@ +### __pycache__ folders +__pycache__/ diff --git a/examples_node_pack/LICENSE b/examples_node_pack/LICENSE new file mode 100644 index 00000000..fdddb29a --- /dev/null +++ b/examples_node_pack/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/examples_node_pack/README.md b/examples_node_pack/README.md new file mode 100644 index 00000000..3e33cb5b --- /dev/null +++ b/examples_node_pack/README.md @@ -0,0 +1,9 @@ +# examples_node_pack + +A node pack with example nodes, just for demonstrating how to create different kinds of widgets/nodes + +They have no other dependency. Just open Nodezator, create a new file and select this folder with the menubar command **Graph > Select node paths** + +The nodes have no purpose beyond being used as demonstrations. They do not represent all possibilities regarding widget options, but they are great for beginners. + +Also, in case you didn't know it is possible, you can see the source of any user-defined node by selecting it and pressing ****. So if you want to know how any node from this node pack was created all you need to do is create it, select it and press **** to see its source code. diff --git a/examples_node_pack/intfloat/int_float_more/__main__.py b/examples_node_pack/intfloat/int_float_more/__main__.py new file mode 100644 index 00000000..453efb59 --- /dev/null +++ b/examples_node_pack/intfloat/int_float_more/__main__.py @@ -0,0 +1,47 @@ +"""Facility for int float entry widget advanced tests.""" + + +### function definition + +def int_float_more( + + ### param 01 + + int_from_0_to_100 : { + "widget_name" : "int_float_entry", + "widget_kwargs": { + "min_value": 0, + "max_value": 100 + }, + "type": int + } = 0, + + ### param 02 + + int_float_none_def_int : { + "widget_name": "int_float_entry", + "type": (int, float, type(None)) + } = 0 + + ): + """Return the arguments given. + + Just a simple node to test advanced options for + usage of the int float entry widget. + + Parameters: + + - int_from_0_to_100 (int) + this int float entry only accepts ints from 0 to 100. + + - int_float_none_def_int (int, float or None) + this int float accepts int, float or None, but the + default value isn't None. + """ + return ( + int_from_0_to_100, + int_float_none_def_int + ) + +### the callable used must also be aliased as 'main' +main_callable = int_float_more diff --git a/examples_node_pack/intfloat/int_float_test/__main__.py b/examples_node_pack/intfloat/int_float_test/__main__.py new file mode 100644 index 00000000..ad1fc286 --- /dev/null +++ b/examples_node_pack/intfloat/int_float_test/__main__.py @@ -0,0 +1,47 @@ +"""Facility for int float entry testing.""" + + +def int_float_test( + + ### int float entry accepts only integers + int_default_int : int = 0, + + ### int float entry accepts only integers and None + int_default_none : int = None, + + ### int float entry accepts only floats + float_default_float : float = 0.0, + + ### int float entry accepts only floats and None + float_default_None : float = None, + + ### int float entries accepts ints and floats (only + ### the default value is different. + + int_float_default_int : (int, float) = 0, + int_float_default_float : (int, float) = 0.0, + + ### int float entries accepts ints, floats and None + int_float_default_none : (int, float) = None, + + ### reversing the order of the classes in the tuple + ### also works, though it makes no difference (this + ### exists so that the user don't bother remembering + ### the order) + float_int_default_int : (float, int) = 0 + + ): + """Return received values.""" + return ( + int_default_int, + int_default_none, + float_default_float, + float_default_None, + int_float_default_int, + int_float_default_float, + int_float_default_none, + float_int_default_int + ) + +### callable used must always be aliased to 'main' +main_callable = int_float_test diff --git a/examples_node_pack/more_widgets/checkbutton_test/__main__.py b/examples_node_pack/more_widgets/checkbutton_test/__main__.py new file mode 100644 index 00000000..dba68356 --- /dev/null +++ b/examples_node_pack/more_widgets/checkbutton_test/__main__.py @@ -0,0 +1,10 @@ +"""Facility for checkbutton test.""" + +### function definition + +def check_button_test(boolean:bool=False) -> bool: + """Return received boolean.""" + return boolean + +### callable used must always be aliased to 'main' +main_callable = check_button_test diff --git a/examples_node_pack/more_widgets/color_button_test/__main__.py b/examples_node_pack/more_widgets/color_button_test/__main__.py new file mode 100644 index 00000000..37e84c9e --- /dev/null +++ b/examples_node_pack/more_widgets/color_button_test/__main__.py @@ -0,0 +1,19 @@ +"""Facility for demonstrating color button widget.""" + +def color_button_test( + + color: { + "widget_name" : "color_button", + "type": tuple + } = (255, 0, 0) + + ): + """Return received color. + + The color button attached to the "color" parameter + makes it easy to select any desired color. + """ + return color + +### functions used must always be aliased as 'main' +main_callable = color_button_test diff --git a/examples_node_pack/more_widgets/default_holder_test/__main__.py b/examples_node_pack/more_widgets/default_holder_test/__main__.py new file mode 100644 index 00000000..5df9c87a --- /dev/null +++ b/examples_node_pack/more_widgets/default_holder_test/__main__.py @@ -0,0 +1,27 @@ +"""Default holder demonstration. + +If you just put a default value in a parameter, a default +holder widget will be used. This widget doesn't have the +ability to edit the value and serves only to display +the default value of the parameter. +""" + + +# function with three parameters, all with default values +# but they don't define widgets, so the values appear +# in the node as default holder widgets, which is just +# greyed out text in the default app theme + +def default_holder_test( + a_string = 'a string', + an_integer = 100, + none_value = None, + ): + + return ( + a_string, + an_integer, + none_value, + ) + +main_callable = default_holder_test diff --git a/examples_node_pack/more_widgets/literal_display_test/__main__.py b/examples_node_pack/more_widgets/literal_display_test/__main__.py new file mode 100644 index 00000000..97627c46 --- /dev/null +++ b/examples_node_pack/more_widgets/literal_display_test/__main__.py @@ -0,0 +1,14 @@ +"""Demonstration of the literal display widget. + +The literal display widget does the same as the literal +entry. It is just that it has more space to see the value. +""" + +def literal_display_test( + python_literal: { + 'widget_name': 'literal_display' + } = ('this', 'is', 'a', 'tuple', 'of', 'values') + ): + return python_literal + +main_callable = literal_display_test diff --git a/examples_node_pack/more_widgets/literal_entry_test/__main__.py b/examples_node_pack/more_widgets/literal_entry_test/__main__.py new file mode 100644 index 00000000..dfa746ea --- /dev/null +++ b/examples_node_pack/more_widgets/literal_entry_test/__main__.py @@ -0,0 +1,18 @@ +"""Literal entry demonstration. + +Can be used to provide any Python literal. +You want. +""" + +def literal_entry_test( + python_literal: { + 'widget_name': 'literal_entry', + # note that we don't need to provide the + # 'type' key here, since this widget can + # edit a vast number of types (as long as + # the value is a python literal + } = None + ): + return python_literal + +main_callable = literal_entry_test diff --git a/examples_node_pack/more_widgets/sorting_button_test/__main__.py b/examples_node_pack/more_widgets/sorting_button_test/__main__.py new file mode 100644 index 00000000..11320847 --- /dev/null +++ b/examples_node_pack/more_widgets/sorting_button_test/__main__.py @@ -0,0 +1,22 @@ +"""Demonstrating the sorting button widget. + +Item can be used to provide sorted collections of a set +of available items. +""" + +def sorting_button_test( + sorted_tuple : { + 'widget_name': 'sorting_button', + 'widget_kwargs': { + 'available_items': { + 'red', + 'green', + 'blue', + }, + }, + 'type': tuple + } = ('blue', 'red') + ): + return sorted_tuple + +main_callable = sorting_button_test diff --git a/examples_node_pack/more_widgets/string_entry_test/__main__.py b/examples_node_pack/more_widgets/string_entry_test/__main__.py new file mode 100644 index 00000000..f0b93fca --- /dev/null +++ b/examples_node_pack/more_widgets/string_entry_test/__main__.py @@ -0,0 +1,5 @@ + +def string_entry_test(a_string:str='string'): + return a_string + +main_callable = string_entry_test diff --git a/examples_node_pack/more_widgets/string_entry_validation/__main__.py b/examples_node_pack/more_widgets/string_entry_validation/__main__.py new file mode 100644 index 00000000..c7db9c58 --- /dev/null +++ b/examples_node_pack/more_widgets/string_entry_validation/__main__.py @@ -0,0 +1,38 @@ +"""Demonstrating string entry widget with validation. + +That is, the user will only be able to leave a value in +the entry if the validation command allows it. + +The validation command option can be any callable that, +given the a string, returns True if it is valid and False +otherwise. It can also be the name of any string method +starting with 'is...'. + +Below we use 'isidentifier', which means the +str.isidentifier() method is used to validate the +content of the string entry. Now the user can only leave +a value in the string for which str.isidentifier() +returns True. For instance, identifiers in Python cannot +have spaces or start with a digit character, so if you +type such values in the entry and press the +entry will not allow it and will instead revert to the +previous value. +""" + + +def string_entry_validation( + + string_with_validation : { + + 'widget_name': 'string_entry', + 'widget_kwargs': { + 'validation_command': 'isidentifier' + }, + 'type': str + + } = 'string' + + ): + return string_with_validation + +main_callable = string_entry_validation diff --git a/examples_node_pack/option_menu_and_tray/option_menu_test/__main__.py b/examples_node_pack/option_menu_and_tray/option_menu_test/__main__.py new file mode 100644 index 00000000..998e884e --- /dev/null +++ b/examples_node_pack/option_menu_and_tray/option_menu_test/__main__.py @@ -0,0 +1,37 @@ +"""Facility for option menu widget testing.""" + +from itertools import combinations + + +def option_menu_test( + + costant_name: { + 'widget_name' : 'option_menu', + 'widget_kwargs': { + 'options' : ['e', 'inf', 'nan', 'pi'] + }, + 'type': str + } = 'pi', + + chosen_combination: { + 'widget_name' : 'option_menu', + 'widget_kwargs': { + 'options': [''.join(tup) for tup in combinations('abcdefgh', 5)] + }, + 'type': str + } = 'abcde' + + ): + """Return math constant specified by name.""" + from math import pi, e, inf, nan + + constant_value = { + "e" : e, + "inf" : inf, + "nan" : nan, + "pi" : pi + }[constant_name] + + return constant_value, chosen_combination + +main_callable = option_menu_test diff --git a/examples_node_pack/option_menu_and_tray/option_tray_test/__main__.py b/examples_node_pack/option_menu_and_tray/option_tray_test/__main__.py new file mode 100644 index 00000000..f75e40a3 --- /dev/null +++ b/examples_node_pack/option_menu_and_tray/option_tray_test/__main__.py @@ -0,0 +1,43 @@ + +def option_tray_test( + + param1: bool = None, + param2: (None, bool) = True, + param3: (type(None), bool) = None, + + param4: { + 'widget_name' : 'option_tray', + 'widget_kwargs' : { + 'options' : [False, None, True] + }, + 'type': (None, bool) + } = None, + + param5: { + 'widget_name' : 'option_tray', + 'widget_kwargs' : { + 'options' : ['Red', 'Green', 'Blue'] + }, + 'type': str + } = 'Red', + + param6: { + 'widget_name' : 'option_tray', + 'widget_kwargs' : { + 'options' : [1, 10, 100, 500] + }, + 'type': int + } = 100 + + ): + """Return tuple with received arguments.""" + return ( + param1, + param2, + param3, + param4, + param5, + param6 + ) + +main_callable = option_tray_test diff --git a/examples_node_pack/others/load_files_test/__main__.py b/examples_node_pack/others/load_files_test/__main__.py new file mode 100644 index 00000000..e37f5983 --- /dev/null +++ b/examples_node_pack/others/load_files_test/__main__.py @@ -0,0 +1,56 @@ +"""Facility for testing loading files within node script folder. + +Each node scripts sits in its own folder, which is called +the node's script folder. The node script must not use +code/resources from other node's script directory. +That is, all the nodes must be self-contained, +independent from each other. + +In this example we present how to load files from within +the node script directory. This way, your node script may +grow indefinitelly in its own directory, having as many +files as you want, so that you can extend/improve your node +locally. + +By keeping all the local resources your node needs in its +own folder, you ensure the nodes are independent from each +other, and can thus be mantained separately, without +concerning yourself with dependencies. +""" +from pathlib import Path + + +### getting the node script location (we call it the +### node script directory) +node_script_dir = Path(__file__).parent + + +### loading data from text files + +## from current location + +filepath00 = node_script_dir / "file00.txt" +with open(str(filepath00), "r", encoding="utf-8") as f: + text00_contents = f.read() + +## from subdirectory + +filepath01 = ( + node_script_dir / "txt_dir" / "another.txt" +) +with open(str(filepath01), "r", encoding="utf-8") as f: + text01_contents = f.read() + + +### now we can use the data anywhere we want in our +### function; we'll use them as the default values +### for our parameters: + +def file_loading_test( + text_a=text00_contents, text_b=text01_contents + ) -> str: + """Concatenate strings.""" + return text_a + text_b + +### callable used must always be aliased as 'main' +main_callable = file_loading_test diff --git a/examples_node_pack/others/load_files_test/file00.txt b/examples_node_pack/others/load_files_test/file00.txt new file mode 100644 index 00000000..aae9b27b --- /dev/null +++ b/examples_node_pack/others/load_files_test/file00.txt @@ -0,0 +1,2 @@ +Simple +text diff --git a/examples_node_pack/others/load_files_test/txt_dir/another.txt b/examples_node_pack/others/load_files_test/txt_dir/another.txt new file mode 100644 index 00000000..2fff00eb --- /dev/null +++ b/examples_node_pack/others/load_files_test/txt_dir/another.txt @@ -0,0 +1,2 @@ +Another +text diff --git a/examples_node_pack/others/multi_input/__main__.py b/examples_node_pack/others/multi_input/__main__.py new file mode 100644 index 00000000..785f7e2c --- /dev/null +++ b/examples_node_pack/others/multi_input/__main__.py @@ -0,0 +1,24 @@ +"""Facility for simple node test.""" + +from collections.abc import Callable, Iterable, Iterator + +def multi_input( + a_list : list, + a_tuple : tuple, + a_dict : dict, + a_boolean : bool, + a_string : str, + number : int, + also_a_number : float, + iterable : Iterable, + iterator : Iterator, + other_type : range, + type_not_specified, + callable_obj : Callable + ): + """Print obj, then return it.""" + pass + +### the callable used must always be aliased as +### 'main' +main_callable = multi_input diff --git a/examples_node_pack/others/multi_output/__main__.py b/examples_node_pack/others/multi_output/__main__.py new file mode 100644 index 00000000..6a933323 --- /dev/null +++ b/examples_node_pack/others/multi_output/__main__.py @@ -0,0 +1,44 @@ +"""Facility for multi output testing building.""" + +from collections.abc import Callable, Iterable, Iterator + +def multi_output() -> [ + + {"name": "str", "type": str}, + {"name": "tuple", "type": tuple}, + {"name": "list", "type": list}, + {"name": "dict", "type": dict}, + {"name": "bool", "type": bool}, + {"name": "number", "type": int}, + {"name": "also_a_number", "type": float}, + {"name": "iterable", "type": Iterable}, + {"name": "iterator", "type": Iterator}, + {"name": "other", "type": range}, + + # specifying type is optional, though; this output + # has no type specified + {"name": "type_not_specified"}, + + # and this is yet another possible annotation + {"name": "callable_obj", "type": Callable}, + + ]: + """Gather arguments on dict and return.""" + d = { + "str": "", + "tuple": (), + "list": [], + "dict": {}, + "bool": False, + "number": 0, + "iterable" : set(), + "iterator" : [1, 2, 3].__iter__(), + "other": range(2), + "type_not_specified": None, + "callable_obj": lambda: None, + } + return d + +### the callable used must always be aliased as +### 'main' +main_callable = multi_output diff --git a/examples_node_pack/others/no_param_test/__main__.py b/examples_node_pack/others/no_param_test/__main__.py new file mode 100644 index 00000000..a1ba01e8 --- /dev/null +++ b/examples_node_pack/others/no_param_test/__main__.py @@ -0,0 +1,10 @@ +"""Facility for testing function with no parameters.""" + +from math import pi + +def no_params(): + """Return pi.""" + return pi + +### callable used must always be aliased as 'main' +main_callable = no_params diff --git a/examples_node_pack/preview_widgets/audio_preview_test/__main__.py b/examples_node_pack/preview_widgets/audio_preview_test/__main__.py new file mode 100644 index 00000000..a6a4ad2e --- /dev/null +++ b/examples_node_pack/preview_widgets/audio_preview_test/__main__.py @@ -0,0 +1,19 @@ +"""Facility for audio preview demonstration.""" + +def audio_preview_test( + + audio_path: { + "widget_name" : "audio_preview", + "type": str + } = '.' + + ): + """Return received audio path. + + The audio preview attached to the "audio_path" parameter + makes it easy to select any desired audio file. + """ + return audio_path + +### the callable used must always be aliased as "main" +main_callable = audio_preview_test diff --git a/examples_node_pack/preview_widgets/font_preview_test/__main__.py b/examples_node_pack/preview_widgets/font_preview_test/__main__.py new file mode 100644 index 00000000..05b49d1f --- /dev/null +++ b/examples_node_pack/preview_widgets/font_preview_test/__main__.py @@ -0,0 +1,19 @@ +"""Facility for font preview demonstration.""" + +def font_preview_test( + + font_path: { + "widget_name" : "font_preview", + "type": str + } = '.' + + ): + """Return received font path. + + The font preview attached to the "font_path" parameter + makes it easy to select any desired font. + """ + return font_path + +### the callable used must always be aliased as "main" +main_callable = font_preview_test diff --git a/examples_node_pack/preview_widgets/image_preview_test/__main__.py b/examples_node_pack/preview_widgets/image_preview_test/__main__.py new file mode 100644 index 00000000..b256215b --- /dev/null +++ b/examples_node_pack/preview_widgets/image_preview_test/__main__.py @@ -0,0 +1,19 @@ +"""Facility for image preview demosntration.""" + +def image_preview_test( + + image_path: { + "widget_name" : "image_preview", + "type": str + } = '.' + + ): + """Return received image path. + + The image preview attached to the "image" parameter + makes it easy to select any desired image. + """ + return image_path + +### the callable used must always be aliased as "main" +main_callable = image_preview_test diff --git a/examples_node_pack/preview_widgets/path_preview_test/__main__.py b/examples_node_pack/preview_widgets/path_preview_test/__main__.py new file mode 100644 index 00000000..26f2a578 --- /dev/null +++ b/examples_node_pack/preview_widgets/path_preview_test/__main__.py @@ -0,0 +1,19 @@ +"""Facility for path preview demonstration.""" + + + +def path_preview_test( + + ### path parameter + + path : { + "widget_name": "path_preview", + "type": str + } = '.' + + ): + """Return received path.""" + return path + +### callable used must always be aliased to 'main' +main_callable = path_preview_test diff --git a/examples_node_pack/preview_widgets/text_preview_test/__main__.py b/examples_node_pack/preview_widgets/text_preview_test/__main__.py new file mode 100644 index 00000000..83ac25eb --- /dev/null +++ b/examples_node_pack/preview_widgets/text_preview_test/__main__.py @@ -0,0 +1,19 @@ +"""Facility for text preview demonstration.""" + +def text_preview_test( + + text_path: { + "widget_name" : "text_preview", + "type": str + } = '.' + + ): + """Return received text path. + + The text preview attached to the "text_path" parameter + makes it easy to select any desired text file. + """ + return text_path + +### the callable used must always be aliased as "main" +main_callable = text_preview_test diff --git a/examples_node_pack/preview_widgets/video_preview_test/__main__.py b/examples_node_pack/preview_widgets/video_preview_test/__main__.py new file mode 100644 index 00000000..9653463e --- /dev/null +++ b/examples_node_pack/preview_widgets/video_preview_test/__main__.py @@ -0,0 +1,20 @@ +"""Facility for video preview demonstration.""" + +def video_preview_test( + + video_path: { + "widget_name" : "video_preview", + "type": str + } = '.' + + ): + """Return received video path. + + The video preview attached to the "video_path" + parameter makes it easy to select any desired video + file. + """ + return video_path + +### the callable used must always be aliased as "main" +main_callable = video_preview_test diff --git a/examples_node_pack/text_display/text_display_python/__main__.py b/examples_node_pack/text_display/text_display_python/__main__.py new file mode 100644 index 00000000..a7e82709 --- /dev/null +++ b/examples_node_pack/text_display/text_display_python/__main__.py @@ -0,0 +1,31 @@ +"""Text display with Python syntax highlighting.""" + +def text_display_python( + + text: { + 'widget_name' : 'text_display', + 'widget_kwargs': { + 'font_path': 'mono_bold', + 'syntax_highlighting': 'python' + }, + 'type': str + } = \ +""" +def hello_world(param1:str) -> str: + '''Docstring''' + print(param1) + a = 3 + 3 + print(a) + return 100 +""" + + ) -> str: + """Return received string. + + The text button attached to the "text" parameter + makes it easy to view/edit the text. + """ + return text + +### functions used must always be aliased as 'main' +main_callable = text_display_python diff --git a/examples_node_pack/text_display/text_display_test/__main__.py b/examples_node_pack/text_display/text_display_test/__main__.py new file mode 100644 index 00000000..ef05ed8c --- /dev/null +++ b/examples_node_pack/text_display/text_display_test/__main__.py @@ -0,0 +1,26 @@ +"""Facility for text input fixture.""" + +def get_text( + + text: { + 'widget_name' : 'text_display', + 'type' : str + } = \ + """ + I'd like to make myself believe + the Planet Earth turns slowly + + It's hard to say that I'd rather stay away when I'm + asleep, cause everything is never as it seems. + """ + + ) -> str: + """Return received string. + + The text button attached to the "text" parameter + makes it easy to view/edit the text. + """ + return text + +### functions used must always be aliased as 'main' +main_callable = get_text diff --git a/examples_node_pack/variable_parameters/all_variable_param_kinds_test/__main__.py b/examples_node_pack/variable_parameters/all_variable_param_kinds_test/__main__.py new file mode 100644 index 00000000..0baa7b01 --- /dev/null +++ b/examples_node_pack/variable_parameters/all_variable_param_kinds_test/__main__.py @@ -0,0 +1,11 @@ +"""Facility for all parameter kinds testing.""" + +### function definition + +def all_var_param_kinds( + pos_or_key, *var_pos, key_only, **var_key): + """Return all arguments received.""" + return pos_or_key, var_pos, key_only, var_key + +### functions used must always be aliased as 'main' +main_callable = all_var_param_kinds diff --git a/examples_node_pack/variable_parameters/var_key_test/__main__.py b/examples_node_pack/variable_parameters/var_key_test/__main__.py new file mode 100644 index 00000000..056bd0b9 --- /dev/null +++ b/examples_node_pack/variable_parameters/var_key_test/__main__.py @@ -0,0 +1,8 @@ +"""Facility for keyword variable parameter testing.""" + +def var_key_test(**kwargs) -> dict: + """Return dict from keyword arguments received.""" + return kwargs + +### callable used must always be aliased as 'main' +main_callable = var_key_test diff --git a/examples_node_pack/variable_parameters/var_pos_test/__main__.py b/examples_node_pack/variable_parameters/var_pos_test/__main__.py new file mode 100644 index 00000000..c0b0bd41 --- /dev/null +++ b/examples_node_pack/variable_parameters/var_pos_test/__main__.py @@ -0,0 +1,8 @@ +"""Facility for positional variable parameter testing.""" + +def var_pos_test(*args) -> tuple: + """Return tuple from positional arguments received.""" + return args + +### callable used must always be aliased as 'main' +main_callable = var_pos_test diff --git a/examples_node_pack/visualization/view_text/__main__.py b/examples_node_pack/visualization/view_text/__main__.py new file mode 100644 index 00000000..af0cf830 --- /dev/null +++ b/examples_node_pack/visualization/view_text/__main__.py @@ -0,0 +1,490 @@ +"""Facility for text visualization.""" + +### standard library import +import asyncio +from itertools import cycle + + +### third-party imports + +## pygame + +from pygame import ( + + QUIT, + + KEYUP, + + K_ESCAPE, + + + K_w, K_a, K_s, K_d, + K_UP, K_LEFT, K_DOWN, K_RIGHT, + + MOUSEBUTTONUP, + MOUSEMOTION, + + Surface, Rect, + + ) + +from pygame.time import Clock +from pygame.font import Font + +from pygame.display import get_surface, update +from pygame.event import get as get_events +from pygame.draw import rect as draw_rect + +from pygame.key import get_pressed as get_pressed_keys + +### get screen reference and a rect for it + +SCREEN = get_surface() +SCREEN_RECT = SCREEN.get_rect() + +### define scrolling speeds in different 2D axes + +X_SCROLLING_SPEED = 20 +Y_SCROLLING_SPEED = 20 + +### obtain fps maintaining operation +maintain_fps = Clock().tick + +### define the framerate +FPS = 30 + + +### now here comes the first big change on our script: +### +### rather than using a single function as our main +### callable, we'll create a whole class to hold +### several methods, one of which we'll be using +### as the main callable for our node; +### +### why do we do that? Simply because we'll be dealing +### with a lot state (different objects, values and +### behaviours) and classes are a great tool for such job + +class TextViewer: + """Manages the loop of the view_text() node.""" + + def __init__(self): + """Create support objects/flags.""" + + ### create a scroll area so the text can be moved + ### around + self.scroll_area = SCREEN_RECT.inflate(-80, -80) + + ### instantiate background + + self.background = ( + Surface(SCREEN.get_size()).convert() + ) + + self.background.fill((128, 128, 128)) + + ### instantiate and store rendering operation + self.render_text = Font(None, 26).render + + ### create lists to hold surfaces and rects + ### representing text lines + + self.line_surfs = [] + self.line_rects = [] + + def handle_events(self): + """Handle events from event queue.""" + + for event in get_events(): + + if event.type == QUIT: + self.running = False + + elif event.type == KEYUP: + + if event.key == K_ESCAPE: + self.running = False + + def handle_key_state(self): + """Handle pressed keys.""" + + key_input = get_pressed_keys() + + ### calculate x movement + + ## calculate x movement if the "moves_horizontally" + ## flag is set + + if self.moves_horizontally: + + ## check whether "go left" and "go right" + ## buttons were pressed + + go_left = any( + key_input[key] for key in (K_a, K_LEFT) + ) + + go_right = any( + key_input[key] for key in (K_d, K_RIGHT) + ) + + ## assign amount of movement on x axis + ## depending on whether "go left" and "go right" + ## buttons were pressed + + if go_left and not go_right: + dx = 1 * X_SCROLLING_SPEED + + elif go_right and not go_left: + dx = -1 * X_SCROLLING_SPEED + + else: dx = 0 + + ## if the "moves_horizontally" flag is not set, + ## it means the text's width is smaller than + ## the screen's width, so there's no need to + ## move/scroll horizontally anyway, so the + ## movement is 0 + else: dx = 0 + + ### perform the same checks/calculations for + ### the y axis + + if self.moves_vertically: + + go_up = any( + key_input[key] for key in (K_w, K_UP) + ) + + go_down = any( + key_input[key] for key in (K_s, K_DOWN) + ) + + if ( + + (go_up and go_down) + or (not go_up and not go_down) + + ): + dy = 0 + + elif go_up and not go_down: + dy = 1 * Y_SCROLLING_SPEED + + elif go_down and not go_up: + dy = -1 * Y_SCROLLING_SPEED + + else: dy = 0 + + ### if there is movement in the x or y + ### axis, move the text + if dx or dy: self.move_text(dx, dy) + + def move_text(self, dx, dy): + """Move text in x and/or y axis. + + It performs extra checks/movement relative to + a scroll area to ensure the text never leaves + the screen completely. + """ + + text_rect = Rect( + *self.line_rects[0].topleft, + self.text_width, + self.text_height, + ) + + scroll_area = self.scroll_area + + ### apply x movement if != 0 + + if dx < 0: + + if ( + (text_rect.right + dx) + < scroll_area.right + ): + dx = scroll_area.right - text_rect.right + + + elif dx > 0: + + if ( + (text_rect.left + dx) + > scroll_area.left + ): + dx = scroll_area.left - text_rect.left + + ### apply y movement if != 0 + + if dy < 0: + + if ( + (text_rect.bottom + dy) + < scroll_area.bottom + ): + dy = scroll_area.bottom - text_rect.bottom + + elif dy > 0: + + if ( + (text_rect.top + dy) + > scroll_area.top + ): + dy = scroll_area.top - text_rect.top + + for rect in self.line_rects: rect.move_ip(dx, dy) + + def watch_window_size(self): + """Perform setups if window was resized.""" + + ### if the screen and the background have the + ### same size, then no window resizing took place, + ### so we exit the function right away + + if SCREEN.get_size() == self.background.get_size(): + return + + ### otherwise, we keep executing the function, + ### performing the needed setups + + ## update the screen rect's size + SCREEN_RECT.size = SCREEN.get_size() + + ## update the moving flags + + self.moves_horizontally = ( + self.text_width > SCREEN_RECT.width - 80 + ) + + self.moves_vertically = ( + self.text_height > SCREEN_RECT.height - 80 + ) + + ## recreate the background + + self.background = ( + + Surface(SCREEN.get_size()).convert() + + ) + + self.background.fill((128, 128, 128)) + + ## redraw everything + self.draw() + + ## replace the scroll area + self.scroll_area = SCREEN_RECT.inflate(-80, -80) + + def check_draw(self): + """If text moved, redraw.""" + + ### if the text is in the same position, + ### do nothing by returning early + + if ( + self.last_topleft == self.line_rects[0].topleft + ): return + + ### otherwise store the current position and + ### redraw background and text + + self.last_topleft = self.line_rects[0].topleft + self.draw() + + def draw(self): + """Draw background and text on screen.""" + blit = SCREEN.blit + + blit(self.background, (0, 0)) + + text_rect = Rect( + *self.line_rects[0].topleft, + self.text_width, + self.text_height, + ) + + text_bg_rect = text_rect.clip(SCREEN_RECT).inflate(40, 40) + + draw_rect( + SCREEN, + (35, 35, 65), + text_bg_rect, + ) + + is_on_screen = SCREEN_RECT.colliderect + + for surf, rect in zip( + + self.line_surfs, + self.line_rects, + + ): + + if is_on_screen(rect): blit(surf, rect) + + async def loop(self): + """Start and keep a loop. + + The loop is only exited when the running flag + is set to False. + """ + + self.running = True + + while self.running: + await asyncio.sleep(0) + + ## maintain a constant fps + maintain_fps(FPS) + + ## watch out for change in the window size, + ## performing needed setups if such change + ## happened + self.watch_window_size() + + ## execute main operation of the loop, + ## that is, input handling and drawing + + self.handle_events() + self.handle_key_state() + self.check_draw() + + ## finally update the screen with + ## pygame.display.update() + update() + + ### clear surf and rect lists + + self.line_surfs.clear() + self.line_rects.clear() + + def create_line_surfaces(self): + """Create text surfaces from text lines.""" + + self.line_surfs.clear() + self.line_rects.clear() + + render_text = self.render_text + + self.line_surfs.extend( + render_text(line_text, True, (235, 235, 235), (35, 35, 65)).convert() + for line_text in self.lines + ) + + self.line_rects.extend( + surf.get_rect() + for surf in self.line_surfs + ) + + topleft = SCREEN_RECT.move(40, 40).topleft + y_offset = 5 + + for rect in self.line_rects: + + rect.topleft = topleft + topleft = rect.move(0, y_offset).bottomleft + + + ### the method below is the main callable we'll use + ### for our node; + ### + ### that is, we'll instantiate the TextViewer class + ### and use this method from the instance as the + ### main callable; + ### + ### don't worry about the "self" parameter, Nodezator + ### is smart enough to ignore it (actually, the smart + ### one is inspect.signature(), the responsible for + ### such behaviour) + + def view_text(self, text: 'text' = ''): + """Display text on screen. + + To stop displaying the text just press . + This will trigger the exit of the inner loop. + """ + ### ensure we receive a non-empty string + + if type(text) != str: + + raise TypeError( + "'text' argument must be a string." + ) + + if not text: + + raise ValueError( + "'text' argument must be a non-empty string." + ) + + ### obtain lines from text + self.lines = text.splitlines() + + ### obtain surfaces for each line of text + self.create_line_surfaces() + + ### obtain text dimensions + + self.text_width = max( + self.line_rects, + key=lambda rect: rect.width + ).width + + self.text_height = ( + + self.line_rects[-1].bottom + - self.line_rects[ 0].top + + ) + + ### update the moving flags; + ### + ### such flags just indicate whether moving the + ### text makes sense horizontally and + ### vertically, depending on whether the text + ### is larger than the screen or not; + ### + ### for instance, if the screen is wider than + ### the text, then there is no need to move + ### the text horizontally, so the corresponding + ### flag is set to false + + self.moves_horizontally = ( + self.text_width > SCREEN_RECT.width - 80 + ) + + self.moves_vertically = ( + self.text_height > SCREEN_RECT.height - 80 + ) + + ### create attribute to track topleft position + self.last_topleft = (0, 0) + + ### redraw everything + self.draw() + + ### loop + asyncio.get_running_loop().create_task(self.loop()) + + + ### set attribute on view_text method so the + ### execution time tracking is dismissed for this + ### node; + ### + ### we need to do this here rather than after + ### instantiating TextViewer because after + ### instantiating the class the view_text method + ### doesn't allow new attributes to be set on it + view_text.dismiss_exec_time_tracking = True + + +### instantiate the TextViewer and use the view_text +### method as the main callable +main_callable = TextViewer().view_text + +### also make sure it can be found in this module using +### its own name, so that it can be found when the node +### layout is exported as a Python script +view_text = main_callable diff --git a/fractions/Fraction.py b/fractions/Fraction.py new file mode 100644 index 00000000..6a519feb --- /dev/null +++ b/fractions/Fraction.py @@ -0,0 +1,207 @@ +from math import floor +from re import match + +REAL_NUM_REGEX = "^[+-]?(?:\d+\.?\d*|\d*\.\d+)$" + + +class Fraction: + def __init__(self, numerator=0, denominator=1): + def handleparams(param): + p = None + if isinstance(param, Fraction): + p = param + elif isinstance(param, int): + p = Fraction(param) + elif isinstance(param, str): + p = Fraction._getfractionfromstr(param) + elif isinstance(param, float): + p = Fraction.fromdecimal(param) + else: + raise FractionException("{} is not compatible" + "as a numerator or denominator" + .format(param)) + return p + + x, y = None, None + if isinstance(numerator, int): + self.numerator = numerator + else: + x = handleparams(numerator) + if isinstance(denominator, int): + if denominator == 0: + raise FractionException("Denominator cannot be 0") + self.denominator = denominator + else: + y = handleparams(denominator) + + z = None + if x is not None and y is not None: + z = x / y + elif y is not None: + z = Fraction.reciprocal(y) * numerator + elif x is not None: + z = x / denominator + + if z is not None: + self.numerator, self.denominator = z.numerator, z.denominator + self.is_normal = False + + @staticmethod + def _getfractionfromstr(num: str): + f = None + num = num.strip() + slashcount = num.count('/') + if slashcount > 1: + raise FractionException("Invalid fraction") + elif slashcount == 1: + x, y = num.split('/') + if [bool(match(REAL_NUM_REGEX, x1)) + for x1 in [x, y]] == [True, True]: + f = Fraction(x, y) + else: + raise FractionException( + "Numerator or Denominator is not a number") + else: + if bool(match(REAL_NUM_REGEX, num)): + f = Fraction(float(num)) + else: + raise FractionException("Invalid number") + return f + + def _gcd(self, num1, num2): + if num2 == 0: + return num1 + return self._gcd(num2, num1 % num2) + + @staticmethod + def reciprocal(fraction): + return Fraction(fraction.denominator, fraction.numerator) + + @staticmethod + def fromdecimal(num, rec=None): + _snum = str(float(num)) + if rec is not None: + if not isinstance(rec, str): + raise FractionException('Recurring part should be a string') + elif '.' in rec: + raise FractionException( + 'Recurring part should not contain decimal places') + elif not str.isdigit(rec): + raise FractionException( + "Recurring part should only be a number") + elif rec not in _snum: + raise FractionException( + 'Recurring part not present in the number') + elif not _snum.endswith(rec): + raise FractionException( + "Number should end with the recurring part") + + _pow_tp = _snum.rfind(rec) - _snum.find('.') - 1 + _nummbpowtp = int(num * (10 ** _pow_tp)) + _nummbpowtpreclen = _nummbpowtp * (10 ** (len(rec))) + int(rec) + return Fraction(_nummbpowtpreclen - _nummbpowtp, + 10 ** (len(rec) + _pow_tp) - (10 ** _pow_tp)) + else: + dec_places = len(_snum[_snum.find('.') + 1:]) + return Fraction(int(round(num * (10 ** dec_places))), + 10 ** dec_places) + + def todecimal(self, decplaces=3): + if decplaces < 0: + raise Exception('Number of decimal places cannot be negative') + elif int(decplaces) != int(floor(decplaces)): + raise Exception( + 'Number of decimal places cannot be a decimal number') + return format(self.numerator / self.denominator, + '.{}f'.format(decplaces)) + + def __add__(self, other_fraction): + a_n, a_d, b_n, b_d = self.numerator, self.denominator, \ + other_fraction.numerator, other_fraction.denominator + denom_lcm = (a_d * b_d) / self._gcd(a_d, b_d) + new_numerator = ((denom_lcm / a_d) * a_n) + ((denom_lcm / b_d) * b_n) + reduced_frac_gcd = self._gcd(new_numerator, denom_lcm) + return Fraction(new_numerator / reduced_frac_gcd, + denom_lcm / reduced_frac_gcd) + + def __sub__(self, other_fraction): + a_n, a_d, b_n, b_d = self.numerator, self.denominator, \ + other_fraction.numerator, other_fraction.denominator + denom_lcm = (a_d * b_d) / self._gcd(a_d, b_d) + new_numerator = ((denom_lcm / a_d) * a_n) - ((denom_lcm / b_d) * b_n) + reduced_frac_gcd = self._gcd(new_numerator, denom_lcm) + return Fraction(new_numerator / reduced_frac_gcd, + denom_lcm / reduced_frac_gcd) + + def __mul__(self, other_fraction): + a_n, a_d, b_n, b_d = self.numerator, self.denominator, \ + other_fraction.numerator, other_fraction.denominator + denom_lcm = a_d * b_d + new_numerator = a_n * b_n + reduced_frac_gcd = self._gcd(new_numerator, denom_lcm) + return Fraction(int(new_numerator / reduced_frac_gcd), + int(denom_lcm / reduced_frac_gcd)) + + def __div__(self, other_fraction): + return self.__mul__(Fraction.reciprocal(other_fraction)) + + def __truediv__(self, other_fraction): + return self.__div__(other_fraction) + + def __lt__(self, other_fraction): + a_n, a_d, b_n, b_d = self.numerator, self.denominator, \ + other_fraction.numerator, other_fraction.denominator + denom_lcm = (a_d * b_d) / self._gcd(a_d, b_d) + return True if a_n * (denom_lcm / a_d) < b_n * \ + (denom_lcm / b_d) else False + + def __gt__(self, other_fraction): + a_n, a_d, b_n, b_d = self.numerator, self.denominator, \ + other_fraction.numerator, other_fraction.denominator + denom_lcm = (a_d * b_d) / self._gcd(a_d, b_d) + return True if a_n * (denom_lcm / a_d) > b_n * \ + (denom_lcm / b_d) else False + + def __le__(self, other_fraction): + a_n, a_d, b_n, b_d = self.numerator, self.denominator, \ + other_fraction.numerator, other_fraction.denominator + denom_lcm = (a_d * b_d) / self._gcd(a_d, b_d) + return True if a_n * (denom_lcm / a_d) <= b_n * \ + (denom_lcm / b_d) else False + + def __ge__(self, other_fraction): + a_n, a_d, b_n, b_d = self.numerator, self.denominator, \ + other_fraction.numerator, other_fraction.denominator + denom_lcm = (a_d * b_d) / self._gcd(a_d, b_d) + return True if a_n * (denom_lcm / a_d) >= b_n * \ + (denom_lcm / b_d) else False + + def __eq__(self, other_fraction): + a_n, a_d, b_n, b_d = self.numerator, self.denominator, \ + other_fraction.numerator, other_fraction.denominator + denom_lcm = (a_d * b_d) / self._gcd(a_d, b_d) + return True if a_n * (denom_lcm / a_d) == b_n * \ + (denom_lcm / b_d) else False + + def __ne__(self, other_fraction): + return not self.__eq__(other_fraction) + + def _normalize(self): + if not self.is_normal: + g = self._gcd(self.numerator, self.denominator) + self.numerator = self.numerator // g + self.denominator = self.denominator // g + self.is_normal = True + + def __str__(self): + self._normalize() + return '{}/{}'.format(self.numerator, self.denominator) + + def __repr__(self): + self._normalize() + return 'Fraction({}/{})'.format(self.numerator, self.denominator) + + +class FractionException(Exception): + def __init__(self, *args: object) -> None: + super().__init__(*args) diff --git a/fractions/__init__.py b/fractions/__init__.py new file mode 100644 index 00000000..c44bac4f --- /dev/null +++ b/fractions/__init__.py @@ -0,0 +1 @@ +from .Fraction import Fraction \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 00000000..c832d444 --- /dev/null +++ b/main.py @@ -0,0 +1,178 @@ +#import pygbag.aio as asyncio +import asyncio +import sys +import fractions +import statistics +from pygame import base +from pygame import quit as quit_pygame +from pygame.display import update +import numpy +from nodezator.config import APP_REFS +from nodezator.pygamesetup import ( + SERVICES_NS, + SCREEN, + switch_mode, + clean_temp_files, + is_modal, +) +from nodezator.pygamesetup.constants import GENERAL_NS +from nodezator.colorsman.colors import WINDOW_BG +from nodezator.logman.main import get_new_logger +from nodezator.our3rdlibs.behaviour import are_changes_saved +from nodezator.loopman.exception import ( + ContinueLoopException, + SwitchLoopException, + QuitAppException, + ResetAppException, +) +from nodezator.dialog import show_dialog_from_key +from nodezator.winman.main import perform_startup_preparations + + +### create logger for module +logger = get_new_logger(__name__) + + +STOPPED = False + +def is_stopped(): + return STOPPED + +def stop_running(): + global STOPPED + STOPPED = True + +def quit_callback(answer): + print("*** answer:", answer) + if answer == "quit": + logger.info("User confirmed closing app even when there are unsaved changes.") + stop_running() + elif answer == "save_and_quit": + APP_REFS.window_manager.save() + stop_running() + else: + logger.info("User cancelled closing app due to existence of unsaved changes.") + +async def main(filepath=None): + filepath = "/data/data/app/assets/samples/test2.ndz" + #print("filepath:", filepath) + + #SCREEN.fill(WINDOW_BG) + #update() + + loop_holder = perform_startup_preparations(filepath) + logger.info("Entering the application loop now.") + + while True: + await asyncio.sleep(0) + if is_modal(): + continue + if is_stopped(): + break + + ### perform various checkups for this frame; + ### + ### stuff like maintaing a constant framerate and more + SERVICES_NS.frame_checkups() + ### run the GUD methods (check glossary for + ### loop holder/methods/loop) + try: + loop_holder.handle_input() + loop_holder.update() + loop_holder.draw() + ### catch exceptions as they happen + ## the sole purpose of this exception is to stop + ## the execution flow of the try block and restart + ## again at the top of the 'while' block, so we + ## just pass, since this is exactly what happens + except ContinueLoopException: + pass + ## this exceptions sets a new object to become the + ## loop holder; the new object is obtained from + ## the 'loop_holder' attribute of the exception + ## instance + except SwitchLoopException as err: + ## set new loop holder + loop_holder = err.loop_holder + ## if loop holder has an enter method, + ## execute it + try: + method = loop_holder.enter + except AttributeError: + pass + else: + try: + method() + except Exception as e: + print("*** MAIN", e) + # + # + + ## this exception exits means the user tried closing + ## the application, which we do so here, unless + ## there are unsaved changes, in which case we only + ## proceed if the user confirms + except QuitAppException: + logger.info("User tried closing the app.") + ## if we are on normal mode and changes are not saved, + ## ask user what to do + if GENERAL_NS.mode_name == 'normal' and not are_changes_saved(): + ## ask user + show_dialog_from_key( + "close_app_dialog", + callback = quit_callback, + ) + continue + + ### if we get to the point, we just perform extra admin + ### tasks to exit the app and its loop + + logger.info("Closing app under expected circumstances.") + + clean_temp_files() + + quit_pygame() + + ## break out of the application loop + break + + ## this exception serves to reset the app to an + ## initial state, that is, when it is just launched, + ## either with or without a file to be loaded + + except ResetAppException as obj: + + ### switch mode according to exception info + switch_mode(obj) + + ### perform startup preparations, retrieving the chosen + ### loop holder, just like we did before starting the + ### mainloop at the beginning of this function + loop_holder = perform_startup_preparations(obj.filepath) + + + ## catch unexpected exceptions so we can quit pygame + ## and log the exception before reraising + + except Exception as err: + + logger.exception("While running the application an unexpected exception ocurred. Doing clean up and reraising now.") + + quit_pygame() + + raise err + print("*** STOPPED") + quit_pygame() + exit() + +# This is the program entry point: +if __name__ == '__main__': + asyncio.run(main()) + #filepath = None + #if len(sys.argv) > 0: + # filepath = sys.argv[1] + #asyncio.run(main(filepath)) + +# Do not add anything from here, especially sys.exit/pygame.quit +# asyncio.run is non-blocking on pygame-wasm and code would be executed +# right before program start main() diff --git a/mynodepack/__init__.py b/mynodepack/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mynodepack/formula/__init__.py b/mynodepack/formula/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mynodepack/formula/circle_area/__init__.py b/mynodepack/formula/circle_area/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mynodepack/formula/circle_area/__main__.py b/mynodepack/formula/circle_area/__main__.py new file mode 100644 index 00000000..bad575d2 --- /dev/null +++ b/mynodepack/formula/circle_area/__main__.py @@ -0,0 +1,9 @@ + +### standard library import +from math import pi + + +def get_circle_area(radius:float=1.0) -> [{'name': 'circle_area', 'type': float}]: + return pi * (radius **2) + +main_callable = get_circle_area diff --git a/mynodepack/formula/hypotenuse/__init__.py b/mynodepack/formula/hypotenuse/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mynodepack/formula/hypotenuse/__main__.py b/mynodepack/formula/hypotenuse/__main__.py new file mode 100644 index 00000000..42f07702 --- /dev/null +++ b/mynodepack/formula/hypotenuse/__main__.py @@ -0,0 +1,11 @@ + +### standard library import +from math import sqrt + + +def get_hypotenuse(leg1:float=3.0, leg2:float=4.0) -> [{'name': 'hypotenuse', 'type': float}]: + return sqrt( + (leg1**2) + (leg2**2) + ) + +main_callable = get_hypotenuse \ No newline at end of file diff --git a/mynodepack/geometric_drawings/__init__.py b/mynodepack/geometric_drawings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mynodepack/geometric_drawings/get_circle/__init__.py b/mynodepack/geometric_drawings/get_circle/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mynodepack/geometric_drawings/get_circle/__main__.py b/mynodepack/geometric_drawings/get_circle/__main__.py new file mode 100644 index 00000000..a4984375 --- /dev/null +++ b/mynodepack/geometric_drawings/get_circle/__main__.py @@ -0,0 +1,27 @@ + +### third-party imports + +from pygame import Surface + +from pygame.draw import circle as draw_circle + + +def get_circle( + color : {'widget_name': 'color_button', 'type': tuple} = (255, 0, 0), + radius:int=2, +) -> [{'name': 'surface', 'type': Surface}]: + """Return surface with circle drawn on it.""" + + diameter = round(radius * 2) + + size = (diameter,) * 2 + + surf = Surface(size).convert_alpha() + surf.fill((0,)*4) + + draw_circle(surf, color, surf.get_rect().center, radius) + + return surf + + +main_callable = get_circle diff --git a/mynodepack/geometric_drawings/get_combined_surfs/__init__.py b/mynodepack/geometric_drawings/get_combined_surfs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mynodepack/geometric_drawings/get_combined_surfs/__main__.py b/mynodepack/geometric_drawings/get_combined_surfs/__main__.py new file mode 100644 index 00000000..7208ad5b --- /dev/null +++ b/mynodepack/geometric_drawings/get_combined_surfs/__main__.py @@ -0,0 +1,53 @@ + +### third-party imports + +from pygame import Surface + +from pygame.math import Vector2 + + + +OPTIONS = ( + 'topleft', + 'topright', + 'bottomleft', + 'bottomright', + 'midleft', + 'midright', + 'midbottom', + 'midtop', + 'center', +) + +def get_combined_surfs( + surf_a: Surface, + surf_b: Surface, + pos_from_b:{'widget_name': 'option_menu', 'widget_kwargs': {'options': OPTIONS}, 'type':str} ='center', + pos_to_a:{'widget_name': 'option_menu', 'widget_kwargs': {'options': OPTIONS}, 'type':str} ='center', + offset_pos_by: 'python_literal' = (0, 0), +) -> [{'name': 'surface', 'type': Surface}]: + """Return new surface by combining surfs a and b.""" + + rect_a = surf_a.get_rect() + rect_b = surf_b.get_rect() + + pos = getattr(rect_b, pos_from_b) + setattr(rect_a, pos_to_a, pos) + rect_a.move_ip(offset_pos_by) + + union = rect_a.union(rect_b) + + offset = -Vector2(union.topleft) + rect_a.move_ip(offset) + rect_b.move_ip(offset) + + surf = Surface(union.size).convert_alpha() + surf.fill((0,)*4) + + surf.blit(surf_b, rect_b) + surf.blit(surf_a, rect_a) + + return surf + + +main_callable = get_combined_surfs diff --git a/mynodepack/geometric_drawings/get_rectangle/__init__.py b/mynodepack/geometric_drawings/get_rectangle/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mynodepack/geometric_drawings/get_rectangle/__main__.py b/mynodepack/geometric_drawings/get_rectangle/__main__.py new file mode 100644 index 00000000..54dd5537 --- /dev/null +++ b/mynodepack/geometric_drawings/get_rectangle/__main__.py @@ -0,0 +1,18 @@ + +### third-party import +from pygame import Surface + + +def get_rectangle( + color : {'widget_name': 'color_button', 'type': tuple} = (255, 0, 0), + width:int=100, + height: int=100, +) -> [{'name': 'surface', 'type': Surface}]: + """Return surface representing rectangle.""" + + surf = Surface((width, height)).convert_alpha() + surf.fill(color) + + return surf + +main_callable = get_rectangle diff --git a/mynodepack/geometric_drawings/save_surface/__init__.py b/mynodepack/geometric_drawings/save_surface/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mynodepack/geometric_drawings/save_surface/__main__.py b/mynodepack/geometric_drawings/save_surface/__main__.py new file mode 100644 index 00000000..36687860 --- /dev/null +++ b/mynodepack/geometric_drawings/save_surface/__main__.py @@ -0,0 +1,19 @@ + +### third-party imports + +from pygame import Surface + +from pygame.image import save as save_surface + + +def _save_surface( + surface:Surface, + filepath:'image_path'='dummy_path', +) -> [{'name': 'None', 'type': None}]: + """Save given surface on disk.""" + pass + +main_callable = save_surface +signature_callable = _save_surface +third_party_import_text = 'from pygame.image import save as save_surface' +call_format = 'save_surface' diff --git a/mynodepack/geometric_drawings/view_surface/__init__.py b/mynodepack/geometric_drawings/view_surface/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mynodepack/geometric_drawings/view_surface/__main__.py b/mynodepack/geometric_drawings/view_surface/__main__.py new file mode 100644 index 00000000..631b68e7 --- /dev/null +++ b/mynodepack/geometric_drawings/view_surface/__main__.py @@ -0,0 +1,532 @@ +"""Facility for image visualization.""" + +### third-party imports +import asyncio + +## pygame + +from pygame import ( + + QUIT, + + KEYUP, + + K_ESCAPE, + + + K_w, K_a, K_s, K_d, + K_UP, K_LEFT, K_DOWN, K_RIGHT, + K_HOME, + + MOUSEBUTTONUP, + MOUSEBUTTONDOWN, + MOUSEMOTION, + + Surface, + + ) + +from pygame.display import get_surface, update +from pygame.time import Clock +from pygame.event import get as get_events + +from pygame.key import get_pressed as get_pressed_keys + + +### local import +from .utils import blit_checker_pattern + + + +### get screen reference and a rect for it + +SCREEN = get_surface() +SCREEN_RECT = SCREEN.get_rect() + +### define scrolling speeds in different 2D axes + +X_SCROLLING_SPEED = 20 +Y_SCROLLING_SPEED = 20 + +### obtain fps maintaining operation +maintain_fps = Clock().tick + +### define the framerate +FPS = 30 + + +### now here comes the first big change on our script: +### +### rather than using a single function as our main +### callable, we'll create a whole class to hold +### several methods, one of which we'll be using +### as the main callable for our node; +### +### why do we do that? Simply because we'll be dealing +### with a lot state (different objects, values and +### behaviours) and classes are a great tool for such job + +class ImageViewer: + """Manages the loop of the view_surface() node.""" + + def __init__(self): + """Create support objects/flags.""" + + ### create a scroll area so the image can be moved + ### around + self.scroll_area = SCREEN_RECT.inflate(-80, -80) + + ### instantiate background + + self.background = ( + Surface(SCREEN.get_size()).convert() + ) + + ### create flag to indicate whether the checker + ### pattern must be drawn on the background; + ### + ### the first time the node is executed the + ### checker pattern is draw on the background + ### and then this flag is set to False; it will + ### remain False for the lifetime of the node, + ### that is, it is used only that time; + ### + ### it is actually optional: we could draw the + ### checker pattern right away if we wanted; we + ### just avoid doing so because though it happens + ### practically in an instant, there's no telling + ### whether other nodes will also perform setups + ### that take time when the node pack is loaded + ### and the sum of the time taken by all nodes + ### might end up resulting in a non-trivial amount + ### of time to load the node pack; + ### + ### maybe we are just being overly cautious, + ### though; + self.must_draw_checker_pattern = True + + def keyboard_mode_event_handling(self): + """Event handling for the keyboard mode.""" + + for event in get_events(): + + if event.type == QUIT: + self.running = False + + elif event.type == MOUSEBUTTONDOWN: + + if event.button == 1: + self.enable_mouse_mode() + + elif event.type == KEYUP: + + if event.key == K_HOME: + + self.image_rect.center = ( + SCREEN_RECT.center + ) + + elif event.key == K_ESCAPE: + self.running = False + + def keyboard_mode_key_state_handling(self): + """Handle pressed keys for keyboard mode.""" + + key_input = get_pressed_keys() + + ### calculate x movement + + ## calculate x movement if the "moves_horizontally" + ## flag is set + + if self.moves_horizontally: + + ## check whether "go left" and "go right" + ## buttons were pressed + + go_left = any( + key_input[key] for key in (K_a, K_LEFT) + ) + + go_right = any( + key_input[key] for key in (K_d, K_RIGHT) + ) + + ## assign amount of movement on x axis + ## depending on whether "go left" and "go right" + ## buttons were pressed + + if go_left and not go_right: + dx = -1 * X_SCROLLING_SPEED + + elif go_right and not go_left: + dx = 1 * X_SCROLLING_SPEED + + else: dx = 0 + + ## if the "moves_horizontally" flag is not set, + ## it means the image's width is smaller than + ## the screen's width, so there's no need to + ## move/scroll horizontally anyway, so the + ## movement is 0 + else: dx = 0 + + ### perform the same checks/calculations for + ### the y axis + + if self.moves_vertically: + + go_up = any( + key_input[key] for key in (K_w, K_UP) + ) + + go_down = any( + key_input[key] for key in (K_s, K_DOWN) + ) + + if ( + + (go_up and go_down) + or (not go_up and not go_down) + + ): + dy = 0 + + elif go_up and not go_down: + dy = -1 * Y_SCROLLING_SPEED + + elif go_down and not go_up: + dy = 1 * Y_SCROLLING_SPEED + + else: dy = 0 + + ### if there is movement in the x or y + ### axis, move the image + if dx or dy: self.move_image(dx, dy) + + def move_image(self, dx, dy): + """Move image in x and/or y axis. + + It performs extra checks/movement relative to + a scroll area to ensure the image never leaves + the screen completely. + """ + + image_rect = self.image_rect + scroll_area = self.scroll_area + + ### apply x movement if != 0 + + if dx < 0: + + if ( + (image_rect.right + dx) + < scroll_area.right + ): + image_rect.right = scroll_area.right + + else: image_rect.x += dx + + elif dx > 0: + + if ( + (image_rect.left + dx) + > scroll_area.left + ): + image_rect.left = scroll_area.left + + else: image_rect.x += dx + + ### apply y movement if != 0 + + if dy < 0: + + if ( + (image_rect.bottom + dy) + < scroll_area.bottom + ): + image_rect.bottom = scroll_area.bottom + + else: image_rect.y += dy + + elif dy > 0: + + if ( + (image_rect.top + dy) + > scroll_area.top + ): + image_rect.top = scroll_area.top + + else: image_rect.y += dy + + def mouse_mode_event_handling(self): + """Event handling for the mouse mode.""" + + for event in get_events(): + + if event.type == QUIT: + self.running = False + + elif event.type == MOUSEMOTION: + self.move_according_to_mouse(*event.rel) + + elif event.type == MOUSEBUTTONUP: + + if event.button == 1: + self.enable_keyboard_mode() + + elif event.type == KEYUP: + + if event.key == K_ESCAPE: + self.running = False + + def move_according_to_mouse(self, dx, dy): + """Support method for the mouse mode.""" + + if not self.moves_horizontally: + dx = 0 + + if not self.moves_vertically: + dy = 0 + + self.move_image(dx, dy) + + def mouse_mode_key_state_handling(self): + """Mouse mode doesn't handle key pressed state. + + So this method does nothing. + """ + + def watch_window_size(self): + """Perform setups if window was resized.""" + + ### if the screen and the background have the + ### same size, then no window resizing took place, + ### so we exit the function right away + + if SCREEN.get_size() == self.background.get_size(): + return + + ### otherwise, we keep executing the function, + ### performing the needed setups + + ## reference image surf and rect locally + + image_surf = self.image_surf + image_rect = self.image_rect + + ## update the screen rect's size + SCREEN_RECT.size = SCREEN.get_size() + + ## center the image on the screen + image_rect.center = SCREEN_RECT.center + + ## update the moving flags + + self.moves_horizontally = ( + image_rect.width > SCREEN_RECT.width + ) + + self.moves_vertically = ( + image_rect.height > SCREEN_RECT.height + ) + + ## recreate the background and redraw the checker + ## pattern on it + + self.background = ( + + Surface(SCREEN.get_size()).convert() + + ) + + blit_checker_pattern(self.background) + + ## draw the background on the screen + SCREEN.blit(self.background, (0, 0)) + + ## blit image on the screen using its rect + SCREEN.blit(image_surf, image_rect) + + ## replace the scroll area + self.scroll_area = SCREEN_RECT.inflate(-80, -80) + + def enable_keyboard_mode(self): + """Set behaviours to move image with keyboard.""" + + self.handle_events = ( + self.keyboard_mode_event_handling + ) + + self.handle_key_state = ( + self.keyboard_mode_key_state_handling + ) + + def enable_mouse_mode(self): + """Set behaviours to move image with the mouse. + + That is, by dragging. + """ + + self.handle_events = ( + self.mouse_mode_event_handling + ) + + self.handle_key_state = ( + self.mouse_mode_key_state_handling + ) + + def draw(self): + """If image moved, redraw.""" + + ### if the image is in the same position, + ### do nothing by returning early + + if ( + self.last_topleft == self.image_rect.topleft + ): return + + ### otherwise store the current position and + ### redraw background and image + + self.last_topleft = self.image_rect.topleft + + SCREEN.blit(self.background, (0, 0)) + + SCREEN.blit( + self.image_surf, self.image_rect + ) + + async def loop(self): + """Start and keep a loop. + + The loop is only exited when the running flag + is set to False. + """ + + self.running = True + + while self.running: + await asyncio.sleep(0) + + ## maintain a constant fps + maintain_fps(FPS) + + ## watch out for change in the window size, + ## performing needed setups if such change + ## happened + self.watch_window_size() + + ## execute main operation of the loop, + ## that is, input handling and drawing + + self.handle_events() + self.handle_key_state() + self.draw() + + ## finally update the screen with + ## pygame.display.update() + update() + + ### remove image surf and rect references + + del self.image_surf + del self.image_rect + + ### the method below is the main callable we'll use + ### for our node; + ### + ### that is, we'll instantiate the ImageViewer class + ### and use this method from the instance as the + ### main callable; + ### + ### don't worry about the "self" parameter, Nodezator + ### is smart enough to ignore it (actually, the smart + ### one is inspect.signature(), the responsible for + ### such behaviour) + + def view_surface(self, surface: Surface): + """Display surface on screen. + + To stop displaying the image just press . + This will trigger the exit of the inner loop. + """ + ### enable keyboard mode + self.enable_keyboard_mode() + + ### draw the checker pattern on the background if + ### needed; this flag is only used once for the + ### lifetime of this node (check the comment on + ### the __init__ method about this flag) + + if self.must_draw_checker_pattern: + + ### draw the checker pattern on the background + blit_checker_pattern(self.background) + + ### set flag to false + self.must_draw_checker_pattern = False + + + ### draw the background on the screen + SCREEN.blit(self.background, (0, 0)) + + ### get rect for image and center it on the screen + + rect = surface.get_rect() + + rect.center = SCREEN_RECT.center + + ### update the moving flags; + ### + ### such flags just indicate whether moving the + ### image makes sense horizontally and + ### vertically, depending on whether the image + ### is larger than the screen or not; + ### + ### for instance, if the screen is wider than + ### the image, then there is no need to move + ### the image horizontally, so the corresponding + ### flag is set to false + + self.moves_horizontally = ( + rect.width > SCREEN_RECT.width + ) + + self.moves_vertically = ( + rect.height > SCREEN_RECT.height + ) + + ### store image surface and rect + + self.image_surf = surface + self.image_rect = rect + + ### blit image on the screen using its rect + SCREEN.blit(surface, rect) + + ### create attribute to track topleft position + self.last_topleft = rect.topleft + + ### loop + asyncio.get_running_loop().create_task(self.loop()) + + ### set attribute on view_surface method so the + ### execution time tracking is dismissed for this + ### node; + ### + ### we need to do this here rather than after + ### instantiating ImageViewer because after + ### instantiating the class the view_surface method + ### doesn't allow new attributes to be set on it + view_surface.dismiss_exec_time_tracking = True + + +### now, instantiate the ImageViewer and use the view_surface +### method as the main callable +main_callable = ImageViewer().view_surface + +### to make it so the callable can be found in this module when +### the node layout is exported as a python script, make sure +### it can be found using its own name +view_surface = main_callable diff --git a/mynodepack/geometric_drawings/view_surface/utils.py b/mynodepack/geometric_drawings/view_surface/utils.py new file mode 100644 index 00000000..dcdf0048 --- /dev/null +++ b/mynodepack/geometric_drawings/view_surface/utils.py @@ -0,0 +1,68 @@ + +### standard library import +from itertools import cycle + +### third-party imports + +from pygame.draw import rect as draw_rect + +from pygame import Rect + + +### the support function below is used to draw a checker +### pattern on the background whenever needed + +def blit_checker_pattern(surf): + """Blit checker pattern on surf.""" + ### define settings + + color_a = (235, 235, 235) + color_b = (120, 120, 120) + + rect_width = 40 + rect_height = 40 + + ### retrieve a rect from the surf + surf_rect = surf.get_rect() + + ### create a color cycler from the received colors + next_color = cycle((color_a, color_b)).__next__ + + ### create a rect with the provided dimensions, called + ### unit rect, since it represents an unit or tile in + ### the checker pattern + unit_rect = Rect(0, 0, rect_width, rect_height) + + ### use the unit rect width and height as offset + ### amounts in the x and y axes + + x_offset = rect_width + y_offset = rect_height + + ### "walk" the surface while blitting the checker + ### pattern until the surface the entire area of + ### the surface is covered by the checker pattern + + while True: + + ## if the unit rect isn't touching the + ## surface area, invert the x_offset, + ## move it back using such new x_offset and + ## move it down using the y_offset + + if not surf_rect.colliderect(unit_rect): + + x_offset = -x_offset + unit_rect.move_ip(x_offset, y_offset) + + ## if even after the previous if block the + ## unit rect still doesn't touch the surface + ## area, break out of the while loop + if not surf_rect.colliderect(unit_rect): break + + ## draw the rect + draw_rect(surf, next_color(), unit_rect) + + ## move the unit rect in the x axis using the + ## x_offset + unit_rect.move_ip(x_offset, 0) diff --git a/nodezator/audioplayer.py b/nodezator/audioplayer.py index edd77ed8..81501038 100644 --- a/nodezator/audioplayer.py +++ b/nodezator/audioplayer.py @@ -1,5 +1,6 @@ +### standard library imports +import asyncio ### third-party imports - from pygame import Rect from pygame.locals import ( @@ -27,7 +28,7 @@ from .config import APP_REFS -from .pygamesetup import SERVICES_NS, SCREEN, SCREEN_RECT +from .pygamesetup import SERVICES_NS, SCREEN, SCREEN_RECT, set_modal from .dialog import create_and_show_dialog @@ -94,7 +95,6 @@ class AudioPlayer(Object2D): def __init__(self): """""" - self.image = render_rect(420, 50, (128, 128, 128)) draw_border(self.image, thickness=2) @@ -187,8 +187,33 @@ def set_current_volume_points(self): start_vector.lerp(volume_area.bottomright, volume), ) - def play_audio(self, audio_paths, index=0): + async def play_audio_loop(self): + set_modal(True) + while self.running: + await asyncio.sleep(0) + ### perform various checkups for this frame; + ### + ### stuff like maintaing a constant framerate and more + SERVICES_NS.frame_checkups() + + try: + + loop_holder.handle_input() + loop_holder.draw() + + except SwitchLoopException as err: + loop_holder = err.loop_holder + + music.stop() + set_modal(False) + if self.callback is not None: + self.callback() + + + def play_audio(self, audio_paths, index=0, callback = None): + self.callback = callback + self.audio_paths = ( [audio_paths] if isinstance(audio_paths, str) else audio_paths ) @@ -213,22 +238,7 @@ def play_audio(self, audio_paths, index=0): loop_holder = self - while self.running: - - ### perform various checkups for this frame; - ### - ### stuff like maintaing a constant framerate and more - SERVICES_NS.frame_checkups() - - try: - - loop_holder.handle_input() - loop_holder.draw() - - except SwitchLoopException as err: - loop_holder = err.loop_holder - - music.stop() + asyncio.get_running_loop().create_task(self.play_audio_loop()) def handle_input(self): """""" diff --git a/nodezator/colorsman/editor/panel/data.py b/nodezator/colorsman/editor/panel/data.py index d44c5e9d..0d51df2d 100644 --- a/nodezator/colorsman/editor/panel/data.py +++ b/nodezator/colorsman/editor/panel/data.py @@ -18,11 +18,7 @@ class ImportExportOperations: """Operations to import/export colors.""" - def import_colors(self): - """Import colors from python literals in path(s).""" - - ### retrieve path(s) from file browser - paths = select_paths(caption="Select path(s)") + def import_colors_callback(self, paths): ### if a path or list of paths was not returned, ### just exit the method by returning @@ -58,12 +54,17 @@ def import_colors(self): ### set the colors into the colors panel self.set_colors(colors) - def export_colors(self): - """Export colors as a python literal.""" + + def import_colors(self): + """Import colors from python literals in path(s).""" ### retrieve path(s) from file browser + select_paths( + caption="Select path(s)", + callback = self.import_colors_callback, + ) - path = select_paths(caption=("Provide a single path wherein to save" " colors")) + def export_colors_callback(self, path): ### if a path or list of paths was not returned, ### just exit the method by returning @@ -97,3 +98,13 @@ def export_colors(self): f"error while saving colors: {str(err)}", level_name="error", ) + + def export_colors(self): + """Export colors as a python literal.""" + + ### retrieve path(s) from file browser + + select_paths( + caption=("Provide a single path wherein to save" " colors"), + callback = self.export_colors_callback, + ) diff --git a/nodezator/dialog.py b/nodezator/dialog.py index 5ebb2c62..8e6747cc 100644 --- a/nodezator/dialog.py +++ b/nodezator/dialog.py @@ -19,7 +19,7 @@ from .translation import DIALOGS_MAP -from .pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen +from .pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen, set_modal from .classes2d.single import Object2D from .classes2d.collections import List2D @@ -176,7 +176,7 @@ class DialogManager(Object2D, LoopHolder): wherever needed in the entire package. """ - def show_dialog_from_key(self, key): + def show_dialog_from_key(self, key, callback = None): """Create a dialog with data from dialogs map. Parameters @@ -186,9 +186,9 @@ def show_dialog_from_key(self, key): dialogs map with which to generate the dialog. """ data = DIALOGS_MAP[key] - return self.create_and_show_dialog(**data) + self.create_and_show_dialog(callback = callback, **data) - def show_formatted_dialog(self, key, *args): + def show_formatted_dialog(self, key, callback = None, *args): """Like show_dialog_from_key(), can format message. Parameters @@ -207,8 +207,24 @@ def show_formatted_dialog(self, key, *args): data["message"] = data["message"].format(*args) ### create dialog - return self.create_and_show_dialog(**data) + self.create_and_show_dialog(callback = callback, **data) + def create_and_show_dialog_callback(self): + ### blit semitransparent obj (make the dialog + ### appear unhighlighted; this is important in + ### case the portions of the screen showing the + ### dialog aren't updated by the next object + ### managing the screen once we leave this + ### method) + self.rect_sized_semitransp_obj.draw() + + ### free up memory from text objects and buttons + self.free_up_memory() + + ### finally return the value picked + if self.callback is not None: + self.callback(self.value) + ### TODO the unhighlight_obj parameter could probably ### be much more versatile, think about it; it could ### also be renamed; @@ -231,6 +247,7 @@ def create_and_show_dialog( dialog_offset_by=(0, 0), ## flag dismissable=False, + callback=None ): """Create a dialog with/out buttons. @@ -260,6 +277,7 @@ def create_and_show_dialog( """ ### store dismissable flag self.dismissable = dismissable + self.callback = callback ### ensure screen rect is used if specific rects ### are None @@ -323,21 +341,7 @@ def create_and_show_dialog( self.draw_once() ### loop - self.loop() - - ### blit semitransparent obj (make the dialog - ### appear unhighlighted; this is important in - ### case the portions of the screen showing the - ### dialog aren't updated by the next object - ### managing the screen once we leave this - ### method) - self.rect_sized_semitransp_obj.draw() - - ### free up memory from text objects and buttons - self.free_up_memory() - - ### finally return the value picked - return self.value + self.loop(callback = self.create_and_show_dialog_callback) def create_message(self, message_text): """Create and position message text object(s). diff --git a/nodezator/editing/data.py b/nodezator/editing/data.py index 95c839ba..93cf4e70 100644 --- a/nodezator/editing/data.py +++ b/nodezator/editing/data.py @@ -136,6 +136,7 @@ class DataHandling: def __init__(self): + self.text_block = None self.title_entry = StringEntry( value="output", command=self.update_data_node_title, @@ -261,20 +262,7 @@ def edit_text_of_selected(self): " or data node for its text to be edited." ) - def edit_text_block_text(self, text_block): - """Edit text block text on text editor.""" - ### retrieve its text - text = text_block.data["text"] - - ### edit the text - - edited_text = edit_text( - text=text, - font_path=FIRA_MONO_BOLD_FONT_PATH, - syntax_highlighting="comment", - validation_command=is_text_block_text_valid, - ) - + def edit_text_block_text_callback(self, edited_text): ### if the edited text is None, it means the user ### cancelled editing the text, so we just indicate ### such in the status bar @@ -287,7 +275,7 @@ def edit_text_block_text(self, text_block): ### one, we do nothing besides indicating such ### in the status bar - elif edited_text == text: + elif edited_text == self.text_block.data["text"]: set_status_message( "Text of text block wasn't updated, since" " text didn't change" @@ -296,7 +284,7 @@ def edit_text_block_text(self, text_block): else: ## insert the new text - text_block.data["text"] = edited_text + self.text_block.data["text"] = edited_text ## indicate the change in the data indicate_unsaved() @@ -306,7 +294,23 @@ def edit_text_block_text(self, text_block): set_status_message("Text of text block was edited.") ## rebuild the surface of the text block - text_block.rebuild_surf() + self.text_block.rebuild_surf() + self.text_block = None + + def edit_text_block_text(self, text_block): + """Edit text block text on text editor.""" + self.text_block = text_block + ### retrieve its text + text = text_block.data["text"] + + ### edit the text + edit_text( + text=text, + font_path=FIRA_MONO_BOLD_FONT_PATH, + syntax_highlighting="comment", + validation_command=is_text_block_text_valid, + callback = self.edit_text_block_text_callback, + ) def edit_data_node_title(self, data_node): diff --git a/nodezator/editing/imageexport/form.py b/nodezator/editing/imageexport/form.py index f2dfa2d1..5d8168b3 100644 --- a/nodezator/editing/imageexport/form.py +++ b/nodezator/editing/imageexport/form.py @@ -1,7 +1,7 @@ """Form for new file creation.""" ### standard library imports - +import asyncio from pathlib import Path from functools import partial, partialmethod @@ -27,7 +27,7 @@ from ...translation import TRANSLATION_HOLDER as t -from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen +from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen, set_modal, has_multi_modal from ...dialog import create_and_show_dialog @@ -54,6 +54,11 @@ from ...surfsman.draw import draw_border, draw_depth_finish from ...surfsman.render import render_rect +from ...surfsman.cache import ( + UNHIGHLIGHT_SURF_MAP, + cache_screen_state, + draw_cached_screen_state, +) from ...loopman.exception import ( QuitAppException, @@ -433,11 +438,7 @@ def build_form_widgets(self): self.widgets.extend((self.cancel_button, self.submit_button)) - def change_filepath(self): - """Pick new path and update label using it.""" - ### pick new path - - paths = select_paths(caption=NEW_IMAGEPATH_CAPTION, path_name=DEFAULT_FILENAME) + def change_filepath_callback(self, paths): ### if paths were given, there can only be one, ### it should be used as the new filepath @@ -447,6 +448,16 @@ def change_filepath(self): self.set_new_filepath(new_filepath) ### + + def change_filepath(self): + """Pick new path and update label using it.""" + ### pick new path + + select_paths( + caption=NEW_IMAGEPATH_CAPTION, + path_name=DEFAULT_FILENAME, + callback = self.change_filepath_callback, + ) def set_new_filepath(self, filepath): @@ -501,41 +512,14 @@ def update_image_type(self): self.set_new_filepath(new_filepath) - def get_image_exporting_settings(self, size): - """Return settings to export an image.""" - ### set form data to None - self.form_data = None - - ### store size - self.size = size - - ### draw screen sized semi-transparent object, - ### so that screen behind form appears as if - ### unhighlighted - - blit_on_screen(UNHIGHLIGHT_SURF_MAP[SCREEN_RECT.size], (0, 0)) - - ### create and store a label informing the image - ### size to the user - - text = (t.editing.image_export_form.final_image_size).format(*size) - - bottomleft = self.rect.move(5, -50).bottomleft - - size_label = Object2D.from_surface( - surface=(render_text(text=text, **TEXT_SETTINGS)), - coordinates_name="bottomleft", - coordinates_value=bottomleft, - ) - - self.widgets.append(size_label) - - ### loop until running attribute is set to False - + async def get_image_exporting_settings_loop(self): + self.running = True - self.loop_holder = self - + level = set_modal(True) while self.running: + await asyncio.sleep(0) + if has_multi_modal(level): + continue; ### perform various checkups for this frame; ### @@ -563,7 +547,8 @@ def get_image_exporting_settings(self, size): self.loop_holder = err.loop_holder ### remove the size label - self.widgets.remove(size_label) + self.widgets.remove(self.size_label) + self.size_label = None ### blit the rect sized semitransparent obj ### on the screen so the form appear as if @@ -571,8 +556,52 @@ def get_image_exporting_settings(self, size): self.rect_size_semitransp_obj.draw() ### finally, return the form data - return self.form_data + set_modal(False) + if self.callback is not None: + self.callback(self.form_data) + + def get_image_exporting_settings(self, size, callback = None): + self.callback = callback + """Return settings to export an image.""" + ### set form data to None + self.form_data = None + self.size_label = None + + ### store size + self.size = size + + ### draw screen sized semi-transparent object, + ### so that screen behind form appears as if + ### unhighlighted + + blit_on_screen(UNHIGHLIGHT_SURF_MAP[SCREEN_RECT.size], (0, 0)) + + ### update the copy of the screen as it is now + cache_screen_state() + ### create and store a label informing the image + ### size to the user + + text = (t.editing.image_export_form.final_image_size).format(*size) + + bottomleft = self.rect.move(5, -50).bottomleft + + size_label = Object2D.from_surface( + surface=(render_text(text=text, **TEXT_SETTINGS)), + coordinates_name="bottomleft", + coordinates_value=bottomleft, + ) + + self.widgets.append(size_label) + + ### loop until running attribute is set to False + + self.loop_holder = self + self.size_label = size_label + + asyncio.get_running_loop().create_task(self.get_image_exporting_settings_loop()) + + def handle_input(self): """Process events from event queue.""" for event in SERVICES_NS.get_events(): @@ -708,6 +737,8 @@ def draw(self): Extends Object2D.draw. """ + ### draw a cached copy of the screen over itself + draw_cached_screen_state() ### draw self (background) super().draw() diff --git a/nodezator/editing/imageexport/main.py b/nodezator/editing/imageexport/main.py index 3f9e4f97..f7dbe2a9 100644 --- a/nodezator/editing/imageexport/main.py +++ b/nodezator/editing/imageexport/main.py @@ -69,70 +69,13 @@ ### create logger for module logger = get_new_logger(__name__) +all_rects = None +all_rectsman = None -def export_as_image(): - """Export loaded file as .html/.svg or .png file. - - The current state of the file is exported, that is, - the objects currently alive, regardless of whether - they are saved or not in the disk. - """ - ### - gm = APP_REFS.gm - - ### if there are no live objects at all in the - ### file, notify user via dialog and cancel - ### operation by returning earlier - - if not gm.nodes and not gm.text_blocks: - - create_and_show_dialog( - "To export the loaded file as an image there" - " must exist at least one live object in it" - ) - - return - - ### gather the rects of all objects in the loaded - ### file; - ### - ### nodes, which are complex objects with more - ### than one rect will be using a rect-like object - ### called "rectsman"; - - all_rects = list( - chain( - # rects (rectsmans) from nodes - (node.rectsman for node in gm.nodes), - # rects from preview toolbars - (obj.rect for obj in gm.preview_toolbars), - # rects from preview panels - (obj.rect for obj in gm.preview_panels), - # rects from text blocks - (block.rect for block in gm.text_blocks), - ) - ) - - ### using the list we created, instantiate a rects - ### manager to control the position of all objects - ### in the loaded file; - all_rectsman = RectsManager(all_rects.__iter__) - - ### store the size of the resulting rects manager - size = all_rectsman.size - - ### prompt user for image exporting settings - ### (passing along the total size occupied by - ### the objects in the loaded file as additional - ### info to be displayed to the user) - settings = get_image_exporting_settings(size) - - ### if the settings received are actually None, - ### it means the user cancelled the operation, - ### so we exit the method by returning - if settings is None: - return - +def do_export_as_image(settings): + global all_rects + global all_rectsman + ### otherwise we proceed with the image exporting ### operation... @@ -169,7 +112,8 @@ def export_as_image(): export_method = export_file_as_html ### try creating and saving image - + msg = None + error_str = "" try: export_method(**settings) @@ -189,9 +133,6 @@ def export_as_image(): error_str = str(err) - else: - error_str = "" - ### then restore the objects to their original ### positions all_rectsman.topleft = original_topleft @@ -200,6 +141,9 @@ def export_as_image(): ### operations above total = time() - start + all_rects = None + all_rectsman = None + ### if there was an error message (an error ### occured), show it to the user via a dialog @@ -230,6 +174,72 @@ def export_as_image(): set_status_message(message) +def export_as_image_callback(settings): + ### if the settings received are actually None, + ### it means the user cancelled the operation, + ### so we exit the method by returning + if settings is None: + return + do_export_as_image(settings) + +def export_as_image(): + global all_rects + global all_rectsman + """Export loaded file as .html/.svg or .png file. + + The current state of the file is exported, that is, + the objects currently alive, regardless of whether + they are saved or not in the disk. + """ + ### + gm = APP_REFS.gm + + ### if there are no live objects at all in the + ### file, notify user via dialog and cancel + ### operation by returning earlier + + if not gm.nodes and not gm.text_blocks: + + create_and_show_dialog( + "To export the loaded file as an image there" + " must exist at least one live object in it" + ) + + return + + ### gather the rects of all objects in the loaded + ### file; + ### + ### nodes, which are complex objects with more + ### than one rect will be using a rect-like object + ### called "rectsman"; + + all_rects = list( + chain( + # rects (rectsmans) from nodes + (node.rectsman for node in gm.nodes), + # rects from preview toolbars + (obj.rect for obj in gm.preview_toolbars), + # rects from preview panels + (obj.rect for obj in gm.preview_panels), + # rects from text blocks + (block.rect for block in gm.text_blocks), + ) + ) + + ### using the list we created, instantiate a rects + ### manager to control the position of all objects + ### in the loaded file; + all_rectsman = RectsManager(all_rects.__iter__) + + ### prompt user for image exporting settings + ### (passing along the total size occupied by + ### the objects in the loaded file as additional + ### info to be displayed to the user) + get_image_exporting_settings( + all_rectsman.size, + callback = export_as_image_callback + ) def export_file_as_web_markup( width, diff --git a/nodezator/editing/nodepacksforms/renaming.py b/nodezator/editing/nodepacksforms/renaming.py index 311cdc06..4fb6ba52 100644 --- a/nodezator/editing/nodepacksforms/renaming.py +++ b/nodezator/editing/nodepacksforms/renaming.py @@ -1,6 +1,7 @@ """Form for changing node packs on existing file.""" ### standard library imports +import asyncio from pathlib import Path @@ -30,7 +31,7 @@ from ...translation import TRANSLATION_HOLDER as t -from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen +from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen, set_modal from ...appinfo import NATIVE_FILE_EXTENSION @@ -265,6 +266,20 @@ def build_form_widgets(self): self.widgets.extend((self.cancel_button, self.submit_button)) + def choose_filepath_callback(self, paths): + ### if paths were given, a single one, should be + ### used as the new filepath; + ### + ### update the label using the given value + + if paths: + + self.chosen_filepath = paths[0] + + self.chosen_filepath_label.set(str(self.chosen_filepath)) + + self.build_renaming_subform() + def choose_filepath(self, event): """Pick new path and update label using it. @@ -281,20 +296,11 @@ def choose_filepath(self, event): """ ### pick new path - paths = select_paths(caption=FILE_MANAGER_CAPTION) - - ### if paths were given, a single one, should be - ### used as the new filepath; - ### - ### update the label using the given value - - if paths: - - self.chosen_filepath = paths[0] + select_paths( + caption=FILE_MANAGER_CAPTION, + callback = self.choose_filepath_callback, + ) - self.chosen_filepath_label.set(str(self.chosen_filepath)) - - self.build_renaming_subform() ### TODO build subform (two columns, where first has ### current names as labels and second has string @@ -392,19 +398,11 @@ def build_renaming_subform(self): (node_pack_name_to_ids[node_pack_name].append(node_id)) - def present_rename_node_packs_form(self): - """Allow user to rename node packs on any file.""" - ### draw semi-transparent object so screen behind - ### form appears as if unhighlighted - - blit_on_screen(UNHIGHLIGHT_SURF_MAP[SCREEN_RECT.size], (0, 0)) - - ### loop until running attribute is set to False - - self.running = True - self.loop_holder = self + async def present_rename_node_packs_form_loop(self): + set_modal(True) while self.running: + await asyncio.sleep(0) ### perform various checkups for this frame; ### @@ -434,6 +432,24 @@ def present_rename_node_packs_form(self): ### draw a semitransparent object over the ### form, so it appears as if unhighlighted self.rect_size_semitransp_obj.draw() + set_modal(False) + if self.callback is not None: + self.callback() + + def present_rename_node_packs_form(self, callback = None): + """Allow user to rename node packs on any file.""" + self.callback = callback + ### draw semi-transparent object so screen behind + ### form appears as if unhighlighted + + blit_on_screen(UNHIGHLIGHT_SURF_MAP[SCREEN_RECT.size], (0, 0)) + + ### loop until running attribute is set to False + + self.running = True + self.loop_holder = self + + asyncio.get_running_loop().create_task(self.present_rename_node_packs_form_loop()) def handle_input(self): """Process events from event queue.""" diff --git a/nodezator/editing/nodepacksforms/selection.py b/nodezator/editing/nodepacksforms/selection.py index a79c8f3f..8728fe55 100644 --- a/nodezator/editing/nodepacksforms/selection.py +++ b/nodezator/editing/nodepacksforms/selection.py @@ -1,10 +1,12 @@ """Form for changing node packs on existing file.""" ### standard library imports +import asyncio from pathlib import Path from functools import partial, partialmethod +from operator import methodcaller ### third-party imports @@ -31,7 +33,7 @@ from ...translation import TRANSLATION_HOLDER as t -from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen +from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen, set_modal, has_multi_modal from ...appinfo import NATIVE_FILE_EXTENSION @@ -472,12 +474,13 @@ def build_form_widgets(self): draw_depth_finish(button_surf) - show_loading_nodes_page = partial( - open_htsl_link, - "nodezator://manual.nodezator.pysite/ch-loading-nodes.htsl", - ) + #show_loading_nodes_page = partial( + # open_htsl_link, + # "nodezator://manual.nodezator.pysite/ch-loading-nodes.htsl", + #) - help_button = Button(button_surf, command=show_loading_nodes_page) + #help_button = Button(button_surf, command=show_loading_nodes_page) + help_button = Button(button_surf, command=self.show_help) help_button.rect.bottomleft = widgets.rect.bottomleft @@ -514,19 +517,21 @@ def retrieve_current_node_packs(self): ### list each node pack self.add_node_packs(all_packs) + def add_local_node_packs_callback(self, paths): + if not paths: + return + + self.add_node_packs(paths) + + def add_local_node_packs(self): """""" ### select new paths; - - paths = select_paths( + select_paths( caption=("Select node to be added to file"), + callback = self.add_local_node_packs_callback, ) - if not paths: - return - - self.add_node_packs(paths) - def add_installed_node_packs_from_entry(self): entry = self.add_installed_node_packs_entry @@ -539,7 +544,7 @@ def add_installed_node_packs_from_entry(self): self.add_node_packs(new_installed_node_packs) def add_node_packs(self, node_packs): - + ### make sure node_packs is a containter if not isinstance(node_packs, (list, tuple)): @@ -620,6 +625,7 @@ def add_node_packs(self, node_packs): y_diff = panel_rect.top - item.rect.top npwl.rect.move_ip(0, y_diff) + def remove_item(self, item): ### reference node pack widget list locally @@ -651,29 +657,13 @@ def remove_item(self, item): y_diff = top - npwl_top npwl.rect.move_ip(0, y_diff) - def present_change_node_packs_form(self): - """Allow user to change node packs on any file.""" - ### perform screen preparations - perform_screen_preparations() - - ### - self.retrieve_current_node_packs() - - ### update values on option menu - - self.node_packs_option_menu.reset_value_and_options( - value=OPTION_MENU_DEFAULT_STRING, - options=[OPTION_MENU_DEFAULT_STRING] + get_known_node_packs(), - custom_command=False, - ) - - ### loop until running attribute is set to False - - self.running = True - self.loop_holder = self + async def present_change_node_packs_form_loop(self): + level = set_modal(True) while self.running: - + await asyncio.sleep(0) + if has_multi_modal(level): + continue; SERVICES_NS.frame_checkups() ### put the handle_input/update/draw method @@ -699,6 +689,35 @@ def present_change_node_packs_form(self): ### draw a semitransparent object over the ### form, so it appears as if unhighlighted self.rect_size_semitransp_obj.draw() + + set_modal(False) + if self.callback is not None: + self.callback() + + def present_change_node_packs_form(self, callback = None): + """Allow user to change node packs on any file.""" + self.callback = callback + + ### perform screen preparations + perform_screen_preparations() + + ### + self.retrieve_current_node_packs() + + ### update values on option menu + + self.node_packs_option_menu.reset_value_and_options( + value=OPTION_MENU_DEFAULT_STRING, + options=[OPTION_MENU_DEFAULT_STRING] + get_known_node_packs(), + custom_command=False, + ) + + ### loop until running attribute is set to False + + self.running = True + self.loop_holder = self + + asyncio.get_running_loop().create_task(self.present_change_node_packs_form_loop()) def handle_input(self): """Process events from event queue.""" @@ -848,6 +867,18 @@ def scroll(self, dy): scroll_up = partialmethod(scroll, 30) scroll_down = partialmethod(scroll, -30) + def show_help_callback(self): + self.draw = self._draw + + def show_help(self): + # ugly hack to display multi modal dialog + self._draw = self.draw + self.draw = partial(methodcaller('update_screen'), SERVICES_NS) + open_htsl_link( + "nodezator://manual.nodezator.pysite/ch-loading-nodes.htsl", + callback = self.show_help_callback, + ) + def apply_changes(self): """Treat data and, if valid, perform changes.""" current_packs = set( diff --git a/nodezator/editing/objinsert.py b/nodezator/editing/objinsert.py index acc76218..51742b5f 100644 --- a/nodezator/editing/objinsert.py +++ b/nodezator/editing/objinsert.py @@ -8,7 +8,6 @@ ### local imports - from ..config import APP_REFS from ..dialog import create_and_show_dialog @@ -67,11 +66,7 @@ def __init__(self): ### was triggered by a duplication operation self.moving_from_duplication = False - def pick_widget_for_proxy_node(self): - - ### retrieve widget kwargs - widget_data = pick_widget() - + def pick_widget_callback(self, widget_data): ### if widget data is None, cancel the operation ### by returning earlier if widget_data is None: @@ -83,6 +78,11 @@ def pick_widget_for_proxy_node(self): self.insert_node( node_hint=widget_data, ) + + def pick_widget_for_proxy_node(self): + + ### retrieve widget kwargs + pick_widget(callback = self.pick_widget_callback) def insert_node( self, diff --git a/nodezator/editing/playback/demonstrate/__init__.py b/nodezator/editing/playback/demonstrate/__init__.py index 0487ea79..0d135e19 100644 --- a/nodezator/editing/playback/demonstrate/__init__.py +++ b/nodezator/editing/playback/demonstrate/__init__.py @@ -1,6 +1,8 @@ """Form for setting and triggering demonstration playing.""" ### standard library imports +import asyncio + from functools import partial, partialmethod @@ -51,7 +53,7 @@ from ....appinfo import NATIVE_FILE_EXTENSION from ....pygamesetup import ( - SERVICES_NS, SCREEN_RECT, blit_on_screen, SCREEN, + SERVICES_NS, SCREEN_RECT, blit_on_screen, SCREEN, set_modal ) from ....dialog import create_and_show_dialog @@ -351,34 +353,11 @@ def update_window_size(self): w, h = TEXT_TO_WINDOW_SIZE[self.window_size_tray.get()] self.window_size_label_full.set(f'{w}x{h}') - def set_demonstration_session(self): - """Present form to set and trigger demonstration session.""" - ### exit with a dialog if feature is not ready for usage yet - - if APP_REFS.wip_lock: - create_and_show_dialog("This feature is a work in progress.") - return - - ### draw screen sized semi-transparent object, so that screen - ### behind form appears as if unhighlighted - blit_on_screen(UNHIGHLIGHT_SURF_MAP[SCREEN_RECT.size], (0, 0)) - - ### - self.search_box.reposition_cursor() - - start_text_input() - set_text_input_rect( - self.search_box.rect.move(0, 20) - ) - - self.focused_obj = self.search_box - - ### loop until running attribute is set to False - - self.running = True - self.loop_holder = self + async def set_demonstration_session_loop(self): + set_modal(True) while self.running: + await asyncio.sleep(0) ### perform various checkups for this frame; ### @@ -415,6 +394,39 @@ def set_demonstration_session(self): ### on the screen so the form appear as if ### unhighlighted self.rect_size_semitransp_obj.draw() + set_modal(False) + if self.callback is not None: + self.callback() + + def set_demonstration_session(self, callback = None): + """Present form to set and trigger demonstration session.""" + ### exit with a dialog if feature is not ready for usage yet + + if APP_REFS.wip_lock: + create_and_show_dialog("This feature is a work in progress.") + return + + self.callback = callback + ### draw screen sized semi-transparent object, so that screen + ### behind form appears as if unhighlighted + blit_on_screen(UNHIGHLIGHT_SURF_MAP[SCREEN_RECT.size], (0, 0)) + + ### + self.search_box.reposition_cursor() + + start_text_input() + set_text_input_rect( + self.search_box.rect.move(0, 20) + ) + + self.focused_obj = self.search_box + + ### loop until running attribute is set to False + + self.running = True + self.loop_holder = self + + asyncio.get_running_loop().create_task(self.set_demonstration_session_loop()) def handle_input(self): """Process events from event queue.""" diff --git a/nodezator/editing/playback/play.py b/nodezator/editing/playback/play.py index 882f2dce..0036f9e2 100644 --- a/nodezator/editing/playback/play.py +++ b/nodezator/editing/playback/play.py @@ -1,6 +1,7 @@ """Form for setting and triggering session playing.""" ### standard library imports +import asyncio from pathlib import Path @@ -30,7 +31,7 @@ from ...appinfo import NATIVE_FILE_EXTENSION -from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen +from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen, set_modal from ...pygamesetup.constants import FPS @@ -350,16 +351,20 @@ def build_form_widgets(self): ## store widgets.extend((self.cancel_button, self.start_button)) - def change_filepath(self): - """Pick new path and update label using it.""" - ### pick new path - paths = select_paths(caption="Select session data file to play") - + def change_filepath_callback(self, paths): ### if paths were given, there can only be one, ### it should be used as the new filepath if paths: self.filepath_label.set(str(paths[0])) + + def change_filepath(self): + """Pick new path and update label using it.""" + ### pick new path + select_paths( + caption="Select session data file to play", + callback = self.change_filepath_callback, + ) def check_speed_button_surfs(self): """Highlight/unhighlighted speed button surfaces. @@ -379,25 +384,10 @@ def check_speed_button_surfs(self): index = 1 if current_speed == speed else 0 button.image = SPEED_BUTTON_SURF_MAP[button][index] - def set_session_playing(self): - """Present form to set and trigger playing session.""" - ### exit with a dialog if feature is not ready for usage yet - - if APP_REFS.wip_lock: - create_and_show_dialog("This feature is a work in progress.") - return - - ### draw screen sized semi-transparent object, - ### so that screen behind form appears as if - ### unhighlighted - blit_on_screen(UNHIGHLIGHT_SURF_MAP[SCREEN_RECT.size], (0, 0)) - - ### loop until running attribute is set to False - - self.running = True - self.loop_holder = self - + async def set_session_playing_loop(self): + set_modal(True) while self.running: + await asyncio.sleep(0) ### perform various checkups for this frame; ### @@ -428,6 +418,30 @@ def set_session_playing(self): ### on the screen so the form appear as if ### unhighlighted self.rect_size_semitransp_obj.draw() + set_modal(False) + if self.callback is not None: + self.callback() + + def set_session_playing(self, callback = None): + """Present form to set and trigger playing session.""" + ### exit with a dialog if feature is not ready for usage yet + + if APP_REFS.wip_lock: + create_and_show_dialog("This feature is a work in progress.") + return + + self.callback = callback + ### draw screen sized semi-transparent object, + ### so that screen behind form appears as if + ### unhighlighted + blit_on_screen(UNHIGHLIGHT_SURF_MAP[SCREEN_RECT.size], (0, 0)) + + ### loop until running attribute is set to False + + self.running = True + self.loop_holder = self + + asyncio.get_running_loop().create_task(self.set_session_playing_loop()) def handle_input(self): diff --git a/nodezator/editing/playback/record.py b/nodezator/editing/playback/record.py index 11863bd3..020738ff 100644 --- a/nodezator/editing/playback/record.py +++ b/nodezator/editing/playback/record.py @@ -1,6 +1,7 @@ """Form for setting and triggering session recording.""" ### standard library imports +import asyncio from pathlib import Path @@ -30,7 +31,7 @@ from ...appinfo import NATIVE_FILE_EXTENSION -from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen +from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen, set_modal from ...dialog import create_and_show_dialog @@ -139,7 +140,8 @@ def __init__(self): ### assign behaviour self.update = empty_function - + self.label = None + ### center form and also append centering method ### as a window resize setup @@ -448,6 +450,13 @@ def build_form_widgets(self): ## store widgets.extend((self.cancel_button, self.start_button)) + def change_filepath_callback(self, paths): + ### if paths were given, there can only be one, + ### it should be used as the new filepath + if paths: + self.label.set(str(paths[0])) + pass + def change_filepath(self, path_purpose='recording'): """Pick new path and update label using it.""" @@ -455,7 +464,7 @@ def change_filepath(self, path_purpose='recording'): caption="Select path wherein to save session recording" path_name=DEFAULT_RECORDING_FILENAME - label = self.recording_filepath_label + self.label = self.recording_filepath_label else: @@ -464,16 +473,13 @@ def change_filepath(self, path_purpose='recording'): " recording session" ) path_name=DEFAULT_FILENAME_TO_LOAD - label = self.filepath_to_load_label + self.label = self.filepath_to_load_label ### pick new path - paths = select_paths(caption=caption, path_name=path_name) + select_paths(caption=caption, + path_name=path_name, + callback = change_filepath_callback) - ### if paths were given, there can only be one, - ### it should be used as the new filepath - - if paths: - label.set(str(paths[0])) change_recording_filepath = partialmethod(change_filepath, 'recording') change_filepath_to_load = partialmethod(change_filepath, 'to_load') @@ -507,25 +513,10 @@ def set_loaded(self): else: self.filepath_to_load_label.set(str(current)) - def set_session_recording(self): - """Present form to set and trigger recording session.""" - ### exit with a dialog if feature is not ready for usage yet - - if APP_REFS.wip_lock: - create_and_show_dialog("This feature is a work in progress.") - return - - ### draw screen sized semi-transparent object, - ### so that screen behind form appears as if - ### unhighlighted - blit_on_screen(UNHIGHLIGHT_SURF_MAP[SCREEN_RECT.size], (0, 0)) - - ### loop until running attribute is set to False - - self.running = True - self.loop_holder = self - + async def set_session_recording_loop(self): + set_modal(True) while self.running: + await asyncio.sleep(0) ### perform various checkups for this frame; ### @@ -556,7 +547,31 @@ def set_session_recording(self): ### on the screen so the form appear as if ### unhighlighted self.rect_size_semitransp_obj.draw() + set_modal(False) + if self.callback is not None: + self.callback() + + + def set_session_recording(self, callback = None): + """Present form to set and trigger recording session.""" + ### exit with a dialog if feature is not ready for usage yet + + if APP_REFS.wip_lock: + create_and_show_dialog("This feature is a work in progress.") + return + + self.callback = callback + ### draw screen sized semi-transparent object, + ### so that screen behind form appears as if + ### unhighlighted + blit_on_screen(UNHIGHLIGHT_SURF_MAP[SCREEN_RECT.size], (0, 0)) + + ### loop until running attribute is set to False + self.running = True + self.loop_holder = self + + asyncio.get_running_loop().create_task(self.set_session_recording_loop()) def handle_input(self): """Process events from event queue.""" @@ -651,12 +666,33 @@ def mouse_method_on_collision(self, method_name, event): on_mouse_release = partialmethod(mouse_method_on_collision, "on_mouse_release") + def do_recording(self): + ### gather data + data = { + "recording_title" : self.recording_title_entry.get(), + "recording_path" : Path(self.recording_filepath_label.get()), + "recording_size" : TEXT_TO_WINDOW_SIZE[self.window_size_tray.get()], + } + + filepath = ( + Path(self.filepath_to_load_label.get()) + if self.load_file_checkbutton.get() + else None + ) + + ### trigger session recording + raise ResetAppException(mode='record', filepath=filepath, data=data) + + def trigger_recording_callback(self, answer): + if not answer: + return + self.do_recording() + def trigger_recording(self): """Treat data and, if valid, trigger session recording.""" - if any_toggle_key_on(): - answer = create_and_show_dialog( + create_and_show_dialog( ( "A toggle key is turned on: (Caps Lock, Num Lock or both)." @@ -670,26 +706,12 @@ def trigger_recording(self): ("Start recording", True), ), dismissable=True, + callback = self.trigger_recording_callback, ) - - if not answer: return - - ### gather data - - data = { - "recording_title" : self.recording_title_entry.get(), - "recording_path" : Path(self.recording_filepath_label.get()), - "recording_size" : TEXT_TO_WINDOW_SIZE[self.window_size_tray.get()], - } - - filepath = ( - Path(self.filepath_to_load_label.get()) - if self.load_file_checkbutton.get() - else None - ) - - ### trigger session recording - raise ResetAppException(mode='record', filepath=filepath, data=data) + + else: + + self.do_recording() def draw(self): """Draw itself and widgets. diff --git a/nodezator/editing/pythonexporting.py b/nodezator/editing/pythonexporting.py index 127f8846..3b7445bc 100644 --- a/nodezator/editing/pythonexporting.py +++ b/nodezator/editing/pythonexporting.py @@ -32,51 +32,60 @@ ### main functions +exported_python_code = None -def export_as_python(): +def export_as_python_callback(paths): + global exported_python_code + + ### act according to whether paths were given - exported_python_code = get_exported_python_code() + ## if paths were given, there can only be one, + ## it should be used as the new filepath - if exported_python_code: + if paths: + filepath = paths[0] - ### grab filepath + ## if no path is given, we return earlier, since + ## it means the user cancelled setting a new + ## path + else: + return - paths = select_paths( - caption=NEW_PYTHON_FILEPATH_CAPTION, - path_name=DEFAULT_FILENAME, - ) + ### if the extension is not allowed, notify the + ### user and cancel the operation by returning - ### act according to whether paths were given + if filepath.suffix.lower() != ".py": - ## if paths were given, there can only be one, - ## it should be used as the new filepath + exported_python_code = None + create_and_show_dialog("File extension must be '.py'") + return - if paths: - filepath = paths[0] + ### otherwise, save the exported code in the given path - ## if no path is given, we return earlier, since - ## it means the user cancelled setting a new - ## path - else: - return + with open(filepath, mode="w", encoding="utf-8") as f: + f.write(exported_python_code) - ### if the extension is not allowed, notify the - ### user and cancel the operation by returning + exported_python_code = None + ### set status message informing user - if filepath.suffix.lower() != ".py": + message = f"File succesfully exported as python script in {filepath}" + set_status_message(message) - create_and_show_dialog("File extension must be '.py'") - return - ### otherwise, save the exported code in the given path +def export_as_python(): + global exported_python_code - with open(filepath, mode="w", encoding="utf-8") as f: - f.write(exported_python_code) + exported_python_code = get_exported_python_code() + + if exported_python_code: - ### set status message informing user + ### grab filepath - message = f"File succesfully exported as python script in {filepath}" - set_status_message(message) + select_paths( + caption=NEW_PYTHON_FILEPATH_CAPTION, + path_name=DEFAULT_FILENAME, + callback = export_as_python_callback, + ) def view_as_python(): diff --git a/nodezator/editing/widgetpicker/main.py b/nodezator/editing/widgetpicker/main.py index 563b6d5e..1e334993 100644 --- a/nodezator/editing/widgetpicker/main.py +++ b/nodezator/editing/widgetpicker/main.py @@ -1,7 +1,6 @@ """Facility for widget setup loop holder.""" - -### local imports - +### standard library imports +import asyncio from itertools import chain from functools import partial, partialmethod @@ -27,7 +26,7 @@ from ...translation import TRANSLATION_HOLDER as t -from ...pygamesetup import SERVICES_NS, SCREEN_RECT +from ...pygamesetup import SERVICES_NS, SCREEN_RECT, set_modal from ...our3rdlibs.button import Button @@ -293,22 +292,10 @@ def reposition_form_elements(self): ### to the right self.submit_button.rect.topleft = self.cancel_button.rect.move(10, 0).topright - def pick_widget(self): - """Display form; return widget instantiation data.""" - self.widget_data = None - - self.semitransp_obj.draw() - - ### loop until running attribute is set to False - - self.running = True - - ## TODO it seems the loop holder doesn't need - ## to be referenced in an attribute here, but can - ## be in a local variable, so make the change; - self.loop_holder = self - + async def pick_widget_loop(self): + set_modal(True) while self.running: + await asyncio.sleep(0) ### perform various checkups for this frame; ### @@ -334,8 +321,28 @@ def pick_widget(self): ## use the loop holder in the err ## attribute of same name self.loop_holder = err.loop_holder + + set_modal(False) + if self.callback is not None: + self.callback(self.widget_data) + + def pick_widget(self, callback = None): + """Display form; return widget instantiation data.""" + self.callback = callback + self.widget_data = None + + self.semitransp_obj.draw() + + ### loop until running attribute is set to False + + self.running = True + + ## TODO it seems the loop holder doesn't need + ## to be referenced in an attribute here, but can + ## be in a local variable, so make the change; + self.loop_holder = self - return self.widget_data + asyncio.get_running_loop().create_task(self.pick_widget_loop()) def handle_input(self): """Process events from event queue.""" @@ -525,10 +532,9 @@ def is_widget_data_ok(self, widget_data): ).format(type(err).__name__, str(err)) create_and_show_dialog(msg) - + return False ### otherwise return True - else: - return True + return True def update(self): """Empty method. diff --git a/nodezator/editing/widgetpopups/creation.py b/nodezator/editing/widgetpopups/creation.py index 248d9c15..4fba7b72 100644 --- a/nodezator/editing/widgetpopups/creation.py +++ b/nodezator/editing/widgetpopups/creation.py @@ -81,23 +81,14 @@ def trigger_simple_widget_picking(self, node, *other_references): self.focus_if_within_boundaries(SERVICES_NS.get_mouse_pos()) - def trigger_subparameter_widget_instantiation( + def do_trigger_subparameter_widget_instantiation( self, widget_data=None, ): - if widget_data is None: - - ### obtain widget data - widget_data = pick_widget() - - ### if widget data is still None, cancel the - ### operation by returning earlier - if widget_data is None: - return - - else: - widget_data = deepcopy(widget_data) + return + + widget_data = deepcopy(widget_data) node, *other_references = self.node_waiting_widget_refs @@ -105,3 +96,23 @@ def trigger_subparameter_widget_instantiation( widget_data, *other_references, ) + + def pick_widget_callback(self, widget_data): + ### if widget data is still None, cancel the + ### operation by returning earlier + if widget_data is not None: + self.do_trigger_subparameter_widget_instantiation(widget_data) + + def trigger_subparameter_widget_instantiation( + self, + widget_data=None, + ): + + if widget_data is None: + + ### obtain widget data + pick_widget(callback = self.pick_widget_callback) + return + + self.do_trigger_subparameter_widget_instantiation(widget_data) + \ No newline at end of file diff --git a/nodezator/fileman/dirpanel/main.py b/nodezator/fileman/dirpanel/main.py index 937d5c8b..125be1b3 100644 --- a/nodezator/fileman/dirpanel/main.py +++ b/nodezator/fileman/dirpanel/main.py @@ -447,24 +447,7 @@ def update_data_from_path_selection(self): ### based on their selection state self.update_path_objs_appearance() - def present_new_path_form(self, is_file): - """Present form to get a new path and pick action. - - The action picked will determine what will be - done with the path we get. - - Parameters - ========== - is_file (boolean) - whether path being create must be treated as - file or directory. - """ - ### present form to user by using get_path, - ### passing the current directory and whether the - ### new path is supposed to be a file or not; - ### store the form data once you leave the form - path = get_path(self.current_dir, is_file) - + def present_new_path_form_callback(self, path): ### if the path is None, it means the user ### cancelled the action, so return now to prevent ### any action from happening @@ -475,7 +458,7 @@ def present_new_path_form(self, is_file): ## pick path creation operation according to ## kind of path chosen - creation_operation = Path.touch if is_file else Path.mkdir + creation_operation = Path.touch if self.is_file else Path.mkdir ## try creating path ('exist_ok' set to False ## causes an error to be raised if path already @@ -489,7 +472,7 @@ def present_new_path_form(self, is_file): # pick name for kind of path depending on # is_file argument - path_kind = "file" if is_file else "folder" + path_kind = "file" if self.is_file else "folder" # put an error message together explaining # the error @@ -509,7 +492,29 @@ def present_new_path_form(self, is_file): self.load_current_dir_contents() self.jump_to_path(path) + + def present_new_path_form(self, is_file): + """Present form to get a new path and pick action. + + The action picked will determine what will be + done with the path we get. + Parameters + ========== + is_file (boolean) + whether path being create must be treated as + file or directory. + """ + ### present form to user by using get_path, + ### passing the current directory and whether the + ### new path is supposed to be a file or not; + ### store the form data once you leave the form + self.is_file = is_file + get_path( + self.current_dir, + is_file, + callback = self.present_new_path_form_callback + ) present_new_file_form = partialmethod(present_new_path_form, True) diff --git a/nodezator/fileman/dirpanel/newpathform.py b/nodezator/fileman/dirpanel/newpathform.py index 405b8edf..2c386677 100644 --- a/nodezator/fileman/dirpanel/newpathform.py +++ b/nodezator/fileman/dirpanel/newpathform.py @@ -1,6 +1,8 @@ """Form to create/return new path from file manager.""" ### standard library import +import asyncio + from functools import partialmethod @@ -22,7 +24,7 @@ from ...translation import TRANSLATION_HOLDER as t -from ...pygamesetup import SERVICES_NS, SCREEN_RECT +from ...pygamesetup import SERVICES_NS, SCREEN_RECT, set_modal from ...dialog import create_and_show_dialog @@ -261,8 +263,44 @@ def submit_form(self): ### False self.running = False - def get_path(self, parent, is_file): + async def get_path_loop(self): + set_modal(True) + while self.running: + await asyncio.sleep(0) + + ### perform various checkups for this frame; + ### + ### stuff like maintaing a constant framerate and more + SERVICES_NS.frame_checkups() + + ### put the handle_input/update/draw method + ### execution inside a try/except clause + ### so that the SwitchLoopException + ### thrown when focusing in and out of some + ### widgets is caught; also, you don't + ### need to catch the QuitAppException, + ### since it is caught in the main loop + + try: + + self.loop_holder.handle_input() + self.loop_holder.update() + self.loop_holder.draw() + + except SwitchLoopException as err: + + ## use the loop holder in the err + ## attribute of same name + self.loop_holder = err.loop_holder + + ### finally, return the new path + set_modal(False) + if self.callback is not None: + self.callback(self.new_path) + + def get_path(self, parent, is_file, callback = None): """Return new path after user edits form.""" + self.callback = callback ### store parent self.parent = parent @@ -305,35 +343,7 @@ def get_path(self, parent, is_file): self.running = True self.loop_holder = self - while self.running: - - ### perform various checkups for this frame; - ### - ### stuff like maintaing a constant framerate and more - SERVICES_NS.frame_checkups() - - ### put the handle_input/update/draw method - ### execution inside a try/except clause - ### so that the SwitchLoopException - ### thrown when focusing in and out of some - ### widgets is caught; also, you don't - ### need to catch the QuitAppException, - ### since it is caught in the main loop - - try: - - self.loop_holder.handle_input() - self.loop_holder.update() - self.loop_holder.draw() - - except SwitchLoopException as err: - - ## use the loop holder in the err - ## attribute of same name - self.loop_holder = err.loop_holder - - ### finally, return the new path - return self.new_path + asyncio.get_running_loop().create_task(self.get_path_loop()) def handle_input(self): """Process events from event queue.""" diff --git a/nodezator/fileman/op.py b/nodezator/fileman/op.py index 0a51a0f5..5642da00 100644 --- a/nodezator/fileman/op.py +++ b/nodezator/fileman/op.py @@ -1,7 +1,7 @@ """File Manager class extension with operations.""" ### standard-library imports - +import asyncio from os import pathsep from functools import partialmethod @@ -32,7 +32,7 @@ ### local imports -from ..pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen +from ..pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen, set_modal from ..translation import TRANSLATION_HOLDER as t @@ -55,12 +55,56 @@ class FileManagerOperations(Object2D): """Operations for file manager class.""" + async def select_paths_loop(self): + + ### keep looping the execution the methods + ### "handle_input", "update" and "drawing" of the + ### loop holder until running is set to False + + set_modal(True) + loop_holder = self + self.running = True + + while self.running: + await asyncio.sleep(0) + + ### perform various checkups for this frame; + ### + ### stuff like maintaing a constant framerate and more + SERVICES_NS.frame_checkups() + + ### run the GUD methods (check the glossary + ### for loop holder/loop/methods) + + try: + loop_holder.handle_input() + loop_holder.update() + loop_holder.draw() + + ### if a SwitchLoopException occur, set a new + ### object to become the loop holder + + except SwitchLoopException as err: + + ## use the loop holder in the err + ## attribute of same name + loop_holder = err.loop_holder + + ### blit smaller semi transparent object + self.rect_size_semitransp_obj.draw() + + set_modal(False) + ### return a copy of the path selection + if (self.callback): + self.callback(tuple(self.path_selection)) + def select_paths( self, *, caption="", path_name="", + callback = None, ): """Return selected paths. @@ -81,6 +125,7 @@ def select_paths( multiple path names, as long as you separate each path with the path separator character (os.pathsep). """ + self.callback = callback ### blit screen sized semi transparent object blit_on_screen(UNHIGHLIGHT_SURF_MAP[SCREEN_RECT.size], (0, 0)) @@ -104,52 +149,16 @@ def select_paths( ### set entry contents self.selection_entry.set(path_name) - + ### alias self as the loop holder - loop_holder = self - - ### keep looping the execution the methods - ### "handle_input", "update" and "drawing" of the - ### loop holder until running is set to False - - self.running = True - - while self.running: - - ### perform various checkups for this frame; - ### - ### stuff like maintaing a constant framerate and more - SERVICES_NS.frame_checkups() - - ### run the GUD methods (check the glossary - ### for loop holder/loop/methods) - - try: - - loop_holder.handle_input() - loop_holder.update() - loop_holder.draw() - - ### if a SwitchLoopException occur, set a new - ### object to become the loop holder - - except SwitchLoopException as err: - - ## use the loop holder in the err - ## attribute of same name - loop_holder = err.loop_holder - - ### blit smaller semi transparent object - self.rect_size_semitransp_obj.draw() - - ### return a copy of the path selection - return tuple(self.path_selection) + asyncio.get_running_loop().create_task(self.select_paths_loop()) def handle_input(self): """Handle event queue.""" for event in SERVICES_NS.get_events(): if event.type == QUIT: + set_modal(False) raise QuitAppException ### KEYDOWN diff --git a/nodezator/graphman/execution.py b/nodezator/graphman/execution.py index 4a48e0c7..f15b52e5 100644 --- a/nodezator/graphman/execution.py +++ b/nodezator/graphman/execution.py @@ -234,7 +234,7 @@ def execute_graph(self, nodes_to_visit=None): ### we'll now keep iterating while there are ### nodes left to be executed - + last_err = None while nodes_to_execute: ### try executing each node @@ -376,76 +376,7 @@ def execute_graph(self, nodes_to_visit=None): ## clear all stored arguments self.clear_arguments() - - ## if the error is among the ones listed - ## below, just notify the user via dialog, - ## using the error converted to a string - ## as the error message - - if isinstance( - err, - ( - MissingInputError, - MissingOutputError, - PositionalSubparameterUnpackingError, - KeywordSubparameterUnpackingError, - ), - ): - - create_and_show_dialog( - str(err), - level_name="error", - ) - - ## any other kind of error is unexpected, - ## so we take further measures - - else: - - ## log traceback in the user log - - USER_LOGGER.exception( - "An error occurred" " during graph execution." - ) - - ## if the error was caused during call - ## or execution of a node's callable, - ## display a custom error message - - if isinstance(err, NodeCallableError): - - # grab the node wherein the - # error bubbled up - error_node = err.node - - # grab the original error - original_error = err.__cause__ - - show_formatted_dialog( - "node_callable_error_dialog", - error_node.title_text, - error_node.id, - original_error.__class__.__name__, - str(original_error), - ) - - ## otherwise we just notify the user - ## with a custom error message - - else: - - error_msg = ( - "node layout execution failed" - " with an unexpected error. The" - " following message was issued" - " >> {}: {}" - ).format(err.__class__.__name__, str(err)) - - create_and_show_dialog( - error_msg, - level_name="error", - ) - + last_err = err ## break out of the "while loop" break @@ -482,6 +413,80 @@ def execute_graph(self, nodes_to_visit=None): time_for_humans = friendly_delta_from_secs(tracked_nodes_total) set_status_message(f"Total execution time was {time_for_humans}") + + if last_err is None: + return + + ## if the error is among the ones listed + ## below, just notify the user via dialog, + ## using the error converted to a string + ## as the error message + + if isinstance( + last_err, + ( + MissingInputError, + MissingOutputError, + PositionalSubparameterUnpackingError, + KeywordSubparameterUnpackingError, + ), + ): + + create_and_show_dialog( + str(last_err), + level_name="error", + ) + + ## any other kind of error is unexpected, + ## so we take further measures + + else: + + ## log traceback in the user log + + USER_LOGGER.exception( + "An error occurred" " during graph execution." + ) + + ## if the error was caused during call + ## or execution of a node's callable, + ## display a custom error message + + if isinstance(last_err, NodeCallableError): + + # grab the node wherein the + # error bubbled up + error_node = last_err.node + + # grab the original error + original_error = last_err.__cause__ + + show_formatted_dialog( + "node_callable_error_dialog", + error_node.title_text, + error_node.id, + original_error.__class__.__name__, + str(original_error), + ) + + ## otherwise we just notify the user + ## with a custom error message + + else: + + error_msg = ( + "node layout execution failed" + " with an unexpected error. The" + " following message was issued" + " >> {}: {}" + ).format(last_err.__class__.__name__, str(last_err)) + + create_and_show_dialog( + error_msg, + level_name="error", + ) + + def check_nodes_and_redirect_data(self, nodes_to_direct_data): diff --git a/nodezator/htsl/main.py b/nodezator/htsl/main.py index cd50e330..c1e81b46 100644 --- a/nodezator/htsl/main.py +++ b/nodezator/htsl/main.py @@ -205,7 +205,7 @@ def center_htsl_browser(self): if hasattr(self, "running") and self.running: APP_REFS.draw_after_window_resize_setups = self.draw_once - def open_htsl_link(self, link): + def open_htsl_link(self, link, callback = None): """Create a htsl page from existing htsl file. The file is found with the given file name. @@ -233,18 +233,24 @@ def open_htsl_link(self, link): ) ### show htsl page - self.show_htsl_page(optional_id) + self.show_htsl_page(optional_id, callback = callback) - def create_and_show_htsl_page(self, htsl_element): + def create_and_show_htsl_page(self, htsl_element, callback = None): self.create_and_set_htsl_objects(htsl_element) - self.show_htsl_page() + self.show_htsl_page(callback = callback) - def show_htsl_page(self, optional_id=""): + def show_htsl_page_callback(self): + ### free up memory from rendered objects + self.free_up_memory() + if self.callback is not None: + self.callback() + + def show_htsl_page(self, optional_id="", callback = None): + self.callback = callback ### define whether horizontal and vertical ### scrolling are enabled - available_width, available_height = self.content_area_obj.rect.size page_width, page_height = self.objs.rect.size @@ -281,10 +287,7 @@ def show_htsl_page(self, optional_id=""): self.draw_once() ### loop - self.loop() - - ### free up memory from rendered objects - self.free_up_memory() + self.loop(callback = self.show_htsl_page_callback) def open_link(self, link_string): diff --git a/nodezator/imagesman/previewer/main.py b/nodezator/imagesman/previewer/main.py index 718c1ac3..8c1bdeb8 100644 --- a/nodezator/imagesman/previewer/main.py +++ b/nodezator/imagesman/previewer/main.py @@ -1,5 +1,6 @@ """Facility for viewing images from given paths.""" - +### standard library import +import asyncio ### third-party import from pygame.math import Vector2 @@ -9,7 +10,7 @@ from ...config import APP_REFS -from ...pygamesetup import SERVICES_NS, SCREEN_RECT +from ...pygamesetup import SERVICES_NS, SCREEN_RECT, set_modal from ...ourstdlibs.behaviour import empty_function @@ -91,8 +92,27 @@ def center_images_previewer(self): if self.running: APP_REFS.draw_after_window_resize_setups = self.response_draw - def preview_images(self, image_paths): + async def preview_images_loop(self): + set_modal(True) + while self.running: + await asyncio.sleep(0) + + ### perform various checkups for this frame; + ### + ### stuff like maintaing a constant framerate and more + SERVICES_NS.frame_checkups() + + self.handle_input() + self.draw() + + set_modal(False) + if self.callback is not None: + self.callback() + + def preview_images(self, image_paths, callback = None): """Display previews of images from the given paths.""" + self.callback = callback + ### cache_screen_state() @@ -114,15 +134,7 @@ def preview_images(self, image_paths): self.running = True - while self.running: - - ### perform various checkups for this frame; - ### - ### stuff like maintaing a constant framerate and more - SERVICES_NS.frame_checkups() - - self.handle_input() - self.draw() + asyncio.get_running_loop().create_task(self.preview_images_loop()) def create_image_surfaces(self): """Create surfaces/objects representing images. diff --git a/nodezator/imagesman/previewer/op.py b/nodezator/imagesman/previewer/op.py index 882ed9f1..baff2edd 100644 --- a/nodezator/imagesman/previewer/op.py +++ b/nodezator/imagesman/previewer/op.py @@ -142,6 +142,9 @@ def browse_thumbs(self, steps): go_to_last = partialmethod(browse_thumbs, INFINITY) go_to_first = partialmethod(browse_thumbs, -INFINITY) + def try_visualizing_full_image_callback(self): + self.response_draw() + def try_visualizing_full_image(self): image_path = self.image_paths[self.thumb_index] @@ -161,8 +164,10 @@ def try_visualizing_full_image(self): create_and_show_dialog("Couldn't find the image") else: - view_surface(full_image_surface) - self.response_draw() + view_surface( + full_image_surface, + callback = self.try_visualizing_full_image_callback + ) def on_mouse_release(self, event): """""" diff --git a/nodezator/logman/main.py b/nodezator/logman/main.py index fcf9c978..21ee1d4e 100644 --- a/nodezator/logman/main.py +++ b/nodezator/logman/main.py @@ -23,7 +23,7 @@ from traceback import format_exception -from logging import DEBUG, Formatter, getLogger +from logging import DEBUG, Formatter, getLogger, StreamHandler from logging.handlers import RotatingFileHandler @@ -45,19 +45,26 @@ ### constants - +game_platform = get_os_name() +print(f"game_platform [{game_platform}]") ## path to log folder -if "APPDATA" in environ: - general_log_dir = Path(environ["APPDATA"]) - +LOG_TO_STDOUT = False +if (game_platform == "Emscripten"): + LOG_TO_STDOUT = True else: - general_log_dir = Path(environ["HOME"]) / ".local" + if "APPDATA" in environ: + general_log_dir = Path(environ["APPDATA"]) -APP_LOGS_DIR = general_log_dir / APP_DIR_NAME / "logs" + else: + general_log_dir = Path(environ["HOME"]) / ".local" -if not APP_LOGS_DIR.exists(): - APP_LOGS_DIR.mkdir(parents=True) + APP_LOGS_DIR = general_log_dir / APP_DIR_NAME / "logs" + APP_LOGS_DIR = general_log_dir / APP_DIR_NAME / "logs" + + if not APP_LOGS_DIR.exists(): + APP_LOGS_DIR.mkdir(parents=True) +# ## log level LOG_LEVEL = DEBUG @@ -113,6 +120,9 @@ class PylLogFormatter(Formatter): """Outputs pyl logs.""" + def __init__(self, log_to_console): + self.log_to_console = log_to_console + def format(self, record): """Return a log record as pyl formated data.""" ### serialize exception info if needed @@ -144,11 +154,16 @@ def format(self, record): *exc_info ) ) - - ### put the record data together in a dict and - ### return it as a pretty-formatted string with - ### a trailing comma - + if self.log_to_console: + ### put the record data together in a dict and + ### return it as a pretty-formatted string with + ### a trailing comma + if exc_info is None: + return (f'{str(datetime.now())} {record.levelname:5} {record.name}/{record.funcName}:{record.lineno} {record.msg}') + else: + return (f'{str(datetime.now())} {record.levelname:5} {record.name}/{record.funcName}:{record.lineno} {record.msg} {exc_info}') + # + # return ( pformat( { @@ -167,10 +182,6 @@ def format(self, record): ) -### instantiate the pyl formatter -pyl_formatter = PylLogFormatter() - - ### utility function for renaming log files @@ -191,33 +202,35 @@ def custom_format_filename(name): ### define a handler to generate a log file for each run - -log_filepath = str(APP_LOGS_DIR / "session.0.log") - -last_run_handler = RotatingFileHandler( - filename=log_filepath, - mode="a", - encoding="utf-8", - backupCount=BACKUP_COUNT, - maxBytes=MAX_BYTES, -) +if LOG_TO_STDOUT: + last_run_handler = StreamHandler() +else: + log_filepath = str(APP_LOGS_DIR / "session.0.log") + last_run_handler = RotatingFileHandler( + filename=log_filepath, + mode="a", + encoding="utf-8", + backupCount=BACKUP_COUNT, + maxBytes=MAX_BYTES, + ) + # rotate the log file if it is not empty + if getsize(log_filepath): + last_run_handler.doRollover() last_run_handler.namer = custom_format_filename - last_run_handler.setLevel(LOG_LEVEL) ### perform final setups +### instantiate the pyl formatter +pyl_formatter = PylLogFormatter(LOG_TO_STDOUT) + ## set pyl formatter on handler last_run_handler.setFormatter(pyl_formatter) ## add handler to the top level logger top_logger.addHandler(last_run_handler) -## rotate the log file if it is not empty -if getsize(log_filepath): - last_run_handler.doRollover() - ## reference the getChild method of the top_logger ## as get_new_logger; modules throughout the package in need ## of logging will import this operation to get new loggers diff --git a/nodezator/loopman/main.py b/nodezator/loopman/main.py index 120d4c3d..58eb1336 100644 --- a/nodezator/loopman/main.py +++ b/nodezator/loopman/main.py @@ -1,7 +1,7 @@ """Loop-related tools for classes.""" ### standard library imports - +import asyncio from functools import partial from operator import methodcaller @@ -11,7 +11,7 @@ ### local imports -from ..pygamesetup import SERVICES_NS +from ..pygamesetup import SERVICES_NS, set_modal, has_multi_modal from .exception import ( ContinueLoopException, @@ -22,25 +22,18 @@ class LoopHolder: - def loop(self): - - ### if loop hold doesn't have a 'draw' method, - ### assign update_screen to the attribute - - try: - self.draw - except AttributeError: - self.draw = partial(methodcaller('update_screen'), SERVICES_NS) - + async def async_loop(self, callback): ### set self as the loop holder loop_holder = self ### set a running flag and start the loop - self.running = True + level = set_modal(True) while self.running: - + await asyncio.sleep(0) + if has_multi_modal(level): + continue; ### perform various checkups for this frame; ### ### stuff like maintaing a constant framerate and more @@ -74,6 +67,41 @@ def loop(self): pass else: method() + set_modal(False) + try: + if callback is not None: + callback() + except SwitchLoopException as err: + + ## set new loop holder + loop_holder = err.loop_holder + + ## if loop holder has an enter method, + ## execute it + + try: + method = loop_holder.enter + except AttributeError: + pass + else: + print("method: ", method) + method() + print("method: executed") + except Exception as e: + raise e + + def loop(self, callback = None): + + ### if loop hold doesn't have a 'draw' method, + ### assign update_screen to the attribute + + try: + self.draw + except AttributeError: + self.draw = partial(methodcaller('update_screen'), SERVICES_NS) + + asyncio.get_running_loop().create_task(self.async_loop(callback)) + def exit_loop(self): self.running = False @@ -85,7 +113,8 @@ def handle_input(self): self.quit() def quit(self): - raise QuitAppException + set_modal(False) + #raise QuitAppException def update(self): """Do nothing.""" diff --git a/nodezator/our3rdlibs/sortingeditor/main.py b/nodezator/our3rdlibs/sortingeditor/main.py index 2fa4af4c..e152cf2f 100644 --- a/nodezator/our3rdlibs/sortingeditor/main.py +++ b/nodezator/our3rdlibs/sortingeditor/main.py @@ -1,6 +1,7 @@ """Facility with mvc manager for manually sorting lists.""" ### standard library imports +import asyncio from random import shuffle @@ -18,7 +19,7 @@ from ...config import APP_REFS -from ...pygamesetup import SERVICES_NS, SCREEN_RECT +from ...pygamesetup import SERVICES_NS, SCREEN_RECT, set_modal from ...ourstdlibs.behaviour import get_oblivious_callable @@ -315,7 +316,40 @@ def sort_items(self, sorting_callable): reverse_items = partialmethod(sort_items, list.reverse) shuffle_items = partialmethod(sort_items, shuffle) - def sort_sequence(self, sorted_items, available_items): + async def sort_sequence_loop(self): + set_modal(True) + while self.running: + await asyncio.sleep(0) + + ### perform various checkups for this frame; + ### + ### stuff like maintaing a constant framerate and more + SERVICES_NS.frame_checkups() + + ## perform GUD operations (initials of the + ## methods; see also the loop holder definition + ## on the glossary) + + self.handle_input() + self.update() + self.draw() + + ### once the loop is exited, return the appropriate + ### value according to the whether the 'cancel' + ### flag is set or not + set_modal(False) + if self.callback is not None: + self.callback( + ## if the flag is on, return None, indicating + ## the edition was cancelled + None + if self.cancel + ## otherwise return a sequence with the sorted + ## values in the type they were received + else _type(item.value for item in self.items) + ) + + def sort_sequence(self, sorted_items, available_items, callback = None): """Prepare objects for edition and start loop. Parameters @@ -326,6 +360,8 @@ def sort_sequence(self, sorted_items, available_items): set representing available items to include and sort in the sorted_items sequence. """ + self.callback = callback + ### store type of sorted items _type = type(sorted_items) @@ -339,35 +375,7 @@ def sort_sequence(self, sorted_items, available_items): self.running = True ### start the widget loop - - while self.running: - - ### perform various checkups for this frame; - ### - ### stuff like maintaing a constant framerate and more - SERVICES_NS.frame_checkups() - - ## perform GUD operations (initials of the - ## methods; see also the loop holder definition - ## on the glossary) - - self.handle_input() - self.update() - self.draw() - - ### once the loop is exited, return the appropriate - ### value according to the whether the 'cancel' - ### flag is set or not - - return ( - ## if the flag is on, return None, indicating - ## the edition was cancelled - None - if self.cancel - ## otherwise return a sequence with the sorted - ## values in the type they were received - else _type(item.value for item in self.items) - ) + asyncio.get_running_loop().create_task(self.sort_sequence_loop()) def prepare_objs(self, sorted_items, available_items): """Instantiate special objects representing items. diff --git a/nodezator/pygamesetup/__init__.py b/nodezator/pygamesetup/__init__.py index 7e78cdad..612be134 100644 --- a/nodezator/pygamesetup/__init__.py +++ b/nodezator/pygamesetup/__init__.py @@ -17,6 +17,24 @@ ## custom services from .services import normal, record, play +MODAL_LEVEL = 0 +def set_modal(is_modal): + global MODAL_LEVEL + if is_modal: + MODAL_LEVEL = MODAL_LEVEL + 1 + elif MODAL_LEVEL > 0: + MODAL_LEVEL = MODAL_LEVEL - 1 + return MODAL_LEVEL + +def is_modal(): + global MODAL_LEVEL + return (MODAL_LEVEL > 0) + +# FIXME: ugly hack to display +# multi level modal dialog +def has_multi_modal(last_level): + global MODAL_LEVEL + return (MODAL_LEVEL > last_level) ### create a namespace to store the services in use SERVICES_NS = type("Object", (), {})() diff --git a/nodezator/pygamesetup/__init__.py.bak b/nodezator/pygamesetup/__init__.py.bak new file mode 100644 index 00000000..328984ae --- /dev/null +++ b/nodezator/pygamesetup/__init__.py.bak @@ -0,0 +1,61 @@ +"""Setup of different modes.""" + +### local imports + +## constants + +from .constants import ( + SCREEN, + SCREEN_RECT, + GENERAL_NS, + GENERAL_SERVICE_NAMES, + reset_caption, + blit_on_screen, + clean_temp_files, +) + +## custom services +from .services import normal, record, play + +HAS_MODAL = False +def set_modal(is_modal): + global HAS_MODAL + HAS_MODAL = is_modal + +def is_modal(): + global HAS_MODAL + return HAS_MODAL + +### create a namespace to store the services in use +SERVICES_NS = type("Object", (), {})() + +### set normal services on namespace (enables normal mode) +### +### there's no need to reset the window mode this first time, +### because it was already properly set in the constants.py +### sibling module +normal.set_behaviour(SERVICES_NS, reset_window_mode=False) + + +### function to switch modes + +def switch_mode(mode_info_ns): + """Switch to specific mode according to reset info data. + + Parameters + ========== + mode_info_ns (loopman.exception.ResetAppException) + has attributes containing data about mode to be + switched to. + """ + mode = GENERAL_NS.mode_name = mode_info_ns.mode + print("*** MODE: ", mode) + + if mode == 'record': + record.set_behaviour(SERVICES_NS, mode_info_ns.data) + + elif mode == 'play': + play.set_behaviour(SERVICES_NS, mode_info_ns.data) + + elif mode == 'normal': + normal.set_behaviour(SERVICES_NS) diff --git a/nodezator/pygamesetup/constants.py.bak b/nodezator/pygamesetup/constants.py.bak new file mode 100644 index 00000000..7eca43bc --- /dev/null +++ b/nodezator/pygamesetup/constants.py.bak @@ -0,0 +1,381 @@ + +### standard library import +import asyncio +from functools import partial + + +### third-party imports + +from pygame import ( + init as init_pygame, + get_sdl_version, + locals as pygame_locals, +) + +from pygame.locals import ( + + QUIT, + KEYDOWN, + + K_F7, + K_F8, + + RESIZABLE, + KMOD_NONE, + +) + +from pygame.mixer import pre_init as pre_init_mixer + +from pygame.key import set_repeat + +from pygame.time import Clock + +from pygame.display import set_icon, set_caption, set_mode, update as display_update + +from pygame.image import load as load_image + +from pygame.event import get + + +# choose appropriate window resize event type according to +# availability + +try: + from pygame.locals import WINDOWRESIZED +except ImportError: + from pygame.locals import VIDEORESIZE + WINDOW_RESIZE_EVENT_TYPE = VIDEORESIZE +else: + WINDOW_RESIZE_EVENT_TYPE = WINDOWRESIZED + + +### local imports + +from ..config import APP_REFS, DATA_DIR + +from ..appinfo import FULL_TITLE, ABBREVIATED_TITLE + +from ..loopman.exception import QuitAppException + + + +### pygame initialization setups + +## pygame mixer pre-initialization +pre_init_mixer(44100, -16, 2, 4096) + +## pygame initialization +init_pygame() + + +### create a callable to reset the caption to a +### default state whenever needed, then use +### it to set the caption + +reset_caption = partial(set_caption, FULL_TITLE, ABBREVIATED_TITLE) +reset_caption() + +### set icon and caption for window + +image_path = str(DATA_DIR / "app_icon.png") +set_icon(load_image(image_path)) + + +### set key repeating (unit: milliseconds) + +set_repeat( + 500, # delay (time before repetition begins) + 30, # interval (interval between repetitions) +) + + +### create/set screen + +SIZE = ( + # this value causes window size to equal screen resolution + (0, 0) + if get_sdl_version() >= (1, 2, 10) + + # if sld isn't >= (1, 2, 10) though, it would raise an exception, + # so we need to provide a proper size + else (1280, 720) +) + +SCREEN = set_mode(SIZE, RESIZABLE) +SCREEN_RECT = SCREEN.get_rect() +blit_on_screen = SCREEN.blit + + + +### framerate-related values/objects + +FPS = 24 + +_CLOCK = Clock() + +maintain_fps = _CLOCK.tick +get_fps = _CLOCK.get_fps + +def update(): + display_update() + asyncio.get_event_loop().create_task(asyncio.sleep(0)) + + +### anonymous object to keep track of general values; +### +### values are introduced/update during app's usage: +### frame index is incremented, reset to -1, mode name +### is changed as we switch to other modes, etc. + +GENERAL_NS = type("Object", (), {})() + +GENERAL_NS.frame_index = -1 +GENERAL_NS.mode_name = 'normal' + + +### name of key pygame services used by all different modes + +GENERAL_SERVICE_NAMES = ( + + "get_events", + + "get_pressed_keys", + "get_pressed_mod_keys", + + "get_mouse_pos", + "get_mouse_pressed", + + "set_mouse_pos", + "set_mouse_visibility", + + "update_screen", + + "frame_checkups", + "frame_checkups_with_fps", + +) + + +## event values to strip + +EVENT_KEY_STRIP_MAP = { + + 'MOUSEMOTION': { + 'buttons': (0, 0, 0), + 'touch': False, + 'window': None, + }, + + 'MOUSEBUTTONDOWN': { + 'button': 1, + 'touch': False, + 'window': None, + }, + + 'MOUSEBUTTONUP': { + 'button': 1, + 'touch': False, + 'window': None, + }, + + 'KEYUP': { + 'mod': KMOD_NONE, + 'unicode': '', + 'window': None, + }, + + 'KEYDOWN': { + 'mod': KMOD_NONE, + 'unicode': '', + 'window': None, + }, + + 'TEXTINPUT': { + 'window': None, + }, + +} + +### event name to make compact + +EVENT_COMPACT_NAME_MAP = { + 'KEYDOWN': 'kd', + 'KEYUP': 'ku', + 'TEXTINPUT': 'ti', + 'MOUSEMOTION': 'mm', + 'MOUSEBUTTONUP': 'mbu', + 'MOUSEBUTTONDOWN': 'mbd', +} + +### key of events to make compact + +EVENT_KEY_COMPACT_NAME_MAP = { + + 'MOUSEMOTION': { + 'pos': 'p', + 'rel': 'r', + 'buttons': 'b', + 'touch': 't', + 'window': 'w', + }, + + 'MOUSEBUTTONDOWN': { + 'pos': 'p', + 'button': 'b', + 'touch': 't', + 'window': 'w', + }, + + 'MOUSEBUTTONUP': { + 'pos': 'p', + 'button': 'b', + 'touch': 't', + 'window': 'w', + }, + + 'KEYUP': { + 'key': 'k', + 'scancode': 's', + 'mod': 'm', + 'unicode': 'u', + 'window': 'w', + }, + + 'KEYDOWN': { + 'key': 'k', + 'scancode': 's', + 'mod': 'm', + 'unicode': 'u', + 'window': 'w', + }, + + 'TEXTINPUT': { + 'text': 't', + 'window': 'w', + }, + +} + + +### available keys + +KEYS_MAP = { + + item : getattr(pygame_locals, item) + + for item in dir(pygame_locals) + + if item.startswith('K_') + +} + +SCANCODE_NAMES_MAP = { + + getattr(pygame_locals, name): name + + for name in dir(pygame_locals) + + if name.startswith('KSCAN') + +} + + +MOD_KEYS_MAP = { + + item: getattr(pygame_locals, item) + + for item in dir(pygame_locals) + + if ( + item.startswith('KMOD') + and item != 'KMOD_NONE' + ) + +} + + +### temporary file cleaning + +def clean_temp_files(): + """Clean temporary files.""" + + ### remove temporary paths + APP_REFS.temp_filepaths_man.ensure_removed() + + ### remove swap path if it there's one + + try: + swap_path = APP_REFS.swap_path + except AttributeError: + pass + else: + swap_path.unlink() + + +### + +def watch_window_size(): + """Perform setups needed if window was resized.""" + ### obtain current size + current_size = SCREEN.get_size() + + ### if current screen size is different from the one + ### we stored... + + if current_size != SCREEN_RECT.size: + + ### perform window resize setups + + SCREEN_RECT.size = current_size + APP_REFS.window_resize_setups() + + ### redraw the window manager + APP_REFS.window_manager.draw() + + ### update the screen copy + APP_REFS.SCREEN_COPY = SCREEN.copy() + + ### if there's a request to draw after the setups, + ### do so and delete the request + + if hasattr( + APP_REFS, + "draw_after_window_resize_setups", + ): + + APP_REFS.draw_after_window_resize_setups() + del APP_REFS.draw_after_window_resize_setups + + +### function to pause when recording/playing session + +class CancelWhenPaused(Exception): + """Raised during pause to cancel and return to normal mode.""" + +def pause(): + + running = True + + while running: + + ### keep constants fps + maintain_fps(FPS) + + ### process events + + for event in get(): + + if event.type == QUIT: + raise QuitAppException + + elif event.type == KEYDOWN: + + if event.key == K_F8: + running = False + + elif event.key == K_F7: + raise CancelWhenPaused + + ### update the screen + update() diff --git a/nodezator/surfsman/viewer/main.py b/nodezator/surfsman/viewer/main.py index 158a0d0c..4474efac 100644 --- a/nodezator/surfsman/viewer/main.py +++ b/nodezator/surfsman/viewer/main.py @@ -1,6 +1,7 @@ """Facility for viewing images from given paths.""" ### standard library import +import asyncio from functools import partialmethod @@ -12,7 +13,7 @@ from ...config import APP_REFS -from ...pygamesetup import SERVICES_NS, SCREEN_RECT +from ...pygamesetup import SERVICES_NS, SCREEN_RECT, set_modal from ...surfsman.render import render_rect @@ -61,14 +62,33 @@ def center_surface_viewer(self): if self.running: APP_REFS.draw_after_window_resize_setups = self.response_draw - def view_surface(self, surface: Surface): + async def view_surface_loop(self): + set_modal(True) + while self.running: + await asyncio.sleep(0) + + ### perform various checkups for this frame; + ### + ### stuff like maintaing a constant framerate and more + SERVICES_NS.frame_checkups() + + self.handle_input() + self.draw() + + del self.image + set_modal(False) + if self.callback is not None: + self.callback() + + + def view_surface(self, surface: Surface, callback = None): """Display given surface.""" if not isinstance(surface, Surface): return TypeError("given argument must be a pygame.Surface.") ### - + self.callback = callback self.image = surface self.rect.size = surface.get_size() self.rect.center = MOVE_AREA.center @@ -78,18 +98,7 @@ def view_surface(self, surface: Surface): ### self.running = True - while self.running: - - ### perform various checkups for this frame; - ### - ### stuff like maintaing a constant framerate and more - SERVICES_NS.frame_checkups() - - self.handle_input() - self.draw() - - del self.image - + asyncio.get_running_loop().create_task(self.view_surface_loop()) ### instantiate the surface viewer and store a reference diff --git a/nodezator/textman/editor/main.py b/nodezator/textman/editor/main.py index 7cc00355..27a378f3 100644 --- a/nodezator/textman/editor/main.py +++ b/nodezator/textman/editor/main.py @@ -1,6 +1,7 @@ """Facility for text editing.""" ### standard library import +import asyncio from functools import partial @@ -19,7 +20,7 @@ from ...config import APP_REFS -from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen +from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen, set_modal from ...dialog import create_and_show_dialog @@ -348,12 +349,67 @@ def paint_editing_and_lineno_areas(self, area_bg_color, lineno_bg_color): ## outline line number area draw_rect(self.image, BLACK, lineno_area, 1) + async def edit_text_loop(self, cursor): + ### loop until running attribute is set to False + self.running = True + set_modal(True) + while self.running: + await asyncio.sleep(0) + ### perform various checkups for this frame; + ### + ### stuff like maintaing a constant framerate and more + SERVICES_NS.frame_checkups() + + ### perform the GUD operations; + ### + ### note that, rather than using a single + ### loop holder, both the cursor and text editor + ### provide GUD operations together + + ## execute handle_input() and update() operations + ## from the cursor object + + cursor.handle_input() + cursor.update() + + ## execute drawing operation from the text editor + self.draw() + + ### disable text editing events; + ### + ### this might only be needed if editor was in insert mode + ### but it is ok to call regardless of that + stop_text_input() + + ### blit the self.rect-size semitransparent obj + ### over the screen to leave the self.rect + ### temporarily darkened in case it keeps showing + ### even after leaving this text editor (in some + ### cases the loop holder which called this + ### method might not care to update the area of the + ### screen previously occupied by this color picker, + ### making it so it keeps appearing as if it was + ### still active on the screen) + self.rect_size_semitransp_obj.draw() + + ### since we won't need the objects forming + ### the text anymore, we clear all text-related + ### objects in the cursor, to free memory + cursor.free_up_memory() + + ### return text attribute + #return self.text + if self.callback is not None: + self.callback(self.text) + set_modal(False) + def edit_text( self, text="", font_path=ENC_SANS_BOLD_FONT_PATH, validation_command=None, syntax_highlighting="", + callback = None ): """Edit given text. @@ -400,7 +456,8 @@ def edit_text( """ ### blit a screen-size semitransparent surface in ### the canvas to increase constrast - + self.callback = callback + blit_on_screen(UNHIGHLIGHT_SURF_MAP[SCREEN_RECT.size], (0, 0)) ### instantiate and store cursor in its own attribute, @@ -420,56 +477,7 @@ def edit_text( ### process validation command self.validation_command = validation_command - ### loop until running attribute is set to False - - self.running = True - - while self.running: - - ### perform various checkups for this frame; - ### - ### stuff like maintaing a constant framerate and more - SERVICES_NS.frame_checkups() - - ### perform the GUD operations; - ### - ### note that, rather than using a single - ### loop holder, both the cursor and text editor - ### provide GUD operations together - - ## execute handle_input() and update() operations - ## from the cursor object - - cursor.handle_input() - cursor.update() - - ## execute drawing operation from the text editor - self.draw() - - ### disable text editing events; - ### - ### this might only be needed if editor was in insert mode - ### but it is ok to call regardless of that - stop_text_input() - - ### blit the self.rect-size semitransparent obj - ### over the screen to leave the self.rect - ### temporarily darkened in case it keeps showing - ### even after leaving this text editor (in some - ### cases the loop holder which called this - ### method might not care to update the area of the - ### screen previously occupied by this color picker, - ### making it so it keeps appearing as if it was - ### still active on the screen) - self.rect_size_semitransp_obj.draw() - - ### since we won't need the objects forming - ### the text anymore, we clear all text-related - ### objects in the cursor, to free memory - cursor.free_up_memory() - - ### return text attribute - return self.text + asyncio.get_running_loop().create_task(self.edit_text_loop(cursor)) @property def validation_command(self): diff --git a/nodezator/textman/viewer/op.py b/nodezator/textman/viewer/op.py index a693bc50..75465909 100644 --- a/nodezator/textman/viewer/op.py +++ b/nodezator/textman/viewer/op.py @@ -1,6 +1,7 @@ """TextViewer class extension with routine operations.""" ### standard library import +import asyncio from math import inf as INFINITY @@ -36,7 +37,7 @@ ### local imports -from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen +from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen, set_modal from ...surfsman.draw import draw_border from ...surfsman.cache import UNHIGHLIGHT_SURF_MAP @@ -74,7 +75,7 @@ class Operations(Object2D): TextViewer class. """ - def run(self): + async def run_loop(self): """Define and enter text viewer loop.""" ### blit a semitransparent surface to in the canvas ### to increase constrast between the dialog and @@ -86,7 +87,9 @@ def run(self): self.running = True + set_modal(True) while self.running: + await asyncio.sleep(0) ### perform various checkups for this frame; ### @@ -96,7 +99,6 @@ def run(self): self.handle_input() self.draw() - ### blit semitransparent obj ## over self.rect @@ -119,7 +121,11 @@ def run(self): ### free memory from objects you won't use anymore self.free_up_memory() + set_modal(False) + def run(self): + asyncio.get_running_loop().create_task(self.run_loop()) + def handle_events(self): """Retrieve and respond to events.""" for event in SERVICES_NS.get_events(): diff --git a/nodezator/textman/viewer/op.py.bak b/nodezator/textman/viewer/op.py.bak new file mode 100644 index 00000000..664ba538 --- /dev/null +++ b/nodezator/textman/viewer/op.py.bak @@ -0,0 +1,474 @@ +"""TextViewer class extension with routine operations.""" + +### standard library import +import asyncio +from math import inf as INFINITY + + +### third-party imports + +from pygame.locals import ( + QUIT, + KEYUP, + K_ESCAPE, + K_RETURN, + K_KP_ENTER, + K_UP, + K_DOWN, + K_LEFT, + K_RIGHT, + K_w, + K_a, + K_s, + K_d, + K_k, + K_h, + K_j, + K_l, + K_PAGEUP, + K_PAGEDOWN, + K_HOME, + K_END, + KMOD_SHIFT, + MOUSEBUTTONUP, + MOUSEMOTION, +) + + +### local imports + +from ...pygamesetup import SERVICES_NS, SCREEN_RECT, blit_on_screen, set_modal + +from ...surfsman.draw import draw_border +from ...surfsman.cache import UNHIGHLIGHT_SURF_MAP + +from ...classes2d.single import Object2D + +from ...loopman.exception import QuitAppException + + +### constants + +## keys with the same purpose + +UP_KEYS = K_UP, K_w, K_k +DOWN_KEYS = K_DOWN, K_s, K_j +LEFT_KEYS = K_LEFT, K_a, K_h +RIGHT_KEYS = K_RIGHT, K_d, K_l + + +### utility function + + +def within_height(rect_a, rect_b): + """Return whether rect_a is within height of rect_b.""" + return rect_a.top >= rect_b.top and rect_a.bottom <= rect_b.bottom + + +### class definition + + +class Operations(Object2D): + """Provides common methods to control and display text. + + This class is meant to be used as an extension of the + TextViewer class. + """ + + async def _run(self): + """Define and enter text viewer loop.""" + ### blit a semitransparent surface to in the canvas + ### to increase constrast between the dialog and + ### whatever is behind it + + blit_on_screen(UNHIGHLIGHT_SURF_MAP[SCREEN_RECT.size], (0, 0)) + + ### loop until self.running is changed + + self.running = True + + while self.running: + ### perform various checkups for this frame; + ### + ### stuff like maintaing a constant framerate and more + SERVICES_NS.frame_checkups() + + self.handle_input() + + self.draw() + await asyncio.sleep(0) + + ### blit semitransparent obj + + ## over self.rect + + blit_on_screen( + UNHIGHLIGHT_SURF_MAP[self.rect.size], + self.rect.topleft, + ) + + ## over lineno panel if line numbers were shown + + if self.draw_lines == self.draw_lines_and_lineno: + + panel_rect = self.lineno_panel.rect + + blit_on_screen( + UNHIGHLIGHT_SURF_MAP[panel_rect.size], + panel_rect.topleft, + ) + + ### free memory from objects you won't use anymore + self.free_up_memory() + set_modal(False) + + def run(self): + set_modal(True) + asyncio.get_running_loop().create_task(self._run()) + + def handle_events(self): + """Retrieve and respond to events.""" + for event in SERVICES_NS.get_events(): + + ### exit app by clicking the close button + ### in the window + if event.type == QUIT: + raise QuitAppException + + elif event.type == KEYUP: + + ## exit the text viewer by releasing + ## any of the following keys + + if event.key in (K_ESCAPE, K_RETURN, K_KP_ENTER): + self.running = False + + ## jump large amounts of pixels up or down + ## (or scroll to top or bottom edge, when + ## shift key is pressed) + + elif event.key == K_PAGEUP: + + if event.mod & KMOD_SHIFT: + self.scroll(0, INFINITY) + + else: + self.scroll(0, self.page_height) + + elif event.key == K_PAGEDOWN: + + if event.mod & KMOD_SHIFT: + self.scroll(0, -INFINITY) + + else: + self.scroll(0, -self.page_height) + + ## scroll to different edges of the text + + elif event.key == K_HOME: + + if event.mod & KMOD_SHIFT: + self.scroll(0, INFINITY) + + else: + self.scroll(INFINITY, 0) + + elif event.key == K_END: + + if event.mod & KMOD_SHIFT: + self.scroll(0, -INFINITY) + + else: + self.scroll(-INFINITY, 0) + + ### mouse button release/mousewheel + + elif event.type == MOUSEBUTTONUP: + + ## exiting the text viewer by clicking out + ## of boundaries + + if event.button in (1, 3): + + if not (self.scroll_area.collidepoint(event.pos)): + self.running = False + + ## scrolling with mousewheel + + elif event.button == 4: + + if SERVICES_NS.get_pressed_mod_keys() & KMOD_SHIFT: + self.scroll(self.line_height, 0) + + else: + self.scroll(0, self.line_height) + + elif event.button == 5: + + if SERVICES_NS.get_pressed_mod_keys() & KMOD_SHIFT: + self.scroll(-self.line_height, 0) + + else: + self.scroll(0, -self.line_height) + + ## mouse movement + + elif event.type == MOUSEMOTION: + + self.hovering_help_icon = self.help_icon.rect.collidepoint(event.pos) + + def handle_key_input(self): + """Respond to inputs from keys.""" + ### retrieve input list + input_list = SERVICES_NS.get_pressed_keys() + + ### define actions based on state of specific keys + + scroll_up = any(map(input_list.__getitem__, UP_KEYS)) + + scroll_down = any(map(input_list.__getitem__, DOWN_KEYS)) + + scroll_left = any(map(input_list.__getitem__, LEFT_KEYS)) + + scroll_right = any(map(input_list.__getitem__, RIGHT_KEYS)) + + ### act according to the states of the actions + + if scroll_up and not scroll_down: + self.scroll(0, self.line_height) + + elif scroll_down and not scroll_up: + self.scroll(0, -self.line_height) + + if scroll_left and not scroll_right: + self.scroll(self.line_height, 0) + + elif scroll_right and not scroll_left: + self.scroll(-self.line_height, 0) + + def draw(self): + """Draw on self.image, then draw it on screen.""" + ### reference image attribute in local variables + image = self.image + + ### clean self.image + image.blit(self.background, (0, 0)) + + ### draw lines + self.draw_lines() + + ### draw a border around self.image, then draw + ### self.image on the screen + + draw_border(image, thickness=2) + super().draw() + + ### execute drawing routine for caption and header + + self.caption_drawing_routine() + self.header_drawing_routine() + + ### draw the help icon and, if it is hovered, + ### the help text + + self.help_icon.draw() + + if self.hovering_help_icon: + self.help_text_obj.draw() + + ### finally update the screen + SERVICES_NS.update_screen() + + def draw_lines_only(self): + """Draw the lines, without the line number.""" + ### reference attributes in local variables + + image = self.image + offset = self.offset + scroll_area = self.scroll_area + + ### for lines colliding with the scroll area, + ### draw them on self.image + + for line in self.lines.get_colliding(scroll_area): + + image.blit(line.image, line.rect.topleft + offset) + + def draw_lines_and_lineno(self): + """Draw lines on scroll area with line numbers.""" + ### draw lineno panel + self.lineno_panel.draw() + + ### reference attributes in local variables + + image = self.image + offset = self.offset + scroll_area = self.scroll_area + + ### iterate over each line and its respective line + ### number + + for lineno, line in enumerate(self.lines, start=1): + + ## reference the line's rect locally + line_rect = line.rect + + ## if the line is along the height of the + ## scroll area draw the line and then its + ## line number; + ## + ## regarding collisions we cannot use + ## pygame.Rect.colliderect here, because empty + ## lines have a rect without width (even though + ## they have height), making it so the rect + ## has area 0 (zero) and rects with area 0 never + ## collide; + ## + ## we also cannot use pygame.Rect.contains here, + ## cause when the user scrolls further to + ## the right because of a large line, smaller + ## lines may be pulled outside the boundaries + ## of the scroll area to the left, and since + ## they would not be contained in the scrolling + ## area, their line numbers would not be drawn + ## + ## this is why we use this special within_height + ## function + + if within_height(line_rect, scroll_area): + + ## draw line with offset topleft + image.blit(line.image, line.rect.topleft + offset) + + ## draw line number + self.draw_lineno(str(lineno), line_rect.y) + + def draw_lineno(self, lineno, line_y): + """Draw line number at the given y coordinate. + + Parameters + ========== + lineno (string) + represents the line number. + line_y (integer) + y coordinate of the line; used as the y + coordinate wherein to blit the line number. + """ + ### use the right coordinate from where to blit line + ### numbers from the last to the first character, + ### from right to left, as the x coordinate + line_x = self.lineno_right + + ### reference the width of a digit's surface locally + digit_width = self.digit_surf_width + + ### reference the digit surf map locally + surf_map = self.digits_surf_map + + ### iterate over digits, blitting each of them + ### while updating the x coordinate so they + ### are blitted one beside the other + + for digit in lineno[::-1]: + + blit_on_screen(surf_map[digit], (line_x, line_y)) + line_x -= digit_width + + def scroll(self, dx, dy): + """Scroll lines, if possible. + + Parameters + ========== + + dx, dy (integers) + indicates amounts (deltas) of pixels to scroll + in x and y axes; accepts negative integers to + scroll in the opposite direction. + + How it works + ============ + + Scrolling can be seen as moving text inside + the scrolling area in the opposite direction + of that which we want to peek. + + So, for instance, if I want to see more of the + bottom of the text, that is, if I want to + scroll down, it means I need to move the text + up. However, additional checks must be performed. + + First, we must check whether there is more text + beyond the scrolling area in the direction we + want to peek (the opposite we want to scroll). + + This is important because if there isn't, we + shouldn't scroll at all, so we set the delta to + 0. + + Second, we must check whether the text in the + direction we want to peek will end up too much + inside the scroll area (instead of being just + inside it). + + We perform such check and, if it is the case, + instead of scrolling the full delta, we just + scroll the amount of pixels enough to align the + text with the scroll area in that side. + """ + ### retrieve a union rect of all lines, + ### representing the text + text_rect = self.lines.rect.union_rect + + ### perform check depending on the direction the + ### text will scroll + + ## text will go left, so we check the right side + + if dx < 0: + + if text_rect.right < self.scroll_area.right: + dx = 0 + + elif text_rect.right + dx < self.scroll_area.right: + + dx = self.scroll_area.right - text_rect.right + + ## text will go right, so we check the left side + + elif dx > 0: + + if text_rect.left > self.scroll_area.left: + dx = 0 + + elif text_rect.left + dx > self.scroll_area.left: + dx = self.scroll_area.left - text_rect.left + + ## text will go up, so we check the bottom + + if dy < 0: + + if text_rect.bottom < self.scroll_area.bottom: + dy = 0 + + elif text_rect.bottom + dy < self.scroll_area.bottom: + + dy = self.scroll_area.bottom - text_rect.bottom + + ## text will go down, so we check the top + + elif dy > 0: + + if text_rect.top > self.scroll_area.top: + dy = 0 + + elif text_rect.top + dy > self.scroll_area.top: + dy = self.scroll_area.top - text_rect.top + + ### move lines (moves the rect of each line) + self.lines.rect.move_ip(dx, dy) + + def free_up_memory(self): + """Free up memory by removing unused text objects.""" + ### clear self.lines to free the memory taken by + ### the line objects (specially their surfaces) + self.lines.clear() diff --git a/nodezator/textman/viewer/prep.py b/nodezator/textman/viewer/prep.py index 0842417c..93c1f361 100644 --- a/nodezator/textman/viewer/prep.py +++ b/nodezator/textman/viewer/prep.py @@ -105,6 +105,8 @@ def view_text( to make the viewer jump to the last line; Default is 0. """ + notify_user = False + ### define general text preset if a string is ### received @@ -203,12 +205,7 @@ def view_text( except SyntaxMappingError: ## notify user via dialog - - create_and_show_dialog( - "Error while applying syntax" - " highlighting. Text will be" - " displayed without it." - ) + notify_user = True ## also turn off the syntax highlighting by ## setting the corresponding variable to an @@ -384,8 +381,16 @@ def view_text( self.help_text_obj.rect.bottomright = self.rect.move(-10, -35).bottomright - ### finally run the text viewer loop - self.run() + if notify_user: + create_and_show_dialog( + "Error while applying syntax" + " highlighting. Text will be" + " displayed without it.", + callback = self.run, + ) + else: + ### finally run the text viewer loop + self.run() def setup_lineno_surfs(self, text_settings): """Create digit surfaces used to show line number. diff --git a/nodezator/videopreview/previewer.py b/nodezator/videopreview/previewer.py index 8bb96514..1d90cc70 100644 --- a/nodezator/videopreview/previewer.py +++ b/nodezator/videopreview/previewer.py @@ -1,3 +1,6 @@ +### standard library import +import asyncio + ### third-party imports from pygame.locals import ( @@ -16,7 +19,7 @@ from ..config import APP_REFS, FFMPEG_AVAILABLE -from ..pygamesetup import SERVICES_NS, SCREEN, SCREEN_RECT +from ..pygamesetup import SERVICES_NS, SCREEN, SCREEN_RECT, set_modal from ..ourstdlibs.behaviour import get_oblivious_callable @@ -134,7 +137,27 @@ def center_video_previewer(self): self.not_available_message_obj.rect.center = SCREEN_RECT.center - def preview_videos(self, video_paths, index=0): + async def preview_videos_loop(self): + set_modal(True) + while self.running: + await asyncio.sleep(0) + + SERVICES_NS.frame_checkups_with_fps(self.fps) + + try: + + loop_holder.handle_input() + loop_holder.update() + loop_holder.draw() + + except SwitchLoopException as err: + loop_holder = err.loop_holder + set_modal(False) + if self.callback is not None: + self.callback() + + def preview_videos(self, video_paths, index=0, callback = None): + self.callback = callback self.video_paths = ( [video_paths] if isinstance(video_paths, str) else video_paths @@ -155,18 +178,7 @@ def preview_videos(self, video_paths, index=0): loop_holder = self - while self.running: - - SERVICES_NS.frame_checkups_with_fps(self.fps) - - try: - - loop_holder.handle_input() - loop_holder.update() - loop_holder.draw() - - except SwitchLoopException as err: - loop_holder = err.loop_holder + asyncio.get_running_loop().create_task(self.preview_videos_loop()) def handle_input(self): """""" diff --git a/nodezator/widget/literaldisplay.py b/nodezator/widget/literaldisplay.py index 1cdced32..37f37076 100644 --- a/nodezator/widget/literaldisplay.py +++ b/nodezator/widget/literaldisplay.py @@ -441,6 +441,17 @@ def reset_style(self, style_name, new_style_value): reset_show_line_number = partialmethod(reset_style, "show_line_number") + def edit_value_callback(self, text): + ### if there is an edited text (it is not None) + ### and it represents a python literal different + ### from the current value, set such value as the + ### new one + + if text is not None: + value = literal_eval(text) + if value != self.value: + self.set(value) + def edit_value(self): """Edit value of widget on the text editor.""" ### retrieve edited text: this triggers the @@ -449,24 +460,14 @@ def edit_value(self): ### the text (or None, if the user decides to ### cancel the operation) - text = edit_text( + edit_text( text=pformat(self.value, width=84), font_path=FIRA_MONO_BOLD_FONT_PATH, syntax_highlighting="python", validation_command=is_python_literal, + callback = self.edit_value_callback, ) - ### if there is an edited text (it is not None) - ### and it represents a python literal different - ### from the current value, set such value as the - ### new one - - if text is not None: - - value = literal_eval(text) - if value != self.value: - self.set(value) - def get_expected_type(self): return _empty diff --git a/nodezator/widget/literalentry.py b/nodezator/widget/literalentry.py index 9c91aa2b..2e574b32 100644 --- a/nodezator/widget/literalentry.py +++ b/nodezator/widget/literalentry.py @@ -156,8 +156,7 @@ class from the module winman.main. 255 for full opacity). """ ### ensure value argument received is a python - ### literal - + ### literal if not self.validate(value): raise TypeError("'value' received must be a python literal") @@ -173,6 +172,7 @@ class from the module winman.main. ### store some of the arguments in their own ### attributes + self.entry_text = None self.name = name self.value = value self.command = command @@ -562,32 +562,37 @@ def resume_editing(self): ### finally we lose focus self.lose_focus() - def edit_on_text_editor(self): - """Edit the entry text in the text editor.""" - ### retrieve the text in the entry - entry_text = self.cursor.get() - - ### since we'll not edit text on the entry anymore, - ### stop text editing events - stop_text_input() - - ### edit it on text editor - text = edit_text(entry_text) - + def edit_on_text_editor_callback(self, text): ### if there is an edited text (if it is not None, ### it means the user confirmed the edition in the ### text editor) and it is different from the entry ### text originally used, set such text as the text ### of the entry - if text is not None and text != entry_text: + if text is not None and text != self.entry_text: self.cursor.set(text) + self.entry_text = None ### regardless of whether the new text was set as ### the entry text or not, we resume the entry's ### editing session self.resume_editing() + + def edit_on_text_editor(self): + """Edit the entry text in the text editor.""" + ### retrieve the text in the entry + self.entry_text = self.cursor.get() + + ### since we'll not edit text on the entry anymore, + ### stop text editing events + stop_text_input() + + ### edit it on text editor + edit_text(self.entry_text, + callback = self.edit_on_text_editor_callback, + ) + def get_expected_type(self): return _empty diff --git a/nodezator/widget/pathpreview/base.py b/nodezator/widget/pathpreview/base.py index 7a9a40ae..810f34a1 100644 --- a/nodezator/widget/pathpreview/base.py +++ b/nodezator/widget/pathpreview/base.py @@ -376,14 +376,7 @@ def collides_with_entry(self, pos): return entry.rect.collidepoint(pos) - def select_new_paths(self): - """Select new path(s) from the file manager. - - If no path is returned, the widget isn't updated. - """ - ### retrieve path(s) from file browser - paths = select_paths(caption="Select path(s)") - + def select_new_paths_callback(self, paths): ### if no paths are selected, return if not paths: return @@ -397,6 +390,17 @@ def select_new_paths(self): else: self.set(paths) + + def select_new_paths(self): + """Select new path(s) from the file manager. + + If no path is returned, the widget isn't updated. + """ + ### retrieve path(s) from file browser + select_paths( + caption="Select path(s)", + callback = self.select_new_paths_callback, + ) def update_previewed_path_from_entry(self): """""" diff --git a/nodezator/widget/pathpreview/text.py b/nodezator/widget/pathpreview/text.py index 01c7d248..8c615ce1 100644 --- a/nodezator/widget/pathpreview/text.py +++ b/nodezator/widget/pathpreview/text.py @@ -205,6 +205,18 @@ def preview_paths(self): def update_previews(self): self.update_image() + def blit_path_representation_callback(self): + try: + subsurf = self.path_repr_subsurf + + except AttributeError: + + subsurf = self.path_repr_subsurf = self.image.subsurface(rect) + + subsurf.blit(NOT_FOUND_SURF_MAP[subsurf.get_size()], (0, 0)) + + super().blit_path_representation() + def blit_path_representation(self): """Blit representation of text in current path.""" @@ -268,18 +280,8 @@ def blit_path_representation(self): " leaving this dialog)." ), level_name="error", + callback = self.blit_path_representation_callback, ) - - try: - subsurf = self.path_repr_subsurf - - except AttributeError: - - subsurf = self.path_repr_subsurf = image.subsurface(rect) - - subsurf.blit(NOT_FOUND_SURF_MAP[subsurf.get_size()], (0, 0)) - - super().blit_path_representation() return no_of_visible_lines = 7 diff --git a/nodezator/widget/sortingbutton.py b/nodezator/widget/sortingbutton.py index ec25e79f..05ec93e8 100644 --- a/nodezator/widget/sortingbutton.py +++ b/nodezator/widget/sortingbutton.py @@ -311,6 +311,11 @@ def update_image(self): ### beside the icon blit_aligned(text_surf, self.image, "midleft", "midleft", offset_pos_by=(22, 0)) + def change_value_callback(self, value): + ### if the output is not None, update the value + ### and available items in the widget + if value is not None: + self.set(value) def change_value(self): """Sort the values. @@ -320,19 +325,12 @@ def change_value(self): """ ### retrieve new values from the sort_list function - output = sort_sequence( + sort_sequence( self.value, self.available_items, + callback = self.change_value_callback ) - ### if the output is not None, update the value - ### and available items in the widget - - if output is not None: - - value = output - self.set(value) - def reset_value_and_available_items( self, value, diff --git a/nodezator/widget/stringentry.py b/nodezator/widget/stringentry.py index 6da9c0d3..267d963e 100644 --- a/nodezator/widget/stringentry.py +++ b/nodezator/widget/stringentry.py @@ -234,6 +234,7 @@ class from the module winman.main. ### store some of the arguments in their own ### attributes + self.entry_text = None self.name = name self.value = value self.command = command @@ -739,31 +740,36 @@ def resume_editing(self): ### finally we lose focus self.lose_focus() - def edit_on_text_editor(self): - """Edit the entry text in the text editor.""" - ### retrieve the text in the entry - entry_text = self.cursor.get() - - ### since we'll not edit text on the entry anymore, - ### stop text editing events - stop_text_input() - - ### edit it on text editor - text = edit_text(entry_text) - + def edit_on_text_editor_callback(self, text): ### if there is an edited text (if it is not None, ### it means the user confirmed the edition in the ### text editor) and it is different from the entry ### text originally used, set such text as the text ### of the entry - if text is not None and text != entry_text: + if text is not None and text != self.entry_text: self.cursor.set(text) + self.entry_text = None ### regardless of whether the new text was set as ### the entry text or not, we resume the entry's ### editing session self.resume_editing() + + def edit_on_text_editor(self): + """Edit the entry text in the text editor.""" + ### retrieve the text in the entry + self.entry_text = self.cursor.get() + + ### since we'll not edit text on the entry anymore, + ### stop text editing events + stop_text_input() + + ### edit it on text editor + edit_text( + self.entry_text, + callback = self.edit_on_text_editor_callback, + ) def get_expected_type(self): return str diff --git a/nodezator/widget/textdisplay.py b/nodezator/widget/textdisplay.py index a7045273..aa60a298 100644 --- a/nodezator/widget/textdisplay.py +++ b/nodezator/widget/textdisplay.py @@ -648,6 +648,13 @@ def reset_style(self, style_name, new_style_value): reset_font_path = partialmethod(reset_style, "font_path") + def edit_value_callback(self, text): + ### if there is an edited text (it is not None) + ### and it is different from the current value, + ### set such text as the new value + if text is not None and text != self.value: + self.set(text) + def edit_value(self): """Edit value of widget on the text editor.""" ### retrieve edited text: this triggers the @@ -656,19 +663,14 @@ def edit_value(self): ### the text (or None, if the user decides to ### cancel the operation) - text = edit_text( + edit_text( text=self.value, font_path=self.font_path, syntax_highlighting=self.syntax_highlighting, validation_command=self.validation_command, + callback = self.edit_value_callback, ) - ### if there is an edited text (it is not None) - ### and it is different from the current value, - ### set such text as the new value - - if text is not None and text != self.value: - self.set(text) def get_expected_type(self): return str diff --git a/nodezator/widget/textdisplay.py.bak b/nodezator/widget/textdisplay.py.bak new file mode 100644 index 00000000..ff0b927d --- /dev/null +++ b/nodezator/widget/textdisplay.py.bak @@ -0,0 +1,1026 @@ +"""Facility for text displaying/editing widget.""" + +### standard library imports + +from functools import partialmethod + +from xml.etree.ElementTree import Element + + +### third-party import +from pygame.draw import rect as draw_rect + + +### local imports + +from ..ourstdlibs.behaviour import empty_function + +from ..ourstdlibs.stringutils import VALIDATION_COMMAND_MAP + +from ..surfsman.draw import blit_aligned, draw_depth_finish +from ..surfsman.render import render_rect, combine_surfaces +from ..surfsman.icon import render_layered_icon + +from ..classes2d.single import Object2D + +from ..textman.text import render_highlighted_line +from ..textman.render import ( + fit_text, + get_text_size, + render_text, +) + +from ..textman.viewer.main import view_text +from ..textman.editor.main import edit_text + +from ..fontsman.constants import ( + ENC_SANS_BOLD_FONT_HEIGHT, + ENC_SANS_BOLD_FONT_PATH, + FIRA_MONO_BOLD_FONT_PATH, +) + +from ..syntaxman.utils import ( + AVAILABLE_SYNTAXES, + SYNTAX_TO_MAPPING_FUNCTION, + get_ready_theme, +) + +from ..syntaxman.exception import SyntaxMappingError + +from ..colorsman.colors import ( + BLACK, + WHITE, + TEXT_DISPLAY_BG, + TEXT_DISPLAY_TEXT_FG, + TEXT_DISPLAY_TEXT_BG, +) + + +### surface representing an icon for the text editor + +ICON_SURF = combine_surfaces( + [ + render_layered_icon( + chars=[chr(ordinal) for ordinal in (35, 36)], + dimension_name="height", + dimension_value=18, + colors=[BLACK, WHITE], + offset_pos_by=(-1, 0), + background_width=20, + background_height=20, + ), + render_layered_icon( + chars=[chr(ordinal) for ordinal in range(115, 119)], + dimension_name="height", + dimension_value=14, + colors=[BLACK, (255, 225, 140), (255, 255, 0), (255, 170, 170)], + ), + ], + retrieve_pos_from="bottomright", + assign_pos_to="bottomright", +) + +ICON_WIDTH, ICON_HEIGHT = ICON_SURF.get_size() + + +### map associating keys with font paths + +FONT_PATH_MAP = { + "sans_bold": ENC_SANS_BOLD_FONT_PATH, + "mono_bold": FIRA_MONO_BOLD_FONT_PATH, +} + + +### class definition + + +class TextDisplay(Object2D): + """A display widget for storing/displaying text.""" + + ### TODO make it so font height is automatically set + ### to be either ENC_SANS_BOLD_FONT_HEIGHT or FIRA_MONO_BOLD... + ### depending on the value of font_path, in a class + ### method which should receive values set by user + ### on node callable + + def __init__( + self, + value="", + font_height=ENC_SANS_BOLD_FONT_HEIGHT, + font_path="sans_bold", + width=155, + no_of_visible_lines=7, + syntax_highlighting="", + show_line_number=False, + name="text_display", + command=empty_function, + validation_command=None, + coordinates_name="topleft", + coordinates_value=(0, 0), + ): + """Store data and perform setups. + + Parameters + ========== + + value (string) + text used as value of the widget. + syntax_highlighting (string) + string hinting the syntax highlighting behaviour; + if empty, no syntax highlighting is applied + when editing the text; if not empty, the string + must indicate which syntax to use for the + highlighting; for now, only 'python' (for + Python code syntax) and 'comment' (a subset + of Python code highlighting to highlight + special "todo" words) are available. + show_line_number (bool) + indicates whether to show the line numbers + or not, when editing the text. + font_path (string) + indicates the font style to be used when + editing the contents. Defaults to 'sans_bold', + which uses the normal font of the app. You can + also use 'mono_bold', which uses a monospace + font when editing the text (for instance, if + the text you want to edit is code). + width (integer) + width of the widget. + no_of_visible_lines (positive integer) + number of lines that can be seen in the + widget. + name (string) + an arbitrary name to help identify the widget; + it is assigned to the 'name' attribute. + validation_command (None, string or callable) + if it is None, the instance is set up so that + no validation is done. + + If it is a string, it must be a key in a + preset map used to grab a valid command. + + If it is a callable, it must accept a single + argument and its return value is used to + determine whether validation passed (when the + return value is truthy value) or not (when it + is not truthy). + coordinates_name (string) + represents attribute name of a pygame.Rect + instance wherein to store the position + information from the coordinates value parameter. + coordinates_value (sequence w/ 2 integers) + represents the x and y coordinates of a + position in 2d space. + """ + ### ensure value argument received is a string + + if type(value) is not str: + + raise TypeError("'value' received must be of 'str' type") + + ### ensure there is at least one visible line + + if no_of_visible_lines < 1: + + raise ValueError("'no_of_visible_lines' must be >= 1") + + ### process and store the font_path argument; + ### + ### besides the font path, the font path itself + ### may also be a key from the FONT_PATH_MAP, + ### in which case it is replaced by the + ### proper path + + try: + font_path = FONT_PATH_MAP[font_path] + except KeyError: + pass + + self.font_path = font_path + + ### store other arguments + + self.value = value + self.font_height = font_height + + self.syntax_highlighting = syntax_highlighting + self.show_line_number = show_line_number + + self.width = width + self.no_of_visible_lines = no_of_visible_lines + + self.command = command + self.name = name + + ### setup validation: since 'validation_command' + ### is a property with setter implementation, this + ### assignment triggers setups for the validation + ### command + self.validation_command = validation_command + + ### create a surface to clean the image attribute + ### surface every time the value changes + + height = ICON_HEIGHT + (font_height * no_of_visible_lines) + 3 + + self.clean_surf = render_rect(width, height, TEXT_DISPLAY_BG) + + ### use blit aligned to blit icon aligned to the + ### topleft + + blit_aligned( + ICON_SURF, + self.clean_surf, + retrieve_pos_from="topleft", + assign_pos_to="topleft", + offset_pos_by=(1, 1), + ) + + ### create an image from the clean surf + self.image = self.clean_surf.copy() + + ### prepare style data + self.prepare_style_data() + + ### update the image contents with the value + self.update_image() + + ### create rect from the image attribute and + ### position it + + self.rect = self.image.get_rect() + setattr(self.rect, coordinates_name, coordinates_value) + + @property + def validation_command(self): + """Return stored validation command.""" + return self._validation_command + + @validation_command.setter + def validation_command(self, validation_command): + """Setup validation according to received argument. + + Parameters + ========== + validation_command (None, string or callable) + check __init__ method's docstring for more + info. + """ + ### check whether the value received is present + ### in the validation command map + + try: + command = VALIDATION_COMMAND_MAP[validation_command] + + ### if it is absent, though... + + except KeyError: + + ## use the value as the command itself, if + ## it is callable + + if callable(validation_command): + command = validation_command + + ## otherwise, it means no valid validation + ## command was given + + else: + + msg = ( + "'validation_command' received invalid" + " input: {} (it must be a valid string" + " or callable)" + ).format(repr(validation_command)) + + raise ValueError(msg) + + ### check whether the value received is valid + + ## try validating + try: + value = command(self.value) + + ## if an exception occurred while validating, + ## catch the exception and raise a new exception + ## from it, explaining the context of such + ## error + + except Exception as err: + + raise RuntimeError( + "An exception was raised while using the" + " specified validation command on the" + " provided value" + ) from err + + ## if validation works, but the result is false, + ## raise a value error + + else: + + if not value: + + raise ValueError( + "the 'validation_command' received" + " doesn't validate the 'value' received." + ) + + ### finally, let's store the validation command + self._validation_command = command + + def on_mouse_release(self, event): + """Act according to mouse release position. + + Parameters + ========== + event (pygame.event.Event of pygame.MOUSEBUTTONUP type or + similar object) + it is required in order to comply with the + mouse action protocol used; we retrieve the + mouse position from its "pos" attribute; + + check documentation of pygame's event submodule on + pygame website for more info about this event object. + """ + ### retrieve x coordinate of mouse position + mouse_x, _ = event.pos + + ### if click was more to the left, where the icon + ### is, we call the method responsible for + ### calling the text editor to edit the value + if mouse_x < (self.rect.x + 17): + print("**** EDIT_VALUE"); + self.edit_value() + + ### otherwise the user clicked on the portion of + ### the button which hints the contents of the + ### widget, so we display the widget value + ### in the text viewer + + else: + + view_text( + text=self.value, + syntax_highlighting=self.syntax_highlighting, + show_line_number=self.show_line_number, + ) + + def get(self): + """Return the widget value.""" + return self.value + + def set(self, value, custom_command=True): + """Set the value of the widget. + + Parameters + ========== + value (string) + the value of the widget, which represents a + text (usually a multiline string, but any + string can be passed, as long as it is + considered valid by the validation_command + callable). + custom_command (boolean) + indicates whether the custom command should be + called after updating the value. + """ + ### ensure value argument received is a string + + if type(value) is not str: + + ## report problem + print("'value' received must be of 'str' type") + + ## exit method by returning early + return + + ### changes are only performed if the new value is + ### indeed different from the current one + + if self.value != value and self.validation_command(value): + + ### store new value + self.value = value + + ### update image + self.update_image() + + ### if requested, execute the custom command + if custom_command: + self.command() + + def update_image(self): + """Update widget image.""" + ### reference image locally for quicker access + + image = self.image + width, height = image.get_size() + + ### clean image surface + image.blit(self.clean_surf, (0, 0)) + + ### + + draw_rect( + image, + self.background_color, + # subarea + (1, ICON_HEIGHT + 2, width - 2, height - ICON_HEIGHT - 1), + ) + + ### + + no_of_visible_lines = self.no_of_visible_lines + show_line_number = self.show_line_number + font_height = self.font_height + font_path = self.font_path + syntax_highlighting = self.syntax_highlighting + + if show_line_number: + + lineno_width, _ = get_text_size( + "01", font_height=font_height, font_path=FIRA_MONO_BOLD_FONT_PATH + ) + + draw_rect( + image, + self.lineno_bg, + (1, ICON_HEIGHT + 2, lineno_width - 2, height - ICON_HEIGHT - 1), + ) + + else: + lineno_width = 0 + + lines = self.value.splitlines()[:no_of_visible_lines] + + if syntax_highlighting: + + try: + highlight_data = self.get_syntax_map(self.value) + + except SyntaxMappingError: + + highlight_data = { + ## store a dict item where the line index + ## is the key and another dict is the value + line_index: { + ## in this dict, an interval representing + ## the indices of all items of the line + ## (character objects) is used as the + ## key, while the 'normal' string is used + ## as value, indicating that all content + ## must be considered normal text + (0, len(line_text)): "normal" + } + ## for each line_index and respective line + for line_index, line_text in enumerate(lines) + ## but only if the line isn't empty + if line_text + } + + ## + x = lineno_width + 4 + y = ICON_HEIGHT + 2 + + theme_text_settings = self.theme_map["text_settings"] + + ## iterate over the visible lines and their + ## indices, highlighting their text according + ## to the highlighting data present + + for line_index, line_text in enumerate(lines, 0): + + ## try popping out the interval data from + ## the highlight data dict with the line + ## index + + try: + interval_data = highlight_data.pop(line_index) + + ## if there is no such data, skip iteration + ## of this item + except KeyError: + pass + + ## otherwise... + else: + + line_surf = render_highlighted_line( + line_text, interval_data, theme_text_settings, join_objects=True + ).image + + image.blit(line_surf, (x, y)) + + y += font_height + + else: + + y = ICON_HEIGHT + 2 + + x = lineno_width + 4 if show_line_number else 4 + + foreground_color = self.foreground_color + background_color = self.background_color + + for line_number, line_text in enumerate(lines, 1): + + if line_number > no_of_visible_lines: + break + + surf = render_text( + text=line_text, + font_height=font_height, + font_path=font_path, + foreground_color=foreground_color, + background_color=background_color, + ) + + image.blit(surf, (x, y)) + + y += font_height + + ### + + if show_line_number: + + y = ICON_HEIGHT + 2 + + lineno_fg = self.lineno_fg + lineno_bg = self.lineno_bg + + for line_number, line_text in enumerate(lines, 1): + + surf = render_text( + text=str(line_number).rjust(2, "0"), + font_height=font_height, + font_path=FIRA_MONO_BOLD_FONT_PATH, + foreground_color=lineno_fg, + background_color=lineno_bg, + ) + + image.blit(surf, (2, y)) + + y += font_height + + draw_depth_finish(image) + + def prepare_style_data(self): + + general_text_settings = { + "font_height": self.font_height, + "font_path": self.font_path, + "foreground_color": TEXT_DISPLAY_TEXT_FG, + "background_color": TEXT_DISPLAY_TEXT_BG, + } + + ### + ### + syntax_highlighting = self.syntax_highlighting + + if syntax_highlighting in AVAILABLE_SYNTAXES: + + ### store a theme map ready for usage with the + ### syntax name and default settings + + self.theme_map = get_ready_theme(syntax_highlighting, general_text_settings) + + ### store specific syntax mapping behaviour + self.get_syntax_map = SYNTAX_TO_MAPPING_FUNCTION[syntax_highlighting] + + ### define foreground and background colors for + ### the line numbers + + ## define text settings for the line numbers + + # reference the theme text settings locally + theme_text_settings = self.theme_map["text_settings"] + + # if the line number settings from the theme + # are available, use them + try: + lineno_settings = theme_text_settings["line_number"] + + # otherwise use the settings for normal text of + # the theme for the line number settings + + except KeyError: + + lineno_settings = theme_text_settings["normal"] + + ## store the colors + + self.lineno_fg = lineno_settings["foreground_color"] + + self.lineno_bg = lineno_settings["background_color"] + + ### define the background color for the text + + self.background_color = self.theme_map["background_color"] + + ### otherwise, no syntax highlighting is applied, + ### but other setups are needed + + else: + + ### we define foreground and background colors + ### for general text and line number text + + self.foreground_color = self.lineno_fg = general_text_settings[ + "foreground_color" + ] + + self.background_color = self.lineno_bg = general_text_settings[ + "background_color" + ] + + def reset_style(self, style_name, new_style_value): + current_style_value = getattr(self, style_name) + + if new_style_value != current_style_value: + + setattr(self, style_name, new_style_value) + self.prepare_style_data() + self.update_image() + + reset_show_line_number = partialmethod(reset_style, "show_line_number") + + reset_syntax_highlighting = partialmethod(reset_style, "syntax_highlighting") + + reset_font_path = partialmethod(reset_style, "font_path") + + def edit_value(self): + """Edit value of widget on the text editor.""" + ### retrieve edited text: this triggers the + ### text editor edition session, which returns + ### the edited text when the user finishes editing + ### the text (or None, if the user decides to + ### cancel the operation) + + text = edit_text( + text=self.value, + font_path=self.font_path, + syntax_highlighting=self.syntax_highlighting, + validation_command=self.validation_command, + ) + + ### if there is an edited text (it is not None) + ### and it is different from the current value, + ### set such text as the new value + + if text is not None and text != self.value: + self.set(text) + + def get_expected_type(self): + return str + + def svg_repr(self): + + g = Element("g", {"class": "text_display"}) + + rect = self.rect.inflate(-2, -2) + + g.append( + Element( + "rect", + { + attr_name: str(getattr(rect, attr_name)) + for attr_name in ("x", "y", "width", "height") + }, + ) + ) + + ### + + x, y = rect.topleft + + for path_directives, style in ( + ( + ( + "m5 3" + " l13 0" + " q-4 4 0 8" + " q4 4 0 8" + " l-13 0" + " q4 -4 0 -8" + " q-4 -4 0 -8" + " Z" + ), + ("fill:white;" "stroke:black;" "stroke-width:2;"), + ), + ( + ("m6 7" " l8 0" " Z"), + ("fill:none;" "stroke:black;" "stroke-width:2;"), + ), + ( + ("m8 11" " l8 0" " Z"), + ("fill:none;" "stroke:black;" "stroke-width:2;"), + ), + ( + ("m9 15" " l8 0" " Z"), + ("fill:none;" "stroke:black;" "stroke-width:2;"), + ), + ( + ("m11 21" "l2 -7" "l5 3" " Z"), + ( + "fill:white;" + "stroke:black;" + "stroke-width:2px;" + "stroke-linejoin:round;" + ), + ), + ( + ("m13 14" "l5 3" "l6 -6" "-5 -3" "l-6 6" " Z"), + ( + "fill:yellow;" + "stroke:black;" + "stroke-width:2px;" + "stroke-linejoin:round;" + ), + ), + ( + ("m19 8" "l5 3" "l4 -4" "l-5 -3" " Z"), + "fill:red;stroke:black;stroke-width:2px;stroke-linejoin:round;", + ), + ): + + path_directives = f"M{x} {y}" + path_directives + + g.append( + Element( + "path", + { + "d": path_directives, + "style": style, + }, + ) + ) + + ######### + + rect = rect.move(0, ICON_HEIGHT) + rect.height += -ICON_HEIGHT + + ### + + style = f"fill:rgb{self.background_color};" + + g.append( + Element( + "rect", + { + "style": style, + **{ + attr_name: str(getattr(rect, attr_name)) + for attr_name in ("x", "y", "width", "height") + }, + }, + ) + ) + + ### + + no_of_visible_lines = self.no_of_visible_lines + show_line_number = self.show_line_number + font_height = self.font_height + font_path = self.font_path + + syntax_highlighting = self.syntax_highlighting + + if show_line_number: + + max_lineno_text = str(len(self.value.splitlines)) + lineno_digits = len(max_lineno_text) + + lineno_width, _ = get_text_size( + max_lineno_text, + font_height=font_height, + font_path=FIRA_MONO_BOLD_FONT_PATH, + ) + + lineno_rect = rect.copy() + lineno_rect.width = lineno_width - 2 + + style = f"fill:rgb{self.lineno_bg};" + + g.append( + Element( + "rect", + { + "style": style, + **{ + attr_name: str(getattr(lineno_rect, attr_name)) + for attr_name in ("x", "y", "width", "height") + }, + }, + ) + ) + + else: + lineno_width = 0 + + lines = self.value.splitlines()[:no_of_visible_lines] + + if syntax_highlighting: + + try: + highlight_data = self.get_syntax_map(self.value) + + except SyntaxMappingError: + + highlight_data = { + ## store a dict item where the line index + ## is the key and another dict is the value + line_index: { + ## in this dict, an interval representing + ## the indices of all items of the line + ## (character objects) is used as the + ## key, while the 'normal' string is used + ## as value, indicating that all content + ## must be considered normal text + (0, len(line_text)): "normal" + } + ## for each line_index and respective line + for line_index, line_text in enumerate(lines) + ## but only if the line isn't empty + if line_text + } + + ## + x = rect.x + lineno_width + 4 + y = rect.y + + theme_text_settings = self.theme_map["text_settings"] + + ## iterate over the visible lines and their + ## indices, highlighting their text according + ## to the highlighting data present + + for line_index, line_text in enumerate(lines, 0): + + y += font_height + + ## try popping out the interval data from + ## the highlight data dict with the line + ## index + + try: + interval_data = highlight_data.pop(line_index) + + ## if there is no such data, skip iteration + ## of this item + except KeyError: + pass + + ## otherwise... + + else: + + string_kwargs_pairs = ( + ( + line_text[including_start:excluding_end], + theme_text_settings[kind], + ) + for (including_start, excluding_end), kind in sorted( + interval_data.items(), key=lambda item: item[0] + ) + ) + + max_right = x + (125 - lineno_width) + + temp_x = x + + for string, text_settings in string_kwargs_pairs: + + x_increment, _ = get_text_size( + string, + font_height=font_height, + font_path=FIRA_MONO_BOLD_FONT_PATH, + ) + + text_fg = text_settings["foreground_color"] + + style = "font:bold 13px monospace;" f"fill:rgb{text_fg};" + + if temp_x + x_increment <= max_right: + + text_element = Element( + "text", + { + "x": str(temp_x), + "y": str(y), + "text-anchor": "start", + "style": style, + }, + ) + + text_element.text = string + + g.append(text_element) + + temp_x += x_increment + + ## try squeezing... + else: + + try: + + string = fit_text( + text=string, + max_width=max_right - temp_x, + ommit_direction="right", + font_height=font_height, + font_path=FIRA_MONO_BOLD_FONT_PATH, + padding=0, + ) + + except ValueError: + string = "\N{horizontal ellipsis}" + + text_element = Element( + "text", + { + "x": str(temp_x), + "y": str(y), + "text-anchor": "start", + "style": style, + }, + ) + + text_element.text = string + + g.append(text_element) + + break + + else: + + y = rect.y + + x = rect.x + (lineno_width + 4 if show_line_number else 4) + + foreground_color = self.foreground_color + + style = "font:bold 13px monospace;" f"fill:rgb{foreground_color};" + + for line_number, line_text in enumerate(lines, 1): + + y += font_height + + if line_number > no_of_visible_lines: + break + + line_text = fit_text( + text=line_text, + max_width=125, + ommit_direction="right", + font_height=font_height, + font_path=font_path, + padding=0, + ) + + text_element = Element( + "text", + { + "x": str(x), + "y": str(y), + "text-anchor": "start", + "style": style, + }, + ) + + text_element.text = line_text + + g.append(text_element) + + ### + + if show_line_number: + + x = rect.x + 4 + y = rect.y + + lineno_fg = self.lineno_fg + + style = "font:bold 13px monospace;" f"fill:rgb{lineno_fg};" + + for line_number, line_text in enumerate(lines, 1): + + y += font_height + + text_element = Element( + "text", + { + "x": str(x), + "y": str(y), + "text-anchor": "start", + "style": style, + }, + ) + + text_element.text = text = str(line_number).rjust(lineno_digits, "0") + + g.append(text_element) + + ### + return g diff --git a/nodezator/winman/fileop.py b/nodezator/winman/fileop.py index 4704db9a..a1e3466c 100644 --- a/nodezator/winman/fileop.py +++ b/nodezator/winman/fileop.py @@ -64,161 +64,261 @@ class FileOperations: """Contains methods related to files.""" + def __init__(self): + self.filepath = None + self.swap_path = None + self.is_temp_file = None + + def do_new(self): + ### generate new temporary filepath + filepath = APP_REFS.temp_filepaths_man.get_new_temp_filepath() + + ### save file + save_pyl({}, filepath) + + ### finally, load (open) the file + self.open(filepath) + + def new_actions_callback(self, answer): + ## XXX review comments in this block + if answer == "open_new": + ### make it appear as if there are no + ### unsaved changes; this will cause the + ### current changes to be ignored and + ### thereby lost when the newly created + ### file is opened + indicate_saved() + ### delete swap path contents + APP_REFS.swap_path.unlink() + + elif answer == "save_first": + self.save() + + else: + return + + self.do_new() + + def discard_changes_callback(self, answer): + if answer: + ### make it appear as if there are no + ### unsaved changes; this will cause the + ### current changes to be ignored and + ### thereby lost when the newly created + ### file is opened + indicate_saved() + self.do_new() + def new(self): """Create a new file.""" + if are_changes_saved(): + ### if there are not and there's a file loaded, it means it + ### is a regular file, so delete its swap file + try: + APP_REFS.source_path + except AttributeError: + pass + else: + APP_REFS.swap_path.unlink() + self.do_new() + return + ### prompt user for action if there are unsaved ### changes in the loaded file - if not are_changes_saved(): + ### whether loaded file is temporary - ### whether loaded file is temporary + if ( + APP_REFS.temp_filepaths_man.is_temp_path( + APP_REFS.source_path + ) + ): - if ( - APP_REFS.temp_filepaths_man.is_temp_path( - APP_REFS.source_path - ) - ): + create_and_show_dialog( - answer = ( - create_and_show_dialog( + ( + "There's a temporary new file already" + " being edited. Should we discard the" + " contents and create a new one?" + ), - ( - "There's a temporary new file already" - " being edited. Should we discard the" - " contents and create a new one?" - ), + buttons=( - buttons=( + ("Yes", True), + ("Abort", False), - ("Yes", True), - ("Abort", False), + ), + level_name="warning", + dismissable=True, + callback = self.discard_changes_callback, + ) - ), - level_name="warning", - dismissable=True, - ) - ) + else: - if answer: - ### make it appear as if there are no - ### unsaved changes; this will cause the - ### current changes to be ignored and - ### thereby lost when the newly created - ### file is opened - indicate_saved() + show_dialog_from_key( + "create_new_while_unsaved_dialog", + callback = self.new_actions_callback, + ) - else: - return + def load_file(self): + ### try loading the file, storing its data + try: + loaded_data = load_pyl(self.filepath) - else: + ### if loading fails abort opening the file by + ### leaving this method after notifying the + ### user of the problem - ## XXX review comments in this block + except Exception as err: - answer = ( - show_dialog_from_key( - "create_new_while_unsaved_dialog", - ) - ) + message = "An error occurred while trying to" " open a file." - if answer == "open_new": + logger.exception(message) - ### make it appear as if there are no - ### unsaved changes; this will cause the - ### current changes to be ignored and - ### thereby lost when the newly created - ### file is opened - indicate_saved() + USER_LOGGER.exception(message) - ### delete swap path contents - APP_REFS.swap_path.unlink() + create_and_show_dialog( + ( + "An error ocurred while trying to" + f" open {self.filepath}. Check the user" + " log () for details." + ), + level_name="error", + ) - elif answer == "save_first": - self.save() + return - else: - return + ### if the given filepath is temporary or the swap path + ### for the regular file does not exist, we copy the + ### source contents into it - ### if there are not and there's a file loaded, it means it - ### is a regular file, so delete its swap file + if self.is_temp_file or not self.swap_path.is_file(): - else: + source_contents = self.filepath.read_text(encoding="utf-8") + + self.swap_path.write_text( + source_contents, + encoding="utf-8", + ) + + ### store both paths for access throughout the + ### system + + ## store source path + APP_REFS.source_path = self.filepath + + ## store swap path + + # admin task for regular files: remove existing swap + # if present and different from the one being loaded + # (this will happen when loading a file when there is + # another one already loaded) + + if not self.is_temp_file: try: - APP_REFS.source_path + current_swap = APP_REFS.swap_path + except AttributeError: pass + else: - APP_REFS.swap_path.unlink() - ### generate new temporary filepath - filepath = APP_REFS.temp_filepaths_man.get_new_temp_filepath() + if not current_swap.samefile(self.swap_path): + current_swap.unlink() - ### save file - save_pyl({}, filepath) + APP_REFS.swap_path = self.swap_path - ### finally, load (open) the file - self.open(filepath) + ### clean up native format data that may + ### exist from previous session + APP_REFS.data.clear() - def open(self, filepath=None): - """Open a new file. + ### replace such data with new native format + ### data loaded from the file to be opened + APP_REFS.data = loaded_data - filepath (pathlib.Path instance) - Path to the file to be opened. - """ - ### if no filepath is provided, prompt user to - ### pick one from the file manager + ### if filepath is not temporary, store it as a recently + ### open file, so it is available in the menubar under + ### the "File > Open recent" submenu + if not self.is_temp_file: + store_recent_file(APP_REFS.source_path) - if not filepath: + ### finally, + ### - prepare the application for a new session + ### - indicate file as unsaved if it is temporary + ### - draw the window manager + ### - restart the loop making the window manager + ### the loop holder + ### + ### drawing is important here cause the user + ### may accidentally keep the mouse over the + ### menubar when the file finishes loading, + ### which would make it so the menu would + ### be the loop holder, thus the graph + ### objects would not be initially drawn + ### on the screen - ## pick path + self.prepare_for_new_session() - paths = select_paths(caption=OPEN_FILE_CAPTION) + if self.is_temp_file: + indicate_unsaved() - ## respond according to number of paths given + self.draw() - length = len(paths) + raise SwitchLoopException - if length == 1: - filepath = paths[0] + def swap_file_callback(self, answer): + # load original file (ignore swap) + if answer == "load_original": + self.swap_path.unlink() + source_contents = ( + self.filepath.read_text(encoding="utf-8") + ) - elif length > 1: + self.swap_path.write_text( + source_contents, + encoding="utf-8", + ) - show_dialog_from_key( - "expected_single_path_dialog" - ) - return + # load swap file (ignore original) - else: - filepath = None + elif answer == "load_swap": - ### if even so the user didn't provide a filepath, - ### return earlier - if not filepath: - return + ### XXX review this block - ### prompt user for action in case a file is provided - ### but there are unsaved changes in the current one + ### TODO also prevent people from + ### - accidentaly - clicking the button + ### that leads here when the dialog + ### comes up; - if filepath and not are_changes_saved(): + # save backup for the filepath - answer = ( - show_dialog_from_key( - "open_new_while_unsaved_dialog", - ) + save_timestamped_backup( + self.filepath, + USER_PREFS["NUMBER_OF_BACKUPS"], ) - if answer == "open new": - pass - elif answer == "save first": - self.save() - else: - return + # copy swap file contents into + # source file + + swap_contents = self.swap_path.read_text(encoding="utf-8") + self.filepath.write_text( + swap_contents, + encoding="utf-8", + ) + + else: + return + + self.load_file() + + def verify_temp_file(self): ### store boolean indicating whether or not the given path ### is of a temporary file - is_temp_file = ( - APP_REFS.temp_filepaths_man.is_temp_path(filepath) + self.is_temp_file = ( + APP_REFS.temp_filepaths_man.is_temp_path(self.filepath) ) ### TODO checks below should be made before the @@ -241,15 +341,15 @@ def open(self, filepath=None): ### processing it for usage if ( - filepath.is_file() - and filepath.suffix.lower() == NATIVE_FILE_EXTENSION + self.filepath.is_file() + and self.filepath.suffix.lower() == NATIVE_FILE_EXTENSION ): ### perform actions depending on whether filepath is a ### temporary file or not - if is_temp_file: - swap_path = TEMP_FILE_SWAP + if self.is_temp_file: + self.swap_path = TEMP_FILE_SWAP else: @@ -259,7 +359,7 @@ def open(self, filepath=None): ### how we'll approach the file loading ## generate swap path - swap_path = get_swap_path(filepath) + self.swap_path = get_swap_path(self.filepath) ## if swap file exists there might have been a ## crash which forced the program to exit, @@ -268,162 +368,76 @@ def open(self, filepath=None): ## prompt the user to decide which action to ## perform: - if swap_path.is_file(): - - answer = show_dialog_from_key("swap_exists_dialog") - - # load original file (ignore swap) - - if answer == "load_original": - - swap_path.unlink() - - source_contents = ( - filepath.read_text(encoding="utf-8") - ) - - swap_path.write_text( - source_contents, - encoding="utf-8", - ) - - # load swap file (ignore original) - - elif answer == "load_swap": - - ### XXX review this block - - ### TODO also prevent people from - ### - accidentaly - clicking the button - ### that leads here when the dialog - ### comes up; - - # save backup for the filepath - - save_timestamped_backup( - filepath, - USER_PREFS["NUMBER_OF_BACKUPS"], - ) - - # copy swap file contents into - # source file - - swap_contents = swap_path.read_text(encoding="utf-8") - - filepath.write_text( - swap_contents, - encoding="utf-8", - ) - - else: - return - - ### try loading the file, storing its data - try: - loaded_data = load_pyl(filepath) - - ### if loading fails abort opening the file by - ### leaving this method after notifying the - ### user of the problem - - except Exception as err: - - message = "An error occurred while trying to" " open a file." - - logger.exception(message) - - USER_LOGGER.exception(message) - - create_and_show_dialog( - ( - "An error ocurred while trying to" - f" open {filepath}. Check the user" - " log () for details." - ), - level_name="error", - ) - - return - - ### if the given filepath is temporary or the swap path - ### for the regular file does not exist, we copy the - ### source contents into it - - if is_temp_file or not swap_path.is_file(): - - source_contents = filepath.read_text(encoding="utf-8") - - swap_path.write_text( - source_contents, - encoding="utf-8", - ) - - ### store both paths for access throughout the - ### system - - ## store source path - APP_REFS.source_path = filepath - - ## store swap path - - # admin task for regular files: remove existing swap - # if present and different from the one being loaded - # (this will happen when loading a file when there is - # another one already loaded) - - if not is_temp_file: - - try: - current_swap = APP_REFS.swap_path + if self.swap_path.is_file(): - except AttributeError: - pass - - else: - - if not current_swap.samefile(swap_path): - current_swap.unlink() - - APP_REFS.swap_path = swap_path - - ### clean up native format data that may - ### exist from previous session - APP_REFS.data.clear() - - ### replace such data with new native format - ### data loaded from the file to be opened - APP_REFS.data = loaded_data + show_dialog_from_key( + "swap_exists_dialog", + callback = self.swap_file_callback, + ) + return - ### if filepath is not temporary, store it as a recently - ### open file, so it is available in the menubar under - ### the "File > Open recent" submenu - if not is_temp_file: - store_recent_file(APP_REFS.source_path) + self.load_file() - ### finally, - ### - prepare the application for a new session - ### - indicate file as unsaved if it is temporary - ### - draw the window manager - ### - restart the loop making the window manager - ### the loop holder - ### - ### drawing is important here cause the user - ### may accidentally keep the mouse over the - ### menubar when the file finishes loading, - ### which would make it so the menu would - ### be the loop holder, thus the graph - ### objects would not be initially drawn - ### on the screen + def do_open_callback(self, answer): + if answer == "open new": + pass + elif answer == "save first": + self.save() + else: + return + self.verify_temp_file() + + def do_open(self): + ### prompt user for action in case a file is provided + ### but there are unsaved changes in the current one + if self.filepath and not are_changes_saved(): + show_dialog_from_key( + "open_new_while_unsaved_dialog", + callback = self.do_open_callback, + ) + else: + self.verify_temp_file() + + def open_callback(self, paths): + ## respond according to number of paths given + length = len(paths) - self.prepare_for_new_session() + if length == 1: + filepath = paths[0] - if is_temp_file: - indicate_unsaved() + elif length > 1: - self.draw() + show_dialog_from_key( + "expected_single_path_dialog" + ) + return - raise SwitchLoopException + else: + filepath = None + ### if even so the user didn't provide a filepath, + ### return earlier + if not filepath: + return + + self.filepath = filepath + self.do_open() + + + def open(self, filepath=None): + """Open a new file. + filepath (pathlib.Path instance) + Path to the file to be opened. + """ + ### if no filepath is provided, prompt user to + ### pick one from the file manager + if not filepath: + ## pick path + select_paths(caption=OPEN_FILE_CAPTION, callback = self.open_callback) + else: + self.filepath = filepath + self.do_open() + def perform_startup_preparations(self, filepath): """Perform tasks for startup and return loop holder. @@ -556,111 +570,13 @@ def save(self): set_status_message("Changes were successfully saved.") - def save_as(self): - """Save data in new file and keep using new file. - - Works by saving the data in the new location - provided by the user via the file manager - session we start and using that new location from - then on. - - Other admin taks are also performed like deleting - the swap file for the original file. - """ - ### prompt user to pick filepath from file manager - - paths = select_paths( - caption=SAVE_AS_CAPTION, - path_name="new_file_name.ndz", - ) - - ### respond according to whether paths were given - - ## if paths were given, it is a single one, we - ## should assign it to 'filepath' variable - if paths: - filepath = paths[0] - - ## if the user didn't provide paths, though, - ## return earlier - else: - return - - - ### perform tasks to save current file in the - ### provided path, using it from now on - - ### if the path doesn't have the right extension, - ### ask user if we should add it ourselves or - ### cancel the operation (in which case we just return) - - if filepath.suffix != NATIVE_FILE_EXTENSION: - - ## build custom message - - message = ( - "Path provided must have a" - f" {NATIVE_FILE_EXTENSION} extension." - " Want us to add it for you?" - ) - - ## each button is represented by a pair - ## consisting of the text of the button and - ## the value the dialog returns when we - ## click it - - buttons = [("Yes", True), ("No, cancel saving new file", False)] - - ## display the dialog and store the answer - ## provided by the user when clicking - answer = create_and_show_dialog(message, buttons) - - ## if the answer is False, then cancel operation - if not answer: - return - - ## otherwise apply the correct extension - - else: - filepath = filepath.with_suffix(NATIVE_FILE_EXTENSION) - - ### if path already exists, prompt user to confirm - ### whether we should override it - - if filepath.exists(): - - ## build custom message - - message = ( - f"The path provided ({filepath}) already exists." - " Should we override it?" - ) - - ## each button is represented by a pair - ## consisting of the text of the button and - ## the value the dialog returns when we - ## click it - - buttons = [("Ok", True), ("Cancel", False)] - - ## display the dialog and store the answer - ## provided by the user when clicking - answer = create_and_show_dialog(message, buttons) - - ### if the answer is False, then we shouldn't - ### override, so we cancel the operation by - ### returning - if not answer: - return - - ### otherwise we proceed - + def perform_save_overwrite(self): ### backup the current source path original_source = APP_REFS.source_path ### assign the new file as the source the be used ### instead - APP_REFS.source_path = filepath + APP_REFS.source_path = self.filepath ### try saving current data on the new path try: @@ -695,12 +611,12 @@ def save_as(self): ### create a swap path for the new source and store ### it - swap_path = get_swap_path(filepath) + swap_path = get_swap_path(self.filepath) APP_REFS.swap_path = swap_path ### save contents of source in the swap path - source_contents = filepath.read_text(encoding="utf-8") + source_contents = self.filepath.read_text(encoding="utf-8") swap_path.write_text(source_contents, encoding="utf-8") @@ -710,7 +626,7 @@ def save_as(self): store_recent_file(APP_REFS.source_path) ### get a custom string representation for the source - path_str = get_custom_path_repr(filepath) + path_str = get_custom_path_repr(self.filepath) ### update caption to show the new loaded path self.put_path_on_caption(path_str) @@ -719,6 +635,119 @@ def save_as(self): ### statusbar set_status_message(f"Using new file from now on {path_str}") + def save_overwrite_callback(self, answer): + ### if the answer is False, then we shouldn't + ### override, so we cancel the operation by + ### returning + if not answer: + return + self.perform_save_overwrite() + + def do_save_as(self): + if not self.filepath.exists(): + self.perform_save_overwrite() + return + + ### if path already exists, prompt user to confirm + ### whether we should override it + + ## build custom message + + message = ( + f"The path provided ({self.filepath}) already exists." + " Should we override it?" + ) + + ## each button is represented by a pair + ## consisting of the text of the button and + ## the value the dialog returns when we + ## click it + + buttons = [("Ok", True), ("Cancel", False)] + + ## display the dialog and store the answer + ## provided by the user when clicking + create_and_show_dialog( + message, + buttons, + callback = self.save_overwrite_callback, + ) + + def add_extension_callback(self, answer): + ## if the answer is False, then cancel operation + if not answer: + return + ## otherwise apply the correct extension + self.filepath = self.filepath.with_suffix(NATIVE_FILE_EXTENSION) + self.do_save_as() + + def save_as_callback(self, paths): + ### respond according to whether paths were given + + ## if paths were given, it is a single one, we + ## should assign it to 'filepath' variable + if paths: + self.filepath = paths[0] + + ## if the user didn't provide paths, though, + ## return earlier + else: + return + + + ### perform tasks to save current file in the + ### provided path, using it from now on + + if self.filepath.suffix == NATIVE_FILE_EXTENSION: + self.do_save_as() + return + + ### if the path doesn't have the right extension, + ### ask user if we should add it ourselves or + ### cancel the operation (in which case we just return) + + ## build custom message + + message = ( + "Path provided must have a" + f" {NATIVE_FILE_EXTENSION} extension." + " Want us to add it for you?" + ) + + ## each button is represented by a pair + ## consisting of the text of the button and + ## the value the dialog returns when we + ## click it + + buttons = [("Yes", True), ("No, cancel saving new file", False)] + + ## display the dialog and store the answer + ## provided by the user when clicking + create_and_show_dialog( + message, + buttons, + callback = self.add_extension_callback, + ) + + def save_as(self): + """Save data in new file and keep using new file. + + Works by saving the data in the new location + provided by the user via the file manager + session we start and using that new location from + then on. + + Other admin taks are also performed like deleting + the swap file for the original file. + """ + ### prompt user to pick filepath from file manager + + select_paths( + caption=SAVE_AS_CAPTION, + path_name="new_file_name.ndz", + callback = self.save_as_callback, + ) + def save_data(self): """Save data on filepath. @@ -727,6 +756,23 @@ def save_data(self): """ save_pyl(APP_REFS.data, APP_REFS.source_path) + def reload_callback(self, answer): + if answer == "reload": + + ### make it appear as if there are no + ### unsaved changes; this will cause the + ### current changes to be ignored and + ### thereby lost when the file is reloaded + ### (opened again) + indicate_saved() + + ### remove swap file + APP_REFS.swap_path.unlink() + + ### finally reopen the current file + self.open(APP_REFS.source_path) + + def reload(self): """Reload current file.""" ### the reloading mechanism doesn't apply to temporary files; @@ -739,33 +785,22 @@ def reload(self): "Cannot reload temporary files.", level_name="warning", ) - return ### prompt user for action in case there are unsaved ### changes - if not are_changes_saved(): - - answer = show_dialog_from_key("reload_unsaved_dialog") - - if answer == "reload": - - ### make it appear as if there are no - ### unsaved changes; this will cause the - ### current changes to be ignored and - ### thereby lost when the file is reloaded - ### (opened again) - indicate_saved() - - elif answer == "abort": - return - - ### remove swap file - APP_REFS.swap_path.unlink() + if are_changes_saved(): + ### remove swap file + APP_REFS.swap_path.unlink() - ### finally reopen the current file - self.open(APP_REFS.source_path) + ### finally reopen the current file + self.open(APP_REFS.source_path) + else: + show_dialog_from_key( + "reload_unsaved_dialog", + callback = self.reload_callback, + ) ### utility diff --git a/nodezator/winman/main.py b/nodezator/winman/main.py index dcfff385..8eff7d98 100644 --- a/nodezator/winman/main.py +++ b/nodezator/winman/main.py @@ -158,7 +158,12 @@ def __init__(self): GraphManager() EditingAssistant() - + + self.state_name = "no_file" + self.original_local_node_packs = None + self.current_local_node_packs = None + self.faulty_pack = None + self.splash_screen = SplashScreen() ### instantiate label widgets @@ -187,238 +192,283 @@ def resize_setups(self): self.create_separator_surface() self.reposition_labels() - def prepare_for_new_session(self): - """Instantiate and set up objects. + def update_session_state(self): + ### set the state picked + self.set_state(self.state_name) - Also takes additional measures to free memory and - check whether there are issues with the nodes - directory, in case a file is to be loaded. - """ - ### first of all free up memory used by data/objects - ### from a possible previous session, data/objects - ### that may not be needed anymore - free_up_memory() + ### instantiate support widgets and set manager + self.build_support_widgets() - ### check if a valid filepath sits on 'source_path' + ### check again if a valid filepath still sits on + ### 'source_path' ### attribute of the APP_REFS object (it means a ### file is loaded) try: APP_REFS.source_path - ### if not, pick 'no_file' state name + ### if not, reset caption to its initial state except AttributeError: - state_name = "no_file" - - ### otherwise perform setups and add the update - ### viz widgets to the methods to be executed when - ### updating the window manager + reset_caption() + ### otherwise, create the popup menu else: + self.create_canvas_popup_menu() - ### check if the local node packs provided in - ### the file are appropriate, to prevent the - ### application from crashing if such node - ### packs don't exist or are somehow faulty - - original_local_node_packs = ( - get_formatted_local_node_packs(APP_REFS.source_path) - ) - - current_local_node_packs = original_local_node_packs.copy() - - local_node_packs_are_ok = True - - while True: - - try: - check_local_node_packs(current_local_node_packs) - - except NODE_PACK_ERRORS as err: - - message = ( - "One of the provided local node packs" - " presented the following issue:" - f" {err}; what would you like to do?" - ) - - options = ( - ("Select replacement/new path", "select"), - ("Cancel loading file", "cancel"), - ) - - answer = create_and_show_dialog( - message, - options, - level_name="warning", - ) - - if answer == "select": - - result = select_paths( - caption="Select replacement/new path for local node pack" - ) - - if result: - - current_local_node_packs.remove(err.faulty_pack) - - replacement = result[0] - current_local_node_packs.append(replacement) - - else: - - local_node_packs_are_ok = False - break - - else: - break - - if local_node_packs_are_ok: - - ### if by this point the original node - ### packs listed have changed, then it is - ### as if we changed the file, so we save - ### its current contents before assigning - ### the new node packs - - if set(current_local_node_packs) != set(original_local_node_packs): - - APP_REFS.data["node_packs"] = [ - str(path) for path in current_local_node_packs - ] - - ## pass content from source to backup - ## files just like rotating contents - ## between log files in Python - - save_timestamped_backup( - APP_REFS.source_path, USER_PREFS["NUMBER_OF_BACKUPS"] - ) - - ## save the data in the source, - ## since it now have different node - ## packs - save_pyl(APP_REFS.data, APP_REFS.source_path) - - ## finally, copy the contents of the - ## source to the swap file + def cancel_loading_file_callback(self): + clean_loaded_file_data() + self.state_name = "no_file" + self.update_session_state() + + def cancel_loading_file(self): + ### trigger cancellation of file + ### loading and pick the 'no_file' state name + show_dialog_from_key( + "cancelled_file_loading_dialog", + callback = cancel_loading_file_callback, + ) + + def load_new_session(self): + if self.current_local_node_packs is not None: - APP_REFS.swap_path.write_text( - APP_REFS.source_path.read_text(encoding="utf-8"), - encoding="utf-8", - ) + ### if by this point the original node + ### packs listed have changed, then it is + ### as if we changed the file, so we save + ### its current contents before assigning + ### the new node packs - ### check if the "installed" node packs provided in - ### the file are appropriate, to prevent the - ### application from crashing if such node - ### packs can't be found or are somehow faulty + if set(self.current_local_node_packs) != set(self.original_local_node_packs): - installed_node_packs = get_formatted_installed_node_packs( - APP_REFS.source_path - ) + APP_REFS.data["node_packs"] = [ + str(path) for path in self.current_local_node_packs + ] - try: - check_installed_node_packs(installed_node_packs) - except NODE_PACK_ERRORS as err: + ## pass content from source to backup + ## files just like rotating contents + ## between log files in Python - message = ( - "One of the provided node packs" - " (the ones supposed to be installed)" - " presented the following issue:" - f" {err}; aborting loading file now" + save_timestamped_backup( + APP_REFS.source_path, USER_PREFS["NUMBER_OF_BACKUPS"] ) - create_and_show_dialog(message) - - installed_node_packs_are_ok = False + ## save the data in the source, + ## since it now have different node + ## packs + save_pyl(APP_REFS.data, APP_REFS.source_path) - else: - installed_node_packs_are_ok = True + ## finally, copy the contents of the + ## source to the swap file - if local_node_packs_are_ok and installed_node_packs_are_ok: - - ### try preparing graph manager for - ### edition - try: - APP_REFS.gm.prepare_for_new_session() - - ### if it fails, report the problem to - ### user and pick the 'no_file' state name + APP_REFS.swap_path.write_text( + APP_REFS.source_path.read_text(encoding="utf-8"), + encoding="utf-8", + ) - except Exception as err: + ### check if the "installed" node packs provided in + ### the file are appropriate, to prevent the + ### application from crashing if such node + ### packs can't be found or are somehow faulty - ## do this to signal other method not to - ## create extra dependencies - del APP_REFS.source_path + installed_node_packs = get_formatted_installed_node_packs( + APP_REFS.source_path + ) - ## report problem to user + try: + check_installed_node_packs(installed_node_packs) + + except NODE_PACK_ERRORS as err: + + message = ( + "One of the provided node packs" + " (the ones supposed to be installed)" + " presented the following issue:" + f" {err}; aborting loading file now" + ) - create_and_show_dialog( - ( - "Error while trying to prepare" - " for new session (check user log" - " on Help menu for more info)" - f": {err}" - ), - level_name="error", - ) + create_and_show_dialog( + message, + callback = self.cancel_loading_file_callback, + ) + return - state_name = "no_file" - ## also log it + ### try preparing graph manager for + ### edition + try: + APP_REFS.gm.prepare_for_new_session() - msg = "Unexpected error while trying" " to prepare for new session" + ### if it fails, report the problem to + ### user and pick the 'no_file' state name - logger.exception(msg) - USER_LOGGER.exception(msg) + except Exception as err: - ### otherwise, perform additional setups - ### and pick the 'loaded_file' state name + ## do this to signal other method not to + ## create extra dependencies + del APP_REFS.source_path - else: + ## also log it - APP_REFS.ea.prepare_for_new_session() + msg = "Unexpected error while trying" " to prepare for new session" - if not APP_REFS.temp_filepaths_man.is_temp_path( - APP_REFS.source_path - ): - store_recent_file(APP_REFS.source_path) + logger.exception(msg) + USER_LOGGER.exception(msg) - self.build_app_widgets() + ## report problem to user + create_and_show_dialog( + ( + "Error while trying to prepare" + " for new session (check user log" + " on Help menu for more info)" + f": {err}" + ), + level_name="error", + callback = self.cancel_loading_file_callback, + ) + return - state_name = "loaded_file" + ### otherwise, perform additional setups + ### and pick the 'loaded_file' state name - ### otherwise, trigger cancellation of file - ### loading and pick the 'no_file' state name + APP_REFS.ea.prepare_for_new_session() - else: + if not APP_REFS.temp_filepaths_man.is_temp_path( + APP_REFS.source_path + ): + store_recent_file(APP_REFS.source_path) + + self.build_app_widgets() + + self.state_name = "loaded_file" + + self.update_session_state(); + + def select_replacement_callback(self, result): + if result: + self.current_local_node_packs.remove(self.faulty_pack) + self.current_local_node_packs.append(result[0]) # replacement + self.verify_local_node_packs() + else: + self.current_local_node_packs = None # invalid local node packs + self.load_new_session() + + def verify_local_node_packs_callback(self, answer): + if answer == "select": + select_paths( + caption="Select replacement/new path for local node pack", + callback = self.select_replacement_callback, + ) + else: + # cancel loading + self.current_local_node_packs = None # invalid local node packs + self.cancel_loading_file() - show_dialog_from_key("cancelled_file_loading_dialog") + def verify_local_node_packs(self): + ### check if the local node packs provided in + ### the file are appropriate, to prevent the + ### application from crashing if such node + ### packs don't exist or are somehow faulty - clean_loaded_file_data() + try: + self.faulty_pack = None + check_local_node_packs(self.current_local_node_packs) + + self.load_new_session() + + except NODE_PACK_ERRORS as err: + + message = ( + "One of the provided local node packs" + " presented the following issue:" + f" {err}; what would you like to do?" + ) - state_name = "no_file" + options = ( + ("Select replacement/new path", "select"), + ("Cancel loading file", "cancel"), + ) - ### set the state picked - self.set_state(state_name) + self.faulty_pack = err.faulty_pack + create_and_show_dialog( + message, + options, + level_name="warning", + callback = self.verify_local_node_packs_callback, + ) + + def prepare_for_new_session(self): + """Instantiate and set up objects. - ### instantiate support widgets and set manager - self.build_support_widgets() + Also takes additional measures to free memory and + check whether there are issues with the nodes + directory, in case a file is to be loaded. + + !!!FIXME!!! need better notation to document this: + + Using callbacks, following STATES are performed to prepare for new session: (described in pseudo code) + + START: prepare_for_new_session(): + if __NO__ valid filepath sits on 'source_path' + then FINISH_NO_FILE + otherwise VERIFICATION_LOOP + + VERIFICATION_LOOP: verify_local_node_packs(): + if the local node packs provided in the file are appropriate + then LOAD_NEW_SESSION + otherwise REPLACE_FAULTY_PACK + or CANCEL_LOADING + + REPLACE_FAULTY_PACK: + replace faulty pack + verify local node packs again => VERIFICATION_LOOP + + CANCEL_LOADING: + clean up + FINISH_NO_FILE + + LOAD_NEW_SESSION: + if the "installed" node packs provided in the file are appropriate + then FINISH_LOADED_FILE + otherwise CANCEL_LOADING + + FINISH_NO_FILE: + update_session_state("no_file") + DONE + + FINISH_LOADED_FILE: + update_session_state("loaded_file") + DONE + + DONE: + new session loaded + """ + ### first of all free up memory used by data/objects + ### from a possible previous session, data/objects + ### that may not be needed anymore + free_up_memory() - ### check again if a valid filepath still sits on - ### 'source_path' + ### check if a valid filepath sits on 'source_path' ### attribute of the APP_REFS object (it means a ### file is loaded) try: APP_REFS.source_path - ### if not, reset caption to its initial state + ### if not, pick 'no_file' state name except AttributeError: - reset_caption() + self.state_name = "no_file" + self.update_session_state() + return - ### otherwise, create the popup menu - else: - self.create_canvas_popup_menu() + ### otherwise perform setups and add the update + ### viz widgets to the methods to be executed when + ### updating the window manager + + ### check if the local node packs provided in the file are appropriate + + self.original_local_node_packs = ( + get_formatted_local_node_packs(APP_REFS.source_path) + ) + self.current_local_node_packs = self.original_local_node_packs.copy() + + self.verify_local_node_packs() def build_state_behaviour_map(self): """Build map with behaviours for each state.""" diff --git a/samples/test.ndz b/samples/test.ndz new file mode 100644 index 00000000..32739675 --- /dev/null +++ b/samples/test.ndz @@ -0,0 +1,35 @@ +{ 'installed_node_packs': [], + 'node_packs': [], + 'nodes': { 0: { 'builtin_id': 'print', + 'commented_out': False, + 'id': 0, + 'midtop': (736.0, 233.0), + 'mode': 'expanded_signature', + 'param_widget_value_map': { 'end': '\n', + 'flush': False, + 'sep': ' '}, + 'subparam_keyword_map': {}, + 'subparam_map': {'objects': [0]}, + 'subparam_unpacking_map': {'objects': []}, + 'subparam_widget_map': {'objects': {}}}, + 1: { 'commented_out': False, + 'id': 1, + 'midtop': (224.0, 258.0), + 'title': 'output', + 'widget_data': { 'widget_kwargs': { 'value': 'This is my ' + 'multi line ' + 'strings.\r\n' + 'string\r\n' + 'in multiple\r\n' + 'lines\r\n' + 'end of lines'}, + 'widget_name': 'text_display'}}}, + 'parent_sockets': [ { 'children': [ { 'class_name': 'InputSocket', + 'id': (0, 'objects', 0)}], + 'class_name': 'OutputSocket', + 'id': (1, 'output')}], + 'text_blocks': [ { 'midtop': (167.0, 74.0), + 'text': 'This is my multi line texts.\r\n' + 'Line 2\r\n' + 'Line 3\r\n' + 'End of lines'}]} \ No newline at end of file diff --git a/samples/test2.ndz b/samples/test2.ndz new file mode 100644 index 00000000..a243044f --- /dev/null +++ b/samples/test2.ndz @@ -0,0 +1,29 @@ +{ 'installed_node_packs': [], + 'node_packs': [], + 'nodes': { 0: { 'commented_out': False, + 'id': 0, + 'midtop': (422.0, 296.0), + 'title': 'output', + 'widget_data': { 'widget_kwargs': { 'value': 'string\n' + 'in multiple\n' + 'lines'}, + 'widget_name': 'text_display'}}, + 1: { 'builtin_id': 'print', + 'commented_out': False, + 'id': 1, + 'midtop': (735.0, 223.0), + 'mode': 'expanded_signature', + 'param_widget_value_map': { 'end': '\n', + 'flush': False, + 'sep': ' '}, + 'subparam_keyword_map': {}, + 'subparam_map': {'objects': [0]}, + 'subparam_unpacking_map': {'objects': []}, + 'subparam_widget_map': {'objects': {}}}}, + 'parent_sockets': [ { 'children': [ { 'class_name': 'InputSocket', + 'id': (1, 'objects', 0)}], + 'class_name': 'OutputSocket', + 'id': (0, 'output')}], + 'text_blocks': [ { 'midtop': (267.0, 150.0), + 'text': 'Just \r\na \r\nmultiline \r\ncomments\r\n'}, + {'midtop': (383.0, 540.0), 'text': 'New text block'}]} \ No newline at end of file diff --git a/statistics/__init__.py b/statistics/__init__.py new file mode 100644 index 00000000..56233e1a --- /dev/null +++ b/statistics/__init__.py @@ -0,0 +1,597 @@ +# -*- coding: UTF-8 -*- +## Module statistics.py +## +## Copyright (c) 2013 Steven D'Aprano . +## +## Licensed under the Apache License, Version 2.0 (the "License"); +## you may not use this file except in compliance with the License. +## You may obtain a copy of the License at +## +## http://www.apache.org/licenses/LICENSE-2.0 +## +## Unless required by applicable law or agreed to in writing, software +## distributed under the License is distributed on an "AS IS" BASIS, +## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +## See the License for the specific language governing permissions and +## limitations under the License. + + +u""" +Basic statistics module. + +This module provides functions for calculating statistics of data, including +averages, variance, and standard deviation. + +Calculating averages +-------------------- + +================== ============================================= +Function Description +================== ============================================= +mean Arithmetic mean (average) of data. +median Median (middle value) of data. +median_low Low median of data. +median_high High median of data. +median_grouped Median, or 50th percentile, of grouped data. +mode Mode (most common value) of data. +================== ============================================= + +Calculate the arithmetic mean ("the average") of data: + +>>> mean([-1.0, 2.5, 3.25, 5.75]) +2.625 + + +Calculate the standard median of discrete data: + +>>> median([2, 3, 4, 5]) +3.5 + + +Calculate the median, or 50th percentile, of data grouped into class intervals +centred on the data values provided. E.g. if your data points are rounded to +the nearest whole number: + +>>> median_grouped([2, 2, 3, 3, 3, 4]) #doctest: +ELLIPSIS +2.8333333333... + +This should be interpreted in this way: you have two data points in the class +interval 1.5-2.5, three data points in the class interval 2.5-3.5, and one in +the class interval 3.5-4.5. The median of these data points is 2.8333... + + +Calculating variability or spread +--------------------------------- + +================== ============================================= +Function Description +================== ============================================= +pvariance Population variance of data. +variance Sample variance of data. +pstdev Population standard deviation of data. +stdev Sample standard deviation of data. +================== ============================================= + +Calculate the standard deviation of sample data: + +>>> stdev([2.5, 3.25, 5.5, 11.25, 11.75]) #doctest: +ELLIPSIS +4.38961843444... + +If you have previously calculated the mean, you can pass it as the optional +second argument to the four "spread" functions to avoid recalculating it: + +>>> data = [1, 2, 2, 4, 4, 4, 5, 6] +>>> mu = mean(data) +>>> pvariance(data, mu) +2.5 + + +Exceptions +---------- + +A single exception is defined: StatisticsError is a subclass of ValueError. + +""" + +from __future__ import division +__all__ = [ u'StatisticsError', + u'pstdev', u'pvariance', u'stdev', u'variance', + u'median', u'median_low', u'median_high', u'median_grouped', + u'mean', u'mode', + ] + + +import collections +import math + +from fractions import Fraction +from decimal import Decimal + + +# === Exceptions === + +class StatisticsError(ValueError): + pass + + +# === Private utilities === + +def _sum(data, start=0): + u"""_sum(data [, start]) -> value + + Return a high-precision sum of the given numeric data. If optional + argument ``start`` is given, it is added to the total. If ``data`` is + empty, ``start`` (defaulting to 0) is returned. + + + Examples + -------- + + >>> _sum([3, 2.25, 4.5, -0.5, 1.0], 0.75) + 11.0 + + Some sources of round-off error will be avoided: + + >>> _sum([1e50, 1, -1e50] * 1000) # Built-in sum returns zero. + 1000.0 + + Fractions and Decimals are also supported: + + >>> from fractions import Fraction as F + >>> _sum([F(2, 3), F(7, 5), F(1, 4), F(5, 6)]) + Fraction(63, 20) + + >>> from decimal import Decimal as D + >>> data = [D("0.1375"), D("0.2108"), D("0.3061"), D("0.0419")] + >>> _sum(data) + Decimal('0.6963') + + Mixed types are currently treated as an error, except that int is + allowed. + """ + # We fail as soon as we reach a value that is not an int or the type of + # the first value which is not an int. E.g. _sum([int, int, float, int]) + # is okay, but sum([int, int, float, Fraction]) is not. + allowed_types = set([int, type(start)]) + n, d = _exact_ratio(start) + partials = {d: n} # map {denominator: sum of numerators} + # Micro-optimizations. + exact_ratio = _exact_ratio + partials_get = partials.get + # Add numerators for each denominator. + for x in data: + _check_type(type(x), allowed_types) + n, d = exact_ratio(x) + partials[d] = partials_get(d, 0) + n + # Find the expected result type. If allowed_types has only one item, it + # will be int; if it has two, use the one which isn't int. + assert len(allowed_types) in (1, 2) + if len(allowed_types) == 1: + assert allowed_types.pop() is int + T = int + else: + T = (allowed_types - set([int])).pop() + if None in partials: + assert issubclass(T, (float, Decimal)) + assert not math.isfinite(partials[None]) + return T(partials[None]) + total = Fraction() + for d, n in sorted(partials.items()): + total += Fraction(n, d) + if issubclass(T, int): + assert total.denominator == 1 + return T(total.numerator) + if issubclass(T, Decimal): + return T(total.numerator)/total.denominator + return T(total) + + +def _check_type(T, allowed): + if T not in allowed: + if len(allowed) == 1: + allowed.add(T) + else: + types = u', '.join([t.__name__ for t in allowed] + [T.__name__]) + raise TypeError(u"unsupported mixed types: %s" % types) + + +def _exact_ratio(x): + u"""Convert Real number x exactly to (numerator, denominator) pair. + + >>> _exact_ratio(0.25) + (1, 4) + + x is expected to be an int, Fraction, Decimal or float. + """ + try: + try: + # int, Fraction + return (x.numerator, x.denominator) + except AttributeError: + # float + try: + return x.as_integer_ratio() + except AttributeError: + # Decimal + try: + return _decimal_to_ratio(x) + except AttributeError: + msg = u"can't convert type '{}' to numerator/denominator" + raise TypeError(msg.format(type(x).__name__)) + except (OverflowError, ValueError): + # INF or NAN + if __debug__: + # Decimal signalling NANs cannot be converted to float :-( + if isinstance(x, Decimal): + assert not x.is_finite() + else: + assert not math.isfinite(x) + return (x, None) + + +# FIXME This is faster than Fraction.from_decimal, but still too slow. +def _decimal_to_ratio(d): + u"""Convert Decimal d to exact integer ratio (numerator, denominator). + + >>> from decimal import Decimal + >>> _decimal_to_ratio(Decimal("2.6")) + (26, 10) + + """ + sign, digits, exp = d.as_tuple() + if exp in (u'F', u'n', u'N'): # INF, NAN, sNAN + assert not d.is_finite() + raise ValueError + num = 0 + for digit in digits: + num = num*10 + digit + if exp < 0: + den = 10**-exp + else: + num *= 10**exp + den = 1 + if sign: + num = -num + return (num, den) + + +def _counts(data): + # Generate a table of sorted (value, frequency) pairs. + table = collections.Counter(iter(data)).most_common() + if not table: + return table + # Extract the values with the highest frequency. + maxfreq = table[0][1] + for i in xrange(1, len(table)): + if table[i][1] != maxfreq: + table = table[:i] + break + return table + + +# === Measures of central tendency (averages) === + +def mean(data): + u"""Return the sample arithmetic mean of data. + + >>> mean([1, 2, 3, 4, 4]) + 2.8 + + >>> from fractions import Fraction as F + >>> mean([F(3, 7), F(1, 21), F(5, 3), F(1, 3)]) + Fraction(13, 21) + + >>> from decimal import Decimal as D + >>> mean([D("0.5"), D("0.75"), D("0.625"), D("0.375")]) + Decimal('0.5625') + + If ``data`` is empty, StatisticsError will be raised. + """ + if iter(data) is data: + data = list(data) + n = len(data) + if n < 1: + raise StatisticsError(u'mean requires at least one data point') + return _sum(data)/n + + +# FIXME: investigate ways to calculate medians without sorting? Quickselect? +def median(data): + u"""Return the median (middle value) of numeric data. + + When the number of data points is odd, return the middle data point. + When the number of data points is even, the median is interpolated by + taking the average of the two middle values: + + >>> median([1, 3, 5]) + 3 + >>> median([1, 3, 5, 7]) + 4.0 + + """ + data = sorted(data) + n = len(data) + if n == 0: + raise StatisticsError(u"no median for empty data") + if n%2 == 1: + return data[n//2] + else: + i = n//2 + return (data[i - 1] + data[i])/2 + + +def median_low(data): + u"""Return the low median of numeric data. + + When the number of data points is odd, the middle value is returned. + When it is even, the smaller of the two middle values is returned. + + >>> median_low([1, 3, 5]) + 3 + >>> median_low([1, 3, 5, 7]) + 3 + + """ + data = sorted(data) + n = len(data) + if n == 0: + raise StatisticsError(u"no median for empty data") + if n%2 == 1: + return data[n//2] + else: + return data[n//2 - 1] + + +def median_high(data): + u"""Return the high median of data. + + When the number of data points is odd, the middle value is returned. + When it is even, the larger of the two middle values is returned. + + >>> median_high([1, 3, 5]) + 3 + >>> median_high([1, 3, 5, 7]) + 5 + + """ + data = sorted(data) + n = len(data) + if n == 0: + raise StatisticsError(u"no median for empty data") + return data[n//2] + + +def median_grouped(data, interval=1): + u""""Return the 50th percentile (median) of grouped continuous data. + + >>> median_grouped([1, 2, 2, 3, 4, 4, 4, 4, 4, 5]) + 3.7 + >>> median_grouped([52, 52, 53, 54]) + 52.5 + + This calculates the median as the 50th percentile, and should be + used when your data is continuous and grouped. In the above example, + the values 1, 2, 3, etc. actually represent the midpoint of classes + 0.5-1.5, 1.5-2.5, 2.5-3.5, etc. The middle value falls somewhere in + class 3.5-4.5, and interpolation is used to estimate it. + + Optional argument ``interval`` represents the class interval, and + defaults to 1. Changing the class interval naturally will change the + interpolated 50th percentile value: + + >>> median_grouped([1, 3, 3, 5, 7], interval=1) + 3.25 + >>> median_grouped([1, 3, 3, 5, 7], interval=2) + 3.5 + + This function does not check whether the data points are at least + ``interval`` apart. + """ + data = sorted(data) + n = len(data) + if n == 0: + raise StatisticsError(u"no median for empty data") + elif n == 1: + return data[0] + # Find the value at the midpoint. Remember this corresponds to the + # centre of the class interval. + x = data[n//2] + for obj in (x, interval): + if isinstance(obj, (unicode, str)): + raise TypeError(u'expected number but got %r' % obj) + try: + L = x - interval/2 # The lower limit of the median interval. + except TypeError: + # Mixed type. For now we just coerce to float. + L = float(x) - float(interval)/2 + cf = data.index(x) # Number of values below the median interval. + # FIXME The following line could be more efficient for big lists. + f = data.count(x) # Number of data points in the median interval. + return L + interval*(n/2 - cf)/f + + +def mode(data): + u"""Return the most common data point from discrete or nominal data. + + ``mode`` assumes discrete data, and returns a single value. This is the + standard treatment of the mode as commonly taught in schools: + + >>> mode([1, 1, 2, 3, 3, 3, 3, 4]) + 3 + + This also works with nominal (non-numeric) data: + + >>> mode(["red", "blue", "blue", "red", "green", "red", "red"]) + 'red' + + If there is not exactly one most common value, ``mode`` will raise + StatisticsError. + """ + # Generate a table of sorted (value, frequency) pairs. + table = _counts(data) + if len(table) == 1: + return table[0][0] + elif table: + raise StatisticsError( + u'no unique mode; found %d equally common values' % len(table) + ) + else: + raise StatisticsError(u'no mode for empty data') + + +# === Measures of spread === + +# See http://mathworld.wolfram.com/Variance.html +# http://mathworld.wolfram.com/SampleVariance.html +# http://en.wikipedia.org/wiki/Algorithms_for_calculating_variance +# +# Under no circumstances use the so-called "computational formula for +# variance", as that is only suitable for hand calculations with a small +# amount of low-precision data. It has terrible numeric properties. +# +# See a comparison of three computational methods here: +# http://www.johndcook.com/blog/2008/09/26/comparing-three-methods-of-computing-standard-deviation/ + +def _ss(data, c=None): + u"""Return sum of square deviations of sequence data. + + If ``c`` is None, the mean is calculated in one pass, and the deviations + from the mean are calculated in a second pass. Otherwise, deviations are + calculated from ``c`` as given. Use the second case with care, as it can + lead to garbage results. + """ + if c is None: + c = mean(data) + ss = _sum((x-c)**2 for x in data) + # The following sum should mathematically equal zero, but due to rounding + # error may not. + ss -= _sum((x-c) for x in data)**2/len(data) + assert not ss < 0, u'negative sum of square deviations: %f' % ss + return ss + + +def variance(data, xbar=None): + u"""Return the sample variance of data. + + data should be an iterable of Real-valued numbers, with at least two + values. The optional argument xbar, if given, should be the mean of + the data. If it is missing or None, the mean is automatically calculated. + + Use this function when your data is a sample from a population. To + calculate the variance from the entire population, see ``pvariance``. + + Examples: + + >>> data = [2.75, 1.75, 1.25, 0.25, 0.5, 1.25, 3.5] + >>> variance(data) + 1.3720238095238095 + + If you have already calculated the mean of your data, you can pass it as + the optional second argument ``xbar`` to avoid recalculating it: + + >>> m = mean(data) + >>> variance(data, m) + 1.3720238095238095 + + This function does not check that ``xbar`` is actually the mean of + ``data``. Giving arbitrary values for ``xbar`` may lead to invalid or + impossible results. + + Decimals and Fractions are supported: + + >>> from decimal import Decimal as D + >>> variance([D("27.5"), D("30.25"), D("30.25"), D("34.5"), D("41.75")]) + Decimal('31.01875') + + >>> from fractions import Fraction as F + >>> variance([F(1, 6), F(1, 2), F(5, 3)]) + Fraction(67, 108) + + """ + if iter(data) is data: + data = list(data) + n = len(data) + if n < 2: + raise StatisticsError(u'variance requires at least two data points') + ss = _ss(data, xbar) + return ss/(n-1) + + +def pvariance(data, mu=None): + u"""Return the population variance of ``data``. + + data should be an iterable of Real-valued numbers, with at least one + value. The optional argument mu, if given, should be the mean of + the data. If it is missing or None, the mean is automatically calculated. + + Use this function to calculate the variance from the entire population. + To estimate the variance from a sample, the ``variance`` function is + usually a better choice. + + Examples: + + >>> data = [0.0, 0.25, 0.25, 1.25, 1.5, 1.75, 2.75, 3.25] + >>> pvariance(data) + 1.25 + + If you have already calculated the mean of the data, you can pass it as + the optional second argument to avoid recalculating it: + + >>> mu = mean(data) + >>> pvariance(data, mu) + 1.25 + + This function does not check that ``mu`` is actually the mean of ``data``. + Giving arbitrary values for ``mu`` may lead to invalid or impossible + results. + + Decimals and Fractions are supported: + + >>> from decimal import Decimal as D + >>> pvariance([D("27.5"), D("30.25"), D("30.25"), D("34.5"), D("41.75")]) + Decimal('24.815') + + >>> from fractions import Fraction as F + >>> pvariance([F(1, 4), F(5, 4), F(1, 2)]) + Fraction(13, 72) + + """ + if iter(data) is data: + data = list(data) + n = len(data) + if n < 1: + raise StatisticsError(u'pvariance requires at least one data point') + ss = _ss(data, mu) + return ss/n + + +def stdev(data, xbar=None): + u"""Return the square root of the sample variance. + + See ``variance`` for arguments and other details. + + >>> stdev([1.5, 2.5, 2.5, 2.75, 3.25, 4.75]) + 1.0810874155219827 + + """ + var = variance(data, xbar) + try: + return var.sqrt() + except AttributeError: + return math.sqrt(var) + + +def pstdev(data, mu=None): + u"""Return the square root of the population variance. + + See ``pvariance`` for arguments and other details. + + >>> pstdev([1.5, 2.5, 2.5, 2.75, 3.25, 4.75]) + 0.986893273527251 + + """ + var = pvariance(data, mu) + try: + return var.sqrt() + except AttributeError: + return math.sqrt(var) \ No newline at end of file