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