ile-de-test/addons/zylann.hterrain/tools/inspector/inspector.gd
2023-10-05 20:02:23 +02:00

484 lines
12 KiB
GDScript

# GDScript implementation of an inspector.
# It generates controls for a provided list of properties,
# which is easier to maintain than placing them by hand and connecting things in the editor.
@tool
extends Control
const USAGE_FILE = "file"
const USAGE_ENUM = "enum"
signal property_changed(key, value)
# Used for most simple types
class HT_InspectorEditor:
var control = null
var getter := Callable()
var setter := Callable()
var key_label : Label
# Used when the control cannot hold the actual value
class HT_InspectorResourceEditor extends HT_InspectorEditor:
var value = null
var label = null
func get_value():
return value
func set_value(v):
value = v
label.text = "null" if v == null else v.resource_path
class HT_InspectorVectorEditor extends HT_InspectorEditor:
signal value_changed(v)
var value := Vector2()
var xed = null
var yed = null
func get_value():
return value
func set_value(v):
xed.value = v.x
yed.value = v.y
value = v
func _component_changed(v, i):
value[i] = v
value_changed.emit(value)
# TODO Rename _schema
var _prototype = null
var _edit_signal := true
# name => editor
var _editors := {}
# Had to separate the container because otherwise I can't open dialogs properly...
@onready var _grid_container = get_node("GridContainer")
@onready var _file_dialog = get_node("OpenFileDialog")
func _ready():
_file_dialog.visibility_changed.connect(
call_deferred.bind("_on_file_dialog_visibility_changed"))
# Test
# set_prototype({
# "seed": {
# "type": TYPE_INT,
# "randomizable": true
# },
# "base_height": {
# "type": TYPE_REAL,
# "range": {"min": -1000.0, "max": 1000.0, "step": 0.1}
# },
# "height_range": {
# "type": TYPE_REAL,
# "range": {"min": -1000.0, "max": 1000.0, "step": 0.1 },
# "default_value": 500.0
# },
# "streamed": {
# "type": TYPE_BOOL
# },
# "texture": {
# "type": TYPE_OBJECT,
# "object_type": Resource
# }
# })
# TODO Rename clear_schema
func clear_prototype():
_editors.clear()
var i = _grid_container.get_child_count() - 1
while i >= 0:
var child = _grid_container.get_child(i)
_grid_container.remove_child(child)
child.call_deferred("free")
i -= 1
_prototype = null
func get_value(key: String):
var editor = _editors[key]
return editor.getter.call()
func get_values():
var values = {}
for key in _editors:
var editor = _editors[key]
values[key] = editor.getter.call()
return values
func set_value(key: String, value):
var editor = _editors[key]
editor.setter.call(value)
func set_values(values: Dictionary):
for key in values:
if _editors.has(key):
var editor = _editors[key]
var v = values[key]
editor.setter.call(v)
# TODO Rename set_schema
func set_prototype(proto: Dictionary):
clear_prototype()
for key in proto:
var prop = proto[key]
var label := Label.new()
label.text = str(key).capitalize()
_grid_container.add_child(label)
var editor := _make_editor(key, prop)
editor.key_label = label
if prop.has("default_value"):
editor.setter.call(prop.default_value)
_editors[key] = editor
if prop.has("enabled"):
set_property_enabled(key, prop.enabled)
_grid_container.add_child(editor.control)
_prototype = proto
func trigger_all_modified():
for key in _prototype:
var value = _editors[key].getter.call_func()
property_changed.emit(key, value)
func set_property_enabled(prop_name: String, enabled: bool):
var ed = _editors[prop_name]
if ed.control is BaseButton:
ed.control.disabled = not enabled
elif ed.control is SpinBox:
ed.control.editable = enabled
elif ed.control is LineEdit:
ed.control.editable = enabled
# TODO Support more editors
var col = ed.key_label.modulate
if enabled:
col.a = 1.0
else:
col.a = 0.5
ed.key_label.modulate = col
func _make_editor(key: String, prop: Dictionary) -> HT_InspectorEditor:
var ed : HT_InspectorEditor = null
var editor : Control = null
var getter : Callable
var setter : Callable
var extra = null
match prop.type:
TYPE_INT, \
TYPE_FLOAT:
var pre = null
if prop.has("randomizable") and prop.randomizable:
editor = HBoxContainer.new()
pre = Button.new()
pre.pressed.connect(_randomize_property_pressed.bind(key))
pre.text = "Randomize"
editor.add_child(pre)
if prop.type == TYPE_INT and prop.has("usage") and prop.usage == USAGE_ENUM:
# Enumerated value
assert(prop.has("enum_items"))
var option_button := OptionButton.new()
for i in len(prop.enum_items):
var item:Array = prop.enum_items[i]
var value:int = item[0]
var text:String = item[1]
option_button.add_item(text, value)
getter = option_button.get_selected_id
setter = func select_id(id: int):
var index:int = option_button.get_item_index(id)
assert(index >= 0)
option_button.select(index)
option_button.item_selected.connect(_property_edited.bind(key))
editor = option_button
else:
# Numeric value
var spinbox := SpinBox.new()
# Spinboxes have shit UX when not expanded...
spinbox.custom_minimum_size = Vector2(120, 16)
_setup_range_control(spinbox, prop)
spinbox.value_changed.connect(_property_edited.bind(key))
# TODO In case the type is INT, the getter should return an integer!
getter = spinbox.get_value
setter = spinbox.set_value
var show_slider = prop.has("range") \
and not (prop.has("slidable") \
and prop.slidable == false)
if show_slider:
if editor == null:
editor = HBoxContainer.new()
var slider := HSlider.new()
# Need to give some size because otherwise the slider is hard to click...
slider.custom_minimum_size = Vector2(32, 16)
_setup_range_control(slider, prop)
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
spinbox.share(slider)
editor.add_child(slider)
editor.add_child(spinbox)
else:
spinbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
if editor == null:
editor = spinbox
else:
editor.add_child(spinbox)
TYPE_STRING:
if prop.has("usage") and prop.usage == USAGE_FILE:
editor = HBoxContainer.new()
var line_edit := LineEdit.new()
line_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
editor.add_child(line_edit)
var exts = []
if prop.has("exts"):
exts = prop.exts
var load_button := Button.new()
load_button.text = "..."
load_button.pressed.connect(_on_ask_load_file.bind(key, exts))
editor.add_child(load_button)
line_edit.text_submitted.connect(_property_edited.bind(key))
getter = line_edit.get_text
setter = line_edit.set_text
else:
editor = LineEdit.new()
editor.text_submitted.connect(_property_edited.bind(key))
getter = editor.get_text
setter = editor.set_text
TYPE_COLOR:
editor = ColorPickerButton.new()
editor.color_changed.connect(_property_edited.bind(key))
getter = editor.get_pick_color
setter = editor.set_pick_color
TYPE_BOOL:
editor = CheckBox.new()
editor.toggled.connect(_property_edited.bind(key))
getter = editor.is_pressed
setter = editor.set_pressed
TYPE_OBJECT:
# TODO How do I even check inheritance if I work on the class themselves, not instances?
if prop.object_type == Resource:
editor = HBoxContainer.new()
var label := Label.new()
label.text = "null"
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.clip_text = true
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
editor.add_child(label)
var load_button := Button.new()
load_button.text = "Load..."
load_button.pressed.connect(_on_ask_load_texture.bind(key))
editor.add_child(load_button)
var clear_button := Button.new()
clear_button.text = "Clear"
clear_button.pressed.connect(_on_ask_clear_texture.bind(key))
editor.add_child(clear_button)
ed = HT_InspectorResourceEditor.new()
ed.label = label
getter = ed.get_value
setter = ed.set_value
TYPE_VECTOR2:
editor = HBoxContainer.new()
ed = HT_InspectorVectorEditor.new()
var xlabel := Label.new()
xlabel.text = "x"
editor.add_child(xlabel)
var xed := SpinBox.new()
xed.size_flags_horizontal = Control.SIZE_EXPAND_FILL
xed.step = 0.01
xed.min_value = -10000
xed.max_value = 10000
# TODO This will fire twice (for each coordinate), hmmm...
xed.value_changed.connect(ed._component_changed.bind(0))
editor.add_child(xed)
var ylabel := Label.new()
ylabel.text = "y"
editor.add_child(ylabel)
var yed = SpinBox.new()
yed.size_flags_horizontal = Control.SIZE_EXPAND_FILL
yed.step = 0.01
yed.min_value = -10000
yed.max_value = 10000
yed.value_changed.connect(ed._component_changed.bind(1))
editor.add_child(yed)
ed.xed = xed
ed.yed = yed
ed.value_changed.connect(_property_edited.bind(key))
getter = ed.get_value
setter = ed.set_value
_:
editor = Label.new()
editor.text = "<not editable>"
getter = _dummy_getter
setter = _dummy_setter
if not(editor is CheckButton):
editor.size_flags_horizontal = Control.SIZE_EXPAND_FILL
if ed == null:
# Default
ed = HT_InspectorEditor.new()
ed.control = editor
ed.getter = getter
ed.setter = setter
return ed
static func _setup_range_control(range_control: Range, prop):
if prop.type == TYPE_INT:
range_control.step = 1
range_control.rounded = true
else:
range_control.step = 0.1
if prop.has("range"):
range_control.min_value = prop.range.min
range_control.max_value = prop.range.max
if prop.range.has("step"):
range_control.step = prop.range.step
else:
# Where is INT_MAX??
range_control.min_value = -0x7fffffff
range_control.max_value = 0x7fffffff
func _property_edited(value, key):
if _edit_signal:
property_changed.emit(key, value)
func _randomize_property_pressed(key):
var prop = _prototype[key]
var v = 0
# TODO Support range step
match prop.type:
TYPE_INT:
if prop.has("range"):
v = randi() % (prop.range.max - prop.range.min) + prop.range.min
else:
v = randi() - 0x7fffffff
TYPE_FLOAT:
if prop.has("range"):
v = randf_range(prop.range.min, prop.range.max)
else:
v = randf()
_editors[key].setter.call(v)
func _dummy_getter():
pass
func _dummy_setter(v):
# TODO Could use extra data to store the value anyways?
pass
func _on_ask_load_texture(key):
_open_file_dialog(["*.png ; PNG files"], _on_texture_selected.bind(key),
FileDialog.ACCESS_RESOURCES)
func _open_file_dialog(filters: Array, callback: Callable, access: int):
_file_dialog.access = access
_file_dialog.clear_filters()
for filter in filters:
_file_dialog.add_filter(filter)
# Can't just use one-shot signals because the dialog could be closed without choosing a file...
# if not _file_dialog.file_selected.is_connected(callback):
# _file_dialog.file_selected.connect(callback, Object.CONNECT_ONE_SHOT)
_file_dialog.file_selected.connect(callback)
_file_dialog.popup_centered_ratio(0.7)
func _on_file_dialog_visibility_changed():
if _file_dialog.visible == false:
# Disconnect listeners automatically,
# so we can re-use the same dialog with different listeners
var cons = _file_dialog.get_signal_connection_list("file_selected")
for con in cons:
_file_dialog.file_selected.disconnect(con.callable)
func _on_texture_selected(path: String, key):
var tex = load(path)
if tex == null:
return
var ed = _editors[key]
ed.setter.call(tex)
_property_edited(tex, key)
func _on_ask_clear_texture(key):
var ed = _editors[key]
ed.setter.call(null)
_property_edited(null, key)
func _on_ask_load_file(key, exts):
var filters := []
for ext in exts:
filters.append(str("*.", ext, " ; ", ext.to_upper(), " files"))
_open_file_dialog(filters, _on_file_selected.bind(key), FileDialog.ACCESS_FILESYSTEM)
func _on_file_selected(path, key):
var ed = _editors[key]
ed.setter.call(path)
_property_edited(path, key)