diff options
Diffstat (limited to 'game/addons/zylann.hterrain/tools/inspector/inspector.gd')
-rw-r--r-- | game/addons/zylann.hterrain/tools/inspector/inspector.gd | 479 |
1 files changed, 479 insertions, 0 deletions
diff --git a/game/addons/zylann.hterrain/tools/inspector/inspector.gd b/game/addons/zylann.hterrain/tools/inspector/inspector.gd new file mode 100644 index 0000000..6039cee --- /dev/null +++ b/game/addons/zylann.hterrain/tools/inspector/inspector.gd @@ -0,0 +1,479 @@ + +# 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 = prop.enum_items[i] + option_button.add_item(item) + + # TODO We assume index, actually + getter = option_button.get_selected_id + setter = option_button.select + 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) |