init python:

    from renpy.parser import ParseError
    from copy import deepcopy

    class Editor(python_object):

        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()
            self.active = False

        def catch(self, *args, **kwargs):
            if not self.active or renpy.is_init_phase():
                return

            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 == "<string>":
                return

            if not isinstance(node, renpy.ast.Say):
                return

            self.node = node

            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, str):
                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.items() if not v is None] # This is faster, but not robust enough.

            l = _list()

            for key, val in d.items():
                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 = [x for x in all_files if path in x]

                    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 = [x for x in all_files if path in x]

            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(list(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, str):
                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, str):
                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.items():
                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)

    @renpy.pure
    class ToggleEditor(Action, NoRollback):
        def __call__(self):
            if not config.developer:
                return

            if renpy.get_screen("editor", layer="interface"):
                e.active = False
                renpy.hide_screen("editor", layer="interface")
            else:
                e.active = True
                renpy.show_screen("editor")

            renpy.restart_interaction()
            renpy.game.context().force_checkpoint = True
            renpy.checkpoint(hard=True)

    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.
    else:
        e = dict() # Hotkey crashes on release otherwise.

screen editor():
    zorder 50
    style_prefix "editor"
    layer "interface"

    text "Active" pos (25, 25)

    default focused = None

    drag:
        pos (50, 50)
        maximum (500, 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).keys():
                            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).items():

            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)