OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / editor_top_bar.gd

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