OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / operators / modal_controls.gd

303 lines · gd ref:
extends RefCounted
class_name ModalControls
## ONE MODAL CONVENTION FOR EVERY TOOL (#120 F3, docs/node-tree-and-stacks.md §4,
## docs/cutting-and-operations.md §5 — HardOps base_modal_controls, BoxCutter's status bar).
##
## An operator DECLARES its keys as a table, and the table is both its status line and its
## help overlay (H), so the two cannot disagree — the same property the keymap's generated
## help has. The common rows (confirm, cancel, help, the value keys) are added here, so
## every tool says them the same way.
##
##   table rows   [keys, what they do, state Callable or null]  ("X Y Z", "axis", -> "X")
##   bind()       a VALUE the tool exposes: [ ] / the wheel step the focused one (Shift =
##                a tenth of a step), Tab moves the focus; the row shows its current value
##   summary()    a card that fades after an instant op ("Mirrored 3 brushes across X")
##
## The operator keeps its own handle_key for its own keys and asks handle_key() here first.
## Nothing here is written to the journal: values go through the operator's own setters,
## which preview; confirm is still the operator's one write.

const KeyRules := preload("res://autoloads/key_rules.gd")

## How long a summary card stays before it is gone.
const SUMMARY_MS := 2500

var confirm_label := "confirm"
var help_open := false
var _rows: Array = []                 # [keys, does, state]
var _values: Array = []               # {id, label, get, set, step, unit}
var _focus := 0


## The operator's own keys. Replaces the table (an operator calls this once, at setup).
func table(rows: Array) -> ModalControls:
	_rows = rows
	return self


## Expose a value: `getter() -> float`, `setter(float)`, one step, a unit suffix, and the
## Shift step (default a tenth; a value on the grid lattice passes its own step — finer
## than one grid step means nothing there).
##
## `step` may be a Callable too, for a step the tool lets you change (Rotate's sticky 15°).
##
## `signed`: an OFFSET (a distance, an angle, steps) reads "+7", as Blender's header does.
func bind(id: String, label: String, getter: Callable, setter: Callable, step: Variant,
		unit: Variant = "", fine: float = -1.0, signed := false) -> ModalControls:
	_values.append({"id": id, "label": label, "get": getter, "set": setter, "step": step, "unit": unit,
		"fine": fine, "signed": signed})
	return self


## Show (and step) value `id` only while `cond()` is true — Move's axis distance means
## nothing while it moves in a plane.
func only_when(id: String, cond: Callable) -> ModalControls:
	for v: Dictionary in _values:
		if String(v["id"]) == id:
			v["when"] = cond
	return self


static func _live(v: Dictionary) -> bool:
	return not v.has("when") or bool((v["when"] as Callable).call())


static func _step_of(v: Dictionary, finer: bool) -> float:
	var s: float = float((v["step"] as Callable).call()) if v["step"] is Callable else float(v["step"])
	if finer:
		return float(v["fine"]) if float(v["fine"]) > 0.0 else s * 0.1
	return s


func focused() -> String:
	return String(_values[_focus]["id"]) if not _values.is_empty() else ""


## Focus the bound value `id` (a tool's own letter for it, as Cablerator's Z / D / N / S
## jump to a value: [ ] then step that one). False if no value has that id.
func focus(id: String) -> bool:
	for i in _values.size():
		if String(_values[i]["id"]) == id:
			_focus = i
			return true
	return false


## MOUSE DRAG OF THE FOCUSED VALUE (#120 §17.7 Q2, Blender's): horizontal pixels x step /
## DRAG_PX; `finer` (Shift) a tenth, `snap` (Ctrl) to whole steps. It ACCUMULATES from the
## value the drag started at, so an integer value (its setter rounds) still moves on small
## motions; a change from elsewhere ([ ], the wheel, a typed value) restarts it there.
## The map calls this for a tool whose drags_value() is true. True if a value moved.
const DRAG_PX := 25.0
var _drag_id := ""
var _drag_from := 0.0
var _drag_px := 0.0
var _drag_last := 0.0


