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)