otclient-redemption/modules/dev_otui/dev_otui.lua
Renan Miyashiro 9bfac7719f
feat(dev_otui): visual .otui screen editor inside the client (#1782)
New Features:
- Added a visual OTUI editor for previewing and editing .otui files, including live preview, widget selection, and editing of properties, anchors, geometry and states.
- Supports adding/removing widgets and properties, creating new screens, and saving back with safety checks and backups.
- Included file/style/anchor pickers and a style gallery with live samples.
- Added an OTML parser/writer to preserve and apply line-accurate edits.

Documentation:
- Added comprehensive editor documentation, workflows and operational notes.
2026-07-31 19:01:14 -03:00

2399 lines
78 KiB
Lua

-- dev_otui: .otui screen editor/inspector rendered by OTClient itself.
-- Fidelity is exact because the same renderer that draws the game draws the
-- preview: same bitmap fonts, same image-border 9-slice, same style cascade.
--
-- The property panel mirrors the file's LINES (through otml.lua), not the
-- widget's getters. That is how properties without a getter show up, such as
-- anchors.* and @onClick, and why what you save is exactly what you read.
local editorWindow = nil
local topButton = nil
local stage = nil -- container where the target .otui is loaded (covers the screen)
local captureLayer = nil -- transparent layer used by "Edit" mode
local selectionBox = nil -- green rectangle around the selected widget
local hoverBox = nil -- yellow rectangle around the widget under the mouse
local resizeHandle = nil -- resize handle, bottom-right corner
local targetPath = nil
local targetFileTime = 0
local watchEvent = nil
local doc = nil -- OTML document of the target file (otml.lua)
local currentNode = nil -- file node matching the selected widget
local nodeError = nil -- why the widget could not be mapped
local currentRef = nil -- how to find currentNode again after reparsing
-- A third of the project's .otui files (every one under /styles) has no main
-- widget: they only define styles, and loadUI returns nil for them. For those
-- we build a showcase instantiating each style, and the selected widget then
-- points at the style definition in the file rather than at a tree node.
local showcaseMode = false
local showcaseStyleIndex = {} -- widget -> index into doc.styleDefs
local selectedWidget = nil
local selectedPath = nil -- index path, used to reselect after a reload
local treeItems = {}
local propRows = {} -- { key, edit, original, readOnly, pending }
local selecting = false
local drag = nil -- state of the drag in progress
local guides = {} -- parent-area guide widgets
local guidesEnabled = false
local pickerWindow = nil -- helper window (open file / choose element)
local pickerEntries = {}
local pickerCallback = nil
local pickerPreview = false -- picker shows a live sample of the highlighted item
local fileCache = nil -- project's .otui list (scanning is expensive)
local styleCache = nil -- style palette; depends on the open file
local AUTO_RELOAD_INTERVAL = 300
local MAX_TREE_DEPTH = 12
local HANDLE_SIZE = 10
-- ============================================================ helpers
local function status(msg, isError)
if not editorWindow then return end
local label = editorWindow:recursiveGetChildById('statusLabel')
label:setText(msg)
label:setColor(isError and '#ff6b6b' or '#dfdf7f')
label:setTooltip(msg) -- the message may not fit; the tooltip holds it whole
if isError then
g_logger.warning('[dev_otui] ' .. msg)
end
end
local function normalizePath(path)
path = path:trim()
if path == '' then return nil end
if path:sub(1, 1) ~= '/' then path = '/' .. path end
if not path:ends('.otui') then path = path .. '.otui' end
return path
end
-- Stacking (bottom -> top): stage, captureLayer, editorWindow, boxes.
local function restackLayers()
if captureLayer and not captureLayer:isDestroyed() then captureLayer:raise() end
if editorWindow and not editorWindow:isDestroyed() then editorWindow:raise() end
if hoverBox and not hoverBox:isDestroyed() then hoverBox:raise() end
if selectionBox and not selectionBox:isDestroyed() then selectionBox:raise() end
if resizeHandle and not resizeHandle:isDestroyed() then resizeHandle:raise() end
end
local function describePath(widget)
local parts = {}
local w = widget
while w and w ~= stage do
local name = w:getStyleName()
if not name or name == '' then name = 'UIWidget' end
local id = w:getId()
if id and id ~= '' and id ~= name then name = name .. '#' .. id end
table.insert(parts, 1, name)
w = w:getParent()
end
return table.concat(parts, ' > ')
end
local function indexPath(widget)
local path = {}
local w = widget
while w and w ~= stage and w:getParent() do
local parent = w:getParent()
table.insert(path, 1, parent:getChildIndex(w))
w = parent
end
return path
end
local function widgetFromIndexPath(path)
if not path or not stage then return nil end
local w = stage
for _, index in ipairs(path) do
w = w:getChildByIndex(index)
if not w then return nil end
end
return w
end
local function ensureBox(existing, color, filled)
if existing and not existing:isDestroyed() then return existing end
local box = g_ui.createWidget('UIWidget', rootWidget)
box:setPhantom(true)
box:setFocusable(false)
box:breakAnchors()
if filled then
box:setBackgroundColor(color)
else
box:setBorderWidth(1)
box:setBorderColor(color)
end
box:hide()
return box
end
local function moveBoxTo(box, widget)
if not box then return end
if not widget or widget:isDestroyed() then
box:hide()
return
end
box:setRect(widget:getRect())
box:show()
box:raise()
end
-- Repositions the selection rectangle and the resize handle on the current widget.
local function refreshSelectionVisuals()
if not selectedWidget or selectedWidget:isDestroyed() then
if selectionBox then selectionBox:hide() end
if resizeHandle then resizeHandle:hide() end
return
end
selectionBox = ensureBox(selectionBox, '#00ff00')
moveBoxTo(selectionBox, selectedWidget)
resizeHandle = ensureBox(resizeHandle, '#00ff00', true)
local rect = selectedWidget:getRect()
resizeHandle:setRect({
x = rect.x + rect.width - HANDLE_SIZE,
y = rect.y + rect.height - HANDLE_SIZE,
width = HANDLE_SIZE,
height = HANDLE_SIZE
})
resizeHandle:setVisible(captureLayer ~= nil)
resizeHandle:raise()
end
-- ============================================================ parent guides
local function clearGuides()
for _, guide in ipairs(guides) do
if guide and not guide:isDestroyed() then guide:destroy() end
end
guides = {}
end
local function addGuide(rect, color, filled)
local guide = g_ui.createWidget('UIWidget', rootWidget)
guide:setPhantom(true)
guide:setFocusable(false)
guide:breakAnchors()
if filled then
guide:setBackgroundColor(color)
else
guide:setBorderWidth(1)
guide:setBorderColor(color)
end
guide:setRect(rect)
guide:raise()
table.insert(guides, guide)
return guide
end
-- Sibling a "prev"/"next"/"<id>" anchor points at, so the link is visible.
local function anchorTargetWidget(target)
if not selectedWidget or target == 'parent' then return nil end
local parent = selectedWidget:getParent()
if not parent then return nil end
if target == 'prev' or target == 'next' then
local index = parent:getChildIndex(selectedWidget)
local wanted = (target == 'prev') and index - 1 or index + 1
-- getChildByIndex counts from the end for indices <= 0 (uiwidget.cpp:1510),
-- so asking for 0 would hand back the last child instead of nothing
if wanted < 1 or wanted > parent:getChildCount() then return nil end
return parent:getChildByIndex(wanted)
end
return parent:getChildById(target)
end
-- Draws the parent's box and centre lines, plus a box around every sibling this
-- widget is anchored to. Only redrawn on selection: the parent does not move
-- while dragging, so there is no need to rebuild these every frame.
local function drawGuides()
clearGuides()
if not guidesEnabled then return end
if not selectedWidget or selectedWidget:isDestroyed() then return end
local parent = selectedWidget:getParent()
if not parent or parent:isDestroyed() then return end
-- padding rect, not the plain rect: that is the area children are laid inside
local area = parent:getPaddingRect()
addGuide(area, '#4c9fff')
addGuide({ x = area.x, y = area.y + math.floor(area.height / 2),
width = area.width, height = 1 }, '#4c9fff55', true)
addGuide({ x = area.x + math.floor(area.width / 2), y = area.y,
width = 1, height = area.height }, '#4c9fff55', true)
if not currentNode then return end
for _, prop in ipairs(Otml.properties(currentNode)) do
if prop.tag:starts('anchors.') and prop.value then
local target = prop.value:match('^([^.]+)')
local widget = target and anchorTargetWidget(target)
if widget and widget ~= selectedWidget and not widget:isDestroyed() then
addGuide(widget:getRect(), '#ff9d3c')
end
end
end
end
function setGuides(enabled)
guidesEnabled = enabled
drawGuides()
if enabled then
status('Guides on: blue is the parent area, orange is a widget this one is anchored to.')
end
end
-- ============================================================ file document
-- Resolves the file node from an index path, without validating.
local function resolveNodeByPath(document, path)
if not document or not document.root then return nil end
local node = document.root
for i = 2, #path do
local kids = Otml.widgetChildren(node)
node = kids[path[i]]
if not node then return nil end
end
return node
end
-- Finds the edited node again in a freshly parsed document, whether it is a
-- widget from the tree or a style definition (showcase).
local function resolveRef(document, ref)
if not document or not ref then return nil end
if ref.kind == 'style' then
return document.styleDefs[ref.index]
end
return resolveNodeByPath(document, ref.path)
end
-- A reference into the file is a path of child indices, so it survives a
-- reparse but says nothing about *which* widget sits there now: a file edited
-- in a text editor between load and save can resolve the same path onto a
-- different widget, and the edit would land on it. Two nodes count as the same
-- one when the style name and the id both match.
local function sameNodeIdentity(a, b)
if not a or not b then return false end
if a.tag ~= b.tag then return false end
local idA = Otml.findProperty(a, 'id')
local idB = Otml.findProperty(b, 'id')
return (idA and idA.value or nil) == (idB and idB.value or nil)
end
-- The three go together: nodeError is the reason currentNode is missing, so
-- leaving it behind makes a later failure report the wrong cause.
local function clearNodeSelection()
currentNode = nil
nodeError = nil
currentRef = nil
end
-- Resolves while validating file-tag against widget-style at every level.
-- A mismatch means the on-screen tree did not come from the file alone (a widget
-- created by an inherited style, say) and writing there would corrupt the file.
local function nodeForWidget(widget)
if not stage or stage:isDestroyed() then return nil, 'preview is closed' end
if not doc then return nil, 'file was not read' end
-- style showcase: the widget is a sample, not a node of the file's tree.
-- clicking a child of the sample walks up to the sample standing for the style.
if showcaseMode then
local w = widget
while w and w ~= stage do
local index = showcaseStyleIndex[w]
if index then
return doc.styleDefs[index], nil, { kind = 'style', index = index }
end
w = w:getParent()
end
return nil, 'click one of the style samples'
end
if not doc.root then return nil, 'file has no main widget' end
local path = indexPath(widget)
if #path == 0 then return nil, 'widget is outside the preview' end
if path[1] ~= 1 then return nil, 'the preview has more than one root widget' end
local node = doc.root
local w = stage:getChildByIndex(1)
if not w then return nil, 'preview is empty' end
if w:getStyleName() ~= node.tag then
return nil, 'file root and preview root disagree'
end
for i = 2, #path do
local kids = Otml.widgetChildren(node)
node = kids[path[i]]
w = w:getChildByIndex(path[i])
if not node or not w then
return nil, 'this widget is not in the file (does it come from a style?)'
end
if w:getStyleName() ~= node.tag then
return nil, 'file and preview disagree at "' .. tostring(node.tag) .. '"'
end
end
return node, nil, { kind = 'widget', path = path }
end
local function readDocument(path)
local readOk, text = pcall(function() return g_resources.readFileContents(path) end)
if not readOk or not text then
doc = nil
-- callers only see false; without this the user is left with the vaguer
-- "file was not read" that the next selection produces
status('Could not read the file: ' .. tostring(path) ..
(readOk and '' or (' (' .. tostring(text) .. ')')), true)
return false
end
local parseOk, parsedOrErr = pcall(function() return Otml.parse(text) end)
if not parseOk then
doc = nil
status('Could not parse the file: ' .. tostring(parsedOrErr), true)
return false
end
doc = parsedOrErr
return true
end
-- Workdir-relative path for writing. The virtual path "/game_idle/x.otui" lives
-- on disk under "<workdir>/modules/game_idle/x.otui", because init.lua mounts the
-- modules folder at the virtual root. Without this conversion,
-- writeFileContentsToWorkDir would create a stray folder at the project root.
local function workDirWritePath(virtualPath)
-- for a file that does not exist yet (new screen), ask about its directory
local probe = virtualPath
if not g_resources.fileExists(virtualPath) then
probe = virtualPath:match('^(.*)/[^/]*$') or '/'
if probe == '' then probe = '/' end
end
local realDir = g_resources.getRealDir(probe)
local workDir = g_resources.getWorkDir()
if not realDir or realDir == '' or not workDir or workDir == '' then
return nil, 'could not locate the file on disk'
end
realDir = realDir:gsub('\\', '/')
workDir = workDir:gsub('\\', '/')
if workDir:sub(-1) ~= '/' then workDir = workDir .. '/' end
if realDir:sub(-1) == '/' then realDir = realDir:sub(1, -2) end
if realDir:sub(1, #workDir) ~= workDir then
return nil, 'file is outside the project directory (' .. realDir .. ')'
end
local prefix = realDir:sub(#workDir + 1)
if prefix == '' then
return virtualPath:sub(2)
end
return prefix .. virtualPath
end
-- ============================================================ tree
local function addTreeItem(widget, depth)
local list = editorWindow:recursiveGetChildById('treeList')
local item = g_ui.createWidget('OtuiTreeItem', list)
local styleName = widget:getStyleName()
if not styleName or styleName == '' then styleName = 'UIWidget' end
local label = string.rep(' ', depth) .. styleName
local id = widget:getId()
if id and id ~= '' and id ~= styleName then label = label .. ' #' .. id end
if not widget:isExplicitlyVisible() then label = label .. ' (hidden)' end
item:setText(label)
item:setTooltip(label:trim())
item.otuiWidget = widget
item.onFocusChange = function(self, focused)
if focused and self.otuiWidget and not selecting then
modules.dev_otui.selectWidget(self.otuiWidget)
end
end
table.insert(treeItems, { item = item, widget = widget })
end
local function buildTreeFrom(widget, depth)
if depth > MAX_TREE_DEPTH then return end
for _, child in ipairs(widget:getChildren()) do
addTreeItem(child, depth)
buildTreeFrom(child, depth + 1)
end
end
local function rebuildTree()
local list = editorWindow:recursiveGetChildById('treeList')
list:destroyChildren()
treeItems = {}
if stage and not stage:isDestroyed() then
buildTreeFrom(stage, 0)
end
end
local function findTreeItem(widget)
for _, entry in ipairs(treeItems) do
if entry.widget == widget then return entry.item end
end
return nil
end
-- ============================================================ property panel
local function findRow(key)
for _, row in ipairs(propRows) do
if row.key == key then return row end
end
return nil
end
local function addPropRow(key, value, readOnly, pending)
local list = editorWindow:recursiveGetChildById('propList')
local widget = g_ui.createWidget('OtuiPropRow', list)
local keyLabel = widget:getChildById('keyLabel')
local valueEdit = widget:getChildById('valueEdit')
local removeBtn = widget:getChildById('removeBtn')
keyLabel:setText(key)
valueEdit:setText(value or '')
if readOnly then
valueEdit:setEnabled(false)
keyLabel:setColor('#808080')
elseif pending then
keyLabel:setColor('#ffcc00')
end
local row = {
key = key,
edit = valueEdit,
label = keyLabel,
original = value or '',
readOnly = readOnly,
pending = pending or false,
removed = false,
}
removeBtn.onClick = function()
modules.dev_otui.removeProperty(key)
end
valueEdit.onKeyPress = function(_, keyCode)
if keyCode == KeyEnter then
modules.dev_otui.applyAll()
return true
end
return false
end
-- also apply when leaving the field, so Enter is not required on every edit
valueEdit.onFocusChange = function(self, focused)
if not focused and not self:isDestroyed() and self:getText() ~= row.original then
modules.dev_otui.applyAll()
end
end
table.insert(propRows, row)
return row
end
local function rebuildPropertyPanel()
local list = editorWindow:recursiveGetChildById('propList')
list:destroyChildren()
propRows = {}
if not currentNode then return end
for _, prop in ipairs(Otml.properties(currentNode)) do
local composite = #prop.children > 0
addPropRow(prop.tag, composite and '(block - edit it in the file)' or prop.value, composite, false)
end
end
-- Used by dragging: reflects in the panel a value already applied to the widget.
local function setPendingProperty(key, value)
local row = findRow(key)
if row then
row.edit:setText(value)
if not row.pending then
row.pending = true
row.label:setColor('#ffcc00')
end
else
addPropRow(key, value, false, true)
end
end
function removeProperty(key)
if not currentNode then
status('Nothing selected.', true)
return
end
local row = findRow(key)
if not row then return end
if row.readOnly then
status('Block property: remove it directly in the file.', true)
return
end
row.removed = true
row.edit:setText('')
row.edit:setEnabled(false)
row.label:setColor('#ff6b6b')
status('"' .. key .. '" will be removed on save.')
end
function addPropertyFromInput()
if not selectedWidget then
status('Select a widget first.', true)
return
end
local input = editorWindow:recursiveGetChildById('newPropEdit'):getText():trim()
local key, value = input:match('^%s*([^:]+)%s*:%s*(.-)%s*$')
if not key then
status('Use the format "property: value".', true)
return
end
key = key:trim()
local row = findRow(key)
if row then
row.edit:setText(value)
row.removed = false
row.edit:setEnabled(true)
setPendingProperty(key, value)
else
addPropRow(key, value, false, true)
end
editorWindow:recursiveGetChildById('newPropEdit'):setText('')
applyAll()
end
-- ============================================================ selection
local function refreshStateChecks(widget)
local function set(id, value)
local check = editorWindow:recursiveGetChildById(id)
if not check then return end
check.suppress = true
check:setChecked(value)
check.suppress = false
end
set('stateOn', widget:isOn())
set('stateChecked', widget:isChecked())
set('stateDisabled', widget:isDisabled())
set('stateHidden', not widget:isExplicitlyVisible())
end
function selectWidget(widget)
if not widget or widget:isDestroyed() then return end
if selecting then return end
selecting = true
selectedWidget = widget
selectedPath = indexPath(widget)
currentNode, nodeError, currentRef = nodeForWidget(widget)
refreshSelectionVisuals()
local rect = widget:getRect()
local origin = currentNode
and ('line ' .. currentNode.line .. ' of the file' ..
(showcaseMode and ' (style definition)' or ''))
or ('not mapped: ' .. tostring(nodeError))
local info = table.concat({
describePath(widget),
string.format('rect: x=%d y=%d w=%d h=%d', rect.x, rect.y, rect.width, rect.height),
origin,
}, '\n')
local infoLabel = editorWindow:recursiveGetChildById('infoLabel')
infoLabel:setText(info)
infoLabel:setTooltip(info)
refreshStateChecks(widget)
rebuildPropertyPanel()
drawGuides()
local item = findTreeItem(widget)
if item and not item:isFocused() then
item:focus()
end
selecting = false
end
function applyState(state, value)
local checkId = 'state' .. state:sub(1, 1):upper() .. state:sub(2)
local check = editorWindow and editorWindow:recursiveGetChildById(checkId)
if check and check.suppress then return end
if not selectedWidget or selectedWidget:isDestroyed() then return end
if state == 'on' then
selectedWidget:setOn(value)
elseif state == 'checked' then
selectedWidget:setChecked(value)
elseif state == 'disabled' then
selectedWidget:setEnabled(not value)
elseif state == 'hidden' then
selectedWidget:setVisible(not value)
end
refreshSelectionVisuals()
end
-- Applies the panel contents to the live widget, without touching the file.
function applyAll()
if not selectedWidget or selectedWidget:isDestroyed() then
status('Select a widget first.', true)
return
end
local style = {}
local count = 0
for _, row in ipairs(propRows) do
-- events (@onClick...) were bound at load time; reapplying does not help
-- the preview and may duplicate handlers
if not row.readOnly and not row.removed and row.key:sub(1, 1) ~= '@' then
style[row.key] = row.edit:getText()
count = count + 1
end
end
if count == 0 then
status('No applicable property.', true)
return
end
local ok, err = pcall(function() selectedWidget:mergeStyle(style) end)
if not ok then
-- in bulk there is no way to tell which property broke; retry one by one
local culprit, culpritErr
for key, value in pairs(style) do
local okOne, errOne = pcall(function() selectedWidget:mergeStyle({ [key] = value }) end)
if not okOne then
culprit, culpritErr = key, errOne
break
end
end
if culprit then
local hint = ''
if culprit:sub(1, 1) == '!' then
-- tags with ! are evaluated as Lua expressions (uiwidget.cpp:714)
hint = ' - values of "' .. culprit .. '" are Lua code: use quotes, like \'my text\''
end
status('Preview failed at "' .. culprit .. '": ' .. tostring(culpritErr) .. hint, true)
else
status('Preview failed: ' .. tostring(err), true)
end
return
end
local parent = selectedWidget:getParent()
if parent then parent:updateLayout() end
refreshSelectionVisuals()
status('Preview applied. Use "Save to file" to persist it.')
end
-- ============================================================ saving
-- Writes text to the target file with a .bak backup and confirms by reading it
-- back from disk. Returns ok, message.
local function persistText(virtualPath, newText, backupText)
local writePath, why = workDirWritePath(virtualPath)
if not writePath then
return false, 'cannot write: ' .. tostring(why)
end
if backupText then
if not g_resources.writeFileContentsToWorkDir(writePath .. '.bak', backupText) then
return false, 'could not write the .bak backup; nothing was changed'
end
end
if not g_resources.writeFileContentsToWorkDir(writePath, newText) then
return false, 'the write failed' ..
(backupText and (' - the original is at ' .. writePath .. '.bak') or '')
end
-- real confirmation: read back through the virtual path and compare
local okRead, current = pcall(function() return g_resources.readFileContents(virtualPath) end)
if not okRead or current ~= newText then
return false, 'wrote to ' .. writePath .. ', but the file read back does not match'
end
return true, writePath
end
function saveFile()
if not targetPath then
status('No file loaded.', true)
return
end
if not selectedWidget or selectedWidget:isDestroyed() then
status('Select a widget before saving.', true)
return
end
if not currentNode then
status('Cannot save: ' .. tostring(nodeError), true)
return
end
-- re-read: the file may have changed in the text editor since it was loaded
if not readDocument(targetPath) then
status('Could not re-read the file before saving.', true)
return
end
local originalText = Otml.serialize(doc)
local ref = currentRef
-- the re-read above may have brought in someone else's changes: the path
-- still resolves, but possibly onto another widget
if not sameNodeIdentity(resolveRef(doc, ref), currentNode) then
status('The file changed since it was loaded and the selection no longer ' ..
'matches. Nothing was written - reload and pick the widget again.', true)
return
end
local edits = {}
for _, row in ipairs(propRows) do
-- readOnly rows are composite blocks (layout:, $hover:...): left untouched
if not row.readOnly then
if row.removed then
table.insert(edits, { key = row.key, remove = true })
else
local value = row.edit:getText()
if value ~= row.original then
table.insert(edits, { key = row.key, value = value })
end
end
end
end
if #edits == 0 then
status('Nothing changed.')
return
end
-- each insertion/removal shifts the following lines, so we reparse and find
-- the node again after every edit
for _, edit in ipairs(edits) do
local node = resolveRef(doc, ref)
if not node then
status('Lost track of the widget in the file. Nothing was written.', true)
readDocument(targetPath)
return
end
local ok, err
if edit.remove then
ok, err = Otml.removeProperty(doc, node, edit.key)
else
ok, err = Otml.setProperty(doc, node, edit.key, edit.value)
end
if not ok then
status('Failed at "' .. edit.key .. '": ' .. tostring(err) .. '. Nothing was written.', true)
readDocument(targetPath)
return
end
doc = Otml.reparse(doc)
end
local newText = Otml.serialize(doc)
local ok, result = persistText(targetPath, newText, originalText)
if not ok then
status(result .. '. Check the .bak before continuing.', true)
readDocument(targetPath)
return
end
targetFileTime = g_resources.getFileTime(targetPath)
readDocument(targetPath)
currentNode = resolveRef(doc, ref)
rebuildPropertyPanel()
status(#edits .. ' change(s) saved to ' .. result)
end
-- ============================================================ stage / loading
function closeStage()
if stage and not stage:isDestroyed() then stage:destroy() end
stage = nil
selectedWidget = nil
clearNodeSelection()
showcaseMode = false
showcaseStyleIndex = {}
treeItems = {}
propRows = {}
if editorWindow then
editorWindow:recursiveGetChildById('treeList'):destroyChildren()
editorWindow:recursiveGetChildById('propList'):destroyChildren()
editorWindow:recursiveGetChildById('infoLabel'):setText('No widget selected.')
end
if selectionBox and not selectionBox:isDestroyed() then selectionBox:hide() end
if hoverBox and not hoverBox:isDestroyed() then hoverBox:hide() end
if resizeHandle and not resizeHandle:isDestroyed() then resizeHandle:hide() end
clearGuides()
end
-- Builds the showcase for style-only files: a live sample of each style with
-- its name beside it. It is the only way to "see" /styles/*.otui.
local function buildStyleShowcase()
showcaseStyleIndex = {}
if not doc or #doc.styleDefs == 0 then
return 0, 0
end
local board = g_ui.createWidget('UIWidget', stage)
board:setId('otuiShowcase')
board:breakAnchors()
board:addAnchor(AnchorTop, 'parent', AnchorTop)
board:addAnchor(AnchorLeft, 'parent', AnchorLeft)
board:setMarginTop(60)
board:setMarginLeft(420)
board:setWidth(520)
board:setBackgroundColor('#00000099')
board:setPhantom(false)
local shown, failed = 0, 0
local y = 6
-- positioned by anchoring to the board (not by absolute coordinates): the
-- layout only resolves later, so getX()/getY() here would still be zero
local function place(widget, marginLeft, marginTop)
widget:breakAnchors()
widget:addAnchor(AnchorTop, 'parent', AnchorTop)
widget:addAnchor(AnchorLeft, 'parent', AnchorLeft)
widget:setMarginTop(marginTop)
widget:setMarginLeft(marginLeft)
end
for index, def in ipairs(doc.styleDefs) do
local name = def.tag:match('^([%w_]+)%s*<')
if name then
local label = g_ui.createWidget('OtuiTreeItem', board)
label:setPhantom(true)
label:setFocusable(false)
label:setText(name)
label:setWidth(190)
label:setHeight(14)
place(label, 6, y)
-- styles that depend on a specific parent (MiniWindow, for instance) may
-- fail to instantiate standalone: that is information, not a fatal error
local ok, sample = pcall(function()
return g_ui.createWidget(name, board)
end)
local rowHeight = 18
if ok and sample then
place(sample, 200, y)
if sample:getWidth() <= 1 then sample:setWidth(120) end
if sample:getHeight() <= 1 then sample:setHeight(16) end
rowHeight = math.max(18, sample:getHeight() + 4)
showcaseStyleIndex[sample] = index
shown = shown + 1
else
label:setText(name .. ' (cannot be instantiated standalone)')
label:setColor('#ff8080')
failed = failed + 1
end
y = y + rowHeight
end
end
board:setHeight(y + 6)
return shown, failed
end
local function loadInto(path, keepPath)
if not g_resources.fileExists(path) then
status('File not found: ' .. path, true)
return false
end
closeStage()
stage = g_ui.createWidget('UIWidget', rootWidget)
stage:setId('otuiStage')
stage:fill('parent')
stage:setPhantom(true) -- the stage steals no clicks; children stay clickable
stage:setFocusable(false)
-- UIManager::loadUI swallows exceptions internally: on a syntax error it
-- only logs and returns nil. That is why we check the return value.
local ok, result = pcall(function() return g_ui.loadUI(path, stage) end)
targetPath = path
targetFileTime = g_resources.getFileTime(path)
readDocument(path)
styleCache = nil -- the palette includes styles defined in the open file
rebuildTree()
restackLayers()
if not ok then
status('Error while loading: ' .. tostring(result), true)
return false
end
if not result then
-- No main widget. If the file defines styles it is a library (every
-- /styles/*.otui is one): show a sample of each style.
if doc and #doc.styleDefs > 0 then
showcaseMode = true
local shown, failed = buildStyleShowcase()
rebuildTree()
restackLayers()
local extra = failed > 0 and (', ' .. failed .. ' not instantiable standalone') or ''
status('Style library: ' .. shown .. ' sample(s)' .. extra ..
'. Click a sample to edit its definition.')
return true
end
status('Failed to parse ' .. path .. ' - open the terminal (Ctrl+T) to see the error.', true)
return false
end
if keepPath then
selectedPath = keepPath
local widget = widgetFromIndexPath(keepPath)
if widget then selectWidget(widget) end
end
status('Loaded: ' .. path .. ' (' .. #treeItems .. ' widgets)')
return true
end
function loadTarget()
local path = normalizePath(editorWindow:recursiveGetChildById('pathEdit'):getText())
if not path then
status('Enter the path of the .otui file.', true)
return
end
selectedPath = nil
if loadInto(path, nil) then
g_settings.set('dev_otui_lastPath', path)
end
end
function reloadTarget()
if not targetPath then
status('Load a file first.', true)
return
end
loadInto(targetPath, selectedPath)
end
-- ============================================================ auto-reload
local function watchTick()
watchEvent = scheduleEvent(watchTick, AUTO_RELOAD_INTERVAL)
if not targetPath then return end
local time = g_resources.getFileTime(targetPath)
if time > targetFileTime then
targetFileTime = time
loadInto(targetPath, selectedPath)
end
end
function setAutoReload(enabled)
removeEvent(watchEvent)
watchEvent = nil
if enabled then
watchTick()
status('Auto-reload on (' .. AUTO_RELOAD_INTERVAL .. 'ms).')
else
status('Auto-reload off.')
end
end
function setDebugBoxes(enabled)
g_ui.setDebugBoxesDrawing(enabled)
end
-- ============================================================ drag / resize
-- Which edges the widget anchors, read from the file (the widget exposes no
-- anchor getters). Decides whether moving changes margin-left or margin-right.
local function anchorFlags()
local flags = {}
if not currentNode then return flags end
for _, prop in ipairs(Otml.properties(currentNode)) do
if prop.tag:starts('anchors.') then
local edge = prop.tag:sub(9)
flags[edge] = true
if edge == 'fill' then
flags.left, flags.right, flags.top, flags.bottom = true, true, true, true
elseif edge == 'centerIn' then
flags.left, flags.top = true, true
end
end
end
return flags
end
local function beginDrag(mode, mousePos)
-- the click handler calls this right after selectWidget, which returns early
-- (leaving no selection) when it is re-entered or the widget is already gone
if not selectedWidget or selectedWidget:isDestroyed() then return false end
local rect = selectedWidget:getRect()
drag = {
mode = mode,
start = { x = mousePos.x, y = mousePos.y },
marginLeft = selectedWidget:getMarginLeft(),
marginRight = selectedWidget:getMarginRight(),
marginTop = selectedWidget:getMarginTop(),
marginBottom = selectedWidget:getMarginBottom(),
width = rect.width,
height = rect.height,
anchors = anchorFlags(),
moved = false,
}
return true
end
local function updateDrag(mousePos)
if not drag or not selectedWidget or selectedWidget:isDestroyed() then return end
local dx = mousePos.x - drag.start.x
local dy = mousePos.y - drag.start.y
if dx == 0 and dy == 0 then return end
drag.moved = true
if drag.mode == 'resize' then
selectedWidget:setWidth(math.max(1, drag.width + dx))
selectedWidget:setHeight(math.max(1, drag.height + dy))
else
local a = drag.anchors
if a.left then selectedWidget:setMarginLeft(drag.marginLeft + dx) end
if a.right then selectedWidget:setMarginRight(drag.marginRight - dx) end
if a.top then selectedWidget:setMarginTop(drag.marginTop + dy) end
if a.bottom then selectedWidget:setMarginBottom(drag.marginBottom - dy) end
end
local parent = selectedWidget:getParent()
if parent then parent:updateLayout() end
refreshSelectionVisuals()
end
-- On release, carries the new values into the panel (not saved yet).
local function finishDrag()
if not drag then return end
if drag.moved and selectedWidget and not selectedWidget:isDestroyed() then
if drag.mode == 'resize' then
local sizeProp = currentNode and Otml.findProperty(currentNode, 'size')
if sizeProp then
setPendingProperty('size', selectedWidget:getWidth() .. ' ' .. selectedWidget:getHeight())
else
setPendingProperty('width', tostring(selectedWidget:getWidth()))
setPendingProperty('height', tostring(selectedWidget:getHeight()))
end
status('Resized. Check the panel and save.')
else
local a = drag.anchors
local touched = false
if a.left then setPendingProperty('margin-left', tostring(selectedWidget:getMarginLeft())); touched = true end
if a.right then setPendingProperty('margin-right', tostring(selectedWidget:getMarginRight())); touched = true end
if a.top then setPendingProperty('margin-top', tostring(selectedWidget:getMarginTop())); touched = true end
if a.bottom then setPendingProperty('margin-bottom', tostring(selectedWidget:getMarginBottom())); touched = true end
if touched then
status('Moved through margins. Check the panel and save.')
else
status('This widget has no anchor in the file, so there is no margin to adjust.', true)
end
end
end
drag = nil
end
-- ============================================================ edit mode
function setPickMode(enabled)
if captureLayer and not captureLayer:isDestroyed() then captureLayer:destroy() end
captureLayer = nil
drag = nil
if hoverBox and not hoverBox:isDestroyed() then hoverBox:hide() end
if not enabled then
if resizeHandle and not resizeHandle:isDestroyed() then resizeHandle:hide() end
status('Edit mode off. The preview responds to clicks again.')
return
end
captureLayer = g_ui.createWidget('UIWidget', rootWidget)
captureLayer:setId('otuiCaptureLayer')
captureLayer:fill('parent')
captureLayer:setFocusable(false)
captureLayer.onMousePress = function(_, mousePos, button)
if button ~= MouseLeftButton then return false end
if not stage or stage:isDestroyed() then return true end
-- the resize handle takes priority over selection
if selectedWidget and not selectedWidget:isDestroyed() then
local rect = selectedWidget:getRect()
if mousePos.x >= rect.x + rect.width - HANDLE_SIZE
and mousePos.x <= rect.x + rect.width
and mousePos.y >= rect.y + rect.height - HANDLE_SIZE
and mousePos.y <= rect.y + rect.height then
beginDrag('resize', mousePos)
return true
end
end
local widget = stage:recursiveGetChildByPos(mousePos, true)
if widget then
modules.dev_otui.selectWidget(widget)
beginDrag('move', mousePos)
else
status('No widget at that point.')
end
return true
end
captureLayer.onMouseMove = function(_, mousePos)
if not stage or stage:isDestroyed() then return false end
if drag then
updateDrag(mousePos)
return true
end
hoverBox = ensureBox(hoverBox, '#ffcc00')
moveBoxTo(hoverBox, stage:recursiveGetChildByPos(mousePos, true))
if selectionBox and not selectionBox:isDestroyed() and selectionBox:isVisible() then
selectionBox:raise()
end
if resizeHandle and not resizeHandle:isDestroyed() and resizeHandle:isVisible() then
resizeHandle:raise()
end
return false
end
captureLayer.onMouseRelease = function(_, _, button)
if button ~= MouseLeftButton then return false end
finishDrag()
return true
end
restackLayers()
refreshSelectionVisuals()
status('Edit mode: click to select, drag to move, green corner to resize.')
end
-- ============================================================ picker (files / elements)
local PICKER_LIMIT = 500
-- Always-available base styles: UIManager defines by itself any name starting
-- with "UI" (uimanager.cpp:535), so they never appear in the style files.
local BUILTIN_STYLES = {
'UIWidget', 'UILabel', 'UIButton', 'UITextEdit', 'UICheckBox',
'UIScrollArea', 'UIScrollBar', 'UIWindow', 'UIProgressBar', 'UIItem',
}
function closePicker()
if pickerWindow and not pickerWindow:isDestroyed() then
pickerWindow:destroy()
end
pickerWindow = nil
pickerEntries = {}
pickerCallback = nil
pickerPreview = false
end
-- Instantiates the highlighted style inside the preview box, so you can see
-- what you are about to add instead of guessing from the name.
function previewPickerItem(styleName, origin)
if not pickerWindow or not pickerPreview then return end
local slot = pickerWindow:recursiveGetChildById('previewSlot')
slot:destroyChildren()
pickerWindow:recursiveGetChildById('previewName'):setText(styleName or '')
pickerWindow:recursiveGetChildById('previewOrigin'):setText(origin or '')
if not styleName then return end
-- styles that need a specific parent (MiniWindow wants a MiniWindowContainer)
-- cannot be built standalone; say so instead of leaving an empty box
local ok, sample = pcall(function() return g_ui.createWidget(styleName, slot) end)
if not ok or not sample then
pickerWindow:recursiveGetChildById('previewOrigin'):setText(
(origin or '') .. '\n(cannot be instantiated standalone)')
return
end
sample:breakAnchors()
sample:addAnchor(AnchorHorizontalCenter, 'parent', AnchorHorizontalCenter)
sample:addAnchor(AnchorVerticalCenter, 'parent', AnchorVerticalCenter)
if sample:getWidth() <= 1 then sample:setWidth(120) end
if sample:getHeight() <= 1 then sample:setHeight(20) end
pickerWindow:recursiveGetChildById('previewName'):setText(
styleName .. ' ' .. sample:getWidth() .. 'x' .. sample:getHeight())
end
function filterPicker()
if not pickerWindow then return end
local list = pickerWindow:recursiveGetChildById('itemList')
list:destroyChildren()
local filter = pickerWindow:recursiveGetChildById('filterEdit'):getText():trim():lower()
local shown = 0
for _, entry in ipairs(pickerEntries) do
if filter == '' or entry.label:lower():find(filter, 1, true) then
local item = g_ui.createWidget('OtuiPickerItem', list)
item:setText(entry.label)
item.pickerValue = entry.value
item.pickerOrigin = entry.origin
item.onDoubleClick = function() modules.dev_otui.acceptPicker() end
item.onFocusChange = function(self, focused)
if focused then
modules.dev_otui.previewPickerItem(self.pickerValue, self.pickerOrigin)
end
end
shown = shown + 1
if shown >= PICKER_LIMIT then break end
end
end
local total = #pickerEntries
local text = shown .. ' of ' .. total
if shown >= PICKER_LIMIT then
text = text .. ' (limited to ' .. PICKER_LIMIT .. ' - refine the search)'
end
pickerWindow:recursiveGetChildById('countLabel'):setText(text)
end
function acceptPicker()
if not pickerWindow then return end
local list = pickerWindow:recursiveGetChildById('itemList')
local item = list:getFocusedChild()
if not item then
status('Pick an item from the list.', true)
return
end
local value = item.pickerValue
local callback = pickerCallback
if callback then callback(value) end
end
local function openPicker(title, hint, entries, callback, withPreview)
closePicker()
pickerEntries = entries
pickerCallback = callback
pickerPreview = withPreview or false
pickerWindow = g_ui.displayUI('dev_otui_picker')
-- without a preview the list takes the whole width
local previewBox = pickerWindow:recursiveGetChildById('previewBox')
previewBox:setVisible(pickerPreview)
if not pickerPreview then
previewBox:setWidth(0)
end
pickerWindow:setText(title)
pickerWindow:recursiveGetChildById('hintLabel'):setText(hint)
pickerWindow:recursiveGetChildById('acceptBtn').onClick = function()
modules.dev_otui.acceptPicker()
end
pickerWindow:recursiveGetChildById('cancelBtn').onClick = function()
modules.dev_otui.closePicker()
end
local filterEdit = pickerWindow:recursiveGetChildById('filterEdit')
filterEdit.onTextChange = function() modules.dev_otui.filterPicker() end
filterEdit:focus()
filterPicker()
pickerWindow:raise()
end
-- Data folders with no .otui at all and thousands of files: scanning them
-- recursively would freeze the UI for seconds on every "Open...".
local SKIP_DIRS = {
things = true, images = true, sounds = true, fonts = true,
sprites = true, minimap = true, particles = true,
}
local function collectOtuiFiles()
if fileCache then return fileCache end
local out = {}
local roots = {}
pcall(function()
roots = g_resources.listDirectoryFiles('/', false, false, false)
end)
for _, name in ipairs(roots) do
if not SKIP_DIRS[name:lower()] then
local entry = '/' .. name
if g_resources.directoryExists(entry) then
local files = {}
pcall(function()
files = g_resources.listDirectoryFiles(entry, true, false, true)
end)
for _, file in ipairs(files) do
if file:ends('.otui') then
table.insert(out, { label = file, value = file })
end
end
elseif entry:ends('.otui') then
table.insert(out, { label = entry, value = entry })
end
end
end
table.sort(out, function(a, b) return a.label < b.label end)
fileCache = out
return out
end
-- Builds the palette by reading the "Name < Base" definitions of style files.
local function collectStyles()
if styleCache then return styleCache end
local seen, out = {}, {}
for _, name in ipairs(BUILTIN_STYLES) do
seen[name] = true
table.insert(out, {
label = name .. ' (base)',
value = name,
origin = 'defined by UIManager itself'
})
end
local files = {}
pcall(function()
files = g_resources.listDirectoryFiles('/styles', true, false, true)
end)
-- the open file itself may also define local styles
if targetPath then table.insert(files, targetPath) end
for _, file in ipairs(files) do
if file:ends('.otui') then
local okRead, text = pcall(function() return g_resources.readFileContents(file) end)
if okRead and text then
local okParse, parsed = pcall(function() return Otml.parse(text) end)
if okParse and parsed then
for _, def in ipairs(parsed.styleDefs) do
local name = def.tag:match('^([%w_]+)%s*<')
if name and not seen[name] then
seen[name] = true
table.insert(out, {
label = name .. ' (' .. file .. ')',
value = name,
origin = file .. ' line ' .. def.line
})
end
end
end
end
end
end
table.sort(out, function(a, b) return a.label < b.label end)
styleCache = out
return out
end
-- Every anchor an edge can legally take. An edge only hooks to an edge of the
-- same axis: anchors.top accepts top/bottom/verticalCenter, never left.
local VERTICAL_EDGES = { 'top', 'bottom', 'verticalCenter' }
local HORIZONTAL_EDGES = { 'left', 'right', 'horizontalCenter' }
local function anchorEntries()
local out = {}
table.insert(out, {
label = 'anchors.fill <- parent (fills the parent area)',
value = { key = 'anchors.fill', value = 'parent' },
})
table.insert(out, {
label = 'anchors.centerIn <- parent (centers in the parent)',
value = { key = 'anchors.centerIn', value = 'parent' },
})
-- targets: the parent, the neighbours, and every sibling that has an id
local targets = { 'parent', 'prev', 'next' }
if currentNode and currentNode.parent then
for _, sibling in ipairs(Otml.widgetChildren(currentNode.parent)) do
if sibling ~= currentNode then
local idProp = Otml.findProperty(sibling, 'id')
if idProp and idProp.value and idProp.value ~= '' then
table.insert(targets, idProp.value)
end
end
end
end
local edges = {
{ 'top', VERTICAL_EDGES }, { 'bottom', VERTICAL_EDGES },
{ 'verticalCenter', VERTICAL_EDGES },
{ 'left', HORIZONTAL_EDGES }, { 'right', HORIZONTAL_EDGES },
{ 'horizontalCenter', HORIZONTAL_EDGES },
}
for _, edge in ipairs(edges) do
for _, target in ipairs(targets) do
for _, targetEdge in ipairs(edge[2]) do
table.insert(out, {
label = 'anchors.' .. edge[1] .. ' <- ' .. target .. '.' .. targetEdge,
value = { key = 'anchors.' .. edge[1], value = target .. '.' .. targetEdge },
})
end
end
end
return out
end
function editAnchors()
if not selectedWidget or not currentNode then
status('Select a widget first.', true)
return
end
local hint = 'Type an edge to filter, e.g. "top". Siblings with an id are listed as targets.'
openPicker('Anchor', hint, anchorEntries(), function(choice)
closePicker()
setPendingProperty(choice.key, choice.value)
applyAll()
drawGuides()
status('Anchor set: ' .. choice.key .. ': ' .. choice.value ..
'. Still a preview - use "Save to file" to persist.')
end, false)
end
function browseFiles()
local entries = collectOtuiFiles()
if #entries == 0 then
status('Found no .otui files.', true)
return
end
openPicker('Open .otui', 'Double-click, or select and press "Use"', entries, function(path)
closePicker()
editorWindow:recursiveGetChildById('pathEdit'):setText(path)
loadTarget()
end, false)
end
-- Generates an id free in the file, like button1, button2...
local function suggestId(styleName)
local base = styleName:gsub('^UI', ''):lower()
local used = {}
if doc and doc.root then
local function walk(node)
local idProp = Otml.findProperty(node, 'id')
if idProp and idProp.value then used[idProp.value] = true end
for _, child in ipairs(Otml.widgetChildren(node)) do walk(child) end
end
walk(doc.root)
end
local i = 1
while used[base .. i] do i = i + 1 end
return base .. i
end
local function insertElement(styleName)
if not targetPath then
status('Load a file first.', true)
return
end
local parentRef = currentRef
local parentPath = (parentRef and parentRef.kind == 'widget') and parentRef.path or nil
if not readDocument(targetPath) then
status('Could not re-read the file.', true)
return
end
local originalText = Otml.serialize(doc)
-- With no selection the new element goes to the screen root. With one, a
-- reference that no longer resolves means the file moved under us; falling
-- back to the root here would quietly insert somewhere else entirely.
local parentNode
if parentRef then
parentNode = resolveRef(doc, parentRef)
if not parentNode then
status('The selected widget is no longer at the same place in the file. ' ..
'Nothing was written - reload and pick it again.', true)
return
end
else
parentNode = doc.root
end
if not parentNode then
status('Could not find the parent widget in the file.', true)
return
end
-- A widget with no anchor cannot be positioned or dragged, so it never goes
-- in bare. Under a box layout the parent places its children and anchors are
-- ignored, so there we add none.
local props = {}
local layoutProp = Otml.findProperty(parentNode, 'layout')
local managed = layoutProp ~= nil
if not managed then
if #Otml.widgetChildren(parentNode) > 0 then
-- stack below the previous sibling, the usual pattern in this codebase
props = {
{ 'anchors.top', 'prev.bottom' },
{ 'anchors.left', 'parent.left' },
{ 'margin-top', '4' },
}
else
props = {
{ 'anchors.top', 'parent.top' },
{ 'anchors.left', 'parent.left' },
}
end
end
local newId = suggestId(styleName)
local ok, err = Otml.addChildWidget(doc, parentNode, styleName, newId, props)
if not ok then
status('Could not insert: ' .. tostring(err), true)
return
end
local okWrite, result = persistText(targetPath, Otml.serialize(doc), originalText)
if not okWrite then
status(result, true)
readDocument(targetPath)
return
end
targetFileTime = g_resources.getFileTime(targetPath)
loadInto(targetPath, parentPath)
local how = managed
and ' (the parent has a layout, so it places the child)'
or ' anchored to ' .. (props[1] and props[1][2] or 'parent')
status(styleName .. ' #' .. newId .. ' added to ' .. (parentNode.tag or '?') .. how)
end
function addElement()
if not targetPath then
status('Load a file first.', true)
return
end
local parentName = 'screen root'
if selectedWidget and currentNode then
parentName = currentNode.tag .. ' (selected)'
elseif selectedWidget and not currentNode then
status('The selected widget is not mapped in the file: ' .. tostring(nodeError), true)
return
end
openPicker('Add element', 'Goes in as a child of: ' .. parentName,
collectStyles(), function(styleName)
closePicker()
insertElement(styleName)
end, true)
end
-- Deletes the selected widget from the file. Destructive and it takes the
-- children along, so it asks first; the .bak still covers a mistake.
local function deleteSelectedNode()
if not targetPath then
status('Load a file first.', true)
return
end
local ref = currentRef
if not ref or ref.kind ~= 'widget' then
status('Select a widget of the tree first.', true)
return
end
if not readDocument(targetPath) then
status('Could not re-read the file.', true)
return
end
local originalText = Otml.serialize(doc)
local node = resolveRef(doc, ref)
if not node then
status('Could not find the widget in the file. Nothing was removed.', true)
return
end
if node == doc.root then
status('Cannot remove the main widget: the file would have no screen.', true)
return
end
local ok, err = Otml.removeNode(doc, node)
if not ok then
status('Could not remove: ' .. tostring(err), true)
readDocument(targetPath)
return
end
local okWrite, result = persistText(targetPath, Otml.serialize(doc), originalText)
if not okWrite then
status(result, true)
readDocument(targetPath)
return
end
targetFileTime = g_resources.getFileTime(targetPath)
selectedPath = nil
loadInto(targetPath, nil)
status('Widget removed. The previous file is at ' .. result .. '.bak')
end
function removeElement()
if not selectedWidget or not currentNode then
status('Select a widget first.', true)
return
end
if currentRef and currentRef.kind == 'style' then
status('Removing a style definition is not supported here; edit the file.', true)
return
end
local label = currentNode.tag
local idProp = Otml.findProperty(currentNode, 'id')
if idProp and idProp.value then
label = label .. ' #' .. idProp.value
end
local children = #Otml.widgetChildren(currentNode)
local message = 'Remove ' .. label .. ' from the file?'
if children > 0 then
message = message .. '\n' .. children .. ' child widget(s) are removed with it.'
end
local box
box = displayGeneralBox('Remove element', message, {
{
text = 'Yes',
callback = function()
if box then box:destroy() end
modules.dev_otui.confirmRemoveElement()
end
},
{
text = 'No',
callback = function()
if box then box:destroy() end
end
},
anchor = AnchorHorizontalCenter
})
end
-- kept public because the message box callback needs a reachable function
function confirmRemoveElement()
deleteSelectedNode()
end
-- The id of a new screen comes from its file name. Everything that is not a
-- letter or a digit is dropped, which can leave nothing at all ("__.otui"), and
-- a widget written with an empty id cannot be looked up by name afterwards.
local function screenIdFromPath(path)
local name = (path or ''):match('([^/]+)%.otui$') or ''
name = name:gsub('[^%w]', '')
if name == '' then return 'newScreen' end
return name
end
function newScreen()
local path = normalizePath(editorWindow:recursiveGetChildById('pathEdit'):getText())
if not path then
status('Type the path of the new file in the field above.', true)
return
end
if g_resources.fileExists(path) then
status('Already exists: ' .. path .. '. Pick another name so nothing is overwritten.', true)
return
end
local id = screenIdFromPath(path)
local text = Otml.skeleton('MainWindow', id, id)
local ok, result = persistText(path, text, nil)
if not ok then
status(result, true)
return
end
fileCache = nil -- the new file must show up under "Open..."
g_settings.set('dev_otui_lastPath', path)
selectedPath = nil
loadInto(path, nil)
status('Screen created at ' .. result .. '. Use "+ Element" to populate it.')
end
-- ============================================================ style gallery
local galleryWindow = nil
local galleryBuildEvent = nil
local MAX_SAMPLE_HEIGHT = 90 -- MainWindow is 200x200; rows stay readable
function closeGallery()
if galleryBuildEvent then
removeEvent(galleryBuildEvent)
galleryBuildEvent = nil
end
if galleryWindow and not galleryWindow:isDestroyed() then
galleryWindow:destroy()
end
galleryWindow = nil
end
-- Classes whose widgets cannot be built outside a real screen: their Lua setup
-- assumes children that only exist there. Building them here renders nothing
-- useful and floods the log, since the failing callback runs again on every
-- geometry change. UIStatsBar:reloadBorder indexes self.grade, which stays nil
-- without its horizontalGrade/verticalGrade children.
-- The menu classes are worse than noisy: UIPopupScrollMenu wires a scroll area
-- and a scroll bar to itself in create(), and standing alone that arrangement
-- never settles -- each layout pass schedules another through
-- UILayout::updateLater, and deferred events run inside the same poll, so the
-- client freezes with no error at all. A menu only exists while display() has
-- it on the root widget, so there is nothing to sample here anyway.
local UNSAFE_CLASSES = {
UIStatsBar = 'needs its grade children; only works inside a real screen',
UIPopupScrollMenu = 'a menu only exists while displayed; standalone it loops the layout',
UIPopupMenu = 'a menu only exists while displayed; standalone it loops the layout',
}
local function fileIsThere(path)
local ok, found = pcall(function() return g_resources.fileExists(path) end)
return ok and found
end
-- image-source values are written without the extension, and the loader appends
-- '.png' when resolving them.
local function textureMissing(path)
if not path or path == '' then return false end
-- only absolute paths are ours to check; anything else is a colour or an alias
if path:sub(1, 1) ~= '/' then return false end
if fileIsThere(path) then return false end
if not path:match('%.%w+$') and fileIsThere(path .. '.png') then return false end
return true
end
-- Every image-source declared inside the block, states included, so a texture
-- referenced only under $hover is not missed.
local function imageSources(node, out)
out = out or {}
for _, child in ipairs(node.children) do
if child.isProp and child.tag == 'image-source' then
table.insert(out, child.value)
elseif #child.children > 0 then
imageSources(child, out)
end
end
return out
end
-- The first missing texture this style would try to load. Walks up the bases,
-- but stops at the first level that declares an image-source of its own: an
-- override hides whatever the base pointed at, broken or not.
local function missingTexture(defs, name, seen)
seen = seen or {}
if seen[name] then return nil end
seen[name] = true
local entry = defs[name]
if not entry then return nil end
local declared = imageSources(entry.node)
if #declared > 0 then
for _, path in ipairs(declared) do
if textureMissing(path) then return path end
end
return nil
end
return entry.base and missingTexture(defs, entry.base, seen) or nil
end
-- A style block cannot define another style inside it: OTML reads the nested
-- line as a child widget whose style name is the whole 'Name < Base' string,
-- which is never defined. data/styles/global_alias_test.otui does exactly this.
local function nestedStyleDef(node)
for _, child in ipairs(node.children) do
if not child.isProp and child.tag:find('<', 1, true) then
return child.tag
end
local nested = nestedStyleDef(child)
if nested then return nested end
end
return nil
end
-- Why a style cannot be sampled, or nil when it can. Checking up front avoids
-- the engine error that asking anyway would log.
local function classProblem(styleName)
local ok, className = pcall(function() return g_ui.getStyleClass(styleName) end)
if not ok or not className or className == '' then return nil end
if UNSAFE_CLASSES[className] then
return UNSAFE_CLASSES[className]
end
-- data/styles has real cases of a base that exists nowhere, e.g.
-- VerticalBar < UIVerticalProgressBarSD
local okGlobal, value = pcall(function() return _G[className] end)
if okGlobal and value == nil then
return 'class ' .. className .. ' is not registered'
end
return nil
end
-- The full pre-check, run before the style is instantiated. Everything it
-- catches would otherwise reach the log as an engine error.
local function styleProblem(defs, name)
local problem = classProblem(name)
if problem then return problem end
local entry = defs[name]
if not entry then return nil end
local nested = nestedStyleDef(entry.node)
if nested then
return 'declares a nested style (' .. nested .. '), which OTML does not accept'
end
local texture = missingTexture(defs, name)
if texture then
return 'image-source ' .. texture .. ' is not in the data folder'
end
return nil
end
-- Renders every style of every file under /styles into one scrollable list,
-- grouped by file. This is the closest thing this project has to a component
-- gallery, and it is exact: the samples are drawn by the client's own renderer.
--
-- Styles that cannot render are not hidden: they are listed with the reason,
-- which makes this double as an audit of data/styles.
function openGallery()
closeGallery()
galleryWindow = g_ui.displayUI('dev_otui_gallery')
local list = galleryWindow:recursiveGetChildById('galleryList')
local files = {}
pcall(function()
files = g_resources.listDirectoryFiles('/styles', true, false, true)
end)
table.sort(files)
local shown, failed, groups = 0, 0, 0
local brokenStyles = {}
-- First pass: parse every file and index the definitions by name. The index is
-- what lets the pre-check follow a style's bases, which live in other files.
local parsedFiles = {}
local defs = {}
for _, file in ipairs(files) do
if file:ends('.otui') then
local okRead, text = pcall(function() return g_resources.readFileContents(file) end)
local parsed
if okRead and text then
local okParse, result = pcall(function() return Otml.parse(text) end)
if okParse then parsed = result end
end
if parsed and #parsed.styleDefs > 0 then
table.insert(parsedFiles, { file = file, doc = parsed })
for _, def in ipairs(parsed.styleDefs) do
local name, base = def.tag:match('^([%w_]+)%s*<%s*([%w_]+)')
-- last definition wins, the same rule the engine applies
if name then defs[name] = { node = def, base = base, file = file } end
end
end
end
end
local countLabel = galleryWindow:recursiveGetChildById('galleryCount')
local function renderStyle(file, def)
local name, base = def.tag:match('^([%w_]+)%s*<%s*([%w_]+)')
if not name then return end
local row = g_ui.createWidget('OtuiGalleryRow', list)
local label = row:getChildById('styleName')
local slot = row:getChildById('sampleSlot')
label:setText(name .. ' < ' .. (base or '?'))
local problem = styleProblem(defs, name)
local ok, sample
if not problem then
-- styles needing a specific parent (MiniWindow wants a
-- MiniWindowContainer) throw here: report, do not crash
ok, sample = pcall(function() return g_ui.createWidget(name, slot) end)
end
if ok and sample and not sample:isDestroyed() then
sample:breakAnchors()
sample:addAnchor(AnchorLeft, 'parent', AnchorLeft)
sample:addAnchor(AnchorVerticalCenter, 'parent', AnchorVerticalCenter)
if sample:getWidth() <= 1 then sample:setWidth(120) end
if sample:getHeight() <= 1 then sample:setHeight(18) end
local height = math.min(sample:getHeight(), MAX_SAMPLE_HEIGHT)
row:setHeight(math.max(26, height + 6))
shown = shown + 1
else
-- a half-built widget may have been left behind before the throw
slot:destroyChildren()
local reason = problem or 'needs a specific parent or setup'
label:setColor('#ff8080')
label:setText(name .. ' < ' .. (base or '?') .. ' (' .. reason .. ')')
label:setTooltip(name .. ': ' .. reason)
failed = failed + 1
table.insert(brokenStyles, { name = name, file = file, reason = reason })
end
end
local function finish()
countLabel:setText(
shown .. ' rendered in ' .. groups .. ' files | ' .. failed .. ' could not render')
-- the audit is worth more than the count: print what is broken and why
if #brokenStyles > 0 then
print('[dev_otui] ' .. #brokenStyles .. ' style(s) in data/styles cannot be instantiated:')
for _, broken in ipairs(brokenStyles) do
print(string.format(' %-34s %-34s %s', broken.name, broken.file, broken.reason))
end
end
status('Style gallery: ' .. shown .. ' live samples, ' .. failed ..
' broken (listed in the terminal, Ctrl+T).')
end
-- Second pass: one sample per frame.
--
-- Building everything inside a single call means the engine lays out the ~250
-- samples in one pass, after this function has already returned: the client
-- has nothing on screen while it does that, and a style whose
-- onGeometryChange keeps re-triggering locks it there with no clue why. One
-- sample per frame keeps the window drawn throughout, and the style being
-- built is named before it is built, so a lock-up names its own cause.
local queue = {}
for _, item in ipairs(parsedFiles) do
table.insert(queue, { header = item })
for _, def in ipairs(item.doc.styleDefs) do
table.insert(queue, { file = item.file, def = def })
end
end
local index = 1
local function step()
galleryBuildEvent = nil
if not galleryWindow or galleryWindow:isDestroyed() then return end
local entry = queue[index]
if not entry then
finish()
return
end
if entry.header then
local header = g_ui.createWidget('OtuiGalleryHeader', list)
header:setText(entry.header.file .. ' (' .. #entry.header.doc.styleDefs .. ')')
groups = groups + 1
else
-- one style blowing up must not take the rest of the build with it: the
-- error would leave the next frame unscheduled and the list half built
local ok, err = pcall(renderStyle, entry.file, entry.def)
if not ok then
failed = failed + 1
table.insert(brokenStyles,
{ name = entry.def.tag, file = entry.file, reason = 'error: ' .. tostring(err) })
end
end
countLabel:setText(string.format('building %d/%d', index, #queue))
index = index + 1
galleryBuildEvent = scheduleEvent(step, 1)
end
-- the editor raises its own layers when it reloads a file, so the gallery
-- makes its own state explicit instead of relying on creation order
galleryWindow:show()
galleryWindow:raise()
galleryWindow:focus()
countLabel:setText('building...')
status('Style gallery: building ' .. #queue .. ' entries...')
-- the first file waits a frame too, so the empty window is drawn first
galleryBuildEvent = scheduleEvent(step, 1)
end
-- ============================================================ self test
-- Run in the terminal (Ctrl+T): modules.dev_otui.selfTest()
-- Validates the parser before you trust it to write your files.
local SAMPLE = table.concat({
'// top comment',
'MyStyle < Button',
' height: 20',
'',
'Window',
' id: win',
' size: 220 500',
'',
' Button',
' id: b1',
' height: 22',
' anchors.top: parent.top',
'',
' Panel',
' id: p1',
' layout:',
' type: verticalBox',
' spacing: 2',
'',
}, '\n')
function selfTest()
local failures = {}
local function check(cond, msg)
if not cond then table.insert(failures, msg) end
end
local d = Otml.parse(SAMPLE)
check(Otml.serialize(d) == SAMPLE, 'round-trip changed the file')
check(d.root and d.root.tag == 'Window', 'root should be Window')
check(#d.styleDefs == 1, 'there should be 1 style definition')
local kids = Otml.widgetChildren(d.root)
check(#kids == 2, 'Window should have 2 child widgets, has ' .. #kids)
local sizeProp = Otml.findProperty(d.root, 'size')
check(sizeProp and sizeProp.value == '220 500', 'size read incorrectly')
local button, panel = kids[1], kids[2]
check(button and button.tag == 'Button', 'first child should be Button')
check(panel and panel.tag == 'Panel', 'second child should be Panel')
local anchor = button and Otml.findProperty(button, 'anchors.top')
check(anchor and anchor.value == 'parent.top', 'anchors.top read incorrectly')
-- the content of "layout:" must not be mistaken for child widgets
if panel then
check(#Otml.widgetChildren(panel) == 0, 'content of layout: became a widget')
local layout = Otml.findProperty(panel, 'layout')
check(layout and #layout.children == 2, 'layout: block read incorrectly')
end
-- changing an existing property must not touch other lines
local d2 = Otml.parse(SAMPLE)
local b2 = Otml.widgetChildren(d2.root)[1]
Otml.setProperty(d2, b2, 'height', '26')
local out2 = Otml.serialize(d2)
check(out2:find('\n height: 26\n', 1, true) ~= nil, 'height was not changed')
check(out2:find('\n height: 20\n', 1, true) ~= nil, 'changed the style height by mistake')
check(#Otml.parse(out2).lines == #d2.lines, 'line count changed without an insertion')
-- inserting a new property: goes after the last one, with the block indentation
local d3 = Otml.parse(SAMPLE)
local b3 = Otml.widgetChildren(d3.root)[1]
Otml.setProperty(d3, b3, 'margin-top', '4')
local out3 = Otml.serialize(d3)
check(out3:find(' anchors.top: parent.top\n margin-top: 4\n', 1, true) ~= nil,
'insertion landed in the wrong place')
-- removing a property
local d4 = Otml.parse(SAMPLE)
local b4 = Otml.widgetChildren(d4.root)[1]
Otml.removeProperty(d4, b4, 'height')
local out4 = Otml.serialize(d4)
check(out4:find(' height: 22', 1, true) == nil, 'height was not removed')
check(out4:find(' id: b1', 1, true) ~= nil, 'removed too many lines')
-- removing a composite block takes its children along
local d5 = Otml.parse(SAMPLE)
local p5 = Otml.widgetChildren(d5.root)[2]
Otml.removeProperty(d5, p5, 'layout')
local out5 = Otml.serialize(d5)
check(out5:find('verticalBox', 1, true) == nil, 'leftover content from the removed block')
check(out5:find(' id: p1', 1, true) ~= nil, 'removed too many lines in the block')
-- inserting a child widget at the end of the parent block
local d6 = Otml.parse(SAMPLE)
Otml.addChildWidget(d6, d6.root, 'Label', 'meuLabel')
local out6 = Otml.serialize(d6)
check(out6:find(' Label\n id: meuLabel', 1, true) ~= nil, 'new widget formatted incorrectly')
local reparsed6 = Otml.parse(out6)
check(#Otml.widgetChildren(reparsed6.root) == 3, 'new widget not recognised as a child')
check(Otml.widgetChildren(reparsed6.root)[3].tag == 'Label', 'new widget landed in the wrong position')
-- inserting inside a child respects that level's indentation
local d7 = Otml.parse(SAMPLE)
local panel7 = Otml.widgetChildren(d7.root)[2]
Otml.addChildWidget(d7, panel7, 'Button', 'dentro')
local reparsed7 = Otml.parse(Otml.serialize(d7))
local panelAfter = Otml.widgetChildren(reparsed7.root)[2]
check(#Otml.widgetChildren(panelAfter) == 1, 'nested child was not recognised')
check(#Otml.widgetChildren(reparsed7.root) == 2, 'nested child moved up a level')
-- style library: no main widget, the showcase case
local styleLib = table.concat({
'Button < UIButton',
' color: #ffffff',
'',
'TabButton < UIButton',
' size: 22 23',
'',
}, '\n')
local d9 = Otml.parse(styleLib)
check(d9.root == nil, 'a style library should have no main widget')
check(#d9.styleDefs == 2, 'should detect 2 style definitions, found ' .. #d9.styleDefs)
check(d9.styleDefs[1].tag:match('^([%w_]+)%s*<') == 'Button', 'style name extracted incorrectly')
check(Otml.findProperty(d9.styleDefs[2], 'size') ~= nil, 'style property was not read')
-- removing a widget takes its whole block, and only that
local d10 = Otml.parse(SAMPLE)
local panel10 = Otml.widgetChildren(d10.root)[2]
Otml.removeNode(d10, panel10)
local out10 = Otml.serialize(d10)
check(out10:find('Panel', 1, true) == nil, 'removed widget left leftovers')
check(out10:find('verticalBox', 1, true) == nil, 'block of the removed widget survived')
check(out10:find(' id: b1', 1, true) ~= nil, 'removal ate a sibling widget')
local reparsed10 = Otml.parse(out10)
check(#Otml.widgetChildren(reparsed10.root) == 1, 'wrong child count after removal')
check(reparsed10.root.tag == 'Window', 'removal damaged the root')
-- removing a nested widget must not touch its parent
local d11 = Otml.parse(SAMPLE)
Otml.addChildWidget(d11, Otml.widgetChildren(d11.root)[1], 'Label', 'inner')
d11 = Otml.parse(Otml.serialize(d11))
local button11 = Otml.widgetChildren(d11.root)[1]
Otml.removeNode(d11, Otml.widgetChildren(button11)[1])
local reparsed11 = Otml.parse(Otml.serialize(d11))
local buttonAfter = Otml.widgetChildren(reparsed11.root)[1]
check(#Otml.widgetChildren(buttonAfter) == 0, 'nested widget was not removed')
check(Otml.findProperty(buttonAfter, 'height') ~= nil, 'removal ate a parent property')
check(#Otml.widgetChildren(reparsed11.root) == 2, 'removal changed the root child count')
-- a new widget must come in with anchors, otherwise it cannot be positioned
local d12 = Otml.parse(SAMPLE)
Otml.addChildWidget(d12, d12.root, 'Label', 'anchored', {
{ 'anchors.top', 'prev.bottom' },
{ 'anchors.left', 'parent.left' },
})
local reparsed12 = Otml.parse(Otml.serialize(d12))
local added = Otml.widgetChildren(reparsed12.root)[3]
check(added and added.tag == 'Label', 'anchored widget was not added')
if added then
local top = Otml.findProperty(added, 'anchors.top')
check(top and top.value == 'prev.bottom', 'anchor was not written')
check(#Otml.widgetChildren(added) == 0, 'anchor lines became child widgets')
check(added.indent == 2, 'anchored widget landed at the wrong indentation')
end
-- the new-screen skeleton must be parseable
local d8 = Otml.parse(Otml.skeleton('MainWindow', 'myScreen', 'My Screen'))
check(d8.root and d8.root.tag == 'MainWindow', 'skeleton has no main widget')
check(Otml.findProperty(d8.root, 'id') ~= nil, 'skeleton has no id')
-- the id of a new screen comes from the file name and must stay usable
check(screenIdFromPath('/game_idle/idle_panel.otui') == 'idlepanel',
'punctuation should be stripped out of the id')
check(screenIdFromPath('/x/MyWindow.otui') == 'MyWindow',
'an already valid name should pass through unchanged')
check(screenIdFromPath('/x/__.otui') == 'newScreen',
'a name with no letters or digits needs a fallback id')
check(screenIdFromPath('') == 'newScreen', 'an empty path needs a fallback id')
check(Otml.parse(Otml.skeleton('MainWindow', screenIdFromPath('/x/__.otui'), 'x')).root ~= nil,
'the skeleton built from a fallback id must still parse')
-- A file reference is a path of child indices: it always resolves, even after
-- someone reorders the file. Identity is what keeps a save off the wrong node.
local beforeEdit = Otml.parse(table.concat({
'Window', ' Button', ' id: ok', ' Label', ' id: cancel', '',
}, '\n'))
local afterEdit = Otml.parse(table.concat({
'Window', ' Label', ' id: cancel', ' Button', ' id: ok', '',
}, '\n'))
local firstChild = { kind = 'widget', path = { 1, 1 } }
local nodeBefore = resolveRef(beforeEdit, firstChild)
local nodeAfter = resolveRef(afterEdit, firstChild)
check(nodeBefore and nodeBefore.tag == 'Button', 'the reference should resolve to the Button')
check(nodeAfter and nodeAfter.tag == 'Label', 'the same path should now land on the Label')
check(not sameNodeIdentity(nodeBefore, nodeAfter),
'a reordered file must not be treated as the same node')
check(sameNodeIdentity(nodeBefore, resolveRef(Otml.parse(Otml.serialize(beforeEdit)), firstChild)),
'an unchanged file must still resolve to the same node')
-- same style name, different id: still a different widget
local renamed = Otml.parse(table.concat({
'Window', ' Button', ' id: other', '',
}, '\n'))
check(not sameNodeIdentity(nodeBefore, resolveRef(renamed, firstChild)),
'a widget with another id must not be treated as the same node')
check(not sameNodeIdentity(nodeBefore, nil), 'an unresolved reference is never the same node')
-- A click selects and then starts a drag; when the selection does not happen
-- the drag must decline instead of indexing a nil widget.
local keepSelected, keepDrag = selectedWidget, drag
selectedWidget, drag = nil, nil
local dragOk, dragStarted = pcall(beginDrag, 'move', { x = 0, y = 0 })
check(dragOk, 'beginDrag must not error without a selected widget')
check(dragStarted == false, 'beginDrag must refuse without a selected widget')
check(drag == nil, 'beginDrag must not leave a drag in progress')
selectedWidget, drag = keepSelected, keepDrag
-- Closing the preview must not leave a stale reason behind: a later save
-- would report why the *previous* widget could not be mapped.
local keepNode, keepError, keepRef = currentNode, nodeError, currentRef
currentNode, nodeError, currentRef = {}, 'stale reason', {}
clearNodeSelection()
check(currentNode == nil and nodeError == nil and currentRef == nil,
'clearNodeSelection must reset all three, nodeError included')
currentNode, nodeError, currentRef = keepNode, keepError, keepRef
-- A read failure must reach the user. readDocument writes the module's `doc`,
-- so the current one is put back before returning.
local savedDoc = doc
check(readDocument('/dev_otui/__no_such_file__.otui') == false,
'readDocument should return false for a missing file')
check(doc == nil, 'readDocument should clear the document when it fails')
local said = editorWindow
and editorWindow:recursiveGetChildById('statusLabel'):getText() or ''
check(said:find('__no_such_file__', 1, true) ~= nil,
'a read failure must be reported, status said: ' .. said)
doc = savedDoc
if #failures == 0 then
print('[dev_otui] selfTest: OK (all checks passed)')
status('selfTest: OK')
else
print('[dev_otui] selfTest FAILED:')
for _, msg in ipairs(failures) do print(' - ' .. msg) end
status('selfTest failed on ' .. #failures .. ' check(s) - see the terminal', true)
end
return #failures == 0
end
-- ============================================================ lifecycle
function toggle()
if editorWindow:isVisible() then
editorWindow:hide()
if topButton then topButton:setOn(false) end
else
editorWindow:show()
restackLayers()
editorWindow:focus()
if topButton then topButton:setOn(true) end
end
end
function init()
editorWindow = g_ui.displayUI('dev_otui')
editorWindow:hide()
local saved = g_settings.getString('dev_otui_lastPath')
if saved and saved ~= '' then
editorWindow:recursiveGetChildById('pathEdit'):setText(saved)
end
topButton = modules.client_topmenu.addTopRightToggleButton(
'otuiEditorButton', tr('OTUI Editor'), '/images/topbuttons/buttons', toggle)
topButton:setOn(false)
Keybind.new('Debug', 'Toggle OTUI Editor', 'Ctrl+Alt+U', '')
Keybind.bind('Debug', 'Toggle OTUI Editor', {
{ type = KEY_DOWN, callback = toggle }
})
end
function terminate()
removeEvent(watchEvent)
watchEvent = nil
Keybind.delete('Debug', 'Toggle OTUI Editor')
closePicker()
closeGallery()
closeStage()
clearGuides()
for _, widget in ipairs({ selectionBox, hoverBox, resizeHandle, captureLayer }) do
if widget and not widget:isDestroyed() then widget:destroy() end
end
selectionBox, hoverBox, resizeHandle, captureLayer = nil, nil, nil, nil
selectedPath = nil
doc = nil
if topButton then topButton:destroy() end
if editorWindow then editorWindow:destroy() end
topButton = nil
editorWindow = nil
end