Module:GameUIBuilder

-- Load modules local fun = require("Module:Functional") local iter = fun.iter

local class = require("Module:Class")

-- BASE CLASS -- local GameUINode = class("GameUINode")

-- Constructor -- -- Creates a new GameUINode instance. function GameUINode:initialize(classes, styles) classes = classes or {} assert(type(classes) == type({}), "`classes` should be a table") self._classes = mw.clone(classes) self._attributes = {} styles = styles or {} assert(type(styles) == type({}), "`styles` should be a table") self._styles = mw.clone(styles) self._nodes = {} end

-- Getters & Setters -- -- Deep copy of a table containing each class name of the object. -- Note: You have to use CSS class to set links; setting the URL directly using CSS is considered insecure. function GameUINode:getClasses return mw.clone(self._classes) end

-- Deep copy of a table containing each HTML attribute of the object. function GameUINode:getAttributes return mw.clone(self._attributes) end

-- Deep copy of a table containing each CSS style of the object. function GameUINode:getStyles return mw.clone(self._styles) end

-- Deep copy of a table containing the children nodes, which are stored as strings function GameUINode:getNodes return mw.clone(self._nodes) end

-- Metamethods -- -- Returns a string representation of the object. function GameUINode:__tostring return self:as_mw_html end

-- Other methods -- -- Adds a class name to the node's class attribute. Does nothing if it already exists. -- If a table is passed in, attempts to add each class in the table. -- Returns the node itself. function GameUINode:addClass(class) if class == nil then return end if type(class) == type("") then class = mw.text.split(class, "%s") elseif type(class) ~= type({}) then error("`class` should be a string or table") end iter(class):each(function (c)		if not (iter(self._classes):index(c)) then			table.insert(self._classes, c)		end	end) return self end -- Removes a class name to the node's class attribute. Does nothing if it does not exist. -- If a table is passed in, attempts to remove each class in the table. -- Returns the node itself. function GameUINode:removeClass(class) if class == nil then return end if type(class) == type("") then class = mw.text.split(class, "%s") elseif type(class) ~= type({}) then error("`class` should be a string or table") end iter(class):each(function (c)		local index = iter(self._classes):index(class)		if (index) then			table.remove(self._classes, index)		end	end) return self end

-- Set an HTML attribute with the given name to the given value on the node. A value of nil causes it to the unset. -- If a single table is passed in, repeats the function for each name/value pair in the table. -- Returns the node itself. function GameUINode:attr(name, value) if type(name) == type({}) then assert(type(value) == type(nil), "`name` is a table, expected `value` to be nil") elseif type(name) == type("") then name = {name = value} else error("`name` should be a string or table") end iter(name):each(function (n, v)		self._attributes[n] = v	end) return self end

-- Set a CSS style with the given name to the given value on the node. A value of nil causes it to the unset. -- If a single table is passed in, repeats the function for each name/value pair in the table. -- Returns the node itself. function GameUINode:css(name, value) if type(name) == type({}) then assert(type(value) == type(nil), "`name` is a table, expected `value` to be nil") elseif type(name) == type("") then name = {name = value} else error("`name` should be a string or table") end iter(name):each(function (n, v)		self._styles[n] = v	end) return self end

-- Converts a `GameUINode` or `ml.html` object to a string. -- Also accepts a string, in which case it is unchanged. function GameUINode:nodeToString(node) if node.as_mw_html then node = node:as_mw_html end return tostring(node) end

-- Appends a child node (as a string) to this node. -- Returns the node itself. function GameUINode:node(node) table.insert(self._nodes, self:nodeToString(node)) return self end

-- Returns the object as a `mw.html` object function GameUINode:as_mw_html local baseNode = mw.html.create("div"):addClass("game-ui"):attr(self._attributes):css(self._styles) baseNode = iter(self._classes):reduce(function (acc, e) return acc:addClass(e) end, baseNode) baseNode = self:buildCurrent(baseNode) return iter(self._nodes):reduce(function (acc, e) return acc:node(e) end, baseNode) end

-- Builds the current object on top of the base node. function GameUINode:buildCurrent(baseNode) return baseNode end

-- Label local GameUILabel = GameUINode:subclass("GameUILabel")

-- Constructor -- -- Creates a new GameUILabel instance. function GameUILabel:initialize(classes, styles, text) GameUILabel.super.initialize(self, classes, styles) self:addClass("game-ui-label") text = text or "" assert(type(text) == type(""), "`text` should be a string") self._text = text end

