OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / tool_settings_bar.gd

337 lines · gd ref:
extends HFlowContainer
## THE TOOL SETTINGS ROW (editor redesign E4, docs/editor-redesign.md §8b): the active
## tool's own settings, under the top bar — TrenchBroom's tool book, Blender's tool header,
## Godot's context toolbar slot.
##
## GENERATED FROM DATA. Every row is a LevelOperator.tool_settings() descriptor; this file
## knows how to draw a descriptor type, never what any tool's settings are. A new tool, or a
## new shape with its own params, shows up here with no edit to this file.
##
## IT FOLLOWS THE OPERATOR, like the top bar follows the map: keys ([ ], Tab, X Y Z) change
## values without telling the bar, so each frame it re-reads the descriptors and updates the
## controls without their signals (set_*_no_signal), and rebuilds only when the SHAPE of the
## list changes (another tool, or a shape whose params differ).
##
## FOCUS: buttons and dropdowns take none — a focused button eats the arrows before the map
## sees them (the UV "works a bit then stops" bug). A SpinBox's text field takes click focus
## so a value can be typed; Enter or Escape hands the keyboard straight back.

const UNIT_SUFFIX := {"deg": "°"}

var map: Node = null
var _stack: Node = null
var _op: LevelOperator = null
var _sig := ""
var _controls: Dictionary = {}       # setting id -> {type, node, group (enum buttons), extra}
var _more_btn: Button = null
var _more_pop: PopupPanel = null
var _more_box: GridContainer = null


func setup(map_node: Node) -> void:
	map = map_node
	add_theme_constant_override("h_separation", 6)
	add_theme_constant_override("v_separation", 4)
	_rebuild()


## ADJUST MODE (E5 Adjust Last): the same renderer pointed at a FIXED operator that is no
## longer active, showing only the settings it can re-run with, and routing changes to
## OperatorStack.adjust instead of the operator.
var target: LevelOperator = null
var apply_override: Callable = Callable()


func active_op() -> LevelOperator:
	if target != null:
		return target
	if _stack == null and map != null and map.has_method("_ensure_ops"):
		_stack = map.call("_ensure_ops")
	return _stack.active if _stack != null else null


func _rows(op: LevelOperator) -> Array:
	if op == null:
		return []
	var rows: Array = op.tool_settings()
	if target == null:
		return rows
	var keep: Array = op.adjust_ids()
	var out: Array = []
	for d: Dictionary in rows:
		if keep.has(String(d.get("id"))):
			out.append(d)
	return out


## The control drawn for a setting id (gates drive it).
func control(sid: String) -> Control:
	return (_controls.get(sid, {}) as Dictionary).get("node") as Control


func setting_ids() -> Array:
	return _controls.keys()


func _process(_d: float) -> void:
	if map == null or not is_visible_in_tree():
		return
	var op := active_op()
	var rows: Array = _rows(op)
	var sig := _signature(op, rows)
	if op != _op or sig != _sig:
		_op = op
		_sig = sig
		_rebuild(rows)
	else:
		_sync(rows)


static func _signature(op: LevelOperator, rows: Array) -> String:
	if op == null:
		return ""
	var parts: Array[String] = [op.op_name()]
	for d: Dictionary in rows:
		parts.append("%s:%s:%s" % [String(d.get("id")), String(d.get("type")), str(d.get("choices", []))])
	return "|".join(parts)


func _clear() -> void:
	for c in get_children():
		remove_child(c)
		c.queue_free()
	_controls.clear()
	_more_btn = null
	_more_pop = null
	_more_box = null


func _rebuild(rows: Array = []) -> void:
	_clear()
	if _op == null:
		if target == null:
			_idle_hint()
		return
	if target != null:
		rows = _rows(_op)                # adjust mode: never the tool's own action buttons
	var title := Label.new()
	title.text = _op.op_name().to_upper()
	title.add_theme_color_override("font_color", Color(1.0, 0.4, 0.35))
	add_child(title)
	for d: Dictionary in rows:
		if bool(d.get("more", false)):
			_ensure_more()
			_more_box.add_child(_label(d))
			_more_box.add_child(_make(d))
		else:
			if String(d.get("type")) != "action":
				add_child(_label(d))
			add_child(_make(d))
	if _more_btn != null:
		move_child(_more_btn, get_child_count() - 1)


