extends HFlowContainer
## THE TOP BAR (editor redesign E1, docs/editor-redesign.md §3): menus generated from the
## keymap, the select-mode buttons, the grid and texture lock.
##
## NOTHING HERE ACTS ON ITS OWN. Every menu item and button runs its action through
## Keymap.run — which replays the bound key — so a click, a key and (later) a palette
## search take ONE code path, the map's input handler. The bar only READS the map's state
## back each frame to show it (select mode, grid, lock, view toggles).
##
## HFlowContainer, as Godot's own 3D toolbar: the bar wraps onto a second line when the
## window is narrow instead of clipping its right end.
## Menus: title -> action ids ("-" = separator). Every label and key comes from Keymap.
## add_menu_item() lets a feature append to a menu without editing this table (E1's
## version of Blender's menu.append).
const MENUS: Array = [
# Keymap step 3 (docs/keymap-blender-279.md §3a): every command in a menu AND on a key
# (user 2026-10-12) — the rows show the live key.
["Edit", ["builder.undo", "builder.redo", "builder.history", "-", "builder.adjust_last",
"builder.repeat_last", "-", "builder.confirm", "builder.duplicate", "builder.duplicate_linked", "builder.delete",
"builder.delete_menu", "builder.delete_hierarchy", "-", "builder.rename"]],
["Select", ["builder.select_all", "builder.deselect", "builder.select_invert", "builder.select_box",
"builder.select_circle",
"builder.select", "-", "builder.select_linked_pick", "builder.deselect_linked_pick",
"builder.select_linked", "builder.select_grouped", "-", "build_mode.sel_vertex", "build_mode.sel_edge", "build_mode.sel_face",
"build_mode.sel_brush", "builder.element_toggle", "builder.select_mode_menu"]],
["Add", ["builder.add_menu", "-", "builder.add_box", "builder.add_stairs", "builder.add_spiral_stairs",
"builder.add_tunnel", "builder.add_cylinder", "builder.add_cone", "builder.add_sphere", "-",
"builder.create_box", "builder.add_curve", "builder.add_cable", "builder.cable_from_edges", "builder.cable_bundle", "builder.carve", "builder.place_asset"]],
# Blender 2.79's OBJECT menu (was "Brush"): transforms, CSG, pivot, and since #120 F1
# parenting, join / separate and collections. One menu, not a new one: a seventh title
# pushed the top band past a 1280-px window (gate_editor_e1).
["Object", ["builder.move", "builder.rotate", "builder.scale", "builder.mirror", "builder.extrude", "builder.knife", "-",
"builder.move_in", "builder.move_out", "-",
"build_mode.csg_subtract", "build_mode.csg_intersect", "build_mode.csg_slice", "build_mode.csg_merge",
"build_mode.csg_discard", "-", "build_mode.faces_hull", "build_mode.faces_extrude", "-",
"builder.pivot_bbox", "builder.pivot_median", "builder.pivot_cursor", "builder.pivot_active",
"builder.pivot_individual", "-", "builder.proportional", "builder.proportional_falloff",
"builder.proportional_connected", "-", "builder.make_parametric", "builder.assign_generator", "builder.add_modifier", "builder.edit_curve", "builder.drop_cable", "-", "builder.parent", "builder.clear_parent", "-",
"builder.join", "builder.separate", "-", "builder.collection_new", "builder.collection_move",
"builder.collection_link", "builder.collection_remove", "builder.save_asset"]],
["Texture", ["builder.uv_menu", "-", "builder.texture_apply", "builder.texture_pick", "-",
"builder.uv_hotspot", "builder.uv_reroll", "builder.uv_pin", "builder.uv_fit", "builder.uv_align"]],
["View", ["builder.fly", "builder.wireframe", "builder.toggle_sidebar", "builder.toggle_toolbar", "builder.reset_panels", "-",
"builder.overlays_all", "builder.brush_view", "builder.clearance_view", "builder.entity_links",
"builder.grid_view", "builder.cutters_view", "-",
"builder.grid_finer", "builder.grid_coarser", "-",
"view.persp_ortho", "view.front", "view.back", "view.right", "view.left", "view.top", "view.bottom",
"view.opposite", "view.selected", "view.all", "view.all_center", "view.center_cursor",
"view.center_pick", "-", "global.help"]],
]
const SEL_MODES: Array = [["vertex", "Vert"], ["edge", "Edge"], ["face", "Face"], ["brush", "Brush"]]
var map: Node = null
var _bar: MenuBar
var _menus: Dictionary = {} # title -> PopupMenu
var _sel_buttons: Dictionary = {} # mode -> Button
var _grid: OptionButton
var _lock: CheckButton
var _snap: Button
var _snap_menu: MenuButton
var _overlays: Button = null
var _overlays_menu: MenuButton = null
func overlays_button() -> Button:
return _overlays
func overlays_menu() -> MenuButton:
return _overlays_menu
func snap_button() -> Button:
return _snap
func _sync_snap_menu(sp: PopupMenu) -> void:
sp.set_item_checked(sp.get_item_index(0), bool(map.snap_enabled))
var m := int(Settings.get_value("editor_units"))
sp.set_item_checked(sp.get_item_index(1), m == 0)
sp.set_item_checked(sp.get_item_index(2), m == 1)
sp.set_item_checked(sp.get_item_index(5), bool(map.snap_target_enabled("vertex")))
sp.set_item_checked(sp.get_item_index(6), bool(map.snap_target_enabled("edge")))
## WORKSPACE TABS (user, 2026-10-04): one F4 editor, "Level editor" / "Bot editor".
## BuildDock owns the workspace (which panels show); the tabs only switch it. The
## level-only parts of this bar hide in the bot workspace.
const WORKSPACES: Array = [["level", "Level editor"], ["bot", "Bot editor"]]
## Menus that only mean something for brushes.
const LEVEL_MENUS: Array = ["Add", "Object", "Texture"]
var _tabs: Dictionary = {} # workspace -> Button
var _level_only: Array[Control] = []
func workspace_tab(name: String) -> Button:
return _tabs.get(name)
func setup(map_node: Node) -> void:
map = map_node
add_theme_constant_override("h_separation", 6)
var tg := ButtonGroup.new()
for w: Array in WORKSPACES:
var t := Button.new()
t.text = String(w[1])
t.toggle_mode = true
t.button_group = tg
t.focus_mode = Control.FOCUS_NONE
t.pressed.connect(func() -> void: BuildDock.set_workspace(String(w[0])))
add_child(t)
_tabs[String(w[0])] = t
add_child(VSeparator.new())
_bar = MenuBar.new()
_bar.flat = true
# THE MAP IS THE ONLY PLACE A KEY ACTS. MenuBar normally fires an item when its
# accelerator is pressed; here the item itself replays that key, so leaving this on
# would loop (key -> menu item -> Keymap.run -> key -> …) and double-fire every
# action. The accelerators are DISPLAY only.
_bar.set_process_shortcut_input(false)
add_child(_bar)
for m: Array in MENUS:
var pm := PopupMenu.new()
pm.name = String(m[0])
pm.hide_on_checkable_item_selection = false
_bar.add_child(pm)
_menus[String(m[0])] = pm
for a: String in m[1]:
_add_item(pm, a)
pm.id_pressed.connect(func(id: int) -> void: activate(String(pm.get_item_metadata(pm.get_item_index(id)))))
pm.about_to_popup.connect(_sync_checks.bind(pm))
add_child(VSeparator.new())
var group := ButtonGroup.new()
for s: Array in SEL_MODES:
var b := Button.new()
b.text = String(s[1])
b.toggle_mode = true
b.button_group = group
b.focus_mode = Control.FOCUS_NONE
var action := "build_mode.sel_%s" % String(s[0])
b.tooltip_text = _tip(action)
b.pressed.connect(activate.bind(action))
add_child(b)
_sel_buttons[String(s[0])] = b
add_child(VSeparator.new())
# THE SNAP MAGNET + its popover (editor redesign E2): toggle on the bar, details one
# click away — Blender's header magnet, Godot's 2D "Snapping Options" menu.
_snap = Button.new()
_snap.text = "🧲 Snap"
_snap.toggle_mode = true
_snap.focus_mode = Control.FOCUS_NONE
_snap.tooltip_text = "%s (%s)\nOff = steps of 1 unit. While a tool is up: Ctrl inverts, Shift = 1/%d steps." % [
String(Keymap.binding_of("builder.snap_toggle").get("label", "Snap")),
Keymap.describe("builder.snap_toggle"), int(map.PRECISION)]
_snap.toggled.connect(func(on: bool) -> void: map.snap_enabled = on)
add_child(_snap)
_snap_menu = MenuButton.new()
_snap_menu.text = "▾"
_snap_menu.flat = true
_snap_menu.focus_mode = Control.FOCUS_NONE
_snap_menu.tooltip_text = "Snapping options"
var sp := _snap_menu.get_popup()
sp.hide_on_checkable_item_selection = false
sp.add_check_item("Snap to grid", 0)
sp.add_separator("Units")
sp.add_radio_check_item("Quake units", 1)
sp.add_radio_check_item("Metres", 2)
sp.add_separator("Targets (placements)")
sp.add_check_item("Snap to vertices", 5)
sp.add_check_item("Snap to edges", 6)
sp.add_separator("While a tool is up")
sp.add_item("Ctrl — invert snapping", 3)
sp.set_item_disabled(sp.get_item_index(3), true)
sp.add_item("Shift — 1/%d steps" % int(map.PRECISION), 4)
sp.set_item_disabled(sp.get_item_index(4), true)
sp.about_to_popup.connect(_sync_snap_menu.bind(sp))
sp.id_pressed.connect(func(id: int) -> void:
match id:
0: map.snap_enabled = not bool(map.snap_enabled)
1: Settings.set_value("editor_units", 0)
2: Settings.set_value("editor_units", 1)
5: map.set_snap_target("vertex", not map.snap_target_enabled("vertex"))
6: map.set_snap_target("edge", not map.snap_target_enabled("edge"))
_sync_snap_menu(sp))
add_child(_snap_menu)
# THE OVERLAYS toggle + its popover (keymap step 6.5, §6a.5) — Blender 2.8's header
# pair, built like the snap one. The toggle IS Shift+Alt+Z; the rows run their keys'
# actions (Keymap.run), so a click and a key take one path.
if map.has_method("overlays_all_on"):
_overlays = Button.new()
_overlays.text = "Overlays"
_overlays.toggle_mode = true
_overlays.focus_mode = Control.FOCUS_NONE
_overlays.tooltip_text = _tip("builder.overlays_all")
_overlays.pressed.connect(activate.bind("builder.overlays_all"))
add_child(_overlays)
_overlays_menu = MenuButton.new()
_overlays_menu.text = "▾"
_overlays_menu.flat = true
_overlays_menu.focus_mode = Control.FOCUS_NONE
_overlays_menu.tooltip_text = "Overlays"
# DISPLAY-ONLY accelerators, as on the menu bar: a MenuButton fires an item on its
# accelerator, the item replays that key, and V looped forever (gate_overlays).
_overlays_menu.set_process_shortcut_input(false)
var op := _overlays_menu.get_popup()
op.hide_on_checkable_item_selection = false
var ov: Dictionary = map.get("OVERLAYS")
for name: String in ov:
_add_item(op, String((ov[name] as Array)[0]))
op.about_to_popup.connect(_sync_checks.bind(op))
# Keymap.run replays the key a frame later; re-read the checks once it has run.
op.id_pressed.connect(func(id: int) -> void:
activate(String(op.get_item_metadata(op.get_item_index(id))))
await get_tree().process_frame
await get_tree().process_frame
if is_instance_valid(op):
_sync_checks(op))
add_child(_overlays_menu)
var gl := Label.new()
gl.text = "Grid"
add_child(gl)
_grid = OptionButton.new()
_grid.focus_mode = Control.FOCUS_NONE
for u: float in map.GRID_UNITS:
_grid.add_item(String(map.fmt_units(u)))
_grid.tooltip_text = "Grid size (%s finer · %s coarser)" % [
Keymap.describe("builder.grid_finer"), Keymap.describe("builder.grid_coarser")]
_grid.item_selected.connect(func(i: int) -> void: map.set_grid_index(i))
add_child(_grid)
# Units are a display choice; relabel the dropdown when it changes.
Settings.setting_changed.connect(func(key: String, _v: Variant) -> void:
if key == "editor_units" and is_instance_valid(_grid):
for i in _grid.item_count:
_grid.set_item_text(i, String(map.fmt_units(map.GRID_UNITS[i]))))
_lock = CheckButton.new()
_lock.text = "Tex lock"
_lock.focus_mode = Control.FOCUS_NONE
_lock.tooltip_text = "Texture lock: moving or scaling a brush carries its textures with it"
_lock.toggled.connect(func(on: bool) -> void: map.tex_lock = on)
add_child(_lock)
# Everything after the menu bar is about brushes: select modes, snap, grid, lock.
var after_bar := false
for c in get_children():
if c == _bar:
after_bar = true
continue
if after_bar and c is Control:
_level_only.append(c as Control)
BuildDock.workspace_changed.connect(_apply_workspace)
_apply_workspace(BuildDock.workspace())
## Show the level-only parts in the level workspace, hide them elsewhere; mark the tab.
func _apply_workspace(name: String) -> void:
var lvl := name == "level"
for c: Control in _level_only:
if is_instance_valid(c):
c.visible = lvl
for i in _bar.get_menu_count():
if LEVEL_MENUS.has(_bar.get_menu_title(i)):
_bar.set_menu_hidden(i, not lvl)
for w: String in _tabs:
(_tabs[w] as Button).set_pressed_no_signal(w == name)
## Append an action to a menu (created if new). Extension point — a feature adds its
## command without editing MENUS.
func add_menu_item(menu: String, action: String) -> void:
if not _menus.has(menu):
var pm := PopupMenu.new()
pm.name = menu
_bar.add_child(pm)
_menus[menu] = pm
pm.id_pressed.connect(func(id: int) -> void: activate(String(pm.get_item_metadata(pm.get_item_index(id)))))
_add_item(_menus[menu], action)
func _add_item(pm: PopupMenu, action: String) -> void:
if action == "-":
pm.add_separator()
return
var b: Dictionary = Keymap.binding_of(action)
var id := pm.item_count
if action.begins_with("build_mode.sel_"):
pm.add_radio_check_item(String(b.get("label", action)), id)
elif action in ["builder.brush_view", "builder.clearance_view", "builder.entity_links",
"builder.grid_view", "builder.cutters_view", "builder.overlays_all",
"build_mode.csg_discard"]:
pm.add_check_item(String(b.get("label", action)), id)
else:
pm.add_item(String(b.get("label", action)), id)
var idx := pm.get_item_index(id)
pm.set_item_metadata(idx, action)
pm.set_item_accelerator(idx, accel_of(action))
## The accelerator a menu shows for an action — built from the LIVE binding.
func accel_of(action: String) -> int:
var b: Dictionary = Keymap.binding_of(action)
if b.is_empty() or b.has("mouse"):
return 0
var k := int(b.get("key", 0))
if bool(b.get("ctrl", false)):
k |= KEY_MASK_CTRL
if bool(b.get("shift", false)):
k |= KEY_MASK_SHIFT
if bool(b.get("alt", false)):
k |= KEY_MASK_ALT
return k
func _tip(action: String) -> String:
return "%s (%s)" % [String(Keymap.binding_of(action).get("label", action)), Keymap.describe(action)]
func activate(action: String) -> void:
Keymap.run(action)
func menus() -> Array:
return _menus.values()
func select_mode_button(mode: String) -> Button:
return _sel_buttons.get(mode)
func grid_option() -> OptionButton:
return _grid
func tex_lock_button() -> CheckButton:
return _lock
## Check marks on open: radio = the map's select mode, checks = the view toggles.
func _sync_checks(pm: PopupMenu) -> void:
for i in pm.item_count:
# A SEPARATOR has no metadata, and String(null) THROWS — which aborted this loop
# at the first "-", so every item below it never got its check or accelerator.
if pm.is_item_separator(i):
continue
var a := String(pm.get_item_metadata(i))
if a.begins_with("build_mode.sel_"):
pm.set_item_checked(i, a == "build_mode.sel_%s" % String(map.get("_sel_mode")))
elif a == "build_mode.csg_discard":
pm.set_item_checked(i, bool(map.get("csg_discard")))
elif a == "builder.overlays_all" and map.has_method("overlays_all_on"):
pm.set_item_checked(i, bool(map.overlays_all_on()))
elif _overlay_of(a) != "":
# An overlay row shows its OWN switch, greyed while the master is off (Blender).
pm.set_item_checked(i, bool(map.overlay_on(_overlay_of(a))))
pm.set_item_disabled(i, not bool(map.overlays_all_on()))
pm.set_item_accelerator(i, accel_of(a)) # a rebind shows on the next open
## The overlay an action toggles ("" if none) — from the map's OVERLAYS table.
func _overlay_of(action: String) -> String:
if map == null or not map.has_method("overlay_on"):
return ""
var ov: Dictionary = map.get("OVERLAYS")
for name: String in ov:
if String((ov[name] as Array)[0]) == action:
return name
return ""
## The bar FOLLOWS the map: keys change grid / select mode / lock without telling it.
func _process(_d: float) -> void:
if map == null or not is_visible_in_tree():
return
var gi := int(map.get("_grid_idx"))
if _grid.selected != gi:
_grid.select(gi)
var mode := String(map.get("_sel_mode"))
for m: String in _sel_buttons:
var b: Button = _sel_buttons[m]
if b.button_pressed != (m == mode):
b.set_pressed_no_signal(m == mode)
var lock := bool(map.get("tex_lock"))
if _lock.button_pressed != lock:
_lock.set_pressed_no_signal(lock)
var snap := bool(map.get("snap_enabled"))
if _snap.button_pressed != snap:
_snap.set_pressed_no_signal(snap)
if _overlays != null:
var ov := bool(map.overlays_all_on()) # Shift+Alt+Z flips it without telling us
if _overlays.button_pressed != ov:
_overlays.set_pressed_no_signal(ov)