OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / build_dock.gd

856 lines · gd ref:
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))