commit 4012a563271bcbbde01f2e0425984635b1b2494f Author: Scarfski <635589975@qq.com> Date: Wed May 27 14:45:42 2026 +0800 init: 从 gilzoide/godot-dockable-container 提取的插件文件 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/dockable_container.gd b/dockable_container.gd new file mode 100644 index 0000000..4c23188 --- /dev/null +++ b/dockable_container.gd @@ -0,0 +1,455 @@ +@tool +class_name DockableContainer +extends Container + +const SplitHandle := preload("split_handle.gd") +const DockablePanel := preload("dockable_panel.gd") +const DragNDropPanel := preload("drag_n_drop_panel.gd") + +@export var tab_alignment := TabBar.ALIGNMENT_CENTER: + get: + return _tab_align + set(value): + _tab_align = value + for i in range(1, _panel_container.get_child_count()): + var panel := _panel_container.get_child(i) as DockablePanel + panel.tab_alignment = value +@export var use_hidden_tabs_for_min_size := false: + get: + return _use_hidden_tabs_for_min_size + set(value): + _use_hidden_tabs_for_min_size = value + for i in range(1, _panel_container.get_child_count()): + var panel := _panel_container.get_child(i) as DockablePanel + panel.use_hidden_tabs_for_min_size = value +@export var tabs_visible := true: + get: + return _tabs_visible + set(value): + _tabs_visible = value + for i in range(1, _panel_container.get_child_count()): + var panel := _panel_container.get_child(i) as DockablePanel + panel.show_tabs = _tabs_visible +## If [code]true[/code] and a panel only has one tab, it keeps that tab hidden even if +## [member tabs_visible] is [code]true[/code]. +## Only takes effect is [member tabs_visible] is [code]true[/code]. +@export var hide_single_tab := false: + get: + return _hide_single_tab + set(value): + _hide_single_tab = value + for i in range(1, _panel_container.get_child_count()): + var panel := _panel_container.get_child(i) as DockablePanel + panel.hide_single_tab = _hide_single_tab +@export var rearrange_group := 0 +@export var layout := DockableLayout.new(): + get: + return _layout + set(value): + set_layout(value) +## If `clone_layout_on_ready` is true, `layout` will be cloned checked `_ready`. +## This is useful for leaving layout Resources untouched in case you want to +## restore layout to its default later. +@export var clone_layout_on_ready := true + +var _layout := DockableLayout.new() +var _panel_container := Container.new() +var _split_container := Container.new() +var _drag_n_drop_panel := DragNDropPanel.new() +var _drag_panel: DockablePanel +var _tab_align := TabBar.ALIGNMENT_CENTER +var _tabs_visible := true +var _use_hidden_tabs_for_min_size := false +var _hide_single_tab := false +var _current_panel_index := 0 +var _current_split_index := 0 +var _children_names := {} +var _layout_dirty := false + + +func _init() -> void: + child_entered_tree.connect(_child_entered_tree) + child_exiting_tree.connect(_child_exiting_tree) + + +func _ready() -> void: + set_process_input(false) + _panel_container.name = "_panel_container" + add_child(_panel_container) + move_child(_panel_container, 0) + _split_container.name = "_split_container" + _split_container.mouse_filter = MOUSE_FILTER_PASS + _panel_container.add_child(_split_container) + + _drag_n_drop_panel.name = "_drag_n_drop_panel" + _drag_n_drop_panel.mouse_filter = MOUSE_FILTER_PASS + _drag_n_drop_panel.visible = false + add_child(_drag_n_drop_panel) + + if not _layout: + set_layout(null) + elif clone_layout_on_ready and not Engine.is_editor_hint(): + set_layout(_layout.clone()) + + +func _notification(what: int) -> void: + if what == NOTIFICATION_SORT_CHILDREN: + _resort() + elif ( + what == NOTIFICATION_DRAG_BEGIN + and _can_handle_drag_data(get_viewport().gui_get_drag_data()) + ): + _drag_n_drop_panel.set_enabled(true, not _layout.root.is_empty()) + set_process_input(true) + elif what == NOTIFICATION_DRAG_END: + _drag_n_drop_panel.set_enabled(false) + set_process_input(false) + + +func _input(event: InputEvent) -> void: + assert(get_viewport().gui_is_dragging(), "FIXME: should only be called when dragging") + if event is InputEventMouseMotion: + var local_position := get_local_mouse_position() + var panel: DockablePanel + for i in range(1, _panel_container.get_child_count()): + var p := _panel_container.get_child(i) as DockablePanel + if p.get_rect().has_point(local_position): + panel = p + break + _drag_panel = panel + if not panel: + return + fit_child_in_rect(_drag_n_drop_panel, panel.get_child_rect()) + + +func _child_entered_tree(node: Node) -> void: + if node == _panel_container or node == _drag_n_drop_panel: + return + _drag_n_drop_panel.move_to_front() + _track_and_add_node(node) + + +func _child_exiting_tree(node: Node) -> void: + if node == _panel_container or node == _drag_n_drop_panel: + return + _untrack_node(node) + + +func _can_drop_data(_position: Vector2, data) -> bool: + return _can_handle_drag_data(data) + + +func _drop_data(_position: Vector2, data) -> void: + var from_node := get_node(data.from_path) + if from_node is TabBar: + from_node = from_node.get_parent() + if from_node == _drag_panel and _drag_panel.get_child_count() == 1: + return + var tab_index = data.tabc_element if data.has("tabc_element") else data.tab_index + var moved_tab = from_node.get_tab_control(tab_index) + if moved_tab is DockableReferenceControl: + moved_tab = moved_tab.reference_to + if not _is_managed_node(moved_tab): + moved_tab.get_parent().remove_child(moved_tab) + add_child(moved_tab) + + if _drag_panel != null: + var margin := _drag_n_drop_panel.get_hover_margin() + _layout.split_leaf_with_node(_drag_panel.leaf, moved_tab, margin) + + _layout_dirty = true + queue_sort() + + +func set_control_as_current_tab(control: Control) -> void: + assert( + control.get_parent_control() == self, + "Trying to focus a control not managed by this container" + ) + if is_control_hidden(control): + push_warning("Trying to focus a hidden control") + return + var leaf := _layout.get_leaf_for_node(control) + if not leaf: + return + var position_in_leaf := leaf.find_child(control) + if position_in_leaf < 0: + return + var panel: DockablePanel + for i in range(1, _panel_container.get_child_count()): + var p := _panel_container.get_child(i) as DockablePanel + if p.leaf == leaf: + panel = p + break + if not panel: + return + panel.current_tab = clampi(position_in_leaf, 0, panel.get_tab_count() - 1) + + +func set_layout(value: DockableLayout) -> void: + if value == null: + value = DockableLayout.new() + if value == _layout: + return + if _layout and _layout.changed.is_connected(queue_sort): + _layout.changed.disconnect(queue_sort) + _layout = value + _layout.changed.connect(queue_sort) + _layout_dirty = true + queue_sort() + + +func set_use_hidden_tabs_for_min_size(value: bool) -> void: + _use_hidden_tabs_for_min_size = value + for i in range(1, _panel_container.get_child_count()): + var panel = _panel_container.get_child(i) + panel.use_hidden_tabs_for_min_size = value + + +func get_use_hidden_tabs_for_min_size() -> bool: + return _use_hidden_tabs_for_min_size + + +func set_control_hidden(child: Control, is_hidden: bool) -> void: + _layout.set_node_hidden(child, is_hidden) + + +func is_control_hidden(child: Control) -> bool: + return _layout.is_node_hidden(child) + + +func get_tabs() -> Array[Control]: + var tabs: Array[Control] = [] + for i in get_child_count(): + var child := get_child(i) + if _is_managed_node(child): + tabs.append(child) + return tabs + + +func get_tab_count() -> int: + var count := 0 + for i in get_child_count(): + var child := get_child(i) + if _is_managed_node(child): + count += 1 + return count + + +func _can_handle_drag_data(data) -> bool: + if not data is Dictionary: + return false + var type = data.get("type", "") + var tab_type = data.get("tab_type", "") + var is_valid_type = ( + type in ["tab", "tab_container_tab", "tabc_element"] + or tab_type in ["tab_container_tab", "tabc_element"] + ) + if is_valid_type: + var from_path = data.get("from_path") + if from_path: + var source_node = get_node_or_null(from_path) + if source_node and source_node.has_method("get_tabs_rearrange_group"): + return source_node.get_tabs_rearrange_group() == rearrange_group + return false + + +func _is_managed_node(node: Node) -> bool: + return ( + node.get_parent() == self + and node != _panel_container + and node != _drag_n_drop_panel + and node is Control + and not node.top_level + ) + + +func _update_layout_with_children() -> void: + var names := PackedStringArray() + _children_names.clear() + for i in range(1, get_child_count() - 1): + var c := get_child(i) + if _track_node(c): + names.append(c.name) + _layout.update_nodes(names) + _layout_dirty = false + + +func _track_node(node: Node) -> bool: + if not _is_managed_node(node): + return false + _children_names[node] = node.name + _children_names[node.name] = node + if not node.renamed.is_connected(_on_child_renamed): + node.renamed.connect(_on_child_renamed.bind(node)) + if not node.tree_exiting.is_connected(_untrack_node): + node.tree_exiting.connect(_untrack_node.bind(node)) + return true + + +func _track_and_add_node(node: Node) -> void: + var tracked_name = _children_names.get(node) + if not _track_node(node): + return + if tracked_name and tracked_name != node.name: + _layout.rename_node(tracked_name, node.name) + _layout_dirty = true + + +func _untrack_node(node: Node) -> void: + _children_names.erase(node) + _children_names.erase(node.name) + if node.renamed.is_connected(_on_child_renamed): + node.renamed.disconnect(_on_child_renamed) + if node.tree_exiting.is_connected(_untrack_node): + node.tree_exiting.disconnect(_untrack_node) + _layout_dirty = true + + +func _resort() -> void: + assert(_panel_container, "FIXME: resorting without _panel_container") + if _panel_container.get_index() != 0: + move_child(_panel_container, 0) + if _drag_n_drop_panel.get_index() < get_child_count() - 1: + _drag_n_drop_panel.move_to_front() + + if _layout_dirty: + _update_layout_with_children() + + var rect := Rect2(Vector2.ZERO, size) + fit_child_in_rect(_panel_container, rect) + _panel_container.fit_child_in_rect(_split_container, rect) + + _current_panel_index = 1 + _current_split_index = 0 + + var children_list := [] + _calculate_panel_and_split_list(children_list, _layout.root) + _fit_panel_and_split_list_to_rect(children_list, rect) + + _untrack_children_after(_panel_container, _current_panel_index) + _untrack_children_after(_split_container, _current_split_index) + + +## Calculate DockablePanel and SplitHandle minimum sizes, skipping empty +## branches. +## +## Returns a DockablePanel checked non-empty leaves, a SplitHandle checked non-empty +## splits, `null` if the whole branch is empty and no space should be used. +## +## `result` will be filled with the non-empty nodes in this post-order tree +## traversal. +func _calculate_panel_and_split_list(result: Array, layout_node: DockableLayoutNode): + if layout_node is DockableLayoutPanel: + var nodes: Array[Control] = [] + for n in layout_node.names: + var node: Control = _children_names.get(n) + if node: + assert(node is Control, "FIXME: node is not a control %s" % node) + assert( + node.get_parent_control() == self, + "FIXME: node is not child of container %s" % node + ) + if is_control_hidden(node): + node.visible = false + else: + nodes.append(node) + if nodes.is_empty(): + return null + else: + var panel := _get_panel(_current_panel_index) + _current_panel_index += 1 + panel.track_nodes(nodes, layout_node) + result.append(panel) + return panel + elif layout_node is DockableLayoutSplit: + # by processing `second` before `first`, traversing `result` from back + # to front yields a nice pre-order tree traversal + var second_result = _calculate_panel_and_split_list(result, layout_node.second) + var first_result = _calculate_panel_and_split_list(result, layout_node.first) + if first_result and second_result: + var split := _get_split(_current_split_index) + _current_split_index += 1 + split.layout_split = layout_node + split.first_minimum_size = first_result.get_layout_minimum_size() + split.second_minimum_size = second_result.get_layout_minimum_size() + result.append(split) + return split + elif first_result: + return first_result + else: # NOTE: this returns null if `second_result` is null + return second_result + else: + push_warning("FIXME: invalid Resource, should be branch or leaf, found %s" % layout_node) + + +## Traverse list from back to front fitting controls where they belong. +## +## Be sure to call this with the result from `_calculate_split_minimum_sizes`. +func _fit_panel_and_split_list_to_rect(panel_and_split_list: Array, rect: Rect2) -> void: + var control = panel_and_split_list.pop_back() + if control is DockablePanel: + _panel_container.fit_child_in_rect(control, rect) + elif control is SplitHandle: + var split_rects = control.get_split_rects(rect) + _split_container.fit_child_in_rect(control, split_rects["self"]) + _fit_panel_and_split_list_to_rect(panel_and_split_list, split_rects["first"]) + _fit_panel_and_split_list_to_rect(panel_and_split_list, split_rects["second"]) + + +## Get the idx'th DockablePanel, reusing an instanced one if possible +func _get_panel(idx: int) -> DockablePanel: + assert(_panel_container, "FIXME: creating panel without _panel_container") + if idx < _panel_container.get_child_count(): + return _panel_container.get_child(idx) + var panel := DockablePanel.new() + panel.tab_alignment = _tab_align + panel.show_tabs = _tabs_visible + panel.hide_single_tab = _hide_single_tab + panel.use_hidden_tabs_for_min_size = _use_hidden_tabs_for_min_size + panel.set_tabs_rearrange_group(maxi(0, rearrange_group)) + _panel_container.add_child(panel) + panel.tab_layout_changed.connect(_on_panel_tab_layout_changed.bind(panel)) + return panel + + +## Get the idx'th SplitHandle, reusing an instanced one if possible +func _get_split(idx: int) -> SplitHandle: + assert(_split_container, "FIXME: creating split without _split_container") + if idx < _split_container.get_child_count(): + return _split_container.get_child(idx) + var split := SplitHandle.new() + _split_container.add_child(split) + return split + + +## Helper for removing and freeing all remaining children from node +func _untrack_children_after(node: Control, idx: int) -> void: + for i in range(idx, node.get_child_count()): + var child := node.get_child(idx) + node.remove_child(child) + child.queue_free() + + +## Handler for `DockablePanel.tab_layout_changed`, update its DockableLayoutPanel +func _on_panel_tab_layout_changed(tab: int, panel: DockablePanel) -> void: + _layout_dirty = true + var control := panel.get_tab_control(tab) + if control is DockableReferenceControl: + control = control.reference_to + if not _is_managed_node(control): + control.get_parent().remove_child(control) + add_child(control) + _layout.move_node_to_leaf(control, panel.leaf, tab) + queue_sort() + + +## Handler for `Node.renamed` signal, updates tracked name for node +func _on_child_renamed(child: Node) -> void: + var old_name: String = _children_names.get(child) + if old_name == str(child.name): + return + _children_names.erase(old_name) + _children_names[child] = child.name + _children_names[child.name] = child + _layout.rename_node(old_name, child.name) diff --git a/dockable_container.gd.uid b/dockable_container.gd.uid new file mode 100644 index 0000000..c7459ee --- /dev/null +++ b/dockable_container.gd.uid @@ -0,0 +1 @@ +uid://ccdi3mi0a414a diff --git a/dockable_panel.gd b/dockable_panel.gd new file mode 100644 index 0000000..d522027 --- /dev/null +++ b/dockable_panel.gd @@ -0,0 +1,108 @@ +@tool +extends TabContainer + +signal tab_layout_changed(tab) + +var leaf: DockableLayoutPanel: + get: + return get_leaf() + set(value): + set_leaf(value) +var show_tabs := true: + get: + return _show_tabs + set(value): + _show_tabs = value + _handle_tab_visibility() +var hide_single_tab := false: + get: + return _hide_single_tab + set(value): + _hide_single_tab = value + _handle_tab_visibility() + +var _leaf: DockableLayoutPanel +var _show_tabs := true +var _hide_single_tab := false + + +func _ready() -> void: + drag_to_rearrange_enabled = true + + +func _enter_tree() -> void: + active_tab_rearranged.connect(_on_tab_changed) + tab_selected.connect(_on_tab_selected) + tab_changed.connect(_on_tab_changed) + + +func _exit_tree() -> void: + active_tab_rearranged.disconnect(_on_tab_changed) + tab_selected.disconnect(_on_tab_selected) + tab_changed.disconnect(_on_tab_changed) + + +func track_nodes(nodes: Array[Control], new_leaf: DockableLayoutPanel) -> void: + _leaf = null # avoid using previous leaf in tab_changed signals + var min_size := mini(nodes.size(), get_child_count()) + # remove spare children + for i in range(min_size, get_child_count()): + var child := get_child(min_size) as DockableReferenceControl + child.reference_to = null + remove_child(child) + child.queue_free() + # add missing children + for i in range(min_size, nodes.size()): + var ref_control := DockableReferenceControl.new() + add_child(ref_control) + assert(nodes.size() == get_child_count(), "FIXME") + # setup children + for i in nodes.size(): + var ref_control := get_child(i) as DockableReferenceControl + ref_control.reference_to = nodes[i] + set_tab_title(i, nodes[i].name) + set_leaf(new_leaf) + _handle_tab_visibility() + + +func get_child_rect() -> Rect2: + var control := get_current_tab_control() + return Rect2(position + control.position, control.size) + + +func set_leaf(value: DockableLayoutPanel) -> void: + if get_tab_count() > 0 and value: + current_tab = clampi(value.current_tab, 0, get_tab_count() - 1) + _leaf = value + + +func get_leaf() -> DockableLayoutPanel: + return _leaf + + +func get_layout_minimum_size() -> Vector2: + return get_combined_minimum_size() + + +func _on_tab_selected(tab: int) -> void: + if _leaf: + _leaf.current_tab = tab + + +func _on_tab_changed(tab: int) -> void: + if not _leaf: + return + var control := get_tab_control(tab) + if not control: + return + var tab_name := control.name + var name_index_in_leaf := _leaf.find_name(tab_name) + if name_index_in_leaf != tab: # NOTE: this handles added tabs (index == -1) + tab_layout_changed.emit(tab) + + +func _handle_tab_visibility() -> void: + if _hide_single_tab and get_tab_count() == 1: + tabs_visible = false + else: + tabs_visible = _show_tabs diff --git a/dockable_panel.gd.uid b/dockable_panel.gd.uid new file mode 100644 index 0000000..dbd3e99 --- /dev/null +++ b/dockable_panel.gd.uid @@ -0,0 +1 @@ +uid://eab55b0hqw02 diff --git a/dockable_panel_reference_control.gd b/dockable_panel_reference_control.gd new file mode 100644 index 0000000..06dc11b --- /dev/null +++ b/dockable_panel_reference_control.gd @@ -0,0 +1,49 @@ +@tool +class_name DockableReferenceControl +extends Container +## Control that mimics its own visibility and rect into another Control. + +var reference_to: Control: + get: + return _reference_to + set(control): + if _reference_to != control: + if is_instance_valid(_reference_to): + _reference_to.renamed.disconnect(_on_reference_to_renamed) + _reference_to.minimum_size_changed.disconnect(update_minimum_size) + _reference_to = control + + minimum_size_changed.emit() + if not is_instance_valid(_reference_to): + return + _reference_to.renamed.connect(_on_reference_to_renamed) + _reference_to.minimum_size_changed.connect(update_minimum_size) + _reference_to.visible = visible + _reposition_reference() + +var _reference_to: Control = null + + +func _ready() -> void: + mouse_filter = MOUSE_FILTER_IGNORE + set_notify_transform(true) + + +func _notification(what: int) -> void: + if what == NOTIFICATION_VISIBILITY_CHANGED and _reference_to: + _reference_to.visible = visible + elif what == NOTIFICATION_TRANSFORM_CHANGED and _reference_to: + _reposition_reference() + + +func _get_minimum_size() -> Vector2: + return _reference_to.get_combined_minimum_size() if _reference_to else Vector2.ZERO + + +func _reposition_reference() -> void: + _reference_to.global_position = global_position + _reference_to.size = size + + +func _on_reference_to_renamed() -> void: + name = _reference_to.name diff --git a/dockable_panel_reference_control.gd.uid b/dockable_panel_reference_control.gd.uid new file mode 100644 index 0000000..32a8e06 --- /dev/null +++ b/dockable_panel_reference_control.gd.uid @@ -0,0 +1 @@ +uid://dpy1ktn2frppg diff --git a/drag_n_drop_panel.gd b/drag_n_drop_panel.gd new file mode 100644 index 0000000..7e5d771 --- /dev/null +++ b/drag_n_drop_panel.gd @@ -0,0 +1,82 @@ +@tool +extends Control + +enum { MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM, MARGIN_CENTER } + +const DRAW_NOTHING := -1 +const DRAW_CENTERED := -2 +const MARGIN_NONE := -1 + +var _draw_margin := DRAW_NOTHING +var _should_split := false + + +func _notification(what: int) -> void: + if what == NOTIFICATION_MOUSE_EXIT: + _draw_margin = DRAW_NOTHING + queue_redraw() + elif what == NOTIFICATION_MOUSE_ENTER and not _should_split: + _draw_margin = DRAW_CENTERED + queue_redraw() + + +func _gui_input(event: InputEvent) -> void: + if _should_split and event is InputEventMouseMotion: + _draw_margin = _find_hover_margin(event.position) + queue_redraw() + + +func _draw() -> void: + var rect: Rect2 + if _draw_margin == DRAW_NOTHING: + return + elif _draw_margin == DRAW_CENTERED: + rect = Rect2(Vector2.ZERO, size) + elif _draw_margin == MARGIN_LEFT: + rect = Rect2(0, 0, size.x * 0.5, size.y) + elif _draw_margin == MARGIN_TOP: + rect = Rect2(0, 0, size.x, size.y * 0.5) + elif _draw_margin == MARGIN_RIGHT: + var half_width = size.x * 0.5 + rect = Rect2(half_width, 0, half_width, size.y) + elif _draw_margin == MARGIN_BOTTOM: + var half_height = size.y * 0.5 + rect = Rect2(0, half_height, size.x, half_height) + var stylebox := get_theme_stylebox("panel", "TooltipPanel") + draw_style_box(stylebox, rect) + + +func set_enabled(enabled: bool, should_split: bool = true) -> void: + visible = enabled + _should_split = should_split + if enabled: + _draw_margin = DRAW_NOTHING + queue_redraw() + + +func get_hover_margin() -> int: + return _draw_margin + + +func _find_hover_margin(point: Vector2) -> int: + var half_size := size * 0.5 + + var left := point.distance_squared_to(Vector2(0, half_size.y)) + var lesser := left + var lesser_margin := MARGIN_LEFT + + var top := point.distance_squared_to(Vector2(half_size.x, 0)) + if lesser > top: + lesser = top + lesser_margin = MARGIN_TOP + + var right := point.distance_squared_to(Vector2(size.x, half_size.y)) + if lesser > right: + lesser = right + lesser_margin = MARGIN_RIGHT + + var bottom := point.distance_squared_to(Vector2(half_size.x, size.y)) + if lesser > bottom: + #lesser = bottom # unused result + lesser_margin = MARGIN_BOTTOM + return lesser_margin diff --git a/drag_n_drop_panel.gd.uid b/drag_n_drop_panel.gd.uid new file mode 100644 index 0000000..0a6c994 --- /dev/null +++ b/drag_n_drop_panel.gd.uid @@ -0,0 +1 @@ +uid://cyjm4hrcfacxv diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..d87d598 --- /dev/null +++ b/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..aa2ead4 --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dy25danh2am23" +path="res://.godot/imported/icon.svg-35635e7bbda4487d4b2942da1d987df8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/dockable_container/icon.svg" +dest_files=["res://.godot/imported/icon.svg-35635e7bbda4487d4b2942da1d987df8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/inspector_plugin/editor_inspector_plugin.gd b/inspector_plugin/editor_inspector_plugin.gd new file mode 100644 index 0000000..73d0372 --- /dev/null +++ b/inspector_plugin/editor_inspector_plugin.gd @@ -0,0 +1,22 @@ +extends EditorInspectorPlugin + +const LayoutEditorProperty := preload("layout_editor_property.gd") + + +func _can_handle(object: Object) -> bool: + return object is DockableContainer + + +func _parse_property( + _object: Object, + _type: Variant.Type, + name: String, + _hint: PropertyHint, + _hint_text: String, + _usage: int, + _wide: bool +) -> bool: + if name == "layout": + var editor_property := LayoutEditorProperty.new() + add_property_editor("layout", editor_property) + return false diff --git a/inspector_plugin/editor_inspector_plugin.gd.uid b/inspector_plugin/editor_inspector_plugin.gd.uid new file mode 100644 index 0000000..f975eae --- /dev/null +++ b/inspector_plugin/editor_inspector_plugin.gd.uid @@ -0,0 +1 @@ +uid://c37eakt4eetx diff --git a/inspector_plugin/layout_editor_property.gd b/inspector_plugin/layout_editor_property.gd new file mode 100644 index 0000000..eb00134 --- /dev/null +++ b/inspector_plugin/layout_editor_property.gd @@ -0,0 +1,71 @@ +extends EditorProperty + +var _container := DockableContainer.new() +var _hidden_menu_button := MenuButton.new() +var _hidden_menu_popup: PopupMenu +var _hidden_menu_list: PackedStringArray + + +func _ready() -> void: + custom_minimum_size = Vector2(128, 256) + + _hidden_menu_button.text = "Visible nodes" + add_child(_hidden_menu_button) + _hidden_menu_popup = _hidden_menu_button.get_popup() + _hidden_menu_popup.hide_on_checkable_item_selection = false + _hidden_menu_popup.about_to_popup.connect(_on_hidden_menu_popup_about_to_show) + _hidden_menu_popup.id_pressed.connect(_on_hidden_menu_popup_id_pressed) + + _container.clone_layout_on_ready = false + _container.custom_minimum_size = custom_minimum_size + + var value := _get_layout().clone() # The layout gets reset when selecting it without clone + for n in value.get_names(): + var child := _create_child_control(n) + _container.add_child(child) + _container.set(get_edited_property(), value) + add_child(_container) + set_bottom_editor(_container) + + +func _exit_tree() -> void: # Not sure if this is needed, but just to be sure + queue_free() + + +func _update_property() -> void: + var value := _get_layout() + _container.set(get_edited_property(), value) + + +func _get_layout() -> DockableLayout: + var original_container := get_edited_object() as DockableContainer + return original_container.get(get_edited_property()) + + +func _create_child_control(named: String) -> Label: + var new_control := Label.new() + new_control.name = named + new_control.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + new_control.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + new_control.clip_text = true + new_control.text = named + return new_control + + +func _on_hidden_menu_popup_about_to_show() -> void: + var layout := _get_layout().clone() + _hidden_menu_popup.clear() + _hidden_menu_list = layout.get_names() + for i in _hidden_menu_list.size(): + var tab_name := _hidden_menu_list[i] + _hidden_menu_popup.add_check_item(tab_name, i) + _hidden_menu_popup.set_item_checked(i, not layout.is_tab_hidden(tab_name)) + + +func _on_hidden_menu_popup_id_pressed(id: int) -> void: + var layout := _get_layout().clone() + var tab_name := _hidden_menu_list[id] + var new_hidden := not layout.is_tab_hidden(tab_name) + _get_layout().set_tab_hidden(tab_name, new_hidden) + _hidden_menu_popup.set_item_checked(id, not new_hidden) + emit_changed(get_edited_property(), _get_layout()) # This line may not be needed diff --git a/inspector_plugin/layout_editor_property.gd.uid b/inspector_plugin/layout_editor_property.gd.uid new file mode 100644 index 0000000..056eb47 --- /dev/null +++ b/inspector_plugin/layout_editor_property.gd.uid @@ -0,0 +1 @@ +uid://dggtoctbof2yq diff --git a/layout.gd b/layout.gd new file mode 100644 index 0000000..e2a8036 --- /dev/null +++ b/layout.gd @@ -0,0 +1,242 @@ +@tool +class_name DockableLayout +extends Resource +## DockableLayout Resource definition, holding the root DockableLayoutNode and hidden tabs. +## +## DockableLayoutSplit are binary trees with nested DockableLayoutSplit subtrees +## and DockableLayoutPanel leaves. Both of them inherit from DockableLayoutNode to help with +## type annotation and define common functionality. +## +## Hidden tabs are marked in the `hidden_tabs` Dictionary by name. + +enum { MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM, MARGIN_CENTER } + +@export var root: DockableLayoutNode = DockableLayoutPanel.new(): + get: + return _root + set(value): + set_root(value) +@export var hidden_tabs := {}: + get: + return _hidden_tabs + set(value): + if value != _hidden_tabs: + _hidden_tabs = value + changed.emit() + +var _changed_signal_queued := false +var _first_leaf: DockableLayoutPanel +var _hidden_tabs: Dictionary +var _leaf_by_node_name: Dictionary +var _root: DockableLayoutNode = DockableLayoutPanel.new() + + +func _init() -> void: + resource_name = "Layout" + + +func set_root(value: DockableLayoutNode, should_emit_changed := true) -> void: + if not value: + value = DockableLayoutPanel.new() + if _root == value: + return + if _root and _root.changed.is_connected(_on_root_changed): + _root.changed.disconnect(_on_root_changed) + _root = value + _root.parent = null + _root.changed.connect(_on_root_changed) + if should_emit_changed: + _on_root_changed() + + +func get_root() -> DockableLayoutNode: + return _root + + +func clone() -> DockableLayout: + return duplicate(true) + + +func get_names() -> PackedStringArray: + return _root.get_names() + + +## Add missing nodes on first leaf and remove nodes outside indices from leaves. +## +## _leaf_by_node_name = { +## (string keys) = respective Leaf that holds the node name, +## } +func update_nodes(names: PackedStringArray) -> void: + _leaf_by_node_name.clear() + _first_leaf = null + var empty_leaves: Array[DockableLayoutPanel] = [] + _ensure_names_in_node(_root, names, empty_leaves) # Changes _leaf_by_node_name and empty_leaves + for l in empty_leaves: + _remove_leaf(l) + if not _first_leaf: + _first_leaf = DockableLayoutPanel.new() + set_root(_first_leaf) + for n in names: + if not _leaf_by_node_name.has(n): + _first_leaf.push_name(n) + _leaf_by_node_name[n] = _first_leaf + _on_root_changed() + + +func move_node_to_leaf(node: Node, leaf: DockableLayoutPanel, relative_position: int) -> void: + var node_name := node.name + var previous_leaf: DockableLayoutPanel = _leaf_by_node_name.get(node_name) + if previous_leaf: + previous_leaf.remove_node(node) + if previous_leaf.is_empty(): + _remove_leaf(previous_leaf) + + leaf.insert_node(relative_position, node) + _leaf_by_node_name[node_name] = leaf + _on_root_changed() + + +func get_leaf_for_node(node: Node) -> DockableLayoutPanel: + return _leaf_by_node_name.get(node.name) + + +func split_leaf_with_node(leaf: DockableLayoutPanel, node: Node, margin: int) -> void: + var root_branch := leaf.parent + var new_leaf := DockableLayoutPanel.new() + var new_branch := DockableLayoutSplit.new() + if margin == MARGIN_LEFT or margin == MARGIN_RIGHT: + new_branch.direction = DockableLayoutSplit.Direction.HORIZONTAL + else: + new_branch.direction = DockableLayoutSplit.Direction.VERTICAL + if margin == MARGIN_LEFT or margin == MARGIN_TOP: + new_branch.first = new_leaf + new_branch.second = leaf + else: + new_branch.first = leaf + new_branch.second = new_leaf + if _root == leaf: + set_root(new_branch, false) + elif root_branch: + if leaf == root_branch.first: + root_branch.first = new_branch + else: + root_branch.second = new_branch + + move_node_to_leaf(node, new_leaf, 0) + + +func add_node(node: Node) -> void: + var node_name := node.name + if _leaf_by_node_name.has(node_name): + return + _first_leaf.push_name(node_name) + _leaf_by_node_name[node_name] = _first_leaf + _on_root_changed() + + +func remove_node(node: Node) -> void: + var node_name := node.name + var leaf: DockableLayoutPanel = _leaf_by_node_name.get(node_name) + if not leaf: + return + leaf.remove_node(node) + _leaf_by_node_name.erase(node_name) + if leaf.is_empty(): + _remove_leaf(leaf) + _on_root_changed() + + +func rename_node(previous_name: String, new_name: String) -> void: + var leaf: DockableLayoutPanel = _leaf_by_node_name.get(previous_name) + if not leaf: + return + leaf.rename_node(previous_name, new_name) + _leaf_by_node_name.erase(previous_name) + _leaf_by_node_name[new_name] = leaf + _on_root_changed() + + +func set_tab_hidden(name: String, hidden: bool) -> void: + if not _leaf_by_node_name.has(name): + return + if hidden: + _hidden_tabs[name] = true + else: + _hidden_tabs.erase(name) + _on_root_changed() + + +func is_tab_hidden(name: String) -> bool: + return _hidden_tabs.get(name, false) + + +func set_node_hidden(node: Node, hidden: bool) -> void: + set_tab_hidden(node.name, hidden) + + +func is_node_hidden(node: Node) -> bool: + return is_tab_hidden(node.name) + + +func _on_root_changed() -> void: + if _changed_signal_queued: + return + _changed_signal_queued = true + set_deferred("_changed_signal_queued", false) + emit_changed.call_deferred() + + +func _ensure_names_in_node( + node: DockableLayoutNode, names: PackedStringArray, empty_leaves: Array[DockableLayoutPanel] +) -> void: + if node is DockableLayoutPanel: + node.update_nodes(names, _leaf_by_node_name) # This changes _leaf_by_node_name + if node.is_empty(): + empty_leaves.append(node) + if not _first_leaf: + _first_leaf = node + elif node is DockableLayoutSplit: + _ensure_names_in_node(node.first, names, empty_leaves) + _ensure_names_in_node(node.second, names, empty_leaves) + else: + assert(false, "Invalid Resource, should be branch or leaf, found %s" % node) + + +func _remove_leaf(leaf: DockableLayoutPanel) -> void: + assert(leaf.is_empty(), "FIXME: trying to remove_at a leaf with nodes") + if _root == leaf: + return + var collapsed_branch := leaf.parent + assert(collapsed_branch is DockableLayoutSplit, "FIXME: leaf is not a child of branch") + var kept_branch: DockableLayoutNode = ( + collapsed_branch.first if leaf == collapsed_branch.second else collapsed_branch.second + ) + var root_branch := collapsed_branch.parent #HERE + if collapsed_branch == _root: + set_root(kept_branch, true) + elif root_branch: + if collapsed_branch == root_branch.first: + root_branch.first = kept_branch + else: + root_branch.second = kept_branch + + +func _print_tree() -> void: + print("TREE") + _print_tree_step(_root, 0, 0) + print("") + + +func _print_tree_step(tree_or_leaf: DockableLayoutNode, level: int, idx: int) -> void: + if tree_or_leaf is DockableLayoutPanel: + print(" |".repeat(level), "- (%d) = " % idx, tree_or_leaf.names) + elif tree_or_leaf is DockableLayoutSplit: + print( + " |".repeat(level), + "-+ (%d) = " % idx, + tree_or_leaf.direction, + " ", + tree_or_leaf.percent + ) + _print_tree_step(tree_or_leaf.first, level + 1, 1) + _print_tree_step(tree_or_leaf.second, level + 1, 2) diff --git a/layout.gd.uid b/layout.gd.uid new file mode 100644 index 0000000..e5731e0 --- /dev/null +++ b/layout.gd.uid @@ -0,0 +1 @@ +uid://b1bj11c3wrsh3 diff --git a/layout_node.gd b/layout_node.gd new file mode 100644 index 0000000..ba3accb --- /dev/null +++ b/layout_node.gd @@ -0,0 +1,29 @@ +@tool +class_name DockableLayoutNode +extends Resource +## Base class for DockableLayout tree nodes + +var parent: DockableLayoutSplit: + get: + return _parent_ref.get_ref() + set(value): + _parent_ref = weakref(value) + +var _parent_ref := WeakRef.new() + + +func emit_tree_changed() -> void: + var node := self + while node: + node.emit_changed() + node = node.parent + + +## Returns whether there are any nodes +func is_empty() -> bool: + return true + + +## Returns all tab names in this node +func get_names() -> PackedStringArray: + return PackedStringArray() diff --git a/layout_node.gd.uid b/layout_node.gd.uid new file mode 100644 index 0000000..a420f7b --- /dev/null +++ b/layout_node.gd.uid @@ -0,0 +1 @@ +uid://dget6gbeqpvlm diff --git a/layout_panel.gd b/layout_panel.gd new file mode 100644 index 0000000..e15201b --- /dev/null +++ b/layout_panel.gd @@ -0,0 +1,89 @@ +@tool +class_name DockableLayoutPanel +extends DockableLayoutNode +## DockableLayout leaf nodes, defining tabs + +@export var names: PackedStringArray: + get: + return get_names() + set(value): + _names = value + emit_tree_changed() +@export var current_tab: int: + get: + return int(clamp(_current_tab, 0, _names.size() - 1)) + set(value): + if value != _current_tab: + _current_tab = value + emit_tree_changed() + +var _names := PackedStringArray() +var _current_tab := 0 + + +func _init() -> void: + resource_name = "Tabs" + + +## Returns all tab names in this node +func get_names() -> PackedStringArray: + return _names + + +func push_name(name: String) -> void: + _names.append(name) + emit_tree_changed() + + +func insert_node(position: int, node: Node) -> void: + _names.insert(position, node.name) + emit_tree_changed() + + +func find_name(node_name: String) -> int: + for i in _names.size(): + if _names[i] == node_name: + return i + return -1 + + +func find_child(node: Node) -> int: + return find_name(node.name) + + +func remove_node(node: Node) -> void: + var i := find_child(node) + if i >= 0: + _names.remove_at(i) + emit_tree_changed() + else: + push_warning("Remove failed, node '%s' was not found" % node) + + +func rename_node(previous_name: String, new_name: String) -> void: + var i := find_name(previous_name) + if i >= 0: + _names.set(i, new_name) + emit_tree_changed() + else: + push_warning("Rename failed, name '%s' was not found" % previous_name) + + +## Returns whether there are any nodes +func is_empty() -> bool: + return _names.is_empty() + + +func update_nodes(node_names: PackedStringArray, data: Dictionary) -> void: + var i := 0 + var removed_any := false + while i < _names.size(): + var current := _names[i] + if not current in node_names or data.has(current): + _names.remove_at(i) + removed_any = true + else: + data[current] = self + i += 1 + if removed_any: + emit_tree_changed() diff --git a/layout_panel.gd.uid b/layout_panel.gd.uid new file mode 100644 index 0000000..5a7b86e --- /dev/null +++ b/layout_panel.gd.uid @@ -0,0 +1 @@ +uid://d15h5pn8k8tjc diff --git a/layout_split.gd b/layout_split.gd new file mode 100644 index 0000000..5e78138 --- /dev/null +++ b/layout_split.gd @@ -0,0 +1,100 @@ +@tool +class_name DockableLayoutSplit +extends DockableLayoutNode +## DockableLayout binary tree nodes, defining subtrees and leaf panels + +enum Direction { HORIZONTAL, VERTICAL } + +@export var direction := Direction.HORIZONTAL: + get: + return get_direction() + set(value): + set_direction(value) +@export_range(0, 1) var percent := 0.5: + get = get_percent, + set = set_percent +@export var first: DockableLayoutNode = DockableLayoutPanel.new(): + get: + return get_first() + set(value): + set_first(value) +@export var second: DockableLayoutNode = DockableLayoutPanel.new(): + get: + return get_second() + set(value): + set_second(value) + +var _direction := Direction.HORIZONTAL +var _percent := 0.5 +var _first: DockableLayoutNode +var _second: DockableLayoutNode + + +func _init() -> void: + resource_name = "Split" + + +func set_first(value: DockableLayoutNode) -> void: + if value == null: + _first = DockableLayoutPanel.new() + else: + _first = value + _first.parent = self + emit_tree_changed() + + +func get_first() -> DockableLayoutNode: + return _first + + +func set_second(value: DockableLayoutNode) -> void: + if value == null: + _second = DockableLayoutPanel.new() + else: + _second = value + _second.parent = self + emit_tree_changed() + + +func get_second() -> DockableLayoutNode: + return _second + + +func set_direction(value: Direction) -> void: + if value != _direction: + _direction = value + emit_tree_changed() + + +func get_direction() -> Direction: + return _direction + + +func set_percent(value: float) -> void: + var clamped_value := clampf(value, 0, 1) + if not is_equal_approx(_percent, clamped_value): + _percent = clamped_value + emit_tree_changed() + + +func get_percent() -> float: + return _percent + + +func get_names() -> PackedStringArray: + var names := _first.get_names() + names.append_array(_second.get_names()) + return names + + +## Returns whether there are any nodes +func is_empty() -> bool: + return _first.is_empty() and _second.is_empty() + + +func is_horizontal() -> bool: + return _direction == Direction.HORIZONTAL + + +func is_vertical() -> bool: + return _direction == Direction.VERTICAL diff --git a/layout_split.gd.uid b/layout_split.gd.uid new file mode 100644 index 0000000..4e78952 --- /dev/null +++ b/layout_split.gd.uid @@ -0,0 +1 @@ +uid://btj8xjkmusjq5 diff --git a/plugin.cfg b/plugin.cfg new file mode 100644 index 0000000..b359591 --- /dev/null +++ b/plugin.cfg @@ -0,0 +1,13 @@ +[plugin] + +name="Dockable Container" +description="Container script that manages docking/tiling UI panels. + +Panels are composed of tabs that can be dragged around and dropped to split another panel or compose its tabs. + +Layout information is stored in Resource objects, so they can be saved/loaded from disk easily. + +This plugin also offers a replica of the Container layout to be edited directly in the inspector." +author="gilzoide" +version="1.1.2" +script="plugin.gd" diff --git a/plugin.gd b/plugin.gd new file mode 100644 index 0000000..e93e010 --- /dev/null +++ b/plugin.gd @@ -0,0 +1,19 @@ +@tool +extends EditorPlugin + +const LayoutInspectorPlugin := preload("inspector_plugin/editor_inspector_plugin.gd") +const Icon := preload("icon.svg") + +var _layout_inspector_plugin: LayoutInspectorPlugin + + +func _enter_tree() -> void: + _layout_inspector_plugin = LayoutInspectorPlugin.new() + add_custom_type("DockableContainer", "Container", DockableContainer, Icon) + add_inspector_plugin(_layout_inspector_plugin) + + +func _exit_tree() -> void: + remove_inspector_plugin(_layout_inspector_plugin) + remove_custom_type("DockableContainer") + _layout_inspector_plugin = null diff --git a/plugin.gd.uid b/plugin.gd.uid new file mode 100644 index 0000000..b6962e7 --- /dev/null +++ b/plugin.gd.uid @@ -0,0 +1 @@ +uid://bsdsrvwv6f10c diff --git a/samples/TestScene.gd b/samples/TestScene.gd new file mode 100644 index 0000000..f94ac97 --- /dev/null +++ b/samples/TestScene.gd @@ -0,0 +1,63 @@ +extends VBoxContainer + +const SAVED_LAYOUT_PATH := "user://layout.tres" + +@onready var _container := $DockableContainers/DockableContainer as DockableContainer +@onready var _clone_control := $HBoxContainer/ControlPrefab as ColorRect +@onready var _checkbox_container := $HBoxContainer as HBoxContainer + + +func _ready() -> void: + if not OS.is_userfs_persistent(): + $HBoxContainer/SaveLayoutButton.visible = false + $HBoxContainer/LoadLayoutButton.visible = false + + var tabs := _container.get_tabs() + for i in tabs.size(): + var checkbox := CheckBox.new() + checkbox.text = str(i) + checkbox.button_pressed = not _container.is_control_hidden(tabs[i]) + checkbox.toggled.connect(_on_CheckButton_toggled.bind(tabs[i])) + _checkbox_container.add_child(checkbox) + + +func _on_add_pressed() -> void: + var control := _clone_control.duplicate() + control.get_node("Buttons/Rename").pressed.connect( + _on_control_rename_button_pressed.bind(control) + ) + control.get_node("Buttons/Remove").pressed.connect( + _on_control_remove_button_pressed.bind(control) + ) + control.color = Color(randf(), randf(), randf()) + control.name = "Control0" + + _container.add_child(control, true) + await _container.sort_children + _container.set_control_as_current_tab(control) + + +func _on_save_pressed() -> void: + if ResourceSaver.save(_container.layout, SAVED_LAYOUT_PATH) != OK: + print("ERROR") + + +func _on_load_pressed() -> void: + var res = load(SAVED_LAYOUT_PATH) + if res: + _container.set_layout(res.clone()) + else: + print("Error") + + +func _on_control_rename_button_pressed(control: Control) -> void: + control.name = StringName(str(control.name) + " =D") + + +func _on_control_remove_button_pressed(control: Control) -> void: + control.get_parent().remove_child(control) + control.queue_free() + + +func _on_CheckButton_toggled(button_pressed: bool, tab: Control) -> void: + _container.set_control_hidden(tab, not button_pressed) diff --git a/samples/TestScene.gd.uid b/samples/TestScene.gd.uid new file mode 100644 index 0000000..7de4460 --- /dev/null +++ b/samples/TestScene.gd.uid @@ -0,0 +1 @@ +uid://bhop40eih06by diff --git a/samples/TestScene.tscn b/samples/TestScene.tscn new file mode 100644 index 0000000..04e1fb6 --- /dev/null +++ b/samples/TestScene.tscn @@ -0,0 +1,174 @@ +[gd_scene format=3 uid="uid://drlvhuchtk6if"] + +[ext_resource type="Script" uid="uid://ccdi3mi0a414a" path="res://addons/dockable_container/dockable_container.gd" id="1"] +[ext_resource type="Script" uid="uid://b1bj11c3wrsh3" path="res://addons/dockable_container/layout.gd" id="2"] +[ext_resource type="Script" uid="uid://bhop40eih06by" path="res://addons/dockable_container/samples/TestScene.gd" id="4"] +[ext_resource type="Script" uid="uid://btj8xjkmusjq5" path="res://addons/dockable_container/layout_split.gd" id="4_yhgfb"] +[ext_resource type="Script" uid="uid://d15h5pn8k8tjc" path="res://addons/dockable_container/layout_panel.gd" id="5"] + +[sub_resource type="Resource" id="Resource_8aoc2"] +resource_name = "Tabs" +script = ExtResource("5") +names = PackedStringArray("Control0") + +[sub_resource type="Resource" id="Resource_6kjom"] +resource_name = "Tabs" +script = ExtResource("5") +names = PackedStringArray("Control1", "Control2") + +[sub_resource type="Resource" id="Resource_hl8y1"] +resource_name = "Split" +script = ExtResource("4_yhgfb") +direction = 1 +first = SubResource("Resource_8aoc2") +second = SubResource("Resource_6kjom") + +[sub_resource type="Resource" id="Resource_ybwqe"] +resource_name = "Layout" +script = ExtResource("2") +root = SubResource("Resource_hl8y1") + +[sub_resource type="Resource" id="Resource_ntwfj"] +resource_name = "Tabs" +script = ExtResource("5") +names = PackedStringArray("Control3") + +[sub_resource type="Resource" id="Resource_dmyvf"] +resource_name = "Tabs" +script = ExtResource("5") +names = PackedStringArray("Control4") + +[sub_resource type="Resource" id="Resource_vag66"] +resource_name = "Split" +script = ExtResource("4_yhgfb") +direction = 1 +percent = 0.281 +first = SubResource("Resource_ntwfj") +second = SubResource("Resource_dmyvf") + +[sub_resource type="Resource" id="Resource_4q660"] +resource_name = "Tabs" +script = ExtResource("5") +names = PackedStringArray("Control5") + +[sub_resource type="Resource" id="Resource_jhibs"] +resource_name = "Split" +script = ExtResource("4_yhgfb") +first = SubResource("Resource_vag66") +second = SubResource("Resource_4q660") + +[sub_resource type="Resource" id="Resource_xhxpg"] +resource_name = "Layout" +script = ExtResource("2") +root = SubResource("Resource_jhibs") + +[node name="SampleScene" type="VBoxContainer" unique_id=63257421] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("4") + +[node name="HBoxContainer" type="HBoxContainer" parent="." unique_id=387084736] +layout_mode = 2 +alignment = 1 + +[node name="AddControlButton" type="Button" parent="HBoxContainer" unique_id=2125688693] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 4 +text = "(+) ADD CONTROL" + +[node name="SaveLayoutButton" type="Button" parent="HBoxContainer" unique_id=1305135861] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 4 +text = "Save Layout" + +[node name="LoadLayoutButton" type="Button" parent="HBoxContainer" unique_id=1243058994] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 4 +text = "Load Layout" + +[node name="ControlPrefab" type="ColorRect" parent="HBoxContainer" unique_id=399318649] +visible = false +custom_minimum_size = Vector2(100, 100) +layout_mode = 2 +color = Color(0.129412, 0.121569, 0.121569, 1) + +[node name="Buttons" type="VBoxContainer" parent="HBoxContainer/ControlPrefab" unique_id=1055220509] +layout_mode = 0 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -65.5 +offset_top = -22.0 +offset_right = 65.5 +offset_bottom = 22.0 + +[node name="Rename" type="Button" parent="HBoxContainer/ControlPrefab/Buttons" unique_id=1635652992] +layout_mode = 2 +text = "Rename" + +[node name="Remove" type="Button" parent="HBoxContainer/ControlPrefab/Buttons" unique_id=325010205] +layout_mode = 2 +text = "REMOVE" + +[node name="DockableContainers" type="HBoxContainer" parent="." unique_id=1263000341] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="DockableContainer" type="Container" parent="DockableContainers" unique_id=1541533577] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1") +layout = SubResource("Resource_ybwqe") + +[node name="Control0" type="ColorRect" parent="DockableContainers/DockableContainer" unique_id=1755020147] +custom_minimum_size = Vector2(100, 100) +layout_mode = 2 + +[node name="Control1" type="ColorRect" parent="DockableContainers/DockableContainer" unique_id=1411986801] +custom_minimum_size = Vector2(100, 100) +layout_mode = 2 +color = Color(0.141176, 0.0745098, 0.603922, 1) + +[node name="Control2" type="ColorRect" parent="DockableContainers/DockableContainer" unique_id=199396917] +visible = false +custom_minimum_size = Vector2(100, 100) +layout_mode = 2 +color = Color(0.533333, 0.380392, 0.380392, 1) + +[node name="Separator" type="ColorRect" parent="DockableContainers" unique_id=1232713094] +custom_minimum_size = Vector2(50, 0) +layout_mode = 2 +color = Color(0, 0, 0, 1) + +[node name="DockableContainer2" type="Container" parent="DockableContainers" unique_id=1148470831] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1") +layout = SubResource("Resource_xhxpg") + +[node name="Control3" type="ColorRect" parent="DockableContainers/DockableContainer2" unique_id=209835842] +custom_minimum_size = Vector2(100, 100) +layout_mode = 2 +color = Color(0, 1, 0.905882, 1) + +[node name="Control4" type="ColorRect" parent="DockableContainers/DockableContainer2" unique_id=1376035184] +custom_minimum_size = Vector2(100, 100) +layout_mode = 2 +color = Color(0, 0.698039, 0.0588235, 1) + +[node name="Control5" type="ColorRect" parent="DockableContainers/DockableContainer2" unique_id=32573735] +custom_minimum_size = Vector2(100, 100) +layout_mode = 2 +color = Color(1, 0.937255, 0, 1) + +[connection signal="pressed" from="HBoxContainer/AddControlButton" to="." method="_on_add_pressed"] +[connection signal="pressed" from="HBoxContainer/SaveLayoutButton" to="." method="_on_save_pressed"] +[connection signal="pressed" from="HBoxContainer/LoadLayoutButton" to="." method="_on_load_pressed"] diff --git a/split_handle.gd b/split_handle.gd new file mode 100644 index 0000000..baf4b1f --- /dev/null +++ b/split_handle.gd @@ -0,0 +1,120 @@ +@tool +extends Control + +const SPLIT_THEME_CLASS: PackedStringArray = [ + "HSplitContainer", # SPLIT_THEME_CLASS[DockableLayoutSplit.Direction.HORIZONTAL] + "VSplitContainer", # SPLIT_THEME_CLASS[DockableLayoutSplit.Direction.VERTICAL] +] + +const SPLIT_MOUSE_CURSOR_SHAPE: Array[Control.CursorShape] = [ + Control.CURSOR_HSPLIT, # SPLIT_MOUSE_CURSOR_SHAPE[DockableLayoutSplit.Direction.HORIZONTAL] + Control.CURSOR_VSPLIT, # SPLIT_MOUSE_CURSOR_SHAPE[DockableLayoutSplit.Direction.VERTICAL] +] + +var layout_split: DockableLayoutSplit +var first_minimum_size: Vector2 +var second_minimum_size: Vector2 + +var _parent_rect: Rect2 +var _mouse_hovering := false +var _dragging := false + + +func _draw() -> void: + var theme_class := SPLIT_THEME_CLASS[layout_split.direction] + var icon := get_theme_icon("grabber", theme_class) + var autohide := bool(get_theme_constant("autohide", theme_class)) + if not icon or (autohide and not _mouse_hovering): + return + + draw_texture(icon, (size - icon.get_size()) * 0.5) + + +func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: + _dragging = event.is_pressed() + if event.double_click: + layout_split.percent = 0.5 + elif _dragging and event is InputEventMouseMotion: + var mouse_in_parent := get_parent_control().get_local_mouse_position() + if layout_split.is_horizontal(): + layout_split.percent = ( + (mouse_in_parent.x - _parent_rect.position.x) / _parent_rect.size.x + ) + else: + layout_split.percent = ( + (mouse_in_parent.y - _parent_rect.position.y) / _parent_rect.size.y + ) + + +func _notification(what: int) -> void: + if what == NOTIFICATION_MOUSE_ENTER: + _mouse_hovering = true + set_split_cursor(true) + if bool(get_theme_constant("autohide", SPLIT_THEME_CLASS[layout_split.direction])): + queue_redraw() + elif what == NOTIFICATION_MOUSE_EXIT: + _mouse_hovering = false + set_split_cursor(false) + if bool(get_theme_constant("autohide", SPLIT_THEME_CLASS[layout_split.direction])): + queue_redraw() + elif what == NOTIFICATION_FOCUS_EXIT: + _dragging = false + + +func get_layout_minimum_size() -> Vector2: + if not layout_split: + return Vector2.ZERO + var separation := get_theme_constant("separation", SPLIT_THEME_CLASS[layout_split.direction]) + if layout_split.is_horizontal(): + return Vector2( + first_minimum_size.x + separation + second_minimum_size.x, + maxf(first_minimum_size.y, second_minimum_size.y) + ) + else: + return Vector2( + maxf(first_minimum_size.x, second_minimum_size.x), + first_minimum_size.y + separation + second_minimum_size.y + ) + + +func set_split_cursor(value: bool) -> void: + if value: + mouse_default_cursor_shape = SPLIT_MOUSE_CURSOR_SHAPE[layout_split.direction] + else: + mouse_default_cursor_shape = CURSOR_ARROW + + +func get_split_rects(rect: Rect2) -> Dictionary: + _parent_rect = rect + var separation := get_theme_constant("separation", SPLIT_THEME_CLASS[layout_split.direction]) + var origin := rect.position + var percent := layout_split.percent + if layout_split.is_horizontal(): + var split_offset := clampf( + rect.size.x * percent - separation * 0.5, + first_minimum_size.x, + rect.size.x - second_minimum_size.x - separation + ) + var second_width := rect.size.x - split_offset - separation + + return { + "first": Rect2(origin.x, origin.y, split_offset, rect.size.y), + "self": Rect2(origin.x + split_offset, origin.y, separation, rect.size.y), + "second": + Rect2(origin.x + split_offset + separation, origin.y, second_width, rect.size.y), + } + else: + var split_offset := clampf( + rect.size.y * percent - separation * 0.5, + first_minimum_size.y, + rect.size.y - second_minimum_size.y - separation + ) + var second_height := rect.size.y - split_offset - separation + + return { + "first": Rect2(origin.x, origin.y, rect.size.x, split_offset), + "self": Rect2(origin.x, origin.y + split_offset, rect.size.x, separation), + "second": + Rect2(origin.x, origin.y + split_offset + separation, rect.size.x, second_height), + } diff --git a/split_handle.gd.uid b/split_handle.gd.uid new file mode 100644 index 0000000..4e26f16 --- /dev/null +++ b/split_handle.gd.uid @@ -0,0 +1 @@ +uid://dst2j6djj1vdv