OverviewFilesCommitsIssuesDocsReleasesPeers

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

187 lines · gd ref:
extends Node
class_name OperatorStack
## The chain: at most one operator active, Escape first-wins.
##
## "At most one" is a decision, not a limitation. TrenchBroom can have a tool active
## while a drag is in progress, and its chain exists to arbitrate that. Here the
## camera is a flying body and the only pointer is where you look, so two active
## operators would be two things claiming the same aim — there is nothing to arbitrate
## between, only ambiguity to invent.

signal changed(op: LevelOperator)

var active: LevelOperator = null


## Start an operator, replacing whatever was running. The replaced one is CANCELLED,
## not confirmed: switching tools is not agreement to commit what you were doing.
func activate(op: LevelOperator) -> bool:
	if active == op:
		return true
	cancel()
	if not op.on_activate():
		return false                     # refused: nothing to act on
	active = op
	_keymap_tool(true)
	changed.emit(active)
	return true


func cancel() -> void:
	if active == null:
		return
	var was := active
	active = null
	_keymap_tool(false)
	was.on_deactivate()
	changed.emit(null)


## The `tool` keymap context lives exactly as long as an operator is active: while it
## is up, Ctrl / Shift / Space are the tool's, not the fly camera's (editor redesign
## E0). By path, not the autoload identifier, so --script gates still compile.
func _keymap_tool(on: bool) -> void:
	var km := get_node_or_null("/root/Keymap")
	if km == null:
		return
	if on:
		km.push_context("tool")
	else:
		km.pop_context("tool")


## Commit, then leave the operator. Confirming is the end of the interaction — staying
## active afterwards would make the next Escape look like it cancels something.
##
## ONE GESTURE (E5): everything the confirm writes shares a gesture id, so one Ctrl+Z takes
## it all back. And if the operator supports ADJUST LAST, the placement it confirmed from is
## kept so its settings can be changed afterwards (adjust()).
func confirm() -> bool:
	if active == null:
		return false
	var op := active
	var persist := _persist_of(op)
	var snap: Dictionary = op.adjust_snapshot() if not op.adjust_ids().is_empty() else {}
	if persist != null:
		persist.begin_gesture(op.gesture_label())
	var wrote := op.confirm()
	if persist != null:
		persist.end_gesture()
	cancel()
	if wrote and persist != null and not snap.is_empty():
		_set_adjust(op, String(persist.last_gesture()), snap)
	elif wrote:
		_set_adjust(null, "", {})
	return wrote


# ---- adjust last (editor redesign E5) -------------------------------------------

## The last confirm that can be adjusted: {op, g, snap}, or empty.
signal adjust_changed(op: LevelOperator)
var _adjust: Dictionary = {}


var _adjusting := false


## True while adjust() is re-running — folds seen now are its own, not a stale offer.
func adjusting() -> bool:
	return _adjusting


func adjustable() -> LevelOperator:
	return _adjust.get("op") as LevelOperator if not _adjust.is_empty() else null


func adjust_gesture() -> String:
	return String(_adjust.get("g", ""))


## Still valid only while that gesture is the player's newest live one — any other edit,
## an undo, or a peer-visible change of it ends the offer.
func adjust_valid() -> bool:
	var op := adjustable()
	if op == null:
		return false
	var persist := _persist_of(op)
	return persist != null and String(persist.newest_live_gesture()) == adjust_gesture()


func clear_adjust() -> void:
	_set_adjust(null, "", {})


func _set_adjust(op: LevelOperator, g: String, snap: Dictionary) -> void:
	var was := adjustable()
	_adjust = {} if op == null else {"op": op, "g": g, "snap": snap}
	if was != op or op != null:
		adjust_changed.emit(op)


## Change one setting of the last operation: undo its gesture, re-run the operator from the
## stored placement with the new value, as a NEW gesture. Records replicate, so this is never
## an in-place edit (docs/editor-redesign.md §4a). True if the re-run wrote.
func adjust(sid: String, value: Variant) -> bool:
	if not adjust_valid():
		clear_adjust()
		return false
	var op := adjustable()
	var persist := _persist_of(op)
	var old_g := adjust_gesture()
	# The refold below emits `folded` while the old gesture is undone, which is exactly
	# when a listener would decide the offer is stale. adjusting() says it is ours.
	_adjusting = true
	persist.undo_gesture(old_g)
	persist.refold()
	op.adjust_restore(_adjust["snap"])
	op.set_tool_setting(sid, value)
	var snap := op.adjust_snapshot()
	persist.begin_gesture(op.gesture_label())
	var wrote := op.confirm()
	persist.end_gesture()
	op.adjust_end()
	_adjusting = false
	if wrote:
		_set_adjust(op, String(persist.last_gesture()), snap)
	else:
		# THE RE-RUN WROTE NOTHING (a 0° turn, a refused plane set): put the original back
		# rather than leave the player with neither. The offer stays on the original.
		persist.redo_gesture(old_g)
		persist.refold()
	return wrote


func _persist_of(op: LevelOperator) -> Object:
	if op == null or op.map == null:
		return null
	var p: Variant = op.map.get("_persist")
	return p as Object if p is Object and (p as Object).has_method("begin_gesture") else null


## Escape, first-wins: an active operator eats it, otherwise the caller keeps it (and
## Escape goes on to mean release-mouse / menu, exactly as it did before).
func handle_escape() -> bool:
	if active == null:
		return false
	cancel()
	return true


func handle_key(event: InputEvent) -> bool:
	return active != null and active.handle_key(event)


func update_preview() -> void:
	if active != null:
		active.update_preview()


func status_line() -> String:
	if active == null:
		return ""
	var s := active.status()
	var note: String = active.snap_note() if active.has_method("snap_note") else ""
	return "%s%s%s" % [active.op_name().to_upper(),
		("  [" + note + "]") if note != "" else "", ("  " + s) if s != "" else ""]