-- Getters & Setters -- -- The content of the label. function GameUILabel:getText return self._text end

-- Other methods -- function GameUILabel:buildCurrent(baseNode) baseNode:tag("div"):addClass("text"):wikitext(self._text) return baseNode end

-- BUTTON -- local GameUIButton = GameUILabel:subclass("GameUIButton")

-- Constructor -- -- Creates a new GameUIButton instance. function GameUIButton:initialize(classes, styles, text, data) GameUIButton.super.initialize(self, classes, styles, text) self:addClass("game-ui-button") data = data or {} assert(type(data) == type({}), "`data` should be a table") self._data = mw.clone(data) end

-- Getters & Setters -- -- A deep copy of the data of the button, which may consist of multiple elements function GameUILabel:getData return mw.clone(self._data) end

-- Other methods -- function GameUIButton:buildCurrent(baseNode) for _, d in ipairs(self._data) do		baseNode:tag("div"):addClass("data"):wikitext(d) end return baseNode end

-- LIST -- local GameUIList = GameUINode:subclass("GameUIList")

-- Constructor -- -- Creates a new GameUIList instance. function GameUIList:initialize(classes, style) GameUIList.super.initialize(self, classes, style) self:addClass("game-ui-list") self._rows = {} end

-- Getters & Setters -- -- A table containing each row of the list. function GameUIList:getRows return mw.clone(self._rows) end

-- The number of rows this object has function GameUIList:getNumRows return #self._rows end -- Other methods -- -- Inserts a row at the specified index (default: at the last index) according to the given arguments. -- Supported arguments include: image_wikitext, left_wikitext, right_wikitext, tooltip_wikitext, js_only, no_js_only -- Returns the node itself. function GameUIList:insertRowAt(args, index) assert(type(args) == type({}), "`args` should be a table") assert(type(index) == type(nil) or type(index) == type(0), "`index` should be nil or a number") local supported_args = { ["image_wikitext"] = true, ["left_wikitext"] = true, ["right_wikitext"] = true, ["tooltip_wikitext"] = true, ["js_only"] = true, ["no_js_only"] = true, }	for k, _ in pairs(args) do		if supported_args[k] == nil then error(string.format("Unrecognized argument name '%s'", k)) end end

if (index) then table.insert(self._rows, index, args) else table.insert(self._rows, args) end return self end

-- Removes a row at the specified index (default: at the last index). -- Returns the node itself. function GameUIList:removeRowAt(index) assert(type(index) == type(nil) or type(index) == type(0), "`index` should be nil or a number") if (index) then table.remove(self._rows, index) else table.remove(self._rows) end return self end

-- Concatenates this list with another list, appending the other's rows -- one after another to this one. Returns this object. function GameUIList:concatenate(other) assert(type(other) == type({}), "`other` should be a table") for _, r in ipairs(other._rows) do table.insert(self._rows, r) end return self end

function GameUIList:buildCurrent(baseNode) local tableNode = baseNode:tag("table") local function addTooltip(node, row) return node:addClass("game-ui-tooltip"):attr("title", row.tooltip_wikitext:gsub("]->", "")) :tag("span"):addClass("hover-content"):wikitext(row.tooltip_wikitext) end

for _, r in ipairs(self._rows) do		local rowNode = tableNode:tag("tr") if (r.js_only) then rowNode:addClass("hide-if-nojs") elseif (r.no_js_only) then rowNode:addClass("remove-if-js") end local imageNode = rowNode:tag("td"):addClass("row-image"):wikitext(r.image_wikitext or "") local leftNode = rowNode:tag("td"):addClass("row-left"):wikitext(r.left_wikitext or "") local rightNode = rowNode:tag("td"):addClass("row-right"):node(r.right_wikitext or "") if (r.tooltip_wikitext) then addTooltip(imageNode, r)			addTooltip(leftNode, r)			addTooltip(rightNode, r)		end -- Keep odd-even consistency, so have to insert a dummy row if (r.js_only) then rowNode:tag("tr"):addClass("remove-if-js"):css("display", "none") end end return baseNode end

-- TABLE -- local GameUITable = GameUINode:subclass("GameUITable")

