From 0a88413385a6e6ac8433f612f2bf8c7d7b50080d Mon Sep 17 00:00:00 2001 From: Chris Johnsen Date: Sun, 6 Apr 2025 02:02:30 -0500 Subject: [PATCH 01/10] gui.dflayout: fort mode toolbar position calculations --- docs/changelog.txt | 1 + docs/dev/Lua API.rst | 124 +++++++++++++++ library/lua/gui/dflayout.lua | 278 ++++++++++++++++++++++++++++++++++ test/library/gui/dflayout.lua | 236 +++++++++++++++++++++++++++++ 4 files changed, 639 insertions(+) create mode 100644 library/lua/gui/dflayout.lua create mode 100644 test/library/gui/dflayout.lua diff --git a/docs/changelog.txt b/docs/changelog.txt index cd43392b78..b9dc87f50f 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -70,6 +70,7 @@ Template for new versions: ## Lua - ``dfhack.military.addToSquad``: expose Military API function +- ``gui.dflayout``: provide DF fort mode toolbar position information ## Removed diff --git a/docs/dev/Lua API.rst b/docs/dev/Lua API.rst index 795df5b377..d62b33a695 100644 --- a/docs/dev/Lua API.rst +++ b/docs/dev/Lua API.rst @@ -6516,6 +6516,130 @@ Example usage:: local first_border_texpos = textures.tp_border_thin(1) +gui.dflayout +============ + +This module provides position information for DF UI elements that are not always +straightforward to calculate. + +It currently only describes the fortress mode toolbars at the bottom of the +screen. + +Unless otherwise noted, the dimensions used by this module are in UI tiles and +offsets are from the boundaries of the (caller provided) interface area. + +General Constants +----------------- + +The module provides these convenience constants: + +* ``MINIMUM_INTERFACE_SIZE`` + + The dimensions (``width`` and ``height``) of the minimum-size DF window: + 114x46 UI tiles. Other fields may also be present, but they are not used by + this module. + +* ``TOOLBAR_HEIGHT`` + + The height of the primary toolbars at the bottom of the DF window (3 rows). + +* ``SECONDARY_TOOLBAR_HEIGHT`` + + The height of the secondary toolbars that are sometimes placed above the + primary toolbars (also 3 rows). + +Fortress Mode Toolbars +---------------------- + +Fortress mode DF draws three primary toolbars and (depending on whether a "tool" +is active) possibly one of several secondary toolbars at the bottom of the +interface area. + +The toolbar descriptions are available through these module fields: + +* ``fort.toolbars.left`` +* ``fort.toolbars.center`` +* ``fort.toolbars.right`` + +The descriptions of the secondary toolbars are available through the fields of +``fort.secondary_toolbars``: + +* ``dig``, ``chop``, ``gather``, ``smooth``, ``erase``, ``stockpile``, + ``stockpile_paint``, ``burrow_paint``, ``traffic``, and ``mass_designation`` + +The DF build menu (which is displayed in mostly the same place and activated in +the same way as the other secondary toolbars) is not currently supported. It has +significant differences from the other secondary toolbars. + +Each toolbar description table has these fields: + +* ``width`` the width of the toolbar +* ``buttons`` a table indexed by "button names" that provides info about + individual buttons: + + * ``offset`` the left-offset from left edge of the toolbar + * ``width`` the width of the button + + Please consult the module source for each toolbar's button names. + +* ``frame(interface_size)`` a function that calculates the placement of the + toolbar when drawn in an interface of the specified size + + The ``interface_size`` should be a table with ``width`` and ``height`` fields. + Common size sources include the ``parent_rect`` parameter passed to a + non-fullscreen overlay widget's layout methods (``updateLayout``, etc.), and + the interface area returned from ``gui.get_interface_rect()``). + + The return value is a table with the following fields: + + * ``l``, ``r``, ``t``, ``b``: the column/row offsets to the toolbar from the + edges of the interface area + * ``w``, ``h``: the size of the toolbar (``w`` is the same as ``toolbar.width``) + + ``l + w + r`` and ``t + h + b`` will equal the provided interface's width and + height, respectively. + + This table "shape" is similar to a `Widget `_'s ``frame`` and + should be useful in positioning a DFHack widget relative to the toolbar. + +Fort toolbar examples: + + * The ends of a toolbar can be located by combining the offset and size data + from a toolbar's frame data:: + + local layout = require('gui.dflayout') + ... + local erase_frame = layout.fort.secondary_toolbars.erase.frame(interface_size) + local erase_right_l_offset = erase_frame.l + erase_frame.w + local erase_left_r_offset = erase_frame.r + erase_frame.w + + -- interface_size.width |--------------------------| + -- erase_frame.l |---------- ------| erase_frame.r + -- | [erase tb] | + -- erase_frame.w | ---------- | + -- erase_right_l_offset |-------------------- | + -- | ----------------| erase_left_r_offset + + * A specific toolbar button can be located by combining the toolbar's frame + data with the ``offset`` of the button:: + + local layout = require('gui.dflayout') + ... + local dig = layout.fort.secondary_toolbars.dig + local dig_frame = dig.frame(interface_size) + local dig_adv = dig.buttons.advanced_toggle + local dig_adv_l_offset = dig_frame.l + dig_adv.offset + local dig_adv_r_offset = dig_frame.r + dig_frame.w - (dig_adv.offset + dig_adv.width) + -- OR interface_size.width - (dig_adv_l_offset + dig_adv.width) + + -- interface_size.width |--------------------------| + -- dig_frame.l |---------- ---| dig_frame.r + -- dig_frame.w | ------------- | + -- dig_adv.offset | ------ | + -- dig_adv.width | -- | + -- | [ dig [] tb ] | + -- dig_adv_l_offset |---------------- --------| dig_adv_r_offset + .. _lua-plugins: ======= diff --git a/library/lua/gui/dflayout.lua b/library/lua/gui/dflayout.lua new file mode 100644 index 0000000000..3b741ae7c8 --- /dev/null +++ b/library/lua/gui/dflayout.lua @@ -0,0 +1,278 @@ +local _ENV = mkmodule('gui.dflayout') + +-- Provide data-driven locations for the DF toolbars at the bottom of the +-- screen. Not quite as nice as getting the data from DF directly, but better +-- than hand-rolling calculations for each "interesting" button. + +TOOLBAR_HEIGHT = 3 +SECONDARY_TOOLBAR_HEIGHT = 3 +MINIMUM_INTERFACE_SIZE = require('gui').mkdims_wh(0, 0, 114, 46) + +-- Only width and height are used here. We could define a new "@class", but +-- LuaLS doesn't seem to accept gui.dimensions values as compatible with that +-- new class... +---@alias DFLayout.Rectangle.Size gui.dimension + +---@generic T +---@param sequences T[][] +---@return T[] +local function concat_sequences(sequences) + local collected = {} + for _, sequence in ipairs(sequences) do + table.move(sequence, 1, #sequence, #collected + 1, collected) + end + return collected +end + +---@alias DFLayout.Toolbar.NamedWidth table -- single entry, value is width +---@alias DFLayout.Toolbar.NamedOffsets table -- multiple entries, values are offsets +---@alias DFLayout.Toolbar.Button { offset: integer, width: integer } +---@alias DFLayout.Toolbar.NamedButtons table -- multiple entries + +---@class DFLayout.Widget.frame: widgets.Widget.frame +---@field l integer Gap between the left edge of the frame and the parent. +---@field t integer Gap between the top edge of the frame and the parent. +---@field r integer Gap between the right edge of the frame and the parent. +---@field b integer Gap between the bottom edge of the frame and the parent. +---@field w integer Width +---@field h integer Height + +---@class DFLayout.Toolbar.Base +---@field buttons DFLayout.Toolbar.NamedButtons +---@field width integer + +---@class DFLayout.Toolbar: DFLayout.Toolbar.Base +---@field frame fun(interface_size: DFLayout.Rectangle.Size): DFLayout.Widget.frame + +---@param widths DFLayout.Toolbar.NamedWidth[] single-name entries only! +---@return DFLayout.Toolbar.Base +local function button_widths_to_toolbar(widths) + local buttons = {} + local offset = 0 + for _, ww in ipairs(widths) do + local name, w = next(ww) + if name then + if not name:startswith('_') then + buttons[name] = { offset = offset, width = w } + end + offset = offset + w + end + end + return { buttons = buttons, width = offset } +end + +---@param buttons string[] +---@return DFLayout.Toolbar.NamedWidth[] +local function buttons_to_widths(buttons) + local widths = {} + for _, button_name in ipairs(buttons) do + table.insert(widths, { [button_name] = 4 }) + end + return widths +end + +---@param buttons string[] +---@return DFLayout.Toolbar.Base +local function buttons_to_toolbar(buttons) + return button_widths_to_toolbar(buttons_to_widths(buttons)) +end + +-- Fortress mode toolbar definitions +fort = {} + +---@alias DFLayout.PrimaryToolbarNames 'left' | 'center' | 'right' + +---@type table +fort.toolbars = {} + +---@class DFLayout.Fort.Toolbar.Left: DFLayout.Toolbar +fort.toolbars.left = buttons_to_toolbar{ + 'citizens', 'tasks', 'places', 'labor', + 'orders', 'nobles', 'objects', 'justice', +} + +---@param interface_size DFLayout.Rectangle.Size +---@return DFLayout.Widget.frame +function fort.toolbars.left.frame(interface_size) + return { + l = 0, + w = fort.toolbars.left.width, + r = interface_size.width - fort.toolbars.left.width, + + t = interface_size.height - TOOLBAR_HEIGHT, + h = TOOLBAR_HEIGHT, + b = 0, + } +end + +local fort_left_center_toolbar_gap_minimum = 7 + +---@class DFLayout.Fort.Toolbar.Center: DFLayout.Toolbar +fort.toolbars.center = button_widths_to_toolbar{ + { _left_border = 1 }, + { dig = 4 }, { chop = 4 }, { gather = 4 }, { smooth = 4 }, { erase = 4 }, + { _divider = 1 }, + { build = 4 }, { stockpile = 4 }, { zone = 4 }, + { _divider = 1 }, + { burrow = 4 }, { cart = 4 }, { traffic = 4 }, + { _divider = 1 }, + { mass_designation = 4 }, + { _right_border = 1 }, +} + +---@param interface_size DFLayout.Rectangle.Size +---@return DFLayout.Widget.frame +function fort.toolbars.center.frame(interface_size) + -- center toolbar is "centered" in interface area, but never closer to the + -- left toolbar than fort_left_center_toolbar_gap_minimum + + local interface_offset_centered = math.ceil((interface_size.width - fort.toolbars.center.width + 1) / 2) + local interface_offset_min = fort.toolbars.left.width + fort_left_center_toolbar_gap_minimum + local interface_offset = math.max(interface_offset_min, interface_offset_centered) + + return { + l = interface_offset, + w = fort.toolbars.center.width, + r = interface_size.width - interface_offset - fort.toolbars.center.width, + + t = interface_size.height - TOOLBAR_HEIGHT, + h = TOOLBAR_HEIGHT, + b = 0, + } +end + +---@alias DFLayout.Fort.SecondaryToolbar.ToolNames 'dig' | 'chop' | 'gather' | 'smooth' | 'erase' | 'build' | 'stockpile' | 'zone' | 'burrow' | 'cart' | 'traffic' | 'mass_designation' +---@alias DFLayout.Fort.SecondaryToolbar.Names 'dig' | 'chop' | 'gather' | 'smooth' | 'erase' | 'stockpile' | 'stockpile_paint' | 'burrow_paint' | 'traffic' | 'mass_designation' + +---@param interface_size DFLayout.Rectangle.Size +---@param tool_name DFLayout.Fort.SecondaryToolbar.ToolNames +---@param secondary_toolbar DFLayout.Toolbar.Base +---@return DFLayout.Widget.frame +local function center_secondary_frame(interface_size, tool_name, secondary_toolbar) + local toolbar_offset = fort.toolbars.center.frame(interface_size).l + local toolbar_button = fort.toolbars.center.buttons[tool_name] or dfhack.error('invalid tool name: ' .. tool_name) + + -- Ideally, the secondary toolbar is positioned directly above the (main) toolbar button + local ideal_offset = toolbar_offset + toolbar_button.offset + + -- In "narrow" interfaces conditions, a wide secondary toolbar (pretty much + -- any tool that has "advanced" options) that was ideally positioned above + -- its tool's button would extend past the right edge of the interface area. + -- Such wide secondary toolbars are instead right justified with a bit of + -- padding. + + -- padding necessary to line up width-constrained secondaries + local secondary_padding = 5 + local width_constrained_offset = math.max(0, interface_size.width - (secondary_toolbar.width + secondary_padding)) + + -- Use whichever position is left-most. + local l = math.min(ideal_offset, width_constrained_offset) + return { + l = l, + w = secondary_toolbar.width, + r = interface_size.width - l - secondary_toolbar.width, + + t = interface_size.height - TOOLBAR_HEIGHT - SECONDARY_TOOLBAR_HEIGHT, + h = SECONDARY_TOOLBAR_HEIGHT, + b = TOOLBAR_HEIGHT, + } +end + +---@type table +fort.secondary_toolbars = {} + +---@param tool_name DFLayout.Fort.SecondaryToolbar.ToolNames +---@param secondary_name DFLayout.Fort.SecondaryToolbar.Names +---@param toolbar DFLayout.Toolbar.Base +---@return DFLayout.Toolbar +local function define_center_secondary(tool_name, secondary_name, toolbar) + local ntb = toolbar --[[@as DFLayout.Toolbar]] + ---@param interface_size DFLayout.Rectangle.Size + ---@return DFLayout.Widget.frame + function ntb.frame(interface_size) + return center_secondary_frame(interface_size, tool_name, ntb) + end + fort.secondary_toolbars[secondary_name] = ntb + return ntb +end + +define_center_secondary('dig', 'dig', buttons_to_toolbar{ + 'dig', 'stairs', 'ramp', 'channel', 'remove_construction', '_gap', + 'rectangle', 'draw', '_gap', + 'advanced_toggle', '_gap', + 'all', 'auto', 'ore_gem', 'gem', '_gap', + 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', '_gap', + 'blueprint', 'blueprint_to_standard', 'standard_to_blueprint', +}) +define_center_secondary('chop', 'chop', buttons_to_toolbar{ + 'chop', '_gap', + 'rectangle', 'draw', '_gap', + 'advanced_toggle', '_gap', + 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', '_gap', + 'blueprint', 'blueprint_to_standard', 'standard_to_blueprint', +}) +define_center_secondary('gather', 'gather', buttons_to_toolbar{ + 'gather', '_gap', + 'rectangle', 'draw', '_gap', + 'advanced_toggle', '_gap', + 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', '_gap', + 'blueprint', 'blueprint_to_standard', 'standard_to_blueprint', +}) +define_center_secondary('smooth', 'smooth', buttons_to_toolbar{ + 'smooth', 'engrave', 'carve_track', 'carve_fortification', '_gap', + 'rectangle', 'draw', '_gap', + 'advanced_toggle', '_gap', + 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', '_gap', + 'blueprint', 'blueprint_to_standard', 'standard_to_blueprint', +}) +define_center_secondary( 'erase', 'erase', buttons_to_toolbar{ + 'rectangle', + 'draw', +}) +-- build -- completely different and quite variable +define_center_secondary('stockpile', 'stockpile', buttons_to_toolbar{ 'add_stockpile' }) +define_center_secondary('stockpile', 'stockpile_paint', buttons_to_toolbar{ + 'rectangle', 'draw', 'erase_toggle', 'remove', +}) +-- zone -- no secondary toolbar +-- burrow -- no direct secondary toolbar +define_center_secondary('burrow', 'burrow_paint', buttons_to_toolbar{ + 'rectangle', 'draw', 'erase_toggle', 'remove', +}) +-- cart -- no secondary toolbar +define_center_secondary('traffic', 'traffic', button_widths_to_toolbar( + concat_sequences{ buttons_to_widths{ + 'high', 'normal', 'low', 'restricted', '_gap', + 'rectangle', 'draw', '_gap', + 'advanced_toggle', '_gap', + }, { + { weight_which = 4 }, + { weight_slider = 26 }, + { weight_input = 6 }, + } } +)) +define_center_secondary('mass_designation', 'mass_designation', buttons_to_toolbar{ + 'claim', 'forbid', 'dump', 'no_dump', 'melt', 'no_melt', 'hidden', 'visible', '_gap', + 'rectangle', 'draw', +}) + +---@class DFLayout.Fort.Toolbar.Right: DFLayout.Toolbar +fort.toolbars.right = buttons_to_toolbar{ + 'squads', 'world', +} + +---@param interface_size DFLayout.Rectangle.Size +---@return DFLayout.Widget.frame +function fort.toolbars.right.frame(interface_size) + return { + l = interface_size.width - fort.toolbars.right.width, + w = fort.toolbars.right.width, + r = 0, + + t = interface_size.height - TOOLBAR_HEIGHT, + h = TOOLBAR_HEIGHT, + b = 0, + } +end + +return _ENV diff --git a/test/library/gui/dflayout.lua b/test/library/gui/dflayout.lua new file mode 100644 index 0000000000..7680fe5e35 --- /dev/null +++ b/test/library/gui/dflayout.lua @@ -0,0 +1,236 @@ +config.target = 'core' + +-- hints for the typechecker +expect = expect or require('test_util.expect') +test = test or {} + +local layout = require('gui.dflayout') +local ftb = layout.fort.toolbars + +local function combine_comment(comment, suffix) + if comment and suffix then + return comment .. ': ' .. suffix + end + return comment or suffix +end + +------ BEGIN MAGIC NUMBERS ------ + +local gui = require('gui') + +local flush_sizes = { + layout.MINIMUM_INTERFACE_SIZE, -- 114x46; from 912x552 with 8x12 UI tiles + gui.mkdims_wh(0, 0, 180, 90), -- 75% interface in 1920x1080 with 8x12 UI tiles + gui.mkdims_wh(0, 0, 240, 90), -- 100% interface in 1920x1080 with 8x12 UI tiles + gui.mkdims_wh(0, 0, 480, 180), -- 100% interface in 3840x2160 with 8x12 UI tiles +} + +local MINIMUM_INTERFACE_WIDTH = layout.MINIMUM_INTERFACE_SIZE.width +local LARGEST_CHECKED_INTERFACE_WIDTH = 210 -- traffic is the last to start "tracking" its center button at 196 interface width + +local function for_all_checked_interface_widths(fn) + for w = MINIMUM_INTERFACE_WIDTH, LARGEST_CHECKED_INTERFACE_WIDTH do + local interface_size = gui.mkdims_wh(0, 0, w, 46) + fn(interface_size) + end +end + +-- Most of the magic constants listed below are related to these values, so warn +-- if these differ from the baseline values. +function test.toolbar_positions_baseline() + expect.eq(ftb.left.width, 32, 'unexpected fort left toolbar width; many tests will probably fail') + expect.eq(ftb.center.width, 53, 'unexpected fort center toolbar width; many tests will probably fail') +end + +local function no_growth() + return 0 +end +local function one_for_one_growth(delta) + return delta +end +local function odd_one_for_two_growth(delta) -- used when starting on an odd width + return delta // 2 +end +local function even_one_for_two_growth(delta) -- used when starting on an even width + return (delta+1) // 2 +end + +-- Fort mode center/secondary toolbar movement can be described in phases. +-- - The first phase can be "no growth" (toolbar is already in sync with a +-- not-yet-moving center toolbar), or "one for one" (toolbar is catching to up +-- the center toolbar). +-- - If it starts in "one for one", it may transition to "no growth" if it +-- catches up to the center toolbar before it starts moving. +-- - The final phase should be a "one for two" growth phase (in sync with center +-- toolbar). + +-- The offset values can observed as the UI x-coord (0 based) that +-- `devel/inspect-screen` shows while the mouse is positioned over the left edge +-- of the toolbar in question while using a 100% interface size and adjusting +-- the window width. + +local FORT_CENTER_MOVES_WIDTH = 131 + +local fort_center_phases = { + { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 39, growth = no_growth }, + { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 40, growth = odd_one_for_two_growth }, +} + +local fort_center_secondary_phases = { + { + name = 'dig', + { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 1, growth = one_for_one_growth }, + { starting_width = 178, offset = 64, growth = even_one_for_two_growth }, + }, + { + name = 'chop', + { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 37, growth = one_for_one_growth }, + { starting_width = 121, offset = 44, growth = no_growth }, + { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 45, growth = odd_one_for_two_growth }, + }, + { + name = 'gather', + { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 37, growth = one_for_one_growth }, + { starting_width = 126, offset = 48, growth = no_growth }, + { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 49, growth = odd_one_for_two_growth }, + }, + { + name = 'smooth', + { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 25, growth = one_for_one_growth }, + { starting_width = 153, offset = 64, growth = odd_one_for_two_growth }, + }, + { + name = 'erase', + { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 56, growth = no_growth }, + { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 57, growth = odd_one_for_two_growth }, + }, + { + name = 'stockpile', + { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 65, growth = no_growth }, + { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 66, growth = odd_one_for_two_growth }, + }, + { + name = 'stockpile_paint', + { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 65, growth = no_growth }, + { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 66, growth = odd_one_for_two_growth }, + }, + { + name = 'burrow_paint', + { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 74, growth = no_growth }, + { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 75, growth = odd_one_for_two_growth }, + }, + { + name = 'traffic', + { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 33, growth = one_for_one_growth }, + { starting_width = 198, offset = 116, growth = even_one_for_two_growth }, + }, + { + name = 'mass_designation', + { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 65, growth = one_for_one_growth }, + { starting_width = 144, offset = 94, growth = even_one_for_two_growth }, + }, +} + +-- find the most advanced phase that starts before interface_width and apply its growth rule +local function phased_offset(interface_width, phases) + for i = #phases, 1, -1 do + local phase = phases[i] + local delta = interface_width - phase.starting_width + if delta >= 0 then + return phase.offset + phase.growth(delta) + end + end +end + +------ END MAGIC NUMBERS ------ + +-- left toolbar is always flush to left and bottom (0 left- and bottom-offsets) +local function expect_bottom_left_frame(a, interface_size, w, h, comment) + local c = curry(combine_comment, comment) + return expect.eq(a.l, 0, c('not flush to left')) + and expect.eq(a.b, 0, c('not flush to bottom')) + and expect.eq(a.w, w, c('unexpected width')) + and expect.eq(a.h, h, c('unexpected height')) + and expect.eq(a.r, interface_size.width - (0 + a.w), c('right offset does not fill i/f width')) + and expect.eq(a.t, interface_size.height - (a.h + 0), c('top offset does not fill i/f height')) +end + +function test.fort_left_toolbar_positions() + local left = ftb.left + for _, size in ipairs(flush_sizes) do + local size_str = ('%dx%d'):format(size.width, size.height) + expect_bottom_left_frame(left.frame(size), size, left.width, layout.TOOLBAR_HEIGHT, size_str) + end + for_all_checked_interface_widths(function(size) + local size_str = ('%dx%d'):format(size.width, size.height) + expect_bottom_left_frame(left.frame(size), size, left.width, layout.TOOLBAR_HEIGHT, size_str) + end) +end + +-- right toolbar is always flush to right and bottom (0 right- and bottom-offsets) +local function expect_bottom_right_frame(a, interface_size, w, h, comment) + local c = curry(combine_comment, comment) + expect.eq(a.r, 0, c('not flush to right')) + expect.eq(a.b, 0, c('not flush to bottom')) + expect.eq(a.w, w, c('unexpected width')) + expect.eq(a.h, h, c('unexpected height')) + expect.eq(a.l, interface_size.width - (a.w + 0), c('left offset does not fill i/f width')) + expect.eq(a.t, interface_size.height - (a.h + 0), c('top offset does not fill i/f height')) +end + +function test.fort_right_toolbar_positions() + local left = ftb.right + for _, size in ipairs(flush_sizes) do + local size_str = ('%dx%d'):format(size.width, size.height) + expect_bottom_right_frame(left.frame(size), size, left.width, layout.TOOLBAR_HEIGHT, size_str) + end + for_all_checked_interface_widths(function(size) + local size_str = ('%dx%d'):format(size.width, size.height) + expect_bottom_right_frame(left.frame(size), size, left.width, layout.TOOLBAR_HEIGHT, size_str) + end) +end + +-- center toolbar is flush to bottom (0 bottom-offset) and has a phase-defined left-offset +local function expect_bottom_center_frame(a, interface_size, w, h, l, comment) + local c = curry(combine_comment, comment) + expect.eq(a.l, l, c('center left-offset')) + expect.eq(a.b, 0, c('not flush to bottom')) + expect.eq(a.w, w, c('unexpected width')) + expect.eq(a.h, h, c('unexpected height')) + expect.eq(a.r, interface_size.width - (a.l + a.w), c('right offset does not fill i/f width')) + expect.eq(a.t, interface_size.height - (a.h + 0), c('top offset does not fill i/f height')) +end + +function test.fort_center_toolbar_positions() + local center = ftb.center + for_all_checked_interface_widths(function(size) + local size_str = ('%dx%d'):format(size.width, size.height) + local expected_l = phased_offset(size.width, fort_center_phases) + expect_bottom_center_frame(center.frame(size), size, center.width, layout.TOOLBAR_HEIGHT, expected_l, size_str) + end) +end + +-- secondary toolbars are just above the bottom toolbars (layout.TOOLBAR_HEIGHT bottom-offset) and have phase-defined left-offsets +local function expect_center_secondary_frame(a, interface_size, w, h, l, comment) + local c = curry(combine_comment, comment) + expect.eq(a.l, l, c('center left-offset')) + expect.eq(a.b, layout.TOOLBAR_HEIGHT, c('not directly above bottom toolbar')) + expect.eq(a.w, w, c('unexpected width')) + expect.eq(a.h, h, c('unexpected height')) + expect.eq(a.r, interface_size.width - (a.l + a.w), c('right offset does not fill i/f width')) + expect.eq(a.t, interface_size.height - (a.h + a.b), c('top offset does not fill i/f height')) +end + +for _, phases in ipairs(fort_center_secondary_phases) do + local name = phases.name + local toolbar = layout.fort.secondary_toolbars[name] + test[('fort_secondary_%s_toolbar_positions'):format(name)] = function() + for_all_checked_interface_widths(function(size) + expect_center_secondary_frame( + toolbar.frame(size), size, + toolbar.width, layout.SECONDARY_TOOLBAR_HEIGHT, + phased_offset(size.width, phases), + ('%s: %dx%d'):format(name, size.width, size.height)) + end) + end +end From 60ad1af4a8bdcf3ef067effd728b8fc1ef386cb6 Mon Sep 17 00:00:00 2001 From: Chris Johnsen Date: Mon, 7 Apr 2025 05:02:28 -0500 Subject: [PATCH 02/10] overlay-dev-guide: describe full UI-relative positioning technique --- docs/dev/overlay-dev-guide.rst | 185 +++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/docs/dev/overlay-dev-guide.rst b/docs/dev/overlay-dev-guide.rst index d87178bfff..b6e907d8be 100644 --- a/docs/dev/overlay-dev-guide.rst +++ b/docs/dev/overlay-dev-guide.rst @@ -459,3 +459,188 @@ screen (by default, but the player can move it wherever). }, } end + +Widget example 4: positioning relative to DF UI +----------------------------------------------- + +The player-controllable positioning provided by the overlay framework is +relative to some corner of the interface area (technically, the positioning is +edge-relative, but the anchors are always two adjacent edges, so it is +equivalent to corner-relative positioning). This corner-relative positioning +works well to adapt overlay positions for interface areas of varying sizes (due +to DF window size changes, or because the DF interface percentage has been +reconfigured). + +However, some overlays are most useful when they are drawn near a DF UI element +that does not maintain a fixed offset from any particular corner. For example, +the central toolbar (i.e., the fort mode tools for designations, etc.) is not +always at the same offset from either the left or the right interface edges (to +maintain a centered position, its left and right offsets each (mostly) grow in +proportion to the width of the interface area). The positions of the "secondary" +toolbars (that "open from" the central toolbar) are similarly variable (but in a +slightly more complicated way). + +Such an overlay could eschew player-customizable positioning, but giving up that +flexibility should not be done lightly. + +Instead, with some clever manipulation, an overlay's corner-relative positioning +can be "translated" to be relative to some other point. To simplify the +following description, only the left component of positioning will be considered +(example code after the description supports all four directions). + +Method +****** + +A widget's frame has two values in each direction: an offset and a size. The +overlay framework sets the offsets (based on the player-controllable position), +but we can inflate the (effective) size to fine-tune the positioning of the +overlay's content. + +Given + +* an overlay with no ``frame_inset`` and a fixed content width + (``OVERLAY_WIDTH``), +* a default left-relative overlay offset (``DEFAULT_L_OFFSET``; this is + ``default_pos.x - 1`` for a left-relative (positive) ``default_pos.x``), +* a player-customized left-relative overlay offset (``DEFAULT_L_OFFSET + + PLAYER_L_DELTA()``), and +* an ideal overlay content left-offset + (``ideal_overlay_l_offset(interface_size)``; e.g., derived from some DF UI + element whose position varies based on the interface size), + +the following are true: + +#. The overlay content will be drawn at this left-offset:: + + frame.l + frame_inset.l + + We introduce the inset here as a "variable" since the overlay isn't using it + and we will need it to correct the position of the overlay's content. If + top-level insets are required, they can be added via a Panel that wraps all + the content, or by adjusting the final formulas to incorporate extra insets. + +#. The overlay system provides the player-customized position through the + overlay's ``frame.l`` (adjusted from a one-based position to a zero-based + offset):: + + frame.l == DEFAULT_L_OFFSET + PLAYER_L_DELTA() + + Substituting this into the prior expression gives a new left-offset + expression for the overlay's content:: + + DEFAULT_L_OFFSET + PLAYER_L_DELTA() + frame_inset.l + +#. We want the player-customized left-offset to be:: + + ideal_overlay_l_offset(interface_size) + PLAYER_L_DELTA() + +#. When we equate the customized ideal left-offset expression with the actual + left-offset expression, we get:: + + DEFAULT_L_OFFSET + PLAYER_L_DELTA() + frame_inset.l == ideal_overlay_l_offset(interface_size) + PLAYER_L_DELTA() + + From which, we can solve for ``frame_inset.l``:: + + frame_inset.l == ideal_overlay_l_offset(interface_size) - DEFAULT_L_OFFSET + +#. The overlay's frame's width should be the sum of its inset and its content + width:: + + frame.w == frame_inset.l + OVERLAY_WIDTH + +These last two equations are the core of this technique for adapting the +corner-relative positions provided by the overlay framework into a UI-relative +overlay content position. + +In code, extended to all four directions:: + + local overlay = require('plugins.overlay') + local Label = require('gui.widgets.labels.label') + local layout = require('gui.dflayout') + + local dig_toolbar = layout.fort.secondary_toolbars.dig + local dig_dig_button = dig_toolbar.buttons.dig + + local WIDTH = 20 + local HEIGHT = 1 + + local function ideal_overlay_offsets(interface_size) + local dig_frame = dig_toolbar.frame(interface_size) + -- aligned with the main mining button + local l = dig_frame.l + dig_dig_button.offset + -- one row higher + local t = dig_frame.t - HEIGHT + return { + l = l, + r = interface_size.width - (l + WIDTH), + t = t, + b = interface_size.height - (t + HEIGHT), + } + end + + local MINIMUM_OFFSETS = ideal_overlay_offsets(layout.MINIMUM_INTERFACE_SIZE) + + UIRelativeOverlay = defclass(UIRelativeOverlay, overlay.OverlayWidget) + UIRelativeOverlay.ATTRS{ + name = 'Can you dig it?', + desc = 'A overlay that has UI-relative positioning.', + default_enabled = true, + default_pos = { x = MINIMUM_OFFSETS.l + 1, y = -(MINIMUM_OFFSETS.b + 1) }, + -- frame and frame_inset are managed in preUpdateLayout + viewscreens = { 'dwarfmode/Designate/DIG_DIG' }, + } + + function UIRelativeOverlay:init() + self.frame = {} + self:addviews{ + Label{ + text_pen = { fg = COLOR_BLACK, bg = COLOR_GREY }, + text = string.char(25):rep(dig_dig_button.width) .. ' I can dig it!', + }, + } + end + + function UIRelativeOverlay:preUpdateLayout(parent_rect) + local o = ideal_overlay_offsets(parent_rect) + local d = { + l = math.max(0, o.l - MINIMUM_OFFSETS.l), + r = math.max(0, o.r - MINIMUM_OFFSETS.r), + t = math.max(0, o.t - MINIMUM_OFFSETS.t), + b = math.max(0, o.b - MINIMUM_OFFSETS.b), + } + self.frame.w = WIDTH + d.l + d.r + self.frame.h = HEIGHT + d.t + d.b + self.frame_inset = d + end + + OVERLAY_WIDGETS = { overlay = UIRelativeOverlay } + +``MINIMUM_OFFSETS`` is based on the minimum interface size so that the overlay +will get zero-sized insets in a minimum size interface area. This lets the +overlay be positioned anywhere inside a minimum size interface area. The +``math.max(0, ...)`` is used to make sure an overlay can't be positioned in way that +places it off-screen when a larger interface is made smaller and the +``MINIMUM_OFFSETS`` are erroneously not actually the minimums. + +Consequences +************ + +"Inflating" the overlay size and "floating" the overlay content like this has +some drawbacks when the interface area is much larger than the minimum size. + +* The overlay outlines drawn by `gui/overlay` reflect the inflated size, and + thus can grow quite large. + + * When the mouse is over an "inflated" overlay outline, the area will be + filled, obscuring a potentially large portion of the interface area. + + * Since the overlay content is inset to "float" inside the inflated overlay + size, it can be quite hard to judge where the content will be drawn while an + overlay move is in progress. It may take multiple tries to move the overlay + content to a particular desired location. + +* Because the overlay size is inflated, the available area for positioning the + overlay content is effectively reduced. The available area is equivalent to + the minimum interface area. The positioning of this area is such that the + "ideal" position in the minimum interface area corresponds to the "ideal" + position in the larger interface area. From 3a27c469f1662988555ec90d1d4577e09447c195 Mon Sep 17 00:00:00 2001 From: Chris Johnsen Date: Mon, 7 Apr 2025 22:06:27 -0500 Subject: [PATCH 03/10] gui.dflayout: alternate centering expression These should be equivalent for the integer values being processed here. The tests (entirely different calculations) pass. Also, checked negative values (not that the difference should go negative) and it still holds as equivalent. Not sure it is worth it though. --- library/lua/gui/dflayout.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/lua/gui/dflayout.lua b/library/lua/gui/dflayout.lua index 3b741ae7c8..7e9b494a98 100644 --- a/library/lua/gui/dflayout.lua +++ b/library/lua/gui/dflayout.lua @@ -126,7 +126,7 @@ function fort.toolbars.center.frame(interface_size) -- center toolbar is "centered" in interface area, but never closer to the -- left toolbar than fort_left_center_toolbar_gap_minimum - local interface_offset_centered = math.ceil((interface_size.width - fort.toolbars.center.width + 1) / 2) + local interface_offset_centered = (interface_size.width - fort.toolbars.center.width) // 2 + 1 local interface_offset_min = fort.toolbars.left.width + fort_left_center_toolbar_gap_minimum local interface_offset = math.max(interface_offset_min, interface_offset_centered) From dc8529cfa969afefd7325ea5195fd455d6988384 Mon Sep 17 00:00:00 2001 From: Chris Johnsen Date: Tue, 22 Apr 2025 23:15:27 -0500 Subject: [PATCH 04/10] gui.dflayout: add getOverlayPlacementInfo Easier way to position overlay relative to a (supported) DF UI element. --- docs/changelog.txt | 2 +- docs/dev/Lua API.rst | 256 +++++--- docs/dev/overlay-dev-guide.rst | 145 +---- library/lua/gui/dflayout.lua | 1122 +++++++++++++++++++++++++++----- test/library/gui/dflayout.lua | 548 ++++++++++++++-- 5 files changed, 1654 insertions(+), 419 deletions(-) diff --git a/docs/changelog.txt b/docs/changelog.txt index b9dc87f50f..1a2d65d67e 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -70,7 +70,7 @@ Template for new versions: ## Lua - ``dfhack.military.addToSquad``: expose Military API function -- ``gui.dflayout``: provide DF fort mode toolbar position information +- ``gui.dflayout``: provide DF fort mode toolbar position information and automatic overlay positioning ## Removed diff --git a/docs/dev/Lua API.rst b/docs/dev/Lua API.rst index d62b33a695..4edb9d66e8 100644 --- a/docs/dev/Lua API.rst +++ b/docs/dev/Lua API.rst @@ -6519,126 +6519,214 @@ Example usage:: gui.dflayout ============ -This module provides position information for DF UI elements that are not always -straightforward to calculate. +This module provides help with positioning overlay widgets relative to DF UI +elements that may not always be straightforward to locate across multiple +interface area sizes (and thus, window sizes). -It currently only describes the fortress mode toolbars at the bottom of the -screen. +It currently supports the fortress mode toolbars at the bottom of the screen. Unless otherwise noted, the dimensions used by this module are in UI tiles and -offsets are from the boundaries of the (caller provided) interface area. +are always inside the DF interface area (which, depending on DF settings, may be +narrower than the DF window). General Constants ----------------- -The module provides these convenience constants: +This module provides these convenience constants: * ``MINIMUM_INTERFACE_SIZE`` The dimensions (``width`` and ``height``) of the minimum-size DF window: - 114x46 UI tiles. Other fields may also be present, but they are not used by - this module. + 114x46 UI tiles. * ``TOOLBAR_HEIGHT`` - The height of the primary toolbars at the bottom of the DF window (3 rows). + The height of the primary toolbars at the bottom of the DF window (3 UI rows). * ``SECONDARY_TOOLBAR_HEIGHT`` The height of the secondary toolbars that are sometimes placed above the - primary toolbars (also 3 rows). + primary toolbars (also 3 UI rows). Fortress Mode Toolbars ---------------------- -Fortress mode DF draws three primary toolbars and (depending on whether a "tool" -is active) possibly one of several secondary toolbars at the bottom of the -interface area. +Fortress mode DF draws three primary toolbars and (depending on the DF "mode") +possibly one of several secondary toolbars at the bottom of the interface area. -The toolbar descriptions are available through these module fields: +Layout Information +~~~~~~~~~~~~~~~~~~ -* ``fort.toolbars.left`` -* ``fort.toolbars.center`` -* ``fort.toolbars.right`` +The "raw" layout description for toolbars gives the width of the toolbar and the +sizes and (relative) positions of its buttons. -The descriptions of the secondary toolbars are available through the fields of -``fort.secondary_toolbars``: +The layouts of the primary toolbars are available through these module fields: -* ``dig``, ``chop``, ``gather``, ``smooth``, ``erase``, ``stockpile``, - ``stockpile_paint``, ``burrow_paint``, ``traffic``, and ``mass_designation`` +* ``element_layouts.fort.toolbars.left`` +* ``element_layouts.fort.toolbars.center`` +* ``element_layouts.fort.toolbars.right`` + +The layouts of the secondary toolbars are available through the fields of +``element_layouts.fort.secondary_toolbars``: + +* ``DIG`` +* ``CHOP`` +* ``GATHER`` +* ``SMOOTH`` +* ``ERASE`` +* ``MAIN_STOCKPILE_MODE`` +* ``STOCKPILE_NEW`` +* ``Add new burrow`` +* ``TRAFFIC`` +* ``ITEM_BUILDING`` + +Except for ``Add new burrow``, these field names are taken from the +``df.main_hover_instruction`` enum names of the "button" that activates the +secondary toolbar (except for ``Add new burrow`` and ``STOCKPILE_NEW``, these +are buttons in the center toolbar). The DF build menu (which is displayed in mostly the same place and activated in the same way as the other secondary toolbars) is not currently supported. It has significant differences from the other secondary toolbars. -Each toolbar description table has these fields: +Each toolbar layout description table provides these fields: -* ``width`` the width of the toolbar -* ``buttons`` a table indexed by "button names" that provides info about - individual buttons: +``width`` + the width of the toolbar - * ``offset`` the left-offset from left edge of the toolbar - * ``width`` the width of the button +``buttons`` + a table indexed by "button names" that provides info about individual buttons: + + * ``offset``: the left-offset from left edge of the toolbar + * ``width``: the width of the button Please consult the module source for each toolbar's button names. -* ``frame(interface_size)`` a function that calculates the placement of the - toolbar when drawn in an interface of the specified size - - The ``interface_size`` should be a table with ``width`` and ``height`` fields. - Common size sources include the ``parent_rect`` parameter passed to a - non-fullscreen overlay widget's layout methods (``updateLayout``, etc.), and - the interface area returned from ``gui.get_interface_rect()``). - - The return value is a table with the following fields: - - * ``l``, ``r``, ``t``, ``b``: the column/row offsets to the toolbar from the - edges of the interface area - * ``w``, ``h``: the size of the toolbar (``w`` is the same as ``toolbar.width``) - - ``l + w + r`` and ``t + h + b`` will equal the provided interface's width and - height, respectively. - - This table "shape" is similar to a `Widget `_'s ``frame`` and - should be useful in positioning a DFHack widget relative to the toolbar. - -Fort toolbar examples: - - * The ends of a toolbar can be located by combining the offset and size data - from a toolbar's frame data:: - - local layout = require('gui.dflayout') - ... - local erase_frame = layout.fort.secondary_toolbars.erase.frame(interface_size) - local erase_right_l_offset = erase_frame.l + erase_frame.w - local erase_left_r_offset = erase_frame.r + erase_frame.w - - -- interface_size.width |--------------------------| - -- erase_frame.l |---------- ------| erase_frame.r - -- | [erase tb] | - -- erase_frame.w | ---------- | - -- erase_right_l_offset |-------------------- | - -- | ----------------| erase_left_r_offset - - * A specific toolbar button can be located by combining the toolbar's frame - data with the ``offset`` of the button:: - - local layout = require('gui.dflayout') - ... - local dig = layout.fort.secondary_toolbars.dig - local dig_frame = dig.frame(interface_size) - local dig_adv = dig.buttons.advanced_toggle - local dig_adv_l_offset = dig_frame.l + dig_adv.offset - local dig_adv_r_offset = dig_frame.r + dig_frame.w - (dig_adv.offset + dig_adv.width) - -- OR interface_size.width - (dig_adv_l_offset + dig_adv.width) - - -- interface_size.width |--------------------------| - -- dig_frame.l |---------- ---| dig_frame.r - -- dig_frame.w | ------------- | - -- dig_adv.offset | ------ | - -- dig_adv.width | -- | - -- | [ dig [] tb ] | - -- dig_adv_l_offset |---------------- --------| dig_adv_r_offset +UI Elements +~~~~~~~~~~~ + +The ``element_layouts`` toolbar descriptions are combined with custom +positioning code to form "dynamic UI elements" that can compute where individual +UI elements will be positioned inside interface areas of various sizes. + +The toolbar "UI elements" are available through these module fields: + +* ``elements.fort.toolbars.left`` +* ``elements.fort.toolbars.center`` +* ``elements.fort.toolbars.right`` +* ``elements.fort.toolbars_buttons.left[button_name]`` +* ``elements.fort.toolbars_buttons.center[button_name]`` +* ``elements.fort.toolbars_buttons.center_close[button_name]`` +* ``elements.fort.toolbars_buttons.right[button_name]`` +* ``elements.fort.secondary_toolbars.[secondary_name]`` +* ``elements.fort.secondary_toolbar_buttons.[secondary_name][button_name]`` + +The ``secondary_name`` and ``button_names`` values are the same names as used +for the layout descriptions. + +These "UI element" values should generally be treated as opaque. They can be +passed to the overlay positioning helper functions described below. + +Automatic Overlay Positioning +----------------------------- + +This module provides higher-level functions that use the provided "dynamic UI +elements" to help automatically position an overlay widget with respect to the +UI element: + +* ``getOverlayPlacementInfo(overlay_placement_spec)`` + + The ``overlay_placement_spec`` parameter should be a table with the following + fields: + + ``size`` + a table with ``width`` and ``height`` fields that specifies the static size + of the overlay widget + + ``ui_element`` + the overlay will be positioned relative to the specified UI element; UI + element values can be retrieved from this module's ``elements`` field. + + ``h_placement`` + a string that specifies the overlay's horizontal placement with respect to + the ``ui_element`` + + * ``'on left'``: the overlay's right edge will be just to the left of the + ``ui_element``'s left edge + * ``'align left edges'``: the overlay's left edge will be aligned to the + ``ui_element``'s left edge + * ``'align right edges'``: the overlay's right edge will be aligned to the + ``ui_element``'s right edge + * ``'on right'``: the overlay's left edge will be just to the right of the + ``ui_element``'s right edge + + ``v_placement`` + a string that specifies the overlay's vertical placement with respect to + the ``ui_element`` + + * ``'above'``: the overlay's bottom edge will be just above the reference + frame's top edge + * ``'align top edges'``: the overlay's top edge will be aligned to the + ``ui_element``'s top edge + * ``'align bottom edges'``: the overlay's bottom edge will be aligned to the + ``ui_element``'s bottom edge + * ``'below'``: the overlay's top edge will be just below the + ``ui_element``'s bottom edge + + ``offset`` + an optional table with ``x`` and ``y`` fields that gives an additional + position offset that is applied after the overlay is positioned relative to + the ``ui_element``. + + ``default_pos`` + an optional table with ``x`` and/or ``y`` fields that overrides the returned + ``default_pos``. This field should be omitted for new overlays, but may be + needed for compatibility with existing "UI element relative" overlay + positioning code. + + A table with the following fields is returned: + + * ``default_pos``: a table that should be used for the overlay's ``default_pos`` + * ``frame``: a table that may be used to initialize the overlay's ``frame`` + * ``preUpdateLayout_fn``: a function that used as (or called from) the + overlay's ``preUpdateLayout`` method + + This function can be used like this:: + + local dflayout = require('gui.dflayout') + local PLACEMENT = dflayout.getOverlayPlacementInfo({ + size = { w = 26, h = 11 }, -- whatever the overlay uses + -- position the overlay one column to the right of + -- the MAIN_STOCKPILE_MODE toolbar + -- (the one with the STOCKPILE_NEW button) + ui_element = dflayout.elements.fort.secondary_toolbars.MAIN_STOCKPILE_MODE, + h_placement = 'on right', + v_placement = 'align bottom edges', + offset = { x = 1 }, + }) + TheOverlay = defclass(TheOverlay, overlay.OverlayWidget) + TheOverlay.ATTRS{ + default_pos=PLACEMENT.default_pos, + frame=PLACEMENT.frame, + -- ... + } + function TheOverlay:init() + -- ... + end + TheOverlay.preUpdateLayout = PLACEMENT.preUpdateLayout_fn + + The ``preUpdateLayout_fn`` function will adjust the overlay widget's + ``frame.w``, ``frame.h``, and ``frame_inset`` fields to arrange for the + overlay to be positioned as requested. The overlay position remains + player-adjustable, but is made relative to the ``ui_element`` position instead + of being relative to the edges of the interface area. + +* ``getLeftOnlyOverlayPlacementInfo(overlay_placement_spec)`` + + This function works like ``getOverlayPlacementInfo``, but it only "pads" the + overlay on the left. This is useful for compatibility with existing "UI + element relative" overlay positioning code (e.g., to avoid needing a version + bump that would reset a player's custom positioning). .. _lua-plugins: diff --git a/docs/dev/overlay-dev-guide.rst b/docs/dev/overlay-dev-guide.rst index b6e907d8be..cca7449f13 100644 --- a/docs/dev/overlay-dev-guide.rst +++ b/docs/dev/overlay-dev-guide.rst @@ -464,7 +464,7 @@ Widget example 4: positioning relative to DF UI ----------------------------------------------- The player-controllable positioning provided by the overlay framework is -relative to some corner of the interface area (technically, the positioning is +relative to a corner of the interface area (technically, the positioning is edge-relative, but the anchors are always two adjacent edges, so it is equivalent to corner-relative positioning). This corner-relative positioning works well to adapt overlay positions for interface areas of varying sizes (due @@ -484,114 +484,38 @@ Such an overlay could eschew player-customizable positioning, but giving up that flexibility should not be done lightly. Instead, with some clever manipulation, an overlay's corner-relative positioning -can be "translated" to be relative to some other point. To simplify the -following description, only the left component of positioning will be considered -(example code after the description supports all four directions). +can be "translated" to be relative to some other UI element. -Method -****** - -A widget's frame has two values in each direction: an offset and a size. The -overlay framework sets the offsets (based on the player-controllable position), -but we can inflate the (effective) size to fine-tune the positioning of the -overlay's content. - -Given - -* an overlay with no ``frame_inset`` and a fixed content width - (``OVERLAY_WIDTH``), -* a default left-relative overlay offset (``DEFAULT_L_OFFSET``; this is - ``default_pos.x - 1`` for a left-relative (positive) ``default_pos.x``), -* a player-customized left-relative overlay offset (``DEFAULT_L_OFFSET + - PLAYER_L_DELTA()``), and -* an ideal overlay content left-offset - (``ideal_overlay_l_offset(interface_size)``; e.g., derived from some DF UI - element whose position varies based on the interface size), - -the following are true: - -#. The overlay content will be drawn at this left-offset:: - - frame.l + frame_inset.l - - We introduce the inset here as a "variable" since the overlay isn't using it - and we will need it to correct the position of the overlay's content. If - top-level insets are required, they can be added via a Panel that wraps all - the content, or by adjusting the final formulas to incorporate extra insets. - -#. The overlay system provides the player-customized position through the - overlay's ``frame.l`` (adjusted from a one-based position to a zero-based - offset):: - - frame.l == DEFAULT_L_OFFSET + PLAYER_L_DELTA() - - Substituting this into the prior expression gives a new left-offset - expression for the overlay's content:: - - DEFAULT_L_OFFSET + PLAYER_L_DELTA() + frame_inset.l - -#. We want the player-customized left-offset to be:: - - ideal_overlay_l_offset(interface_size) + PLAYER_L_DELTA() - -#. When we equate the customized ideal left-offset expression with the actual - left-offset expression, we get:: - - DEFAULT_L_OFFSET + PLAYER_L_DELTA() + frame_inset.l == ideal_overlay_l_offset(interface_size) + PLAYER_L_DELTA() - - From which, we can solve for ``frame_inset.l``:: - - frame_inset.l == ideal_overlay_l_offset(interface_size) - DEFAULT_L_OFFSET - -#. The overlay's frame's width should be the sum of its inset and its content - width:: - - frame.w == frame_inset.l + OVERLAY_WIDTH - -These last two equations are the core of this technique for adapting the -corner-relative positions provided by the overlay framework into a UI-relative -overlay content position. - -In code, extended to all four directions:: +The ``gui.dflayout`` module provides the ``getOverlayPlacementInfo`` helper +function that provides helper values that can automatically adapt an overlay's +corner-relative, player-customizable position to be relative to a UI element +while still being player-customizable:: local overlay = require('plugins.overlay') local Label = require('gui.widgets.labels.label') - local layout = require('gui.dflayout') - - local dig_toolbar = layout.fort.secondary_toolbars.dig - local dig_dig_button = dig_toolbar.buttons.dig - - local WIDTH = 20 - local HEIGHT = 1 - - local function ideal_overlay_offsets(interface_size) - local dig_frame = dig_toolbar.frame(interface_size) - -- aligned with the main mining button - local l = dig_frame.l + dig_dig_button.offset - -- one row higher - local t = dig_frame.t - HEIGHT - return { - l = l, - r = interface_size.width - (l + WIDTH), - t = t, - b = interface_size.height - (t + HEIGHT), - } - end + local dflayout = require('gui.dflayout') + + local WIDTH, HEIGHT = 20, 1 -- whatever static size the overlay needs + local PLACEMENT = dflayout.getOverlayPlacementInfo{ + size = { w = WIDTH, h = HEIGHT }, + ui_element = dflayout.elements.fort.secondary_toolbar_buttons.DIG.DIG_DIG, + h_placement = 'align left edges', + v_placement = 'above', + } - local MINIMUM_OFFSETS = ideal_overlay_offsets(layout.MINIMUM_INTERFACE_SIZE) + local dig_dig_button = dflayout.element_layouts.fort.secondary_toolbars.DIG.buttons.DIG_DIG UIRelativeOverlay = defclass(UIRelativeOverlay, overlay.OverlayWidget) UIRelativeOverlay.ATTRS{ name = 'Can you dig it?', desc = 'A overlay that has UI-relative positioning.', default_enabled = true, - default_pos = { x = MINIMUM_OFFSETS.l + 1, y = -(MINIMUM_OFFSETS.b + 1) }, + default_pos = PLACEMENT.default_pos, -- frame and frame_inset are managed in preUpdateLayout viewscreens = { 'dwarfmode/Designate/DIG_DIG' }, } function UIRelativeOverlay:init() - self.frame = {} self:addviews{ Label{ text_pen = { fg = COLOR_BLACK, bg = COLOR_GREY }, @@ -600,33 +524,17 @@ In code, extended to all four directions:: } end - function UIRelativeOverlay:preUpdateLayout(parent_rect) - local o = ideal_overlay_offsets(parent_rect) - local d = { - l = math.max(0, o.l - MINIMUM_OFFSETS.l), - r = math.max(0, o.r - MINIMUM_OFFSETS.r), - t = math.max(0, o.t - MINIMUM_OFFSETS.t), - b = math.max(0, o.b - MINIMUM_OFFSETS.b), - } - self.frame.w = WIDTH + d.l + d.r - self.frame.h = HEIGHT + d.t + d.b - self.frame_inset = d - end + UIRelativeOverlay.preUpdateLayout = PLACEMENT.preUpdateLayout_fn OVERLAY_WIDGETS = { overlay = UIRelativeOverlay } -``MINIMUM_OFFSETS`` is based on the minimum interface size so that the overlay -will get zero-sized insets in a minimum size interface area. This lets the -overlay be positioned anywhere inside a minimum size interface area. The -``math.max(0, ...)`` is used to make sure an overlay can't be positioned in way that -places it off-screen when a larger interface is made smaller and the -``MINIMUM_OFFSETS`` are erroneously not actually the minimums. - Consequences ************ -"Inflating" the overlay size and "floating" the overlay content like this has -some drawbacks when the interface area is much larger than the minimum size. +The generated ``preUpdateLayout_fn`` function works by "inflating" the overlay +size (setting ``frame.w`` and ``frame.h``) and "floating" the overlay content +(setting ``frame_inset``). This has some drawbacks when the interface area is +much larger than the minimum size. * The overlay outlines drawn by `gui/overlay` reflect the inflated size, and thus can grow quite large. @@ -641,6 +549,9 @@ some drawbacks when the interface area is much larger than the minimum size. * Because the overlay size is inflated, the available area for positioning the overlay content is effectively reduced. The available area is equivalent to - the minimum interface area. The positioning of this area is such that the - "ideal" position in the minimum interface area corresponds to the "ideal" - position in the larger interface area. + the area that is available around the targeted UI element in minimum-size + interface area. + +An alternate ``getLeftOnlyOverlayPlacementInfo`` function is available that only +"inflates" and "floats" on the left side, which is compatible with the way +several existing overlays overlays are positioned relative to DF toolbars. diff --git a/library/lua/gui/dflayout.lua b/library/lua/gui/dflayout.lua index 7e9b494a98..c35a7fd100 100644 --- a/library/lua/gui/dflayout.lua +++ b/library/lua/gui/dflayout.lua @@ -1,17 +1,30 @@ local _ENV = mkmodule('gui.dflayout') +local utils = require('utils') + -- Provide data-driven locations for the DF toolbars at the bottom of the -- screen. Not quite as nice as getting the data from DF directly, but better -- than hand-rolling calculations for each "interesting" button. TOOLBAR_HEIGHT = 3 SECONDARY_TOOLBAR_HEIGHT = 3 -MINIMUM_INTERFACE_SIZE = require('gui').mkdims_wh(0, 0, 114, 46) --- Only width and height are used here. We could define a new "@class", but --- LuaLS doesn't seem to accept gui.dimensions values as compatible with that --- new class... ----@alias DFLayout.Rectangle.Size gui.dimension +-- Basic rectangular size class. Should be structurally compatible with +-- gui.dimension (get_interface_rect) and gui.ViewRect (updateLayout's +-- parent_rect), but the LuaLSP disagrees. +---@class DFLayout.Rectangle.Size.class +---@field width integer +---@field height integer + +-- An alias that gathers a few types that we know are compatible with our +-- width/height size requirements. +---@alias DFLayout.Rectangle.Size +--- | DFLayout.Rectangle.Size.class basic width/height size +--- | gui.dimension e.g., gui.get_interface_rect() +--- | gui.ViewRect e.g., parent_rect supplied to updateLayout subsidiary methods + +---@type DFLayout.Rectangle.Size +MINIMUM_INTERFACE_SIZE = { width = 114, height = 46 } ---@generic T ---@param sequences T[][] @@ -24,29 +37,31 @@ local function concat_sequences(sequences) return collected end ----@alias DFLayout.Toolbar.NamedWidth table -- single entry, value is width ----@alias DFLayout.Toolbar.NamedOffsets table -- multiple entries, values are offsets ----@alias DFLayout.Toolbar.Button { offset: integer, width: integer } ----@alias DFLayout.Toolbar.NamedButtons table -- multiple entries - ----@class DFLayout.Widget.frame: widgets.Widget.frame +---@class DFLayout.FullInsets ---@field l integer Gap between the left edge of the frame and the parent. ---@field t integer Gap between the top edge of the frame and the parent. ---@field r integer Gap between the right edge of the frame and the parent. ---@field b integer Gap between the bottom edge of the frame and the parent. + +-- Like widgets.Widget.frame, but no optional fields. +---@class DFLayout.FullyPlacedFrame: DFLayout.FullInsets ---@field w integer Width ---@field h integer Height ----@class DFLayout.Toolbar.Base ----@field buttons DFLayout.Toolbar.NamedButtons ----@field width integer +-- Function that generates a "full placement" for a given interface size. +---@alias DFLayout.FrameFn fun(interface_size: DFLayout.Rectangle.Size): DFLayout.FullyPlacedFrame + +---@alias DFLayout.Toolbar.Button { offset: integer, width: integer } +---@alias DFLayout.Toolbar.Buttons table -- multiple entries +---@alias DFLayout.Toolbar.Widths table -- single entry, value is width ----@class DFLayout.Toolbar: DFLayout.Toolbar.Base ----@field frame fun(interface_size: DFLayout.Rectangle.Size): DFLayout.Widget.frame +---@class DFLayout.Toolbar.Layout +---@field buttons DFLayout.Toolbar.Buttons +---@field width integer ----@param widths DFLayout.Toolbar.NamedWidth[] single-name entries only! ----@return DFLayout.Toolbar.Base -local function button_widths_to_toolbar(widths) +---@param widths DFLayout.Toolbar.Widths[] single-name entries only! +---@return DFLayout.Toolbar.Layout +local function button_widths_to_toolbar_layout(widths) local buttons = {} local offset = 0 for _, ww in ipairs(widths) do @@ -61,43 +76,213 @@ local function button_widths_to_toolbar(widths) return { buttons = buttons, width = offset } end +local BUTTON_WIDTH = 4 + ---@param buttons string[] ----@return DFLayout.Toolbar.NamedWidth[] +---@return DFLayout.Toolbar.Widths[] local function buttons_to_widths(buttons) local widths = {} for _, button_name in ipairs(buttons) do - table.insert(widths, { [button_name] = 4 }) + table.insert(widths, { [button_name] = BUTTON_WIDTH }) end return widths end ---@param buttons string[] ----@return DFLayout.Toolbar.Base -local function buttons_to_toolbar(buttons) - return button_widths_to_toolbar(buttons_to_widths(buttons)) +---@return DFLayout.Toolbar.Layout +local function buttons_to_toolbar_layout(buttons) + return button_widths_to_toolbar_layout(buttons_to_widths(buttons)) end --- Fortress mode toolbar definitions -fort = {} +--- DF UI element definitions --- ----@alias DFLayout.PrimaryToolbarNames 'left' | 'center' | 'right' +element_layouts = { + fort = { + toolbars = { + ---@type DFLayout.Toolbar.Layout + left = buttons_to_toolbar_layout{ + 'MAIN_OPEN_CREATURES', + 'MAIN_OPEN_TASKS', + 'MAIN_OPEN_PLACES', + 'MAIN_OPEN_LABOR', + 'MAIN_OPEN_WORK_ORDERS', + 'MAIN_OPEN_NOBLES', + 'MAIN_OPEN_OBJECTS', + 'MAIN_OPEN_JUSTICE', + }, + ---@type DFLayout.Toolbar.Layout + center = button_widths_to_toolbar_layout(concat_sequences{ + { { _left_border = 1 } }, + buttons_to_widths{ 'DIG', 'CHOP', 'GATHER', 'SMOOTH', 'ERASE' }, + { { _divider = 1 } }, + buttons_to_widths{ 'MAIN_BUILDING_MODE', 'MAIN_STOCKPILE_MODE', 'MAIN_ZONE_MODE' }, + { { _divider = 1 } }, + buttons_to_widths{ 'MAIN_BURROW_MODE', 'MAIN_HAULING_MODE', 'TRAFFIC' }, + { { _divider = 1 } }, + buttons_to_widths{ 'ITEM_BUILDING' }, + { { _right_border = 1 } }, + }), + ---@type DFLayout.Toolbar.Layout + right = buttons_to_toolbar_layout{ + 'MAIN_OPEN_SQUADS', 'MAIN_OPEN_WORLD', + }, + }, + secondary_toolbars = { + ---@type DFLayout.Toolbar.Layout + DIG = buttons_to_toolbar_layout{ + 'DIG_DIG', + 'DIG_STAIRS', + 'DIG_RAMP', + 'DIG_CHANNEL', + 'DIG_REMOVE_STAIRS_RAMPS', '_gap', + 'DIG_PAINT_RECTANGLE', + 'DIG_FREE_PAINT', '_gap', + 'DIG_OPEN_RIGHT', '_gap', -- also DIG_CLOSE_LEFT + 'DIG_MODE_ALL', + 'DIG_MODE_AUTO', + 'DIG_MODE_ONLY_ORE_GEM', + 'DIG_MODE_ONLY_GEM', '_gap', + 'DIG_PRIORITY_1', + 'DIG_PRIORITY_2', + 'DIG_PRIORITY_3', + 'DIG_PRIORITY_4', + 'DIG_PRIORITY_5', + 'DIG_PRIORITY_6', + 'DIG_PRIORITY_7', '_gap', + 'DIG_TO_BLUEPRINT', -- also DIG_TO_STANDARD + 'DIG_GO_FROM_BLUEPRINT', + 'DIG_GO_TO_BLUEPRINT', + }, + ---@type DFLayout.Toolbar.Layout + CHOP = buttons_to_toolbar_layout{ + 'CHOP_REGULAR', '_gap', + 'CHOP_PAINT_RECTANGLE', + 'CHOP_FREE_PAINT', '_gap', + 'CHOP_OPEN_RIGHT', '_gap', -- also CHOP_CLOSE_LEFT + 'CHOP_PRIORITY_1', + 'CHOP_PRIORITY_2', + 'CHOP_PRIORITY_3', + 'CHOP_PRIORITY_4', + 'CHOP_PRIORITY_5', + 'CHOP_PRIORITY_6', + 'CHOP_PRIORITY_7', '_gap', + 'CHOP_TO_BLUEPRINT', -- also CHOP_TO_STANDARD + 'CHOP_GO_FROM_BLUEPRINT', + 'CHOP_GO_TO_BLUEPRINT', + }, + ---@type DFLayout.Toolbar.Layout + GATHER = buttons_to_toolbar_layout{ + 'GATHER_REGULAR', '_gap', + 'GATHER_PAINT_RECTANGLE', + 'GATHER_FREE_PAINT', '_gap', + 'GATHER_OPEN_RIGHT', '_gap', -- also GATHER_CLOSE_LEFT + 'GATHER_PRIORITY_1', + 'GATHER_PRIORITY_2', + 'GATHER_PRIORITY_3', + 'GATHER_PRIORITY_4', + 'GATHER_PRIORITY_5', + 'GATHER_PRIORITY_6', + 'GATHER_PRIORITY_7', '_gap', + 'GATHER_TO_BLUEPRINT', -- also GATHER_TO_STANDARD + 'GATHER_GO_FROM_BLUEPRINT', + 'GATHER_GO_TO_BLUEPRINT', + }, + ---@type DFLayout.Toolbar.Layout + SMOOTH = buttons_to_toolbar_layout{ + 'SMOOTH_SMOOTH', + 'SMOOTH_ENGRAVE', + 'SMOOTH_TRACK', + 'SMOOTH_FORTIFY', '_gap', + 'SMOOTH_PAINT_RECTANGLE', + 'SMOOTH_FREE_PAINT', '_gap', + 'SMOOTH_OPEN_RIGHT', '_gap', -- also SMOOTH_CLOSE_LEFT + 'SMOOTH_PRIORITY_1', + 'SMOOTH_PRIORITY_2', + 'SMOOTH_PRIORITY_3', + 'SMOOTH_PRIORITY_4', + 'SMOOTH_PRIORITY_5', + 'SMOOTH_PRIORITY_6', + 'SMOOTH_PRIORITY_7', '_gap', + 'SMOOTH_TO_BLUEPRINT', -- also SMOOTH_TO_STANDARD + 'SMOOTH_GO_FROM_BLUEPRINT', + 'SMOOTH_GO_TO_BLUEPRINT', + }, + ---@type DFLayout.Toolbar.Layout + ERASE = buttons_to_toolbar_layout{ + -- Note: The ERASE secondary toolbar re-uses main_hover_instruction values from ITEM_BUILDING + 'ITEM_BUILDING_PAINT_RECTANGLE', + 'ITEM_BUILDING_FREE_PAINT', + }, + -- MAIN_BUILDING_MODE -- completely different and quite variable + ---@type DFLayout.Toolbar.Layout + MAIN_STOCKPILE_MODE = buttons_to_toolbar_layout{ 'STOCKPILE_NEW' }, + ---@type DFLayout.Toolbar.Layout + STOCKPILE_NEW = buttons_to_toolbar_layout{ + 'STOCKPILE_PAINT_RECTANGLE', + 'STOCKPILE_PAINT_FREE', + 'STOCKPILE_ERASE', + 'STOCKPILE_PAINT_REMOVE', + }, + -- MAIN_ZONE_MODE -- no secondary toolbar + -- MAIN_BURROW_MODE -- no direct secondary toolbar + ---@type DFLayout.Toolbar.Layout + ['Add new burrow'] = buttons_to_toolbar_layout{ -- "Add new burrow" is the text of button in burrows window; there is no main_hover_instruction for it + 'BURROW_PAINT_RECTANGLE', + 'BURROW_PAINT_FREE', + 'BURROW_ERASE', + 'BURROW_PAINT_REMOVE', + }, + -- MAIN_HAULING_MODE -- no secondary toolbar + ---@type DFLayout.Toolbar.Layout + TRAFFIC = button_widths_to_toolbar_layout( + concat_sequences{ buttons_to_widths{ + 'TRAFFIC_HIGH', + 'TRAFFIC_NORMAL', + 'TRAFFIC_LOW', + 'TRAFFIC_RESTRICTED', '_gap', + 'TRAFFIC_PAINT_RECTANGLE', + 'TRAFFIC_FREE_PAINT', '_gap', + 'TRAFFIC_OPEN_RIGHT', '_gap', -- also TRAFFIC_CLOSE_LEFT + }, { + -- These last spans all use TRAFFIC_SLIDERS as the + -- main_hover_instruction, but have distinct interactions. + -- Note: The TRAFFIC secondary toolbar is taller (total of four + -- toolbar heights) in this region. Only the bottom row (in the + -- normal secondary toolbar area) is not currently represented + -- here. + { ['TRAFFIC_SLIDERS.which'] = 4 }, + { ['TRAFFIC_SLIDERS.slider'] = 26 }, + { ['TRAFFIC_SLIDERS.value'] = 6 }, + } } + ), + ---@type DFLayout.Toolbar.Layout + ITEM_BUILDING = buttons_to_toolbar_layout{ + 'ITEM_BUILDING_CLAIM', + 'ITEM_BUILDING_FORBID', + 'ITEM_BUILDING_DUMP', + 'ITEM_BUILDING_UNDUMP', + 'ITEM_BUILDING_MELT', + 'ITEM_BUILDING_UNMELT', + 'ITEM_BUILDING_UNHIDE', + 'ITEM_BUILDING_HIDE', '_gap', + 'ITEM_BUILDING_PAINT_RECTANGLE', + 'ITEM_BUILDING_FREE_PAINT', + }, + }, + } +} ----@type table -fort.toolbars = {} +local fort_tb_layout = element_layouts.fort.toolbars +local fort_stb_layout = element_layouts.fort.secondary_toolbars ----@class DFLayout.Fort.Toolbar.Left: DFLayout.Toolbar -fort.toolbars.left = buttons_to_toolbar{ - 'citizens', 'tasks', 'places', 'labor', - 'orders', 'nobles', 'objects', 'justice', -} +--- DF UI element "frame" calculation functions --- ----@param interface_size DFLayout.Rectangle.Size ----@return DFLayout.Widget.frame -function fort.toolbars.left.frame(interface_size) +---@type DFLayout.FrameFn +local function fort_left_tb_frame(interface_size) return { l = 0, - w = fort.toolbars.left.width, - r = interface_size.width - fort.toolbars.left.width, + w = fort_tb_layout.left.width, + r = interface_size.width - fort_tb_layout.left.width, t = interface_size.height - TOOLBAR_HEIGHT, h = TOOLBAR_HEIGHT, @@ -105,35 +290,34 @@ function fort.toolbars.left.frame(interface_size) } end -local fort_left_center_toolbar_gap_minimum = 7 - ----@class DFLayout.Fort.Toolbar.Center: DFLayout.Toolbar -fort.toolbars.center = button_widths_to_toolbar{ - { _left_border = 1 }, - { dig = 4 }, { chop = 4 }, { gather = 4 }, { smooth = 4 }, { erase = 4 }, - { _divider = 1 }, - { build = 4 }, { stockpile = 4 }, { zone = 4 }, - { _divider = 1 }, - { burrow = 4 }, { cart = 4 }, { traffic = 4 }, - { _divider = 1 }, - { mass_designation = 4 }, - { _right_border = 1 }, -} +local FORT_LEFT_CENTER_TOOLBAR_GAP_MINIMUM = 7 ----@param interface_size DFLayout.Rectangle.Size ----@return DFLayout.Widget.frame -function fort.toolbars.center.frame(interface_size) +---@type DFLayout.FrameFn +local function fort_center_tb_frame(interface_size) -- center toolbar is "centered" in interface area, but never closer to the - -- left toolbar than fort_left_center_toolbar_gap_minimum + -- left toolbar than FORT_LEFT_CENTER_TOOLBAR_GAP_MINIMUM - local interface_offset_centered = (interface_size.width - fort.toolbars.center.width) // 2 + 1 - local interface_offset_min = fort.toolbars.left.width + fort_left_center_toolbar_gap_minimum + local interface_offset_centered = (interface_size.width - fort_tb_layout.center.width) // 2 + 1 + local interface_offset_min = fort_tb_layout.left.width + FORT_LEFT_CENTER_TOOLBAR_GAP_MINIMUM local interface_offset = math.max(interface_offset_min, interface_offset_centered) return { l = interface_offset, - w = fort.toolbars.center.width, - r = interface_size.width - interface_offset - fort.toolbars.center.width, + w = fort_tb_layout.center.width, + r = interface_size.width - interface_offset - fort_tb_layout.center.width, + + t = interface_size.height - TOOLBAR_HEIGHT, + h = TOOLBAR_HEIGHT, + b = 0, + } +end + +---@type DFLayout.FrameFn +local function fort_right_tb_frame(interface_size) + return { + l = interface_size.width - fort_tb_layout.right.width, + w = fort_tb_layout.right.width, + r = 0, t = interface_size.height - TOOLBAR_HEIGHT, h = TOOLBAR_HEIGHT, @@ -141,138 +325,722 @@ function fort.toolbars.center.frame(interface_size) } end ----@alias DFLayout.Fort.SecondaryToolbar.ToolNames 'dig' | 'chop' | 'gather' | 'smooth' | 'erase' | 'build' | 'stockpile' | 'zone' | 'burrow' | 'cart' | 'traffic' | 'mass_designation' ----@alias DFLayout.Fort.SecondaryToolbar.Names 'dig' | 'chop' | 'gather' | 'smooth' | 'erase' | 'stockpile' | 'stockpile_paint' | 'burrow_paint' | 'traffic' | 'mass_designation' +---@alias DFLayout.Fort.SecondaryToolbar.CenterButton 'DIG' | 'CHOP' | 'GATHER' | 'SMOOTH' | 'ERASE' | 'MAIN_STOCKPILE_MODE' | 'MAIN_BURROW_MODE' | 'TRAFFIC' | 'ITEM_BUILDING' +---@alias DFLayout.Fort.SecondaryToolbar.Names 'DIG' | 'CHOP' | 'GATHER' | 'SMOOTH' | 'ERASE' | 'MAIN_STOCKPILE_MODE' | 'STOCKPILE_NEW' | 'Add new burrow' | 'TRAFFIC' | 'ITEM_BUILDING' + +-- Derive the frame_fn for a secondary toolbar that "wants to" align with the +-- specified center toolbar button. +---@param center_button_name DFLayout.Fort.SecondaryToolbar.CenterButton +---@param secondary_toolbar_layout DFLayout.Toolbar.Layout +---@return DFLayout.FrameFn +local function get_secondary_frame_fn(center_button_name, secondary_toolbar_layout) + local toolbar_button = fort_tb_layout.center.buttons[center_button_name] + or dfhack.error('bad center toolbar button name: ' .. tostring(center_button_name)) + return function(interface_size) + local toolbar_offset = fort_center_tb_frame(interface_size).l + + -- Ideally, the secondary toolbar is positioned directly above the (main) toolbar button + local ideal_offset = toolbar_offset + toolbar_button.offset + + -- In "narrow" interfaces conditions, a wide secondary toolbar (pretty much + -- any tool that has "advanced" options) that was ideally positioned above + -- its tool's button would extend past the right edge of the interface area. + -- Such wide secondary toolbars are instead right justified with a bit of + -- padding. + + -- padding necessary to line up width-constrained secondaries + local secondary_padding = 5 + local width_constrained_offset = math.max(0, interface_size.width - (secondary_toolbar_layout.width + secondary_padding)) + + -- Use whichever position is left-most. + local l = math.min(ideal_offset, width_constrained_offset) + return { + l = l, + w = secondary_toolbar_layout.width, + r = interface_size.width - l - secondary_toolbar_layout.width, + + t = interface_size.height - TOOLBAR_HEIGHT - SECONDARY_TOOLBAR_HEIGHT, + h = SECONDARY_TOOLBAR_HEIGHT, + b = TOOLBAR_HEIGHT, + } + end +end +---@type table +local fort_secondary_tb_frames = { + DIG = get_secondary_frame_fn('DIG', fort_stb_layout.DIG), + CHOP = get_secondary_frame_fn('CHOP', fort_stb_layout.CHOP), + GATHER = get_secondary_frame_fn('GATHER', fort_stb_layout.GATHER), + SMOOTH = get_secondary_frame_fn('SMOOTH', fort_stb_layout.SMOOTH), + ERASE = get_secondary_frame_fn('ERASE', fort_stb_layout.ERASE), + -- MAIN_BUILDING_MODE -- completely different and quite variable + MAIN_STOCKPILE_MODE = get_secondary_frame_fn('MAIN_STOCKPILE_MODE', fort_stb_layout.MAIN_STOCKPILE_MODE), + STOCKPILE_NEW = get_secondary_frame_fn('MAIN_STOCKPILE_MODE', fort_stb_layout.STOCKPILE_NEW), + -- MAIN_ZONE_MODE -- no secondary toolbar + -- MAIN_BURROW_MODE -- no direct secondary toolbar + ['Add new burrow'] = get_secondary_frame_fn('MAIN_BURROW_MODE', fort_stb_layout['Add new burrow']), + -- MAIN_HAULING_MODE -- no secondary toolbar + TRAFFIC = get_secondary_frame_fn('TRAFFIC', fort_stb_layout.TRAFFIC), + ITEM_BUILDING = get_secondary_frame_fn('ITEM_BUILDING', fort_stb_layout.ITEM_BUILDING), +} + +---@class DFLayout.DynamicUIElement +---@field frame_fn DFLayout.FrameFn +---@field minimum_insets DFLayout.FullInsets + +-- Create a DFLayout.DynamicUIElement from a DFLayout.FrameFn. +-- +-- Note: The `frame_fn` must generate inset values that are non-decreasing as +-- the input interface size grows (i.e., the minimum insets are found when +-- placing the frame in a minimum-size interface area). This is true for all the +-- DF toolbars and their sub-components, but not for all UI elements in general. +---@param frame_fn DFLayout.FrameFn +---@return DFLayout.DynamicUIElement +local function nd_inset_ui_el(frame_fn) + local min_frame = frame_fn(MINIMUM_INTERFACE_SIZE) + return { + frame_fn = frame_fn, + minimum_insets = { + l = min_frame.l, + r = min_frame.r, + t = min_frame.t, + b = min_frame.b, + } + } +end + +-- Derive the DynamicUIElement for the named button given a toolbar's frame_fn, and its button button layout. +---@param toolbar_frame_fn DFLayout.FrameFn +---@param toolbar_layout DFLayout.Toolbar.Layout +---@param button_name string +---@return DFLayout.DynamicUIElement +local function button_ui_el(toolbar_frame_fn, toolbar_layout, button_name) + local button = toolbar_layout.buttons[button_name] + or dfhack.error('button not present in given toolbar layout: ' .. tostring(button_name)) + return nd_inset_ui_el(function(interface_size) + local toolbar_frame = toolbar_frame_fn(interface_size) + local l = toolbar_frame.l + button.offset + local r = interface_size.width - (l + button.width) + return { + l = l, + w = button.width, + r = r, + + t = toolbar_frame.t, + h = toolbar_frame.h, + b = toolbar_frame.b, + } + end) +end + +-- button_ui_el, specialized for the secondary toolbars. +---@param toolbar_name DFLayout.Fort.SecondaryToolbar.Names +---@param button_name string +---@return DFLayout.DynamicUIElement +local function secondary_button_ui_el(toolbar_name, button_name) + local frame_fn = fort_secondary_tb_frames[toolbar_name] + or dfhack.error('secondary toolbar name not in fort_secondary_tb_frames: ' .. tostring(toolbar_name)) + local layout = fort_stb_layout[toolbar_name] + or dfhack.error('secondary toolbar name not in fort_el_layout.secondary_toolbars: ' .. tostring(toolbar_name)) + return button_ui_el(frame_fn, layout, button_name) +end + +elements = { + fort = { + toolbars = { + ---@type DFLayout.DynamicUIElement + left = nd_inset_ui_el(fort_left_tb_frame), + ---@type DFLayout.DynamicUIElement + center = nd_inset_ui_el(fort_center_tb_frame), + ---@type DFLayout.DynamicUIElement + right = nd_inset_ui_el(fort_right_tb_frame), + }, + toolbar_buttons = { + left = { + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_CREATURES = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_CREATURES'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_TASKS = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_TASKS'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_PLACES = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_PLACES'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_LABOR = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_LABOR'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_WORK_ORDERS = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_WORK_ORDERS'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_NOBLES = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_NOBLES'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_OBJECTS = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_OBJECTS'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_JUSTICE = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_JUSTICE'), + }, + center = { + ---@type DFLayout.DynamicUIElement + DIG = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'DIG'), + ---@type DFLayout.DynamicUIElement + CHOP = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'CHOP'), + ---@type DFLayout.DynamicUIElement + GATHER = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'GATHER'), + ---@type DFLayout.DynamicUIElement + SMOOTH = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'SMOOTH'), + ---@type DFLayout.DynamicUIElement + ERASE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'ERASE'), + ---@type DFLayout.DynamicUIElement + MAIN_BUILDING_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_BUILDING_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_STOCKPILE_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_STOCKPILE_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_ZONE_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_ZONE_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_BURROW_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_BURROW_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_HAULING_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_HAULING_MODE'), + ---@type DFLayout.DynamicUIElement + TRAFFIC = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'TRAFFIC'), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'ITEM_BUILDING'), + }, + center_close = { + ---@type DFLayout.DynamicUIElement + DIG_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'DIG'), + ---@type DFLayout.DynamicUIElement + CHOP_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'CHOP'), + ---@type DFLayout.DynamicUIElement + GATHER_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'GATHER'), + ---@type DFLayout.DynamicUIElement + SMOOTH_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'SMOOTH'), + ---@type DFLayout.DynamicUIElement + ERASE_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'ERASE'), + ---@type DFLayout.DynamicUIElement + MAIN_BUILDING_MODE_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_BUILDING_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_STOCKPILE_MODE_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_STOCKPILE_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_ZONE_MODE_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_ZONE_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_BURROW_MODE_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_BURROW_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_HAULING_MODE_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_HAULING_MODE'), + ---@type DFLayout.DynamicUIElement + TRAFFIC_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'TRAFFIC'), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'ITEM_BUILDING'), + }, + right = { + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_SQUADS = button_ui_el(fort_right_tb_frame, fort_tb_layout.right, 'MAIN_OPEN_SQUADS'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_WORLD = button_ui_el(fort_right_tb_frame, fort_tb_layout.right, 'MAIN_OPEN_WORLD'), + }, + }, + secondary_toolbars = { + ---@type DFLayout.DynamicUIElement + DIG = nd_inset_ui_el(fort_secondary_tb_frames.DIG), + ---@type DFLayout.DynamicUIElement + CHOP = nd_inset_ui_el(fort_secondary_tb_frames.CHOP), + ---@type DFLayout.DynamicUIElement + GATHER = nd_inset_ui_el(fort_secondary_tb_frames.GATHER), + ---@type DFLayout.DynamicUIElement + SMOOTH = nd_inset_ui_el(fort_secondary_tb_frames.SMOOTH), + ---@type DFLayout.DynamicUIElement + ERASE = nd_inset_ui_el(fort_secondary_tb_frames.ERASE), + ---@type DFLayout.DynamicUIElement + MAIN_STOCKPILE_MODE = nd_inset_ui_el(fort_secondary_tb_frames.MAIN_STOCKPILE_MODE), + ---@type DFLayout.DynamicUIElement + STOCKPILE_NEW = nd_inset_ui_el(fort_secondary_tb_frames.STOCKPILE_NEW), + ---@type DFLayout.DynamicUIElement + ['Add new burrow'] = nd_inset_ui_el(fort_secondary_tb_frames['Add new burrow']), + ---@type DFLayout.DynamicUIElement + TRAFFIC = nd_inset_ui_el(fort_secondary_tb_frames.TRAFFIC), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING = nd_inset_ui_el(fort_secondary_tb_frames.ITEM_BUILDING), + }, + secondary_toolbar_buttons = { + DIG = { + ---@type DFLayout.DynamicUIElement + DIG_DIG = secondary_button_ui_el('DIG', 'DIG_DIG'), + ---@type DFLayout.DynamicUIElement + DIG_STAIRS = secondary_button_ui_el('DIG', 'DIG_STAIRS'), + ---@type DFLayout.DynamicUIElement + DIG_RAMP = secondary_button_ui_el('DIG', 'DIG_RAMP'), + ---@type DFLayout.DynamicUIElement + DIG_CHANNEL = secondary_button_ui_el('DIG', 'DIG_CHANNEL'), + ---@type DFLayout.DynamicUIElement + DIG_REMOVE_STAIRS_RAMPS = secondary_button_ui_el('DIG', 'DIG_REMOVE_STAIRS_RAMPS'), + ---@type DFLayout.DynamicUIElement + DIG_PAINT_RECTANGLE = secondary_button_ui_el('DIG', 'DIG_PAINT_RECTANGLE'), + ---@type DFLayout.DynamicUIElement + DIG_FREE_PAINT = secondary_button_ui_el('DIG', 'DIG_FREE_PAINT'), + ---@type DFLayout.DynamicUIElement + DIG_OPEN_RIGHT = secondary_button_ui_el('DIG', 'DIG_OPEN_RIGHT'), + ---@type DFLayout.DynamicUIElement + DIG_CLOSE_LEFT = secondary_button_ui_el('DIG', 'DIG_OPEN_RIGHT'), + ---@type DFLayout.DynamicUIElement + DIG_MODE_ALL = secondary_button_ui_el('DIG', 'DIG_MODE_ALL'), + ---@type DFLayout.DynamicUIElement + DIG_MODE_AUTO = secondary_button_ui_el('DIG', 'DIG_MODE_AUTO'), + ---@type DFLayout.DynamicUIElement + DIG_MODE_ONLY_ORE_GEM = secondary_button_ui_el('DIG', 'DIG_MODE_ONLY_ORE_GEM'), + ---@type DFLayout.DynamicUIElement + DIG_MODE_ONLY_GEM = secondary_button_ui_el('DIG', 'DIG_MODE_ONLY_GEM'), + ---@type DFLayout.DynamicUIElement + DIG_PRIORITY_1 = secondary_button_ui_el('DIG', 'DIG_PRIORITY_1'), + ---@type DFLayout.DynamicUIElement + DIG_PRIORITY_2 = secondary_button_ui_el('DIG', 'DIG_PRIORITY_2'), + ---@type DFLayout.DynamicUIElement + DIG_PRIORITY_3 = secondary_button_ui_el('DIG', 'DIG_PRIORITY_3'), + ---@type DFLayout.DynamicUIElement + DIG_PRIORITY_4 = secondary_button_ui_el('DIG', 'DIG_PRIORITY_4'), + ---@type DFLayout.DynamicUIElement + DIG_PRIORITY_5 = secondary_button_ui_el('DIG', 'DIG_PRIORITY_5'), + ---@type DFLayout.DynamicUIElement + DIG_PRIORITY_6 = secondary_button_ui_el('DIG', 'DIG_PRIORITY_6'), + ---@type DFLayout.DynamicUIElement + DIG_PRIORITY_7 = secondary_button_ui_el('DIG', 'DIG_PRIORITY_7'), + ---@type DFLayout.DynamicUIElement + DIG_TO_BLUEPRINT = secondary_button_ui_el('DIG', 'DIG_TO_BLUEPRINT'), + ---@type DFLayout.DynamicUIElement + DIG_TO_STANDARD = secondary_button_ui_el('DIG', 'DIG_TO_BLUEPRINT'), + ---@type DFLayout.DynamicUIElement + DIG_GO_FROM_BLUEPRINT = secondary_button_ui_el('DIG', 'DIG_GO_FROM_BLUEPRINT'), + ---@type DFLayout.DynamicUIElement + DIG_GO_TO_BLUEPRINT = secondary_button_ui_el('DIG', 'DIG_GO_TO_BLUEPRINT'), + }, + CHOP = { + ---@type DFLayout.DynamicUIElement + CHOP_REGULAR = secondary_button_ui_el('CHOP', 'CHOP_REGULAR'), + ---@type DFLayout.DynamicUIElement + CHOP_PAINT_RECTANGLE = secondary_button_ui_el('CHOP', 'CHOP_PAINT_RECTANGLE'), + ---@type DFLayout.DynamicUIElement + CHOP_FREE_PAINT = secondary_button_ui_el('CHOP', 'CHOP_FREE_PAINT'), + ---@type DFLayout.DynamicUIElement + CHOP_OPEN_RIGHT = secondary_button_ui_el('CHOP', 'CHOP_OPEN_RIGHT'), + ---@type DFLayout.DynamicUIElement + CHOP_CLOSE_LEFT = secondary_button_ui_el('CHOP', 'CHOP_OPEN_RIGHT'), + ---@type DFLayout.DynamicUIElement + CHOP_PRIORITY_1 = secondary_button_ui_el('CHOP', 'CHOP_PRIORITY_1'), + ---@type DFLayout.DynamicUIElement + CHOP_PRIORITY_2 = secondary_button_ui_el('CHOP', 'CHOP_PRIORITY_2'), + ---@type DFLayout.DynamicUIElement + CHOP_PRIORITY_3 = secondary_button_ui_el('CHOP', 'CHOP_PRIORITY_3'), + ---@type DFLayout.DynamicUIElement + CHOP_PRIORITY_4 = secondary_button_ui_el('CHOP', 'CHOP_PRIORITY_4'), + ---@type DFLayout.DynamicUIElement + CHOP_PRIORITY_5 = secondary_button_ui_el('CHOP', 'CHOP_PRIORITY_5'), + ---@type DFLayout.DynamicUIElement + CHOP_PRIORITY_6 = secondary_button_ui_el('CHOP', 'CHOP_PRIORITY_6'), + ---@type DFLayout.DynamicUIElement + CHOP_PRIORITY_7 = secondary_button_ui_el('CHOP', 'CHOP_PRIORITY_7'), + ---@type DFLayout.DynamicUIElement + CHOP_TO_BLUEPRINT = secondary_button_ui_el('CHOP', 'CHOP_TO_BLUEPRINT'), + ---@type DFLayout.DynamicUIElement + CHOP_TO_STANDARD = secondary_button_ui_el('CHOP', 'CHOP_TO_BLUEPRINT'), + ---@type DFLayout.DynamicUIElement + CHOP_GO_FROM_BLUEPRINT = secondary_button_ui_el('CHOP', 'CHOP_GO_FROM_BLUEPRINT'), + ---@type DFLayout.DynamicUIElement + CHOP_GO_TO_BLUEPRINT = secondary_button_ui_el('CHOP', 'CHOP_GO_TO_BLUEPRINT'), + }, + GATHER = { + ---@type DFLayout.DynamicUIElement + GATHER_REGULAR = secondary_button_ui_el('GATHER', 'GATHER_REGULAR'), + ---@type DFLayout.DynamicUIElement + GATHER_PAINT_RECTANGLE = secondary_button_ui_el('GATHER', 'GATHER_PAINT_RECTANGLE'), + ---@type DFLayout.DynamicUIElement + GATHER_FREE_PAINT = secondary_button_ui_el('GATHER', 'GATHER_FREE_PAINT'), + ---@type DFLayout.DynamicUIElement + GATHER_OPEN_RIGHT = secondary_button_ui_el('GATHER', 'GATHER_OPEN_RIGHT'), + ---@type DFLayout.DynamicUIElement + GATHER_CLOSE_LEFT = secondary_button_ui_el('GATHER', 'GATHER_OPEN_RIGHT'), + ---@type DFLayout.DynamicUIElement + GATHER_PRIORITY_1 = secondary_button_ui_el('GATHER', 'GATHER_PRIORITY_1'), + ---@type DFLayout.DynamicUIElement + GATHER_PRIORITY_2 = secondary_button_ui_el('GATHER', 'GATHER_PRIORITY_2'), + ---@type DFLayout.DynamicUIElement + GATHER_PRIORITY_3 = secondary_button_ui_el('GATHER', 'GATHER_PRIORITY_3'), + ---@type DFLayout.DynamicUIElement + GATHER_PRIORITY_4 = secondary_button_ui_el('GATHER', 'GATHER_PRIORITY_4'), + ---@type DFLayout.DynamicUIElement + GATHER_PRIORITY_5 = secondary_button_ui_el('GATHER', 'GATHER_PRIORITY_5'), + ---@type DFLayout.DynamicUIElement + GATHER_PRIORITY_6 = secondary_button_ui_el('GATHER', 'GATHER_PRIORITY_6'), + ---@type DFLayout.DynamicUIElement + GATHER_PRIORITY_7 = secondary_button_ui_el('GATHER', 'GATHER_PRIORITY_7'), + ---@type DFLayout.DynamicUIElement + GATHER_TO_BLUEPRINT = secondary_button_ui_el('GATHER', 'GATHER_TO_BLUEPRINT'), + ---@type DFLayout.DynamicUIElement + GATHER_TO_STANDARD = secondary_button_ui_el('GATHER', 'GATHER_TO_BLUEPRINT'), + ---@type DFLayout.DynamicUIElement + GATHER_GO_FROM_BLUEPRINT = secondary_button_ui_el('GATHER', 'GATHER_GO_FROM_BLUEPRINT'), + ---@type DFLayout.DynamicUIElement + GATHER_GO_TO_BLUEPRINT = secondary_button_ui_el('GATHER', 'GATHER_GO_TO_BLUEPRINT'), + }, + SMOOTH = { + ---@type DFLayout.DynamicUIElement + SMOOTH_SMOOTH = secondary_button_ui_el('SMOOTH', 'SMOOTH_SMOOTH'), + ---@type DFLayout.DynamicUIElement + SMOOTH_ENGRAVE = secondary_button_ui_el('SMOOTH', 'SMOOTH_ENGRAVE'), + ---@type DFLayout.DynamicUIElement + SMOOTH_TRACK = secondary_button_ui_el('SMOOTH', 'SMOOTH_TRACK'), + ---@type DFLayout.DynamicUIElement + SMOOTH_FORTIFY = secondary_button_ui_el('SMOOTH', 'SMOOTH_FORTIFY'), + ---@type DFLayout.DynamicUIElement + SMOOTH_PAINT_RECTANGLE = secondary_button_ui_el('SMOOTH', 'SMOOTH_PAINT_RECTANGLE'), + ---@type DFLayout.DynamicUIElement + SMOOTH_FREE_PAINT = secondary_button_ui_el('SMOOTH', 'SMOOTH_FREE_PAINT'), + ---@type DFLayout.DynamicUIElement + SMOOTH_OPEN_RIGHT = secondary_button_ui_el('SMOOTH', 'SMOOTH_OPEN_RIGHT'), + ---@type DFLayout.DynamicUIElement + SMOOTH_CLOSE_LEFT = secondary_button_ui_el('SMOOTH', 'SMOOTH_OPEN_RIGHT'), + ---@type DFLayout.DynamicUIElement + SMOOTH_PRIORITY_1 = secondary_button_ui_el('SMOOTH', 'SMOOTH_PRIORITY_1'), + ---@type DFLayout.DynamicUIElement + SMOOTH_PRIORITY_2 = secondary_button_ui_el('SMOOTH', 'SMOOTH_PRIORITY_2'), + ---@type DFLayout.DynamicUIElement + SMOOTH_PRIORITY_3 = secondary_button_ui_el('SMOOTH', 'SMOOTH_PRIORITY_3'), + ---@type DFLayout.DynamicUIElement + SMOOTH_PRIORITY_4 = secondary_button_ui_el('SMOOTH', 'SMOOTH_PRIORITY_4'), + ---@type DFLayout.DynamicUIElement + SMOOTH_PRIORITY_5 = secondary_button_ui_el('SMOOTH', 'SMOOTH_PRIORITY_5'), + ---@type DFLayout.DynamicUIElement + SMOOTH_PRIORITY_6 = secondary_button_ui_el('SMOOTH', 'SMOOTH_PRIORITY_6'), + ---@type DFLayout.DynamicUIElement + SMOOTH_PRIORITY_7 = secondary_button_ui_el('SMOOTH', 'SMOOTH_PRIORITY_7'), + ---@type DFLayout.DynamicUIElement + SMOOTH_TO_BLUEPRINT = secondary_button_ui_el('SMOOTH', 'SMOOTH_TO_BLUEPRINT'), + ---@type DFLayout.DynamicUIElement + SMOOTH_TO_STANDARD = secondary_button_ui_el('SMOOTH', 'SMOOTH_TO_BLUEPRINT'), + ---@type DFLayout.DynamicUIElement + SMOOTH_GO_FROM_BLUEPRINT = secondary_button_ui_el('SMOOTH', 'SMOOTH_GO_FROM_BLUEPRINT'), + ---@type DFLayout.DynamicUIElement + SMOOTH_GO_TO_BLUEPRINT = secondary_button_ui_el('SMOOTH', 'SMOOTH_GO_TO_BLUEPRINT'), + }, + ERASE = { + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING_PAINT_RECTANGLE = secondary_button_ui_el('ERASE', 'ITEM_BUILDING_PAINT_RECTANGLE'), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING_FREE_PAINT = secondary_button_ui_el('ERASE', 'ITEM_BUILDING_FREE_PAINT'), + }, + MAIN_STOCKPILE_MODE = { + ---@type DFLayout.DynamicUIElement + STOCKPILE_NEW = secondary_button_ui_el('MAIN_STOCKPILE_MODE', 'STOCKPILE_NEW'), + }, + STOCKPILE_NEW = { + ---@type DFLayout.DynamicUIElement + STOCKPILE_PAINT_RECTANGLE = secondary_button_ui_el('STOCKPILE_NEW', 'STOCKPILE_PAINT_RECTANGLE'), + ---@type DFLayout.DynamicUIElement + STOCKPILE_PAINT_FREE = secondary_button_ui_el('STOCKPILE_NEW', 'STOCKPILE_PAINT_FREE'), + ---@type DFLayout.DynamicUIElement + STOCKPILE_ERASE = secondary_button_ui_el('STOCKPILE_NEW', 'STOCKPILE_ERASE'), + ---@type DFLayout.DynamicUIElement + STOCKPILE_PAINT_REMOVE = secondary_button_ui_el('STOCKPILE_NEW', 'STOCKPILE_PAINT_REMOVE'), + }, + ['Add new burrow'] = { + ---@type DFLayout.DynamicUIElement + BURROW_PAINT_RECTANGLE = secondary_button_ui_el('Add new burrow', 'BURROW_PAINT_RECTANGLE'), + ---@type DFLayout.DynamicUIElement + BURROW_PAINT_FREE = secondary_button_ui_el('Add new burrow', 'BURROW_PAINT_FREE'), + ---@type DFLayout.DynamicUIElement + BURROW_ERASE = secondary_button_ui_el('Add new burrow', 'BURROW_ERASE'), + ---@type DFLayout.DynamicUIElement + BURROW_PAINT_REMOVE = secondary_button_ui_el('Add new burrow', 'BURROW_PAINT_REMOVE'), + }, + TRAFFIC = { + ---@type DFLayout.DynamicUIElement + TRAFFIC_HIGH = secondary_button_ui_el('TRAFFIC', 'TRAFFIC_HIGH'), + ---@type DFLayout.DynamicUIElement + TRAFFIC_NORMAL = secondary_button_ui_el('TRAFFIC', 'TRAFFIC_NORMAL'), + ---@type DFLayout.DynamicUIElement + TRAFFIC_LOW = secondary_button_ui_el('TRAFFIC', 'TRAFFIC_LOW'), + ---@type DFLayout.DynamicUIElement + TRAFFIC_RESTRICTED = secondary_button_ui_el('TRAFFIC', 'TRAFFIC_RESTRICTED'), + ---@type DFLayout.DynamicUIElement + TRAFFIC_PAINT_RECTANGLE = secondary_button_ui_el('TRAFFIC', 'TRAFFIC_PAINT_RECTANGLE'), + ---@type DFLayout.DynamicUIElement + TRAFFIC_FREE_PAINT = secondary_button_ui_el('TRAFFIC', 'TRAFFIC_FREE_PAINT'), + ---@type DFLayout.DynamicUIElement + TRAFFIC_OPEN_RIGHT = secondary_button_ui_el('TRAFFIC', 'TRAFFIC_OPEN_RIGHT'), + ---@type DFLayout.DynamicUIElement + TRAFFIC_CLOSE_LEFT = secondary_button_ui_el('TRAFFIC', 'TRAFFIC_OPEN_RIGHT'), + ---@type DFLayout.DynamicUIElement + ['TRAFFIC_SLIDERS.which'] = secondary_button_ui_el('TRAFFIC', 'TRAFFIC_SLIDERS.which'), + ---@type DFLayout.DynamicUIElement + ['TRAFFIC_SLIDERS.slider'] = secondary_button_ui_el('TRAFFIC', 'TRAFFIC_SLIDERS.slider'), + ---@type DFLayout.DynamicUIElement + ['TRAFFIC_SLIDERS.value'] = secondary_button_ui_el('TRAFFIC', 'TRAFFIC_SLIDERS.value'), + }, + ITEM_BUILDING = { + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING_CLAIM = secondary_button_ui_el('ITEM_BUILDING', 'ITEM_BUILDING_CLAIM'), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING_FORBID = secondary_button_ui_el('ITEM_BUILDING', 'ITEM_BUILDING_FORBID'), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING_DUMP = secondary_button_ui_el('ITEM_BUILDING', 'ITEM_BUILDING_DUMP'), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING_UNDUMP = secondary_button_ui_el('ITEM_BUILDING', 'ITEM_BUILDING_UNDUMP'), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING_MELT = secondary_button_ui_el('ITEM_BUILDING', 'ITEM_BUILDING_MELT'), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING_UNMELT = secondary_button_ui_el('ITEM_BUILDING', 'ITEM_BUILDING_UNMELT'), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING_UNHIDE = secondary_button_ui_el('ITEM_BUILDING', 'ITEM_BUILDING_UNHIDE'), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING_HIDE = secondary_button_ui_el('ITEM_BUILDING', 'ITEM_BUILDING_HIDE'), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING_PAINT_RECTANGLE = secondary_button_ui_el('ITEM_BUILDING', 'ITEM_BUILDING_PAINT_RECTANGLE'), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING_FREE_PAINT = secondary_button_ui_el('ITEM_BUILDING', 'ITEM_BUILDING_FREE_PAINT'), + }, + }, + }, +} + +--- Automatic UI-relative Overlay Positioning --- + +---@alias DFLayout.Placement.HorizontalAlignment +--- | 'on left' overlay."right edge col" + 1 == reference_frame."left edge col" +--- | 'align left edges' overlay."left edge col" == reference_frame."left edge col" +--- | 'align right edges' overlay."right edge col" == reference_frame."right edge col" +--- | 'on right' overlay."left edge col" == reference_frame."right edge col" + 1 +---@alias DFLayout.Placement.VerticalAlignment +--- | 'above' overlay."bottom edge row" + 1 == reference_frame."top edge row" +--- | 'align top edges' overlay."top edge row" == reference_frame."top edge row" +--- | 'align bottom edges' overlay."bottom edge row" == reference_frame."bottom edge row" +--- | 'below' overlay."top edge row" == reference_frame."bottom edge row" + 1 + +---@alias DFLayout.Placement.Offset { x?: integer, y?: integer } +---@alias DFLayout.Placement.DefaultPos { x?: integer, y?: integer } + +---@class DFLayout.Placement.Size +---@field w integer +---@field h integer + +---@class DFLayout.Placement.Spec +---@field size DFLayout.Placement.Size the static size of overlay +---@field ui_element DFLayout.DynamicUIElement a UI element value from the `elements` tree +---@field h_placement DFLayout.Placement.HorizontalAlignment how to align the overlay's horizontal position against the `ui_element` +---@field v_placement DFLayout.Placement.VerticalAlignment how to align the overlay's vertical position against the `ui_element` +---@field offset? DFLayout.Placement.Offset how far to move overlay after alignment with `ui_element` +---@field default_pos? DFLayout.Placement.DefaultPos supply "legacy" overlay default_pos for placement compatibility + +---@alias DFLayout.Placement.GenericAlignment 'place before' | 'align start' | 'align end' | 'place after' + +local generic_placement_from_horizontal = { + ['on left'] = 'place before', + ['align left edges'] = 'align start', + ['align right edges'] = 'align end', + ['on right'] = 'place after', +} +local generic_placement_from_vertical = { + ['above'] = 'place before', + ['align top edges'] = 'align start', + ['align bottom edges'] = 'align end', + ['below'] = 'place after', +} + +-- Place a specified span (width or height) with the specified alignment with +-- respect to the given reference position and span. +---@param available_span integer +---@param ref_offset_before integer +---@param ref_span integer +---@param placed_span integer +---@param placement DFLayout.Placement.GenericAlignment +---@param offset? integer +---@return integer before +---@return integer span +---@return integer after +local function place_span(available_span, ref_offset_before, ref_span, placed_span, placement, offset) + if placed_span >= available_span then + return 0, available_span, 0 + end + local before + if placement == 'align start' then + before = ref_offset_before + elseif placement == 'align end' then + before = ref_offset_before + ref_span - placed_span + elseif placement == 'place before' then + before = ref_offset_before - placed_span + elseif placement == 'place after' then + before = ref_offset_before + ref_span + else + dfhack.error('invalid generic placement: ' .. tostring(placement)) + end + before = math.max(0, before + (offset or 0)) + local after = available_span - (before + placed_span) + if after < 0 then + before = before + after + after = 0 + end + return before, placed_span, after +end + + +-- Runs `frame_fn(interface_size)` and checks the resulting frame for "sanity" +-- (non-negative paddings, positive sizes, paddings and sizes span +-- `interface_size`). +-- +-- Returns the frame or throws an error. +---@param interface_size DFLayout.Rectangle.Size +---@param frame_fn DFLayout.FrameFn +---@return DFLayout.FullyPlacedFrame +local function checked_frame(interface_size, frame_fn) + local frame = frame_fn(interface_size) + if frame.l < 0 or frame.w <= 0 or frame.r < 0 + or frame.l + frame.w + frame.r ~= interface_size.width + then + dfhack.error( + ('horizontal placement is invalid: l=%d w=%d r=%d W=%d') + :format(frame.l, frame.w, frame.r, interface_size.width)) + end + if frame.t < 0 or frame.h <= 0 or frame.b < 0 + or frame.t + frame.h + frame.b ~= interface_size.height + then + dfhack.error( + ('vertical placement is invalid: t=%d h=%d rb%d H=%d') + :format(frame.t, frame.h, frame.b, interface_size.height)) + end + return frame +end + +-- Place the specified area with respect to the specified reference with the +-- specified alignment and offset. ---@param interface_size DFLayout.Rectangle.Size ----@param tool_name DFLayout.Fort.SecondaryToolbar.ToolNames ----@param secondary_toolbar DFLayout.Toolbar.Base ----@return DFLayout.Widget.frame -local function center_secondary_frame(interface_size, tool_name, secondary_toolbar) - local toolbar_offset = fort.toolbars.center.frame(interface_size).l - local toolbar_button = fort.toolbars.center.buttons[tool_name] or dfhack.error('invalid tool name: ' .. tool_name) - - -- Ideally, the secondary toolbar is positioned directly above the (main) toolbar button - local ideal_offset = toolbar_offset + toolbar_button.offset - - -- In "narrow" interfaces conditions, a wide secondary toolbar (pretty much - -- any tool that has "advanced" options) that was ideally positioned above - -- its tool's button would extend past the right edge of the interface area. - -- Such wide secondary toolbars are instead right justified with a bit of - -- padding. - - -- padding necessary to line up width-constrained secondaries - local secondary_padding = 5 - local width_constrained_offset = math.max(0, interface_size.width - (secondary_toolbar.width + secondary_padding)) - - -- Use whichever position is left-most. - local l = math.min(ideal_offset, width_constrained_offset) +---@param spec DFLayout.Placement.Spec +---@return DFLayout.FullyPlacedFrame +local function place_overlay_frame(interface_size, spec) + local ref_frame = checked_frame(interface_size, spec.ui_element.frame_fn) + + local generic_h_placement = generic_placement_from_horizontal[spec.h_placement] + or dfhack.error('invalid h_placement: ' .. tostring(spec.h_placement)) + local l, w, r = place_span(interface_size.width, + ref_frame.l, ref_frame.w, + spec.size.w, generic_h_placement, spec.offset and spec.offset.x) + + local generic_v_placement = generic_placement_from_vertical[spec.v_placement] + or dfhack.error('invalid v_placement: ' .. tostring(spec.v_placement)) + local t, h, b = place_span(interface_size.height, + ref_frame.t, ref_frame.h, + spec.size.h, generic_v_placement, spec.offset and spec.offset.y) + return { l = l, - w = secondary_toolbar.width, - r = interface_size.width - l - secondary_toolbar.width, + w = w, + r = r, - t = interface_size.height - TOOLBAR_HEIGHT - SECONDARY_TOOLBAR_HEIGHT, - h = SECONDARY_TOOLBAR_HEIGHT, - b = TOOLBAR_HEIGHT, + t = t, + h = h, + b = b, } end ----@type table -fort.secondary_toolbars = {} - ----@param tool_name DFLayout.Fort.SecondaryToolbar.ToolNames ----@param secondary_name DFLayout.Fort.SecondaryToolbar.Names ----@param toolbar DFLayout.Toolbar.Base ----@return DFLayout.Toolbar -local function define_center_secondary(tool_name, secondary_name, toolbar) - local ntb = toolbar --[[@as DFLayout.Toolbar]] - ---@param interface_size DFLayout.Rectangle.Size - ---@return DFLayout.Widget.frame - function ntb.frame(interface_size) - return center_secondary_frame(interface_size, tool_name, ntb) +-- Provide default `default_pos` values based on nominal values, and compute the +-- direction-specific delta to those nominal values. +---@param xy 'x' | 'y' the default_pos field name (used in error messages) +---@param pos? integer +---@param nominal_positive integer +---@param nominal_negative integer +---@param default_to_positive boolean controls which nominal value is used if `pos` is falsy +---@return integer pos `pos`, or one of its defaults (when 0 or falsy) +---@return integer padding_on_positive_side 0 or padding required to move from positive `value` to `nominal_positive` +---@return integer padding_on_negative_side 0 or padding required to move from negative `value` to `nominal_negative` +local function pos_and_paddings(xy, pos, nominal_positive, nominal_negative, default_to_positive) + if not pos or pos == 0 then + pos = default_to_positive and nominal_positive or nominal_negative + end + if 0 < pos then + if nominal_positive < pos then + dfhack.error('specified placement requires 1 <= default_pos.'..xy..' <= '..nominal_positive) + end + return pos, nominal_positive - pos, 0 + end + if pos < nominal_negative then + dfhack.error('specified placement requires -1 >= default_pos.'..xy..' >= '..nominal_negative) end - fort.secondary_toolbars[secondary_name] = ntb - return ntb + return pos, 0, pos - nominal_negative end -define_center_secondary('dig', 'dig', buttons_to_toolbar{ - 'dig', 'stairs', 'ramp', 'channel', 'remove_construction', '_gap', - 'rectangle', 'draw', '_gap', - 'advanced_toggle', '_gap', - 'all', 'auto', 'ore_gem', 'gem', '_gap', - 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', '_gap', - 'blueprint', 'blueprint_to_standard', 'standard_to_blueprint', -}) -define_center_secondary('chop', 'chop', buttons_to_toolbar{ - 'chop', '_gap', - 'rectangle', 'draw', '_gap', - 'advanced_toggle', '_gap', - 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', '_gap', - 'blueprint', 'blueprint_to_standard', 'standard_to_blueprint', -}) -define_center_secondary('gather', 'gather', buttons_to_toolbar{ - 'gather', '_gap', - 'rectangle', 'draw', '_gap', - 'advanced_toggle', '_gap', - 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', '_gap', - 'blueprint', 'blueprint_to_standard', 'standard_to_blueprint', -}) -define_center_secondary('smooth', 'smooth', buttons_to_toolbar{ - 'smooth', 'engrave', 'carve_track', 'carve_fortification', '_gap', - 'rectangle', 'draw', '_gap', - 'advanced_toggle', '_gap', - 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', '_gap', - 'blueprint', 'blueprint_to_standard', 'standard_to_blueprint', -}) -define_center_secondary( 'erase', 'erase', buttons_to_toolbar{ - 'rectangle', - 'draw', -}) --- build -- completely different and quite variable -define_center_secondary('stockpile', 'stockpile', buttons_to_toolbar{ 'add_stockpile' }) -define_center_secondary('stockpile', 'stockpile_paint', buttons_to_toolbar{ - 'rectangle', 'draw', 'erase_toggle', 'remove', -}) --- zone -- no secondary toolbar --- burrow -- no direct secondary toolbar -define_center_secondary('burrow', 'burrow_paint', buttons_to_toolbar{ - 'rectangle', 'draw', 'erase_toggle', 'remove', -}) --- cart -- no secondary toolbar -define_center_secondary('traffic', 'traffic', button_widths_to_toolbar( - concat_sequences{ buttons_to_widths{ - 'high', 'normal', 'low', 'restricted', '_gap', - 'rectangle', 'draw', '_gap', - 'advanced_toggle', '_gap', - }, { - { weight_which = 4 }, - { weight_slider = 26 }, - { weight_input = 6 }, - } } -)) -define_center_secondary('mass_designation', 'mass_designation', buttons_to_toolbar{ - 'claim', 'forbid', 'dump', 'no_dump', 'melt', 'no_melt', 'hidden', 'visible', '_gap', - 'rectangle', 'draw', -}) - ----@class DFLayout.Fort.Toolbar.Right: DFLayout.Toolbar -fort.toolbars.right = buttons_to_toolbar{ - 'squads', 'world', -} +---@class DFLayout.OverlayPlacementInfo +---@field default_pos { x: integer, y: integer } use for the overlay's default_pos +---@field frame widgets.Widget.frame use for the overlay's initial frame +---@field preUpdateLayout_fn fun(self_overlay_widget: widgets.Widget, parent_rect: gui.ViewRect) use the overlay's preUpdateLayout method (the "self" param is overlay.OverlayWidget, but that isn't a declared type) + +---@alias DFLayout.Placement.InsetsFilter fun(insets: DFLayout.FullInsets): DFLayout.FullInsets + +---@param overlay_placement_spec DFLayout.Placement.Spec +---@param insets_filter? DFLayout.Placement.InsetsFilter +---@return DFLayout.OverlayPlacementInfo overlay_placement_info +local function get_overlay_placement_info(overlay_placement_spec, insets_filter) + overlay_placement_spec = utils.clone(overlay_placement_spec, true) --[[@as DFLayout.Placement.Spec]] + local minimum_placement = place_overlay_frame(MINIMUM_INTERFACE_SIZE, overlay_placement_spec) + + -- decode spec.default_pos into pos values and padding values + local override_default_pos = overlay_placement_spec.default_pos + local x_pos, l_pad, r_pad = pos_and_paddings('x', + override_default_pos and override_default_pos.x, + (minimum_placement.l + 1), -- one-based, left-relative + -(minimum_placement.r + 1), -- one-based, right-relative + true -- default to left-relative + ) + local y_pos, t_pad, b_pad = pos_and_paddings('y', + override_default_pos and override_default_pos.y, + (minimum_placement.t + 1), -- one-based, top-relative + -(minimum_placement.b + 1), -- one-based, bottom-relative + false -- default to bottom-relative + ) ----@param interface_size DFLayout.Rectangle.Size ----@return DFLayout.Widget.frame -function fort.toolbars.right.frame(interface_size) return { - l = interface_size.width - fort.toolbars.right.width, - w = fort.toolbars.right.width, - r = 0, + default_pos = { + x = x_pos, + y = y_pos, + }, + frame = { + w = math.min(MINIMUM_INTERFACE_SIZE.width, overlay_placement_spec.size.w), + h = math.min(MINIMUM_INTERFACE_SIZE.height, overlay_placement_spec.size.h), + }, + ---@param self_overlay_widget widgets.Widget + ---@param parent_rect gui.ViewRect + preUpdateLayout_fn = function(self_overlay_widget, parent_rect) + local el_frame = overlay_placement_spec.ui_element.frame_fn(parent_rect) + local minimum_el_insets = overlay_placement_spec.ui_element.minimum_insets + local insets = { + l = math.max(0, el_frame.l - minimum_el_insets.l) + l_pad, + r = math.max(0, el_frame.r - minimum_el_insets.r) + r_pad, + t = math.max(0, el_frame.t - minimum_el_insets.t) + t_pad, + b = math.max(0, el_frame.b - minimum_el_insets.b) + b_pad, + } + insets = insets_filter and insets_filter(insets) or insets + local placement = place_overlay_frame(parent_rect, overlay_placement_spec) - t = interface_size.height - TOOLBAR_HEIGHT, - h = TOOLBAR_HEIGHT, + self_overlay_widget.frame_inset = insets + self_overlay_widget.frame.w = insets.l + placement.w + insets.r + self_overlay_widget.frame.h = insets.t + placement.h + insets.b + end, + } +end + +-- Return a table with values that can be used to automatically place an +-- overlay widget relative to a reference position. +---@param overlay_placement_spec DFLayout.Placement.Spec +---@return DFLayout.OverlayPlacementInfo overlay_placement_info +function getOverlayPlacementInfo(overlay_placement_spec) + return get_overlay_placement_info(overlay_placement_spec) +end + +---@type DFLayout.Placement.InsetsFilter +local function only_left_inset(insets) + return { + l = insets.l, + r = 0, + t = 0, b = 0, } end +-- Similar to `getOverlayPlacementInfo`, but only arranges for "padding" on the +-- left. This is compatible with several existing, hand-rolled overlay +-- positioning calculations. +---@param overlay_placement_spec DFLayout.Placement.Spec +---@return DFLayout.OverlayPlacementInfo overlay_placement_info +function getLeftOnlyOverlayPlacementInfo(overlay_placement_spec) + return get_overlay_placement_info(overlay_placement_spec, only_left_inset) +end return _ENV diff --git a/test/library/gui/dflayout.lua b/test/library/gui/dflayout.lua index 7680fe5e35..dd3ab53925 100644 --- a/test/library/gui/dflayout.lua +++ b/test/library/gui/dflayout.lua @@ -5,7 +5,8 @@ expect = expect or require('test_util.expect') test = test or {} local layout = require('gui.dflayout') -local ftb = layout.fort.toolbars +local ftb_layouts = layout.element_layouts.fort.toolbars +local ftb_elements = layout.elements.fort.toolbars local function combine_comment(comment, suffix) if comment and suffix then @@ -18,17 +19,28 @@ end local gui = require('gui') +-- 114x46; from 912x552 with 8x12 UI tiles +local MIN_INTERFACE = gui.mkdims_wh(0, 0, layout.MINIMUM_INTERFACE_SIZE.width, layout.MINIMUM_INTERFACE_SIZE.height) +-- 75% interface in 1920x1080 with 8x12 UI tiles +local BIG_PARTIAL_INTERFACE = gui.mkdims_wh(30, 0, 180, 90) +-- 100% interface in 1920x1080 with 8x12 UI tiles +local BIG_INTERFACE = gui.mkdims_wh(0, 0, 240, 90) + local flush_sizes = { - layout.MINIMUM_INTERFACE_SIZE, -- 114x46; from 912x552 with 8x12 UI tiles - gui.mkdims_wh(0, 0, 180, 90), -- 75% interface in 1920x1080 with 8x12 UI tiles - gui.mkdims_wh(0, 0, 240, 90), -- 100% interface in 1920x1080 with 8x12 UI tiles + MIN_INTERFACE, + BIG_PARTIAL_INTERFACE, + BIG_INTERFACE, gui.mkdims_wh(0, 0, 480, 180), -- 100% interface in 3840x2160 with 8x12 UI tiles } local MINIMUM_INTERFACE_WIDTH = layout.MINIMUM_INTERFACE_SIZE.width local LARGEST_CHECKED_INTERFACE_WIDTH = 210 -- traffic is the last to start "tracking" its center button at 196 interface width -local function for_all_checked_interface_widths(fn) +local function for_all_checked_interface_sizes(fn) + for _, size in ipairs(flush_sizes) do + local interface_size = gui.mkdims_wh(0, 0, size.width, size.height) + fn(interface_size) + end for w = MINIMUM_INTERFACE_WIDTH, LARGEST_CHECKED_INTERFACE_WIDTH do local interface_size = gui.mkdims_wh(0, 0, w, 46) fn(interface_size) @@ -38,8 +50,8 @@ end -- Most of the magic constants listed below are related to these values, so warn -- if these differ from the baseline values. function test.toolbar_positions_baseline() - expect.eq(ftb.left.width, 32, 'unexpected fort left toolbar width; many tests will probably fail') - expect.eq(ftb.center.width, 53, 'unexpected fort center toolbar width; many tests will probably fail') + expect.eq(ftb_layouts.left.width, 32, 'unexpected fort left toolbar width; many tests will probably fail') + expect.eq(ftb_layouts.center.width, 53, 'unexpected fort center toolbar width; many tests will probably fail') end local function no_growth() @@ -52,7 +64,7 @@ local function odd_one_for_two_growth(delta) -- used when starting on an odd wid return delta // 2 end local function even_one_for_two_growth(delta) -- used when starting on an even width - return (delta+1) // 2 + return (delta + 1) // 2 end -- Fort mode center/secondary toolbar movement can be described in phases. @@ -73,59 +85,59 @@ local FORT_CENTER_MOVES_WIDTH = 131 local fort_center_phases = { { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 39, growth = no_growth }, - { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 40, growth = odd_one_for_two_growth }, + { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 40, growth = odd_one_for_two_growth }, } local fort_center_secondary_phases = { { - name = 'dig', + name = 'DIG', { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 1, growth = one_for_one_growth }, { starting_width = 178, offset = 64, growth = even_one_for_two_growth }, }, { - name = 'chop', + name = 'CHOP', { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 37, growth = one_for_one_growth }, { starting_width = 121, offset = 44, growth = no_growth }, { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 45, growth = odd_one_for_two_growth }, }, { - name = 'gather', + name = 'GATHER', { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 37, growth = one_for_one_growth }, { starting_width = 126, offset = 48, growth = no_growth }, { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 49, growth = odd_one_for_two_growth }, }, { - name = 'smooth', + name = 'SMOOTH', { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 25, growth = one_for_one_growth }, { starting_width = 153, offset = 64, growth = odd_one_for_two_growth }, }, { - name = 'erase', + name = 'ERASE', { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 56, growth = no_growth }, { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 57, growth = odd_one_for_two_growth }, }, { - name = 'stockpile', + name = 'MAIN_STOCKPILE_MODE', { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 65, growth = no_growth }, { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 66, growth = odd_one_for_two_growth }, }, { - name = 'stockpile_paint', + name = 'STOCKPILE_NEW', { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 65, growth = no_growth }, { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 66, growth = odd_one_for_two_growth }, }, { - name = 'burrow_paint', + name = 'Add new burrow', { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 74, growth = no_growth }, { starting_width = FORT_CENTER_MOVES_WIDTH, offset = 75, growth = odd_one_for_two_growth }, }, { - name = 'traffic', + name = 'TRAFFIC', { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 33, growth = one_for_one_growth }, { starting_width = 198, offset = 116, growth = even_one_for_two_growth }, }, { - name = 'mass_designation', + name = 'ITEM_BUILDING', { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 65, growth = one_for_one_growth }, { starting_width = 144, offset = 94, growth = even_one_for_two_growth }, }, @@ -144,6 +156,8 @@ end ------ END MAGIC NUMBERS ------ +--- Test the toolbar frame functions: *_toolbar_positions --- + -- left toolbar is always flush to left and bottom (0 left- and bottom-offsets) local function expect_bottom_left_frame(a, interface_size, w, h, comment) local c = curry(combine_comment, comment) @@ -156,14 +170,11 @@ local function expect_bottom_left_frame(a, interface_size, w, h, comment) end function test.fort_left_toolbar_positions() - local left = ftb.left - for _, size in ipairs(flush_sizes) do - local size_str = ('%dx%d'):format(size.width, size.height) - expect_bottom_left_frame(left.frame(size), size, left.width, layout.TOOLBAR_HEIGHT, size_str) - end - for_all_checked_interface_widths(function(size) + local w = ftb_layouts.left.width + local left_frame = ftb_elements.left.frame_fn + for_all_checked_interface_sizes(function(size) local size_str = ('%dx%d'):format(size.width, size.height) - expect_bottom_left_frame(left.frame(size), size, left.width, layout.TOOLBAR_HEIGHT, size_str) + expect_bottom_left_frame(left_frame(size), size, w, layout.TOOLBAR_HEIGHT, size_str) end) end @@ -179,14 +190,11 @@ local function expect_bottom_right_frame(a, interface_size, w, h, comment) end function test.fort_right_toolbar_positions() - local left = ftb.right - for _, size in ipairs(flush_sizes) do + local w = ftb_layouts.right.width + local right_frame = ftb_elements.right.frame_fn + for_all_checked_interface_sizes(function(size) local size_str = ('%dx%d'):format(size.width, size.height) - expect_bottom_right_frame(left.frame(size), size, left.width, layout.TOOLBAR_HEIGHT, size_str) - end - for_all_checked_interface_widths(function(size) - local size_str = ('%dx%d'):format(size.width, size.height) - expect_bottom_right_frame(left.frame(size), size, left.width, layout.TOOLBAR_HEIGHT, size_str) + expect_bottom_right_frame(right_frame(size), size, w, layout.TOOLBAR_HEIGHT, size_str) end) end @@ -202,11 +210,12 @@ local function expect_bottom_center_frame(a, interface_size, w, h, l, comment) end function test.fort_center_toolbar_positions() - local center = ftb.center - for_all_checked_interface_widths(function(size) + local w = ftb_layouts.center.width + local center_frame = ftb_elements.center.frame_fn + for_all_checked_interface_sizes(function(size) local size_str = ('%dx%d'):format(size.width, size.height) local expected_l = phased_offset(size.width, fort_center_phases) - expect_bottom_center_frame(center.frame(size), size, center.width, layout.TOOLBAR_HEIGHT, expected_l, size_str) + expect_bottom_center_frame(center_frame(size), size, w, layout.TOOLBAR_HEIGHT, expected_l, size_str) end) end @@ -223,14 +232,473 @@ end for _, phases in ipairs(fort_center_secondary_phases) do local name = phases.name - local toolbar = layout.fort.secondary_toolbars[name] + local w = layout.element_layouts.fort.secondary_toolbars[name].width + local frame = layout.elements.fort.secondary_toolbars[name].frame_fn test[('fort_secondary_%s_toolbar_positions'):format(name)] = function() - for_all_checked_interface_widths(function(size) + for_all_checked_interface_sizes(function(size) expect_center_secondary_frame( - toolbar.frame(size), size, - toolbar.width, layout.SECONDARY_TOOLBAR_HEIGHT, + frame(size), size, + w, layout.SECONDARY_TOOLBAR_HEIGHT, phased_offset(size.width, phases), ('%s: %dx%d'):format(name, size.width, size.height)) end) end end + +--- Test the button frame functions: *_toolbar_button_positions --- + +for _, toolbar in ipairs{ + { 'left', { 'fort', 'toolbars' }, { 'fort', 'toolbar_buttons' } }, + { 'center', { 'fort', 'toolbars' }, { 'fort', 'toolbar_buttons' } }, + { 'right', { 'fort', 'toolbars' }, { 'fort', 'toolbar_buttons' } }, + { 'DIG', { 'fort', 'secondary_toolbars' }, { 'fort', 'secondary_toolbar_buttons' } }, + { 'CHOP', { 'fort', 'secondary_toolbars' }, { 'fort', 'secondary_toolbar_buttons' } }, + { 'GATHER', { 'fort', 'secondary_toolbars' }, { 'fort', 'secondary_toolbar_buttons' } }, + { 'SMOOTH', { 'fort', 'secondary_toolbars' }, { 'fort', 'secondary_toolbar_buttons' } }, + { 'ERASE', { 'fort', 'secondary_toolbars' }, { 'fort', 'secondary_toolbar_buttons' } }, + { 'MAIN_STOCKPILE_MODE', { 'fort', 'secondary_toolbars' }, { 'fort', 'secondary_toolbar_buttons' } }, + { 'STOCKPILE_NEW', { 'fort', 'secondary_toolbars' }, { 'fort', 'secondary_toolbar_buttons' } }, + { 'Add new burrow', { 'fort', 'secondary_toolbars' }, { 'fort', 'secondary_toolbar_buttons' } }, + { 'TRAFFIC', { 'fort', 'secondary_toolbars' }, { 'fort', 'secondary_toolbar_buttons' } }, + { 'ITEM_BUILDING', { 'fort', 'secondary_toolbars' }, { 'fort', 'secondary_toolbar_buttons' } }, +} do + local toolbar_name = toolbar[1] + local toolbar_path = copyall(toolbar[2]) + table.insert(toolbar_path, toolbar_name) + local buttons_path = copyall(toolbar[3]) + table.insert(buttons_path, toolbar_name) + local toolbar_el = safe_index(layout.elements, table.unpack(toolbar_path)) + local button_layouts = safe_index(layout.element_layouts, table.unpack(toolbar_path)).buttons + local button_els = safe_index(layout.elements, table.unpack(buttons_path)) + test[('fort_%s_toolbar_button_positions'):format(toolbar_name)] = function() + for_all_checked_interface_sizes(function(size) + local toolbar_frame = toolbar_el.frame_fn(size) + local function c(b, d) + return ('%s %s: %dx%d'):format(b, d, size.width, size.height) + end + for button_name, button_spec in pairs(button_layouts) do + local button_el = button_els[button_name] + expect.true_(button_el, c(button_name, 'element should exist')) + if button_el then + local frame = button_el.frame_fn(size) + local expected_l = toolbar_frame.l + button_spec.offset + local expected_r = toolbar_frame.w - (button_spec.offset + button_spec.width) + toolbar_frame.r + expect.eq(frame.w, button_spec.width, c(button_name, 'w')) + expect.eq(frame.h, toolbar_frame.h, c(button_name, 'h')) + expect.eq(frame.l, expected_l, c(button_name, 'l')) + expect.eq(frame.r, expected_r, c(button_name, 'r')) + expect.eq(frame.t, toolbar_frame.t, c(button_name, 't')) + expect.eq(frame.b, toolbar_frame.b, c(button_name, 'b')) + end + end + end) + end +end + +--- Test the overlay helper: overlay_placement_info_* --- + +-- 5x5 in the center of the interface area +local function get_centered_ui_el(size) + local function frame_fn(interface_size) + local l = (interface_size.width - size.w) // 2 + local b = (interface_size.height - size.h) // 2 + return { + l = l, + w = size.w, + r = interface_size.width - size.w - l, + + t = interface_size.height - size.h - b, + h = size.h, + b = b, + } + end + ---@type DFLayout.DynamicUIElement + return { + frame_fn = frame_fn, + minimum_insets = frame_fn(layout.MINIMUM_INTERFACE_SIZE), + } +end + +-- test alignment specification +function test.overlay_placement_info_alignments() + ---@type { h: DFLayout.Placement.HorizontalAlignment, x: integer }[] + local has = { + { h = 'on left', x = 52 }, + { h = 'align left edges', x = 55 }, + { h = 'align right edges', x = 57 }, + { h = 'on right', x = 60 }, + } + ---@type { v: DFLayout.Placement.VerticalAlignment, y: integer }[] + local vas = { + { v = 'above', y = -26 }, + { v = 'align top edges', y = -23 }, + { v = 'align bottom edges', y = -21 }, + { v = 'below', y = -18 }, + } + local el = get_centered_ui_el{ w = 5, h = 5 } + for _, ha in ipairs(has) do + for _, va in ipairs(vas) do + ---@type DFLayout.Placement.Spec + local spec = { + size = { w = 3, h = 3 }, + ui_element = el, + h_placement = ha.h, + v_placement = va.v, + } + local placement = layout.getOverlayPlacementInfo(spec) + expect.table_eq( + placement.default_pos, + { x = ha.x, y = va.y }, + ha.h .. ', ' .. va.v .. ' default_pos') + end + end +end + +local function sum(...) + local s = { x = 0, y = 0 } + for _, v in ipairs({...}) do + s.x = s.x + (v.x or 0) + s.y = s.y + (v.y or 0) + end + return s +end + +-- test offset specification +function test.overlay_placement_info_offset() + ---@type DFLayout.Placement.Spec + local base_spec = { + size = { w = 3, h = 3 }, + ui_element = get_centered_ui_el{ w = 5, h = 5 }, + h_placement = 'on left', + v_placement = 'above', + } + local base_placement = layout.getOverlayPlacementInfo(base_spec) + + for _, dx in ipairs{ -1, 0, 2 } do + for _, dy in ipairs{ -3, 0, 4 } do + local spec = copyall(base_spec) + spec.offset = { x = dx, y = dy } + local placement = layout.getLeftOnlyOverlayPlacementInfo(spec) + expect.table_eq( + placement.default_pos, + sum(base_placement.default_pos, spec.offset), + 'default_pos should incorporate offset') + end + end +end + +local function size_to_ViewRect(size) + return gui.ViewRect{ rect = gui.mkdims_wh(0, 0, size.width, size.height) } +end + +-- test default_pos specification +function test.overlay_placement_info_default_pos() + local el = get_centered_ui_el{ w = 5, h = 5 } + local function spec(dp) + ---@type DFLayout.Placement.Spec + local new_spec = { + size = { w = 3, h = 3 }, + ui_element = el, + h_placement = 'align left edges', + v_placement = 'align bottom edges', + } + new_spec.default_pos = dp + return new_spec + end + + -- check that insets reflect deltas from the default default_pos; and + -- "deeper" positions are rejected + local function test_around_default_pos(ddp) + local depth = 10 + for _, dx in ipairs{ -depth, 0, depth } do + for _, dy in ipairs{ -depth, 0, depth } do + local s = ('(%d,%d) + (%d,%d)'):format(ddp.x, ddp.y, dx, dy) + local new_dp = sum(ddp, { x = dx, y = dy }) + local new_spec = spec(new_dp) + if math.abs(new_dp.x) > math.abs(ddp.x) + or math.abs(new_dp.y) > math.abs(ddp.y) + then + expect.error(function() + layout.getOverlayPlacementInfo(new_spec) + end, s .. ': default_pos "deeper" than default should be rejected') + goto continue + end + local placement = layout.getOverlayPlacementInfo(new_spec) + expect.table_eq( + placement.default_pos, + new_dp, + ('%s ~= (%d,%d)'):format(s, new_dp.x, new_dp.y)) + local fake_widget = { frame = {} } + placement.preUpdateLayout_fn(fake_widget, size_to_ViewRect(MIN_INTERFACE)) + local expected_insets = {} + if new_dp.x > 0 then + expected_insets.l = -dx + expected_insets.r = 0 + else + expected_insets.l = 0 + expected_insets.r = dx + end + if new_dp.y > 0 then + expected_insets.t = -dy + expected_insets.b = 0 + else + expected_insets.t = 0 + expected_insets.b = dy + end + expect.table_eq( + fake_widget.frame_inset, + expected_insets, + 'insets should match delta') + ::continue:: + end + end + end + + local l = 54 + local r = 57 + local t = 23 + local b = 20 + + -- default_pos from upper-left + test_around_default_pos{ + x = l + 1, + y = t + 1, + } + -- default_pos from upper-right + test_around_default_pos{ + x = -(r + 1), + y = t + 1, + } + -- default_pos from lower-left + test_around_default_pos{ + x = (l + 1), + y = -(b + 1), + } + -- default_pos from lower-right + test_around_default_pos{ + x = -(r + 1), + y = -(b + 1), + } +end + +-- test positioning that would nominal hang off the edge +function test.overlay_placement_info_spanning_edge() + local function test_placements(gap, h_specs, v_specs) + local el = get_centered_ui_el{ + w = MIN_INTERFACE.width - 2 * gap, + h = MIN_INTERFACE.height - 2 * gap, + } + for _, h_spec in ipairs(h_specs) do + for _, v_spec in ipairs(v_specs) do + ---@type DFLayout.Placement.Spec + local spec = { + size = { w = 10, h = 10 }, + ui_element = el, + h_placement = h_spec.placement, + v_placement = v_spec.placement, + } + local placement = layout.getOverlayPlacementInfo(spec) + expect.table_eq( + placement.default_pos, + { x = h_spec.x, y = v_spec.y }, + ('%d gap %s, %s default_pos'):format(gap, h_spec.placement, v_spec.placement)) + end + end + end + + -- if the gaps are not greater than 10, the 10x10 is placed in the corners + + ---@type { h: DFLayout.Placement.HorizontalAlignment, x: integer }[] + local corner_h_placement = { + { placement = 'on left', x = 1 }, + { placement = 'on right', x = 105 }, + } + ---@type { v: DFLayout.Placement.VerticalAlignment, y: integer }[] + local corner_v_placement = { + { placement = 'above', y = -37 }, + { placement = 'below', y = -1 }, + } + test_placements(0, corner_h_placement, corner_v_placement) + test_placements(5, corner_h_placement, corner_v_placement) + test_placements(10, corner_h_placement, corner_v_placement) + + -- once there is extra room, the 10x10 can move away from the corners + + test_placements(11, { + { placement = 'on left', x = 2 }, + { placement = 'on right', x = 104 }, + }, { + { placement = 'above', y = -36 }, + { placement = 'below', y = -2 }, + }) +end + +-- oversized placement is limited to layout.MINIMUM_INTERFACE_SIZE +function test.overlay_placement_info_oversized() + local placement = layout.getOverlayPlacementInfo{ + size = { w= MIN_INTERFACE.width + 1, h = MIN_INTERFACE.height + 1 }, + ui_element = layout.elements.fort.toolbars.left, + h_placement = 'align right edges', + v_placement = 'align bottom edges', + offset = { x = 1 }, + } + expect.table_eq( + placement.frame, + { w = MIN_INTERFACE.width, h = MIN_INTERFACE.height }, + 'oversize area should be limited to MINIMUM_INTERFACE_SIZE') + expect.table_eq( + placement.default_pos, + { x = 1, y = -1 }, + 'oversize area should end up in corner') +end + +-- test a "real" positioning across multiple interface sizes +function test.overlay_placement_info_DIG_button() + -- normally, this would be specified as 'on right' of DIG_OPEN_RIGHT without + -- an offset; but since this is a test, we are exercising an different + -- horizontal placement + ---@type DFLayout.Placement.Spec + local spec = { + size = { w = 26, h = 11 }, + ui_element = layout.elements.fort.secondary_toolbar_buttons.DIG.DIG_MODE_ALL, + h_placement = 'align left edges', + v_placement = 'align bottom edges', + offset = { x = -4 }, + } + local placement = layout.getOverlayPlacementInfo(spec) + expect.table_eq(placement.default_pos, { + x = 42, + y = -4, + }, 'default_pos') + + local min_if = size_to_ViewRect(MIN_INTERFACE) + local big_partial_if = size_to_ViewRect(BIG_PARTIAL_INTERFACE) + local big_if = size_to_ViewRect(BIG_INTERFACE) + + local frame = {} + local fake_widget = { frame = frame } + + placement.preUpdateLayout_fn(fake_widget, min_if) + expect.table_eq(fake_widget.frame_inset, { + l = 0, + r = 0, + t = 0, + b = 0, + }, 'zero inset for minimum-size i/f') + expect.eq(frame, fake_widget.frame, 'frame is same table') + expect.table_eq(fake_widget.frame, { w = spec.size.w, h = spec.size.h }, 'frame w/h should be populated') + + placement.preUpdateLayout_fn(fake_widget, big_partial_if) + local big_partial_inset = { + l = 64, + r = 2, + t = 44, + b = 0, + } + expect.table_eq(fake_widget.frame_inset, big_partial_inset, 'inset for big window with partial i/f') + expect.eq(frame, fake_widget.frame, 'frame is same table') + expect.table_eq(fake_widget.frame, { + w = spec.size.w + big_partial_inset.l + big_partial_inset.r, + h = spec.size.h + big_partial_inset.t + big_partial_inset.b, + }, 'frame w/h should be populated') + + placement.preUpdateLayout_fn(fake_widget, big_if) + local big_inset = { + l = 94, + r = 32, + t = 44, + b = 0, + } + expect.table_eq(fake_widget.frame_inset, big_inset, 'inset for big window with full i/f') + expect.eq(frame, fake_widget.frame, 'frame is same table') + expect.table_eq(fake_widget.frame, { + w = spec.size.w + big_inset.l + big_inset.r, + h = spec.size.h + big_inset.t + big_inset.b, + }, 'frame w/h should be populated') + + placement.preUpdateLayout_fn(fake_widget, min_if) + expect.table_eq(fake_widget.frame_inset, { + l = 0, + r = 0, + t = 0, + b = 0, + }, 'zero inset for minimum-size i/f') + expect.eq(frame, fake_widget.frame, 'frame is same table') + expect.table_eq(fake_widget.frame, { w = spec.size.w, h = spec.size.h }, 'frame w/h should be populated') +end + +-- test a "real" positioning with an off-nominal default_pos across multiple interface sizes +function test.overlay_placement_info_ERASE_toolbar() + ---@type DFLayout.Placement.Spec + local spec = { + size = { w = 26, h = 10 }, + ui_element = layout.elements.fort.secondary_toolbars.ERASE, + h_placement = 'on right', + v_placement = 'align bottom edges', + offset = { x = 1 }, + default_pos = { x = 42 }, -- nominal is 66 == 42 + 24 + } + local dx = 24 -- due to forced default_pos: shrink default_pos.x, grow inset.l + local placement = layout.getOverlayPlacementInfo(spec) + expect.table_eq(placement.default_pos, { + x = 66 - dx, + y = -4, + }, 'requested default_pos') + + local min_if = size_to_ViewRect(MIN_INTERFACE) -- 114 + local big_partial_if = size_to_ViewRect(BIG_PARTIAL_INTERFACE) -- 180 + local big_if = size_to_ViewRect(BIG_INTERFACE) -- 240 + + local frame = {} + local fake_widget = { frame = frame } + + placement.preUpdateLayout_fn(fake_widget, min_if) + local min_inset = { + l = 0 + dx, + r = 0, + t = 0, + b = 0, + } + expect.table_eq(fake_widget.frame_inset, min_inset, 'mostly zero inset for minimum-size i/f') + expect.eq(frame, fake_widget.frame, 'frame is same table') + expect.table_eq(fake_widget.frame, { + w = min_inset.l + spec.size.w + min_inset.r, + h = min_inset.t + spec.size.h + min_inset.b, + }, 'frame w/h should be populated') + + placement.preUpdateLayout_fn(fake_widget, big_partial_if) + local big_partial_inset = { + l = 25 + dx, + r = 41, + t = 44, + b = 0, + } + expect.table_eq(fake_widget.frame_inset, big_partial_inset, 'inset for big window with partial i/f') + expect.eq(frame, fake_widget.frame, 'frame is same table') + expect.table_eq(fake_widget.frame, { + w = spec.size.w + big_partial_inset.l + big_partial_inset.r, + h = spec.size.h + big_partial_inset.t + big_partial_inset.b, + }, 'frame w/h should be populated') + + placement.preUpdateLayout_fn(fake_widget, big_if) + local big_inset = { + l = 55 + dx, + r = 71, + t = 44, + b = 0, + } + expect.table_eq(fake_widget.frame_inset, big_inset, 'inset for big window with full i/f') + expect.eq(frame, fake_widget.frame, 'frame is same table') + expect.table_eq(fake_widget.frame, { + w = spec.size.w + big_inset.l + big_inset.r, + h = spec.size.h + big_inset.t + big_inset.b, + }, 'frame w/h should be populated') + + placement.preUpdateLayout_fn(fake_widget, min_if) + expect.table_eq(fake_widget.frame_inset, min_inset, 'mostly zero inset for minimum-size i/f') + expect.eq(frame, fake_widget.frame, 'frame is same table') + expect.table_eq(fake_widget.frame, { + w = min_inset.l + spec.size.w + min_inset.r, + h = min_inset.t + spec.size.h + min_inset.b, + }, 'frame w/h should be populated') +end From 610aea8f1137ff30846ae68d46d4b9c50fd6e12d Mon Sep 17 00:00:00 2001 From: Chris Johnsen Date: Wed, 23 Apr 2025 22:00:39 -0500 Subject: [PATCH 05/10] don't directly require Label widget --- docs/dev/overlay-dev-guide.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dev/overlay-dev-guide.rst b/docs/dev/overlay-dev-guide.rst index cca7449f13..0cbc68ef7f 100644 --- a/docs/dev/overlay-dev-guide.rst +++ b/docs/dev/overlay-dev-guide.rst @@ -492,7 +492,7 @@ corner-relative, player-customizable position to be relative to a UI element while still being player-customizable:: local overlay = require('plugins.overlay') - local Label = require('gui.widgets.labels.label') + local widgets = require('gui.widgets') local dflayout = require('gui.dflayout') local WIDTH, HEIGHT = 20, 1 -- whatever static size the overlay needs @@ -517,7 +517,7 @@ while still being player-customizable:: function UIRelativeOverlay:init() self:addviews{ - Label{ + widgets.Label{ text_pen = { fg = COLOR_BLACK, bg = COLOR_GREY }, text = string.char(25):rep(dig_dig_button.width) .. ' I can dig it!', }, From 77f9e2ab696c144fce40588638395c202d8914ed Mon Sep 17 00:00:00 2001 From: Chris Johnsen Date: Sat, 26 Apr 2025 03:46:12 -0500 Subject: [PATCH 06/10] gui.dflayout: add names to UI elements and overlay placement specs --- library/lua/gui/dflayout.lua | 175 +++++++++++++++++++++-------------- 1 file changed, 103 insertions(+), 72 deletions(-) diff --git a/library/lua/gui/dflayout.lua b/library/lua/gui/dflayout.lua index c35a7fd100..deb5428447 100644 --- a/library/lua/gui/dflayout.lua +++ b/library/lua/gui/dflayout.lua @@ -385,6 +385,7 @@ local fort_secondary_tb_frames = { } ---@class DFLayout.DynamicUIElement +---@field name string ---@field frame_fn DFLayout.FrameFn ---@field minimum_insets DFLayout.FullInsets @@ -394,11 +395,13 @@ local fort_secondary_tb_frames = { -- the input interface size grows (i.e., the minimum insets are found when -- placing the frame in a minimum-size interface area). This is true for all the -- DF toolbars and their sub-components, but not for all UI elements in general. +---@param name string ---@param frame_fn DFLayout.FrameFn ---@return DFLayout.DynamicUIElement -local function nd_inset_ui_el(frame_fn) +local function nd_inset_ui_el(name, frame_fn) local min_frame = frame_fn(MINIMUM_INTERFACE_SIZE) return { + name = name, frame_fn = frame_fn, minimum_insets = { l = min_frame.l, @@ -410,14 +413,15 @@ local function nd_inset_ui_el(frame_fn) end -- Derive the DynamicUIElement for the named button given a toolbar's frame_fn, and its button button layout. +---@param toolbar_name string ---@param toolbar_frame_fn DFLayout.FrameFn ---@param toolbar_layout DFLayout.Toolbar.Layout ---@param button_name string ---@return DFLayout.DynamicUIElement -local function button_ui_el(toolbar_frame_fn, toolbar_layout, button_name) +local function button_ui_el(toolbar_name, toolbar_frame_fn, toolbar_layout, button_name) local button = toolbar_layout.buttons[button_name] or dfhack.error('button not present in given toolbar layout: ' .. tostring(button_name)) - return nd_inset_ui_el(function(interface_size) + return nd_inset_ui_el(toolbar_name .. '.' .. button_name, function(interface_size) local toolbar_frame = toolbar_frame_fn(interface_size) local l = toolbar_frame.l + button.offset local r = interface_size.width - (l + button.width) @@ -433,6 +437,19 @@ local function button_ui_el(toolbar_frame_fn, toolbar_layout, button_name) end) end +local function left_button_ui_el(button_name) + return button_ui_el('fort.toolbar_buttons.left', fort_left_tb_frame, fort_tb_layout.left, button_name) +end +local function center_button_ui_el(button_name) + return button_ui_el('fort.toolbar_buttons.center', fort_center_tb_frame, fort_tb_layout.center, button_name) +end +local function center_close_button_ui_el(button_name) + return button_ui_el('fort.toolbar_buttons.center', fort_center_tb_frame, fort_tb_layout.center, button_name) +end +local function right_button_ui_el(button_name) + return button_ui_el('fort.toolbar_buttons.right', fort_right_tb_frame, fort_tb_layout.right, button_name) +end + -- button_ui_el, specialized for the secondary toolbars. ---@param toolbar_name DFLayout.Fort.SecondaryToolbar.Names ---@param button_name string @@ -442,118 +459,120 @@ local function secondary_button_ui_el(toolbar_name, button_name) or dfhack.error('secondary toolbar name not in fort_secondary_tb_frames: ' .. tostring(toolbar_name)) local layout = fort_stb_layout[toolbar_name] or dfhack.error('secondary toolbar name not in fort_el_layout.secondary_toolbars: ' .. tostring(toolbar_name)) - return button_ui_el(frame_fn, layout, button_name) + return button_ui_el( + ('fort.secondary_toolbar_buttons.%s.%s'):format(toolbar_name, button_name), + frame_fn, layout, button_name) end elements = { fort = { toolbars = { ---@type DFLayout.DynamicUIElement - left = nd_inset_ui_el(fort_left_tb_frame), + left = nd_inset_ui_el('fort.toolbars.left', fort_left_tb_frame), ---@type DFLayout.DynamicUIElement - center = nd_inset_ui_el(fort_center_tb_frame), + center = nd_inset_ui_el('fort.toolbars.center', fort_center_tb_frame), ---@type DFLayout.DynamicUIElement - right = nd_inset_ui_el(fort_right_tb_frame), + right = nd_inset_ui_el('fort.toolbars.left', fort_right_tb_frame), }, toolbar_buttons = { left = { ---@type DFLayout.DynamicUIElement - MAIN_OPEN_CREATURES = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_CREATURES'), + MAIN_OPEN_CREATURES = left_button_ui_el('MAIN_OPEN_CREATURES'), ---@type DFLayout.DynamicUIElement - MAIN_OPEN_TASKS = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_TASKS'), + MAIN_OPEN_TASKS = left_button_ui_el('MAIN_OPEN_TASKS'), ---@type DFLayout.DynamicUIElement - MAIN_OPEN_PLACES = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_PLACES'), + MAIN_OPEN_PLACES = left_button_ui_el('MAIN_OPEN_PLACES'), ---@type DFLayout.DynamicUIElement - MAIN_OPEN_LABOR = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_LABOR'), + MAIN_OPEN_LABOR = left_button_ui_el('MAIN_OPEN_LABOR'), ---@type DFLayout.DynamicUIElement - MAIN_OPEN_WORK_ORDERS = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_WORK_ORDERS'), + MAIN_OPEN_WORK_ORDERS = left_button_ui_el('MAIN_OPEN_WORK_ORDERS'), ---@type DFLayout.DynamicUIElement - MAIN_OPEN_NOBLES = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_NOBLES'), + MAIN_OPEN_NOBLES = left_button_ui_el('MAIN_OPEN_NOBLES'), ---@type DFLayout.DynamicUIElement - MAIN_OPEN_OBJECTS = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_OBJECTS'), + MAIN_OPEN_OBJECTS = left_button_ui_el('MAIN_OPEN_OBJECTS'), ---@type DFLayout.DynamicUIElement - MAIN_OPEN_JUSTICE = button_ui_el(fort_left_tb_frame, fort_tb_layout.left, 'MAIN_OPEN_JUSTICE'), + MAIN_OPEN_JUSTICE = left_button_ui_el('MAIN_OPEN_JUSTICE'), }, center = { ---@type DFLayout.DynamicUIElement - DIG = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'DIG'), + DIG = center_button_ui_el('DIG'), ---@type DFLayout.DynamicUIElement - CHOP = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'CHOP'), + CHOP = center_button_ui_el('CHOP'), ---@type DFLayout.DynamicUIElement - GATHER = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'GATHER'), + GATHER = center_button_ui_el('GATHER'), ---@type DFLayout.DynamicUIElement - SMOOTH = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'SMOOTH'), + SMOOTH = center_button_ui_el('SMOOTH'), ---@type DFLayout.DynamicUIElement - ERASE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'ERASE'), + ERASE = center_button_ui_el('ERASE'), ---@type DFLayout.DynamicUIElement - MAIN_BUILDING_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_BUILDING_MODE'), + MAIN_BUILDING_MODE = center_button_ui_el('MAIN_BUILDING_MODE'), ---@type DFLayout.DynamicUIElement - MAIN_STOCKPILE_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_STOCKPILE_MODE'), + MAIN_STOCKPILE_MODE = center_button_ui_el('MAIN_STOCKPILE_MODE'), ---@type DFLayout.DynamicUIElement - MAIN_ZONE_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_ZONE_MODE'), + MAIN_ZONE_MODE = center_button_ui_el('MAIN_ZONE_MODE'), ---@type DFLayout.DynamicUIElement - MAIN_BURROW_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_BURROW_MODE'), + MAIN_BURROW_MODE = center_button_ui_el('MAIN_BURROW_MODE'), ---@type DFLayout.DynamicUIElement - MAIN_HAULING_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_HAULING_MODE'), + MAIN_HAULING_MODE = center_button_ui_el('MAIN_HAULING_MODE'), ---@type DFLayout.DynamicUIElement - TRAFFIC = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'TRAFFIC'), + TRAFFIC = center_button_ui_el('TRAFFIC'), ---@type DFLayout.DynamicUIElement - ITEM_BUILDING = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'ITEM_BUILDING'), + ITEM_BUILDING = center_button_ui_el('ITEM_BUILDING'), }, center_close = { ---@type DFLayout.DynamicUIElement - DIG_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'DIG'), + DIG_LOWER_MODE = center_close_button_ui_el('DIG'), ---@type DFLayout.DynamicUIElement - CHOP_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'CHOP'), + CHOP_LOWER_MODE = center_close_button_ui_el('CHOP'), ---@type DFLayout.DynamicUIElement - GATHER_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'GATHER'), + GATHER_LOWER_MODE = center_close_button_ui_el('GATHER'), ---@type DFLayout.DynamicUIElement - SMOOTH_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'SMOOTH'), + SMOOTH_LOWER_MODE = center_close_button_ui_el('SMOOTH'), ---@type DFLayout.DynamicUIElement - ERASE_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'ERASE'), + ERASE_LOWER_MODE = center_close_button_ui_el('ERASE'), ---@type DFLayout.DynamicUIElement - MAIN_BUILDING_MODE_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_BUILDING_MODE'), + MAIN_BUILDING_MODE_LOWER_MODE = center_close_button_ui_el('MAIN_BUILDING_MODE'), ---@type DFLayout.DynamicUIElement - MAIN_STOCKPILE_MODE_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_STOCKPILE_MODE'), + MAIN_STOCKPILE_MODE_LOWER_MODE = center_close_button_ui_el('MAIN_STOCKPILE_MODE'), ---@type DFLayout.DynamicUIElement - MAIN_ZONE_MODE_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_ZONE_MODE'), + MAIN_ZONE_MODE_LOWER_MODE = center_close_button_ui_el('MAIN_ZONE_MODE'), ---@type DFLayout.DynamicUIElement - MAIN_BURROW_MODE_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_BURROW_MODE'), + MAIN_BURROW_MODE_LOWER_MODE = center_close_button_ui_el('MAIN_BURROW_MODE'), ---@type DFLayout.DynamicUIElement - MAIN_HAULING_MODE_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'MAIN_HAULING_MODE'), + MAIN_HAULING_MODE_LOWER_MODE = center_close_button_ui_el('MAIN_HAULING_MODE'), ---@type DFLayout.DynamicUIElement - TRAFFIC_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'TRAFFIC'), + TRAFFIC_LOWER_MODE = center_close_button_ui_el('TRAFFIC'), ---@type DFLayout.DynamicUIElement - ITEM_BUILDING_LOWER_MODE = button_ui_el(fort_center_tb_frame, fort_tb_layout.center, 'ITEM_BUILDING'), + ITEM_BUILDING_LOWER_MODE = center_close_button_ui_el('ITEM_BUILDING'), }, right = { ---@type DFLayout.DynamicUIElement - MAIN_OPEN_SQUADS = button_ui_el(fort_right_tb_frame, fort_tb_layout.right, 'MAIN_OPEN_SQUADS'), + MAIN_OPEN_SQUADS = right_button_ui_el('MAIN_OPEN_SQUADS'), ---@type DFLayout.DynamicUIElement - MAIN_OPEN_WORLD = button_ui_el(fort_right_tb_frame, fort_tb_layout.right, 'MAIN_OPEN_WORLD'), + MAIN_OPEN_WORLD = right_button_ui_el('MAIN_OPEN_WORLD'), }, }, secondary_toolbars = { ---@type DFLayout.DynamicUIElement - DIG = nd_inset_ui_el(fort_secondary_tb_frames.DIG), + DIG = nd_inset_ui_el('fort.secondary_toolbars.DIG', fort_secondary_tb_frames.DIG), ---@type DFLayout.DynamicUIElement - CHOP = nd_inset_ui_el(fort_secondary_tb_frames.CHOP), + CHOP = nd_inset_ui_el('fort.secondary_toolbars.CHOP', fort_secondary_tb_frames.CHOP), ---@type DFLayout.DynamicUIElement - GATHER = nd_inset_ui_el(fort_secondary_tb_frames.GATHER), + GATHER = nd_inset_ui_el('fort.secondary_toolbars.GATHER', fort_secondary_tb_frames.GATHER), ---@type DFLayout.DynamicUIElement - SMOOTH = nd_inset_ui_el(fort_secondary_tb_frames.SMOOTH), + SMOOTH = nd_inset_ui_el('fort.secondary_toolbars.SMOOTH', fort_secondary_tb_frames.SMOOTH), ---@type DFLayout.DynamicUIElement - ERASE = nd_inset_ui_el(fort_secondary_tb_frames.ERASE), + ERASE = nd_inset_ui_el('fort.secondary_toolbars.ERASE', fort_secondary_tb_frames.ERASE), ---@type DFLayout.DynamicUIElement - MAIN_STOCKPILE_MODE = nd_inset_ui_el(fort_secondary_tb_frames.MAIN_STOCKPILE_MODE), + MAIN_STOCKPILE_MODE = nd_inset_ui_el('fort.secondary_toolbars.MAIN_STOCKPILE_MODE', fort_secondary_tb_frames.MAIN_STOCKPILE_MODE), ---@type DFLayout.DynamicUIElement - STOCKPILE_NEW = nd_inset_ui_el(fort_secondary_tb_frames.STOCKPILE_NEW), + STOCKPILE_NEW = nd_inset_ui_el('fort.secondary_toolbars.STOCKPILE_NEW', fort_secondary_tb_frames.STOCKPILE_NEW), ---@type DFLayout.DynamicUIElement - ['Add new burrow'] = nd_inset_ui_el(fort_secondary_tb_frames['Add new burrow']), + ['Add new burrow'] = nd_inset_ui_el('fort.secondary_toolbars.Add new burrow', fort_secondary_tb_frames['Add new burrow']), ---@type DFLayout.DynamicUIElement - TRAFFIC = nd_inset_ui_el(fort_secondary_tb_frames.TRAFFIC), + TRAFFIC = nd_inset_ui_el('fort.secondary_toolbars.TRAFFIC', fort_secondary_tb_frames.TRAFFIC), ---@type DFLayout.DynamicUIElement - ITEM_BUILDING = nd_inset_ui_el(fort_secondary_tb_frames.ITEM_BUILDING), + ITEM_BUILDING = nd_inset_ui_el('fort.secondary_toolbars.ITEM_BUILDING', fort_secondary_tb_frames.ITEM_BUILDING), }, secondary_toolbar_buttons = { DIG = { @@ -815,6 +834,7 @@ elements = { ---@field h integer ---@class DFLayout.Placement.Spec +---@field name string used in error messages ---@field size DFLayout.Placement.Size the static size of overlay ---@field ui_element DFLayout.DynamicUIElement a UI element value from the `elements` tree ---@field h_placement DFLayout.Placement.HorizontalAlignment how to align the overlay's horizontal position against the `ui_element` @@ -873,30 +893,36 @@ local function place_span(available_span, ref_offset_before, ref_span, placed_sp return before, placed_span, after end - -- Runs `frame_fn(interface_size)` and checks the resulting frame for "sanity" -- (non-negative paddings, positive sizes, paddings and sizes span -- `interface_size`). -- -- Returns the frame or throws an error. ---@param interface_size DFLayout.Rectangle.Size ----@param frame_fn DFLayout.FrameFn +---@param ui_element DFLayout.DynamicUIElement ---@return DFLayout.FullyPlacedFrame -local function checked_frame(interface_size, frame_fn) - local frame = frame_fn(interface_size) +local function checked_frame(interface_size, ui_element) + local frame = ui_element.frame_fn(interface_size) if frame.l < 0 or frame.w <= 0 or frame.r < 0 or frame.l + frame.w + frame.r ~= interface_size.width then - dfhack.error( - ('horizontal placement is invalid: l=%d w=%d r=%d W=%d') - :format(frame.l, frame.w, frame.r, interface_size.width)) + dfhack.error(('%s: horizontal placement is invalid: l=%d w=%d r=%d W=%d') + :format(ui_element.name, frame.l, frame.w, frame.r, interface_size.width)) end if frame.t < 0 or frame.h <= 0 or frame.b < 0 or frame.t + frame.h + frame.b ~= interface_size.height then - dfhack.error( - ('vertical placement is invalid: t=%d h=%d rb%d H=%d') - :format(frame.t, frame.h, frame.b, interface_size.height)) + dfhack.error(('%s: vertical placement is invalid: t=%d h=%d b=%d H=%d') + :format(ui_element.name, frame.t, frame.h, frame.b, interface_size.height)) + end + for _, d in ipairs{'l', 'r', 't', 'b'} do + local gen = frame[d] + local min = ui_element.minimum_insets[d] + if gen < min then + dfhack.printerr( + ('error in %s.frame_fn result.%d: %d < %d (generated < minimum)') + :format(ui_element.name, d, gen, min)) + end end return frame end @@ -907,16 +933,16 @@ end ---@param spec DFLayout.Placement.Spec ---@return DFLayout.FullyPlacedFrame local function place_overlay_frame(interface_size, spec) - local ref_frame = checked_frame(interface_size, spec.ui_element.frame_fn) + local ref_frame = checked_frame(interface_size, spec.ui_element) local generic_h_placement = generic_placement_from_horizontal[spec.h_placement] - or dfhack.error('invalid h_placement: ' .. tostring(spec.h_placement)) + or dfhack.error(('%s: invalid h_placement: %s'):format(spec.name, spec.h_placement)) local l, w, r = place_span(interface_size.width, ref_frame.l, ref_frame.w, spec.size.w, generic_h_placement, spec.offset and spec.offset.x) local generic_v_placement = generic_placement_from_vertical[spec.v_placement] - or dfhack.error('invalid v_placement: ' .. tostring(spec.v_placement)) + or dfhack.error(('%s: invalid v_placement: %s'):format(spec.name, spec.v_placement)) local t, h, b = place_span(interface_size.height, ref_frame.t, ref_frame.h, spec.size.h, generic_v_placement, spec.offset and spec.offset.y) @@ -933,27 +959,29 @@ local function place_overlay_frame(interface_size, spec) end -- Provide default `default_pos` values based on nominal values, and compute the --- direction-specific delta to those nominal values. +---@param spec_name string ---@param xy 'x' | 'y' the default_pos field name (used in error messages) ---@param pos? integer ---@param nominal_positive integer ---@param nominal_negative integer ----@param default_to_positive boolean controls which nominal value is used if `pos` is falsy +---@param default_to_positive boolean controls which nominal value is used if `pos` is nil or 0 ---@return integer pos `pos`, or one of its defaults (when 0 or falsy) ---@return integer padding_on_positive_side 0 or padding required to move from positive `value` to `nominal_positive` ---@return integer padding_on_negative_side 0 or padding required to move from negative `value` to `nominal_negative` -local function pos_and_paddings(xy, pos, nominal_positive, nominal_negative, default_to_positive) - if not pos or pos == 0 then +local function pos_and_paddings(spec_name, xy, pos, nominal_positive, nominal_negative, default_to_positive) + if pos == nil or pos == 0 then pos = default_to_positive and nominal_positive or nominal_negative end if 0 < pos then if nominal_positive < pos then - dfhack.error('specified placement requires 1 <= default_pos.'..xy..' <= '..nominal_positive) + dfhack.error(('%s: specified placement requires 1 <= default_pos.%s <= %d') + :format(spec_name, xy, nominal_positive)) end return pos, nominal_positive - pos, 0 end if pos < nominal_negative then - dfhack.error('specified placement requires -1 >= default_pos.'..xy..' >= '..nominal_negative) + dfhack.error(('%s: specified placement requires %d <= default_pos.%s <= -1') + :format(spec_name, nominal_negative, xy)) end return pos, 0, pos - nominal_negative end @@ -970,17 +998,20 @@ end ---@return DFLayout.OverlayPlacementInfo overlay_placement_info local function get_overlay_placement_info(overlay_placement_spec, insets_filter) overlay_placement_spec = utils.clone(overlay_placement_spec, true) --[[@as DFLayout.Placement.Spec]] + overlay_placement_spec.name = overlay_placement_spec.name or '[unnamed overlay placement]' local minimum_placement = place_overlay_frame(MINIMUM_INTERFACE_SIZE, overlay_placement_spec) -- decode spec.default_pos into pos values and padding values local override_default_pos = overlay_placement_spec.default_pos - local x_pos, l_pad, r_pad = pos_and_paddings('x', + local x_pos, l_pad, r_pad = pos_and_paddings( + overlay_placement_spec.name, 'x', override_default_pos and override_default_pos.x, (minimum_placement.l + 1), -- one-based, left-relative -(minimum_placement.r + 1), -- one-based, right-relative true -- default to left-relative ) - local y_pos, t_pad, b_pad = pos_and_paddings('y', + local y_pos, t_pad, b_pad = pos_and_paddings( + overlay_placement_spec.name, 'y', override_default_pos and override_default_pos.y, (minimum_placement.t + 1), -- one-based, top-relative -(minimum_placement.b + 1), -- one-based, bottom-relative From c1f611d8fc5a52156f7dccddee03dea1215f06c5 Mon Sep 17 00:00:00 2001 From: Chris Johnsen Date: Tue, 29 Apr 2025 05:35:17 -0500 Subject: [PATCH 07/10] gui.dflayout: support for "growing" UI elements The UI elements that were initially supported (toolbars and their buttons) were all fixed-sizes and had inset values that were non-decreasing as the interface width grew. The new functionality supports UI elements that grow with the size of the interface area and have inset values that may increase or decrease as the interface size (or other DF state) changes. The "item list" part of two tabs from the DF info window are given as a demonstration "UI element": the Orders tab and the Places/Zones sub-tab. --- library/lua/gui/dflayout.lua | 497 ++++++++++++++++++++++++---------- test/library/gui/dflayout.lua | 392 ++++++++++++++++++++++++++- 2 files changed, 739 insertions(+), 150 deletions(-) diff --git a/library/lua/gui/dflayout.lua b/library/lua/gui/dflayout.lua index deb5428447..889e33309f 100644 --- a/library/lua/gui/dflayout.lua +++ b/library/lua/gui/dflayout.lua @@ -9,21 +9,23 @@ local utils = require('utils') TOOLBAR_HEIGHT = 3 SECONDARY_TOOLBAR_HEIGHT = 3 --- Basic rectangular size class. Should be structurally compatible with --- gui.dimension (get_interface_rect) and gui.ViewRect (updateLayout's --- parent_rect), but the LuaLSP disagrees. ----@class DFLayout.Rectangle.Size.class +-- Basic rectangular size class. Should structurally accept gui.dimension (e.g., +-- get_interface_rect) and gui.ViewRect (e.g., updateLayout's parent_rect), but +-- the LuaLSP disagrees. +-- +---@class DFLayout.Interface.Size.class ---@field width integer ---@field height integer -- An alias that gathers a few types that we know are compatible with our -- width/height size requirements. ----@alias DFLayout.Rectangle.Size ---- | DFLayout.Rectangle.Size.class basic width/height size +-- +---@alias DFLayout.Interface.Size +--- | DFLayout.Interface.Size.class basic width/height size --- | gui.dimension e.g., gui.get_interface_rect() --- | gui.ViewRect e.g., parent_rect supplied to updateLayout subsidiary methods ----@type DFLayout.Rectangle.Size +---@type DFLayout.Interface.Size MINIMUM_INTERFACE_SIZE = { width = 114, height = 46 } ---@generic T @@ -37,27 +39,102 @@ local function concat_sequences(sequences) return collected end ----@class DFLayout.FullInsets +---@alias DFLayout.Toolbar.Button { offset: integer, width: integer } +---@alias DFLayout.Toolbar.Buttons table -- multiple entries + +---@class DFLayout.Toolbar.Layout +---@field buttons DFLayout.Toolbar.Buttons +---@field width integer + +---@class DFLayout.Inset.partial +---@field l? integer Optional gap between the left edge of the frame and the parent. +---@field t? integer Optional gap between the top edge of the frame and the parent. +---@field r? integer Optional gap between the right edge of the frame and the parent. +---@field b? integer Optional gap between the bottom edge of the frame and the parent. + +---@class DFLayout.Inset ---@field l integer Gap between the left edge of the frame and the parent. ---@field t integer Gap between the top edge of the frame and the parent. ---@field r integer Gap between the right edge of the frame and the parent. ---@field b integer Gap between the bottom edge of the frame and the parent. -- Like widgets.Widget.frame, but no optional fields. ----@class DFLayout.FullyPlacedFrame: DFLayout.FullInsets +-- +---@class DFLayout.Frame: DFLayout.Inset ---@field w integer Width ---@field h integer Height +---@alias DFLayout.FrameFn.FeatureTests table + +---@alias DFLayout.FrameFn.Features table + -- Function that generates a "full placement" for a given interface size. ----@alias DFLayout.FrameFn fun(interface_size: DFLayout.Rectangle.Size): DFLayout.FullyPlacedFrame +-- +-- If the FrameFn tests for a feature (by calling a `feature_tests` field), a +-- true result should result in the relevant placement inset being larger +-- (further to the edge of the interface area) than for a false result. +-- +---@alias DFLayout.FrameFn fun(interface_size: DFLayout.Interface.Size, feature_tests: DFLayout.FrameFn.FeatureTests): frame: DFLayout.Frame, active_features: DFLayout.FrameFn.Features? ----@alias DFLayout.Toolbar.Button { offset: integer, width: integer } ----@alias DFLayout.Toolbar.Buttons table -- multiple entries ----@alias DFLayout.Toolbar.Widths table -- single entry, value is width +---@alias DFLayout.DynamicUIElement.State boolean | integer | string | table ----@class DFLayout.Toolbar.Layout ----@field buttons DFLayout.Toolbar.Buttons ----@field width integer +-- A name-tagged bundle of a DFLayout.FrameFn and its supporting values. +-- +-- The `collapsable_inset` field should describe the amount `frame_fn`-reported +-- insets might shrink when computed for interfaces sizes other than +-- MINIMUM_INTERFACE_SIZE. It should also include inset shrinkage due to +-- positive "feature tests" done by `frame_fn`. +-- +---@class DFLayout.DynamicUIElement +---@field name string +---@field frame_fn DFLayout.FrameFn function that describes where DF will draw the UI element in a given interface size +---@field collapsable_inset? DFLayout.Inset.partial amount of UI element inset on MINIMUM_INTERFACE_SIZE that might disappear with different sizes or different states +---@field state_fn? fun(): DFLayout.DynamicUIElement.State return non-size state values that affect the placement of the UI element + +---@alias DFLayout.Placement.HorizontalAlignment +--- | 'on left' overlay."right edge col" + 1 == reference_frame."left edge col" +--- | 'align left edges' overlay."left edge col" == reference_frame."left edge col" +--- | 'align right edges' overlay."right edge col" == reference_frame."right edge col" +--- | 'on right' overlay."left edge col" == reference_frame."right edge col" + 1 +---@alias DFLayout.Placement.VerticalAlignment +--- | 'above' overlay."bottom edge row" + 1 == reference_frame."top edge row" +--- | 'align top edges' overlay."top edge row" == reference_frame."top edge row" +--- | 'align bottom edges' overlay."bottom edge row" == reference_frame."bottom edge row" +--- | 'below' overlay."top edge row" == reference_frame."bottom edge row" + 1 + +---@alias DFLayout.Placement.Offset { x?: integer, y?: integer } + +---@class DFLayout.Placement.DefaultPos +---@field x? integer directly specify a default_pos.x value +---@field y? integer directly specify a default_pos.y value +---@field from_right? boolean automatic default_pos.x should be based on right edge +---@field from_top? boolean automatic default_pos.y should be based on top edge + +---@class DFLayout.Placement.Size +---@field w integer +---@field h integer + +---@class DFLayout.Placement.Spec +---@field name string used in error messages +---@field size DFLayout.Placement.Size the static size of overlay +---@field ui_element DFLayout.DynamicUIElement a UI element value from the `elements` tree +---@field h_placement DFLayout.Placement.HorizontalAlignment how to align the overlay's horizontal position against the `ui_element` +---@field v_placement DFLayout.Placement.VerticalAlignment how to align the overlay's vertical position against the `ui_element` +---@field offset? DFLayout.Placement.Offset how far to move overlay after alignment with `ui_element` +---@field default_pos? DFLayout.Placement.DefaultPos specify an overlay default_pos, or which edges it should be based on +---@field feature_tests? DFLayout.FrameFn.FeatureTests for internal/testing use + +---@alias DFLayout.Placement.GenericAlignment 'place before' | 'align start' | 'align end' | 'place after' + +---@class DFLayout.OverlayPlacementInfo +---@field default_pos { x: integer, y: integer } use for the overlay's default_pos +---@field frame widgets.Widget.frame use for the overlay's initial frame +---@field preUpdateLayout_fn fun(self_overlay_widget: widgets.Widget, parent_rect: gui.ViewRect) use as the overlay's preUpdateLayout method (the "self" param is overlay.OverlayWidget) +---@field onRenderBody_fn fun(self_overlay_widget: widgets.Widget, painter: gui.Painter) use as the overlay's onRenderBody_fn method (the "self" param is overlay.OverlayWidget) + +--- DF UI element definitions --- + +---@alias DFLayout.Toolbar.Widths table -- single entry, value is width ---@param widths DFLayout.Toolbar.Widths[] single-name entries only! ---@return DFLayout.Toolbar.Layout @@ -94,8 +171,6 @@ local function buttons_to_toolbar_layout(buttons) return button_widths_to_toolbar_layout(buttons_to_widths(buttons)) end ---- DF UI element definitions --- - element_layouts = { fort = { toolbars = { @@ -330,6 +405,7 @@ end -- Derive the frame_fn for a secondary toolbar that "wants to" align with the -- specified center toolbar button. +-- ---@param center_button_name DFLayout.Fort.SecondaryToolbar.CenterButton ---@param secondary_toolbar_layout DFLayout.Toolbar.Layout ---@return DFLayout.FrameFn @@ -384,35 +460,18 @@ local fort_secondary_tb_frames = { ITEM_BUILDING = get_secondary_frame_fn('ITEM_BUILDING', fort_stb_layout.ITEM_BUILDING), } ----@class DFLayout.DynamicUIElement ----@field name string ----@field frame_fn DFLayout.FrameFn ----@field minimum_insets DFLayout.FullInsets - --- Create a DFLayout.DynamicUIElement from a DFLayout.FrameFn. --- --- Note: The `frame_fn` must generate inset values that are non-decreasing as --- the input interface size grows (i.e., the minimum insets are found when --- placing the frame in a minimum-size interface area). This is true for all the --- DF toolbars and their sub-components, but not for all UI elements in general. ---@param name string ---@param frame_fn DFLayout.FrameFn ---@return DFLayout.DynamicUIElement -local function nd_inset_ui_el(name, frame_fn) - local min_frame = frame_fn(MINIMUM_INTERFACE_SIZE) +local function ui_el(name, frame_fn) return { name = name, frame_fn = frame_fn, - minimum_insets = { - l = min_frame.l, - r = min_frame.r, - t = min_frame.t, - b = min_frame.b, - } } end -- Derive the DynamicUIElement for the named button given a toolbar's frame_fn, and its button button layout. +-- ---@param toolbar_name string ---@param toolbar_frame_fn DFLayout.FrameFn ---@param toolbar_layout DFLayout.Toolbar.Layout @@ -421,7 +480,7 @@ end local function button_ui_el(toolbar_name, toolbar_frame_fn, toolbar_layout, button_name) local button = toolbar_layout.buttons[button_name] or dfhack.error('button not present in given toolbar layout: ' .. tostring(button_name)) - return nd_inset_ui_el(toolbar_name .. '.' .. button_name, function(interface_size) + return ui_el(toolbar_name .. '.' .. button_name, function(interface_size) local toolbar_frame = toolbar_frame_fn(interface_size) local l = toolbar_frame.l + button.offset local r = interface_size.width - (l + button.width) @@ -451,6 +510,7 @@ local function right_button_ui_el(button_name) end -- button_ui_el, specialized for the secondary toolbars. +-- ---@param toolbar_name DFLayout.Fort.SecondaryToolbar.Names ---@param button_name string ---@return DFLayout.DynamicUIElement @@ -468,11 +528,11 @@ elements = { fort = { toolbars = { ---@type DFLayout.DynamicUIElement - left = nd_inset_ui_el('fort.toolbars.left', fort_left_tb_frame), + left = ui_el('fort.toolbars.left', fort_left_tb_frame), ---@type DFLayout.DynamicUIElement - center = nd_inset_ui_el('fort.toolbars.center', fort_center_tb_frame), + center = ui_el('fort.toolbars.center', fort_center_tb_frame), ---@type DFLayout.DynamicUIElement - right = nd_inset_ui_el('fort.toolbars.left', fort_right_tb_frame), + right = ui_el('fort.toolbars.left', fort_right_tb_frame), }, toolbar_buttons = { left = { @@ -554,25 +614,25 @@ elements = { }, secondary_toolbars = { ---@type DFLayout.DynamicUIElement - DIG = nd_inset_ui_el('fort.secondary_toolbars.DIG', fort_secondary_tb_frames.DIG), + DIG = ui_el('fort.secondary_toolbars.DIG', fort_secondary_tb_frames.DIG), ---@type DFLayout.DynamicUIElement - CHOP = nd_inset_ui_el('fort.secondary_toolbars.CHOP', fort_secondary_tb_frames.CHOP), + CHOP = ui_el('fort.secondary_toolbars.CHOP', fort_secondary_tb_frames.CHOP), ---@type DFLayout.DynamicUIElement - GATHER = nd_inset_ui_el('fort.secondary_toolbars.GATHER', fort_secondary_tb_frames.GATHER), + GATHER = ui_el('fort.secondary_toolbars.GATHER', fort_secondary_tb_frames.GATHER), ---@type DFLayout.DynamicUIElement - SMOOTH = nd_inset_ui_el('fort.secondary_toolbars.SMOOTH', fort_secondary_tb_frames.SMOOTH), + SMOOTH = ui_el('fort.secondary_toolbars.SMOOTH', fort_secondary_tb_frames.SMOOTH), ---@type DFLayout.DynamicUIElement - ERASE = nd_inset_ui_el('fort.secondary_toolbars.ERASE', fort_secondary_tb_frames.ERASE), + ERASE = ui_el('fort.secondary_toolbars.ERASE', fort_secondary_tb_frames.ERASE), ---@type DFLayout.DynamicUIElement - MAIN_STOCKPILE_MODE = nd_inset_ui_el('fort.secondary_toolbars.MAIN_STOCKPILE_MODE', fort_secondary_tb_frames.MAIN_STOCKPILE_MODE), + MAIN_STOCKPILE_MODE = ui_el('fort.secondary_toolbars.MAIN_STOCKPILE_MODE', fort_secondary_tb_frames.MAIN_STOCKPILE_MODE), ---@type DFLayout.DynamicUIElement - STOCKPILE_NEW = nd_inset_ui_el('fort.secondary_toolbars.STOCKPILE_NEW', fort_secondary_tb_frames.STOCKPILE_NEW), + STOCKPILE_NEW = ui_el('fort.secondary_toolbars.STOCKPILE_NEW', fort_secondary_tb_frames.STOCKPILE_NEW), ---@type DFLayout.DynamicUIElement - ['Add new burrow'] = nd_inset_ui_el('fort.secondary_toolbars.Add new burrow', fort_secondary_tb_frames['Add new burrow']), + ['Add new burrow'] = ui_el('fort.secondary_toolbars.Add new burrow', fort_secondary_tb_frames['Add new burrow']), ---@type DFLayout.DynamicUIElement - TRAFFIC = nd_inset_ui_el('fort.secondary_toolbars.TRAFFIC', fort_secondary_tb_frames.TRAFFIC), + TRAFFIC = ui_el('fort.secondary_toolbars.TRAFFIC', fort_secondary_tb_frames.TRAFFIC), ---@type DFLayout.DynamicUIElement - ITEM_BUILDING = nd_inset_ui_el('fort.secondary_toolbars.ITEM_BUILDING', fort_secondary_tb_frames.ITEM_BUILDING), + ITEM_BUILDING = ui_el('fort.secondary_toolbars.ITEM_BUILDING', fort_secondary_tb_frames.ITEM_BUILDING), }, secondary_toolbar_buttons = { DIG = { @@ -813,62 +873,119 @@ elements = { }, } ---- Automatic UI-relative Overlay Positioning --- +---@param minimum_inset DFLayout.Inset +---@param scrollbar_feature_name string +---@return DFLayout.FrameFn +local function get_info_frame_fn(minimum_inset, scrollbar_feature_name) + ---@type DFLayout.FrameFn + return function(interface_size, feature_tests) + local l = minimum_inset.l + local r = minimum_inset.r + local t = minimum_inset.t + local b = minimum_inset.b + + -- main info window tabs need to wrap in narrow interface widths + if interface_size.width < 155 then + t = t + 2 + end ----@alias DFLayout.Placement.HorizontalAlignment ---- | 'on left' overlay."right edge col" + 1 == reference_frame."left edge col" ---- | 'align left edges' overlay."left edge col" == reference_frame."left edge col" ---- | 'align right edges' overlay."right edge col" == reference_frame."right edge col" ---- | 'on right' overlay."left edge col" == reference_frame."right edge col" + 1 ----@alias DFLayout.Placement.VerticalAlignment ---- | 'above' overlay."bottom edge row" + 1 == reference_frame."top edge row" ---- | 'align top edges' overlay."top edge row" == reference_frame."top edge row" ---- | 'align bottom edges' overlay."bottom edge row" == reference_frame."bottom edge row" ---- | 'below' overlay."top edge row" == reference_frame."bottom edge row" + 1 + -- info item rows are 3 UI rows each; if there are extra UI rows, they + -- go into the bottom inset (immediately below the last item row) + local extra_ui_rows = (interface_size.height - (t + b)) % 3 + b = b + extra_ui_rows ----@alias DFLayout.Placement.Offset { x?: integer, y?: integer } ----@alias DFLayout.Placement.DefaultPos { x?: integer, y?: integer } + local h = interface_size.height - (t + b) ----@class DFLayout.Placement.Size ----@field w integer ----@field h integer + -- if it is needed, the scroll bar (2 UI cols) is in the right inset area + local displayable_count = h // 3 + local scrollbar = feature_tests[scrollbar_feature_name](displayable_count) + if scrollbar then + r = r + 2 + end ----@class DFLayout.Placement.Spec ----@field name string used in error messages ----@field size DFLayout.Placement.Size the static size of overlay ----@field ui_element DFLayout.DynamicUIElement a UI element value from the `elements` tree ----@field h_placement DFLayout.Placement.HorizontalAlignment how to align the overlay's horizontal position against the `ui_element` ----@field v_placement DFLayout.Placement.VerticalAlignment how to align the overlay's vertical position against the `ui_element` ----@field offset? DFLayout.Placement.Offset how far to move overlay after alignment with `ui_element` ----@field default_pos? DFLayout.Placement.DefaultPos supply "legacy" overlay default_pos for placement compatibility + return { + l = l, + w = interface_size.width - (l + r), + r = r, ----@alias DFLayout.Placement.GenericAlignment 'place before' | 'align start' | 'align end' | 'place after' + t = t, + h = h, + b = b, + }, { + scrollbar = scrollbar, + } + end +end -local generic_placement_from_horizontal = { - ['on left'] = 'place before', - ['align left edges'] = 'align start', - ['align right edges'] = 'align end', - ['on right'] = 'place after', +-- A UI element that encompasses the area used to display orders in the Order DF +-- info window tab. The area includes unused rows. +-- +---@type DFLayout.DynamicUIElement +local orders_ui_element = { + name = 'Orders list rows', + collapsable_inset = { + r = 2, -- possible lack of scrollbar on right + t = 2, -- possible un-wrapped info tab bar + -- bottom inset has variation of 2 (mod 3 from item rows), but is at its + -- minimum in MINIMUM_INTERFACE_SIZE, so it can not shrink any + }, + frame_fn = get_info_frame_fn({ + l = 6, -- 4 UI cols notification area, 1 UI col border, 1 UI col empty + r = 35, -- 28 UI cols squad area, 1 UI col border, 6 UI cols new order button area, 0 or 2 UI cols scroll bar + t = 8, -- 4 UI rows top-bar, 1 UI row border, 2 or 4 UI rows info tabs, 1 UI row empty + b = 9, -- 3 UI rows bottom toolbar area, 1 UI row border, 1 UI row empty, 3 UI rows text blurb, 1 UI row empty, 0-2 UI rows expansion gap + }, 'orders_needs_scrollbar'), + state_fn = function() + return #df.global.world.manager_orders.all + end, } -local generic_placement_from_vertical = { - ['above'] = 'place before', - ['align top edges'] = 'align start', - ['align bottom edges'] = 'align end', - ['below'] = 'place after', + +-- A UI element that encompasses the area used to display zones in the +-- Places/Zones DF info window tab. The area includes unused rows. +-- +---@type DFLayout.DynamicUIElement +local zones_ui_element = { + name = 'Places/Zones list rows', + collapsable_inset = { + r = 2, -- possible lack of scrollbar on right + t = 2, -- possible un-wrapped info tab bar + -- bottom has variation of 2 (mod 3 from item rows), and is at its + -- maximum in MINIMUM_INTERFACE_SIZE, so it might shrink up to 2 + b = 2, -- possible item row gap-filler (mod 3) + }, + frame_fn = get_info_frame_fn({ + l = 6, -- 4 UI cols notification area, 1 UI col border, 1 UI col empty + r = 30, -- 28 UI cols squad area, 1 UI col border, 1 UI col empty + t = 10, -- 4 UI rows top-bar, 1 UI row border, 2 or 4 UI rows info tabs, 2 UI rows places tabs, 1 UI row empty + b = 5, -- 3 UI rows bottom toolbar area, 1 UI row border, 1 UI row empty, 0-2 UI rows expansion gap + }, 'zones_needs_scrollbar'), + state_fn = function() + return #df.global.game.main_interface.info.buildings.list[df.buildings_mode_type.ZONES] + end, } +experimental_elements = { + ---@type DFLayout.DynamicUIElement + orders = orders_ui_element, + ---@type DFLayout.DynamicUIElement + zones = zones_ui_element, +} + +--- Automatic UI-relative Overlay Positioning --- + -- Place a specified span (width or height) with the specified alignment with -- respect to the given reference position and span. +-- ---@param available_span integer ---@param ref_offset_before integer ----@param ref_span integer +---@param ref_offset_after integer ---@param placed_span integer ---@param placement DFLayout.Placement.GenericAlignment ---@param offset? integer ---@return integer before ---@return integer span ---@return integer after -local function place_span(available_span, ref_offset_before, ref_span, placed_span, placement, offset) +local function place_span(available_span, ref_offset_before, ref_offset_after, placed_span, placement, offset) if placed_span >= available_span then return 0, available_span, 0 end @@ -876,11 +993,11 @@ local function place_span(available_span, ref_offset_before, ref_span, placed_sp if placement == 'align start' then before = ref_offset_before elseif placement == 'align end' then - before = ref_offset_before + ref_span - placed_span + before = available_span - ref_offset_after - placed_span elseif placement == 'place before' then before = ref_offset_before - placed_span elseif placement == 'place after' then - before = ref_offset_before + ref_span + before = available_span - ref_offset_after else dfhack.error('invalid generic placement: ' .. tostring(placement)) end @@ -898,11 +1015,13 @@ end -- `interface_size`). -- -- Returns the frame or throws an error. ----@param interface_size DFLayout.Rectangle.Size +-- +---@param interface_size DFLayout.Interface.Size ---@param ui_element DFLayout.DynamicUIElement ----@return DFLayout.FullyPlacedFrame -local function checked_frame(interface_size, ui_element) - local frame = ui_element.frame_fn(interface_size) +---@param feature_tests DFLayout.FrameFn.FeatureTests +---@return DFLayout.Frame +local function checked_frame(interface_size, ui_element, feature_tests) + local frame = ui_element.frame_fn(interface_size, feature_tests) if frame.l < 0 or frame.w <= 0 or frame.r < 0 or frame.l + frame.w + frame.r ~= interface_size.width then @@ -915,36 +1034,56 @@ local function checked_frame(interface_size, ui_element) dfhack.error(('%s: vertical placement is invalid: t=%d h=%d b=%d H=%d') :format(ui_element.name, frame.t, frame.h, frame.b, interface_size.height)) end - for _, d in ipairs{'l', 'r', 't', 'b'} do - local gen = frame[d] - local min = ui_element.minimum_insets[d] - if gen < min then - dfhack.printerr( - ('error in %s.frame_fn result.%d: %d < %d (generated < minimum)') - :format(ui_element.name, d, gen, min)) - end - end return frame end +local default_feature_tests = { + orders_needs_scrollbar = function(displayable_count) + return #df.global.world.manager_orders.all > displayable_count + end, + zones_needs_scrollbar = function(displayable_count) + return + #df.global.game.main_interface.info.buildings.list[df.buildings_mode_type.ZONES] + > displayable_count + end, +} + +local generic_placement_from_horizontal = { + ['on left'] = 'place before', + ['align left edges'] = 'align start', + ['align right edges'] = 'align end', + ['on right'] = 'place after', +} +local generic_placement_from_vertical = { + ['above'] = 'place before', + ['align top edges'] = 'align start', + ['align bottom edges'] = 'align end', + ['below'] = 'place after', +} + -- Place the specified area with respect to the specified reference with the -- specified alignment and offset. ----@param interface_size DFLayout.Rectangle.Size +-- +---@param interface_size DFLayout.Interface.Size ---@param spec DFLayout.Placement.Spec ----@return DFLayout.FullyPlacedFrame -local function place_overlay_frame(interface_size, spec) - local ref_frame = checked_frame(interface_size, spec.ui_element) +---@param feature_tests? DFLayout.FrameFn.FeatureTests +---@return DFLayout.Frame +local function place_overlay_frame(interface_size, spec, feature_tests) + local ref_inset = checked_frame(interface_size, spec.ui_element, + feature_tests -- get_overlay_placement_info + or spec.feature_tests -- tests + or default_feature_tests) local generic_h_placement = generic_placement_from_horizontal[spec.h_placement] or dfhack.error(('%s: invalid h_placement: %s'):format(spec.name, spec.h_placement)) local l, w, r = place_span(interface_size.width, - ref_frame.l, ref_frame.w, + ref_inset.l, ref_inset.r, spec.size.w, generic_h_placement, spec.offset and spec.offset.x) local generic_v_placement = generic_placement_from_vertical[spec.v_placement] or dfhack.error(('%s: invalid v_placement: %s'):format(spec.name, spec.v_placement)) local t, h, b = place_span(interface_size.height, - ref_frame.t, ref_frame.h, + ref_inset.t, ref_inset.b, spec.size.h, generic_v_placement, spec.offset and spec.offset.y) return { @@ -958,17 +1097,23 @@ local function place_overlay_frame(interface_size, spec) } end --- Provide default `default_pos` values based on nominal values, and compute the +-- Calculate (or validate the requested) `default_pos` and required paddings +-- based on nominal position and forced paddings. +-- ---@param spec_name string ---@param xy 'x' | 'y' the default_pos field name (used in error messages) ---@param pos? integer ---@param nominal_positive integer ---@param nominal_negative integer +---@param positive_pad integer extra padding to force on positive side +---@param negative_pad integer extra padding to force on negative side ---@param default_to_positive boolean controls which nominal value is used if `pos` is nil or 0 ---@return integer pos `pos`, or one of its defaults (when 0 or falsy) ---@return integer padding_on_positive_side 0 or padding required to move from positive `value` to `nominal_positive` ---@return integer padding_on_negative_side 0 or padding required to move from negative `value` to `nominal_negative` -local function pos_and_paddings(spec_name, xy, pos, nominal_positive, nominal_negative, default_to_positive) +local function pos_and_paddings(spec_name, xy, pos, nominal_positive, nominal_negative, positive_pad, negative_pad, default_to_positive) + nominal_positive = nominal_positive - positive_pad + nominal_negative = nominal_negative + negative_pad if pos == nil or pos == 0 then pos = default_to_positive and nominal_positive or nominal_negative end @@ -977,45 +1122,92 @@ local function pos_and_paddings(spec_name, xy, pos, nominal_positive, nominal_ne dfhack.error(('%s: specified placement requires 1 <= default_pos.%s <= %d') :format(spec_name, xy, nominal_positive)) end - return pos, nominal_positive - pos, 0 + return pos, positive_pad + nominal_positive - pos, negative_pad end if pos < nominal_negative then dfhack.error(('%s: specified placement requires %d <= default_pos.%s <= -1') :format(spec_name, nominal_negative, xy)) end - return pos, 0, pos - nominal_negative + return pos, positive_pad, negative_pad + pos - nominal_negative end ----@class DFLayout.OverlayPlacementInfo ----@field default_pos { x: integer, y: integer } use for the overlay's default_pos ----@field frame widgets.Widget.frame use for the overlay's initial frame ----@field preUpdateLayout_fn fun(self_overlay_widget: widgets.Widget, parent_rect: gui.ViewRect) use the overlay's preUpdateLayout method (the "self" param is overlay.OverlayWidget, but that isn't a declared type) +local state_changed_cache = setmetatable({}, { __mode = 'k' }) + +---@param widget widgets.Widget +---@param ui_element DFLayout.DynamicUIElement +---@return boolean +local function state_changed(widget, ui_element) + if widget == nil then return end + local previous = state_changed_cache[widget] + local current = ui_element.state_fn() + state_changed_cache[widget] = current + if type(previous) ~= 'table' or type(current) ~= 'table' then + return current ~= previous + end + for k, v in pairs(current) do + if v ~= previous[k] then return true end + previous[k] = nil + end + if next(previous) then return true end + return false +end + +---@type DFLayout.FrameFn.FeatureTests +local all_features = setmetatable({}, { + __index = function(table, field) + return function() return true end + end, +}) ----@alias DFLayout.Placement.InsetsFilter fun(insets: DFLayout.FullInsets): DFLayout.FullInsets +---@alias DFLayout.Placement.InsetFilter fun(inset: DFLayout.Inset): DFLayout.Inset ---@param overlay_placement_spec DFLayout.Placement.Spec ----@param insets_filter? DFLayout.Placement.InsetsFilter +---@param inset_filter? DFLayout.Placement.InsetFilter ---@return DFLayout.OverlayPlacementInfo overlay_placement_info -local function get_overlay_placement_info(overlay_placement_spec, insets_filter) +local function get_overlay_placement_info(overlay_placement_spec, inset_filter) overlay_placement_spec = utils.clone(overlay_placement_spec, true) --[[@as DFLayout.Placement.Spec]] overlay_placement_spec.name = overlay_placement_spec.name or '[unnamed overlay placement]' - local minimum_placement = place_overlay_frame(MINIMUM_INTERFACE_SIZE, overlay_placement_spec) - -- decode spec.default_pos into pos values and padding values - local override_default_pos = overlay_placement_spec.default_pos + -- get the "default" placement (in a MINIMUM_INTERFACE_SIZE area) + local default_placement = place_overlay_frame(MINIMUM_INTERFACE_SIZE, overlay_placement_spec, all_features) + + local default_from_left = true -- default to left-relative + local default_from_top = false -- default to bottom-relative + + -- decode overlay_placement_spec.default_pos into pos values and padding values + local override_default_pos = overlay_placement_spec.default_pos or {} + if override_default_pos.from_right ~= nil then + if override_default_pos.x then + dfhack.printerr(('warning: %s: default_pos.from_right will be ignored since .x is also present') + :format(overlay_placement_spec.name)) + end + default_from_left = not override_default_pos.from_right + end + if override_default_pos.from_top ~= nil then + if override_default_pos.y then + dfhack.printerr(('warning: %s: default_pos.from_top will be ignored since .y is also present') + :format(overlay_placement_spec.name)) + end + default_from_top = not not override_default_pos.from_top + end + local padding = overlay_placement_spec.ui_element.collapsable_inset or {} local x_pos, l_pad, r_pad = pos_and_paddings( overlay_placement_spec.name, 'x', - override_default_pos and override_default_pos.x, - (minimum_placement.l + 1), -- one-based, left-relative - -(minimum_placement.r + 1), -- one-based, right-relative - true -- default to left-relative + override_default_pos.x, + (default_placement.l + 1), -- one-based, left-relative + -(default_placement.r + 1), -- one-based, right-relative + padding.l or 0, + padding.r or 0, + default_from_left ) local y_pos, t_pad, b_pad = pos_and_paddings( overlay_placement_spec.name, 'y', - override_default_pos and override_default_pos.y, - (minimum_placement.t + 1), -- one-based, top-relative - -(minimum_placement.b + 1), -- one-based, bottom-relative - false -- default to bottom-relative + override_default_pos.y, + (default_placement.t + 1), -- one-based, top-relative + -(default_placement.b + 1), -- one-based, bottom-relative + padding.t or 0, + padding.b or 0, + default_from_top ) return { @@ -1024,42 +1216,48 @@ local function get_overlay_placement_info(overlay_placement_spec, insets_filter) y = y_pos, }, frame = { - w = math.min(MINIMUM_INTERFACE_SIZE.width, overlay_placement_spec.size.w), - h = math.min(MINIMUM_INTERFACE_SIZE.height, overlay_placement_spec.size.h), + w = default_placement.w, + h = default_placement.h, }, - ---@param self_overlay_widget widgets.Widget + ---@param self_overlay_widget widgets.Widget overlay.OverlayWidget ---@param parent_rect gui.ViewRect preUpdateLayout_fn = function(self_overlay_widget, parent_rect) - local el_frame = overlay_placement_spec.ui_element.frame_fn(parent_rect) - local minimum_el_insets = overlay_placement_spec.ui_element.minimum_insets - local insets = { - l = math.max(0, el_frame.l - minimum_el_insets.l) + l_pad, - r = math.max(0, el_frame.r - minimum_el_insets.r) + r_pad, - t = math.max(0, el_frame.t - minimum_el_insets.t) + t_pad, - b = math.max(0, el_frame.b - minimum_el_insets.b) + b_pad, - } - insets = insets_filter and insets_filter(insets) or insets local placement = place_overlay_frame(parent_rect, overlay_placement_spec) + local inset = { + l = placement.l - default_placement.l + l_pad, + r = placement.r - default_placement.r + r_pad, + t = placement.t - default_placement.t + t_pad, + b = placement.b - default_placement.b + b_pad, + } + inset = inset_filter and inset_filter(inset) or inset - self_overlay_widget.frame_inset = insets - self_overlay_widget.frame.w = insets.l + placement.w + insets.r - self_overlay_widget.frame.h = insets.t + placement.h + insets.b + self_overlay_widget.frame_inset = inset + self_overlay_widget.frame.w = inset.l + placement.w + inset.r + self_overlay_widget.frame.h = inset.t + placement.h + inset.b + end, + ---@param self_overlay_widget widgets.Widget overlay.OverlayWidget + ---@param painter gui.Painter + onRenderBody_fn = function(self_overlay_widget, painter) + if state_changed(self_overlay_widget, overlay_placement_spec.ui_element) then + self_overlay_widget.frame.w = nil -- the overlay system will notice the change and call updateLayout + end end, } end -- Return a table with values that can be used to automatically place an -- overlay widget relative to a reference position. +-- ---@param overlay_placement_spec DFLayout.Placement.Spec ---@return DFLayout.OverlayPlacementInfo overlay_placement_info function getOverlayPlacementInfo(overlay_placement_spec) return get_overlay_placement_info(overlay_placement_spec) end ----@type DFLayout.Placement.InsetsFilter -local function only_left_inset(insets) +---@type DFLayout.Placement.InsetFilter +local function only_left_inset(inset) return { - l = insets.l, + l = inset.l, r = 0, t = 0, b = 0, @@ -1068,6 +1266,7 @@ end -- Similar to `getOverlayPlacementInfo`, but only arranges for "padding" on the -- left. This is compatible with several existing, hand-rolled overlay -- positioning calculations. +-- ---@param overlay_placement_spec DFLayout.Placement.Spec ---@return DFLayout.OverlayPlacementInfo overlay_placement_info function getLeftOnlyOverlayPlacementInfo(overlay_placement_spec) diff --git a/test/library/gui/dflayout.lua b/test/library/gui/dflayout.lua index dd3ab53925..45503ecbbd 100644 --- a/test/library/gui/dflayout.lua +++ b/test/library/gui/dflayout.lua @@ -314,8 +314,8 @@ local function get_centered_ui_el(size) end ---@type DFLayout.DynamicUIElement return { + name = 'Centered UI element for testing', frame_fn = frame_fn, - minimum_insets = frame_fn(layout.MINIMUM_INTERFACE_SIZE), } end @@ -340,6 +340,7 @@ function test.overlay_placement_info_alignments() for _, va in ipairs(vas) do ---@type DFLayout.Placement.Spec local spec = { + name = 'overlay placement spec for .{h,v}_placement testing', size = { w = 3, h = 3 }, ui_element = el, h_placement = ha.h, @@ -367,6 +368,7 @@ end function test.overlay_placement_info_offset() ---@type DFLayout.Placement.Spec local base_spec = { + name = 'overlay placement spec for .offset testing', size = { w = 3, h = 3 }, ui_element = get_centered_ui_el{ w = 5, h = 5 }, h_placement = 'on left', @@ -397,6 +399,7 @@ function test.overlay_placement_info_default_pos() local function spec(dp) ---@type DFLayout.Placement.Spec local new_spec = { + name = 'overlay placement spec for .default_pos testing', size = { w = 3, h = 3 }, ui_element = el, h_placement = 'align left edges', @@ -481,6 +484,37 @@ function test.overlay_placement_info_default_pos() } end +-- test default_pos anchor specification without specific values +function test.overlay_placement_info_default_pos_corner() + local el = get_centered_ui_el{ w = 5, h = 5 } + local function spec(dp) + ---@type DFLayout.Placement.Spec + local new_spec = { + name = 'overlay placement spec for .default_pos testing', + size = { w = 3, h = 3 }, + ui_element = el, + h_placement = 'on right', + v_placement = 'below', + } + new_spec.default_pos = dp + return new_spec + end + + for _, t in ipairs{ + { right = false, top = false, x = 60, y = -18 }, + { right = true, top = false, x = -53, y = -18 }, + { right = true, top = true, x = -53, y = 27 }, + { right = false, top = true, x = 60, y = 27 }, + } do + local placement = layout.getOverlayPlacementInfo(spec{ + from_right = t.right, + from_top = t.top, + }) + local corner = (t.top and 'U' or 'L') .. (t.right and 'R' or 'L') + expect.table_eq(placement.default_pos, { x = t.x, y = t.y }, corner .. '-anchored default_pos') + end +end + -- test positioning that would nominal hang off the edge function test.overlay_placement_info_spanning_edge() local function test_placements(gap, h_specs, v_specs) @@ -492,6 +526,7 @@ function test.overlay_placement_info_spanning_edge() for _, v_spec in ipairs(v_specs) do ---@type DFLayout.Placement.Spec local spec = { + name = 'overlay placement spec testing near edges', size = { w = 10, h = 10 }, ui_element = el, h_placement = h_spec.placement, @@ -536,6 +571,7 @@ end -- oversized placement is limited to layout.MINIMUM_INTERFACE_SIZE function test.overlay_placement_info_oversized() local placement = layout.getOverlayPlacementInfo{ + name = 'overlay placement spec for oversized testing', size = { w= MIN_INTERFACE.width + 1, h = MIN_INTERFACE.height + 1 }, ui_element = layout.elements.fort.toolbars.left, h_placement = 'align right edges', @@ -559,6 +595,7 @@ function test.overlay_placement_info_DIG_button() -- horizontal placement ---@type DFLayout.Placement.Spec local spec = { + name = 'overlay placement spec for DIG.DIG_MODE_ALL testing', size = { w = 26, h = 11 }, ui_element = layout.elements.fort.secondary_toolbar_buttons.DIG.DIG_MODE_ALL, h_placement = 'align left edges', @@ -631,6 +668,7 @@ end function test.overlay_placement_info_ERASE_toolbar() ---@type DFLayout.Placement.Spec local spec = { + name = 'overlay placement spec for ERASE toolbar testing', size = { w = 26, h = 10 }, ui_element = layout.elements.fort.secondary_toolbars.ERASE, h_placement = 'on right', @@ -702,3 +740,355 @@ function test.overlay_placement_info_ERASE_toolbar() h = min_inset.t + spec.size.h + min_inset.b, }, 'frame w/h should be populated') end + +--- Test the experimental overlay helper: overlay_experimental_placement_info_* --- + +function test.overlay_experimental_placement_info_orders_list_element_positions() + for _, t in ipairs{ + -- no scrollbar, wrapped info tab bar + { w = 114, h = 46, c = 9, p = { l = 6, w = 73, r = 35, t = 10, h = 27, b = 9 } }, + { w = 114, h = 47, c = 9, p = { l = 6, w = 73, r = 35, t = 10, h = 27, b = 10 } }, + { w = 114, h = 48, c = 9, p = { l = 6, w = 73, r = 35, t = 10, h = 27, b = 11 } }, + { w = 114, h = 49, c = 10, p = { l = 6, w = 73, r = 35, t = 10, h = 30, b = 9 } }, + + -- no scrollbar, unwrapped info tab bar + { w = 155, h = 46, c = 9, p = { l = 6, w = 114, r = 35, t = 8, h = 27, b = 11 } }, + { w = 155, h = 47, c = 10, p = { l = 6, w = 114, r = 35, t = 8, h = 30, b = 9 } }, + { w = 155, h = 48, c = 10, p = { l = 6, w = 114, r = 35, t = 8, h = 30, b = 10 } }, + { w = 155, h = 49, c = 10, p = { l = 6, w = 114, r = 35, t = 8, h = 30, b = 11 } }, + { w = 155, h = 50, c = 11, p = { l = 6, w = 114, r = 35, t = 8, h = 33, b = 9 } }, + + -- with scrollbar, wrapped info tab bar + { w = 114, h = 46, c = 10, p = { l = 6, w = 71, r = 37, t = 10, h = 27, b = 9 } }, + { w = 114, h = 47, c = 10, p = { l = 6, w = 71, r = 37, t = 10, h = 27, b = 10 } }, + { w = 114, h = 48, c = 10, p = { l = 6, w = 71, r = 37, t = 10, h = 27, b = 11 } }, + { w = 114, h = 49, c = 11, p = { l = 6, w = 71, r = 37, t = 10, h = 30, b = 9 } }, + + -- with scrollbar, unwrapped info tab bar + { w = 155, h = 46, c = 10, p = { l = 6, w = 112, r = 37, t = 8, h = 27, b = 11 } }, + { w = 155, h = 47, c = 11, p = { l = 6, w = 112, r = 37, t = 8, h = 30, b = 9 } }, + { w = 155, h = 48, c = 11, p = { l = 6, w = 112, r = 37, t = 8, h = 30, b = 10 } }, + { w = 155, h = 49, c = 11, p = { l = 6, w = 112, r = 37, t = 8, h = 30, b = 11 } }, + { w = 155, h = 50, c = 12, p = { l = 6, w = 112, r = 37, t = 8, h = 33, b = 9 } }, + } do + local f = layout.experimental_elements.orders.frame_fn({width = t.w, height = t.h}, { + orders_needs_scrollbar = function(n) + expect.eq(n, t.p.h // 3, 'displayable row count') + return t.c > n + end, + }) + expect.table_eq(f, t.p, ('%dx%d w/%d orders'):format(t.w, t.h, t.c)) + end +end + +function test.overlay_experimental_placement_info_zones_list_element_positions() + for _, t in ipairs{ + -- no scrollbar, wrapped info tab bar + { w = 114, h = 46, c = 9, p = { l = 6, w = 78, r = 30, t = 12, h = 27, b = 7 } }, + { w = 114, h = 47, c = 10, p = { l = 6, w = 78, r = 30, t = 12, h = 30, b = 5 } }, + { w = 114, h = 48, c = 10, p = { l = 6, w = 78, r = 30, t = 12, h = 30, b = 6 } }, + { w = 114, h = 49, c = 10, p = { l = 6, w = 78, r = 30, t = 12, h = 30, b = 7 } }, + { w = 114, h = 50, c = 11, p = { l = 6, w = 78, r = 30, t = 12, h = 33, b = 5 } }, + + -- no scrollbar, unwrapped info tab bar + { w = 155, h = 46, c = 10, p = { l = 6, w = 119, r = 30, t = 10, h = 30, b = 6 } }, + { w = 155, h = 47, c = 10, p = { l = 6, w = 119, r = 30, t = 10, h = 30, b = 7 } }, + { w = 155, h = 48, c = 11, p = { l = 6, w = 119, r = 30, t = 10, h = 33, b = 5 } }, + { w = 155, h = 49, c = 11, p = { l = 6, w = 119, r = 30, t = 10, h = 33, b = 6 } }, + { w = 155, h = 50, c = 11, p = { l = 6, w = 119, r = 30, t = 10, h = 33, b = 7 } }, + { w = 155, h = 51, c = 12, p = { l = 6, w = 119, r = 30, t = 10, h = 36, b = 5 } }, + + -- with scrollbar, wrapped info tab bar + { w = 114, h = 46, c = 10, p = { l = 6, w = 76, r = 32, t = 12, h = 27, b = 7 } }, + { w = 114, h = 47, c = 11, p = { l = 6, w = 76, r = 32, t = 12, h = 30, b = 5 } }, + { w = 114, h = 48, c = 11, p = { l = 6, w = 76, r = 32, t = 12, h = 30, b = 6 } }, + { w = 114, h = 49, c = 11, p = { l = 6, w = 76, r = 32, t = 12, h = 30, b = 7 } }, + { w = 114, h = 50, c = 12, p = { l = 6, w = 76, r = 32, t = 12, h = 33, b = 5 } }, + + -- with scrollbar, unwrapped info tab bar + { w = 155, h = 46, c = 11, p = { l = 6, w = 117, r = 32, t = 10, h = 30, b = 6 } }, + { w = 155, h = 47, c = 11, p = { l = 6, w = 117, r = 32, t = 10, h = 30, b = 7 } }, + { w = 155, h = 48, c = 12, p = { l = 6, w = 117, r = 32, t = 10, h = 33, b = 5 } }, + { w = 155, h = 49, c = 12, p = { l = 6, w = 117, r = 32, t = 10, h = 33, b = 6 } }, + { w = 155, h = 50, c = 12, p = { l = 6, w = 117, r = 32, t = 10, h = 33, b = 7 } }, + { w = 155, h = 51, c = 13, p = { l = 6, w = 117, r = 32, t = 10, h = 36, b = 5 } }, + } do + local f = layout.experimental_elements.zones.frame_fn({width = t.w, height = t.h}, { + zones_needs_scrollbar = function(n) + expect.eq(n, t.p.h // 3, 'displayable row count') + return t.c > n + end, + }) + expect.table_eq(f, t.p, ('%dx%d w/%d zones'):format(t.w, t.h, t.c)) + end +end + +local function fully_place(interface_size, frame) + expect.true_(frame.w, 'should have .w') + expect.true_(frame.h, 'should have .h') + local l, r, t, b = frame.l, frame.r, frame.t, frame.b + if l == nil and r ~= nil then + l = interface_size.width - (frame.w + r) + end + if r == nil and l ~= nil then + r = interface_size.width - (l + frame.w) + end + if t == nil and b ~= nil then + t = interface_size.height - (frame.h + b) + end + if b == nil and t ~= nil then + b = interface_size.height - (t + frame.h) + end + return { + l = l, w = frame.w, r = r, + t = t, h = frame.h, b = b, + } +end + +local function apply_insets(interface_size, frame, frame_inset) + frame = fully_place(interface_size, frame) + return { + l = frame.l + frame_inset.l, + r = frame.r + frame_inset.r, + w = frame.w - (frame_inset.l + frame_inset.r), + t = frame.t + frame_inset.t, + b = frame.b + frame_inset.b, + h = frame.h - (frame_inset.t + frame_inset.b), + } +end + +local function corner_name(left, top) + if left then + if top then + return 'UL' + else + return 'LL' + end + else + if top then + return 'UR' + else + return 'LR' + end + end +end + +local function check_corner(name, size, placement, fields) + return function(interface_size, el_f, c) + local frame = { + w = placement.frame.w, + h = placement.frame.h, + } + if placement.default_pos.x > 0 then + frame.l = placement.default_pos.x - 1 + else + frame.r = -placement.default_pos.x - 1 + end + if placement.default_pos.y > 0 then + frame.t = placement.default_pos.y - 1 + else + frame.b = -placement.default_pos.y - 1 + end + local fake_widget = { frame = frame } + placement.preUpdateLayout_fn(fake_widget, interface_size) + local frame_inset = fake_widget.frame_inset + for _, d in ipairs{ 'l', 'r', 't', 'b' } do + expect.ge(frame_inset[d], 0, c .. ' ' .. name .. ' .' .. d .. ' should be non-negative') + end + + local base = { w = size.w, h = size.h } + for _, f in ipairs(fields) do + base[f] = el_f[f] + end + expect.table_eq( + apply_insets(interface_size, frame, frame_inset), + fully_place(interface_size, base), + name .. ' corner: ' .. c) + end +end + +function test.overlay_experimental_placement_info_orders_list() + local orders = layout.experimental_elements.orders + local size = { w = 2, h = 2 } + local item_count + local feature_tests = { + orders_needs_scrollbar = function(n) + return item_count > n + end, + } + + local function placement(overlay_left, overlay_top, from_right, from_top) + local corner = corner_name(overlay_left, overlay_top) + local anchor = corner_name(not from_right, from_top) + ---@type DFLayout.Placement.HorizontalAlignment + local hp = overlay_left and 'align left edges' or 'align right edges' + ---@type DFLayout.Placement.VerticalAlignment + local vp = overlay_top and 'align top edges' or 'align bottom edges' + local placement = layout.getOverlayPlacementInfo{ + name = anchor .. '-anchored overlay placement spec for Orders list (' .. corner .. ') testing', + size = size, + ui_element = orders, + h_placement = hp, + v_placement = vp, + default_pos = { + from_right = from_right, + from_top = from_top, + }, + feature_tests = feature_tests, + } + return placement + end + + for _, from in ipairs{ + { right = false, top = false }, -- overlay positioned from left, bottom + { right = false, top = true }, -- overlay positioned from left, top + { right = true, top = true }, -- overlay positioned from right, top + { right = true, top = false }, -- overlay positioned from right, bottom + } do + local anchor_corner = corner_name(not from.right, from.top) .. '-anchored' + + local UL = placement(true, true, from.right, from.top) + local UR = placement(false, true, from.right, from.top) + local LR = placement(false, false, from.right, from.top) + local LL = placement(true, false, from.right, from.top) + + -- check default_pos + + local check_UL = check_corner(anchor_corner .. ' UL', size, UL, {'l', 't'}) + local check_UR = check_corner(anchor_corner .. ' UR', size, UR, {'r', 't'}) + local check_LR = check_corner(anchor_corner .. ' LR', size, LR, {'r', 'b'}) + local check_LL = check_corner(anchor_corner .. ' LL', size, LL, {'l', 'b'}) + + for _, t in ipairs{ + -- no scrollbar, wrapped info tab bar + { w = 114, h = 46, c = 9 }, + { w = 114, h = 47, c = 9 }, + { w = 114, h = 48, c = 9 }, + { w = 114, h = 49, c = 10 }, + + -- no scrollbar, unwrapped info tab bar + { w = 155, h = 46, c = 9 }, + { w = 155, h = 47, c = 10 }, + { w = 155, h = 48, c = 10 }, + { w = 155, h = 49, c = 10 }, + { w = 155, h = 50, c = 11 }, + + -- with scrollbar, wrapped info tab bar + { w = 114, h = 46, c = 10 }, + { w = 114, h = 47, c = 10 }, + { w = 114, h = 48, c = 10 }, + { w = 114, h = 49, c = 11 }, + + -- with scrollbar, unwrapped info tab bar + { w = 155, h = 46, c = 10 }, + { w = 155, h = 47, c = 11 }, + { w = 155, h = 48, c = 11 }, + { w = 155, h = 49, c = 11 }, + { w = 155, h = 50, c = 12 }, + } do + local interface_size = { width = t.w, height = t.h } + local c = ('%dx%d w/%d orders'):format(t.w, t.h, t.c) + item_count = t.c + local el_f = orders.frame_fn(interface_size, feature_tests) + check_UL(interface_size, el_f, c) + check_UR(interface_size, el_f, c) + check_LR(interface_size, el_f, c) + check_LL(interface_size, el_f, c) + end + end +end + +function test.overlay_experimental_placement_info_zones_list() + local zones = layout.experimental_elements.zones + local size = { w = 2, h = 2 } + local item_count + local feature_tests = { + zones_needs_scrollbar = function(n) + return item_count > n + end + } + + local function placement(overlay_left, overlay_top, from_right, from_top) + local corner = corner_name(overlay_left, overlay_top) + local anchor = corner_name(not from_right, from_top) + ---@type DFLayout.Placement.HorizontalAlignment + local hp = overlay_left and 'align left edges' or 'align right edges' + ---@type DFLayout.Placement.VerticalAlignment + local vp = overlay_top and 'align top edges' or 'align bottom edges' + local placement = layout.getOverlayPlacementInfo{ + name = anchor .. '-anchored overlay placement spec for Places/Zones list (' .. corner .. ') testing', + size = size, + ui_element = zones, + h_placement = hp, + v_placement = vp, + default_pos = { + from_right = from_right, + from_top = from_top, + }, + feature_tests = feature_tests, + } + return placement + end + + for _, from in ipairs{ + { right = false, top = false }, -- overlay positioned from left, bottom + { right = false, top = true }, -- overlay positioned from left, top + { right = true, top = true }, -- overlay positioned from right, top + { right = true, top = false }, -- overlay positioned from right, bottom + } do + local anchor_corner = corner_name(not from.right, from.top) .. '-anchored' + + local UL = placement(true, true, from.right, from.top) + local UR = placement(false, true, from.right, from.top) + local LR = placement(false, false, from.right, from.top) + local LL = placement(true, false, from.right, from.top) + + -- check default_pos + + local check_UL = check_corner(anchor_corner .. ' UL', size, UL, {'l', 't'}) + local check_UR = check_corner(anchor_corner .. ' UR', size, UR, {'r', 't'}) + local check_LR = check_corner(anchor_corner .. ' LR', size, LR, {'r', 'b'}) + local check_LL = check_corner(anchor_corner .. ' LL', size, LL, {'l', 'b'}) + + for _, t in ipairs{ + -- no scrollbar, wrapped info tab bar + { w = 114, h = 46, c = 9 }, + { w = 114, h = 47, c = 10 }, + { w = 114, h = 48, c = 10 }, + { w = 114, h = 49, c = 10 }, + { w = 114, h = 50, c = 11 }, + + -- no scrollbar, unwrapped info tab bar + { w = 155, h = 46, c = 10 }, + { w = 155, h = 47, c = 10 }, + { w = 155, h = 48, c = 11 }, + { w = 155, h = 49, c = 11 }, + { w = 155, h = 50, c = 11 }, + { w = 155, h = 51, c = 12 }, + + -- with scrollbar, wrapped info tab bar + { w = 114, h = 46, c = 10 }, + { w = 114, h = 47, c = 11 }, + { w = 114, h = 48, c = 11 }, + { w = 114, h = 49, c = 11 }, + { w = 114, h = 50, c = 12 }, + + -- with scrollbar, unwrapped info tab bar + { w = 155, h = 46, c = 11 }, + { w = 155, h = 47, c = 11 }, + { w = 155, h = 48, c = 12 }, + { w = 155, h = 49, c = 12 }, + { w = 155, h = 50, c = 12 }, + { w = 155, h = 51, c = 13 }, + } do + local interface_size = { width = t.w, height = t.h } + local c = ('%dx%d w/%d zones'):format(t.w, t.h, t.c) + item_count = t.c + local el_f = zones.frame_fn(interface_size, feature_tests) + check_UL(interface_size, el_f, c) + check_UR(interface_size, el_f, c) + check_LR(interface_size, el_f, c) + check_LL(interface_size, el_f, c) + end + end +end From 4f62e28f94944f6164205e71ac5f1b55926460d3 Mon Sep 17 00:00:00 2001 From: Chris Johnsen Date: Wed, 30 Apr 2025 02:05:33 -0500 Subject: [PATCH 08/10] gui.dflayout: export getUIElementFrame and getRelativePlacement The first is the previously-internal wrapper around calling `.frame_fn`. This should help with callers treating the UI element values as opaque, since `frame_fn` was the most interesting bit there. The second is the previously-internal "relative positioning" function used in `getOverlayPlacementInfo`. It should be useful in other contexts that would like to do element-relative position calculations, but without the extras stuff that overlay-positioning requires. --- library/lua/gui/dflayout.lua | 113 +++++++------- test/library/gui/dflayout.lua | 284 ++++++++++++++++++++-------------- 2 files changed, 225 insertions(+), 172 deletions(-) diff --git a/library/lua/gui/dflayout.lua b/library/lua/gui/dflayout.lua index 889e33309f..6de3662997 100644 --- a/library/lua/gui/dflayout.lua +++ b/library/lua/gui/dflayout.lua @@ -121,9 +121,11 @@ end ---@field h_placement DFLayout.Placement.HorizontalAlignment how to align the overlay's horizontal position against the `ui_element` ---@field v_placement DFLayout.Placement.VerticalAlignment how to align the overlay's vertical position against the `ui_element` ---@field offset? DFLayout.Placement.Offset how far to move overlay after alignment with `ui_element` ----@field default_pos? DFLayout.Placement.DefaultPos specify an overlay default_pos, or which edges it should be based on ---@field feature_tests? DFLayout.FrameFn.FeatureTests for internal/testing use +---@class DFLayout.OverlayPlacement.Spec: DFLayout.Placement.Spec +---@field default_pos? DFLayout.Placement.DefaultPos specify an overlay default_pos, or which edges it should be based on + ---@alias DFLayout.Placement.GenericAlignment 'place before' | 'align start' | 'align end' | 'place after' ---@class DFLayout.OverlayPlacementInfo @@ -971,7 +973,48 @@ experimental_elements = { zones = zones_ui_element, } ---- Automatic UI-relative Overlay Positioning --- +default_feature_tests = { + orders_needs_scrollbar = function(displayable_count) + return #df.global.world.manager_orders.all > displayable_count + end, + zones_needs_scrollbar = function(displayable_count) + return + #df.global.game.main_interface.info.buildings.list[df.buildings_mode_type.ZONES] + > displayable_count + end, +} + +--- UI Element Position and Relative Placement --- + +-- Returns a "fully placed frame" for the `ui_element` in an interface area of +-- `interface_size`. +-- +-- Throws an error if the computed frame does not span the `interface_size`, or +-- if it has negative inset values. +-- +---@param ui_element DFLayout.DynamicUIElement +---@param interface_size DFLayout.Interface.Size +---@param feature_tests? DFLayout.FrameFn.FeatureTests for internal/test use +---@return DFLayout.Frame +function getUIElementFrame(ui_element, interface_size, feature_tests) + local frame = ui_element.frame_fn(interface_size, + feature_tests -- getRelativePlacement + or default_feature_tests) + + if frame.l < 0 or frame.w <= 0 or frame.r < 0 + or frame.l + frame.w + frame.r ~= interface_size.width + then + dfhack.error(('%s: horizontal placement is invalid: l=%d w=%d r=%d W=%d') + :format(ui_element.name, frame.l, frame.w, frame.r, interface_size.width)) + end + if frame.t < 0 or frame.h <= 0 or frame.b < 0 + or frame.t + frame.h + frame.b ~= interface_size.height + then + dfhack.error(('%s: vertical placement is invalid: t=%d h=%d b=%d H=%d') + :format(ui_element.name, frame.t, frame.h, frame.b, interface_size.height)) + end + return frame +end -- Place a specified span (width or height) with the specified alignment with -- respect to the given reference position and span. @@ -1010,44 +1053,6 @@ local function place_span(available_span, ref_offset_before, ref_offset_after, p return before, placed_span, after end --- Runs `frame_fn(interface_size)` and checks the resulting frame for "sanity" --- (non-negative paddings, positive sizes, paddings and sizes span --- `interface_size`). --- --- Returns the frame or throws an error. --- ----@param interface_size DFLayout.Interface.Size ----@param ui_element DFLayout.DynamicUIElement ----@param feature_tests DFLayout.FrameFn.FeatureTests ----@return DFLayout.Frame -local function checked_frame(interface_size, ui_element, feature_tests) - local frame = ui_element.frame_fn(interface_size, feature_tests) - if frame.l < 0 or frame.w <= 0 or frame.r < 0 - or frame.l + frame.w + frame.r ~= interface_size.width - then - dfhack.error(('%s: horizontal placement is invalid: l=%d w=%d r=%d W=%d') - :format(ui_element.name, frame.l, frame.w, frame.r, interface_size.width)) - end - if frame.t < 0 or frame.h <= 0 or frame.b < 0 - or frame.t + frame.h + frame.b ~= interface_size.height - then - dfhack.error(('%s: vertical placement is invalid: t=%d h=%d b=%d H=%d') - :format(ui_element.name, frame.t, frame.h, frame.b, interface_size.height)) - end - return frame -end - -local default_feature_tests = { - orders_needs_scrollbar = function(displayable_count) - return #df.global.world.manager_orders.all > displayable_count - end, - zones_needs_scrollbar = function(displayable_count) - return - #df.global.game.main_interface.info.buildings.list[df.buildings_mode_type.ZONES] - > displayable_count - end, -} - local generic_placement_from_horizontal = { ['on left'] = 'place before', ['align left edges'] = 'align start', @@ -1064,14 +1069,14 @@ local generic_placement_from_vertical = { -- Place the specified area with respect to the specified reference with the -- specified alignment and offset. -- ----@param interface_size DFLayout.Interface.Size ---@param spec DFLayout.Placement.Spec ----@param feature_tests? DFLayout.FrameFn.FeatureTests +---@param interface_size DFLayout.Interface.Size +---@param feature_tests? DFLayout.FrameFn.FeatureTests for internal/test use ---@return DFLayout.Frame -local function place_overlay_frame(interface_size, spec, feature_tests) - local ref_inset = checked_frame(interface_size, spec.ui_element, - feature_tests -- get_overlay_placement_info - or spec.feature_tests -- tests +function getRelativePlacement(spec, interface_size, feature_tests) + local ref_inset = getUIElementFrame(spec.ui_element, interface_size, + feature_tests -- get_overlay_placement_info + or spec.feature_tests -- tests or default_feature_tests) local generic_h_placement = generic_placement_from_horizontal[spec.h_placement] @@ -1097,6 +1102,8 @@ local function place_overlay_frame(interface_size, spec, feature_tests) } end +--- Automatic UI-relative Overlay Positioning --- + -- Calculate (or validate the requested) `default_pos` and required paddings -- based on nominal position and forced paddings. -- @@ -1153,7 +1160,7 @@ local function state_changed(widget, ui_element) end ---@type DFLayout.FrameFn.FeatureTests -local all_features = setmetatable({}, { +all_features = setmetatable({}, { __index = function(table, field) return function() return true end end, @@ -1161,15 +1168,15 @@ local all_features = setmetatable({}, { ---@alias DFLayout.Placement.InsetFilter fun(inset: DFLayout.Inset): DFLayout.Inset ----@param overlay_placement_spec DFLayout.Placement.Spec +---@param overlay_placement_spec DFLayout.OverlayPlacement.Spec ---@param inset_filter? DFLayout.Placement.InsetFilter ---@return DFLayout.OverlayPlacementInfo overlay_placement_info local function get_overlay_placement_info(overlay_placement_spec, inset_filter) - overlay_placement_spec = utils.clone(overlay_placement_spec, true) --[[@as DFLayout.Placement.Spec]] + overlay_placement_spec = utils.clone(overlay_placement_spec, true) --[[@as DFLayout.OverlayPlacement.Spec]] overlay_placement_spec.name = overlay_placement_spec.name or '[unnamed overlay placement]' -- get the "default" placement (in a MINIMUM_INTERFACE_SIZE area) - local default_placement = place_overlay_frame(MINIMUM_INTERFACE_SIZE, overlay_placement_spec, all_features) + local default_placement = getRelativePlacement(overlay_placement_spec, MINIMUM_INTERFACE_SIZE, all_features) local default_from_left = true -- default to left-relative local default_from_top = false -- default to bottom-relative @@ -1222,7 +1229,7 @@ local function get_overlay_placement_info(overlay_placement_spec, inset_filter) ---@param self_overlay_widget widgets.Widget overlay.OverlayWidget ---@param parent_rect gui.ViewRect preUpdateLayout_fn = function(self_overlay_widget, parent_rect) - local placement = place_overlay_frame(parent_rect, overlay_placement_spec) + local placement = getRelativePlacement(overlay_placement_spec, parent_rect) local inset = { l = placement.l - default_placement.l + l_pad, r = placement.r - default_placement.r + r_pad, @@ -1248,7 +1255,7 @@ end -- Return a table with values that can be used to automatically place an -- overlay widget relative to a reference position. -- ----@param overlay_placement_spec DFLayout.Placement.Spec +---@param overlay_placement_spec DFLayout.OverlayPlacement.Spec ---@return DFLayout.OverlayPlacementInfo overlay_placement_info function getOverlayPlacementInfo(overlay_placement_spec) return get_overlay_placement_info(overlay_placement_spec) @@ -1267,7 +1274,7 @@ end -- left. This is compatible with several existing, hand-rolled overlay -- positioning calculations. -- ----@param overlay_placement_spec DFLayout.Placement.Spec +---@param overlay_placement_spec DFLayout.OverlayPlacement.Spec ---@return DFLayout.OverlayPlacementInfo overlay_placement_info function getLeftOnlyOverlayPlacementInfo(overlay_placement_spec) return get_overlay_placement_info(overlay_placement_spec, only_left_inset) diff --git a/test/library/gui/dflayout.lua b/test/library/gui/dflayout.lua index 45503ecbbd..211e83f172 100644 --- a/test/library/gui/dflayout.lua +++ b/test/library/gui/dflayout.lua @@ -171,10 +171,10 @@ end function test.fort_left_toolbar_positions() local w = ftb_layouts.left.width - local left_frame = ftb_elements.left.frame_fn for_all_checked_interface_sizes(function(size) local size_str = ('%dx%d'):format(size.width, size.height) - expect_bottom_left_frame(left_frame(size), size, w, layout.TOOLBAR_HEIGHT, size_str) + local frame = layout.getUIElementFrame(ftb_elements.left, size) + expect_bottom_left_frame(frame, size, w, layout.TOOLBAR_HEIGHT, size_str) end) end @@ -191,10 +191,10 @@ end function test.fort_right_toolbar_positions() local w = ftb_layouts.right.width - local right_frame = ftb_elements.right.frame_fn for_all_checked_interface_sizes(function(size) local size_str = ('%dx%d'):format(size.width, size.height) - expect_bottom_right_frame(right_frame(size), size, w, layout.TOOLBAR_HEIGHT, size_str) + local frame = layout.getUIElementFrame(ftb_elements.right, size) + expect_bottom_right_frame(frame, size, w, layout.TOOLBAR_HEIGHT, size_str) end) end @@ -211,11 +211,11 @@ end function test.fort_center_toolbar_positions() local w = ftb_layouts.center.width - local center_frame = ftb_elements.center.frame_fn for_all_checked_interface_sizes(function(size) local size_str = ('%dx%d'):format(size.width, size.height) local expected_l = phased_offset(size.width, fort_center_phases) - expect_bottom_center_frame(center_frame(size), size, w, layout.TOOLBAR_HEIGHT, expected_l, size_str) + local frame = layout.getUIElementFrame(ftb_elements.center, size) + expect_bottom_center_frame(frame, size, w, layout.TOOLBAR_HEIGHT, expected_l, size_str) end) end @@ -233,11 +233,11 @@ end for _, phases in ipairs(fort_center_secondary_phases) do local name = phases.name local w = layout.element_layouts.fort.secondary_toolbars[name].width - local frame = layout.elements.fort.secondary_toolbars[name].frame_fn + local el = layout.elements.fort.secondary_toolbars[name] test[('fort_secondary_%s_toolbar_positions'):format(name)] = function() for_all_checked_interface_sizes(function(size) expect_center_secondary_frame( - frame(size), size, + layout.getUIElementFrame(el, size), size, w, layout.SECONDARY_TOOLBAR_HEIGHT, phased_offset(size.width, phases), ('%s: %dx%d'):format(name, size.width, size.height)) @@ -272,7 +272,7 @@ for _, toolbar in ipairs{ local button_els = safe_index(layout.elements, table.unpack(buttons_path)) test[('fort_%s_toolbar_button_positions'):format(toolbar_name)] = function() for_all_checked_interface_sizes(function(size) - local toolbar_frame = toolbar_el.frame_fn(size) + local toolbar_frame = layout.getUIElementFrame(toolbar_el, size) local function c(b, d) return ('%s %s: %dx%d'):format(b, d, size.width, size.height) end @@ -280,7 +280,7 @@ for _, toolbar in ipairs{ local button_el = button_els[button_name] expect.true_(button_el, c(button_name, 'element should exist')) if button_el then - local frame = button_el.frame_fn(size) + local frame = layout.getUIElementFrame(button_el, size) local expected_l = toolbar_frame.l + button_spec.offset local expected_r = toolbar_frame.w - (button_spec.offset + button_spec.width) + toolbar_frame.r expect.eq(frame.w, button_spec.width, c(button_name, 'w')) @@ -295,9 +295,8 @@ for _, toolbar in ipairs{ end end ---- Test the overlay helper: overlay_placement_info_* --- +--- Test relative placement: element_based_placement_* --- --- 5x5 in the center of the interface area local function get_centered_ui_el(size) local function frame_fn(interface_size) local l = (interface_size.width - size.w) // 2 @@ -320,73 +319,166 @@ local function get_centered_ui_el(size) end -- test alignment specification -function test.overlay_placement_info_alignments() - ---@type { h: DFLayout.Placement.HorizontalAlignment, x: integer }[] +function test.element_based_placement_alignments() + ---@type { h: DFLayout.Placement.HorizontalAlignment, l: integer, r: integer }[] local has = { - { h = 'on left', x = 52 }, - { h = 'align left edges', x = 55 }, - { h = 'align right edges', x = 57 }, - { h = 'on right', x = 60 }, + { h = 'on left', l = 51, r = 60 }, + { h = 'align left edges', l = 54, r = 57 }, + { h = 'align right edges', l = 56, r = 55 }, + { h = 'on right', l = 59, r = 52 }, } - ---@type { v: DFLayout.Placement.VerticalAlignment, y: integer }[] + ---@type { v: DFLayout.Placement.VerticalAlignment, t: integer, b: integer }[] local vas = { - { v = 'above', y = -26 }, - { v = 'align top edges', y = -23 }, - { v = 'align bottom edges', y = -21 }, - { v = 'below', y = -18 }, + { v = 'above', t = 18, b = 25 }, + { v = 'align top edges', t = 21, b = 22 }, + { v = 'align bottom edges', t = 23, b = 20 }, + { v = 'below', t = 26, b = 17 }, } local el = get_centered_ui_el{ w = 5, h = 5 } for _, ha in ipairs(has) do for _, va in ipairs(vas) do ---@type DFLayout.Placement.Spec local spec = { - name = 'overlay placement spec for .{h,v}_placement testing', + name = 'placement spec for .{h,v}_placement testing', size = { w = 3, h = 3 }, ui_element = el, h_placement = ha.h, v_placement = va.v, } - local placement = layout.getOverlayPlacementInfo(spec) - expect.table_eq( - placement.default_pos, - { x = ha.x, y = va.y }, - ha.h .. ', ' .. va.v .. ' default_pos') + local placement = layout.getRelativePlacement(spec, MIN_INTERFACE) + expect.table_eq(placement, { + l = ha.l, + w = 3, + r = ha.r, + + t = va.t, + h = 3, + b = va.b, + }, ha.h .. ', ' .. va.v .. ' placement') end end end -local function sum(...) - local s = { x = 0, y = 0 } - for _, v in ipairs({...}) do - s.x = s.x + (v.x or 0) - s.y = s.y + (v.y or 0) - end - return s -end - -- test offset specification -function test.overlay_placement_info_offset() - ---@type DFLayout.Placement.Spec +function test.element_based_placement_offset() + ---@type DFLayout.OverlayPlacement.Spec local base_spec = { - name = 'overlay placement spec for .offset testing', + name = 'placement spec for .offset testing', size = { w = 3, h = 3 }, ui_element = get_centered_ui_el{ w = 5, h = 5 }, h_placement = 'on left', v_placement = 'above', } - local base_placement = layout.getOverlayPlacementInfo(base_spec) + local base_placement = layout.getRelativePlacement(base_spec, MIN_INTERFACE) for _, dx in ipairs{ -1, 0, 2 } do for _, dy in ipairs{ -3, 0, 4 } do local spec = copyall(base_spec) spec.offset = { x = dx, y = dy } - local placement = layout.getLeftOnlyOverlayPlacementInfo(spec) - expect.table_eq( - placement.default_pos, - sum(base_placement.default_pos, spec.offset), - 'default_pos should incorporate offset') + local placement = layout.getRelativePlacement(spec, MIN_INTERFACE) + expect.table_eq(placement, { + l = base_placement.l + dx, + w = base_placement.w, + r = base_placement.r - dx, + + t = base_placement.t + dy, + h = base_placement.h, + b = base_placement.b - dy, + }, ('(%d,%d) offset placement'):format(dx, dy)) + end + end +end + +-- test positioning that would nominal hang off the edge +function test.element_based_placement_spanning_edge() + local function test_placements(gap, h_specs, v_specs) + local el = get_centered_ui_el{ + w = MIN_INTERFACE.width - 2 * gap, + h = MIN_INTERFACE.height - 2 * gap, + } + for _, h_spec in ipairs(h_specs) do + for _, v_spec in ipairs(v_specs) do + ---@type DFLayout.OverlayPlacement.Spec + local spec = { + name = 'overlay placement spec testing near edges', + size = { w = 10, h = 10 }, + ui_element = el, + h_placement = h_spec.placement, + v_placement = v_spec.placement, + } + local placement = layout.getRelativePlacement(spec, MIN_INTERFACE) + expect.table_eq(placement, { + l = h_spec.l, + w = 10, + r = h_spec.r, + + t = v_spec.t, + h = 10, + b = v_spec.b, + }, ('%d gap %s, %s placement'):format(gap, h_spec.placement, v_spec.placement)) + end end end + + -- if the gaps are not greater than 10, the 10x10 is placed in the corners + + ---@type { h: DFLayout.Placement.HorizontalAlignment, l: integer, r: integer }[] + local corner_h_placement = { + { placement = 'on left', l = 0, r = 104 }, + { placement = 'on right', l = 104, r = 0 }, + } + ---@type { v: DFLayout.Placement.VerticalAlignment, t: integer, b: integer }[] + local corner_v_placement = { + { placement = 'above', t = 0, b = 36 }, + { placement = 'below', t = 36, b = 0 }, + } + test_placements(0, corner_h_placement, corner_v_placement) + test_placements(5, corner_h_placement, corner_v_placement) + test_placements(10, corner_h_placement, corner_v_placement) + + -- once there is extra room, the 10x10 can move away from the corners + + test_placements(11, { + { placement = 'on left', l = 1, r = 103 }, + { placement = 'on right', l = 103, r = 1 }, + }, { + { placement = 'above', t = 1, b = 35 }, + { placement = 'below', t = 35, b = 1 }, + }) +end + +-- oversized placement is limited to layout.MINIMUM_INTERFACE_SIZE +function test.element_based_placement_oversized() + for _, size in ipairs{ MIN_INTERFACE, BIG_PARTIAL_INTERFACE, BIG_INTERFACE } do + local placement = layout.getRelativePlacement({ + name = 'placement spec for oversized testing', + size = { w = size.width + 1, h = size.height + 1 }, + ui_element = layout.elements.fort.toolbars.left, + h_placement = 'align right edges', + v_placement = 'align bottom edges', + offset = { x = 1 }, + }, size) + expect.table_eq(placement, { + l = 0, + w = size.width, + r = 0, + + t = 0, + h = size.height, + b = 0, + }, 'oversize area should be limited to interface size') + end +end + +--- Test the overlay helper: overlay_placement_info_* --- + +local function sum(...) + local s = { x = 0, y = 0 } + for _, v in ipairs({ ... }) do + s.x = s.x + (v.x or 0) + s.y = s.y + (v.y or 0) + end + return s end local function size_to_ViewRect(size) @@ -397,7 +489,7 @@ end function test.overlay_placement_info_default_pos() local el = get_centered_ui_el{ w = 5, h = 5 } local function spec(dp) - ---@type DFLayout.Placement.Spec + ---@type DFLayout.OverlayPlacement.Spec local new_spec = { name = 'overlay placement spec for .default_pos testing', size = { w = 3, h = 3 }, @@ -488,7 +580,7 @@ end function test.overlay_placement_info_default_pos_corner() local el = get_centered_ui_el{ w = 5, h = 5 } local function spec(dp) - ---@type DFLayout.Placement.Spec + ---@type DFLayout.OverlayPlacement.Spec local new_spec = { name = 'overlay placement spec for .default_pos testing', size = { w = 3, h = 3 }, @@ -515,64 +607,11 @@ function test.overlay_placement_info_default_pos_corner() end end --- test positioning that would nominal hang off the edge -function test.overlay_placement_info_spanning_edge() - local function test_placements(gap, h_specs, v_specs) - local el = get_centered_ui_el{ - w = MIN_INTERFACE.width - 2 * gap, - h = MIN_INTERFACE.height - 2 * gap, - } - for _, h_spec in ipairs(h_specs) do - for _, v_spec in ipairs(v_specs) do - ---@type DFLayout.Placement.Spec - local spec = { - name = 'overlay placement spec testing near edges', - size = { w = 10, h = 10 }, - ui_element = el, - h_placement = h_spec.placement, - v_placement = v_spec.placement, - } - local placement = layout.getOverlayPlacementInfo(spec) - expect.table_eq( - placement.default_pos, - { x = h_spec.x, y = v_spec.y }, - ('%d gap %s, %s default_pos'):format(gap, h_spec.placement, v_spec.placement)) - end - end - end - - -- if the gaps are not greater than 10, the 10x10 is placed in the corners - - ---@type { h: DFLayout.Placement.HorizontalAlignment, x: integer }[] - local corner_h_placement = { - { placement = 'on left', x = 1 }, - { placement = 'on right', x = 105 }, - } - ---@type { v: DFLayout.Placement.VerticalAlignment, y: integer }[] - local corner_v_placement = { - { placement = 'above', y = -37 }, - { placement = 'below', y = -1 }, - } - test_placements(0, corner_h_placement, corner_v_placement) - test_placements(5, corner_h_placement, corner_v_placement) - test_placements(10, corner_h_placement, corner_v_placement) - - -- once there is extra room, the 10x10 can move away from the corners - - test_placements(11, { - { placement = 'on left', x = 2 }, - { placement = 'on right', x = 104 }, - }, { - { placement = 'above', y = -36 }, - { placement = 'below', y = -2 }, - }) -end - -- oversized placement is limited to layout.MINIMUM_INTERFACE_SIZE function test.overlay_placement_info_oversized() local placement = layout.getOverlayPlacementInfo{ name = 'overlay placement spec for oversized testing', - size = { w= MIN_INTERFACE.width + 1, h = MIN_INTERFACE.height + 1 }, + size = { w = MIN_INTERFACE.width + 1, h = MIN_INTERFACE.height + 1 }, ui_element = layout.elements.fort.toolbars.left, h_placement = 'align right edges', v_placement = 'align bottom edges', @@ -593,7 +632,7 @@ function test.overlay_placement_info_DIG_button() -- normally, this would be specified as 'on right' of DIG_OPEN_RIGHT without -- an offset; but since this is a test, we are exercising an different -- horizontal placement - ---@type DFLayout.Placement.Spec + ---@type DFLayout.OverlayPlacement.Spec local spec = { name = 'overlay placement spec for DIG.DIG_MODE_ALL testing', size = { w = 26, h = 11 }, @@ -666,7 +705,7 @@ end -- test a "real" positioning with an off-nominal default_pos across multiple interface sizes function test.overlay_placement_info_ERASE_toolbar() - ---@type DFLayout.Placement.Spec + ---@type DFLayout.OverlayPlacement.Spec local spec = { name = 'overlay placement spec for ERASE toolbar testing', size = { w = 26, h = 10 }, @@ -771,7 +810,8 @@ function test.overlay_experimental_placement_info_orders_list_element_positions( { w = 155, h = 49, c = 11, p = { l = 6, w = 112, r = 37, t = 8, h = 30, b = 11 } }, { w = 155, h = 50, c = 12, p = { l = 6, w = 112, r = 37, t = 8, h = 33, b = 9 } }, } do - local f = layout.experimental_elements.orders.frame_fn({width = t.w, height = t.h}, { + local size = { width = t.w, height = t.h } + local f = layout.getUIElementFrame(layout.experimental_elements.orders, size, { orders_needs_scrollbar = function(n) expect.eq(n, t.p.h // 3, 'displayable row count') return t.c > n @@ -813,7 +853,8 @@ function test.overlay_experimental_placement_info_zones_list_element_positions() { w = 155, h = 50, c = 12, p = { l = 6, w = 117, r = 32, t = 10, h = 33, b = 7 } }, { w = 155, h = 51, c = 13, p = { l = 6, w = 117, r = 32, t = 10, h = 36, b = 5 } }, } do - local f = layout.experimental_elements.zones.frame_fn({width = t.w, height = t.h}, { + local size = { width = t.w, height = t.h } + local f = layout.getUIElementFrame(layout.experimental_elements.zones, size, { zones_needs_scrollbar = function(n) expect.eq(n, t.p.h // 3, 'displayable row count') return t.c > n @@ -840,8 +881,13 @@ local function fully_place(interface_size, frame) b = interface_size.height - (t + frame.h) end return { - l = l, w = frame.w, r = r, - t = t, h = frame.h, b = b, + l = l, + w = frame.w, + r = r, + + t = t, + h = frame.h, + b = b, } end @@ -954,10 +1000,10 @@ function test.overlay_experimental_placement_info_orders_list() -- check default_pos - local check_UL = check_corner(anchor_corner .. ' UL', size, UL, {'l', 't'}) - local check_UR = check_corner(anchor_corner .. ' UR', size, UR, {'r', 't'}) - local check_LR = check_corner(anchor_corner .. ' LR', size, LR, {'r', 'b'}) - local check_LL = check_corner(anchor_corner .. ' LL', size, LL, {'l', 'b'}) + local check_UL = check_corner(anchor_corner .. ' UL', size, UL, { 'l', 't' }) + local check_UR = check_corner(anchor_corner .. ' UR', size, UR, { 'r', 't' }) + local check_LR = check_corner(anchor_corner .. ' LR', size, LR, { 'r', 'b' }) + local check_LL = check_corner(anchor_corner .. ' LL', size, LL, { 'l', 'b' }) for _, t in ipairs{ -- no scrollbar, wrapped info tab bar @@ -989,7 +1035,7 @@ function test.overlay_experimental_placement_info_orders_list() local interface_size = { width = t.w, height = t.h } local c = ('%dx%d w/%d orders'):format(t.w, t.h, t.c) item_count = t.c - local el_f = orders.frame_fn(interface_size, feature_tests) + local el_f = layout.getUIElementFrame(orders, interface_size, feature_tests) check_UL(interface_size, el_f, c) check_UR(interface_size, el_f, c) check_LR(interface_size, el_f, c) @@ -1045,10 +1091,10 @@ function test.overlay_experimental_placement_info_zones_list() -- check default_pos - local check_UL = check_corner(anchor_corner .. ' UL', size, UL, {'l', 't'}) - local check_UR = check_corner(anchor_corner .. ' UR', size, UR, {'r', 't'}) - local check_LR = check_corner(anchor_corner .. ' LR', size, LR, {'r', 'b'}) - local check_LL = check_corner(anchor_corner .. ' LL', size, LL, {'l', 'b'}) + local check_UL = check_corner(anchor_corner .. ' UL', size, UL, { 'l', 't' }) + local check_UR = check_corner(anchor_corner .. ' UR', size, UR, { 'r', 't' }) + local check_LR = check_corner(anchor_corner .. ' LR', size, LR, { 'r', 'b' }) + local check_LL = check_corner(anchor_corner .. ' LL', size, LL, { 'l', 'b' }) for _, t in ipairs{ -- no scrollbar, wrapped info tab bar @@ -1084,7 +1130,7 @@ function test.overlay_experimental_placement_info_zones_list() local interface_size = { width = t.w, height = t.h } local c = ('%dx%d w/%d zones'):format(t.w, t.h, t.c) item_count = t.c - local el_f = zones.frame_fn(interface_size, feature_tests) + local el_f = layout.getUIElementFrame(zones, interface_size, feature_tests) check_UL(interface_size, el_f, c) check_UR(interface_size, el_f, c) check_LR(interface_size, el_f, c) From 69e7a9edf8ff6621da97a6cd40be38483518fecb Mon Sep 17 00:00:00 2001 From: Chris Johnsen Date: Wed, 30 Apr 2025 04:45:01 -0500 Subject: [PATCH 09/10] gui.dflayout: getUIElementStateChecker Provide access to the state_fn internal bit of UI elements. --- library/lua/gui/dflayout.lua | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/library/lua/gui/dflayout.lua b/library/lua/gui/dflayout.lua index 6de3662997..bef90bf5fb 100644 --- a/library/lua/gui/dflayout.lua +++ b/library/lua/gui/dflayout.lua @@ -1144,7 +1144,7 @@ local state_changed_cache = setmetatable({}, { __mode = 'k' }) ---@param ui_element DFLayout.DynamicUIElement ---@return boolean local function state_changed(widget, ui_element) - if widget == nil then return end + if widget == nil or ui_element == nil or ui_element.state_fn == nil then return false end local previous = state_changed_cache[widget] local current = ui_element.state_fn() state_changed_cache[widget] = current @@ -1159,6 +1159,30 @@ local function state_changed(widget, ui_element) return false end +-- The positions of some UI elements vary due to influences other than the +-- interface size. This function returns a function (the "checker") that checks +-- whether a UI element's position-influencing state (other than interface size) +-- has changed since the last time the "checker" was called. +-- +-- The returned function should be strongly held somewhere that won't be +-- collected until the caller is no longer interested in the position of the UI +-- element (e.g., a field of a DFHack widget that relies on the UI element's +-- position). +-- +-- When the checker function returns true, the position of the UI element might +-- have changed. It should be recomputed (e.g., `getUIElementFrame`) and used to +-- update whatever depends on the position of the UI element. +-- +---@param ui_element DFLayout.DynamicUIElement +---@return fun(): boolean +function getUIElementStateChecker(ui_element) + if ui_element == nil or ui_element.state_fn == nil then + return function() return false end + end + local cache_key = {} -- unique value, strongly held by the closure, so be sure to strongly hold the returned closure + return curry(state_changed, cache_key, ui_element) +end + ---@type DFLayout.FrameFn.FeatureTests all_features = setmetatable({}, { __index = function(table, field) From e92023623db09d542f58cbe5d6ac135d4b52d806 Mon Sep 17 00:00:00 2001 From: Chris Johnsen Date: Wed, 30 Apr 2025 06:35:41 -0500 Subject: [PATCH 10/10] update docs for gui.dflayout additions --- docs/dev/Lua API.rst | 145 +++++++++++++++++++++++++-------- docs/dev/overlay-dev-guide.rst | 8 +- 2 files changed, 115 insertions(+), 38 deletions(-) diff --git a/docs/dev/Lua API.rst b/docs/dev/Lua API.rst index 9adc0a0114..b227a5071b 100644 --- a/docs/dev/Lua API.rst +++ b/docs/dev/Lua API.rst @@ -6658,56 +6658,111 @@ for the layout descriptions. These "UI element" values should generally be treated as opaque. They can be passed to the overlay positioning helper functions described below. -Automatic Overlay Positioning ------------------------------ +Position based on UI Element +---------------------------- -This module provides higher-level functions that use the provided "dynamic UI -elements" to help automatically position an overlay widget with respect to the -UI element: +This module provides several functions to work with the provided UI element +values: -* ``getOverlayPlacementInfo(overlay_placement_spec)`` +* ``getUIElementFrame(ui_element, interface_size)`` + + Computes the position of the UI element when drawn in a interface of the + specified size. The ``interface_size`` should have ``width`` and ``height`` + fields (e.g., ``gui.get_interface_rect()``, or the ``parent_rect`` parameter + from the ``updateLayout`` family of methods). + + Note: Some UI elements may need to query DF state beyond the provided + interface size. + + A table with the following fields is returned: + + * ``w``, ``h``: the width and height of the UI element + * ``l``, ``r``, ``t``, ``b``: zero-based, inset-style values that give the + offsets from the edges of the interface area to the UI element + +* ``getUIElementStateChecker(ui_element)`` + + Returns a function that can be used to check for changes in (non-size) DF + state that may affect the position of the UI element. For example, list UI + elements might need a scrollbar depending on how many items there are to + display. + + :: + + -- in a widget's init method + self.state_changed = layout.getUIElementStateChecker(el) + + -- in the widget's render handler + if self.state_changed() then + local new_el_frame = layout.getUIElementFrame(el, gui.get_interface_rect()) + -- adapt to the position described by new_el_frame + end + +* ``getRelativePlacement(placement_spec, interface_size)`` + + Computes a rectangular frame relative to the position of the given UI element. + + Note: See ``getOverlayPlacementInfo`` for special support for DFHack overlays. - The ``overlay_placement_spec`` parameter should be a table with the following - fields: + The ``placement_spec`` parameter should be a table with the following fields: + + ``name`` + an identifying string value that can be used in error messages ``size`` - a table with ``width`` and ``height`` fields that specifies the static size - of the overlay widget + a table with ``width`` and ``height`` fields that specifies the size + of the rectangle that is being placed ``ui_element`` - the overlay will be positioned relative to the specified UI element; UI + the placed rectangle will be positioned relative to this UI element; UI element values can be retrieved from this module's ``elements`` field. ``h_placement`` - a string that specifies the overlay's horizontal placement with respect to + a string that specifies the rectangle's horizontal placement with respect to the ``ui_element`` - * ``'on left'``: the overlay's right edge will be just to the left of the - ``ui_element``'s left edge - * ``'align left edges'``: the overlay's left edge will be aligned to the + * ``'on left'``: the rectangle's right edge will be just to the left of + the ``ui_element``'s left edge + * ``'align left edges'``: the rectangle's left edge will be aligned to the ``ui_element``'s left edge - * ``'align right edges'``: the overlay's right edge will be aligned to the - ``ui_element``'s right edge - * ``'on right'``: the overlay's left edge will be just to the right of the - ``ui_element``'s right edge + * ``'align right edges'``: the rectangle's right edge will be aligned to + the ``ui_element``'s right edge + * ``'on right'``: the rectangle's left edge will be just to the right of + the ``ui_element``'s right edge ``v_placement`` - a string that specifies the overlay's vertical placement with respect to + a string that specifies the rectangle's vertical placement with respect to the ``ui_element`` - * ``'above'``: the overlay's bottom edge will be just above the reference - frame's top edge - * ``'align top edges'``: the overlay's top edge will be aligned to the + * ``'above'``: the rectangle's bottom edge will be just above the + reference frame's top edge + * ``'align top edges'``: the rectangle's top edge will be aligned to the ``ui_element``'s top edge - * ``'align bottom edges'``: the overlay's bottom edge will be aligned to the - ``ui_element``'s bottom edge - * ``'below'``: the overlay's top edge will be just below the + * ``'align bottom edges'``: the rectangle's bottom edge will be aligned to + the ``ui_element``'s bottom edge + * ``'below'``: the rectangle's top edge will be just below the ``ui_element``'s bottom edge ``offset`` an optional table with ``x`` and ``y`` fields that gives an additional - position offset that is applied after the overlay is positioned relative to - the ``ui_element``. + position offset that is applied after the rectangle is positioned relative + to the ``ui_element``. + + The return value is the same kind of table as returned from + ``getUIElementFrame`` (i.e., a "fully placed" frame). + +Automatic Overlay Positioning +----------------------------- + +This module provides higher-level functions that use the provided UI element +values to help automatically position an overlay widget with respect to the +referenced UI element: + +* ``getOverlayPlacementInfo(overlay_placement_spec)`` + + The ``overlay_placement_spec`` parameter is a table with the same fields that + ``getRelativePlacement`` takes for its placement specification, with the + addition of: ``default_pos`` an optional table with ``x`` and/or ``y`` fields that overrides the returned @@ -6715,17 +6770,36 @@ UI element: needed for compatibility with existing "UI element relative" overlay positioning code. + Alternatively, the interface area edges from which the overlay is positioned + by default can be specified by giving ``from_right`` and ``from_top`` + boolean fields. + + Note: the ``size`` field is the *static* size of the overlay. Overlays with + dynamic sizes are not supported. + A table with the following fields is returned: - * ``default_pos``: a table that should be used for the overlay's ``default_pos`` - * ``frame``: a table that may be used to initialize the overlay's ``frame`` - * ``preUpdateLayout_fn``: a function that used as (or called from) the - overlay's ``preUpdateLayout`` method + ``default_pos`` + a table that should be used for the overlay's ``default_pos`` + + ``frame`` + a table that may be used to initialize the overlay's ``frame`` + + ``preUpdateLayout_fn`` + a function that should be used as (or called from) the overlay's + ``preUpdateLayout`` method + + ``onRenderBody_fn`` + a function that should be used as (or called from) the overlay's + ``onRenderBody`` method; this checks for non-size state changes that + ``ui_element`` is sensitive to and arranges for the widget to have its + ``updateLayout`` method called when state changes are noticed. This function can be used like this:: local dflayout = require('gui.dflayout') local PLACEMENT = dflayout.getOverlayPlacementInfo({ + name = 'TheOverlay', size = { w = 26, h = 11 }, -- whatever the overlay uses -- position the overlay one column to the right of -- the MAIN_STOCKPILE_MODE toolbar @@ -6745,6 +6819,7 @@ UI element: -- ... end TheOverlay.preUpdateLayout = PLACEMENT.preUpdateLayout_fn + TheOverlay.onRenderBody = PLACEMENT.onRenderBody_fn The ``preUpdateLayout_fn`` function will adjust the overlay widget's ``frame.w``, ``frame.h``, and ``frame_inset`` fields to arrange for the @@ -6755,9 +6830,9 @@ UI element: * ``getLeftOnlyOverlayPlacementInfo(overlay_placement_spec)`` This function works like ``getOverlayPlacementInfo``, but it only "pads" the - overlay on the left. This is useful for compatibility with existing "UI - element relative" overlay positioning code (e.g., to avoid needing a version - bump that would reset a player's custom positioning). + overlay on the left. This is useful for compatibility with existing UI element + relative overlay positioning code (e.g., to avoid needing a version bump that + would reset a player's custom positioning). .. _lua-plugins: diff --git a/docs/dev/overlay-dev-guide.rst b/docs/dev/overlay-dev-guide.rst index 0cbc68ef7f..dd7710a0df 100644 --- a/docs/dev/overlay-dev-guide.rst +++ b/docs/dev/overlay-dev-guide.rst @@ -495,8 +495,9 @@ while still being player-customizable:: local widgets = require('gui.widgets') local dflayout = require('gui.dflayout') - local WIDTH, HEIGHT = 20, 1 -- whatever static size the overlay needs + local WIDTH, HEIGHT = 25, 1 -- whatever static size the overlay needs local PLACEMENT = dflayout.getOverlayPlacementInfo{ + name = 'dig indicator', size = { w = WIDTH, h = HEIGHT }, ui_element = dflayout.elements.fort.secondary_toolbar_buttons.DIG.DIG_DIG, h_placement = 'align left edges', @@ -507,7 +508,7 @@ while still being player-customizable:: UIRelativeOverlay = defclass(UIRelativeOverlay, overlay.OverlayWidget) UIRelativeOverlay.ATTRS{ - name = 'Can you dig it?', + name = 'dig indicator', desc = 'A overlay that has UI-relative positioning.', default_enabled = true, default_pos = PLACEMENT.default_pos, @@ -519,12 +520,13 @@ while still being player-customizable:: self:addviews{ widgets.Label{ text_pen = { fg = COLOR_BLACK, bg = COLOR_GREY }, - text = string.char(25):rep(dig_dig_button.width) .. ' I can dig it!', + text = string.char(25):rep(dig_dig_button.width) .. ' Digging starts here!', }, } end UIRelativeOverlay.preUpdateLayout = PLACEMENT.preUpdateLayout_fn + UIRelativeOverlay.onRenderBody = PLACEMENT.onRenderBody_fn OVERLAY_WIDGETS = { overlay = UIRelativeOverlay }