diff --git a/docs/changelog.txt b/docs/changelog.txt index b94cdce955..731f8b92c4 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -74,6 +74,7 @@ Template for new versions: ## Lua - ``script-manager``: new ``get_active_mods()`` function for getting information on active mods - ``script-manager``: new ``get_mod_info_metadata()`` function for getting information out of mod ``info.txt`` files +- ``gui.dflayout``: provide DF fort mode toolbar position information and automatic overlay positioning ## Removed @@ -113,6 +114,8 @@ Template for new versions: ## Lua - ``dfhack.military.addToSquad``: expose Military API function + +## Removed - ``dfhack.buildings.getOwner``: make new Buildings API available to Lua # 51.10-r1 diff --git a/docs/dev/Lua API.rst b/docs/dev/Lua API.rst index e810a18440..b227a5071b 100644 --- a/docs/dev/Lua API.rst +++ b/docs/dev/Lua API.rst @@ -6547,6 +6547,293 @@ Example usage:: local first_border_texpos = textures.tp_border_thin(1) +gui.dflayout +============ + +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 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 +are always inside the DF interface area (which, depending on DF settings, may be +narrower than the DF window). + +General Constants +----------------- + +This module provides these convenience constants: + +* ``MINIMUM_INTERFACE_SIZE`` + + The dimensions (``width`` and ``height``) of the minimum-size DF window: + 114x46 UI tiles. + +* ``TOOLBAR_HEIGHT`` + + 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 UI rows). + +Fortress Mode Toolbars +---------------------- + +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. + +Layout Information +~~~~~~~~~~~~~~~~~~ + +The "raw" layout description for toolbars gives the width of the toolbar and the +sizes and (relative) positions of its buttons. + +The layouts of the primary toolbars are available through these module fields: + +* ``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 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: + + * ``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. + +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. + +Position based on UI Element +---------------------------- + +This module provides several functions to work with the provided UI element +values: + +* ``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 ``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 size + of the rectangle that is being placed + + ``ui_element`` + 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 rectangle's horizontal placement with respect to + the ``ui_element`` + + * ``'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 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 rectangle's vertical placement with respect to + the ``ui_element`` + + * ``'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 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 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 + ``default_pos``. This field should be omitted for new overlays, but may be + 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 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 + -- (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 + 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 + 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 d87178bfff..dd7710a0df 100644 --- a/docs/dev/overlay-dev-guide.rst +++ b/docs/dev/overlay-dev-guide.rst @@ -459,3 +459,101 @@ 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 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 +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 UI element. + +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 widgets = require('gui.widgets') + local dflayout = require('gui.dflayout') + + 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', + v_placement = 'above', + } + + local dig_dig_button = dflayout.element_layouts.fort.secondary_toolbars.DIG.buttons.DIG_DIG + + UIRelativeOverlay = defclass(UIRelativeOverlay, overlay.OverlayWidget) + UIRelativeOverlay.ATTRS{ + name = 'dig indicator', + desc = 'A overlay that has UI-relative positioning.', + default_enabled = true, + default_pos = PLACEMENT.default_pos, + -- frame and frame_inset are managed in preUpdateLayout + viewscreens = { 'dwarfmode/Designate/DIG_DIG' }, + } + + function UIRelativeOverlay:init() + self:addviews{ + widgets.Label{ + text_pen = { fg = COLOR_BLACK, bg = COLOR_GREY }, + 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 } + +Consequences +************ + +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. + + * 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 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 new file mode 100644 index 0000000000..bef90bf5fb --- /dev/null +++ b/library/lua/gui/dflayout.lua @@ -0,0 +1,1307 @@ +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 + +-- 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.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.Interface.Size +MINIMUM_INTERFACE_SIZE = { width = 114, height = 46 } + +---@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.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.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. +-- +-- 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.DynamicUIElement.State boolean | integer | string | table + +-- 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 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 +---@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 +local function button_widths_to_toolbar_layout(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 + +local BUTTON_WIDTH = 4 + +---@param buttons string[] +---@return DFLayout.Toolbar.Widths[] +local function buttons_to_widths(buttons) + local widths = {} + for _, button_name in ipairs(buttons) do + table.insert(widths, { [button_name] = BUTTON_WIDTH }) + end + return widths +end + +---@param buttons string[] +---@return DFLayout.Toolbar.Layout +local function buttons_to_toolbar_layout(buttons) + return button_widths_to_toolbar_layout(buttons_to_widths(buttons)) +end + +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', + }, + }, + } +} + +local fort_tb_layout = element_layouts.fort.toolbars +local fort_stb_layout = element_layouts.fort.secondary_toolbars + +--- DF UI element "frame" calculation functions --- + +---@type DFLayout.FrameFn +local function fort_left_tb_frame(interface_size) + return { + l = 0, + w = fort_tb_layout.left.width, + r = interface_size.width - fort_tb_layout.left.width, + + t = interface_size.height - TOOLBAR_HEIGHT, + h = TOOLBAR_HEIGHT, + b = 0, + } +end + +local FORT_LEFT_CENTER_TOOLBAR_GAP_MINIMUM = 7 + +---@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 + + 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_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, + b = 0, + } +end + +---@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), +} + +---@param name string +---@param frame_fn DFLayout.FrameFn +---@return DFLayout.DynamicUIElement +local function ui_el(name, frame_fn) + return { + name = name, + frame_fn = 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_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 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) + return { + l = l, + w = button.width, + r = r, + + t = toolbar_frame.t, + h = toolbar_frame.h, + b = toolbar_frame.b, + } + 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 +---@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( + ('fort.secondary_toolbar_buttons.%s.%s'):format(toolbar_name, button_name), + frame_fn, layout, button_name) +end + +elements = { + fort = { + toolbars = { + ---@type DFLayout.DynamicUIElement + left = ui_el('fort.toolbars.left', fort_left_tb_frame), + ---@type DFLayout.DynamicUIElement + center = ui_el('fort.toolbars.center', fort_center_tb_frame), + ---@type DFLayout.DynamicUIElement + right = ui_el('fort.toolbars.left', fort_right_tb_frame), + }, + toolbar_buttons = { + left = { + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_CREATURES = left_button_ui_el('MAIN_OPEN_CREATURES'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_TASKS = left_button_ui_el('MAIN_OPEN_TASKS'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_PLACES = left_button_ui_el('MAIN_OPEN_PLACES'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_LABOR = left_button_ui_el('MAIN_OPEN_LABOR'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_WORK_ORDERS = left_button_ui_el('MAIN_OPEN_WORK_ORDERS'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_NOBLES = left_button_ui_el('MAIN_OPEN_NOBLES'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_OBJECTS = left_button_ui_el('MAIN_OPEN_OBJECTS'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_JUSTICE = left_button_ui_el('MAIN_OPEN_JUSTICE'), + }, + center = { + ---@type DFLayout.DynamicUIElement + DIG = center_button_ui_el('DIG'), + ---@type DFLayout.DynamicUIElement + CHOP = center_button_ui_el('CHOP'), + ---@type DFLayout.DynamicUIElement + GATHER = center_button_ui_el('GATHER'), + ---@type DFLayout.DynamicUIElement + SMOOTH = center_button_ui_el('SMOOTH'), + ---@type DFLayout.DynamicUIElement + ERASE = center_button_ui_el('ERASE'), + ---@type DFLayout.DynamicUIElement + MAIN_BUILDING_MODE = center_button_ui_el('MAIN_BUILDING_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_STOCKPILE_MODE = center_button_ui_el('MAIN_STOCKPILE_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_ZONE_MODE = center_button_ui_el('MAIN_ZONE_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_BURROW_MODE = center_button_ui_el('MAIN_BURROW_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_HAULING_MODE = center_button_ui_el('MAIN_HAULING_MODE'), + ---@type DFLayout.DynamicUIElement + TRAFFIC = center_button_ui_el('TRAFFIC'), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING = center_button_ui_el('ITEM_BUILDING'), + }, + center_close = { + ---@type DFLayout.DynamicUIElement + DIG_LOWER_MODE = center_close_button_ui_el('DIG'), + ---@type DFLayout.DynamicUIElement + CHOP_LOWER_MODE = center_close_button_ui_el('CHOP'), + ---@type DFLayout.DynamicUIElement + GATHER_LOWER_MODE = center_close_button_ui_el('GATHER'), + ---@type DFLayout.DynamicUIElement + SMOOTH_LOWER_MODE = center_close_button_ui_el('SMOOTH'), + ---@type DFLayout.DynamicUIElement + ERASE_LOWER_MODE = center_close_button_ui_el('ERASE'), + ---@type DFLayout.DynamicUIElement + MAIN_BUILDING_MODE_LOWER_MODE = center_close_button_ui_el('MAIN_BUILDING_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_STOCKPILE_MODE_LOWER_MODE = center_close_button_ui_el('MAIN_STOCKPILE_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_ZONE_MODE_LOWER_MODE = center_close_button_ui_el('MAIN_ZONE_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_BURROW_MODE_LOWER_MODE = center_close_button_ui_el('MAIN_BURROW_MODE'), + ---@type DFLayout.DynamicUIElement + MAIN_HAULING_MODE_LOWER_MODE = center_close_button_ui_el('MAIN_HAULING_MODE'), + ---@type DFLayout.DynamicUIElement + TRAFFIC_LOWER_MODE = center_close_button_ui_el('TRAFFIC'), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING_LOWER_MODE = center_close_button_ui_el('ITEM_BUILDING'), + }, + right = { + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_SQUADS = right_button_ui_el('MAIN_OPEN_SQUADS'), + ---@type DFLayout.DynamicUIElement + MAIN_OPEN_WORLD = right_button_ui_el('MAIN_OPEN_WORLD'), + }, + }, + secondary_toolbars = { + ---@type DFLayout.DynamicUIElement + DIG = ui_el('fort.secondary_toolbars.DIG', fort_secondary_tb_frames.DIG), + ---@type DFLayout.DynamicUIElement + CHOP = ui_el('fort.secondary_toolbars.CHOP', fort_secondary_tb_frames.CHOP), + ---@type DFLayout.DynamicUIElement + GATHER = ui_el('fort.secondary_toolbars.GATHER', fort_secondary_tb_frames.GATHER), + ---@type DFLayout.DynamicUIElement + SMOOTH = ui_el('fort.secondary_toolbars.SMOOTH', fort_secondary_tb_frames.SMOOTH), + ---@type DFLayout.DynamicUIElement + ERASE = ui_el('fort.secondary_toolbars.ERASE', fort_secondary_tb_frames.ERASE), + ---@type DFLayout.DynamicUIElement + MAIN_STOCKPILE_MODE = ui_el('fort.secondary_toolbars.MAIN_STOCKPILE_MODE', fort_secondary_tb_frames.MAIN_STOCKPILE_MODE), + ---@type DFLayout.DynamicUIElement + STOCKPILE_NEW = ui_el('fort.secondary_toolbars.STOCKPILE_NEW', fort_secondary_tb_frames.STOCKPILE_NEW), + ---@type DFLayout.DynamicUIElement + ['Add new burrow'] = ui_el('fort.secondary_toolbars.Add new burrow', fort_secondary_tb_frames['Add new burrow']), + ---@type DFLayout.DynamicUIElement + TRAFFIC = ui_el('fort.secondary_toolbars.TRAFFIC', fort_secondary_tb_frames.TRAFFIC), + ---@type DFLayout.DynamicUIElement + ITEM_BUILDING = ui_el('fort.secondary_toolbars.ITEM_BUILDING', 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'), + }, + }, + }, +} + +---@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 + + -- 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 + + local h = interface_size.height - (t + b) + + -- 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 + + return { + l = l, + w = interface_size.width - (l + r), + r = r, + + t = t, + h = h, + b = b, + }, { + scrollbar = scrollbar, + } + end +end + +-- 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, +} + +-- 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, +} + +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. +-- +---@param available_span integer +---@param ref_offset_before 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_offset_after, 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 = available_span - ref_offset_after - placed_span + elseif placement == 'place before' then + before = ref_offset_before - placed_span + elseif placement == 'place after' then + before = available_span - ref_offset_after + 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 + +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 spec DFLayout.Placement.Spec +---@param interface_size DFLayout.Interface.Size +---@param feature_tests? DFLayout.FrameFn.FeatureTests for internal/test use +---@return DFLayout.Frame +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] + or dfhack.error(('%s: invalid h_placement: %s'):format(spec.name, spec.h_placement)) + local l, w, r = place_span(interface_size.width, + 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_inset.t, ref_inset.b, + spec.size.h, generic_v_placement, spec.offset and spec.offset.y) + + return { + l = l, + w = w, + r = r, + + t = t, + h = h, + b = b, + } +end + +--- Automatic UI-relative Overlay Positioning --- + +-- 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, 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 + if 0 < pos then + if nominal_positive < pos then + dfhack.error(('%s: specified placement requires 1 <= default_pos.%s <= %d') + :format(spec_name, xy, nominal_positive)) + end + 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, positive_pad, negative_pad + pos - nominal_negative +end + +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 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 + 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 + +-- 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) + return function() return true end + end, +}) + +---@alias DFLayout.Placement.InsetFilter fun(inset: DFLayout.Inset): DFLayout.Inset + +---@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.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 = 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 + + -- 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.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.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 { + default_pos = { + x = x_pos, + y = y_pos, + }, + frame = { + w = default_placement.w, + h = default_placement.h, + }, + ---@param self_overlay_widget widgets.Widget overlay.OverlayWidget + ---@param parent_rect gui.ViewRect + preUpdateLayout_fn = function(self_overlay_widget, parent_rect) + 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, + 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 = 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.OverlayPlacement.Spec +---@return DFLayout.OverlayPlacementInfo overlay_placement_info +function getOverlayPlacementInfo(overlay_placement_spec) + return get_overlay_placement_info(overlay_placement_spec) +end + +---@type DFLayout.Placement.InsetFilter +local function only_left_inset(inset) + return { + l = inset.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.OverlayPlacement.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 new file mode 100644 index 0000000000..211e83f172 --- /dev/null +++ b/test/library/gui/dflayout.lua @@ -0,0 +1,1140 @@ +config.target = 'core' + +-- hints for the typechecker +expect = expect or require('test_util.expect') +test = test or {} + +local layout = require('gui.dflayout') +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 + return comment .. ': ' .. suffix + end + return comment or suffix +end + +------ BEGIN MAGIC NUMBERS ------ + +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 = { + 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_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) + 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_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() + 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 = '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_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 = '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', + { starting_width = MINIMUM_INTERFACE_WIDTH, offset = 33, growth = one_for_one_growth }, + { starting_width = 198, offset = 116, growth = even_one_for_two_growth }, + }, + { + 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 }, + }, +} + +-- 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 ------ + +--- 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) + 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 w = ftb_layouts.left.width + for_all_checked_interface_sizes(function(size) + local size_str = ('%dx%d'):format(size.width, size.height) + local frame = layout.getUIElementFrame(ftb_elements.left, size) + expect_bottom_left_frame(frame, size, w, 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 w = ftb_layouts.right.width + for_all_checked_interface_sizes(function(size) + local size_str = ('%dx%d'):format(size.width, size.height) + local frame = layout.getUIElementFrame(ftb_elements.right, size) + expect_bottom_right_frame(frame, size, w, 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 w = ftb_layouts.center.width + 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) + local frame = layout.getUIElementFrame(ftb_elements.center, size) + expect_bottom_center_frame(frame, size, w, 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 w = layout.element_layouts.fort.secondary_toolbars[name].width + 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( + layout.getUIElementFrame(el, 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 = layout.getUIElementFrame(toolbar_el, 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 = 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')) + 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 relative placement: element_based_placement_* --- + +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 { + name = 'Centered UI element for testing', + frame_fn = frame_fn, + } +end + +-- test alignment specification +function test.element_based_placement_alignments() + ---@type { h: DFLayout.Placement.HorizontalAlignment, l: integer, r: integer }[] + local has = { + { 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, t: integer, b: integer }[] + local vas = { + { 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 = '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.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 + +-- test offset specification +function test.element_based_placement_offset() + ---@type DFLayout.OverlayPlacement.Spec + local base_spec = { + 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.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.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) + 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.OverlayPlacement.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', + 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 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.OverlayPlacement.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 + +-- 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', + 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.OverlayPlacement.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', + 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.OverlayPlacement.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', + 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 + +--- 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 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 + 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 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 + 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 = 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) + 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 = 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) + check_LL(interface_size, el_f, c) + end + end +end