singe/assets/Forge/Forge.singe
2026-09-13 22:06:39 -05:00

859 lines
25 KiB
Text

--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- Forge: the authoring tool (PLAN section 58).
--
-- It is a Singe game. Nothing here is a preview: the canvas is the same overlay, at the same
-- coordinates, that the game will be played in, so what is placed is what is seen.
--
-- The layout comes from the spike recorded in that section. Chrome is an RmlUi document, which is
-- what RmlUi is good at; the canvas beside it is drawn into the overlay and picked with
-- collidePointRect, which is what Singe has always done with a pointer and a set of rectangles.
-- They compose because the engine offers a button to the GUI first and passes on what it did not
-- use, while pointer motion is never consumed at all.
-- Framework is wanted for SCANCODE, which the key handling below names; AuthorCompile brings in
-- the runtime and the manifest. A generated game loads Framework for itself, so the editor only
-- discovered it needed it when the rule editor started naming keys.
dofile("Singe/Framework.singe")
dofile("Forge/AuthorCompile.singe")
local lfs = require("lfs")
FORGE = {
game = nil, -- The description being edited.
path = nil,
selected = nil, -- Index into game.entities.
dirty = false,
gui = nil,
document = nil,
held = nil, -- The entity being dragged.
grabX = 0,
grabY = 0,
panelX = 0, -- The chrome slides sideways so nothing is permanently under it.
panelDrag = nil, -- Offset from the panel's left edge while it is being dragged.
mode = "entities", -- or "rules". The panel is narrow; one list at a time reads better.
rule = 1, -- Selected rule.
part = 0, -- Selected condition or action within it; 0 is the rule itself.
message = "loaded"
}
local PANEL_W = 190 -- Matches AuthorEdit.rml; the canvas starts to the right of it.
local HANDLE = 6 -- Half the size of the square drawn at an entity's own position.
local TAB_W = 16 -- The panel's drag tab, drawn on the canvas just outside its edge.
local TAB_H = 54
local TAB_Y = 10
local function element(id)
return rmlui.contexts["gui" .. FORGE.gui].documents["forge"]:GetElementById(id)
end
-- An entity's box in overlay coordinates. A look without a size still gets one, so that a text
-- entity can be picked up and moved like anything else.
function forgeBounds(entity)
local w = (entity.look and entity.look.w) or 90
local h = (entity.look and entity.look.h) or 20
return entity.x - w / 2, entity.y - h / 2, w, h
end
function forgeSelect(index)
FORGE.selected = index
forgeRefresh()
end
-- The topmost entity under a point, so overlapping things pick the one drawn last.
function forgePick(x, y)
local index
for index = #FORGE.game.entities, 1, -1 do
local bx, by, bw, bh = forgeBounds(FORGE.game.entities[index])
if collidePointRect(x, y, bx, by, bw, bh) then
return index
end
end
return nil
end
-- Text on its way into the document, with RML's special characters made harmless. A rule's note
-- and a Lua action's code both come from the author and both can contain them.
local function escape(text)
local out = tostring(text or "")
out = string.gsub(out, "&", "&amp;")
out = string.gsub(out, "<", "&lt;")
out = string.gsub(out, ">", "&gt;")
return out
end
local function refreshEntities()
local rows = {}
local index
for index, entity in ipairs(FORGE.game.entities) do
local class = (index == FORGE.selected) and "row selected" or "row"
rows[#rows + 1] = string.format('<div id="e%d" class="%s">%s</div>', index, class, escape(entity.id))
end
element("list").inner_rml = table.concat(rows)
for index = 1, #FORGE.game.entities do
guiSetHandler(FORGE.gui, FORGE.document, "e" .. index, "click", function()
forgeSelect(index)
end)
end
if FORGE.selected ~= nil then
local entity = FORGE.game.entities[FORGE.selected]
element("detail").inner_rml = string.format("%s<br/>x %d y %d<br/>%s", escape(entity.id), entity.x or 0, entity.y or 0, escape(FORGE.message))
else
element("detail").inner_rml = "nothing selected<br/>" .. escape(FORGE.message)
end
end
-- The event sheet. The selected rule is opened in place and its conditions and actions listed
-- under it, because a rule only means anything as a whole: seeing "when" without "then" is no use.
local function refreshRules()
local rows = {}
local index
local rule
for index, rule in ipairs(FORGE.game.rules or {}) do
local class = (index == FORGE.rule) and "row selected" or "row"
rows[#rows + 1] = string.format('<div class="%s">%d. %s</div>', class, index, escape(rule.note or "rule"))
if index == FORGE.rule then
local parts = forgeParts(rule)
local at
for at, part in ipairs(parts) do
local mark = (at == FORGE.part) and "part here" or "part"
rows[#rows + 1] = string.format('<div class="%s">%s %s</div>', mark, (part.kind == "when") and "when" or "then", escape(forgePartText(part.item)))
end
if #parts == 0 then
rows[#rows + 1] = '<div class="part">(empty)</div>'
end
end
end
if #rows == 0 then
rows[1] = '<div class="part">no rules yet</div>'
end
element("list").inner_rml = table.concat(rows)
element("detail").inner_rml = string.format("rule %d of %d<br/>%s", FORGE.rule, #(FORGE.game.rules or {}), escape(FORGE.message))
end
function forgeRefresh()
if FORGE.gui == nil then
return
end
element("title").inner_rml = escape(FORGE.game.title or "Untitled") .. (FORGE.dirty and " *" or "")
element("grip").inner_rml = (FORGE.mode == "rules") and "&#8801; Rules" or "&#8801; Entities"
if FORGE.mode == "rules" then
refreshRules()
else
refreshEntities()
end
end
function forgeBegin(path)
FORGE.path = path
FORGE.game = authorLoad(path)
if FORGE.game == nil then
return false
end
FORGE.gui = guiNew(overlayGetWidth(), overlayGetHeight())
FORGE.document = guiLoad(FORGE.gui, "Forge/Forge.rml")
guiSetInput(FORGE.gui, true)
guiSetHandler(FORGE.gui, FORGE.document, "grip", "mousedown", function()
local x = mouseGetPosition(0)
forgePanelGrab(x)
end)
guiSetHandler(FORGE.gui, FORGE.document, "grip", "mouseup", function()
FORGE.panelDrag = nil
FORGE.message = "panel at " .. FORGE.panelX
forgeRefresh()
end)
forgePanelTo(0)
forgeRefresh()
return true
end
-- Moves the selected entity. The description is the truth; nothing is cached anywhere else, so
-- saving is only writing it back out.
function forgeMove(index, x, y)
local entity = FORGE.game.entities[index]
entity.x = math.floor(x)
entity.y = math.floor(y)
FORGE.dirty = true
forgeRefresh()
end
function forgeSave()
authorSave(FORGE.game, FORGE.path)
FORGE.dirty = false
FORGE.message = "saved"
forgeRefresh()
return FORGE.path
end
-- Everything a released game needs, in a directory of its own: the compiled script, the runtime
-- it loads, its games.dat, and the description it was built from so it can be opened again.
--
-- Named export rather than release because forgeRelease is the mouse button coming up; "release"
-- is a verb here and a noun there, and the two collided.
--
-- The runtime is copied out of Forge, which is the only place it exists: nothing of Forge ships
-- inside Singe, so a game carries the copy it needs and stands on its own from then on.
--
-- The result is a directory --pack turns into a .game, and it runs on a machine that has never had
-- Forge on it.
function forgeExport(folder, name)
local title = FORGE.game.title or "Untitled"
local script = name or string.gsub(title, "[^%w]", "")
local dat
if script == "" then
script = "Game"
end
-- lfs.mkdir answers false when the directory is already there, which is not a failure.
lfs.mkdir(folder)
if lfs.attributes(folder, "mode") ~= "directory" then
FORGE.message = "cannot make " .. folder
forgeRefresh()
return nil
end
FORGE.game.source = FORGE.path
local file = io.open(folder .. "/" .. script .. ".singe", "w")
if file == nil then
FORGE.message = "cannot write into " .. folder
forgeRefresh()
return nil
end
file:write(authorCompile(FORGE.game))
file:close()
if not authorCopy(AUTHOR_RUNTIME, folder .. "/Author.singe") then
FORGE.message = "could not copy the runtime"
forgeRefresh()
return nil
end
-- The description travels with the game, so the release can be opened and edited again.
authorSave(FORGE.game, folder .. "/" .. script .. ".game")
-- A games.dat so the menu lists it, written from what the description already knows.
dat = io.open(folder .. "/games.dat", "w")
if dat ~= nil then
dat:write(string.format("-- Written by Forge.\nGAMES = {\n\t{\n\t\tTITLE = %q,\n\t\tSCRIPT = %q,\n\t\tDATA = %q,\n\t\tVIDEO = \"Singe/menuBackground.mkv\",\n\t\tRESOLUTION_X = %d,\n\t\tRESOLUTION_Y = %d\n\t}\n}\n",
title, folder .. "/" .. script .. ".singe", folder, overlayGetWidth(), overlayGetHeight()))
dat:close()
end
FORGE.message = "released to " .. folder
forgeRefresh()
return folder
end
-- Compiles what is on screen and hands back the path, so the caller can dofile it and play.
function forgeBuild(outputFile)
FORGE.game.source = FORGE.path
local file = assert(io.open(outputFile, "w"))
file:write(authorCompile(FORGE.game))
file:close()
FORGE.message = "built"
forgeRefresh()
return outputFile
end
function forgePress(x, y)
-- Whatever is under the chrome belongs to it, wherever it has been dragged to. The GUI has
-- already had its chance at this click; checking again is belt and braces for the case where
-- an element did not claim it.
-- The tab first: it sits outside the panel, so it is reachable, and it is what moves the panel.
local tx, ty, tw, th = forgeTab()
if collidePointRect(x, y, tx, ty, tw, th) then
forgePanelGrab(x)
return
end
if forgeOverPanel(x) then
return
end
FORGE.held = forgePick(x, y)
if FORGE.held ~= nil then
local entity = FORGE.game.entities[FORGE.held]
FORGE.grabX = x - entity.x
FORGE.grabY = y - entity.y
forgeSelect(FORGE.held)
end
end
function forgeRelease()
FORGE.held = nil
FORGE.panelDrag = nil
end
-- Whether an x lies under the chrome as it is placed right now.
function forgeOverPanel(x)
return (x >= FORGE.panelX) and (x < FORGE.panelX + PANEL_W)
end
-- Moves the chrome. Clamped so a panel dragged off the edge can always be got back.
function forgePanelTo(x)
local limit = overlayGetWidth() - PANEL_W
FORGE.panelX = math.max(0, math.min(math.floor(x), limit))
if FORGE.gui ~= nil then
element("panel").style.left = FORGE.panelX .. "px"
end
end
-- The tab that drags the panel, in overlay coordinates. It is deliberately drawn on the *canvas*,
-- just outside the panel, rather than being an element in the document: a press on the canvas is
-- the one pointer path this editor has been shown to receive reliably, whereas whether a document
-- gets the press at all depends on the GUI routing in _guiPointer, which the spike in PLAN section
-- 58 found silent under a window manager. Drawing the tab ourselves needs none of that.
function forgeTab()
return FORGE.panelX + PANEL_W, TAB_Y, TAB_W, TAB_H
end
function forgePanelGrab(x)
FORGE.panelDrag = x - FORGE.panelX
FORGE.message = "moving the panel"
forgeRefresh()
end
function forgeDrag(x, y)
if FORGE.panelDrag ~= nil then
forgePanelTo(x - FORGE.panelDrag)
elseif FORGE.held ~= nil then
forgeMove(FORGE.held, x - FORGE.grabX, y - FORGE.grabY)
end
end
-- Draws the description. Note that this draws the *description*, not a running game: there are
-- no nodes here and no physics, which is why an entity can be dragged through a wall.
function forgeDraw(pointerX, pointerY)
local index
colorBackground(18, 18, 26, 255)
overlayClear()
for index, entity in ipairs(FORGE.game.entities) do
local bx, by, bw, bh = forgeBounds(entity)
local look = entity.look or {}
local row
-- Everything is drawn, including whatever is under the chrome. The panel is opaque and
-- slides, so it covers rather than hides: drag it aside and what was beneath it is there,
-- in the right place, and can be picked up. Clipping was the earlier answer and it lied.
colorForeground(look.r or 180, look.g or 180, look.b or 190, 255)
if look.kind == "text" then
colorForeground(look.r or 235, look.g or 235, look.b or 245, 255)
if (look.text or "") ~= "" then
fontPrint(entity.x, entity.y, look.text)
end
overlayBox(bx, by, bx + bw, by + bh)
else
for row = math.floor(by), math.floor(by + bh) do
overlayLine(bx, row, bx + bw, row)
end
end
if index == FORGE.selected then
colorForeground(255, 210, 70, 255)
overlayBox(bx - 2, by - 2, bx + bw + 2, by + bh + 2)
overlayBox(entity.x - HANDLE, entity.y - HANDLE, entity.x + HANDLE, entity.y + HANDLE)
end
end
-- The panel's drag tab.
local tx, ty, tw, th = forgeTab()
local trow
colorForeground(70, 70, 96, 255)
for trow = ty, ty + th do
overlayLine(tx, trow, tx + tw, trow)
end
colorForeground(255, 207, 74, 255)
overlayLine(tx + 4, ty + 16, tx + tw - 4, ty + 16)
overlayLine(tx + 4, ty + 26, tx + tw - 4, ty + 26)
overlayLine(tx + 4, ty + 36, tx + tw - 4, ty + 36)
if pointerX ~= nil then
colorForeground(255, 220, 60, 255)
overlayLine(pointerX - 7, pointerY, pointerX + 7, pointerY)
overlayLine(pointerX, pointerY - 7, pointerX, pointerY + 7)
end
guiDraw(FORGE.gui)
end
-- ===== The event sheet ========================================================================
--
-- Rules are where someone with little programming skill actually spends their time, so this is the
-- half of the editor that matters. It is driven by keys rather than by the pointer: the bundled
-- menu's document is driven by keys and the pad and has always worked that way, whereas whether a
-- document receives the pointer is the open question recorded in PLAN section 58. A cabinet wants
-- keys anyway.
--
-- The vocabulary comes from AUTHOR, so a rule editor never needs changing when a condition or an
-- action is added: it lists whatever the manifest declares.
local DEFAULTS = {
number = 0,
string = "",
boolean = true,
entity = "",
scancode = "SPACE",
switch = "SWITCH_BUTTON1",
expression = '""',
lua = "-- your Lua here",
file = ""
}
-- Every condition and action of a rule, flattened, so one index walks the whole thing.
function forgeParts(rule)
local parts = {}
local item
for _, item in ipairs(rule.when or {}) do
parts[#parts + 1] = { kind = "when", item = item }
end
for _, item in ipairs(rule.act or {}) do
parts[#parts + 1] = { kind = "act", item = item }
end
return parts
end
-- A new condition or action, with a value for each parameter the manifest declares, so it compiles
-- the moment it is added rather than only once every field has been filled in.
function forgeMakePart(set, name)
local entry = set[name]
local item = { name }
local key
local kind
if entry == nil then
return nil
end
for key, kind in pairs(entry.params or {}) do
if kind == "entity" and #FORGE.game.entities > 0 then
item[key] = FORGE.game.entities[1].id
else
item[key] = DEFAULTS[kind]
end
end
return item
end
function forgeRuleAdd(kindName, name)
local rule = FORGE.game.rules[FORGE.rule]
local set = (kindName == "when") and AUTHOR.conditions or AUTHOR.actions
local item = forgeMakePart(set, name)
if (rule == nil) or (item == nil) then
FORGE.message = "no such " .. kindName
return false
end
local parts
local at
rule[kindName] = rule[kindName] or {}
rule[kindName][#rule[kindName] + 1] = item
FORGE.dirty = true
FORGE.message = "added " .. name
-- Select what was just added, so its parameters can be set straight away. Conditions come
-- before actions in the flattened list, so adding one shifts every action along: the new
-- part is found by identity rather than by assuming it went on the end.
parts = forgeParts(rule)
for at = 1, #parts do
if parts[at].item == item then
FORGE.part = at
end
end
forgeRefresh()
return true
end
function forgePartDelete()
local rule = FORGE.game.rules[FORGE.rule]
local parts = forgeParts(rule)
local part = parts[FORGE.part]
local list
local i
if part == nil then
return false
end
list = rule[part.kind]
for i = 1, #list do
if list[i] == part.item then
table.remove(list, i)
break
end
end
FORGE.part = math.min(FORGE.part, #forgeParts(rule))
FORGE.dirty = true
FORGE.message = "deleted"
forgeRefresh()
return true
end
-- Changes one parameter of the selected condition or action. Numbers arrive as numbers so that a
-- description keeps compiling to the same thing whether it was typed or dragged.
function forgePartSet(key, value)
local parts = forgeParts(FORGE.game.rules[FORGE.rule])
local part = parts[FORGE.part]
if part == nil then
return false
end
part.item[key] = value
FORGE.dirty = true
FORGE.message = key .. " = " .. tostring(value)
forgeRefresh()
return true
end
function forgeRuleNew(note)
FORGE.game.rules[#FORGE.game.rules + 1] = { note = note or "new rule", when = {}, act = {} }
FORGE.rule = #FORGE.game.rules
FORGE.part = 0
FORGE.dirty = true
FORGE.message = "rule added"
forgeRefresh()
return FORGE.rule
end
function forgeRuleDelete()
if FORGE.game.rules[FORGE.rule] == nil then
return false
end
table.remove(FORGE.game.rules, FORGE.rule)
FORGE.rule = math.max(1, math.min(FORGE.rule, #FORGE.game.rules))
FORGE.part = 0
FORGE.dirty = true
FORGE.message = "rule deleted"
forgeRefresh()
return true
end
-- One condition or action as a line: its name, then its parameters in a fixed order so the same
-- rule always reads the same way.
function forgePartText(item)
local keys = {}
local out = {}
local key
for key in pairs(item) do
if key ~= 1 then
keys[#keys + 1] = key
end
end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
for _, key in ipairs(keys) do
out[#out + 1] = key .. " " .. tostring(item[key])
end
return item[1] .. (( #out > 0) and (": " .. table.concat(out, ", ")) or "")
end
function forgeMode(mode)
FORGE.mode = mode
forgeRefresh()
end
-- Keys. Point the engine's callback at this and the editor is usable without a pointer at all,
-- which is how the bundled menu has always worked and what a cabinet needs.
--
-- TAB entities or rules
-- UP / DOWN move within the list
-- LEFT/RIGHT move the panel out of the way
-- ENTER type a value for the selected condition or action; again for its next value
-- ESC put the value back
-- N new rule DELETE delete the selected rule or part
-- S save B build
function forgeKey(keysym, scancode)
local rules = FORGE.game.rules or {}
-- SCANCODE entries are tables of { name, value } and the engine hands onKeyPressed two plain
-- integers, so the comparison is against .value. Comparing against the table itself is always
-- false with a real keyboard; it only appeared to work because a test passed the table.
if FORGE.editing ~= nil then
if scancode == SCANCODE.RETURN.value then
forgeEditNext()
return
end
if scancode == SCANCODE.ESCAPE.value then
forgeEditCancel()
return
end
if forgeTyped(keysym) then
return
end
elseif scancode == SCANCODE.RETURN.value then
forgeEditNext()
return
end
if scancode == SCANCODE.TAB.value then
forgeMode((FORGE.mode == "rules") and "entities" or "rules")
elseif scancode == SCANCODE.LEFT.value then
forgePanelTo(FORGE.panelX - 40)
elseif scancode == SCANCODE.RIGHT.value then
forgePanelTo(FORGE.panelX + 40)
elseif FORGE.mode == "entities" then
if scancode == SCANCODE.DOWN.value then
forgeSelect(math.min((FORGE.selected or 0) + 1, #FORGE.game.entities))
elseif scancode == SCANCODE.UP.value then
forgeSelect(math.max((FORGE.selected or 2) - 1, 1))
end
else
-- In the rules, up and down walk the rule and its parts as one list: past the last part
-- of a rule is the next rule, which is how it reads on screen.
if scancode == SCANCODE.DOWN.value then
local parts = forgeParts(rules[FORGE.rule] or {})
if FORGE.part < #parts then
FORGE.part = FORGE.part + 1
elseif FORGE.rule < #rules then
FORGE.rule = FORGE.rule + 1
FORGE.part = 0
end
FORGE.lastField = nil
forgeRefresh()
elseif scancode == SCANCODE.UP.value then
if FORGE.part > 0 then
FORGE.part = FORGE.part - 1
elseif FORGE.rule > 1 then
FORGE.rule = FORGE.rule - 1
FORGE.part = #forgeParts(rules[FORGE.rule] or {})
end
FORGE.lastField = nil
forgeRefresh()
elseif scancode == SCANCODE.N.value then
forgeRuleNew()
elseif scancode == SCANCODE.DELETE.value then
if FORGE.part > 0 then
forgePartDelete()
else
forgeRuleDelete()
end
end
end
if scancode == SCANCODE.S.value then
forgeSave()
elseif scancode == SCANCODE.B.value then
forgeBuild(singeGetDataPath() .. "preview.singe")
end
end
-- ===== Typing a value =========================================================================
--
-- A rule editor that can add "when keyHeld" but cannot change SPACE to UP is most of the way to
-- useless, which is the half of the job that matters for someone with little programming skill.
--
-- Keysyms arrive as characters the way Tools.singe takes them, so this needs nothing of the GUI:
-- ENTER starts editing the selected condition or action, each press moves to its next parameter,
-- and ESCAPE puts back what was there.
FORGE.editing = nil -- { key = name, text = typed so far, was = the value it started as }
-- The parameters of a part, in the order the panel shows them, so typing walks them the same way.
function forgeFieldNames(item)
local keys = {}
local key
for key in pairs(item) do
if key ~= 1 then
keys[#keys + 1] = key
end
end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
return keys
end
-- A typed value as the description should hold it. A number has to come back a number: "220" and
-- 220 compile to different source, and a description that changed shape because a value was
-- retyped would stop round-tripping.
local function typedValue(text, was)
local number = tonumber(text)
if (type(was) == "number") and (number ~= nil) then
return number
end
if text == "true" then
return true
end
if text == "false" then
return false
end
if (type(was) ~= "string") and (number ~= nil) then
return number
end
return text
end
function forgeEditCommit()
if FORGE.editing == nil then
return false
end
forgePartSet(FORGE.editing.key, typedValue(FORGE.editing.text, FORGE.editing.was))
FORGE.editing = nil
return true
end
function forgeEditCancel()
if FORGE.editing == nil then
return false
end
forgePartSet(FORGE.editing.key, FORGE.editing.was)
FORGE.editing = nil
FORGE.message = "unchanged"
forgeRefresh()
return true
end
-- Starts editing, or moves on to the next parameter of the same part. Committing as it moves is
-- what makes ENTER, ENTER, ENTER feel like filling in a form.
function forgeEditNext()
local rule = FORGE.game.rules[FORGE.rule]
local parts = forgeParts(rule or {})
local part = parts[FORGE.part]
local names
local at = 0
local i
if part == nil then
return false
end
names = forgeFieldNames(part.item)
if #names == 0 then
FORGE.message = part.item[1] .. " takes no values"
forgeRefresh()
return false
end
if FORGE.editing ~= nil then
forgeEditCommit()
end
for i = 1, #names do
if names[i] == FORGE.lastField then
at = i
end
end
at = (at % #names) + 1
FORGE.lastField = names[at]
FORGE.editing = { key = names[at], text = "", was = part.item[names[at]] }
FORGE.message = "type a value for " .. names[at] .. ", ENTER for the next, ESC to put it back"
forgeRefresh()
return true
end
-- One typed character. Point the engine's onKeyPressed at forgeKey and this is reached from there.
function forgeTyped(keysym)
if FORGE.editing == nil then
return false
end
if keysym == 8 then
FORGE.editing.text = string.sub(FORGE.editing.text, 1, -2)
elseif (keysym >= 32) and (keysym < 127) then
FORGE.editing.text = FORGE.editing.text .. string.char(keysym)
else
return false
end
-- Shown as it is typed, so the value in the list is the value being entered.
forgePartSet(FORGE.editing.key, (FORGE.editing.text == "") and FORGE.editing.was or typedValue(FORGE.editing.text, FORGE.editing.was))
FORGE.message = FORGE.editing.key .. " = " .. FORGE.editing.text .. "_"
forgeRefresh()
return true
end