Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement floating windows #36

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
71 changes: 70 additions & 1 deletion addons/dockable_container/dockable_container.gd
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const DragNDropPanel := preload("drag_n_drop_panel.gd")

var _layout := DockableLayout.new()
var _panel_container := Container.new()
var _windows_container := Container.new()
var _split_container := Container.new()
var _drag_n_drop_panel := DragNDropPanel.new()
var _drag_panel: DockablePanel
Expand All @@ -80,6 +81,8 @@ func _ready() -> void:
_split_container.name = "_split_container"
_split_container.mouse_filter = MOUSE_FILTER_PASS
_panel_container.add_child(_split_container)
_windows_container.name = "_windows_container"
get_parent().call_deferred("add_child", _windows_container)
Copy link
Owner

@gilzoide gilzoide Feb 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this script creates _windows_container, it should also destroy it, likely in _exit_tree.
By the way, can't _windows_container be a direct child of DockableContainer instead of a sibling? It could be a special child that is not docked, just as _panel_container, _split_container and _drag_n_drop_panel are.


_drag_n_drop_panel.name = "_drag_n_drop_panel"
_drag_n_drop_panel.mouse_filter = MOUSE_FILTER_PASS
Expand Down Expand Up @@ -161,6 +164,61 @@ func _drop_data(_position: Vector2, data) -> void:
queue_sort()


func _add_floating_options(tab_container: DockablePanel) -> void:
var options := PopupMenu.new()
options.add_item("Make Floating")
options.id_pressed.connect(_toggle_floating.bind(tab_container))
options.size.y = 0
_windows_container.add_child(options)
tab_container.set_popup(options)
Comment on lines +167 to +173
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method could be implemented in DockablePanel directly, what do you think? DockablePanel would then have a toggle_floating_window signal or something like this, that already passes all required values like current tab index, current tab name, leaf control, etc... and DockableContainer simply connects it to _toggle_floating.

By the way, I think the popup doesn't need to be a child of _windows_container or anything else, setting a popup to a TabContainer ties the popup to it and you don't need to worry about it ever again: it will be managed by the tab container. Doesn't it? 🤔



## Required when converting a window back to panel.
func _refresh_tabs_visible() -> void:
if tabs_visible:
tabs_visible = false
await get_tree().process_frame
await get_tree().process_frame
tabs_visible = true


func _toggle_floating(_id: int, tab_container: DockablePanel) -> void:
var node_name := tab_container.get_tab_title(tab_container.current_tab)
var node := get_node(node_name)
if is_instance_valid(node):
var tab_position := maxi(tab_container.leaf.find_child(node), 0)
_convert_to_window(node, {"tab_position": tab_position, "tab_container": tab_container})
else:
print("Node ", node_name, " not found!")


## Converts a panel to floating window.
func _convert_to_window(content: Control, previous_data := {}) -> void:
var old_owner := content.owner
var data := {}
if content.name in layout.windows:
data = layout.windows[content.name]
Comment on lines +198 to +200
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could use Dictionary.get

Suggested change
var data := {}
if content.name in layout.windows:
data = layout.windows[content.name]
var data := layout.windows.get(content.name, {}) as Dictionary

var window := FloatingWindow.new(content, data)
_windows_container.add_child(window)
window.show()
_refresh_tabs_visible()
window.close_requested.connect(_convert_to_panel.bind(window, old_owner, previous_data))
window.data_changed.connect(layout.save_window_properties)


## Converts a floating window into a panel.
func _convert_to_panel(window: FloatingWindow, old_owner: Node, previous_data := {}) -> void:
var content := window.window_content
window.remove_child(content)
window.destroy()
add_child(content)
content.owner = old_owner
if previous_data.has("tab_container") and is_instance_valid(previous_data["tab_container"]):
var tab_position := previous_data.get("tab_position", 0) as int
previous_data["tab_container"].leaf.insert_node(tab_position, content)
_refresh_tabs_visible()


func set_control_as_current_tab(control: Control) -> void:
assert(
control.get_parent_control() == self,
Expand Down Expand Up @@ -195,14 +253,24 @@ func set_layout(value: DockableLayout) -> void:
_layout.changed.disconnect(queue_sort)
_layout = value
_layout.changed.connect(queue_sort)
for window in _windows_container.get_children():
if not window.name in _layout.windows and window is FloatingWindow:
window.prevent_data_erasure = true # We don't want to delete data.
window.close_requested.emit() # Removes the window.
Comment on lines +257 to +259
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using prevent_data_erasure, closing all windows and reopening them later, we could use a pool of FloatingWindow nodes, much like we pool DockablePanel and SplitHandle nodes. In _resort, we would call _untrack_children_after(_windows_container) after setting all active floating windows, which would delete unused windows, making them close anyway.

continue
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this continue unnecessary, since it's the last statement of the for block?

for window: String in _layout.windows.keys():
var panel := find_child(window, false)
# Only those windows get created which were not previously created.
if panel:
_convert_to_window(panel)
_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)
var panel := _panel_container.get_child(i) as DockablePanel
panel.use_hidden_tabs_for_min_size = value


