From 569fd4ed67ed7c07ddd90629e5fbe163935b9fea Mon Sep 17 00:00:00 2001 From: Renan Miyashiro <37278838+renanale@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:49:27 -0300 Subject: [PATCH 1/7] feat(dev_otui): visual .otui screen editor inside the client A development tool to build and edit .otui screens while seeing the real result: the preview is drawn by OTClient's own renderer, so bitmap fonts, image-border 9-slice and the style cascade are exact, not approximated. New self-contained module; no existing file is modified. dev_otui.lua editor, inspector, drag/resize, saving otml.lua line-oriented OTML parser/writer dev_otui.otui main window (resizable) dev_otui_picker.otui file/element/anchor picker, with live preview dev_otui_gallery.otui gallery of every style in data/styles dev_otui.otmod module manifest README.md usage and design notes Features: - live preview of any .otui with hot-reload on save (300 ms), keeping the selected widget across reloads - widget tree, click-to-select on screen, selection highlight - property panel mirroring the file's LINES rather than the widget's getters, which is what exposes anchors.* and @onClick (no getter on UIWidget) and shows every value exactly as written - dragging moves the widget through margins, honouring the anchors declared in the file; the corner handle resizes - add elements from a palette that previews each style live; new widgets are written with anchors, since an unanchored widget cannot be positioned - anchor picker offering only same-axis combinations over the parent, prev, next and every sibling with an id - guides drawing the parent's padding area and centre lines, and boxing the sibling an anchor points at - delete the selected widget from the file, children included, with a prompt - style gallery rendering all 225 styles of data/styles in one scrollable list, grouped by file; a style that cannot be sampled is listed with the reason instead of being built, which doubles as an audit of data/styles. The reason is worked out from the files before instantiating (base class not registered, style nested inside a style, image-source absent from the data folder), so the audit does not itself fill the log with engine errors. UIStatsBar, UIPopupMenu and UIPopupScrollMenu are never instantiated: they build their own children in create() and, standing alone, their layout reschedules itself forever, which freezes the client with nothing logged. The list is built one sample per frame so the window stays usable while it fills, rather than the client going dark through one 250-sample layout pass - saving back to the .otui preserves comments and formatting, writes a .bak and confirms by reading the file back after writing - style showcase for the 56 files that only define styles (every one under /styles): loadUI returns nil for them since they have no main widget, so each style is instantiated as a live sample instead - create a new screen from scratch - selfTest() covering the OTML parser, the part able to corrupt files Usage: button on the top-right menu, or Ctrl+Alt+U. Validation: modules.dev_otui.selfTest() in the terminal (Ctrl+T). Co-Authored-By: Claude Opus 5 (1M context) --- modules/dev_otui/README.md | 135 ++ modules/dev_otui/dev_otui.lua | 2270 ++++++++++++++++++++++++ modules/dev_otui/dev_otui.otmod | 11 + modules/dev_otui/dev_otui.otui | 409 +++++ modules/dev_otui/dev_otui_gallery.otui | 116 ++ modules/dev_otui/dev_otui_picker.otui | 151 ++ modules/dev_otui/otml.lua | 283 +++ 7 files changed, 3375 insertions(+) create mode 100644 modules/dev_otui/README.md create mode 100644 modules/dev_otui/dev_otui.lua create mode 100644 modules/dev_otui/dev_otui.otmod create mode 100644 modules/dev_otui/dev_otui.otui create mode 100644 modules/dev_otui/dev_otui_gallery.otui create mode 100644 modules/dev_otui/dev_otui_picker.otui create mode 100644 modules/dev_otui/otml.lua diff --git a/modules/dev_otui/README.md b/modules/dev_otui/README.md new file mode 100644 index 0000000000..88d258ea5b --- /dev/null +++ b/modules/dev_otui/README.md @@ -0,0 +1,135 @@ +# dev_otui — visual `.otui` editor + +A development tool for building and editing `.otui` screens while seeing the +real result. The preview is drawn by OTClient's own renderer, so bitmap fonts, +`image-border` 9-slice and the whole style cascade are exact rather than an +approximation. + +Open it with the button on the top-right menu, or `Ctrl+Alt+U`. + +## Quick start + +1. **Open...** and pick any `.otui` in the project — it renders on screen and + the widget tree is filled in. +2. Click a widget in the tree, or tick **Edit** and click it directly on screen. +3. The property panel shows that widget's properties **as written in the file**. + Change a value and press Enter (or **Apply (preview)**) to see it live. +4. **Save to file** writes the change back, keeping the rest of the file intact. + +Tick **Auto-reload** to have the preview follow the file while you edit it in +your text editor. + +## What each control does + +| Control | Purpose | +| --- | --- | +| `Load` / `Open...` | load the `.otui` typed in the field, or pick one from a searchable list | +| `New screen` | create a new file with a minimal `MainWindow`; refuses to overwrite | +| `Reload` | re-read the file, keeping the current selection | +| `+ Element` | insert a child widget, picked from a palette that shows a live preview of each style | +| `- Element` | delete the selected widget from the file, children included (asks first) | +| `Anchor...` | pick an anchor for the selected widget from the valid combinations | +| `Guides` | draw the parent's area and centre lines, and box the widgets this one is anchored to | +| `Style gallery` | render every style of every file under `/styles` in one scrollable list, grouped by file | +| `Auto-reload` | watch the file and reload it on change (300 ms) | +| `Debug boxes` | toggle `g_ui.setDebugBoxesDrawing`, outlining every widget | +| `Edit` | click to select on screen, drag to move, green corner to resize | +| `$on` / `$checked` / `$disabled` | force widget states to inspect their styling | +| `Apply (preview)` | apply the panel to the live widget; the file is untouched | +| `Save to file` | write the changes to the `.otui` | + +The gallery doubles as an audit of `data/styles`: a style that cannot be sampled +is listed with the reason instead of being hidden, and the same list is printed +to the terminal. The reason is worked out *before* instantiating — an unregistered +base class, a style nested inside another style, an `image-source` that is not in +the data folder — so the audit does not itself fill the log with engine errors. + +Three classes are never instantiated at all (`UIStatsBar`, `UIPopupMenu`, +`UIPopupScrollMenu`). They build their own children in `create()` and expect a +host that a sample row cannot provide; standing alone, their layout never +settles and keeps rescheduling itself. Since deferred events are processed +inside the same dispatcher poll, that freezes the client with no error logged — +so they are refused up front rather than tried. The list is also built one +sample per frame: the window is drawn and usable while it fills, instead of the +client going dark through a single 250-sample layout pass. + +## How it works + +**The property panel mirrors the file's lines, not the widget's getters.** +`otml.lua` parses the `.otui` keeping the original lines, and edits rewrite only +the lines that change. Two consequences: + +- Properties with no getter on `UIWidget` — `anchors.*`, `@onClick`, `!text` — + are visible and editable, and values appear exactly as written. +- Saving preserves comments, blank lines, ordering and alignment. + +**Dragging rewrites margins, not coordinates.** Which margin depends on the +anchors the widget declares in the file: with `anchors.left` a drag becomes +`margin-left`; with `anchors.right` it becomes `margin-right` inverted; with +both, the widget shifts while keeping its width. + +**The element palette previews what you are adding.** Highlighting an entry in +`+ Element` instantiates that style live in the side panel, with its name, size +and the file and line where it is defined. Styles that cannot be built +standalone say so instead of showing an empty box. + +**New widgets come in anchored.** A widget with no anchor cannot be positioned +or dragged at all, so inserting one writes anchors with it: hooked below the +previous sibling when there is one (`anchors.top: prev.bottom`), or to the +parent's top-left when it is the first child. If the parent has a `layout:`, the +parent places its children and anchors would be ignored, so none are written. + +**`Anchor...` only offers legal combinations.** An edge can only hook to an edge +of the same axis — `anchors.top` accepts `top`, `bottom` and `verticalCenter`, +never `left`. The list is built from that rule, over the parent, `prev`, `next` +and every sibling that has an id, plus the `fill` and `centerIn` shortcuts. + +**Guides show what the widget is anchored to.** Blue is the parent's *padding* +rect — the area children are actually laid inside — with its centre lines. +Orange boxes the sibling an anchor points at, so `prev.bottom` stops being +guesswork. They redraw on selection, not on every drag frame: the parent does +not move while you drag. + +**Style-only files get a showcase.** A third of the project's `.otui` files +(every one under `/styles`) declares only styles and has no main widget, so +`loadUI` returns `nil` for them. For those the editor instantiates a live sample +of each style; selecting a sample edits the style definition in the file. + +Styles that need a specific parent — `MiniWindow` expects a +`MiniWindowContainer` — cannot be instantiated standalone and are listed in red +instead of breaking the showcase. + +## Safety + +Writing is the only destructive operation, so it is guarded: + +- a `.bak` copy is written before each save; +- after writing, the file is read back and compared before reporting success; +- selection is validated by walking the on-screen tree and the file tree in + parallel, comparing the style name at every level. If they disagree — a widget + that came from an inherited style, for instance — saving is refused rather + than writing to the wrong node. + +## Tests + +The parser is the part capable of corrupting files, so it has a self test. +In the client terminal (`Ctrl+T`): + +```lua +modules.dev_otui.selfTest() +``` + +It asserts, among others: byte-for-byte round-trip, composite blocks such as +`layout:` not being mistaken for child widgets, edits not touching neighbouring +lines, insertion indentation, and removal of a block taking its children along. + +## Notes + +- Editing a file under `/styles` changes a style used by the whole client, not + by a single screen. +- `.otui` files are read with a CP1252 bitmap font; multi-byte UTF-8 characters + render as garbage. +- Changing `dev_otui.otmod` requires restarting the client: + `Module::reload()` reuses the script list captured at discovery time. + Changes to `.lua` and `.otui` only need + `g_modules.getModule('dev_otui'):reload()`. diff --git a/modules/dev_otui/dev_otui.lua b/modules/dev_otui/dev_otui.lua new file mode 100644 index 0000000000..909c933961 --- /dev/null +++ b/modules/dev_otui/dev_otui.lua @@ -0,0 +1,2270 @@ +-- 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 + +-- 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 + 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 + + 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 + currentNode = nil + currentRef = nil + 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) + 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, + } +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 + local parentNode = parentRef and resolveRef(doc, parentRef) or doc.root + 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 + +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 = path:match('([^/]+)%.otui$') or 'minhaJanela' + id = id:gsub('[^%w]', '') + 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', 'minhaTela', 'Minha Tela')) + check(d8.root and d8.root.tag == 'MainWindow', 'skeleton has no main widget') + check(Otml.findProperty(d8.root, 'id') ~= nil, 'skeleton has no id') + + 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 diff --git a/modules/dev_otui/dev_otui.otmod b/modules/dev_otui/dev_otui.otmod new file mode 100644 index 0000000000..f6e7b2f481 --- /dev/null +++ b/modules/dev_otui/dev_otui.otmod @@ -0,0 +1,11 @@ +Module + name: dev_otui + description: Editor/inspector de telas .otui com preview fiel dentro do proprio cliente + author: Renan + website: none + sandboxed: true + scripts: [ otml, dev_otui ] + autoload: true + dependencies: [ client_topmenu ] + @onLoad: init() + @onUnload: terminate() diff --git a/modules/dev_otui/dev_otui.otui b/modules/dev_otui/dev_otui.otui new file mode 100644 index 0000000000..e1cf6dedf0 --- /dev/null +++ b/modules/dev_otui/dev_otui.otui @@ -0,0 +1,409 @@ +OtuiTreeItem < UILabel + height: 14 + font: verdana-11px-monochrome + color: #c0c0c0 + background-color: alpha + text-offset: 2 0 + focusable: true + phantom: false + + $focus: + background-color: #585858 + color: #ffffff + +OtuiSectionLabel < UILabel + height: 12 + font: verdana-11px-monochrome + color: #8f8f8f + margin-top: 5 + +OtuiStateCheck < CheckBox + width: 90 + height: 14 + font: verdana-11px-monochrome + color: #c0c0c0 + +# One row of the property panel: name, editable value and a remove button. +# +# focusable MUST be true here and on propList: recursiveFocus() only focuses +# the focusable levels of the hierarchy (uiwidget.cpp:876) and +# propagateOnKeyText() only descends into focused children (uiwidget.cpp:2082). +# With a non-focusable link in the chain, the row's TextEdit never receives +# what you type. +OtuiPropRow < UIWidget + height: 18 + focusable: true + + UILabel + id: keyLabel + width: 118 + height: 16 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + font: verdana-11px-monochrome + color: #9fc6e0 + text-offset: 2 1 + + Button + id: removeBtn + !text: 'x' + width: 16 + height: 16 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + TextEdit + id: valueEdit + height: 18 + anchors.left: keyLabel.right + anchors.right: removeBtn.left + anchors.verticalCenter: parent.verticalCenter + margin-left: 2 + margin-right: 3 + +Window + id: otuiEditorWindow + size: 360 660 + !text: 'OTUI Editor' + anchors.left: parent.left + anchors.top: parent.top + margin-left: 12 + margin-top: 40 + + OtuiSectionLabel + id: pathTitle + !text: '.otui file' + margin-top: 0 + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + TextEdit + id: pathEdit + height: 20 + anchors.top: prev.bottom + anchors.left: parent.left + anchors.right: parent.right + margin-top: 2 + !text: '/game_idle/game_idle.otui' + + Button + id: loadBtn + !text: 'Load' + height: 20 + width: 100 + anchors.top: prev.bottom + anchors.left: parent.left + margin-top: 4 + @onClick: modules.dev_otui.loadTarget() + + Button + id: browseBtn + !text: 'Open...' + height: 20 + width: 100 + anchors.top: prev.top + anchors.left: prev.right + margin-left: 4 + @onClick: modules.dev_otui.browseFiles() + + Button + id: newScreenBtn + !text: 'New screen' + height: 20 + anchors.top: prev.top + anchors.left: prev.right + anchors.right: parent.right + margin-left: 4 + @onClick: modules.dev_otui.newScreen() + + Button + id: reloadBtn + !text: 'Reload' + height: 20 + width: 85 + anchors.top: prev.bottom + anchors.left: parent.left + margin-top: 4 + @onClick: modules.dev_otui.reloadTarget() + + Button + id: addElementBtn + !text: '+ Element' + height: 20 + width: 80 + anchors.top: prev.top + anchors.left: prev.right + margin-left: 4 + @onClick: modules.dev_otui.addElement() + + Button + id: removeElementBtn + !text: '- Element' + height: 20 + width: 80 + anchors.top: prev.top + anchors.left: prev.right + margin-left: 4 + @onClick: modules.dev_otui.removeElement() + + Button + id: closeStageBtn + !text: 'Close' + height: 20 + anchors.top: prev.top + anchors.left: prev.right + anchors.right: parent.right + margin-left: 4 + @onClick: modules.dev_otui.closeStage() + + Button + id: galleryBtn + !text: 'Style gallery' + height: 20 + width: 120 + anchors.top: prev.bottom + anchors.left: parent.left + margin-top: 4 + @onClick: modules.dev_otui.openGallery() + + OtuiStateCheck + id: autoReloadCheck + !text: 'Auto-reload' + width: 88 + anchors.top: prev.bottom + anchors.left: parent.left + margin-top: 6 + @onCheckChange: modules.dev_otui.setAutoReload(self:isChecked()) + + OtuiStateCheck + id: debugBoxesCheck + !text: 'Boxes' + width: 62 + anchors.top: prev.top + anchors.left: prev.right + margin-left: 6 + @onCheckChange: modules.dev_otui.setDebugBoxes(self:isChecked()) + + OtuiStateCheck + id: guidesCheck + !text: 'Guides' + width: 68 + anchors.top: prev.top + anchors.left: prev.right + margin-left: 6 + @onCheckChange: modules.dev_otui.setGuides(self:isChecked()) + + OtuiStateCheck + id: pickCheck + !text: 'Edit' + width: 52 + anchors.top: prev.top + anchors.left: prev.right + margin-left: 6 + @onCheckChange: modules.dev_otui.setPickMode(self:isChecked()) + + HorizontalSeparator + id: sep1 + anchors.top: prev.bottom + anchors.left: parent.left + anchors.right: parent.right + margin-top: 6 + + OtuiSectionLabel + id: treeTitle + !text: 'Widget tree' + anchors.top: prev.bottom + anchors.left: parent.left + anchors.right: parent.right + + TextList + id: treeList + height: 150 + anchors.top: treeTitle.bottom + anchors.left: parent.left + anchors.right: treeScroll.left + margin-top: 2 + padding: 1 + focusable: false + vertical-scrollbar: treeScroll + + # anchored to treeTitle, not to treeList: anchoring the two to each other + # creates a circular dependency and the layout is aborted + VerticalScrollBar + id: treeScroll + height: 150 + anchors.top: treeTitle.bottom + anchors.right: parent.right + margin-top: 2 + step: 14 + pixels-scroll: true + + Label + id: infoLabel + height: 58 + font: verdana-11px-monochrome + color: #a0d0a0 + text-wrap: true + text-align: topLeft + phantom: false + anchors.top: treeList.bottom + anchors.left: parent.left + anchors.right: parent.right + margin-top: 4 + !text: 'No widget selected.' + + OtuiStateCheck + id: stateOn + !text: '$on' + width: 68 + anchors.top: prev.bottom + anchors.left: parent.left + margin-top: 2 + @onCheckChange: modules.dev_otui.applyState('on', self:isChecked()) + + OtuiStateCheck + id: stateChecked + !text: '$checked' + width: 78 + anchors.top: prev.top + anchors.left: prev.right + margin-left: 4 + @onCheckChange: modules.dev_otui.applyState('checked', self:isChecked()) + + OtuiStateCheck + id: stateDisabled + !text: '$disabled' + width: 78 + anchors.top: prev.top + anchors.left: prev.right + margin-left: 4 + @onCheckChange: modules.dev_otui.applyState('disabled', self:isChecked()) + + OtuiStateCheck + id: stateHidden + !text: 'hidden' + width: 68 + anchors.top: prev.top + anchors.left: prev.right + margin-left: 4 + @onCheckChange: modules.dev_otui.applyState('hidden', self:isChecked()) + + OtuiSectionLabel + id: propTitle + !text: 'Properties in the file' + anchors.top: prev.bottom + anchors.left: parent.left + anchors.right: parent.right + + # From propTitle down the layout is elastic: the property list anchors both + # top AND bottom, so every pixel gained by resizing the window goes to it. + # Without this, growing the window would only create empty space. + TextList + id: propList + anchors.top: propTitle.bottom + anchors.left: parent.left + anchors.right: propScroll.left + anchors.bottom: newPropEdit.top + margin-top: 2 + margin-bottom: 4 + padding: 1 + focusable: true + vertical-scrollbar: propScroll + + VerticalScrollBar + id: propScroll + anchors.top: propTitle.bottom + anchors.bottom: newPropEdit.top + anchors.right: parent.right + margin-top: 2 + margin-bottom: 4 + step: 18 + pixels-scroll: true + + TextEdit + id: newPropEdit + height: 20 + anchors.bottom: applyAllBtn.top + anchors.left: parent.left + anchors.right: addPropBtn.left + margin-bottom: 4 + margin-right: 4 + !text: '' + + Button + id: addPropBtn + !text: 'Add' + height: 20 + width: 55 + anchors.bottom: applyAllBtn.top + anchors.right: anchorBtn.left + margin-bottom: 4 + margin-right: 4 + @onClick: modules.dev_otui.addPropertyFromInput() + + Button + id: anchorBtn + !text: 'Anchor...' + height: 20 + width: 85 + anchors.bottom: applyAllBtn.top + anchors.right: parent.right + margin-bottom: 4 + @onClick: modules.dev_otui.editAnchors() + + Button + id: applyAllBtn + !text: 'Apply (preview)' + height: 20 + width: 160 + anchors.bottom: statusLabel.top + anchors.left: parent.left + margin-bottom: 4 + @onClick: modules.dev_otui.applyAll() + + Button + id: saveBtn + !text: 'Save to file' + height: 20 + anchors.bottom: statusLabel.top + anchors.left: applyAllBtn.right + anchors.right: parent.right + margin-left: 4 + margin-bottom: 4 + @onClick: modules.dev_otui.saveFile() + + Label + id: statusLabel + font: verdana-11px-monochrome + color: #dfdf7f + text-align: topLeft + text-wrap: true + phantom: false + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 34 + !text: 'Ready.' + + # negative margins push the borders onto the window frame, outside the + # padding; otherwise they would steal clicks from the scrollbars on the right + ResizeBorder + id: bottomResize + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + margin-bottom: -10 + height: 4 + minimum: 420 + maximum: 1400 + + ResizeBorder + id: rightResize + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + margin-right: -10 + width: 4 + minimum: 330 + maximum: 1000 diff --git a/modules/dev_otui/dev_otui_gallery.otui b/modules/dev_otui/dev_otui_gallery.otui new file mode 100644 index 0000000000..33eb0481a2 --- /dev/null +++ b/modules/dev_otui/dev_otui_gallery.otui @@ -0,0 +1,116 @@ +# A category heading inside the gallery scroll. +OtuiGalleryHeader < UILabel + height: 20 + font: verdana-11px-rounded + color: #9fc6e0 + background-color: #2a2a2a + text-offset: 4 3 + phantom: true + focusable: false + +# One style: its name on the left, the live sample on the right. +OtuiGalleryRow < UIWidget + height: 26 + focusable: false + + UILabel + id: styleName + width: 200 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + margin-left: 6 + font: verdana-11px-monochrome + color: #c0c0c0 + phantom: true + + # the sample is instantiated in here; clipping stops an oversized widget + # (MainWindow is 200x200) from pushing the row out of shape + UIWidget + id: sampleSlot + anchors.left: prev.right + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + margin-left: 8 + margin-right: 6 + clipping: true + focusable: false + +MainWindow + id: otuiGalleryWindow + size: 720 620 + !text: 'Style gallery' + + @onEscape: modules.dev_otui.closeGallery() + + OtuiSectionLabel + id: galleryHint + height: 12 + margin-top: 0 + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + !text: 'Every style in data/styles, grouped by file, rendered by the client itself.' + + TextList + id: galleryList + anchors.top: prev.bottom + anchors.left: parent.left + anchors.right: galleryScroll.left + anchors.bottom: closeGalleryBtn.top + margin-top: 4 + margin-bottom: 6 + padding: 1 + focusable: true + vertical-scrollbar: galleryScroll + + # anchored to the same neighbours the list uses, never to the list itself + VerticalScrollBar + id: galleryScroll + anchors.top: galleryHint.bottom + anchors.bottom: closeGalleryBtn.top + anchors.right: parent.right + margin-top: 4 + margin-bottom: 6 + step: 26 + pixels-scroll: true + + Label + id: galleryCount + height: 16 + font: verdana-11px-monochrome + color: #8f8f8f + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: closeGalleryBtn.left + margin-right: 6 + !text: '' + + Button + id: closeGalleryBtn + !text: 'Close' + width: 90 + height: 20 + anchors.bottom: parent.bottom + anchors.right: parent.right + @onClick: modules.dev_otui.closeGallery() + + ResizeBorder + id: galleryBottomResize + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + margin-bottom: -10 + height: 4 + minimum: 380 + maximum: 1400 + + ResizeBorder + id: galleryRightResize + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + margin-right: -10 + width: 4 + minimum: 420 + maximum: 1600 diff --git a/modules/dev_otui/dev_otui_picker.otui b/modules/dev_otui/dev_otui_picker.otui new file mode 100644 index 0000000000..f331901d74 --- /dev/null +++ b/modules/dev_otui/dev_otui_picker.otui @@ -0,0 +1,151 @@ +OtuiPickerItem < UILabel + height: 14 + font: verdana-11px-monochrome + color: #c0c0c0 + background-color: alpha + text-offset: 2 0 + focusable: true + phantom: false + + $focus: + background-color: #585858 + color: #ffffff + +MainWindow + id: otuiPickerWindow + size: 620 460 + !text: 'Select' + + @onEscape: modules.dev_otui.closePicker() + @onEnter: modules.dev_otui.acceptPicker() + + OtuiSectionLabel + id: hintLabel + height: 12 + margin-top: 0 + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + !text: '' + + TextEdit + id: filterEdit + height: 20 + anchors.top: prev.bottom + anchors.left: parent.left + anchors.right: parent.right + margin-top: 2 + !text: '' + + # focusable must be true along the whole chain, otherwise the row's TextEdit + # receives no keyboard input + TextList + id: itemList + anchors.top: filterEdit.bottom + anchors.left: parent.left + anchors.right: itemScroll.left + anchors.bottom: acceptBtn.top + margin-top: 4 + margin-bottom: 6 + padding: 1 + focusable: true + vertical-scrollbar: itemScroll + + # every anchor here points at neighbours the list also uses, never at the list + # itself: mutual anchors become a circular dependency and the layout is aborted + VerticalScrollBar + id: itemScroll + anchors.top: filterEdit.bottom + anchors.bottom: acceptBtn.top + anchors.right: previewBox.left + margin-top: 4 + margin-bottom: 6 + margin-right: 8 + step: 14 + pixels-scroll: true + + UIWidget + id: previewBox + width: 250 + anchors.top: filterEdit.bottom + anchors.bottom: acceptBtn.top + anchors.right: parent.right + margin-top: 4 + margin-bottom: 6 + border-width: 1 + border-color: #272727 + background-color: #3a3a3a + focusable: false + + UILabel + id: previewName + height: 14 + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + margin: 3 + font: verdana-11px-monochrome + color: #9fc6e0 + text-align: center + !text: '' + + UILabel + id: previewOrigin + height: 12 + anchors.top: prev.bottom + anchors.left: parent.left + anchors.right: parent.right + margin-left: 3 + margin-right: 3 + font: verdana-11px-monochrome + color: #8f8f8f + text-align: center + text-wrap: true + !text: '' + + HorizontalSeparator + id: previewSep + anchors.top: prev.bottom + anchors.left: parent.left + anchors.right: parent.right + margin: 4 3 0 3 + + # the live sample goes in here; clipping keeps an oversized widget from + # spilling over the rest of the window + UIWidget + id: previewSlot + anchors.top: prev.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + margin: 4 3 3 3 + clipping: true + focusable: false + + Button + id: acceptBtn + !text: 'Use' + width: 90 + height: 20 + anchors.bottom: parent.bottom + anchors.right: cancelBtn.left + margin-right: 6 + + Button + id: cancelBtn + !text: 'Close' + width: 90 + height: 20 + anchors.bottom: parent.bottom + anchors.right: parent.right + + Label + id: countLabel + height: 16 + font: verdana-11px-monochrome + color: #8f8f8f + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: acceptBtn.left + margin-right: 6 + !text: '' diff --git a/modules/dev_otui/otml.lua b/modules/dev_otui/otml.lua new file mode 100644 index 0000000000..4a550f0e5f --- /dev/null +++ b/modules/dev_otui/otml.lua @@ -0,0 +1,283 @@ +-- Line-oriented OTML parser/writer. +-- +-- The file is not rebuilt from the tree: we keep the original lines and change +-- only the ones that must change. That preserves comments, blank lines, +-- alignment and ordering exactly as they were written. +-- +-- Rules mirrored from the C++ parser (src/framework/otml/otmlparser.cpp): +-- * two spaces of indentation per level +-- * '//' and '#' start a comment (the line is trimmed before the check) +-- * a node whose line contains ':' is UNIQUE, and unique nodes are +-- PROPERTIES; without ':' the node becomes a CHILD WIDGET (uimanager.cpp:735) +-- * a value of '|', '|-' or '|+' starts a multiline block (indented below) + +Otml = {} + +-- ============================================================ reading + +local function splitLines(text) + local trailing = text:sub(-1) == '\n' + local padded = trailing and text or (text .. '\n') + local lines = {} + for line in padded:gmatch('([^\n]*)\n') do + lines[#lines + 1] = (line:gsub('\r$', '')) + end + return lines, trailing +end + +local function isComment(content) + return content:sub(1, 2) == '//' or content:sub(1, 1) == '#' +end + +-- Splits "key: value" following the C++ rule: any ':' marks a property. +-- Returns tag, value, isProp. +local function splitNode(content) + local colon = content:find(':', 1, true) + if not colon then + return content, nil, false + end + local tag = content:sub(1, colon - 1):trim() + local value = content:sub(colon + 1):trim() + return tag, value, true +end + +function Otml.parse(text) + local lines, trailing = splitLines(text) + + local doc = { + lines = lines, + eol = text:find('\r\n', 1, true) and '\r\n' or '\n', + trailingNewline = trailing, + roots = {}, + styleDefs = {}, + root = nil, + } + + local stack = {} + local skipDeeperThan = nil + + for i, raw in ipairs(lines) do + local content = raw:trim() + local skip = (content == '' or isComment(content)) + + if not skip then + local indent = #(raw:match('^ *') or '') + + -- inside a multiline block the content is text/code, not OTML + if skipDeeperThan and indent > skipDeeperThan then + skip = true + else + skipDeeperThan = nil + end + + if not skip then + local tag, value, isProp = splitNode(content) + + local node = { + tag = tag, + value = value, + isProp = isProp, + indent = indent, + line = i, + children = {}, + parent = nil, + } + + while #stack > 0 and stack[#stack].indent >= indent do + table.remove(stack) + end + + local parent = stack[#stack] + if parent then + node.parent = parent + table.insert(parent.children, node) + else + table.insert(doc.roots, node) + if tag:find('<', 1, true) then + table.insert(doc.styleDefs, node) + elseif not isProp and not doc.root then + doc.root = node + end + end + + table.insert(stack, node) + + if isProp and value and value:sub(1, 1) == '|' then + skipDeeperThan = indent + end + end + end + end + + return doc +end + +-- ============================================================ queries + +-- Children that become widgets (lines without ':'), in the same order in which +-- createWidgetFromOTML instantiates them. +function Otml.widgetChildren(node) + local out = {} + for _, child in ipairs(node.children) do + if not child.isProp and not child.tag:find('<', 1, true) then + table.insert(out, child) + end + end + return out +end + +function Otml.properties(node) + local out = {} + for _, child in ipairs(node.children) do + if child.isProp then + table.insert(out, child) + end + end + return out +end + +function Otml.findProperty(node, key) + for _, child in ipairs(node.children) do + if child.isProp and child.tag == key then + return child + end + end + return nil +end + +-- Last line occupied by the node, including the block indented below it. +function Otml.lastLineOf(node) + local last = node.line + for _, child in ipairs(node.children) do + local childLast = Otml.lastLineOf(child) + if childLast > last then last = childLast end + end + return last +end + +-- ============================================================ writing +-- Every function that inserts/removes lines invalidates the line numbers held +-- in the tree. Callers must reparse before the next edit (Otml.reparse). + +function Otml.reparse(doc) + local text = Otml.serialize(doc) + return Otml.parse(text) +end + +-- Where a new property goes: right after the node's last direct property, so +-- properties stay above child widgets. +local function insertionLine(node) + local line = node.line + for _, child in ipairs(node.children) do + if child.isProp then + local last = Otml.lastLineOf(child) + if last > line then line = last end + end + end + return line + 1 +end + +function Otml.setProperty(doc, node, key, value) + local prop = Otml.findProperty(node, key) + + if prop then + if #prop.children > 0 then + return false, 'composite property (block); edit it in the file' + end + doc.lines[prop.line] = string.rep(' ', prop.indent) .. key .. ': ' .. value + return true + end + + local text = string.rep(' ', node.indent + 2) .. key .. ': ' .. value + table.insert(doc.lines, insertionLine(node), text) + return true +end + +function Otml.removeProperty(doc, node, key) + local prop = Otml.findProperty(node, key) + if not prop then + return false, 'property not present in the file' + end + local last = Otml.lastLineOf(prop) + for i = last, prop.line, -1 do + table.remove(doc.lines, i) + end + return true +end + +-- Appends a child widget at the end of the parent's block. Lines go in with the +-- indentation of the level below, the only step the C++ parser accepts +-- (otmlparser.cpp: "must indent every 2 spaces"). +-- props is an ordered list of { key, value } written under the new widget. A +-- widget with no anchors at all cannot be positioned or dragged, so callers +-- pass sensible defaults here. +function Otml.addChildWidget(doc, parentNode, styleName, id, props) + if not parentNode then + return false, 'no parent widget' + end + + local indent = string.rep(' ', parentNode.indent + 2) + local at = Otml.lastLineOf(parentNode) + 1 + + local block = {} + -- blank line separating it from the previous content, if any + if #parentNode.children > 0 then + table.insert(block, '') + end + table.insert(block, indent .. styleName) + if id and id ~= '' then + table.insert(block, indent .. ' id: ' .. id) + end + for _, prop in ipairs(props or {}) do + table.insert(block, indent .. ' ' .. prop[1] .. ': ' .. prop[2]) + end + + for i = #block, 1, -1 do + table.insert(doc.lines, at, block[i]) + end + + return true +end + +-- Removes a node and the whole block indented below it (a widget takes its +-- children along). Also drops one blank line directly above it, so repeated +-- removals do not pile up empty gaps in the file. +function Otml.removeNode(doc, node) + if not node then + return false, 'no node given' + end + + local first = node.line + local last = Otml.lastLineOf(node) + + if first > 1 then + local above = doc.lines[first - 1] + if above and above:trim() == '' then + first = first - 1 + end + end + + for i = last, first, -1 do + table.remove(doc.lines, i) + end + return true +end + +-- Skeleton for a brand new screen. +function Otml.skeleton(styleName, id, title) + return table.concat({ + styleName, + ' id: ' .. id, + " !text: '" .. title .. "'", + ' size: 300 200', + '', + }, '\n') +end + +function Otml.serialize(doc) + local text = table.concat(doc.lines, doc.eol) + if doc.trailingNewline then + text = text .. doc.eol + end + return text +end From 56264c3d08eb3d92df8d43905373bc05d102d8d5 Mon Sep 17 00:00:00 2001 From: Renan Miyashiro <37278838+renanale@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:53:54 -0300 Subject: [PATCH 2/7] fix(dev_otui): report a file read failure instead of swallowing it readDocument cleared the document and returned false without saying why. Callers ignore the return value, so a read error surfaced only later, as the vaguer "file was not read" on the next selection. selfTest covers it: reading a missing path must return false, clear the document and leave the reason on the status line. Co-Authored-By: Claude Opus 5 (1M context) --- modules/dev_otui/dev_otui.lua | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/modules/dev_otui/dev_otui.lua b/modules/dev_otui/dev_otui.lua index 909c933961..652210b9de 100644 --- a/modules/dev_otui/dev_otui.lua +++ b/modules/dev_otui/dev_otui.lua @@ -331,6 +331,10 @@ 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 @@ -2200,6 +2204,18 @@ function selfTest() check(d8.root and d8.root.tag == 'MainWindow', 'skeleton has no main widget') check(Otml.findProperty(d8.root, 'id') ~= nil, 'skeleton has no id') + -- 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') From a0d91222551e089599376ce6e2b710ea7eb40359 Mon Sep 17 00:00:00 2001 From: Renan Miyashiro <37278838+renanale@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:56:59 -0300 Subject: [PATCH 3/7] fix(dev_otui): verify the file reference still points at the selected widget A reference into the file is a path of child indices. It survives a reparse, but it says nothing about which widget sits there now: a file reordered in a text editor between load and save resolves the same path onto a different widget, and the edit lands on it. Both write paths now check identity (style name + id) before touching anything: - saveFile aborts when the re-read document no longer has the selected widget at that path - insertElement no longer falls back to doc.root when the reference fails to resolve, which quietly inserted the new element somewhere else selfTest covers it with a reordered file, a renamed widget and an unresolved reference. Co-Authored-By: Claude Opus 5 (1M context) --- modules/dev_otui/dev_otui.lua | 66 +++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/modules/dev_otui/dev_otui.lua b/modules/dev_otui/dev_otui.lua index 652210b9de..7cf554760d 100644 --- a/modules/dev_otui/dev_otui.lua +++ b/modules/dev_otui/dev_otui.lua @@ -278,6 +278,20 @@ local function resolveRef(document, ref) 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 + -- 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. @@ -748,6 +762,14 @@ function saveFile() 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 @@ -1525,8 +1547,21 @@ local function insertElement(styleName) local originalText = Otml.serialize(doc) - -- with no selection, the new element goes to the screen root - local parentNode = parentRef and resolveRef(doc, parentRef) or doc.root + -- 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 @@ -2204,6 +2239,33 @@ function selfTest() check(d8.root and d8.root.tag == 'MainWindow', 'skeleton has no main widget') check(Otml.findProperty(d8.root, 'id') ~= nil, 'skeleton has no id') + -- 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 read failure must reach the user. readDocument writes the module's `doc`, -- so the current one is put back before returning. local savedDoc = doc From cfbb70d810e26639ed62cce868ef7514c87de168 Mon Sep 17 00:00:00 2001 From: Renan Miyashiro <37278838+renanale@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:57:44 -0300 Subject: [PATCH 4/7] fix(dev_otui): clear nodeError when the preview closes closeStage reset currentNode and currentRef but kept nodeError, so a save or an insert attempted afterwards reported why the *previous* widget could not be mapped. The three are now cleared together through clearNodeSelection, since nodeError only means anything as the reason currentNode is missing. selfTest covers it. Co-Authored-By: Claude Opus 5 (1M context) --- modules/dev_otui/dev_otui.lua | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/modules/dev_otui/dev_otui.lua b/modules/dev_otui/dev_otui.lua index 7cf554760d..3b0619e18c 100644 --- a/modules/dev_otui/dev_otui.lua +++ b/modules/dev_otui/dev_otui.lua @@ -292,6 +292,14 @@ local function sameNodeIdentity(a, b) 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. @@ -838,8 +846,7 @@ function closeStage() if stage and not stage:isDestroyed() then stage:destroy() end stage = nil selectedWidget = nil - currentNode = nil - currentRef = nil + clearNodeSelection() showcaseMode = false showcaseStyleIndex = {} treeItems = {} @@ -2266,6 +2273,15 @@ function selfTest() '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') + -- 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 From a326dd415d7b445ea7987f8fe5714210534e5272 Mon Sep 17 00:00:00 2001 From: Renan Miyashiro <37278838+renanale@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:58:40 -0300 Subject: [PATCH 5/7] fix(dev_otui): do not start a drag without a selected widget The click handler calls beginDrag right after selectWidget, and selectWidget returns early - leaving the selection untouched, possibly nil - when it is re-entered or the widget was already destroyed. beginDrag then indexed nil inside the mouse handler. It now declines and returns false instead. selfTest covers it. Co-Authored-By: Claude Opus 5 (1M context) --- modules/dev_otui/dev_otui.lua | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/modules/dev_otui/dev_otui.lua b/modules/dev_otui/dev_otui.lua index 3b0619e18c..8c175989d6 100644 --- a/modules/dev_otui/dev_otui.lua +++ b/modules/dev_otui/dev_otui.lua @@ -1065,6 +1065,10 @@ local function anchorFlags() 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, @@ -1078,6 +1082,7 @@ local function beginDrag(mode, mousePos) anchors = anchorFlags(), moved = false, } + return true end local function updateDrag(mousePos) @@ -2273,6 +2278,16 @@ function selfTest() '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 From b7016bf1119763dd08783386301adeec8f97a927 Mon Sep 17 00:00:00 2001 From: Renan Miyashiro <37278838+renanale@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:59:55 -0300 Subject: [PATCH 6/7] fix(dev_otui): keep the new-screen id in English and never let it be empty The fallback id for a new screen was 'minhaJanela', Portuguese in a module that is otherwise English. Stripping everything that is not a letter or a digit could also leave nothing at all ("__.otui"), and a widget written with an empty id cannot be looked up by name afterwards. Both are now handled in screenIdFromPath, which falls back to 'newScreen'. The self test used Portuguese names for the same reason and is now English. selfTest covers the punctuation, the already-valid name, the name with no alphanumerics and the empty path. Co-Authored-By: Claude Opus 5 (1M context) --- modules/dev_otui/dev_otui.lua | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/modules/dev_otui/dev_otui.lua b/modules/dev_otui/dev_otui.lua index 8c175989d6..0d3fb2dd7e 100644 --- a/modules/dev_otui/dev_otui.lua +++ b/modules/dev_otui/dev_otui.lua @@ -1743,6 +1743,16 @@ 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 @@ -1755,8 +1765,7 @@ function newScreen() return end - local id = path:match('([^/]+)%.otui$') or 'minhaJanela' - id = id:gsub('[^%w]', '') + local id = screenIdFromPath(path) local text = Otml.skeleton('MainWindow', id, id) local ok, result = persistText(path, text, nil) @@ -2247,10 +2256,21 @@ function selfTest() end -- the new-screen skeleton must be parseable - local d8 = Otml.parse(Otml.skeleton('MainWindow', 'minhaTela', 'Minha Tela')) + 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({ From 657bc94bc314b905fb70beb7daed6e21e0bb7c62 Mon Sep 17 00:00:00 2001 From: Renan Miyashiro <37278838+renanale@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:01:05 -0300 Subject: [PATCH 7/7] docs(dev_otui): list the hidden state toggle and hyphenate self-test The controls table listed $on, $checked and $disabled but not the fourth toggle, which applyState and refreshStateChecks implement as 'hidden' (setVisible). Also hyphenates "self-test" to match the surrounding prose. Co-Authored-By: Claude Opus 5 (1M context) --- modules/dev_otui/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/dev_otui/README.md b/modules/dev_otui/README.md index 88d258ea5b..3270354f44 100644 --- a/modules/dev_otui/README.md +++ b/modules/dev_otui/README.md @@ -35,6 +35,7 @@ your text editor. | `Debug boxes` | toggle `g_ui.setDebugBoxesDrawing`, outlining every widget | | `Edit` | click to select on screen, drag to move, green corner to resize | | `$on` / `$checked` / `$disabled` | force widget states to inspect their styling | +| `hidden` | hide the selected widget, to see what is underneath it | | `Apply (preview)` | apply the panel to the live widget; the file is untouched | | `Save to file` | write the changes to the `.otui` | @@ -112,7 +113,7 @@ Writing is the only destructive operation, so it is guarded: ## Tests -The parser is the part capable of corrupting files, so it has a self test. +The parser is the part capable of corrupting files, so it has a self-test. In the client terminal (`Ctrl+T`): ```lua