LoafyLemon
8542fabbb4
* Fixed editor active flag becoming stuck after rolling back to before it was called * Put editor onto interface layer to exclude it from transitions
603 lines
21 KiB
Plaintext
603 lines
21 KiB
Plaintext
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()
|
|
self.active = False
|
|
|
|
def catch(self, *args, **kwargs):
|
|
if not self.active:
|
|
return
|
|
|
|
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 == "<string>":
|
|
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)
|
|
|
|
@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()
|
|
|
|
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)
|
|
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)
|