extends CanvasLayer
## THE BUILD-MODE UI — visible while building, generated from the keymap.
##
## Build mode had no interface at all: it announced itself with print statements to a
## terminal the player cannot see, and the only way to know what a key did was to read
## the source. So this is the mode's actual surface — what mode you are in, and what
## you can do right now.
##
## IT IS GENERATED, exactly like the F1 help screen, and for the same reason: a
## hand-written panel is a second claim about what the keys are, and it is wrong the
## first time one moves. Both read Keymap.bindings(), so the UI and the hotkeys cannot
## disagree — which is the property asked for ("it should line up with the hotkey
## options we have"), and it is a property of where the data comes from rather than of
## anyone remembering to update two files.
##
## It shows the ACTIVE contexts only. In build mode that is global + builder +
## build_mode, and the player row is gone — which is honest, because the exclusive
## context really has taken your legs away.
var _operator: String = ""
## What `B` will pick and therefore what `G` will move. A mode you were not told you
## were in is the failure mode modes have — the same reason the operator line exists.
var _select_mode: String = ""
var _title: Label = null
var _text: RichTextLabel = null
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
_build()
GameMode.mode_changed.connect(func(_m: String) -> void: _render())
GameMode.mode_changed.connect(_on_mode_for_first_run)
# CURSOR and FLY are exactly the sort of mode you must be told you are in —
# "my clicks do nothing" is indistinguishable from a broken panel otherwise —
# so the status line re-renders on the pointer sub-mode too.
GameMode.build_submode_changed.connect(func(_s: String) -> void: _render())
_render()
## TWO PANELS, NOT ONE, and the split is the point.
##
## This used to be a single top-right overlay with its own chrome, its own anchor
## and no height bound, so it grew off the bottom of the screen as the keymap grew.
## It is now content in BuildDock, which owns all of that — but it cannot simply
## become one collapsible section, because half of it must never be collapsed:
##
## STATUS which mode, which select mode, which operator is live. A modal tool
## that does not announce itself is the failure mode modes have, and
## hiding it behind a disclosure triangle recreates that exactly.
## HOTKEYS the generated key reference. Long, read occasionally, and the thing
## that was making the panel outgrow the window. Collapsed by default.
##
## Both are still GENERATED from Keymap.bindings(), which is the property that
## matters: a hand-written list is a second claim about what the keys are and it
## is wrong the first time one moves.
func _build() -> void:
# STATUS MOVED TO THE STATUS BAR (editor redesign E1): a fixed row under the bottom
# region that never collapses — the property the paragraph above asked of it — and
# that now also carries the generated key hints. _title stays as the model of the
# mode line; it is no longer a dock panel.
_title = Label.new()
_text = RichTextLabel.new()
_text.bbcode_enabled = true
_text.fit_content = true
_text.custom_minimum_size = Vector2(BuildDock.DOCK_WIDTH - 40, 0)
BuildDock.register("hotkeys", "Hotkeys", _text, false)
## The active operator and its current setting. A modal tool must announce itself —
## being in a mode you were not told about is the failure mode modes have.
func set_operator(line: String) -> void:
if line == _operator:
return
_operator = line
_render()
func set_select_mode(mode: String) -> void:
if mode == _select_mode:
return
_select_mode = mode
_render()
## Rebuilt on show rather than cached: a rebind between openings must be reflected,
## and the cost is a few dozen string joins.
func _render() -> void:
# THE MODE IS IN THE TITLE, not only in the key list. It is sticky and it changes
# what every click and every G does — selecting "brush" and forgetting reads
# exactly like "vertex selection is broken, it grabs the whole brush".
_title.text = _operator
if _operator == "":
_title.text = "BUILD — SELECT %s" % _select_mode.to_upper() if _select_mode != "" else "BUILD MODE"
# The pointer sub-mode belongs in the title for the same reason the select mode
# does: it silently changes what the mouse does, and "my clicks do nothing" is
# indistinguishable from a broken panel if nothing says you are in FLY.
if GameMode.is_building():
_title.text += " [%s]" % ("CURSOR" if GameMode.is_cursor() else "FLY")
# An active operator turns the header red, so "a tool is live" is legible without
# reading the words.
_title.add_theme_color_override("font_color",
Color(1.0, 0.4, 0.35) if _operator != "" else Color(1.0, 0.7, 0.2))
_render_status()
var head := ""
if _select_mode != "":
head = "[color=#7fe3b0]select: %s[/color]\n" % _select_mode.to_upper()
var by_context: Dictionary = {}
for b: Dictionary in Keymap.bindings():
var c: String = String(b["context"])
# ONLY WHAT IS REACHABLE. Listing suppressed player actions would be a lie:
# build_mode is exclusive, so `jump` genuinely does nothing here.
if not Keymap.is_context_active(c):
continue
if String(b["keys"]) == "":
continue
if not by_context.has(c):
by_context[c] = []
(by_context[c] as Array).append(b)
var out := ""
for c: String in ["builder", "build_mode", "global"]:
if not by_context.has(c):
continue
var rows: Array = by_context[c]
rows.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return String(a["label"]) < String(b["label"]))
out += "\n[color=#c9a227]%s[/color]\n" % c.to_upper()
for b: Dictionary in rows:
out += " [color=#ffb84d]%-12s[/color] [color=#ddd]%s[/color]\n" % [
String(b["keys"]), String(b["label"])]
_text.text = head + out.strip_edges()
# ---- the status bar (editor redesign E1) ---------------------------------------
## The editing map supplies what only it knows — grid, selection — as a Callable
## returning {grid: String, sel: int}. Null on maps without the builder.
var info_provider: Callable = Callable()
## Idle hints: the verbs used most, each shown with its LIVE key. Generated, never
## typed: a rebind changes the bar the next frame.
## ORDER = PRIORITY: the bar is clipped to the bottom band's width, so the ways to find
## everything else (F1 keys, F3 search, Shift+Space tools) come first — measured in a
## 1600 px window, only the first few hints are visible.
const IDLE_HINTS: Array = [
["global.help", "all keys"], ["builder.palette", "search"], ["builder.tool_popup", "tools"],
# Keymap step 3: shapes come from the add menu (Shift+A); LMB selects.
["builder.add_menu", "add"], ["builder.select_box", "box select"], ["builder.knife", "knife"],
["builder.move", "move"], ["builder.extrude", "extrude"], ["builder.texture_apply", "texture"],
["builder.undo", "undo"],
]
## MODIFIER HINT (editor redesign E3e, which-key style — the gap TrenchBroom leaves to its
## manual): hold Ctrl / Shift / Alt this long and the bar lists what it means RIGHT NOW
## and every chord with it in the active contexts. Not while looking (RMB held): Shift
## is fly-fast then, and you are not reading the bar.
const MOD_HINT_MS := 300
var _mod := ""
var _mod_since := 0
var _status_left_text := ""
var _status_right_text := ""
func status_text() -> String:
return _status_left_text
func status_right() -> String:
return _status_right_text
func _process(_d: float) -> void:
# Grid and selection change without telling the HUD; re-reading them is a dict and
# two string compares per frame, and only while building.
if GameMode.is_building():
_render_status()
_render_readout()
_fade_summary()
# ---- the live readout (editor redesign E4) ------------------------------------
## The editing map supplies the active tool's readout() ("+64u X", "+90° Y"); drawn just
## under the crosshair, where you are looking while you drag — Godot's set_message,
## Blender's header text, moved to the eye.
var readout_provider: Callable = Callable()
var _readout: Label = null
func readout_text() -> String:
return _readout.text if _readout != null and _readout.visible else ""
func _render_readout() -> void:
var t := ""
if GameMode.is_building() and readout_provider.is_valid():
t = String(readout_provider.call())
if t == "" and _readout == null:
return
if _readout == null:
_readout = Label.new()
_readout.set_anchors_preset(Control.PRESET_CENTER)
_readout.grow_horizontal = Control.GROW_DIRECTION_BOTH
_readout.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_readout.position = Vector2(-160, 28) # just below the crosshair
_readout.custom_minimum_size = Vector2(320, 0)
_readout.mouse_filter = Control.MOUSE_FILTER_IGNORE
_readout.add_theme_color_override("font_color", Color(1.0, 0.85, 0.4))
_readout.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
_readout.add_theme_constant_override("outline_size", 4)
var holder := Control.new()
holder.set_anchors_preset(Control.PRESET_FULL_RECT)
holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(holder)
holder.add_child(_readout)
_readout.text = t
_readout.visible = t != ""
func _key(action: String) -> String:
return "[color=#ffb84d]%s[/color]" % Keymap.describe(action)
## ---- THE MODAL CONVENTION's surfaces (#120 F3, ModalControls) ----------------
## The active tool's HELP (H): its key table with the state inline, top-left over the
## view while open. And the SUMMARY card after an instant op, fading out.
var _help: RichTextLabel = null
var _summary: Label = null
var _summary_until := 0
func set_tool_help(text: String) -> void:
if text == "" and _help == null:
return
if _help == null:
_help = RichTextLabel.new()
_help.bbcode_enabled = true
_help.fit_content = true
_help.custom_minimum_size = Vector2(380, 0)
_help.position = Vector2(24, 96)
_help.mouse_filter = Control.MOUSE_FILTER_IGNORE
_help.add_theme_color_override("default_color", Color(0.92, 0.92, 0.92))
_help.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
_help.add_theme_constant_override("outline_size", 4)
add_child(_help)
_help.text = text
_help.visible = text != ""
func tool_help_text() -> String:
return _help.text if _help != null and _help.visible else ""
func show_summary(text: String, ms: int = 2500) -> void:
if _summary == null:
_summary = Label.new()
_summary.set_anchors_preset(Control.PRESET_CENTER)
_summary.grow_horizontal = Control.GROW_DIRECTION_BOTH
_summary.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_summary.position = Vector2(-200, 64)
_summary.custom_minimum_size = Vector2(400, 0)
_summary.mouse_filter = Control.MOUSE_FILTER_IGNORE
_summary.add_theme_color_override("font_color", Color(0.6, 1.0, 0.75))
_summary.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
_summary.add_theme_constant_override("outline_size", 4)
var holder := Control.new()
holder.set_anchors_preset(Control.PRESET_FULL_RECT)
holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(holder)
holder.add_child(_summary)
_summary.text = text
_summary.modulate.a = 1.0
_summary.visible = true
_summary_until = Time.get_ticks_msec() + ms
func summary_text() -> String:
return _summary.text if _summary != null and _summary.visible else ""
func _fade_summary() -> void:
if _summary == null or not _summary.visible:
return
var left := _summary_until - Time.get_ticks_msec()
if left <= 0:
_summary.visible = false
elif left < 600:
_summary.modulate.a = float(left) / 600.0
## FIRST-RUN CARD (editor redesign E3f, §5.7): the first time a player enters build mode,
## name the four ways in — F1 keys, RMB hold/tap, F3 search, Shift+Space tools — with
## their LIVE keys. Dismissed once, remembered per player (BuildDock pref
## first_run_seen); leaving build mode hides it without dismissing.
var _card: PanelContainer = null
var _card_text: RichTextLabel = null
func _on_mode_for_first_run(mode: String) -> void:
if mode != GameMode.BUILD:
if _card != null:
_card.visible = false
return
if bool(BuildDock.pref("first_run_seen", false)):
return
if _card == null:
_build_card()
_card_text.text = first_run_text()
_card.visible = true
func first_run_visible() -> bool:
return _card != null and _card.visible
func first_run_text() -> String:
return ("[b]Build mode[/b]\n"
+ "%s every key, searchable\n" % _key("global.help")
+ "[color=#ffb84d]RMB[/color] hold to look and fly (WASD) · tap for a menu\n"
+ "%s fly mode (Enter keeps the view, Esc goes back)\n" % _key("builder.fly")
+ "%s search every command\n" % _key("builder.palette")
+ "%s tools at the cursor\n" % _key("builder.tool_popup")
+ "hold [color=#ffb84d]Ctrl[/color] / [color=#ffb84d]Shift[/color] the status bar says what they do")
func dismiss_first_run() -> void:
if _card != null:
_card.visible = false
BuildDock.set_pref("first_run_seen", true)
func _build_card() -> void:
_card = PanelContainer.new()
_card.set_anchors_preset(Control.PRESET_CENTER)
_card.custom_minimum_size = Vector2(420, 0)
_card.position = Vector2(-210, -120)
var box := VBoxContainer.new()
_card.add_child(box)
_card_text = RichTextLabel.new()
_card_text.bbcode_enabled = true
_card_text.fit_content = true
_card_text.custom_minimum_size = Vector2(400, 0)
box.add_child(_card_text)
var ok := Button.new()
ok.text = "Got it"
ok.focus_mode = Control.FOCUS_NONE
ok.pressed.connect(dismiss_first_run)
box.add_child(ok)
var holder := Control.new()
holder.set_anchors_preset(Control.PRESET_FULL_RECT)
holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(holder)
holder.add_child(_card)
## What a held modifier does now: its own meaning (the fly camera's, or the tool's while
## one is up — keymap.md rule 1c) and every chord with it whose context is active.
func modifier_hint(mod: String) -> String:
var tool_up := Keymap.is_context_active("tool")
# Keymap step 1: Ctrl / Shift fly the camera ONLY while flying (RMB held / Shift+F),
# and this hint is not shown then — so alone, with no tool up, they mean nothing.
var own: Dictionary = {
"ctrl": "invert snapping" if tool_up else "",
"shift": "1/%d steps" % 8 if tool_up else "",
"alt": "",
}
var name: String = {"ctrl": "Ctrl", "shift": "Shift", "alt": "Alt"}[mod]
var parts: Array[String] = []
if String(own[mod]) != "":
parts.append("alone: %s" % own[mod])
for b: Dictionary in Keymap.bindings():
var keys := String(b["keys"])
var action := String(b["action"])
if not keys.begins_with(name + "+") or action.begins_with("debug.") or action == "toggle_metrics":
continue
if not Keymap.is_context_active(String(b["context"])):
continue
var tail := keys.substr(name.length() + 1)
if tail.begins_with("F") and tail.length() <= 3 and tail.substr(1).is_valid_int():
continue # Ctrl+F-key diagnostics: not editing chords
parts.append("[color=#ffb84d]%s[/color] %s" % [keys, String(b["label"])])
return "[b]%s[/b] %s" % [name, " · ".join(parts)]
## Which single modifier is held long enough to explain itself; "" otherwise.
func _held_modifier() -> String:
var held: Array[String] = []
if Input.is_key_pressed(KEY_CTRL):
held.append("ctrl")
if Input.is_key_pressed(KEY_SHIFT):
held.append("shift")
if Input.is_key_pressed(KEY_ALT):
held.append("alt")
var m := held[0] if held.size() == 1 else ""
if m != _mod:
_mod = m
_mod_since = Time.get_ticks_msec()
if m == "" or not GameMode.wants_pointer() or Time.get_ticks_msec() - _mod_since < MOD_HINT_MS:
return ""
return m
## The active keymap contexts in words, innermost last: "BUILD · faces · tool: Move",
## "BUILD · FLYING", "BUILD · MENU". (Always and the editor-tools layer are implied.)
const STRIP_NAMES := {"player": "PLAY", "build_mode": "BUILD", "face_sel": "faces", "tool": "tool",
"fly": "FLYING", "menu": "MENU", "text_input": "TYPING"}
func context_strip() -> String:
var parts: Array[String] = []
for c: String in Keymap.active_contexts():
if not STRIP_NAMES.has(c):
continue
var w := String(STRIP_NAMES[c])
if c == "tool" and _operator != "":
w = "tool: %s" % _operator.get_slice(" ", 0)
parts.append(w)
return " · ".join(parts)
func _render_status() -> void:
var left := ""
var mod := _held_modifier()
if mod != "":
left = modifier_hint(mod)
elif _operator != "":
# A TOOL IS UP: what it is, and the keys that end it. Ctrl/Shift/Space are the
# tool's now (keymap.md rule 1c) — say how to fly anyway.
left = "[color=#ff6659][b]%s[/b][/color] %s confirm · %s cancel · hold RMB fly" % [
_operator, _key("builder.confirm"), "[color=#ffb84d]Esc[/color]"]
else:
# The first three IDLE_HINTS (how to find everything) lead, then the mouse, then
# the verbs — the bar clips at the band's width, so the order is the priority.
var parts: Array[String] = ["[b]%s[/b]" % _title.text]
var mouse: Array[String] = ["[color=#ffb84d]LMB[/color] pick",
"[color=#ffb84d]RMB[/color] look / tap menu", "[color=#ffb84d]Shift+LMB[/color] add"]
for i in IDLE_HINTS.size():
if i == 3:
parts.append_array(mouse)
var h: Array = IDLE_HINTS[i]
if Keymap.describe(String(h[0])) != "":
parts.append("%s %s" % [_key(String(h[0])), String(h[1])])
left = " · ".join(parts)
# THE CONTEXT STRIP (keymap step 4, docs/keymap-blender-279.md §4a) leads the right
# side: the contexts a key is about to be read in, in words.
var right := context_strip()
if info_provider.is_valid():
var info: Dictionary = info_provider.call()
right += " · grid %s · %d selected" % [String(info.get("grid", "?")), int(info.get("sel", 0))]
if left != _status_left_text or right != _status_right_text:
_status_left_text = left
_status_right_text = right
BuildDock.set_status(left, right)