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 ""]