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