-- Constructor -- -- Creates a new GameUITable instance. -- `content` is a nested table where `content[a][b]` is the field for row `a`, column `b`. -- The `fuse_fields` option causes fields to be fused together in a row where consecutive values are equal. function GameUITable:initialize(classes, styles, title, content, fuse_fields) GameUITable.super.initialize(self, classes, styles) self:addClass("game-ui-table") title = title or "" assert(type(title) == type(""), "`title` should be a string") self._title = title content = content or { {} } assert(type(content) == type({}), "`content` should be a table") local row_headers = {} local col_headers = {} for kr, vr in pairs(content) do		table.insert(row_headers, kr) for kc, vc in pairs(vr) do			if not iter(col_headers):index(kc) then table.insert(col_headers, kc) end end end self._row_headers = row_headers self._col_headers = col_headers self._content = content self.fuse_fields = fuse_fields or false end

-- Getters & Setters -- -- The title of the table. function GameUITable:getTitle return self._title end -- Deep copy of a table of strings, denoting the header for each row after the first. function GameUITable:getRowHeaders return mw.clone(self._row_headers) end -- Deep copy of a table of strings, denoting the header for each column after the first. function GameUITable:getColHeaders return mw.clone(self._col_headers) end

-- The number of rows this object has function GameUITable:getNumRows return #self._row_headers end

-- The number of columns this object has function GameUITable:getNumCols return #self._col_headers end

-- Deep copy of a nested table of wikitext strings. The first layer of nesting has keys corresponding -- to the rows, while the second layer has keys corresponding to the columns. function GameUITable:getContent return mw.clone(self._content) end

-- Other methods -- -- Sorts the row headers by an ordering function that takes in two elements and -- returns true if the first element should come first. function GameUITable:sortRows(sort_fn) table.sort(self._row_headers, sort_fn) end

-- Sorts the column headers by an ordering function that takes in two elements and -- returns true if the first element should come first. function GameUITable:sortCols(sort_fn) table.sort(self._col_headers, sort_fn) end

function GameUITable:buildCurrent(baseNode) local tableNode = baseNode:tag("table"):css("width", "100%"):css("text-align", "center") local function addRowHeaderCell(acc, e)		return acc:tag("th"):attr("scope", "row"):wikitext(e):done end local function addColHeaderCell(acc, e)		return acc:tag("th"):attr("scope", "col"):wikitext(e):done end local function addContentCell(acc, e)		return acc:tag("td"):wikitext(e or ""):done end -- Header row rowNode = addColHeaderCell(tableNode:tag("tr"), self._title) rowNode = iter(self._col_headers):reduce(function (acc, e) return addColHeaderCell(acc, e) end, rowNode) -- Content rows for _, vr in ipairs(self._row_headers) do		local rowNode = tableNode:tag("tr") rowNode = addRowHeaderCell(rowNode, vr) local function addContentCells for _, vc in ipairs(self._col_headers) do				rowNode = addContentCell(rowNode, self._content[vr][vc]) end return rowNode end if (self.fuse_fields) then local count, val = 0, nil for _, vc in ipairs(self._col_headers) do				if (val ~= self._content[vr][vc]) then if count > 0 then rowNode = rowNode:tag("td"):attr("colspan", tostring(count)):wikitext(val) end val = self._content[vr][vc] count = 1 else count = count + 1 end end if (count > 0) then rowNode = rowNode:tag("td"):attr("colspan", tostring(count)):wikitext(val) end rowNode = rowNode:done else rowNode = addContentCells end end return baseNode end

-- TABBER -- local GameUITabber = GameUINode:subclass("GameUITabber")

-- Obtains the next (pseudo-)unique ID local function getNextUID(e) return tonumber(mw.getCurrentFrame:expandTemplate{ title = "Random Number", args = {tostring(2^31 - 1)} }) end

-- Constructor -- -- Creates a new GameUITabber instance. function GameUITabber:initialize(classes, styles, titles, contents) GameUITabber.super.initialize(self, classes, styles) self:addClass("game-ui-tabber") titles = titles or {} assert(type(titles) == type({}), "`titles` should be a table") contents = contents or {} assert(type(contents) == type({}), "`contents` should be a table") self._group_uid = getNextUID(nil) self._titles = {} self._contents = {} self._tab_uids = {} for i = 1, #titles do self:insertTabAt(titles[i], contents[i]) end end

