From 86d8f23f9c4eea374b6785c94323965f48d7546e Mon Sep 17 00:00:00 2001 From: Austin Hicks Date: Thu, 28 Nov 2024 21:00:35 -0800 Subject: [PATCH] WIP --- .vscode/settings.json | 2 +- locale/en/ui-grid.cfg | 2 + math-helpers.lua | 14 +++ scripts/ui/grid.lua | 86 +++++++++++++++ scripts/ui/tab-list.lua | 232 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 locale/en/ui-grid.cfg create mode 100644 scripts/ui/grid.lua create mode 100644 scripts/ui/tab-list.lua diff --git a/.vscode/settings.json b/.vscode/settings.json index fbb787ad..19e3cd7a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,6 +30,6 @@ } ], "Lua.workspace.userThirdParty": [ - "c:\\Users\\Austin\\AppData\\Roaming\\Code\\User\\workspaceStorage\\5232b5213d87a49c56164541939376a4\\justarandomgeek.factoriomod-debug\\sumneko-3rd" + "c:\\Users\\Austin\\AppData\\Roaming\\Code\\User\\workspaceStorage\\63b7677c3b16e179985a791a1818f333\\justarandomgeek.factoriomod-debug\\sumneko-3rd" ] } \ No newline at end of file diff --git a/locale/en/ui-grid.cfg b/locale/en/ui-grid.cfg new file mode 100644 index 00000000..58539963 --- /dev/null +++ b/locale/en/ui-grid.cfg @@ -0,0 +1,2 @@ +[fa] +ui-grid-empty=Empty grid diff --git a/math-helpers.lua b/math-helpers.lua index 31fcf6b4..389e064c 100644 --- a/math-helpers.lua +++ b/math-helpers.lua @@ -22,4 +22,18 @@ function mod.mod1(index, length) return ((index - 1) % length) + 1 end +---@param x number +---@param low number +---@param high number +---@return number +function mod.clamp(x, low, high) + if x < low then + return low + elseif x > high then + return high + else + return x + end +end + return mod diff --git a/scripts/ui/grid.lua b/scripts/ui/grid.lua new file mode 100644 index 00000000..eedfba9f --- /dev/null +++ b/scripts/ui/grid.lua @@ -0,0 +1,86 @@ +--[[ +A simple grid: you give it a callback to get cell contents, dimensions, and +row/column names, and it handles figuring out all the keyboard stuff for you. + +Designed to be mounted in a TabList (ui/tab-list.lua). Callbacks should return +tablist responses to force closure. + +Grid positions are 1-based, to match lua tables. + +Your state will have a field `grid` injected into it; this field should be +treated as private. This allows passing you the full tab context directly, as +if you had implemented all of this logic yourself. +]] +local Math2 = require("math-helpers") + +local mod = {} + +---@alias fa.ui.GridStringOrResponse LocalisedString | fa.ui.TabEventResponse +---@alias fa.ui.GridNames (LocalisedString[])|(fun(number): fa.ui.GridStringOrResponse) + +---@class fa.ui.GridCallbacks +---@field title LocalisedString +---@field row_names fa.ui.GridNames +---@field col_names fa.ui.GridNames +---@field get_dims fun(self, fa.ui.TabContext): (number| fa.ui.TabEventResponse, number?) +---@field read_cell fun(self, fa.ui.TabContext, number, number): fa.ui.GridStringOrResponse + +---@class fa.ui.GridState +---@field x number +---@field y number + +---@class fa.ui.GridContext: fa.ui.TabContext +---@field state { grid: fa.ui.GridState } + +---@class fa.ui.Grid: fa.ui.TabCallbacks +---@field callbacks fa.ui.GridCallbacks +local GridImpl = {} + +---@param context fa.ui.GridContext +---@return fa.ui.TabEventResponse? +function GridImpl:_force_inbounds(context) + local size_x, size_y = self.callbacks.get_dims(context) + + -- Forced closure. + if type(size_x) ~= "number" then return size_x end + assert(size_y) + + if size_x == 0 or size_y == 0 then return end + + local state = context.state.grid + state.x = Math2.clamp(state.x, 1, size_x) + state.y = Math2.clamp(state.y, 1, size_y) +end + +function printout_if_lstring(msg, pindex) + if type(msg) == "table" and msg.kind then return msg end + printout(msg, pindex) +end +---@param context fa.ui.GridContext +function GridImpl:_move(context, dx, dy) + local state = context.state.grid + local x, y = state.x, state.y + local old_x, old_y = x, y + + state.x = x + dx + state.y = y + dy + local forced_resp = self:_force_inbounds(context) + if forced_resp then return forced_resp end + + local moved = state.x ~= old_x or state.y ~= old_y + return printout_if_lstring(self:_read_cell_string(context), context.pindex) +end + +---@return fa.ui.TabEventResponse|LocalisedString +function GridImpl:_read_cell_string(context) + local forced_resp = self:_force_inbounds(context) + if forced_resp then return forced_resp end + local size_x, size_y = self.callbacks:get_dims(context) + if type(size_x) ~= "number" then return size_x end + assert(size_y) + + if size_x == 0 or size_y == 0 then return { "fa.ui-grid-empty" } end + return self.callbacks:read_cell(context.state.grid.x, context.state.grid.y) +end + +return mod diff --git a/scripts/ui/tab-list.lua b/scripts/ui/tab-list.lua new file mode 100644 index 00000000..97fa841c --- /dev/null +++ b/scripts/ui/tab-list.lua @@ -0,0 +1,232 @@ +--[[ +Holds a list of named tabs, and lets you select between them. This is a lesser +form of #263, which can be converted to the full form as a special case, by +creating a layer that just calls the methods. That's a good idea anyway: +ultimately no one wants to write complex UI by individually handling keys. + +For now this does not support dynamicism. That is to say that tabs must be +declared at the top level of a file. You don't get multiple instances per +player. For example you can't have two open belt analyzers. As is the theme, +this can also be extended later once we are at the point of being able to have a +proper framework. In particular, layers can be used to provide the missing UI +stack abstractions that would allow multiple arbitrary sets of UIs to all be +open at once. + +For now this isn't super documented. If you are looking for some overarching +framework you won't find it. That comes later. This just gets us off the +ground. + +This is the top level of the UI hierarchy and also handles the state management. +Each tab will receive a context with a field to use for persistent state. +Whatever it stores there persists. It can reset this state by responding to +`on_tab_focused` and related events. Again, not super documented for now: this +is just better than status quo. + +To clarify how input gets here, this is currently hardcoded in the menu logic in +control.lua. That is, by checking global.players[pindex].menu_name, and setting +it for the open tab list. +]] +local StorageManager = require("scripts.storage-manager") +local Math2 = require("math-helpers") + +local mod = {} + +---@class fa.ui.TabContext +---@field pindex number +---@field player LuaPlayer +---@field state table Goes into storage. +---@field parameters table Whatever was passed to :open() + +---@enum fa.ui.TabEventResponseKind +mod.EVENT_RESPONSES = { + -- Return this special value to close a tab list in response to finding e.g. + -- an invalid entity. `on_tab_list_closed` will stil be called. State for + -- all tabs in this list is also dropped. + CLOSE_INVALID = {}, + + -- Return this value to ask the handler to clear this tab's state, resetting + -- it to the empty table. + CLEAR_STATE = {}, +} + +-- Can include other fields. +---@alias fa.ui.TabEventResponse { kind: fa.ui.TabEventResponseKind } + +---@alias fa.ui.SimpleTabHandler fun(fa.ui.TabContext): fa.ui.TabEventResponse + +---@class fa.ui.TabCallbacks +---@field title LocalisedString? If set, read out when the tab changes to this tab. +---@field on_tab_focused fa.ui.SimpleTabHandler? +---@field on_tab_unfocused fa.ui.SimpleTabHandler? +---@field on_tab_list_opened fa.ui.SimpleTabHandler? +---@field on_tab_list_closed fa.ui.SimpleTabHandler? +---@field on_up fa.ui.SimpleTabHandler? +---@field on_down fa.ui.SimpleTabHandler? +---@field on_left fa.ui.SimpleTabHandler? +---@field on_right fa.ui.SimpleTabHandler? + +---@class fa.ui.TabDescriptor +---@field name string +---@field callbacks fa.ui.TabCallbacks + +---@class fa.ui.TabListDeclaration +---@field menu_name string +---@field tabs fa.ui.TabDescriptor[] + +---@class fa.ui.TabListStorageState +---@field active_tab number +---@field tab_states table +---@field parameters any +---@field currently_open boolean + +---@type table> +local tablist_storage = StorageManager.declare_storage_module("tab_list", {}) + +---@class fa.ui.TabList +---@field descriptors table +---@field menu_name string +---@field tab_order string[] +local TabList = {} +local TabList_meta = { __index = TabList } +mod.TabList = TabList + +-- Returns whatever the callback returns, including special responses. Does +-- nothing if this tablist is not open, which can happen when calling a bunch of +-- events back to back if the tab list has to close in the middle of a sequence +-- of actions. +function TabList:_do_callback(pindex, target_tab_index, cb_name, ...) + local tl = tablist_storage[pindex][self.menu_name] + local tabname = self.tab_order[target_tab_index] + local tabstate = tl.tab_states[tabname] + assert(tabstate, "this gets set up on self:open()") + if not tabstate.currently_open then return end + + local callbacks = self.descriptors[tabname].callbacks + local callback = callbacks[cb_name] + + -- The tab might not want to do anything. + if not callback then return end + + local player = game.get_player(pindex) + assert(player, "Somehow got input from a dead player") + + ---@type fa.ui.TabContext + local context = { + pindex = pindex, + player = player, + state = tabstate, + parameters = tl.parameters, + } + + local result = callback(context, ...) + if result and type(result) == "table" and result.kind then + local k = result.kind + if k == mod.EVENT_RESPONSES.CLEAR_STATE then + tl.tab_states[tabname] = {} + elseif k == mod.EVENT_RESPONSES.CLOSE_INVALID then + tl.tab_states = {} + -- TODO: closing logic. We don't have opening logic yet. + end + end + + return result +end + +-- Returns a method which will call the event handler for the given name, so +-- that we may avoid rewriting the same body over and over. +local function build_simple_method(evt_name) + return function(self, pindex) + local tl = tablist_storage[pindex][self.menu_name] + self:_do_callback(pindex, tl.active_tab, evt_name) + end +end + +---@type fun(self) +TabList.on_up = build_simple_method("on_up") +---@type fun(self) +TabList.on_down = build_simple_method("on_down") +---@type fun(self) +TabList.on_left = build_simple_method("on_left") +---@type fun(self) +TabList.on_right = build_simple_method("on_right") + +---@param pindex number +---@param direction 1|-1 +function TabList:_cycle(self, pindex, direction) + local tl = tablist_storage[pindex][self.menu_name] + local old_index = tl.active_tab + + local new_index = Math2.mod1(tl.active_tab + direction, #self.tab_order) + local wrapped = new_index ~= tl.active_tab + direction + + -- Need to play sounds here. + + local tabname = self.tab_order[tl.active_tab] + local desc = self.tabs[tabname] + local title = desc.callbacks.title + if title then printout(title, pindex) end + + -- Call the event on the old tab, then change to the new tab, then call the + -- focused event on the new tab. But only do these if the tab actually + -- changed, so that we don't duplicate work in the case of one tab. + if old_index ~= new_index then + self:_do_callback(pindex, old_index, "on_tab_unfocused") + self:_do_callback(pindex, new_index, "on_tab_focused") + end + tl.active_tab = new_index +end + +function TabList:next_tab(self, pindex) + self:_cycle(pindex, 1) +end + +function TabList:previous_tab(self, pindex) + self:_cycle(pindex, -1) +end + +function TabList:open(pindex, parameters) + storage.players[pindex].menu_name = self.menu_name + local tabstate = tablist_storage[pindex][self.menu_name] + + -- Tabs wishing to clear their state have the infrastructure to handle that + -- via responding in on_tab_list_opened/closed. + if not tabstate then + tabstate = { + parameters = parameters, + active_tab = 1, + tab_order = {}, + tab_states = {}, + currently_open = true, + } + tablist_storage[pindex][self.menu_name] = tabstate + + for _, desc in pairs(self.descriptors) do + table.insert(tabstate.tab_order, desc.name) + tabstate.tab_states[desc.name] = {} + end + end + + -- Parameters always gets updated. + tabstate.parameters = parameters + -- And it is now open. + tabstate.currently_open = true + + -- Tell all the tabs that they have opened, but abort if any of them close. + + for i = 1, #tabstate.tab_order do + self:_do_callback(pindex, i, "on_tab_list_opened") + end +end + +function TabList:close(pindex) + storage.players[pindex].menu_name = nil + storage.players[pindex].in_menu = false + + for i = 1, #self.tab_order do + self:_do_callback(pindex, i, "on_tab_list_closed") + end + + tablist_storage[pindex][self.menu_name].currently_open = false +end + +return mod