Expand Down Expand Up @@ -401,6 +469,7 @@ func _get_panel(idx: int) -> DockablePanel:
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))
_add_floating_options(panel)
_panel_container.add_child(panel)
panel.tab_layout_changed.connect(_on_panel_tab_layout_changed.bind(panel))
return panel
Expand Down
2 changes: 2 additions & 0 deletions addons/dockable_container/dockable_panel.gd
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ func _exit_tree() -> void:
active_tab_rearranged.disconnect(_on_tab_changed)
tab_selected.disconnect(_on_tab_selected)
tab_changed.disconnect(_on_tab_changed)
if is_instance_valid(get_popup()):
get_popup().queue_free()
Comment on lines +43 to +44
Copy link
Owner

@gilzoide gilzoide Feb 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't add the popup as a child of _window_container as mentioned above, this would likely be unnecessary. As per the documentation:

This is a required internal node, removing and freeing it may cause a crash.



func track_nodes(nodes: Array[Control], new_leaf: DockableLayoutPanel) -> void:
Expand Down
80 changes: 80 additions & 0 deletions addons/dockable_container/floating_window.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
class_name FloatingWindow
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not too sure about it, but maybe this class should be called DockableFloatingWindow to namespace the type, avoiding possible name clashes? The other "private" classes in this package don't use a global class_name, not sure if this one should too. People shouldn't be creating instances of FloatingWindow in the editor, for example.

extends Window

## Emitted when the window's position or size changes, or when it's closed.
signal data_changed

var window_content: Control
var prevent_data_erasure := false
var _is_initialized := false


func _init(content: Control, data := {}) -> void:
window_content = content
title = window_content.name
name = window_content.name
min_size = window_content.get_minimum_size()
unresizable = false
wrap_controls = true
always_on_top = true
ready.connect(_deserialize.bind(data))


func _ready() -> void:
set_deferred(&"size", Vector2(300, 300))
await get_tree().process_frame
await get_tree().process_frame
Comment on lines +24 to +26
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe there is another signal that makes more sense that we could wait here instead of generically waiting for 2 frames? If there isn't any, just ignore this comment.

if get_tree().current_scene.get_window().gui_embed_subwindows:
position = DisplayServer.window_get_size() / 2 - size / 2
else:
position = DisplayServer.screen_get_usable_rect().size / 2 - size / 2
# Enable always_on_top for all child windows,
# to fix a bug where the child windows of floating windows appear behind them.
# TODO: Remove the loop when this bug gets fixed in Godot's side.
# Probably when https://github.com/godotengine/godot/issues/92848 is closed.
for dialog_child in find_children("", "Window", true, false):
if dialog_child is Window:
dialog_child.always_on_top = always_on_top


func _input(event: InputEvent) -> void:
if event is InputEventMouse:
# Emit `data_changed` when the window is being moved.
if not window_content.get_rect().has_point(event.position) and _is_initialized:
data_changed.emit(name, serialize())


func serialize() -> Dictionary:
return {"size": size, "position": position}


func _deserialize(data: Dictionary) -> void:
window_content.get_parent().remove_child(window_content)
window_content.visible = true
window_content.global_position = Vector2.ZERO
add_child(window_content)
size_changed.connect(window_size_changed)
if "position" in data:
await get_tree().process_frame
await get_tree().process_frame
position = data["position"]
if "size" in data:
set_deferred(&"size", data["size"])
_is_initialized = true


func window_size_changed() -> void:
window_content.size = size
window_content.position = Vector2.ZERO
if _is_initialized:
data_changed.emit(name, serialize())


func destroy() -> void:
size_changed.disconnect(window_size_changed)
queue_free()


func _exit_tree() -> void:
if _is_initialized and !prevent_data_erasure:
data_changed.emit(name, {})
18 changes: 18 additions & 0 deletions addons/dockable_container/layout.gd
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,19 @@ enum { MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM, MARGIN_CENTER }
if value != _hidden_tabs:
_hidden_tabs = value
changed.emit()
## A [Dictionary] of [StringName] and [Dictionary], containing data such as position and size.
@export var windows := {}:
get:
return _windows
set(value):
if value != _windows:
_windows = value
changed.emit()

var _changed_signal_queued := false
var _first_leaf: DockableLayoutPanel
var _hidden_tabs: Dictionary
var _windows: Dictionary
var _leaf_by_node_name: Dictionary
var _root: DockableLayoutNode = DockableLayoutPanel.new()

Expand Down Expand Up @@ -166,6 +175,15 @@ func set_tab_hidden(name: String, hidden: bool) -> void:
_on_root_changed()


func save_window_properties(window_name: StringName, data: Dictionary) -> void:
var new_windows = windows.duplicate(true)
if data.is_empty():
new_windows.erase(window_name)
else:
new_windows[window_name] = data
windows = new_windows
Comment on lines +179 to +184
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to duplicate windows here? If it's about emitting the changed signal, we could just modify _windows and call _on_root_changed instead.



func is_tab_hidden(name: String) -> bool:
return _hidden_tabs.get(name, false)

Expand Down
4 changes: 4 additions & 0 deletions addons/dockable_container/samples/TestScene.tscn
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ resource_name = "Layout"
script = ExtResource("2")
root = SubResource("Resource_hl8y1")
hidden_tabs = {}
windows = {}
save_on_change = false

[sub_resource type="Resource" id="Resource_ntwfj"]
resource_name = "Tabs"
Expand Down Expand Up @@ -71,6 +73,8 @@ resource_name = "Layout"
script = ExtResource("2")
root = SubResource("Resource_jhibs")
hidden_tabs = {}
windows = {}
save_on_change = false

[node name="SampleScene" type="VBoxContainer"]
anchors_preset = 15
Expand Down