-- Getters & Setters -- -- The unique ID of the tabber function GameUITabber:getGroupUID return self._group_uid end -- Deep copy of a table containing the title of each tab. function GameUITabber:getTitles return mw.clone(self._titles) end -- The contents of each tab. -- Each element is an GameUINode. function GameUITabber:getContents return self._contents end

-- The number of tabs this object has. function GameUITabber:getNumTabs return #self._titles end -- Deep copy of a table containing the unique identifier for each tab. function GameUITabber:getTabUIDs return mw.clone(self._tab_uids) end

-- Other methods -- -- Updates the title for the tab at the specified index. function GameUITabber:setTitle(value, index) assert(type(value) == type(""), "`value` should be a string") assert(type(index) == type(0), "`index` should be a number") assert(index >= 1 or index <= self:getNumTabs, "Parameter `index` is out of bounds") self._titles[index] = value end

-- Inserts a tab with the specified title and node (as a string) at the specified index (default: at the last index). -- Returns the node itself. function GameUITabber:insertTabAt(title, content, index) title = title or "" assert(type(title) == type(""), "`title` should be a string") assert(type(index) == type(nil) or type(index) == type(0), "`index` should be nil or a number")

if (index) then table.insert(self._titles, index, title) table.insert(self._contents, index, self:nodeToString(content)) table.insert(self._tab_uids, index, getNextUID(nil)) else table.insert(self._titles, title) table.insert(self._contents, self:nodeToString(content)) table.insert(self._tab_uids, getNextUID(nil)) end return self end

-- Removes a tab at the specified index (default: at the last index). -- Returns the node itself. function GameUITabber:removeTabAt(index) assert(type(index) == type(nil) or type(index) == type(0), "`index` should be nil or a number") if (index) then table.remove(self._titles, index) table.remove(self._contents, index) table.remove(self._tab_uids, index) else table.remove(self._titles) table.remove(self._contents) table.remove(self._tab_uids) end return self end

-- Other methods -- function GameUITabber:buildCurrent(baseNode) local tabNode = baseNode:tag("div") if (self:getNumTabs == 1) then tabNode = tabNode:addClass("tabs-lone") else tabNode = tabNode:addClass("tabs-multi"):css("grid-template-columns", string.format("repeat(%d, min-content)", self:getNumTabs)) end -- Use the radio button trick local node = "" local group_uid_str = string.format("%d", self._group_uid) for i = 1, self:getNumTabs do		local title, uid, content = self._titles[i], self._tab_uids[i], self._contents[i] local uid_str = string.format("%d", uid) local args = { id = uid_str, name = group_uid_str, title = title } if (i == 1) then args["checked"] = "checked" end local radioTab = mw.getCurrentFrame:callParserFunction{name = "#widget:Radio Tab", args = args} node = node .. radioTab .. content end tabNode:node(node) return baseNode end

-- MENU -- local GameUIMenu = GameUINode:subclass("GameUIMenu")

-- Constructor -- -- Creates a new GameUIMenu instance. function GameUIMenu:initialize(classes, styles, title, leftButton, rightButton) GameUIMenu.super.initialize(self, classes, styles) self:addClass("game-ui-menu") title = title or "" assert(type(title) == type(""), "`title` should be a string") self._title = title if (leftButton) then assert(type(leftButton) == type({}), "`leftButton` should be a table") self._leftButton = leftButton end if (rightButton) then assert(type(rightButton) == type({}), "`rightButton` should be a table") self._rightButton = rightButton end end

-- Getters & Setters -- -- The GameUIButton at the top left of the menu function GameUIMenu:getLeftButton return self._leftButton end

-- The title of the menu function GameUIMenu:getTitle return self._title end

-- The GameUIButton at the top right of the menu function GameUIMenu:rightButton return self._rightButton end

-- Other methods -- function GameUIMenu:buildCurrent(baseNode) local titlebar = GameUILabel({ "game-ui-titlebar" }, nil, self._title) if (self._leftButton) then titlebar:node(self:nodeToString(self._leftButton:addClass("left-button"))) end if (self._rightButton) then titlebar:node(self:nodeToString(self._rightButton:addClass("right-button"))) end baseNode:node(self:nodeToString(titlebar)) return baseNode end

return { GameUINode = GameUINode, GameUILabel = GameUILabel, GameUIButton = GameUIButton, GameUIList = GameUIList, GameUITable = GameUITable, GameUITabber = GameUITabber, GameUIMenu = GameUIMenu, }