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