extends CanvasLayer
## THE EDITOR PANEL LAYER — a right-hand dock that panels register into.
##
## NAMED BuildDock, NOT EditorDock: `EditorDock` is a real class in this engine
## (it is in Godot's own ClassDB — the autoload silently resolved to it and every
## call became "static function not found in base GDScriptNativeClass"). Autoload
## names share a namespace with engine classes, so anything Editor*-shaped is
## already taken. This is the BUILD-mode dock anyway, which is the truer name.
##
## Build mode's UI was two unrelated overlays that each invented their own chrome,
## their own anchoring and their own visibility rule: BuildHud (generated hotkey
## list, top-right, no height bound) and the sky panel (top-left, built by game.gd,
## toggled by a raw KEY_F2 that also switched you to play mode). Neither could be
## joined by a third without a third set of all of that, which is what hyper-games
## #28 means by "the panel layer" and #22 §4.1 by "blocked on the pointer".
##
## So this owns the things every panel needs and no panel should re-answer:
##
## WHERE one column, right edge, one width, one margin.
## CHROME one style. A panel supplies CONTENT, never a background.
## WHEN visible in build mode only, hidden by mode_changed.
## HEIGHT capped to the window with the overflow scrolling. BuildHud shipped
## without this and ran off the bottom of the screen as soon as the
## keymap grew — which it does every time a binding is added.
## ORDER registration order, so the dock reads the same on every launch.
##
## PANELS ARE CONTENT, NOT WINDOWS. register() takes a Control and wraps it in a
## collapsible section. That is what lets the atmosphere sliders and the hotkey
## list live in the same column without either knowing the other exists, and what
## makes the next one (the #28 outliner, a material browser) a Control and a call
## rather than another overlay.
##
## IT DOES NOT OWN THE POINTER. GameMode does — see wants_pointer() there. A dock
## that also decided when the mouse was free would be the second owner of that
## question, and the two would drift exactly like the mode/fly pair did.
const DOCK_WIDTH := 340.0
## Top + bottom breathing room used when capping the column against the window.
const VIEWPORT_MARGIN := 48.0
## THE LEFT EDGE IS NOT EMPTY, and this is the whole of why the left column
## starts lower than the right one. Four things already live in that strip, none
## of them ours and all of them at FIXED positions:
##
## PeerLabel y 10-30 (the game HUD)
## FPSLabel y 30-50
## [membership] block y 56-~120 addons/keet_social/
## keet_game_state.gd:53, layer 128
## [mesh] readout y 128-~170 keet_game_state.gd:61
##
## They are on layer 128 and the dock is on 99, so they draw OVER a panel rather
## than fighting it — the collision is silent and looks like a broken panel. The
## dock yields, because it is the thing that knows it is sharing an edge; making
## keet_social move would put the knowledge in the file least likely to be read
## when the next overlay is added.
##
## F9 hides the membership overlay, so this is generous rather than exact — it
## clears the strip whether or not that toggle has been pressed.
const LEFT_TOP_MARGIN := 190
## The right edge has no HUD, so it keeps the ordinary margin.
const TOP_MARGIN := 16
## TWO COLUMNS. The organizer is a persistent view of the whole scene and every
## other panel is a tool for what you are doing right now, so they compete for
## the same edge and the one you are reading loses. Split by ROLE: tools left,
## the organizer right.
##
## SIDE is the caller's, not the dock's. A registry of which id goes where would
## have to know about every panel, which is exactly the coupling this file exists
## to avoid — see "PANELS ARE CONTENT" above.
const SIDE_LEFT := "left"
const SIDE_RIGHT := "right"
## A ROW along the bottom edge, between the two columns (#94 P1 §9): the texture
## strip, which wants WIDTH for a run of thumbnails rather than height. Inset by a
## column's width on each side so it never sits under either column.
const SIDE_BOTTOM := "bottom"
## The bottom row may take at most this share of the window height.
const BOTTOM_MAX_FRACTION := 0.4
## A ROW along the TOP edge, between the columns (editor redesign E1): the top bar —
## menus, select modes, grid, texture lock.
const SIDE_TOP := "top"
## THE ORGANIZER AREA, over the right column (Blender's outliner over its properties;
## docs/editor-areas.md §4.3).
const SIDE_ORGANIZER := "organizer"
## THE AREAS (hyper-games #124, docs/editor-areas.md §4.1, step B1). The regions are no
## longer overlays anchored to the window's edges: they are opaque AREAS in a split layout
## around ONE free rectangle — the View3D leaf, R — through which the 3D shows (the window
## still renders it full size behind; B2 centres the image in R). Godot's SplitContainer
## gives every border a mouse drag and respects minimum sizes; the sizes are per-player
## prefs (`area.<side>`, px), written when a border is dragged.
##
## VBox (the window)
## ├─ top band full width
## ├─ VSplit ─┬─ HSplit ─┬─ left column
## │ │ ├─ View3D (empty: R)
## │ │ └─ VSplit ─┬─ organizer
## │ │ └─ right column
## │ └─ bottom strip
## └─ status bar full width, never collapses
const AREA_DEFAULTS := {"left": 340, "right": 380, "organizer": 380, "bottom": 150}
const AREA_BG := Color(0.05, 0.06, 0.08, 1.0)
## The narrowest an open column can be dragged (its panels scroll sideways below theirs).
const COLUMN_MIN := 160.0
var _hsplit: HSplitContainer = null
var _vsplit: VSplitContainer = null
var _rsplit: VSplitContainer = null
var _view3d: Control = null
## FOUR COLLAPSIBLE REGIONS (user decision 2026-10-02). Each region has its own small
## triangle on its inner edge; clicking it hides the region's content and leaves the
## triangle, so the region can always be brought back. Per player and remembered —
## UI state is never replicated (docs/editor-redesign.md §4a).
##
## CI IS NOT A PLAYER. Deploy CI runs on the same machine and user:// as the game, so the
## player's own editor prefs (snap off, a rotate step of 90°, a remembered box size) used to
## change what the gates measured — snap:false failed the 0.1298.0 deploy, and gate_editor_e3
## wrote tool.box.size=1 that broke its own next run. Under CI (pear-git sets CI=true on every
## step) prefs go to their own file and are NEVER READ BACK, so every gate process starts from
## the defaults and the player's file is never touched. Saving still works (gates assert it).
var LAYOUT_PATH := "user://editor_layout_ci.json" if OS.get_environment("CI") == "true" else "user://editor_layout.json"
var _fresh := OS.get_environment("CI") == "true"
## Glyph shown on each region's toggle: [open, collapsed]. The arrow points the way
## the region will move when clicked.
const TOGGLE_GLYPHS := {
"left": ["◀", "▶"], "right": ["▶", "◀"], "top": ["▲", "▼"], "bottom": ["▼", "▲"],
"organizer": ["▲", "▼"],
}
var _collapsed: Dictionary = {} # side -> bool
## side -> {"root": MarginContainer, "scroll": ScrollContainer, "column": VBoxContainer}
var _sides: Dictionary = {}
## id -> {"section": VBoxContainer, "body": Control, "header": Button,
## "open": bool, "side": String}
var _panels: Dictionary = {}
func _ready() -> void:
# Under the F1 help screen (128), which is a modal takeover and must cover the
# dock rather than fight it. BuildHud no longer draws anything of its own — it
# registers into here — so there is nothing at 100 to sit beneath any more.
layer = 99
visible = false
process_mode = Node.PROCESS_MODE_ALWAYS
_build()
GameMode.mode_changed.connect(_on_mode_changed)
if GameMode.is_building():
visible = true
func _build() -> void:
_load_layout()
var box := VBoxContainer.new()
box.name = "Areas"
box.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
box.add_theme_constant_override("separation", 0)
box.theme = _editor_theme()
add_child(box)
_build_region(SIDE_TOP, box)
_vsplit = _split(true)
_vsplit.size_flags_vertical = Control.SIZE_EXPAND_FILL
box.add_child(_vsplit)
_hsplit = _split(false)
_hsplit.size_flags_vertical = Control.SIZE_EXPAND_FILL
_vsplit.add_child(_hsplit)
_build_region(SIDE_LEFT, _hsplit)
# R: draws nothing and takes no mouse, so the 3D (and the map's picking) shows through.
_view3d = Control.new()
_view3d.name = "View3D"
_view3d.mouse_filter = Control.MOUSE_FILTER_IGNORE
_view3d.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_view3d.size_flags_vertical = Control.SIZE_EXPAND_FILL
_hsplit.add_child(_view3d)
_rsplit = _split(true)
_hsplit.add_child(_rsplit)
_build_region(SIDE_ORGANIZER, _rsplit)
_build_region(SIDE_RIGHT, _rsplit)
(_sides[SIDE_RIGHT]["frame"] as Control).size_flags_vertical = Control.SIZE_EXPAND_FILL
_build_region(SIDE_BOTTOM, _vsplit)
_build_status_bar(box)
for side: String in _sides:
_apply_collapsed(side)
# DEFERRED: `dragged` fires before the container re-lays its children, so reading the
# areas' sizes there saved the size the drag started from.
for sc: SplitContainer in [_hsplit, _vsplit, _rsplit]:
sc.dragged.connect(func(_o: int) -> void: call_deferred("_remember_sizes"))
sc.drag_ended.connect(func() -> void: call_deferred("_remember_sizes"))
_refresh_empty()
get_viewport().size_changed.connect(_clamp_to_viewport)
# The top band hugs its content, and the tool bar in it is a FLOW container: its height
# depends on the width it was laid out at. Measured once, before the band had its full
# width, it kept a height of 300 px for a 160 px bar until something else re-measured.
(_sides[SIDE_TOP]["column"] as Control).minimum_size_changed.connect(
func() -> void: call_deferred("_clamp_to_viewport"))
call_deferred("_apply_sizes")
## THE EDITOR'S THEME (#124 spacing audit item 1; user 2026-10-18: "lots of wasted space
## in our ui"). The project had no theme, so every editor widget was Godot's default: 16 px
## text and 4 px padding, ~31 px tall (measured) against Blender's 20 px widget unit and
## 11 pt text (blender-source wm_window.cc:777, UI_interface_c.hh:426). This Theme sits on
## the editor's areas only — the game's HUD, menus and chat keep the defaults. It starts
## from Godot's OWN styleboxes (so the widgets look the same, just tighter) with 6 / 2 px
## content margins, 12 px text, 2 px box separation and Blender's tree indent (20).
func _editor_theme() -> Theme:
var base := ThemeDB.get_default_theme()
var t := Theme.new()
t.default_font_size = 12
var states := ["normal", "hover", "pressed", "disabled", "focus", "hover_pressed",
"normal_mirrored", "hover_mirrored", "pressed_mirrored", "disabled_mirrored", "hover_pressed_mirrored"]
for type: String in ["Button", "OptionButton", "CheckBox", "CheckButton", "MenuBar", "MenuButton"]:
_tighten(t, base, type, states)
_tighten(t, base, "LineEdit", ["normal", "focus", "read_only"])
for type2: String in ["VBoxContainer", "HBoxContainer"]:
t.set_constant("separation", type2, 2)
t.set_constant("v_separation", "Tree", 2)
t.set_constant("item_margin", "Tree", 20)
return t
## Each named stylebox of `type`, Godot's default duplicated with Blender-tight padding.
func _tighten(t: Theme, base: Theme, type: String, names: Array) -> void:
for n: String in names:
if not base.has_stylebox(n, type):
continue
var sb: StyleBox = base.get_stylebox(n, type).duplicate()
sb.content_margin_left = 6.0
sb.content_margin_right = 6.0
sb.content_margin_top = 2.0
sb.content_margin_bottom = 2.0
t.set_stylebox(n, type, sb)
## A border the mouse can drag. IGNORE on the container itself: a PASS container would
## mark every click in R as handled and the map would never pick (its draggers are their
## own Controls and still take the drag).
func _split(vertical: bool) -> SplitContainer:
var sc: SplitContainer = VSplitContainer.new() if vertical else HSplitContainer.new()
sc.mouse_filter = Control.MOUSE_FILTER_IGNORE
# Blender's 2 px gap between areas (border_width) rather than the default 12; the drag
# still catches the pointer 6 px wide (#124 spacing audit item 3).
sc.add_theme_constant_override("separation", 2)
sc.add_theme_constant_override("minimum_grab_thickness", 6)
return sc
## One area: an OPAQUE frame (it takes the mouse — nothing behind it is picked) holding a
## margin, the collapse triangle and the scrolling column panels register into.
func _build_region(side: String, parent: Control) -> void:
var band := side == SIDE_TOP or side == SIDE_BOTTOM
var frame := PanelContainer.new()
frame.name = "Area_%s" % side
var style := StyleBoxFlat.new()
style.bg_color = AREA_BG
# No border: the 2 px gap between areas separates them, as in Blender (audit item 10).
frame.add_theme_stylebox_override("panel", style)
frame.mouse_filter = Control.MOUSE_FILTER_STOP
parent.add_child(frame)
var root := MarginContainer.new()
for m: String in ["margin_left", "margin_right", "margin_top", "margin_bottom"]:
root.add_theme_constant_override(m, 4)
frame.add_child(root)
var stack: BoxContainer = HBoxContainer.new() if band else VBoxContainer.new()
stack.add_theme_constant_override("separation", 2)
root.add_child(stack)
var toggle := _make_toggle(side)
if band:
toggle.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
stack.add_child(toggle) # a band's triangle is in its row already
else:
# NO ROW FOR THE TRIANGLE (#124 spacing audit item 4; Blender's region toggle is a
# corner zone): it floats in the area's top-right corner, over the empty end of the
# first panel's title line. The overlay keeps its size, so a COLLAPSED column is
# still the triangle's width.
var overlay := Control.new()
overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
frame.add_child(overlay)
overlay.add_child(toggle)
toggle.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
# Grow LEFT from the right anchor: the preset's offsets were taken before the
# triangle reached its final size, and growing right put it past the area's edge.
toggle.grow_horizontal = Control.GROW_DIRECTION_BEGIN
# Follow the triangle's REAL size: measured at creation (before the theme and font
# reach it) it was too small, and a collapsed column cut the triangle off-screen.
toggle.minimum_size_changed.connect(func() -> void: overlay.custom_minimum_size = toggle.get_combined_minimum_size())
overlay.custom_minimum_size = toggle.get_combined_minimum_size()
var scroll := ScrollContainer.new()
# A COLUMN SCROLLS SIDEWAYS when it is narrower than its panels. With horizontal
# scrolling off, a ScrollContainer's minimum width is its content's, so a column could
# never be dragged narrower than its widest panel (the outliner, ~450 px: the right
# border sat pinned at the default and "the horizontal panel sliding does not work",
# user 2026-10-18). A band still wraps instead (its width is the window's).
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED if band else ScrollContainer.SCROLL_MODE_AUTO
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
stack.add_child(scroll)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 2) # Blender's panel gap (audit item 5)
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
# EXPAND, so the scroll stretches the column to the area's height: a panel whose body
# asks to fill (the outliner's Tree, audit item 7) then gets the spare height.
column.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.add_child(column)
_sides[side] = {"frame": frame, "root": root, "scroll": scroll, "column": column, "toggle": toggle}
## The small triangle that collapses / restores a region. FOCUS_NONE for the same
## reason the panel headers are: Tab must never be eaten by the dock.
func _make_toggle(side: String) -> Button:
var t := Button.new()
t.flat = true
t.focus_mode = Control.FOCUS_NONE
t.add_theme_font_size_override("font_size", 12)
t.add_theme_color_override("font_color", Color(1.0, 0.7, 0.2))
t.tooltip_text = "Show / hide the %s panel" % side
t.pressed.connect(func() -> void: set_region_collapsed(side, not is_region_collapsed(side)))
return t
## The sizes the borders stand at: a collapsed area asks for 0, which the SplitContainer
## clamps to its minimum (the toggle strip). Offsets (split_container.cpp
## _update_default_dragger_positions): a fixed area BEFORE the expanding one starts at
## 0, so its offset is its size; one AFTER starts at the far edge, so its offset is
## minus its size.
func _area_size(side: String) -> int:
if is_region_collapsed(side):
return 0
return int(_prefs.get("area." + side, AREA_DEFAULTS.get(side, 300)))
func _apply_sizes() -> void:
if _hsplit == null:
return
_hsplit.split_offsets = PackedInt32Array([_area_size(SIDE_LEFT), -_area_size(SIDE_RIGHT)])
_vsplit.split_offsets = PackedInt32Array([-_area_size(SIDE_BOTTOM)])
_rsplit.split_offsets = PackedInt32Array([_area_size(SIDE_ORGANIZER)])
_clamp_to_viewport()
## A border was dragged: remember every open area's size (per player, never replicated).
func _remember_sizes() -> void:
var now := {
SIDE_LEFT: (_sides[SIDE_LEFT]["frame"] as Control).size.x,
SIDE_RIGHT: _rsplit.size.x,
SIDE_ORGANIZER: (_sides[SIDE_ORGANIZER]["frame"] as Control).size.y,
SIDE_BOTTOM: (_sides[SIDE_BOTTOM]["frame"] as Control).size.y,
}
for side: String in now:
if not is_region_collapsed(side) and (_sides[side]["frame"] as Control).is_visible_in_tree():
_prefs["area." + side] = int(now[side])
_save_layout()
## A RUNNING TOOL OWNS THE MOUSE (#124): the map takes every mouse event before the GUI
## while a tool is up (map_dc_level._input), but Godot works out HOVER from the pointer's
## position each frame, not from the events it delivers — so panel buttons still lit up
## under the travelling pointer. While a tool is up this transparent shield lies over
## every area: it is what the pointer is over, so nothing in a panel hovers. It never acts
## on an event itself (the map has already taken them).
var _shield: Control = null
func set_modal(on: bool) -> void:
if _shield == null:
_shield = Control.new()
_shield.name = "ModalShield"
_shield.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_shield.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_shield)
_shield.visible = on
func is_modal() -> bool:
return _shield != null and _shield.visible
## R, the free rectangle the 3D shows through (screen pixels). The whole window when the
## areas are hidden (play mode).
## The 3D view's Control (MOUSE_FILTER_IGNORE: clicks pass to the game). A panel that
## drags something INTO the view (the asset browser, #120 L4) arms it as a drop target for
## the drag's duration and disarms it after.
func view3d() -> Control:
return _view3d
func view_rect() -> Rect2:
if visible and _view3d != null and _view3d.is_visible_in_tree():
return _view3d.get_global_rect()
return get_viewport().get_visible_rect()
## An area with nothing in it is hidden (the organizer on a map without one, the strip in
## the bot workspace), so R takes its room.
func _refresh_empty() -> void:
for side: String in [SIDE_ORGANIZER, SIDE_BOTTOM]:
if not _sides.has(side):
continue
var any := false
for f: Node in (_sides[side]["column"] as Node).get_children():
if f is Control and (f as Control).visible and not f.is_queued_for_deletion():
any = true
(_sides[side]["frame"] as Control).visible = any
# ---- regions: collapse, persistence -----------------------------------------
func has_region(side: String) -> bool:
return _sides.has(side)
func region_toggle(side: String) -> Button:
return _sides.get(side, {}).get("toggle") as Button
## IS THIS SCREEN POINT ON THE DOCK? (#109, 2026-10-12: "mouse should not select things
## that are underneath a panel window".) The frames and columns are containers that PASS
## the mouse — they must, or the wheel would stop scrolling a column over a Label — so a
## click on a panel's padding, a Label or the gap between two panels reached the map's
## _unhandled_input and selected what was behind (gate_click_through). The map asks this
## before acting on a click: any visible region's panel stack, its toggle, the status bar.
##
## Since the areas (#124 B1) every pixel outside R belongs to an opaque area, so this is
## "the point is outside R" while the dock shows.
func covers(p: Vector2) -> bool:
if not visible or _view3d == null:
return false
return not _view3d.get_global_rect().has_point(p)
## The part that hides when the region collapses (the toggle does not).
func region_content(side: String) -> Control:
return _sides.get(side, {}).get("scroll") as Control
func is_region_collapsed(side: String) -> bool:
return bool(_collapsed.get(side, false))
func set_region_collapsed(side: String, collapsed: bool) -> void:
if not _sides.has(side):
return
_collapsed[side] = collapsed
_apply_collapsed(side)
_save_layout()
## A collapsed area keeps only its triangle; its border goes to the triangle's size and R
## takes the room (docs/editor-areas.md §8.3). The RIGHT side's triangle folds the whole
## side, the organizer over it included; the organizer's own folds only the organizer.
func _apply_collapsed(side: String) -> void:
var rec: Dictionary = _sides[side]
var c := is_region_collapsed(side)
(rec["scroll"] as Control).visible = not c
(rec["toggle"] as Button).text = String(TOGGLE_GLYPHS[side][1 if c else 0])
# A usable floor for an OPEN column (its panels scroll sideways below their width);
# a collapsed one is only its triangle.
if side == SIDE_LEFT or side == SIDE_RIGHT or side == SIDE_ORGANIZER:
(rec["frame"] as Control).custom_minimum_size.x = 0.0 if c else COLUMN_MIN
if side == SIDE_RIGHT:
(_sides[SIDE_ORGANIZER]["frame"] as Control).custom_minimum_size.x = 0.0 if c else COLUMN_MIN
if side == SIDE_RIGHT and _sides.has(SIDE_ORGANIZER):
(_sides[SIDE_ORGANIZER]["root"] as Control).visible = not c
call_deferred("_apply_sizes")
## Per-player EDITOR preferences (snap, grid…) — the same file as the region layout,
## because both are "how this player likes the editor" and neither may replicate.
var _prefs: Dictionary = {}
var _loaded := false
func pref(key: String, default: Variant) -> Variant:
if not _loaded:
_load_layout()
return _prefs.get(key, default)
func set_pref(key: String, value: Variant) -> void:
if not _loaded:
_load_layout()
if _prefs.get(key) == value:
return
_prefs[key] = value
_save_layout()
func _load_layout() -> void:
_loaded = true
if _fresh or not FileAccess.file_exists(LAYOUT_PATH):
return
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(LAYOUT_PATH))
if d is Dictionary:
_collapsed = (d as Dictionary).get("collapsed", {})
_prefs = (d as Dictionary).get("prefs", {})
func _save_layout() -> void:
var f := FileAccess.open(LAYOUT_PATH, FileAccess.WRITE)
if f == null:
push_warning("[BuildDock] cannot write %s — layout will not persist" % LAYOUT_PATH)
return
f.store_string(JSON.stringify({"collapsed": _collapsed, "prefs": _prefs}))
f.close()
# ---- the status bar ----------------------------------------------------------
var _status_left: RichTextLabel = null
var _status_right: Label = null
## BuildHud fills it (it already owns "what mode / tool am I in"); the dock only owns
## where it sits and how it looks.
func _build_status_bar(parent: Control) -> void:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.03, 0.035, 0.05, 0.9)
style.set_content_margin_all(4)
style.content_margin_top = 1 # ~22 px, Blender's (audit item 8)
style.content_margin_bottom = 1
style.set_corner_radius_all(3)
var frame := PanelContainer.new()
frame.add_theme_stylebox_override("panel", style)
parent.add_child(frame)
var row := HBoxContainer.new()
frame.add_child(row)
_status_left = RichTextLabel.new()
_status_left.bbcode_enabled = true
# ONE CLIPPED LINE. fit_content with no wrap made the label's MINIMUM width the
# whole hint line (~1300 px), which forced the bottom band wider than the gap
# between the columns on any window narrower than ~2200 px.
_status_left.fit_content = false
_status_left.scroll_active = false
_status_left.autowrap_mode = TextServer.AUTOWRAP_OFF
_status_left.custom_minimum_size = Vector2(0, 20)
_status_left.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_status_left.clip_contents = true
row.add_child(_status_left)
_status_right = Label.new()
_status_right.add_theme_color_override("font_color", Color(1.0, 0.7, 0.2))
row.add_child(_status_right)
func set_status(left_bbcode: String, right: String) -> void:
if _status_left != null:
_status_left.text = left_bbcode
if _status_right != null:
_status_right.text = right
## Add a panel. `body` is yours; the section, header and chrome are ours.
##
## Idempotent on id: registering twice REPLACES the body rather than stacking a
## second copy, because the natural caller is a map's _ready and maps are entered
## more than once.
func register(id: String, title: String, body: Control, open: bool = true,
side: String = SIDE_LEFT, workspace: String = "") -> void:
if body == null:
push_warning("[BuildDock] register(%s) with no body" % id)
return
if not _sides.has(side):
push_warning("[BuildDock] register(%s) with unknown side '%s' — using left" % [id, side])
side = SIDE_LEFT
if _panels.has(id):
unregister(id)
# WHERE THE PLAYER MOVED IT (#124 B3, "Move to ▸"): a saved side wins over the one
# the caller registers it in, which stays its HOME (Reset panel layout).
var home := side
var moved := String(pref(PANEL_SIDE_PREF + id, ""))
if home != SIDE_TOP and MOVE_TARGETS.has(moved):
side = moved
# ONE LINE IN THE TOP BAND (user 2026-10-18: "editor should be to the right so that the
# entire panel can be one line, same with tool"): there a panel is a ROW — its content,
# then its title at the right end — not a title line above the content.
var row := side == SIDE_TOP
var section: BoxContainer = HBoxContainer.new() if row else VBoxContainer.new()
section.add_theme_constant_override("separation", 6 if row else 0)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.05, 0.06, 0.08, 0.86)
style.border_color = Color(1.0, 0.7, 0.2, 0.55) # the editing UI's colour, as BuildHud
style.set_border_width_all(1)
style.set_corner_radius_all(3)
style.set_content_margin_all(3 if row else 8)
var frame := PanelContainer.new()
frame.add_theme_stylebox_override("panel", style)
frame.add_child(section)
(_sides[side]["column"] as VBoxContainer).add_child(frame)
# A Button, not a Label with an input handler: it is focusable and keyboard
# reachable for free, which a click-target built out of a Label is not.
var header := Button.new()
header.flat = true
# FOCUS_NONE, and this is load-bearing. Tab is Godot's ui_focus_next, so a
# focusable Control anywhere in a visible dock eats it — and Tab is
# global.mode_toggle, the way OUT of build mode. Focusable headers therefore
# trapped you in the editor, tabbing between panel headers instead. Click
# still works; only keyboard focus traversal is declined.
header.focus_mode = Control.FOCUS_NONE
header.alignment = HORIZONTAL_ALIGNMENT_LEFT
header.add_theme_font_size_override("font_size", 12) # Blender's 11 pt titles (audit item 6)
header.add_theme_color_override("font_color", Color(1.0, 0.7, 0.2))
if row:
header.size_flags_horizontal = Control.SIZE_SHRINK_END
header.size_flags_vertical = Control.SIZE_SHRINK_CENTER
else:
section.add_child(header)
body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
# NO PANEL MAY EAT TAB. The header above declines focus for this reason and
# says so; a panel's own controls are the hole in that. The outliner arrived
# with four Buttons and a Tree, all FOCUS_ALL by default, and Tab stopped
# reaching the create tool's shape cycle — it was traversing dock widgets
# instead. Fixing it per panel means every future panel gets one chance to
# forget, so the rule lives HERE, with the chrome it belongs to.
#
# Downgraded rather than removed: FOCUS_CLICK is skipped by keyboard
# traversal but still focusable by clicking, which is what a Tree needs for
# inline rename and a slider for keyboard nudging once you have clicked it.
_decline_tab_focus(body)
section.add_child(body)
if row:
section.add_child(header) # the title at the row's right end
var rec := {"section": section, "body": body, "header": header, "title": title,
"open": open, "side": side, "home": home, "workspace": workspace}
_panels[id] = rec
header.pressed.connect(func() -> void: _toggle(id))
if home != SIDE_TOP:
header.tooltip_text = "Click: fold · Right-click: Move to…"
header.gui_input.connect(_on_header_input.bind(id, header))
_apply_open(id)
_apply_workspace_to(id)
_refresh_empty()
_clamp_to_viewport()
# Say so. A panel that fails to register is otherwise indistinguishable from a
# dock that is not showing, and that cost a round trip already.
print("[BuildDock] + %s (%s, %s) — %d panel(s)"
% [id, side, "open" if open else "collapsed", _panels.size()])
## Every Control under `node` that would accept keyboard focus is downgraded to
## click-only, so nothing in the dock appears in Tab's traversal order.
##
## BUTTONS, DROPDOWNS AND SLIDERS TAKE NO FOCUS AT ALL. Click focus was not enough:
## a clicked button keeps keyboard focus and the GUI then eats the arrows for focus
## navigation before the map's _unhandled_key_input, so the UV keys "worked a bit
## then stopped" (user report 2026-10-03, gate_uv_controls). Text entry (LineEdit,
## SpinBox) keeps click focus — typing needs it, and Keymap's text_input context
## already hands the keyboard over while it is focused.
func _decline_tab_focus(node: Node) -> void:
if (node is BaseButton or node is Range) and not (node is SpinBox):
(node as Control).focus_mode = Control.FOCUS_NONE
elif node is Control and (node as Control).focus_mode == Control.FOCUS_ALL:
(node as Control).focus_mode = Control.FOCUS_CLICK
for child in node.get_children():
_decline_tab_focus(child)
func unregister(id: String) -> void:
if not _panels.has(id):
return
var rec: Dictionary = _panels[id]
var section: Control = rec["section"]
# The frame is the section's parent — free that, or the chrome outlives its body.
var frame: Node = section.get_parent()
if frame and is_instance_valid(frame):
frame.queue_free()
_panels.erase(id)
_refresh_empty()
_clamp_to_viewport()
func has_panel(id: String) -> bool:
return _panels.has(id)
# ---- moving panels between areas (#124 B3; docs/editor-areas.md §4.2) ----------
#
# Every area but the 3D View is a panel column, so an area's "type" is which panels it
# holds: Move to ▸ on a panel's header is how a player rearranges them. The top band is
# not a target — its panels are one-line rows, built differently.
const MOVE_TARGETS: Array = [SIDE_LEFT, SIDE_ORGANIZER, SIDE_RIGHT, SIDE_BOTTOM]
const MOVE_LABELS := {"left": "Left column", "organizer": "Organizer (right, top)",
"right": "Right column", "bottom": "Bottom strip"}
const PANEL_SIDE_PREF := "panel.side."
var _move_menu: PopupMenu = null
var _move_id := ""
func _on_header_input(e: InputEvent, id: String, header: Control) -> void:
if e is InputEventMouseButton and (e as InputEventMouseButton).pressed \
and (e as InputEventMouseButton).button_index == MOUSE_BUTTON_RIGHT:
_open_move_menu(id, header)
header.accept_event()
func _open_move_menu(id: String, at: Control) -> void:
if _move_menu == null:
_move_menu = PopupMenu.new()
_move_menu.id_pressed.connect(func(i: int) -> void: move_panel(_move_id, String(MOVE_TARGETS[i])))
add_child(_move_menu)
_move_id = id
_move_menu.clear()
var here := String((_panels[id] as Dictionary)["side"])
for i in MOVE_TARGETS.size():
_move_menu.add_item("Move to ▸ " + String(MOVE_LABELS[MOVE_TARGETS[i]]), i)
_move_menu.set_item_disabled(i, MOVE_TARGETS[i] == here)
var r := at.get_global_rect()
_move_menu.popup(Rect2i(Vector2i(r.position + Vector2(0, r.size.y)), Vector2i.ZERO))
## Put panel `id` in area `side`, and remember it there for this player.
func move_panel(id: String, side: String) -> void:
if not _panels.has(id) or not MOVE_TARGETS.has(side):
return
var rec: Dictionary = _panels[id]
if String(rec["home"]) == SIDE_TOP:
return
if side != String(rec["side"]):
var frame := (rec["section"] as Control).get_parent()
frame.reparent(_sides[side]["column"] as Node, false)
rec["side"] = side
if side == String(rec["home"]):
_prefs.erase(PANEL_SIDE_PREF + id)
_save_layout()
else:
set_pref(PANEL_SIDE_PREF + id, side)
# Not moved out of sight: a collapsed target opens (the right side's triangle folds
# the organizer too).
for s: String in ([SIDE_RIGHT, side] if side == SIDE_ORGANIZER else [side]):
if is_region_collapsed(s):
set_region_collapsed(s, false)
_refresh_empty()
call_deferred("_clamp_to_viewport")
## Every panel back to the area it was registered in; the saved moves are forgotten.
func reset_panel_layout() -> void:
for id: String in _panels:
move_panel(id, String((_panels[id] as Dictionary)["home"]))
for k: String in _prefs.keys():
if k.begins_with(PANEL_SIDE_PREF):
_prefs.erase(k)
_save_layout()
# ---- workspaces (user, 2026-10-04: "level editor / bot editor tabs") ------------
#
# One F4 editor, several WORKSPACES chosen from the top bar's tabs. A panel is tagged
# with the workspace it belongs to at register(); untagged panels (the top bar, the
# Object panel, History, the Outliner) show in every workspace. Switching shows the
# matching panels and hides the rest — nothing is unregistered, so each keeps its
# state. A new editor is a new workspace name plus the panels tagged with it.
signal workspace_changed(name: String)
const WS_LEVEL := "level"
const WS_BOT := "bot"
var _workspace := WS_LEVEL
func workspace() -> String:
return _workspace
func set_workspace(name: String) -> void:
if name == "":
name = WS_LEVEL
var changed := name != _workspace
_workspace = name
for id: String in _panels:
_apply_workspace_to(id)
_refresh_empty()
set_pref("workspace", name)
call_deferred("_clamp_to_viewport")
if changed:
workspace_changed.emit(name)
func _apply_workspace_to(id: String) -> void:
var rec: Dictionary = _panels[id]
var ws := String(rec.get("workspace", ""))
var section: Control = rec["section"]
var frame: Control = section.get_parent() as Control
if frame != null and is_instance_valid(frame):
frame.visible = ws == "" or ws == _workspace
## The panels a workspace shows (for gates).
func panels_in(name: String) -> Array:
var out: Array = []
for id: String in _panels:
var ws := String((_panels[id] as Dictionary).get("workspace", ""))
if ws == "" or ws == name:
out.append(id)
return out
## Open/close one section without touching the others.
func _toggle(id: String) -> void:
if not _panels.has(id):
return
var rec: Dictionary = _panels[id]
rec["open"] = not bool(rec["open"])
_apply_open(id)
func _apply_open(id: String) -> void:
var rec: Dictionary = _panels[id]
var body: Control = rec["body"]
var header: Button = rec["header"]
if is_instance_valid(body):
body.visible = bool(rec["open"])
# A body that asks for the spare height (SIZE_EXPAND) gets it while open; folded,
# its frame shrinks back to the header.
var frame := (rec["section"] as Control).get_parent() as Control
if frame != null and body.size_flags_vertical & Control.SIZE_EXPAND:
frame.size_flags_vertical = Control.SIZE_EXPAND_FILL if bool(rec["open"]) else Control.SIZE_FILL
if is_instance_valid(header):
header.text = "%s %s" % ["▾" if bool(rec["open"]) else "▸", String(rec["title"])]
call_deferred("_clamp_to_viewport")
## The screen width a side area occupies right now (the right side: organizer + column).
func _column_width(side: String) -> float:
if not _sides.has(side):
return 0.0
var c: Control = _rsplit if side == SIDE_RIGHT else _sides[side]["frame"]
return c.size.x if c != null and c.is_visible_in_tree() else 0.0
func _on_mode_changed(mode: String) -> void:
visible = mode == GameMode.BUILD
if visible:
call_deferred("_clamp_to_viewport")
## The areas fill their splits; the TOP band still needs the window's size: it hugs its
## content (a ScrollContainer's own minimum height is near zero, so without this it
## collapses to a sliver), up to BOTTOM_MAX_FRACTION of the window.
##
## (The LEFT column used to pad itself down past the HUD readouts at the window's
## top-left — LEFT_TOP_MARGIN. Those follow the 3D view's corner in build mode since
## a8aae69, so the pad was only a gap above the tools: user 2026-10-18.)
func _clamp_to_viewport() -> void:
if _sides.is_empty():
return
var height: float = get_viewport().get_visible_rect().size.y
var tscroll: ScrollContainer = _sides[SIDE_TOP]["scroll"]
var tcol: VBoxContainer = _sides[SIDE_TOP]["column"]
tscroll.custom_minimum_size = Vector2(0, minf(tcol.get_combined_minimum_size().y, height * BOTTOM_MAX_FRACTION))