## No tool up: name the tools and their LIVE keys, so the row teaches rather than sits empty.
func _idle_hint() -> void:
	var l := Label.new()
	var parts: Array[String] = []
	for a: Array in [["builder.add_menu", "add"], ["builder.knife", "knife"], ["builder.move", "move"],
			["builder.rotate", "rotate"], ["builder.scale", "scale"], ["builder.extrude", "extrude"]]:
		var k: String = Keymap.describe(String(a[0]))
		if k != "":
			parts.append("%s %s" % [k, String(a[1])])
	l.text = "No tool — " + " · ".join(parts)
	l.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
	# SHRINKS, never widens the band: an unclipped Label's minimum width is its whole text,
	# which pushed the top band past the gap between the columns of a 1280-px window
	# (gate_editor_e1). Ellipsis on a narrow window; the tooltip keeps the full line.
	l.clip_text = true
	l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
	l.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	l.custom_minimum_size = Vector2(160, 0)
	l.tooltip_text = l.text
	l.mouse_filter = Control.MOUSE_FILTER_PASS
	add_child(l)


func _ensure_more() -> void:
	if _more_btn != null:
		return
	_more_btn = Button.new()
	_more_btn.text = "⋯"
	_more_btn.flat = true
	_more_btn.focus_mode = Control.FOCUS_NONE
	_more_btn.tooltip_text = "More settings"
	_more_btn.pressed.connect(_on_more)
	add_child(_more_btn)
	_more_pop = PopupPanel.new()
	_more_box = GridContainer.new()
	_more_box.columns = 2
	_more_pop.add_child(_more_box)
	add_child(_more_pop)


func _on_more() -> void:
	var r := _more_btn.get_global_rect()
	_more_pop.popup(Rect2i(Vector2i(r.position + Vector2(0, r.size.y)), Vector2i.ZERO))


func _label(d: Dictionary) -> Label:
	var l := Label.new()
	l.text = String(d.get("label", d.get("id")))
	l.tooltip_text = String(d.get("tip", ""))
	l.mouse_filter = Control.MOUSE_FILTER_PASS
	return l


func _make(d: Dictionary) -> Control:
	var sid := String(d.get("id"))
	var tip := String(d.get("tip", ""))
	match String(d.get("type")):
		"enum":
			var choices: Array = d.get("choices", [])
			if choices.size() <= 4:
				var row := HBoxContainer.new()
				var group := ButtonGroup.new()
				var buttons: Array = []
				for i in choices.size():
					var b := Button.new()
					b.text = String(choices[i])
					b.toggle_mode = true
					b.button_group = group
					b.focus_mode = Control.FOCUS_NONE
					b.tooltip_text = tip
					b.button_pressed = int(d.get("value", 0)) == i
					b.pressed.connect(_on_enum.bind(sid, i))
					row.add_child(b)
					buttons.append(b)
				_controls[sid] = {"type": "enum_buttons", "node": row, "buttons": buttons}
				return row
			var ob := OptionButton.new()
			ob.focus_mode = Control.FOCUS_NONE
			ob.tooltip_text = tip
			for c in choices:
				ob.add_item(String(c))
			ob.select(int(d.get("value", 0)))
			ob.item_selected.connect(_on_option.bind(sid))
			_controls[sid] = {"type": "enum_option", "node": ob}
			return ob
		"int", "float":
			var row := HBoxContainer.new()
			var sb := SpinBox.new()
			sb.min_value = float(d.get("min", -1.0e9))
			sb.max_value = float(d.get("max", 1.0e9))
			sb.step = float(d.get("step", 1.0 if String(d.get("type")) == "int" else 0.01))
			sb.allow_greater = not d.has("max")
			sb.allow_lesser = not d.has("min")
			sb.suffix = String(UNIT_SUFFIX.get(String(d.get("unit", "")), ""))
			sb.tooltip_text = tip
			sb.custom_minimum_size = Vector2(76, 0)
			sb.set_value_no_signal(float(d.get("value", 0)))
			sb.focus_mode = Control.FOCUS_NONE
			sb.get_line_edit().focus_mode = Control.FOCUS_CLICK
			sb.get_line_edit().text_submitted.connect(_on_submitted.bind(sb))
			sb.value_changed.connect(_on_number.bind(sid))
			row.add_child(sb)
			# A grid-step count is read in the player's length unit beside it ("= 16u").
			var extra: Label = null
			if String(d.get("unit", "")) == "grid":
				extra = Label.new()
				extra.add_theme_color_override("font_color", Color(0.75, 0.75, 0.75))
				row.add_child(extra)
				_set_grid_text(extra, float(d.get("value", 0)))
			_controls[sid] = {"type": "number", "node": row, "spin": sb, "extra": extra}
			return row
		"bool":
			var cb := CheckButton.new()
			cb.text = ""
			cb.focus_mode = Control.FOCUS_NONE
			cb.tooltip_text = tip
			cb.button_pressed = bool(d.get("value", false))
			cb.toggled.connect(_on_bool.bind(sid))
			_controls[sid] = {"type": "bool", "node": cb}
			return cb
		"action":
			var b := Button.new()
			b.text = String(d.get("label", sid))
			b.focus_mode = Control.FOCUS_NONE
			b.tooltip_text = tip
			b.pressed.connect(_on_action.bind(sid))
			_controls[sid] = {"type": "action", "node": b}
			return b
	var unknown := Label.new()
	unknown.text = "?"
	return unknown