func drag(dx: float, finer: bool, snap: bool) -> bool:
	if _values.is_empty() or not _live(_values[_focus]) or is_zero_approx(dx):
		return false
	var v: Dictionary = _values[_focus]
	var now := float((v["get"] as Callable).call())
	if String(v["id"]) != _drag_id or not is_equal_approx(now, _drag_last):
		_drag_id = String(v["id"])
		_drag_from = now
		_drag_px = 0.0
	_drag_px += dx * (0.1 if finer else 1.0)
	var s := _step_of(v, false)
	var delta := _drag_px / DRAG_PX * s
	if snap and s > 0.0:
		delta = roundf(delta / s) * s
	(v["set"] as Callable).call(_drag_from + delta)
	_drag_last = float((v["get"] as Callable).call())
	return true


## ---- HANDLES INSIDE A TOOL (#120 §17.9) ----------------------------------------------
## A dot on the object that drags a bound value: `pos() -> Vector3 or null` (null = no dot
## now), `axis() -> Vector3` (the line it moves on), `value_at(start, metres) -> float`
## (the value for a drag of `metres` along the axis from the grab), `begin()` at the grab.
## Inside a tool LMB confirms, so the map asks grab_handle() first: a press within
## HANDLE_PX of a dot grabs it, motion drags it, and the release lets go WITHOUT confirming.
const HANDLE_PX := 14.0
var _handles: Array = []              # {id, value, pos, axis, value_at, begin}
var _grab: Dictionary = {}             # {h, start, at, axis, t0}


func handle(id: String, value_id: String, pos: Callable, axis: Callable, value_at: Callable,
		begin: Callable = Callable()) -> ModalControls:
	_handles.append({"id": id, "value": value_id, "pos": pos, "axis": axis, "value_at": value_at, "begin": begin})
	return self


## Every dot shown now (the map draws them over the tool's preview).
func handle_points() -> PackedVector3Array:
	var out := PackedVector3Array()
	for h: Dictionary in _handles:
		var p: Variant = (h["pos"] as Callable).call()
		if p is Vector3:
			out.append(p)
	return out


func grabbing() -> bool:
	return not _grab.is_empty()


## A press at screen point `at` (`cam` the 3D view's camera, `ray` the pointer's): grab the
## nearest dot within HANDLE_PX. True if one was grabbed.
func grab_handle(at: Vector2, cam: Camera3D, ray: Dictionary) -> bool:
	_grab = {}
	if cam == null or ray.is_empty():
		return false
	var best: Dictionary = {}
	var best_px := HANDLE_PX
	for h: Dictionary in _handles:
		var p: Variant = (h["pos"] as Callable).call()
		if not (p is Vector3) or cam.is_position_behind(p):
			continue
		var px := cam.unproject_position(p).distance_to(at)
		if px < best_px:
			best_px = px
			best = h
	if best.is_empty():
		return false
	if (best["begin"] as Callable).is_valid():
		(best["begin"] as Callable).call()
	var v := _value(String(best["value"]))
	if v.is_empty():
		return false
	var p0: Vector3 = (best["pos"] as Callable).call()
	var ax: Vector3 = ((best["axis"] as Callable).call() as Vector3).normalized()
	_grab = {"h": best, "v": v, "start": float((v["get"] as Callable).call()), "at": p0, "axis": ax}
	_grab["t0"] = _along(ray)
	return true


## The pointer moved while a dot is held: its value follows the ray's place on the axis.
func drag_handle(ray: Dictionary) -> void:
	if _grab.is_empty() or ray.is_empty():
		return
	var m := _along(ray) - float(_grab["t0"])
	var nv: float = ((_grab["h"] as Dictionary)["value_at"] as Callable).call(float(_grab["start"]), m)
	((_grab["v"] as Dictionary)["set"] as Callable).call(nv)


func release_handle() -> void:
	_grab = {}


## Where the ray passes the held dot's axis line, as metres along the axis from the grab.
func _along(ray: Dictionary) -> float:
	var a: Vector3 = _grab["at"]
	var u: Vector3 = _grab["axis"]
	var from: Vector3 = ray["from"]
	var cp: PackedVector3Array = Geometry3D.get_closest_points_between_segments(a - u * 10000.0, a + u * 10000.0,
		from, from + (ray["dir"] as Vector3) * 10000.0)
	return (cp[0] - a).dot(u)


func _value(id: String) -> Dictionary:
	for v: Dictionary in _values:
		if String(v["id"]) == id:
			return v
	return {}


