diff --git a/components/lua_ui/content.cpp b/components/lua_ui/content.cpp index 6f9cf61f2f..e7cf474bc9 100644 --- a/components/lua_ui/content.cpp +++ b/components/lua_ui/content.cpp @@ -44,11 +44,10 @@ namespace LuaUi void Content::insert(size_t index, const sol::table& table) { - size_t size = mOrdered.size(); - if (size < index) + if (mOrdered.size() < index) throw std::logic_error("Can't have gaps in UI Content."); mOrdered.insert(mOrdered.begin() + index, table); - for (size_t i = index; i < size; ++i) + for (size_t i = index; i < mOrdered.size(); ++i) { sol::optional name = mOrdered[i]["name"]; if (name.has_value()) diff --git a/components/lua_ui/element.cpp b/components/lua_ui/element.cpp index 46eb543cca..9f70cfc1da 100644 --- a/components/lua_ui/element.cpp +++ b/components/lua_ui/element.cpp @@ -55,8 +55,7 @@ namespace LuaUi { WidgetExtension* ext = children[i]; sol::table newLayout = content.at(i); - if (ext->widget()->getTypeName() == widgetType(newLayout) - && ext->getLayout() == newLayout) + if (ext->widget()->getTypeName() == widgetType(newLayout)) { updateWidget(ext, newLayout); } diff --git a/components/lua_ui/flex.cpp b/components/lua_ui/flex.cpp index e90fba2a1a..dfe943a02f 100644 --- a/components/lua_ui/flex.cpp +++ b/components/lua_ui/flex.cpp @@ -71,6 +71,7 @@ namespace LuaUi w->forcePosition(childPosition); primary(size) += static_cast(growFactor * getGrow(w)); w->forceSize(size); + w->updateCoord(); primary(childPosition) += primary(size); w->updateCoord(); } diff --git a/docs/source/generate_luadoc.sh b/docs/source/generate_luadoc.sh index 5376aa0ab9..d6bbbe3634 100755 --- a/docs/source/generate_luadoc.sh +++ b/docs/source/generate_luadoc.sh @@ -68,3 +68,4 @@ $DOCUMENTOR_PATH -f doc -d $OUTPUT_DIR openmw_aux/*lua $DOCUMENTOR_PATH -f doc -d $OUTPUT_DIR scripts/omw/ai.lua $DOCUMENTOR_PATH -f doc -d $OUTPUT_DIR scripts/omw/camera.lua $DOCUMENTOR_PATH -f doc -d $OUTPUT_DIR scripts/omw/mwui/init.lua +$DOCUMENTOR_PATH -f doc -d $OUTPUT_DIR scripts/omw/settings/player.lua diff --git a/docs/source/reference/lua-scripting/api.rst b/docs/source/reference/lua-scripting/api.rst index 110771fbcf..a6009edf1a 100644 --- a/docs/source/reference/lua-scripting/api.rst +++ b/docs/source/reference/lua-scripting/api.rst @@ -27,6 +27,7 @@ Lua API reference interface_ai interface_camera interface_mwui + interface_settings iterables @@ -94,12 +95,19 @@ Sources can be found in ``resources/vfs/openmw_aux``. In theory mods can overrid **Interfaces of built-in scripts** -+---------------------------------------------------------+--------------------+---------------------------------------------------------------+ -| Interface | Can be used | Description | -+=========================================================+====================+===============================================================+ -|:ref:`AI ` | by local scripts | | Control basic AI of NPCs and creatures. | -+---------------------------------------------------------+--------------------+---------------------------------------------------------------+ -|:ref:`Camera ` | by player scripts | | Allows to alter behavior of the built-in camera script | -| | | | without overriding the script completely. | -+---------------------------------------------------------+--------------------+---------------------------------------------------------------+ +.. list-table:: + :widths: 20 20 60 + * - Interface + - Can be used + - Description + * - :ref:`AI ` + - by local scripts + - Control basic AI of NPCs and creatures. + * - :ref:`Camera ` + - by player scripts + - | Allows to alter behavior of the built-in camera script + | without overriding the script completely. + * - :ref:`Settings ` + - by player and global scripts + - Save, display and track changes of setting values. diff --git a/docs/source/reference/lua-scripting/interface_settings.rst b/docs/source/reference/lua-scripting/interface_settings.rst new file mode 100644 index 0000000000..cd1994ccfa --- /dev/null +++ b/docs/source/reference/lua-scripting/interface_settings.rst @@ -0,0 +1,6 @@ +Interface Settings +================== + +.. raw:: html + :file: generated_html/scripts_omw_settings_player.html + diff --git a/docs/source/reference/lua-scripting/overview.rst b/docs/source/reference/lua-scripting/overview.rst index 91c0a071e0..e21469a53f 100644 --- a/docs/source/reference/lua-scripting/overview.rst +++ b/docs/source/reference/lua-scripting/overview.rst @@ -460,15 +460,22 @@ The order in which the scripts are started is important. So if one mod should ov **Interfaces of built-in scripts** -+---------------------------------------------------------+--------------------+---------------------------------------------------------------+ -| Interface | Can be used | Description | -+=========================================================+====================+===============================================================+ -|:ref:`AI ` | by local scripts | | Control basic AI of NPCs and creatures. | -+---------------------------------------------------------+--------------------+---------------------------------------------------------------+ -|:ref:`Camera ` | by player scripts | | Allows to alter behavior of the built-in camera script | -| | | | without overriding the script completely. | -+---------------------------------------------------------+--------------------+---------------------------------------------------------------+ +.. list-table:: + :widths: 20 20 60 + * - Interface + - Can be used + - Description + * - :ref:`AI ` + - by local scripts + - Control basic AI of NPCs and creatures. + * - :ref:`Camera ` + - by player scripts + - | Allows to alter behavior of the built-in camera script + | without overriding the script completely. + * - :ref:`Settings ` + - by player and global scripts + - Save, display and track changes of setting values. Event system ============ diff --git a/docs/source/reference/lua-scripting/widgets/flex.rst b/docs/source/reference/lua-scripting/widgets/flex.rst index 359d0d4394..2648e88996 100644 --- a/docs/source/reference/lua-scripting/widgets/flex.rst +++ b/docs/source/reference/lua-scripting/widgets/flex.rst @@ -24,7 +24,7 @@ Properties - ui.ALIGNMENT (Start) - Where to align the children in the main axis. * - arrange - - ui.ALIGNMETN (Start) + - ui.ALIGNMENT (Start) - How to arrange the children in the cross axis. External diff --git a/files/builtin_scripts/CMakeLists.txt b/files/builtin_scripts/CMakeLists.txt index a3ba1f5149..318e389585 100644 --- a/files/builtin_scripts/CMakeLists.txt +++ b/files/builtin_scripts/CMakeLists.txt @@ -17,6 +17,10 @@ set(LUA_BUILTIN_FILES scripts/omw/console/player.lua scripts/omw/console/global.lua scripts/omw/console/local.lua + scripts/omw/settings/player.lua + scripts/omw/settings/global.lua + scripts/omw/settings/common.lua + scripts/omw/settings/render.lua l10n/Calendar/en.yaml diff --git a/files/builtin_scripts/builtin.omwscripts b/files/builtin_scripts/builtin.omwscripts index af6320e0b2..989575ee1a 100644 --- a/files/builtin_scripts/builtin.omwscripts +++ b/files/builtin_scripts/builtin.omwscripts @@ -4,3 +4,5 @@ PLAYER: scripts/omw/console/player.lua GLOBAL: scripts/omw/console/global.lua CUSTOM: scripts/omw/console/local.lua PLAYER: scripts/omw/mwui/init.lua +GLOBAL: scripts/omw/settings/global.lua +PLAYER: scripts/omw/settings/player.lua diff --git a/files/builtin_scripts/scripts/omw/settings/common.lua b/files/builtin_scripts/scripts/omw/settings/common.lua new file mode 100644 index 0000000000..1e87dfd62b --- /dev/null +++ b/files/builtin_scripts/scripts/omw/settings/common.lua @@ -0,0 +1,135 @@ +local storage = require('openmw.storage') + +local contextSection = storage.playerSection or storage.globalSection +local groupSectionKey = 'OmwSettingGroups' +local groupSection = contextSection(groupSectionKey) +groupSection:removeOnExit() + +local function validateSettingOptions(options) + if type(options) ~= 'table' then + error('Setting options must be a table') + end + if type(options.key) ~= 'string' then + error('Setting must have a key') + end + if type(options.permanentStorage) ~= 'boolean' then + error('Setting must have a permanentStorage flag') + end + if type(options.renderer) ~= 'string' then + error('Setting must have a renderer') + end + if type(options.name) ~= 'string' then + error('Setting must have a name localization key') + end + if type(options.description) ~= 'string' then + error('Setting must have a descripiton localization key') + end +end + +local function validateGroupOptions(options) + if type(options) ~= 'table' then + error('Group options must be a table') + end + if type(options.key) ~= 'string' then + error('Group must have a key') + end + local conventionPrefix = "Settings" + if options.key:sub(1, conventionPrefix:len()) ~= conventionPrefix then + print(("Group key %s doesn't start with %s"):format(options.key, conventionPrefix)) + end + if type(options.page) ~= 'string' then + error('Group must belong to a page') + end + if type(options.order) ~= 'number' and type(options.order) ~= 'nil' then + error('Group order must be a number') + end + if type(options.l10n) ~= 'string' then + error('Group must have a localization context') + end + if type(options.name) ~= 'string' then + error('Group must have a name localization key') + end + if type(options.description) ~= 'string' then + error('Group must have a description localization key') + end + if type(options.settings) ~= 'table' then + error('Group must have a table of settings') + end + for _, opt in ipairs(options.settings) do + validateSettingOptions(opt) + end +end + +local function registerSetting(options) + return { + key = options.key, + permanentStorage = options.permanentStorage, + default = options.default, + renderer = options.renderer, + argument = options.argument, + + name = options.name, + description = options.description, + } +end + +local function registerGroup(options) + validateGroupOptions(options) + if groupSection:get(options.key) then + error(('Group with key %s was already registered'):format(options.key)) + end + local group = { + key = options.key, + page = options.page, + order = options.order or 0, + l10n = options.l10n, + name = options.name, + description = options.description, + + settings = {}, + } + local valueSection = contextSection(options.key) + for _, opt in ipairs(options.settings) do + local setting = registerSetting(opt) + if group.settings[setting.key] then + error(('Duplicate setting key %s'):format(options.key)) + end + group.settings[setting.key] = setting + if not valueSection:get(setting.key) then + valueSection:set(setting.key, setting.default) + end + end + groupSection:set(group.key, group) +end + +return { + getSection = function(global, key) + return (global and storage.globalSection or storage.playerSection)(key) + end, + setGlobalEvent = 'OMWSettingsGlobalSet', + groupSectionKey = groupSectionKey, + onLoad = function(saved) + if not saved then return end + for groupKey, settings in pairs(saved) do + local section = contextSection(groupKey) + for key, value in pairs(settings) do + section:set(key, value) + end + end + end, + onSave = function() + local saved = {} + for groupKey, group in pairs(groupSection:asTable()) do + local section = contextSection(groupKey) + saved[groupKey] = {} + for key, value in pairs(section:asTable()) do + if not group.settings[key].permanentStorage then + saved[groupKey][key] = value + end + end + end + groupSection:reset() + return saved + end, + registerGroup = registerGroup, +} \ No newline at end of file diff --git a/files/builtin_scripts/scripts/omw/settings/global.lua b/files/builtin_scripts/scripts/omw/settings/global.lua new file mode 100644 index 0000000000..5818668b0e --- /dev/null +++ b/files/builtin_scripts/scripts/omw/settings/global.lua @@ -0,0 +1,19 @@ +local storage = require('openmw.storage') + +local common = require('scripts.omw.settings.common') + +return { + interfaceName = 'Settings', + interface = { + registerGroup = common.registerGroup, + }, + engineHandlers = { + onLoad = common.onLoad, + onSave = common.onSave, + }, + eventHandlers = { + [common.setGlobalEvent] = function(e) + storage.globalSection(e.groupKey):set(e.settingKey, e.value) + end, + }, +} \ No newline at end of file diff --git a/files/builtin_scripts/scripts/omw/settings/player.lua b/files/builtin_scripts/scripts/omw/settings/player.lua new file mode 100644 index 0000000000..612ba3ec7b --- /dev/null +++ b/files/builtin_scripts/scripts/omw/settings/player.lua @@ -0,0 +1,160 @@ +local ui = require('openmw.ui') +local async = require('openmw.async') +local util = require('openmw.util') + +local common = require('scripts.omw.settings.common') +local render = require('scripts.omw.settings.render') + +render.registerRenderer('text', function(value, set, arg) + return { + type = ui.TYPE.TextEdit, + props = { + size = util.vector2(arg and arg.size or 150, 30), + text = value, + textColor = util.color.rgb(1, 1, 1), + textSize = 15, + textAlignV = ui.ALIGNMENT.End, + }, + events = { + textChanged = async:callback(function(s) set(s) end), + }, + } +end) + +--- +-- @type PageOptions +-- @field #string key A unique key +-- @field #string l10n A localization context (an argument of core.l10n) +-- @field #string name A key from the localization context +-- @field #string description A key from the localization context + +--- +-- @type GroupOptions +-- @field #string key A unique key, starts with "Settings" by convention +-- @field #string l10n A localization context (an argument of core.l10n) +-- @field #string name A key from the localization context +-- @field #string description A key from the localization context +-- @field #string page Key of a page which will contain this group +-- @field #number order Groups within the same page are sorted by this number, or their key for equal values. +-- Defaults to 0. +-- @field #list<#SettingOptions> settings A [iterables#List](iterables.html#List) of #SettingOptions + +--- +-- @type SettingOptions +-- @field #string key A unique key +-- @field #string name A key from the localization context +-- @field #string description A key from the localization context +-- @field default A default value +-- @field #string renderer A renderer key +-- @field argument An argument for the renderer +-- @field #boolean permanentStorage Whether the setting should is stored in permanent storage, or in the save file + +return { + interfaceName = 'Settings', + --- + -- @module Settings + -- @usage + -- -- In a player script + -- local storage = require('openmw.storage') + -- local I = require('openmw.interfaces') + -- I.Settings.registerGroup({ + -- key = 'SettingsPlayerMyMod', + -- page = 'MyPage', + -- l10n = 'mymod', + -- name = 'modName', + -- description = 'modDescription', + -- settings = { + -- { + -- key = 'Greeting', + -- renderer = 'text', + -- name = 'greetingName', + -- description = 'greetingDescription', + -- default = 'Hello, world!', + -- argument = { + -- size = 200, + -- }, + -- }, + -- }, + -- }) + -- local playerSettings = storage.playerSection('SettingsPlayerMyMod') + -- -- access a setting page registered by a global script + -- local globalSettings = storage.globalSection('SettingsGlobalMyMod') + interface = { + --- + -- @field [parent=#Settings] #string version + version = 0, + --- + -- @function [parent=#Settings] registerPage Register a page to be displayed in the settings menu, + -- only available in player scripts + -- @param #PageOptions options + -- @usage + -- I.Settings.registerPage({ + -- key = 'MyModName', + -- l10n = 'MyModName', + -- name = 'MyModName', + -- description = 'MyModDescription', + -- })--- + registerPage = render.registerPage, + --- + -- @function [parent=#Settings] registerRenderer Register a renderer, + -- only avaialable in player scripts + -- @param #string key + -- @param #function renderer A renderer function, receives setting's value, + -- a function to change it and an argument from the setting options + -- @usage + -- I.Settings.registerRenderer('text', function(value, set, arg) + -- return { + -- type = ui.TYPE.TextEdit, + -- props = { + -- size = util.vector2(arg and arg.size or 150, 30), + -- text = value, + -- textColor = util.color.rgb(1, 1, 1), + -- textSize = 15, + -- textAlignV = ui.ALIGNMENT.End, + -- }, + -- events = { + -- textChanged = async:callback(function(s) set(s) end), + -- }, + -- } + -- end) + registerRenderer = render.registerRenderer, + --- + -- @function [parent=#Settings] registerGroup Register a group to be attached to a page, + -- available both in player and global scripts + -- @param #GroupOptions options + -- @usage + -- I.Settings.registerGroup { + -- key = 'SettingsTest', + -- page = 'test', + -- l10n = 'test', + -- name = 'Player', + -- description = 'Player settings group', + -- settings = { + -- { + -- key = 'Greeting', + -- saveOnly = true, + -- default = 'Hi', + -- renderer = 'text', + -- argument = { + -- size = 200, + -- }, + -- name = 'Text Input', + -- description = 'Short text input', + -- }, + -- { + -- key = 'Key', + -- saveOnly = false, + -- default = input.KEY.LeftAlt, + -- renderer = 'keybind', + -- name = 'Key', + -- description = 'Bind Key', + -- }, + -- } + -- } + registerGroup = common.registerGroup, + }, + engineHandlers = { + onLoad = common.onLoad, + onSave = common.onSave, + }, +} \ No newline at end of file diff --git a/files/builtin_scripts/scripts/omw/settings/render.lua b/files/builtin_scripts/scripts/omw/settings/render.lua new file mode 100644 index 0000000000..c072d20e94 --- /dev/null +++ b/files/builtin_scripts/scripts/omw/settings/render.lua @@ -0,0 +1,314 @@ +local ui = require('openmw.ui') +local util = require('openmw.util') +local async = require('openmw.async') +local core = require('openmw.core') +local storage = require('openmw.storage') + +local common = require('scripts.omw.settings.common') + +local renderers = {} +local function registerRenderer(name, renderFunction) + renderers[name] = renderFunction +end + +local pages = {} +local groups = {} +local pageOptions = {} + +local padding = function(size) + return { + props = { + size = util.vector2(size, size), + } + } +end +local smallPadding = padding(10) +local bigPadding = padding(25) + +local pageHeader = { + props = { + textColor = util.color.rgb(1, 1, 1), + textSize = 30, + }, +} +local groupHeader = { + props = { + textColor = util.color.rgb(1, 1, 1), + textSize = 25, + }, +} +local normal = { + props = { + textColor = util.color.rgb(1, 1, 1), + textSize = 20, + }, +} + +local function renderSetting(group, setting, value, global) + local renderFunction = renderers[setting.renderer] + if not renderFunction then + error(('Setting %s of %s has unknown renderer %s'):format(setting.key, group.key, setting.renderer)) + end + local set = function(value) + if global then + core.sendGlobalEvent(common.setGlobalEvent, { + groupKey = group.key, + settingKey = setting.key, + value = value, + }) + else + storage.playerSection(group.key):set(setting.key, value) + end + end + local l10n = core.l10n(group.l10n) + return { + name = setting.key, + type = ui.TYPE.Flex, + content = ui.content { + { + type = ui.TYPE.Flex, + props = { + horizontal = true, + align = ui.ALIGNMENT.Start, + arrange = ui.ALIGNMENT.End, + }, + content = ui.content { + { + type = ui.TYPE.Text, + template = normal, + props = { + text = l10n(setting.name), + }, + }, + smallPadding, + renderFunction(value, set, setting.argument), + smallPadding, + { + type = ui.TYPE.Text, + template = normal, + props = { + text = 'Reset', + }, + events = { + mouseClick = async:callback(function() + set(setting.default) + end), + }, + }, + }, + }, + }, + } +end + +local groupLayoutName = function(key, global) + return ('%s%s'):format(global and 'global_' or 'player_', key) +end + +local function renderGroup(group, global) + local l10n = core.l10n(group.l10n) + local layout = { + name = groupLayoutName(group.key, global), + type = ui.TYPE.Flex, + content = ui.content { + { + type = ui.TYPE.Flex, + props = { + horizontal = true, + align = ui.ALIGNMENT.Start, + arrange = ui.ALIGNMENT.End, + }, + content = ui.content { + { + name = 'name', + type = ui.TYPE.Text, + template = groupHeader, + props = { + text = l10n(group.name), + }, + }, + smallPadding, + { + name = 'description', + type = ui.TYPE.Text, + template = normal, + props = { + text = l10n(group.description), + }, + }, + }, + }, + smallPadding, + { + name = 'settings', + type = ui.TYPE.Flex, + content = ui.content{}, + }, + bigPadding, + }, + } + local settingsContent = layout.content.settings.content + local valueSection = common.getSection(global, group.key) + for _, setting in pairs(group.settings) do + settingsContent:add(renderSetting(group, setting, valueSection:get(setting.key), global)) + end + return layout +end + +local function pageGroupComparator(a, b) + return a.order < b.order or ( + a.order == b.order and a.key < b.key + ) +end + +local function generateSearchHints(page) + local hints = {} + local l10n = core.l10n(page.l10n) + table.insert(hints, l10n(page.name)) + table.insert(hints, l10n(page.description)) + local pageGroups = groups[page.key] + for _, pageGroup in pairs(pageGroups) do + local group = common.getSection(pageGroup.global, common.groupSectionKey):get(pageGroup.key) + local l10n = core.l10n(group.l10n) + table.insert(hints, l10n(group.name)) + table.insert(hints, l10n(group.description)) + for _, setting in pairs(group.settings) do + table.insert(hints, l10n(setting.name)) + table.insert(hints, l10n(setting.description)) + end + end + return table.concat(hints, ' ') +end + +local function renderPage(page) + local l10n = core.l10n(page.l10n) + local layout = { + name = page.key, + type = ui.TYPE.Flex, + content = ui.content { + smallPadding, + { + type = ui.TYPE.Flex, + props = { + horizontal = true, + align = ui.ALIGNMENT.Start, + arrange = ui.ALIGNMENT.End, + }, + content = ui.content { + { + name = 'name', + type = ui.TYPE.Text, + template = pageHeader, + props = { + text = l10n(page.name), + }, + }, + smallPadding, + { + name = 'description', + type = ui.TYPE.Text, + template = normal, + props = { + text = l10n(page.description), + }, + }, + }, + }, + bigPadding, + { + name = 'groups', + type = ui.TYPE.Flex, + content = ui.content {}, + }, + }, + } + local groupsContent = layout.content.groups.content + local pageGroups = groups[page.key] + local sortedGroups = {} + for i, v in ipairs(pageGroups) do sortedGroups[i] = v end + table.sort(sortedGroups, pageGroupComparator) + for _, pageGroup in ipairs(sortedGroups) do + local group = common.getSection(pageGroup.global, common.groupSectionKey):get(pageGroup.key) + groupsContent:add(renderGroup(group, pageGroup.global)) + end + return { + name = l10n(page.name), + element = ui.create(layout), + searchHints = generateSearchHints(page), + } +end + +local function onSettingChanged(global) + return async:callback(function(groupKey, settingKey) + local group = common.getSection(global, common.groupSectionKey):get(groupKey) + if not pageOptions[group.page] then return end + + local element = pageOptions[group.page].element + local groupLayout = element.layout.content.groups.content[groupLayoutName(group.key, global)] + local settingsLayout = groupLayout.content.settings + local value = common.getSection(global, group.key):get(settingKey) + settingsLayout.content[settingKey] = renderSetting(group, group.settings[settingKey], value, global) + element:update() + end) +end +local function onGroupRegistered(global, key) + local group = common.getSection(global, common.groupSectionKey):get(key) + groups[group.page] = groups[group.page] or {} + local pageGroup = { + key = group.key, + global = global, + order = group.order, + } + table.insert(groups[group.page], pageGroup) + common.getSection(global, group.key):subscribe(onSettingChanged(global)) + + if not pages[group.page] then return end + local options = renderPage(pages[group.page]) + pageOptions[group.page].element:destroy() + for k, v in pairs(options) do + pageOptions[group.page][k] = v + end +end +local globalGroups = storage.globalSection(common.groupSectionKey) +for groupKey in pairs(globalGroups:asTable()) do + onGroupRegistered(true, groupKey) +end +globalGroups:subscribe(async:callback(function(_, key) + if key then onGroupRegistered(true, key) end +end)) +storage.playerSection(common.groupSectionKey):subscribe(async:callback(function(_, key) + if key then onGroupRegistered(false, key) end +end)) + +local function registerPage(options) + if type(options) ~= 'table' then + error('Page options must be a table') + end + if type(options.key) ~= 'string' then + error('Page must have a key') + end + if type(options.l10n) ~= 'string' then + error('Page must have a localization context') + end + if type(options.name) ~= 'string' then + error('Page must have a name') + end + if type(options.description) ~= 'string' then + error('Page must have a description') + end + local page = { + key = options.key, + l10n = options.l10n, + name = options.name, + description = options.description, + } + pages[page.key] = page + groups[page.key] = groups[page.key] or {} + pageOptions[page.key] = renderPage(page) + ui.registerSettingsPage(pageOptions[page.key]) +end + +return { + registerPage = registerPage, + registerRenderer = registerRenderer, +} \ No newline at end of file