# 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 = "" 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)