## H (help), Tab (focus) and [ ] (the focused value). True if the key was one of these.
func handle_key(event: InputEvent) -> bool:
	if not (event is InputEventKey) or not (event as InputEventKey).pressed:
		return false
	var k := event as InputEventKey
	match KeyRules.key_of(k):
		KEY_H:
			if k.ctrl_pressed or k.alt_pressed:
				return false
			help_open = not help_open
			return true
		KEY_TAB:
			if _values.size() < 2:
				return false
			_focus = (_focus + (-1 if k.shift_pressed else 1) + _values.size()) % _values.size()
			return true
		KEY_BRACKETRIGHT, KEY_BRACKETLEFT:
			if _values.is_empty() or not _live(_values[_focus]):
				return false
			var v: Dictionary = _values[_focus]
			var step := _step_of(v, k.shift_pressed)
			var dir := 1.0 if KeyRules.key_of(k) == KEY_BRACKETRIGHT else -1.0
			(v["set"] as Callable).call(float((v["get"] as Callable).call()) + dir * step)
			return true
	return false


## Every row the tool answers to, its own first, then the values, then the common ones:
## [[keys, does, state], …] with state already a String ("" = none).
func rows() -> Array:
	var out: Array = []
	for r: Array in _rows:
		var st := ""
		if r.size() > 2 and r[2] is Callable and (r[2] as Callable).is_valid():
			st = str((r[2] as Callable).call())
		out.append([String(r[0]), String(r[1]), st])
	for i in _values.size():
		var v: Dictionary = _values[i]
		if not _live(v):
			continue
		var u: Variant = v["unit"]                  # a String, or a Callable (it can change: the grid)
		var x := float((v["get"] as Callable).call())
		var val := "%s%s%s" % ["+" if bool(v["signed"]) and x >= 0.0 else "", _num(x),
			str((u as Callable).call()) if u is Callable else String(u)]
		out.append(["[ ] / wheel" if i == _focus else "Tab", String(v["label"]) + (" (Shift finer)" if i == _focus else ""), val])
	if _values.size() >= 2:
		out.append(["Tab", "next value", focused()])
	out.append(["Enter / LMB", confirm_label, ""])
	out.append(["Esc / RMB", "cancel", ""])
	out.append(["H", "this help", "on" if help_open else ""])
	return out


static func _num(f: float) -> String:
	return String.num(f, 3) if absf(f - roundf(f)) > 1e-6 else str(int(roundf(f)))


## One line for the status bar: "X Y Z axis: X · [ ] width: 0.1m · count: 3 · Enter mirror ·
## Esc cancel · H help". A row with no keys is plain state ("pivot: median"); a value
## not in focus shows its value only; the Tab row is the overlay's.
func status_text() -> String:
	var parts: Array[String] = []
	for r: Array in rows():
		var keys := String(r[0]).get_slice(" / ", 0)
		var does := String(r[1]).get_slice(" (", 0)
		var st := String(r[2])
		if does == "next value":
			continue
		if keys == "Tab" and st != "":
			parts.append("%s: %s" % [does, st])           # a value not in focus
		elif keys == "":
			if st != "":                                  # a row with nothing to say now is not shown
				parts.append("%s: %s" % [does, st])
		elif st != "" and does != "this help":
			parts.append("%s %s: %s" % [keys, does, st])
		else:
			parts.append("%s %s" % [keys, does])
	return " · ".join(parts)


## The help overlay: one row per line, keys in amber, state inline.
func help_text(title: String) -> String:
	var out := "[b]%s[/b]\n" % title
	for r: Array in rows():
		out += "[color=#ffb84d]%-12s[/color] %s%s\n" % [String(r[0]), String(r[1]),
			("  [color=#7fe3b0]%s[/color]" % String(r[2])) if String(r[2]) != "" else ""]
	return out.strip_edges()


## A fading card after an instant op (BuildHud draws it; "" from a stub tree).
static func summary(text: String) -> void:
	var tree := Engine.get_main_loop() as SceneTree
	var hud: Node = tree.root.get_node_or_null("BuildHud") if tree != null else null
	if hud != null and hud.has_method("show_summary"):
		hud.show_summary(text, SUMMARY_MS)