func _set_grid_text(l: Label, steps: float) -> void:
	if l == null or map == null or not map.has_method("fmt_units") or not map.has_method("grid_units"):
		return
	l.text = "= %s" % String(map.fmt_units(steps * float(map.grid_units())))


## Values follow the operator without firing the controls' own signals.
func _sync(rows: Array) -> void:
	for d: Dictionary in rows:
		var c: Dictionary = _controls.get(String(d.get("id")), {})
		if c.is_empty():
			continue
		match String(c["type"]):
			"enum_buttons":
				var buttons: Array = c["buttons"]
				for i in buttons.size():
					var b: Button = buttons[i]
					if b.button_pressed != (i == int(d.get("value", 0))):
						b.set_pressed_no_signal(i == int(d.get("value", 0)))
			"enum_option":
				var ob: OptionButton = c["node"]
				if ob.selected != int(d.get("value", 0)):
					ob.select(int(d.get("value", 0)))
			"number":
				var sb: SpinBox = c["spin"]
				# Not while the player is typing into it — that would overwrite the keystrokes.
				if not sb.get_line_edit().has_focus() and not is_equal_approx(sb.value, float(d.get("value", 0))):
					sb.set_value_no_signal(float(d.get("value", 0)))
				_set_grid_text(c.get("extra") as Label, float(d.get("value", 0)))
			"bool":
				var cb: CheckButton = c["node"]
				if cb.button_pressed != bool(d.get("value", false)):
					cb.set_pressed_no_signal(bool(d.get("value", false)))


func _apply(sid: String, v: Variant) -> void:
	if apply_override.is_valid():
		apply_override.call(sid, v)
		return
	var op := active_op()
	if op != null:
		op.set_tool_setting(sid, v)
		if map != null and map.has_method("_update_op_hud"):
			map.call("_update_op_hud")


func _on_enum(sid: String, i: int) -> void:
	_apply(sid, i)


func _on_option(i: int, sid: String) -> void:
	_apply(sid, i)


func _on_number(v: float, sid: String) -> void:
	_apply(sid, v)


func _on_bool(on: bool, sid: String) -> void:
	_apply(sid, on)


func _on_action(sid: String) -> void:
	_apply(sid, true)


## Enter in a typed value: apply it (SpinBox does on submit) and give the keyboard back.
func _on_submitted(_t: String, sb: SpinBox) -> void:
	sb.apply()
	sb.get_line_edit().release_focus()