Initial commit, layout mockup

This commit is contained in:
2025-02-15 22:57:49 +01:00
commit 21b101e2bb
70 changed files with 7646 additions and 0 deletions

View File

@@ -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.

View File

@@ -0,0 +1,448 @@
@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 data is Dictionary and data.get("type") in ["tab_container_tab", "tabc_element"]:
var tabc := get_node_or_null(data.get("from_path"))
return (
tabc
and tabc.has_method("get_tabs_rearrange_group")
and tabc.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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<g>
<rect style="fill-rule: nonzero; fill: rgb(142, 239, 152);" x="4.001" y="4.016" width="4.005" height="5.004" rx="1" ry="1"/>
<rect style="fill-rule: nonzero; fill: rgb(142, 239, 152);" x="8.999" y="7.016" width="3.006" height="5.004" rx="1" ry="1"/>
<rect style="fill-rule: nonzero; fill: rgb(142, 239, 152);" x="4.004" y="10.023" width="4.005" height="1.99" rx="1" ry="1"/>
<rect style="fill-rule: nonzero; fill: rgb(142, 239, 152);" x="9" y="3.991" width="3.006" height="2.031" rx="1" ry="1"/>
</g>
<path d="M 13 1 C 14.097 1 15 1.903 15 3 L 15 13 C 15 14.097 14.097 15 13 15 L 3 15 C 1.903 15 1 14.097 1 13 L 1 3 C 1 1.903 1.903 1 3 1 L 13 1 Z M 3 13 L 13 13 L 13 3 L 3 3 L 3 13 Z" fill-rule="nonzero" style="fill-rule: nonzero; fill: rgb(142, 239, 152);"/>
</svg>

After

Width:  |  Height:  |  Size: 979 B

View File

@@ -0,0 +1,37 @@
[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/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,177 @@
[gd_scene load_steps=16 format=3 uid="uid://drlvhuchtk6if"]
[ext_resource type="Script" path="res://addons/dockable_container/dockable_container.gd" id="1"]
[ext_resource type="Script" path="res://addons/dockable_container/layout.gd" id="2"]
[ext_resource type="Script" path="res://addons/dockable_container/samples/TestScene.gd" id="4"]
[ext_resource type="Script" path="res://addons/dockable_container/layout_split.gd" id="4_yhgfb"]
[ext_resource type="Script" 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")
current_tab = 0
[sub_resource type="Resource" id="Resource_6kjom"]
resource_name = "Tabs"
script = ExtResource("5")
names = PackedStringArray("Control1", "Control2")
current_tab = 0
[sub_resource type="Resource" id="Resource_hl8y1"]
resource_name = "Split"
script = ExtResource("4_yhgfb")
direction = 1
percent = 0.5
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")
hidden_tabs = {}
[sub_resource type="Resource" id="Resource_ntwfj"]
resource_name = "Tabs"
script = ExtResource("5")
names = PackedStringArray("Control3")
current_tab = 0
[sub_resource type="Resource" id="Resource_dmyvf"]
resource_name = "Tabs"
script = ExtResource("5")
names = PackedStringArray("Control4")
current_tab = 0
[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")
current_tab = 0
[sub_resource type="Resource" id="Resource_jhibs"]
resource_name = "Split"
script = ExtResource("4_yhgfb")
direction = 0
percent = 0.5
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")
hidden_tabs = {}
[node name="SampleScene" type="VBoxContainer"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("4")
[node name="HBoxContainer" type="HBoxContainer" parent="."]
layout_mode = 2
alignment = 1
[node name="AddControlButton" type="Button" parent="HBoxContainer"]
layout_mode = 2
size_flags_horizontal = 0
size_flags_vertical = 4
text = "(+) ADD CONTROL"
[node name="SaveLayoutButton" type="Button" parent="HBoxContainer"]
layout_mode = 2
size_flags_horizontal = 0
size_flags_vertical = 4
text = "Save Layout"
[node name="LoadLayoutButton" type="Button" parent="HBoxContainer"]
layout_mode = 2
size_flags_horizontal = 0
size_flags_vertical = 4
text = "Load Layout"
[node name="ControlPrefab" type="ColorRect" parent="HBoxContainer"]
visible = false
layout_mode = 2
color = Color(0.129412, 0.121569, 0.121569, 1)
[node name="Buttons" type="VBoxContainer" parent="HBoxContainer/ControlPrefab"]
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"]
layout_mode = 2
text = "Rename"
[node name="Remove" type="Button" parent="HBoxContainer/ControlPrefab/Buttons"]
layout_mode = 2
text = "REMOVE"
[node name="DockableContainers" type="HBoxContainer" parent="."]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="DockableContainer" type="Container" parent="DockableContainers"]
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"]
layout_mode = 2
[node name="Control1" type="ColorRect" parent="DockableContainers/DockableContainer"]
layout_mode = 2
color = Color(0.141176, 0.0745098, 0.603922, 1)
[node name="Control2" type="ColorRect" parent="DockableContainers/DockableContainer"]
visible = false
layout_mode = 2
color = Color(0.533333, 0.380392, 0.380392, 1)
[node name="Separator" type="ColorRect" parent="DockableContainers"]
custom_minimum_size = Vector2(50, 0)
layout_mode = 2
color = Color(0, 0, 0, 1)
[node name="DockableContainer2" type="Container" parent="DockableContainers"]
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"]
layout_mode = 2
color = Color(0, 1, 0.905882, 1)
[node name="Control4" type="ColorRect" parent="DockableContainers/DockableContainer2"]
layout_mode = 2
color = Color(0, 0.698039, 0.0588235, 1)
[node name="Control5" type="ColorRect" parent="DockableContainers/DockableContainer2"]
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"]

View File

@@ -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),
}