init python: from renpy.parser import ParseError from copy import deepcopy class Editor(NoRollback): def __init__(self): self.node = None self._live_code = None # Volatile; Can be changed at any given moment. self.history = _dict() self.expressions = self.define_expressions() self.active_expressions = _dict() self.persistent_expressions = _dict() self.last_expressions = _dict() def catch(self, *args, **kwargs): global n # DEBUG self.node = None self.live_code = None #stack = renpy.get_return_stack() statement = renpy.game.context().current if not statement: return node = renpy.game.script.namemap.get(statement, None) # Console call fallback if node.filename == "": return if not isinstance(node, renpy.ast.Say): return self.node = node n = node # DEBUG who = node.who file = node.filename line = node.linenumber code = node.get_code() code_file = self.read_file(file, line) # We need to use deepcopy, otherwise the dict # would be participating in rollback self.last_expressions = deepcopy(self.active_expressions) self.active_expressions.update(self.resolve_expressions()) self.live_code = code self.apply_persistent() # Consistency check if not matches(code, code_file): s = "Active {color=#e54624}node code{/color} differs from the {color=#40bf77}file code{/color},"\ " would you like to update the node?\n\n\n"\ "{color=#e54624}" + code + "{/color}\n->\n"\ "{color=#40bf77}" + code_file + "{/color}" prompt = Text(s, style="editor_text") layout.yesno_screen(prompt, [SetField(e, "live_code", code_file), e.live_replace, Notify("Updated.")], Notify("Cancelled.")) self.write_history(file, line, self.live_code) def replace(self, what, contents): node = self.node if what in ("who", "what"): setattr(node, what, contents) elif what == "args": args = node.arguments if not args: args = ArgumentInfo(contents, None, None) node.arguments = args else: if args.starred_indexes or args.doublestarred_indexes: raise Exception("Starred arguments are not implemented.") node.arguments.arguments = contents else: raise TypeError("Type '{}' is not implemented.".format(what)) file = node.filename line = node.linenumber code = node.get_code() self.write_file(file, line, code) self.write_history(file, line, code) self.live_code = code renpy.run(RestartStatement()) # This has to be run last. def replace_expression(self, expr, val): node = self.node who = node.who no_kw_args= ("mouth", "eyes", "eyebrows", "pupils", "expression") kw_args = ("tears", "cheeks", "hair") # We need to make sure not to add quotes # to expressions or variables. if isinstance(val, basestring): val = "\"{}\"".format(val) # Insert new expression d = self.get_expressions_active(who) d[expr] = val # Convert to list of tuples # l = [(k, "\"{}\"".format(v)) for k, v in d.iteritems() if not v is None] # This is faster, but not robust enough. l = _list() for key, val in d.iteritems(): if key in kw_args and val is None: continue # First four arguments are preferred to be keywordless, # so we'll just insert them into positions to avoid issues. if key in no_kw_args: key = None l.append((key, val)) self.replace("args", l) @property def live_code(self): return self._live_code @live_code.setter def live_code(self, code): if self._live_code == code: return # Additional validation goes here self._live_code = code def live_replace(self): node = self.node file = node.filename line = node.linenumber code = node.get_code() live_code = self.live_code if code == live_code: return rnode = self.parse(file, line, live_code) if rnode is None: return # It's simpler to replace node attributes # than the entire node in every linked context. node.who = rnode.who node.attributes = rnode.attributes node.temporary_attributes = rnode.temporary_attributes node.interact = rnode.interact node.what = rnode.what node.arguments = rnode.arguments node.with_ = rnode.with_ self.write_file(file, line, live_code) self.write_history(file, line, live_code) renpy.run(RestartStatement()) # This has to be run last. def live_reset(self): node = self.node self.live_code = node.get_code() def parse(self, file, line, code): renpy.game.exception_info = 'While parsing ' + file + '.' try: lines = renpy.parser.list_logical_lines(file, code, line) nested = renpy.parser.group_logical_lines(lines) except ParseError as e: renpy.notify("Parsing failed.\n{size=-8}(Check console for details){/size}") print(e) return None lexer = renpy.parser.Lexer(nested) block = renpy.parser.parse_block(lexer) if not block: renpy.notify("Parsing failed.\n{size=-8}(Check console for details){/size}") print("Fatal error while parsing the code block.") return None return block[-1] def read_file(self, file, line): file = os.path.join(config.basedir, file) line = line-1 try: with open(file, "r") as f: data = f.readlines() return data[line].partition("#")[0].strip() # Remove comments and strip spaces. except EnvironmentError as e: renpy.notify("File read error.\n{size=-8}(Check console for details){/size}") print(e) except IndexError as e: renpy.notify("File index error.\n{size=-8}(Check console for details){/size}") print(e) print("(Most likely the file was tampered with.)") def write_file(self, file, line, code): file = os.path.join(config.basedir, file) line = line-1 try: with open(file, "r+", newline="\n") as f: data = f.readlines() old = data[line].partition("#")[0].strip() # Remove comments and strip spaces. new = data[line].replace(old, code) data[line] = new f.seek(0) f.truncate() f.writelines(data) renpy.notify("Saved.") # except FileNotFoundError: # Python 3 only :( # renpy.notify("Source file is missing.") except EnvironmentError as e: renpy.notify("File write error.\n{size=-8}(Check console for details){/size}") print(e) def read_history(self, file, line): # _dict, and _list methods do not participate in rollback # unlike their revertable counterparts, so that's what we'll use. return self.history.get(file, _dict()).get(line, _list()) def write_history(self, file, line, code): if code in self.read_history(file, line): return self.history.setdefault(file, _dict()).setdefault(line, _list()).append(code) def clear_history(self, file, line): self.history.setdefault(file, _dict())[line] = _list() def launch_editor(self, file, line): renpy.launch_editor([file], line) def define_expressions(self): # This function is kind of messy, # because each character has unique display methods. # Some things are required to be hardcoded. # Define expressions for Doll type characters. filters = ("_mask", "_skin") all_files = renpy.list_files() d = _dict() for name in CHARACTERS: key = name[:3] d[key] = OrderedDict() for expr in ("mouth", "eyes", "eyebrows", "pupils", "cheeks", "tears"): path = "characters/{}/face/{}/".format(name, expr) files = filter(lambda x: path in x, all_files) d[key][expr] = [x.split(path)[1].split(".webp")[0] for x in files if x.endswith(".webp") and not any(f in x for f in filters)] if expr in ("cheeks", "tears"): # For cheeks, tears and hair None is a valid option, and chosen by default. d[key][expr].insert(0, None) # Define additional Tonks' hair choices. d["ton"]["hair"] = [None, "neutral", "angry", "annoyed", "happy", "disgusted", "sad", "purple", "scared", "horny"] # Define expressions for Genie. filters = None path = "characters/genie/" files = None d["gen"] = _dict() d["gen"]["expression"] = ["angry", "grin", "base", "open"] # Define expressions for Snape. filters = ("b01", "b01_01", "b02", "picture_Frame", "wand") path = "characters/snape/main/" files = filter(lambda x: path in x, all_files) d["sna"] = _dict() d["sna"]["expression"] = [x.split(path)[1].split(".webp")[0] for x in files if x.endswith(".webp") and not any(f in x for f in filters)] d["sna"]["special"] = [None, "wand", "picture_frame"] return d def resolve_expressions(self): node = self.node who = node.who args = node.arguments if who in SAYERS: keywords = ["mouth", "eyes", "eyebrows", "pupils", "cheeks", "tears", "emote", "face", "xpos", "ypos", "pos", "flip", "trans", "animation", "hair"] else: keywords = ["expression", "face", "xpos", "ypos", "pos", "flip", "trans", "animation", "wand"] # Arguments are contained within a list, # they consist of opaque tuples, # each one containing a keyword, and a value. # keyword can be a None if the keyword # is implied by the argument position. d = _dict() d[who] = _dict() # Args is an ArgumentInfo object, or None, # the true list of arguments is kept # within the object itself. if not args: return d args = args.arguments # Resolve arguments for character statements. # (There should be a simpler way to do this, right?) for i, (key, val) in enumerate(args): if key is None: key = keywords[i] d[who][key] = val # Sort our dictionary using an index map, # if we don't, we'll end up messing up # the order of keywordless arguments. imap = {v: i for i, v in enumerate(keywords)} d[who] = OrderedDict(sorted(d[who].items(), key=lambda x: imap[x[0]])) return d def get_expression_types(self, who): return self.expressions.get(who, _dict()) def get_expressions_active(self, who): return self.active_expressions.get(who, _dict()) def get_expressions_active_type(self, who, type): expr = self.get_expressions_active(who).get(type) if isinstance(expr, basestring): expr = strip(expr) return expr def get_expressions_persistent(self, who): return self.persistent_expressions.get(who, _dict()) def get_expressions_persistent_type(self, who, type): return self.get_expressions_persistent(who).get(type, False) def set_expressions_persistent_type(self, who, type): self.persistent_expressions.setdefault(who, _dict())[type] = True def toggle_expressions_persistent_type(self, who, type): val = self.get_expressions_persistent_type(who, type) self.persistent_expressions.setdefault(who, _dict())[type] = not val def get_expressions_last(self, who): return self.last_expressions.get(who, _dict()) def get_expressions_last_type(self, who, type): expr = self.get_expressions_last(who).get(type, None) if isinstance(expr, basestring): expr = strip(expr) return expr def apply_persistent(self): # This function will break the editor # if it participates in rollback if renpy.in_rollback() or renpy.in_fixed_rollback(): return node = self.node who = node.who persistent = self.get_expressions_persistent(who) last = self.get_expressions_last(who) active = self.get_expressions_active(who) if last == active: return for type, val in persistent.iteritems(): if val is False: continue last = self.get_expressions_last_type(who, type) active = self.get_expressions_active_type(who, type) if last == active: continue self.replace_expression(type, last) def get_node_history(self): node = self.node file = node.filename line = node.linenumber return self.read_history(file, line) if config.developer: e = Editor() #config.all_character_callbacks.append(e.catch) # This is more efficient. config.start_interact_callbacks.append(e.catch) # This allows to catch more statements and reset them if node types don't match. screen editor(): zorder 50 style_prefix "editor" text "Active" pos (25, 25) default focused = None drag: pos (50, 50) xminimum 500 xmaximum 500 xsize 500 frame: has vbox fixed: fit_first True text "Expression Editor {size=-2}ver 0.3a{/size}" style "editor_title" if e.node: vbox: hbox: for expr_type in e.get_expression_types(e.node.who).iterkeys(): textbutton "[expr_type]": action [CaptureFocus(expr_type), SetScreenVariable("focused", expr_type)] selected GetFocusRect(expr_type) if e.node.who in SAYERS: textbutton "😊": action Function(e.toggle_expressions_persistent_type, e.node.who, "cheeks") selected (e.get_expressions_persistent_type(e.node.who, "cheeks")) tooltip "Toggle persistent cheeks" textbutton "😢": action Function(e.toggle_expressions_persistent_type, e.node.who, "tears") selected (e.get_expressions_persistent_type(e.node.who, "tears")) tooltip "Toggle persistent tears" if e.node.who == "ton": textbutton "✂️": action Function(e.toggle_expressions_persistent_type, e.node.who, "hair") selected (e.get_expressions_persistent_type(e.node.who, "hair")) tooltip "Toggle persistent hair" add Solid("#ffffff80") xysize (480, 1) hbox: textbutton "History": action [CaptureFocus("history"), SetScreenVariable("focused", "history")] selected GetFocusRect("history") sensitive bool(e.get_node_history()) textbutton "Copy": action [Notify("Copied."), Function(set_clipboard, e.live_code)] selected False textbutton "Paste": action [Notify("Pasted."), SetField(e, "live_code", get_clipboard()), Function(e.live_replace)] selected False if focused == 0: dismiss action [SetScreenVariable("focused", 0), Function(e.live_reset)] button: input default "[e.live_code]" value FieldInputValue(e, "live_code") action NullAction() selected (focused == 0) key_events True key ["K_RETURN"] action Function(e.live_replace) key ["ctrl_K_c"] action [Notify("Copied."), Function(set_clipboard, e.live_code)] key ["ctrl_K_v"] action [Notify("Pasted."), SetField(e, "live_code", get_clipboard())] else: textbutton "[e.live_code]" action SetScreenVariable("focused", 0) if e.node: for expr_type, expr_list in e.get_expression_types(e.node.who).iteritems(): if GetFocusRect(expr_type): dismiss action [ClearFocus(expr_type), SetScreenVariable("focused", None)] nearrect: focus expr_type frame: modal True has vbox: box_wrap True style_prefix "editor_dropdown" for expr in expr_list: textbutton "[expr]": action [ ClearFocus(expr_type), SetScreenVariable("focused", None), Function(e.replace_expression, expr_type, expr)] selected (expr == e.get_expressions_active_type(e.node.who, expr_type)) if GetFocusRect("history"): dismiss action [ClearFocus("history"), SetScreenVariable("focused", None)] nearrect: focus "history" frame: modal True has vbox: box_wrap True style_prefix "editor_history" for i, entry in enumerate(e.get_node_history(), 1): textbutton "[i]. [entry]": action [ ClearFocus("history"), SetScreenVariable("focused", None), SetField(e, "live_code", entry), Function(e.live_replace)] selected (entry == e.live_code) key ["K_ESCAPE"] action [ClearFocus(focused), SetScreenVariable("focused", None), Function(e.live_reset)] style editor_text: color "#D0D0D0" hover_color "#e54624" selected_color "#40bf77" selected_hover_color "#E6A825" insensitive_color "#6b6b6b" bold True size 13 font "DejaVuSans.ttf" outlines [ (1, "#000", 0, 0) ] adjust_spacing False style editor_button: background None hover_background "#00000099" selected_background "#00000099" align (0, 0) padding (5, 5) style editor_button_text is editor_text style editor_input is editor_text: color "#40bf77" hover_color "#e54624" caret "caret" hover_caret "hover_caret" style editor_dropdown_button is editor_button: xmaximum 200 xfill True hover_background "#00000099" style editor_dropdown_button_text is editor_text style editor_history_button is editor_dropdown_button: xmaximum 600 xfill True style editor_history_button_text is editor_text: size 10 style editor_frame: background "#00000099" style editor_title is editor_text: color "#fff" image caret = Text("<", style="editor_text", color="#e54624", size=12) image hover_caret = Text("<", style="editor_text", color="#40bf77", size=12)