From 0faee9afd716817427f7647f91b911cd9a56e7dc Mon Sep 17 00:00:00 2001 From: Rodrigo Cardoso Buske Date: Wed, 9 Sep 2026 21:10:14 -0300 Subject: [PATCH 1/6] orders: extract work order list geometry --- docs/about/Authors.rst | 1 + docs/changelog.txt | 2 + plugins/lua/orders.lua | 68 ++----------------- plugins/lua/orders/work_order_list.lua | 90 ++++++++++++++++++++++++++ test/plugins/orders.lua | 30 +++++++++ 5 files changed, 127 insertions(+), 64 deletions(-) create mode 100644 plugins/lua/orders/work_order_list.lua diff --git a/docs/about/Authors.rst b/docs/about/Authors.rst index eefd440758..077880c439 100644 --- a/docs/about/Authors.rst +++ b/docs/about/Authors.rst @@ -202,6 +202,7 @@ Robert Heinrich rh73 Robert Janetzko robertjanetzko Rocco Moretti roccomoretti RocheLimit +Rodrigo Cardoso Buske robuske rofl0r rofl0r root Rose RosaryMala diff --git a/docs/changelog.txt b/docs/changelog.txt index 78e8a56cc3..5a18e4be5e 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -87,6 +87,8 @@ Template for new versions: ## Lua +- `orders`: make work-order list geometry available to Lua overlays + ## Removed # 53.16-r1.1 diff --git a/plugins/lua/orders.lua b/plugins/lua/orders.lua index a470e3e2ce..88a176d0c8 100644 --- a/plugins/lua/orders.lua +++ b/plugins/lua/orders.lua @@ -3,6 +3,7 @@ local _ENV = mkmodule('plugins.orders') local dialogs = require('gui.dialogs') local gui = require('gui') local overlay = require('plugins.overlay') +local work_order_list = require('plugins.orders.work_order_list') local textures = require('gui.textures') local utils = require('utils') local widgets = require('gui.widgets') @@ -714,11 +715,6 @@ end -- OrdersSearchOverlay -- -local ORDER_HEIGHT = 3 -local TABS_WIDTH_THRESHOLD = 155 -local LIST_START_Y_ONE_TABS_ROW = 8 -local LIST_START_Y_TWO_TABS_ROWS = 10 -local BOTTOM_MARGIN = 9 local ARROW_X = 10 local SELECTED_PEN = dfhack.pen.parse{fg=COLOR_BLACK, bg=COLOR_WHITE, bold=true} @@ -756,63 +752,6 @@ local function concat_order_names() return table.concat(names, "|") end -local function getListStartY() - local rect = gui.get_interface_rect() - - if rect.width >= TABS_WIDTH_THRESHOLD then - return LIST_START_Y_ONE_TABS_ROW - else - return LIST_START_Y_TWO_TABS_ROWS - end -end - -local function getViewportSize() - local rect = gui.get_interface_rect() - local list_start_y = getListStartY() - - local available_height = rect.height - list_start_y - BOTTOM_MARGIN - return math.floor(available_height / ORDER_HEIGHT) -end - -local function getVisibleOrderIndices() - local orders = df.global.world.manager_orders.all - local scroll_pos = mi.info.work_orders.scroll_position_work_orders - - if #orders == 0 then return 0, -1 end - - local viewport_size = getViewportSize() - local viewport_start = scroll_pos - local viewport_end = scroll_pos + viewport_size - 1 - - -- Handle end-of-list case - if viewport_end >= #orders then - viewport_end = #orders - 1 - viewport_start = math.max(0, viewport_end - viewport_size + 1) - end - - return viewport_start, viewport_end -end - -local function calculateOrderY(order_idx) - local orders = df.global.world.manager_orders.all - - if #orders == 0 or order_idx < 0 or order_idx >= #orders then - return nil - end - - local viewport_start, viewport_end = getVisibleOrderIndices() - - -- Check if order is in viewport - if order_idx < viewport_start or order_idx > viewport_end then - return nil - end - - local list_start_y = getListStartY() - local pos_in_viewport = order_idx - viewport_start - - return list_start_y + (pos_in_viewport * ORDER_HEIGHT) -end - OrdersSearchOverlay = defclass(OrdersSearchOverlay, overlay.OverlayWidget) OrdersSearchOverlay.ATTRS{ desc='Adds a search box to find and navigate to matching manager orders.', @@ -952,7 +891,8 @@ function OrdersSearchOverlay:cycle_match(direction) -- Scroll to the selected match only if not already visible local order_idx = self.matched_indices[self.current_match_idx] - local viewport_start, viewport_end = getVisibleOrderIndices() + local viewport_start, viewport_end = + work_order_list.get_visible_order_indices() if order_idx < viewport_start or order_idx > viewport_end then mi.info.work_orders.scroll_position_work_orders = order_idx end @@ -1030,7 +970,7 @@ function OrdersSearchOverlay:render_highlights(dc) self.matched_indices[self.current_match_idx] or nil for _, match_order_idx in ipairs(self.matched_indices) do - local match_y = calculateOrderY(match_order_idx) + local match_y = work_order_list.get_order_y(match_order_idx) if match_y then local pen = (match_order_idx == selected_order_idx) and SELECTED_PEN or MATCH_PEN diff --git a/plugins/lua/orders/work_order_list.lua b/plugins/lua/orders/work_order_list.lua new file mode 100644 index 0000000000..c4145f2261 --- /dev/null +++ b/plugins/lua/orders/work_order_list.lua @@ -0,0 +1,90 @@ +local _ENV = mkmodule('plugins.orders.work_order_list') + +local gui = require('gui') + +ORDER_HEIGHT = 3 + +local TABS_WIDTH_THRESHOLD = 155 +local LIST_START_Y_ONE_TABS_ROW = 8 +local LIST_START_Y_TWO_TABS_ROWS = 10 +local BOTTOM_MARGIN = 9 + +---@param interface_width integer +---@return integer +local function calculate_list_start_y(interface_width) + if interface_width >= TABS_WIDTH_THRESHOLD then + return LIST_START_Y_ONE_TABS_ROW + end + return LIST_START_Y_TWO_TABS_ROWS +end + +---@param interface_height integer +---@param list_start_y integer +---@return integer +local function calculate_viewport_size(interface_height, list_start_y) + local available_height = interface_height - list_start_y - BOTTOM_MARGIN + return math.floor(available_height / ORDER_HEIGHT) +end + +---@param order_count integer +---@param viewport_size integer +---@param requested_start integer +---@return integer viewport_start +---@return integer viewport_end +local function calculate_visible_order_indices( + order_count, viewport_size, requested_start) + if order_count == 0 then return 0, -1 end + + local viewport_start = requested_start + local viewport_end = requested_start + viewport_size - 1 + + -- Handle end-of-list case + if viewport_end >= order_count then + viewport_end = order_count - 1 + viewport_start = math.max(0, viewport_end - viewport_size + 1) + end + return viewport_start, viewport_end +end + +---@return integer +function get_list_start_y() + return calculate_list_start_y(gui.get_interface_rect().width) +end + +---@return integer +function get_viewport_size() + return calculate_viewport_size( + gui.get_interface_rect().height, get_list_start_y()) +end + +---@return integer viewport_start +---@return integer viewport_end +function get_visible_order_indices() + local order_count = #df.global.world.manager_orders.all + local requested_start = df.global.game.main_interface.info.work_orders + .scroll_position_work_orders + return calculate_visible_order_indices( + order_count, get_viewport_size(), requested_start) +end + +---@param order_idx integer +---@return integer|nil y +function get_order_y(order_idx) + local orders = df.global.world.manager_orders.all + if order_idx < 0 or order_idx >= #orders then return nil end + + local viewport_start, viewport_end = get_visible_order_indices() + + -- Check if order is in viewport + if order_idx < viewport_start or order_idx > viewport_end then return nil end + + return get_list_start_y() + (order_idx - viewport_start) * ORDER_HEIGHT +end + +unit_test_hooks = { + calculate_list_start_y = calculate_list_start_y, + calculate_viewport_size = calculate_viewport_size, + calculate_visible_order_indices = calculate_visible_order_indices, +} + +return _ENV diff --git a/test/plugins/orders.lua b/test/plugins/orders.lua index dab396ba96..3135bcfb81 100644 --- a/test/plugins/orders.lua +++ b/test/plugins/orders.lua @@ -1,6 +1,8 @@ config.mode = 'fortress' config.target = 'orders' +local work_order_list = require('plugins.orders.work_order_list') + local FILE_PATH_PATTERN = dfhack.getConfigPath() .. '/orders/%s.json' local BACKUP_FILE_NAME = 'tmp-backup' @@ -266,3 +268,31 @@ function test.list() expect.eq(CR_OK, status) expect.str_find(BACKUP_FILE_NAME:gsub('%-', '%%-'), output) end + +function test.work_order_list_geometry() + local hooks = work_order_list.unit_test_hooks + + expect.eq(10, hooks.calculate_list_start_y(154)) + expect.eq(8, hooks.calculate_list_start_y(155)) + expect.eq(4, hooks.calculate_viewport_size(30, 8)) + + local viewport_start, viewport_end = + hooks.calculate_visible_order_indices(0, 4, 0) + expect.eq(0, viewport_start) + expect.eq(-1, viewport_end) + + viewport_start, viewport_end = + hooks.calculate_visible_order_indices(10, 4, 2) + expect.eq(2, viewport_start) + expect.eq(5, viewport_end) + + viewport_start, viewport_end = + hooks.calculate_visible_order_indices(10, 4, 8) + expect.eq(6, viewport_start) + expect.eq(9, viewport_end) + + viewport_start, viewport_end = + hooks.calculate_visible_order_indices(10, 20, 8) + expect.eq(0, viewport_start) + expect.eq(9, viewport_end) +end From 1145d2751978351d0fa142e0b6df2e4dab0bdc31 Mon Sep 17 00:00:00 2001 From: Rodrigo Cardoso Buske Date: Tue, 15 Sep 2026 23:04:40 -0300 Subject: [PATCH 2/6] orders: standardize work order list API names --- plugins/lua/orders.lua | 6 ++--- plugins/lua/orders/work_order_list.lua | 36 +++++++++++++------------- test/plugins/orders.lua | 18 ++++++------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/plugins/lua/orders.lua b/plugins/lua/orders.lua index 88a176d0c8..1e1dc5e272 100644 --- a/plugins/lua/orders.lua +++ b/plugins/lua/orders.lua @@ -3,7 +3,7 @@ local _ENV = mkmodule('plugins.orders') local dialogs = require('gui.dialogs') local gui = require('gui') local overlay = require('plugins.overlay') -local work_order_list = require('plugins.orders.work_order_list') +local workOrderList = require('plugins.orders.work_order_list') local textures = require('gui.textures') local utils = require('utils') local widgets = require('gui.widgets') @@ -892,7 +892,7 @@ function OrdersSearchOverlay:cycle_match(direction) -- Scroll to the selected match only if not already visible local order_idx = self.matched_indices[self.current_match_idx] local viewport_start, viewport_end = - work_order_list.get_visible_order_indices() + workOrderList.getVisibleOrderIndices() if order_idx < viewport_start or order_idx > viewport_end then mi.info.work_orders.scroll_position_work_orders = order_idx end @@ -970,7 +970,7 @@ function OrdersSearchOverlay:render_highlights(dc) self.matched_indices[self.current_match_idx] or nil for _, match_order_idx in ipairs(self.matched_indices) do - local match_y = work_order_list.get_order_y(match_order_idx) + local match_y = workOrderList.getOrderY(match_order_idx) if match_y then local pen = (match_order_idx == selected_order_idx) and SELECTED_PEN or MATCH_PEN diff --git a/plugins/lua/orders/work_order_list.lua b/plugins/lua/orders/work_order_list.lua index c4145f2261..f86bbfc029 100644 --- a/plugins/lua/orders/work_order_list.lua +++ b/plugins/lua/orders/work_order_list.lua @@ -11,7 +11,7 @@ local BOTTOM_MARGIN = 9 ---@param interface_width integer ---@return integer -local function calculate_list_start_y(interface_width) +local function calculateListStartY(interface_width) if interface_width >= TABS_WIDTH_THRESHOLD then return LIST_START_Y_ONE_TABS_ROW end @@ -21,7 +21,7 @@ end ---@param interface_height integer ---@param list_start_y integer ---@return integer -local function calculate_viewport_size(interface_height, list_start_y) +local function calculateViewportSize(interface_height, list_start_y) local available_height = interface_height - list_start_y - BOTTOM_MARGIN return math.floor(available_height / ORDER_HEIGHT) end @@ -31,7 +31,7 @@ end ---@param requested_start integer ---@return integer viewport_start ---@return integer viewport_end -local function calculate_visible_order_indices( +local function calculateVisibleOrderIndices( order_count, viewport_size, requested_start) if order_count == 0 then return 0, -1 end @@ -47,44 +47,44 @@ local function calculate_visible_order_indices( end ---@return integer -function get_list_start_y() - return calculate_list_start_y(gui.get_interface_rect().width) +function getListStartY() + return calculateListStartY(gui.get_interface_rect().width) end ---@return integer -function get_viewport_size() - return calculate_viewport_size( - gui.get_interface_rect().height, get_list_start_y()) +function getViewportSize() + return calculateViewportSize( + gui.get_interface_rect().height, getListStartY()) end ---@return integer viewport_start ---@return integer viewport_end -function get_visible_order_indices() +function getVisibleOrderIndices() local order_count = #df.global.world.manager_orders.all local requested_start = df.global.game.main_interface.info.work_orders .scroll_position_work_orders - return calculate_visible_order_indices( - order_count, get_viewport_size(), requested_start) + return calculateVisibleOrderIndices( + order_count, getViewportSize(), requested_start) end ---@param order_idx integer ---@return integer|nil y -function get_order_y(order_idx) +function getOrderY(order_idx) local orders = df.global.world.manager_orders.all if order_idx < 0 or order_idx >= #orders then return nil end - local viewport_start, viewport_end = get_visible_order_indices() + local viewport_start, viewport_end = getVisibleOrderIndices() -- Check if order is in viewport if order_idx < viewport_start or order_idx > viewport_end then return nil end - return get_list_start_y() + (order_idx - viewport_start) * ORDER_HEIGHT + return getListStartY() + (order_idx - viewport_start) * ORDER_HEIGHT end -unit_test_hooks = { - calculate_list_start_y = calculate_list_start_y, - calculate_viewport_size = calculate_viewport_size, - calculate_visible_order_indices = calculate_visible_order_indices, +unitTestHooks = { + calculateListStartY = calculateListStartY, + calculateViewportSize = calculateViewportSize, + calculateVisibleOrderIndices = calculateVisibleOrderIndices, } return _ENV diff --git a/test/plugins/orders.lua b/test/plugins/orders.lua index 3135bcfb81..74d3720159 100644 --- a/test/plugins/orders.lua +++ b/test/plugins/orders.lua @@ -1,7 +1,7 @@ config.mode = 'fortress' config.target = 'orders' -local work_order_list = require('plugins.orders.work_order_list') +local workOrderList = require('plugins.orders.work_order_list') local FILE_PATH_PATTERN = dfhack.getConfigPath() .. '/orders/%s.json' @@ -270,29 +270,29 @@ function test.list() end function test.work_order_list_geometry() - local hooks = work_order_list.unit_test_hooks + local hooks = workOrderList.unitTestHooks - expect.eq(10, hooks.calculate_list_start_y(154)) - expect.eq(8, hooks.calculate_list_start_y(155)) - expect.eq(4, hooks.calculate_viewport_size(30, 8)) + expect.eq(10, hooks.calculateListStartY(154)) + expect.eq(8, hooks.calculateListStartY(155)) + expect.eq(4, hooks.calculateViewportSize(30, 8)) local viewport_start, viewport_end = - hooks.calculate_visible_order_indices(0, 4, 0) + hooks.calculateVisibleOrderIndices(0, 4, 0) expect.eq(0, viewport_start) expect.eq(-1, viewport_end) viewport_start, viewport_end = - hooks.calculate_visible_order_indices(10, 4, 2) + hooks.calculateVisibleOrderIndices(10, 4, 2) expect.eq(2, viewport_start) expect.eq(5, viewport_end) viewport_start, viewport_end = - hooks.calculate_visible_order_indices(10, 4, 8) + hooks.calculateVisibleOrderIndices(10, 4, 8) expect.eq(6, viewport_start) expect.eq(9, viewport_end) viewport_start, viewport_end = - hooks.calculate_visible_order_indices(10, 20, 8) + hooks.calculateVisibleOrderIndices(10, 20, 8) expect.eq(0, viewport_start) expect.eq(9, viewport_end) end From 12f0fcb1fa72fea1fb6ad6e6562e21209816d708 Mon Sep 17 00:00:00 2001 From: Rodrigo Cardoso Buske Date: Tue, 8 Sep 2026 21:40:36 -0300 Subject: [PATCH 3/6] orders: add position editing overlay --- plugins/lua/orders.lua | 5 + plugins/lua/orders/position.lua | 75 ++++ plugins/lua/orders/position_overlay.lua | 572 ++++++++++++++++++++++++ test/plugins/orders.lua | 157 +++++++ 4 files changed, 809 insertions(+) create mode 100644 plugins/lua/orders/position.lua create mode 100644 plugins/lua/orders/position_overlay.lua diff --git a/plugins/lua/orders.lua b/plugins/lua/orders.lua index 1e1dc5e272..75e1a90404 100644 --- a/plugins/lua/orders.lua +++ b/plugins/lua/orders.lua @@ -4,6 +4,7 @@ local dialogs = require('gui.dialogs') local gui = require('gui') local overlay = require('plugins.overlay') local workOrderList = require('plugins.orders.work_order_list') +local position_overlay = require('plugins.orders.position_overlay') local textures = require('gui.textures') local utils = require('utils') local widgets = require('gui.widgets') @@ -776,6 +777,7 @@ function OrdersSearchOverlay:init() frame={t=0, l=0}, key='CUSTOM_ALT_S', on_change=self:callback('update_filter'), + on_focus=position_overlay.clear_active_edit, on_submit=self:callback('on_submit'), on_submit2=self:callback('on_submit2'), }, @@ -827,6 +829,8 @@ function OrdersSearchOverlay:init() main_panel, minimized_panel, } + position_overlay.bind_orders_search_field( + self.subviews.filter) self.minimized = false self.matched_indices = {} @@ -987,6 +991,7 @@ end OVERLAY_WIDGETS = { recheck=RecheckOverlay, importexport=OrdersOverlay, + position=position_overlay.PositionOverlay, search=OrdersSearchOverlay, skillrestrictions=SkillRestrictionOverlay, laborrestrictions=LaborRestrictionsOverlay, diff --git a/plugins/lua/orders/position.lua b/plugins/lua/orders/position.lua new file mode 100644 index 0000000000..80c194a884 --- /dev/null +++ b/plugins/lua/orders/position.lua @@ -0,0 +1,75 @@ +local _ENV = mkmodule('plugins.orders.position') + +---@param value string|number +---@param label string +---@return integer|nil position +---@return string|nil error_message +local function parse_position(value, label) + local position + if type(value) == 'number' then + position = value + elseif type(value) == 'string' and value:match('^%d+$') then + position = tonumber(value) + end + + position = position and math.tointeger(position) + if not position or position < 1 then + return nil, ('%s must be a positive integer; got %q.'): + format(label, tostring(value)) + end + + return position +end + +--- Validates and moves an existing order pointer within a zero-based vector. +---@param orders df.manager_order[] +---@param current_position string|number One-based current position. +---@param new_value string|number One-based destination position. +---@return df.manager_order|nil order +---@return string|nil error_message +local function move_in_vector(orders, current_position, new_value) + local parsed_current_position, current_error = + parse_position(current_position, 'Current position') + if not parsed_current_position then return nil, current_error end + + local new_position, new_error = parse_position(new_value, 'New position') + if not new_position then return nil, new_error end + + local order_count = #orders + if parsed_current_position > order_count then + return nil, + ('Current position %d is outside the valid range 1..%d.'): + format(parsed_current_position, order_count) + end + if new_position > order_count then + return nil, ('New position %d is outside the valid range 1..%d.'): + format(new_position, order_count) + end + + local current_index = parsed_current_position - 1 + local order = orders[current_index] + if not order then return nil, 'The selected manager order no longer exists.' end + if parsed_current_position == new_position then return order end + + -- DF containers use zero-based indices. Erasing a pointer-vector cell does + -- not delete its pointee, so retain and reinsert the existing order. + orders:erase(current_index) + orders:insert(new_position - 1, order) + return order +end + +--- Moves an existing manager-order pointer to a one-based vector position. +---@param current_position string|number +---@param new_value string|number +---@return df.manager_order|nil order +---@return string|nil error_message +function move(current_position, new_value) + return move_in_vector( + df.global.world.manager_orders.all, current_position, new_value) +end + +unit_test_hooks = { + move_in_vector = move_in_vector, +} + +return _ENV diff --git a/plugins/lua/orders/position_overlay.lua b/plugins/lua/orders/position_overlay.lua new file mode 100644 index 0000000000..3139e7ecc8 --- /dev/null +++ b/plugins/lua/orders/position_overlay.lua @@ -0,0 +1,572 @@ +local _ENV = mkmodule('plugins.orders.position_overlay') + +local dialogs = require('gui.dialogs') +local gui = require('gui') +local position = require('plugins.orders.position') +local overlay = require('plugins.overlay') +local work_order_list = require('plugins.orders.work_order_list') +local widgets = require('gui.widgets') + +-- Keep the bracketed positions in the left-side gutter beside each order. +local POSITION_X = 6 +local MIN_EDITOR_WIDTH = 4 +local FIELD_BRACKETS_WIDTH = 2 +local EDITOR_TEXT_PEN = COLOR_LIGHTCYAN +local INACTIVE_TEXT_PEN = COLOR_WHITE +local POSITION_HOVER_PEN = dfhack.pen.parse { + fg = COLOR_BLACK, + bg = COLOR_WHITE, +} + +local TOGGLE_PANEL_X = 25 +local TOGGLE_PANEL_BOTTOM = 6 +local TOGGLE_PANEL_WIDTH = 15 +local TOGGLE_PANEL_HEIGHT = 3 + +local ORDER_HEIGHT = work_order_list.ORDER_HEIGHT + +---@param order_id integer +---@return integer|nil order_idx +local function find_order_index(order_id) + local orders = df.global.world.manager_orders.all + for order_idx = 0, #orders - 1 do + if orders[order_idx].id == order_id then return order_idx end + end +end + +---@param first widgets.Widget.frame +---@param second widgets.Widget.frame +---@return boolean +local function frames_equal(first, second) + return first.l == second.l + and first.t == second.t + and first.r == second.r + and first.b == second.b + and first.w == second.w + and first.h == second.h +end + +---@return boolean +local function are_order_details_open() + return df.global.game.main_interface.job_details.open +end + +---@type widgets.EditField|nil +local orders_search_field + +---@type plugins.orders.position_overlay.PositionOverlay|nil +local active_position_overlay + +--- Registers the search field that should lose focus when position editing begins. +---@param field widgets.EditField|nil +function bind_orders_search_field(field) + orders_search_field = field +end + +--- Cancels the active position edit when the Orders search field gains focus. +function clear_active_edit() + if active_position_overlay then active_position_overlay:clear_selection() end +end + +local function unfocus_orders_search() + if orders_search_field and orders_search_field.focus then + orders_search_field:setFocus(false) + end +end + +---@param modifiers table +---@return boolean +local function has_modifier(modifiers) + return not not (modifiers.ctrl + or modifiers.shift + or modifiers.alt + or modifiers.super) +end + +---@return boolean +local function is_modifier_active() + return has_modifier(dfhack.internal.getModifiers()) +end + +---@param ch string +---@return boolean +local function accept_position_digit(ch) + return ch:match('^%d$') ~= nil +end + +---@param keys table +---@return boolean +local function is_mouse_or_scroll_key(keys) + return keys._MOUSE_L + or keys._MOUSE_L_DOWN + or keys._MOUSE_R + or keys._MOUSE_R_DOWN + or keys._MOUSE_M + or keys._MOUSE_M_DOWN + or keys.CONTEXT_SCROLL_UP + or keys.CONTEXT_SCROLL_DOWN + or keys.CONTEXT_SCROLL_PAGEUP + or keys.CONTEXT_SCROLL_PAGEDOWN +end + +---@param field widgets.EditField +local function select_all_field_text(field) + -- Use the EditField's native Ctrl+A behavior so typing replaces the + -- displayed position while cursor and selection behavior remain standard. + field:onInput { CUSTOM_CTRL_A = true } +end + +---@param field widgets.EditField +local function clear_field_text_selection(field) + -- EditField:setCursor() delegates to TextAreaContent:setCursor(), which + -- clears its selection range without changing the field text. + field:setCursor() +end + +---@return integer +local function get_editor_width() + local order_count = #df.global.world.manager_orders.all + return math.max(MIN_EDITOR_WIDTH, #tostring(order_count)) +end + +---@class plugins.orders.position_overlay.PositionEdit +---@field order_id integer +---@field slot integer +---@field text string + +---@class plugins.orders.position_overlay.PositionOverlay: dfhack.class, widgets.Panel +---@field super widgets.Panel +---@field edit plugins.orders.position_overlay.PositionEdit|nil +---@field clear_selection fun(self: plugins.orders.position_overlay.PositionOverlay) +---@overload fun(init_table: table): self +PositionOverlay = defclass(PositionOverlay, overlay.OverlayWidget) +PositionOverlay.ATTRS { + desc = 'Displays and directly edits fort-wide work-order positions.', + default_enabled = true, + viewscreens = 'dwarfmode/Info/WORK_ORDERS/Default', + -- Position fields occupy fixed row positions and are not repositionable. + full_interface = true, + frame = { w = 1, h = 1 }, + version = 1, +} + +function PositionOverlay:init() + active_position_overlay = self + self.positions_visible = true + self.panel_minimized = false + self.position_rows = {} + self.position_fields = {} + self.slot_order_ids = {} + self.edit = nil + self.syncing_fields = true + + local interface_rect = gui.get_interface_rect() + self.frame.w = interface_rect.width + self.frame.h = interface_rect.height + local viewport_size = work_order_list.get_viewport_size() + self:addviews { + widgets.Panel { + view_id = 'positions_panel', + frame = { + l = TOGGLE_PANEL_X - 1, + b = TOGGLE_PANEL_BOTTOM, + w = TOGGLE_PANEL_WIDTH, + h = TOGGLE_PANEL_HEIGHT, + }, + frame_style = gui.MEDIUM_FRAME, + frame_background = gui.CLEAR_PEN, + visible = function() return not self.panel_minimized end, + subviews = { + widgets.Label { + frame = { l = 0, t = 0 }, + text = 'positions', + text_pen = COLOR_WHITE, + text_hpen = POSITION_HOVER_PEN, + auto_width = true, + on_click = self:callback('toggle_positions'), + }, + widgets.RadioButton { + view_id = 'positions_toggle', + frame = { l = 10, t = 0 }, + initial_state = true, + on_change = self:callback('set_positions_visible'), + }, + }, + }, + widgets.Panel { + frame = { + l = TOGGLE_PANEL_X + TOGGLE_PANEL_WIDTH - 4, + b = TOGGLE_PANEL_BOTTOM + TOGGLE_PANEL_HEIGHT - 1, + w = 3, + h = 1, + }, + subviews = { + widgets.Label { + frame = { l = 0, w = 1, h = 1 }, + text = '[', + text_pen = COLOR_RED, + visible = function() return self.panel_minimized end, + }, + widgets.Label { + frame = { l = 1, w = 1, h = 1 }, + text = { { + text = function() + return self.panel_minimized and string.char(31) + or string.char(30) + end, + } }, + text_pen = dfhack.pen.parse { + fg = COLOR_BLACK, + bg = COLOR_GREY, + }, + text_hpen = POSITION_HOVER_PEN, + on_click = self:callback('toggle_panel_minimized'), + }, + widgets.Label { + frame = { r = 0, w = 1, h = 1 }, + text = ']', + text_pen = COLOR_RED, + visible = function() return self.panel_minimized end, + }, + }, + }, + } + self:ensure_position_field_count(viewport_size) + self.syncing_fields = false +end + +--- Adds fields when a larger interface makes more order rows visible. +---@param count integer +function PositionOverlay:ensure_position_field_count(count) + while #self.position_fields < count do + local slot = #self.position_fields + 1 + local field + field = widgets.EditField { + frame = { + l = 1, + r = 1, + h = 1, + }, + visible = function() + return self.edit ~= nil and self.edit.slot == slot + end, + text_pen = EDITOR_TEXT_PEN, + on_char = accept_position_digit, + on_change = function(text) self:on_field_change(slot, text) end, + on_focus = function() self:on_field_focus(slot) end, + on_unfocus = function() + local position_field = self.position_fields[slot] + if position_field then + clear_field_text_selection(position_field) + end + end, + on_submit = function() self:on_field_submit(slot) end, + on_submit2 = function() self:on_field_submit(slot) end, + } + local position_label = widgets.Label { + frame = { + l = 1, + r = 1, + h = 1, + }, + visible = function() + return self.edit == nil or self.edit.slot ~= slot + end, + text = { { + text = function() return field.text end, + } }, + text_pen = INACTIVE_TEXT_PEN, + text_hpen = POSITION_HOVER_PEN, + on_click = function() field:setFocus(true) end, + } + local row = widgets.Panel { + frame = { + l = POSITION_X - 1, + t = work_order_list.get_list_start_y() + + (slot - 1) * ORDER_HEIGHT, + w = get_editor_width() + FIELD_BRACKETS_WIDTH, + h = 1, + }, + visible = function() + return self.positions_visible + and self.slot_order_ids[slot] ~= nil + end, + subviews = { + widgets.Label { + frame = { l = 0, w = 1, h = 1 }, + text = '[', + text_pen = COLOR_RED, + }, + position_label, + field, + widgets.Label { + frame = { r = 0, w = 1, h = 1 }, + text = ']', + text_pen = COLOR_RED, + }, + }, + } + self.position_rows[slot] = row + self.position_fields[slot] = field + self:addviews { row } + + -- A hotkey-less EditField requests focus when added. Row fields must + -- all begin inactive and only acquire focus from a click. + field:setFocus(false) + end +end + +--- Toggles whether the position rows are visible. +function PositionOverlay:toggle_positions() + local toggle = self.subviews.positions_toggle + toggle:setState(not toggle.toggle_state) +end + +--- Shows or hides the position rows. +---@param visible boolean +function PositionOverlay:set_positions_visible(visible) + if self.positions_visible == visible then return end + if not visible then self:clear_selection() end + self.positions_visible = visible + self:sync_position_fields() +end + +--- Shows or hides the bottom control panel. +function PositionOverlay:toggle_panel_minimized() + self.panel_minimized = not self.panel_minimized +end + +--- Starts editing the order currently assigned to a visible row field. +---@param slot integer +function PositionOverlay:on_field_focus(slot) + if self.syncing_fields then return end + + local order_id = self.slot_order_ids[slot] + if order_id == nil then return end + + unfocus_orders_search() + self.edit = { + order_id = order_id, + slot = slot, + text = self.position_fields[slot].text, + } +end + +--- Records text only from the field that owns the active edit. +---@param slot integer +---@param text string +function PositionOverlay:on_field_change(slot, text) + local edit = self.edit + if self.syncing_fields or not edit + or self.slot_order_ids[slot] ~= edit.order_id then + return + end + + edit.text = text +end + +--- Moves the selected order to the entered one-based position. +---@param slot integer +function PositionOverlay:on_field_submit(slot) + local edit = self.edit + if not edit or self.slot_order_ids[slot] ~= edit.order_id then return end + + local current_order_idx = find_order_index(edit.order_id) + if current_order_idx == nil then + self:clear_selection() + dialogs.showMessage('Error', + 'orders: The selected manager order no longer exists.', + COLOR_LIGHTRED) + return + end + + local field = self.position_fields[slot] + local order, error_message = position.move( + current_order_idx + 1, field.text) + if not order then + field:setFocus(true) + dialogs.showMessage('Error', + ('orders: %s'):format(error_message or 'Could not move order.'), + COLOR_LIGHTRED) + return + end + + self:clear_selection() +end + +---@return widgets.EditField|nil field +function PositionOverlay:get_selected_field() + local edit = self.edit + if not edit or self.slot_order_ids[edit.slot] ~= edit.order_id then + return nil + end + return self.position_fields[edit.slot] +end + +--- Cancels the current proposal and clears its field focus. +function PositionOverlay:clear_selection() + local field = self.edit and self.position_fields[self.edit.slot] or nil + self.edit = nil + + if field then + if field.focus then field:setFocus(false) end + clear_field_text_selection(field) + end +end + +function PositionOverlay:overlay_ondisable() + self:clear_selection() +end + +--- Synchronizes the fixed row fields with the current scroll position. +function PositionOverlay:sync_position_fields() + local viewport_size = work_order_list.get_viewport_size() + local viewport_start, viewport_end = + work_order_list.get_visible_order_indices() + local editor_width = get_editor_width() + local old_selected_slot = self.edit and self.edit.slot or nil + local old_selected_field = old_selected_slot + and self.position_fields[old_selected_slot] or nil + local selected_was_focused = old_selected_field + and old_selected_field.focus or false + + self.syncing_fields = true + self:ensure_position_field_count(viewport_size) + + local interface_rect = gui.get_interface_rect() + local overlay_frame = { + l = 0, + t = 0, + w = interface_rect.width, + h = interface_rect.height, + } + local layout_changed = not frames_equal(self.frame, overlay_frame) + if layout_changed then self.frame = overlay_frame end + + for slot, row in ipairs(self.position_rows) do + local row_frame = { + l = POSITION_X - 1, + t = work_order_list.get_list_start_y() + + (slot - 1) * ORDER_HEIGHT, + w = editor_width + FIELD_BRACKETS_WIDTH, + h = 1, + } + if not frames_equal(row.frame, row_frame) then + row.frame = row_frame + layout_changed = true + end + end + + if layout_changed then self:updateLayout() end + + local selected_order_idx = self.edit + and find_order_index(self.edit.order_id) or nil + local selected_is_visible = selected_order_idx ~= nil + and selected_order_idx >= viewport_start + and selected_order_idx <= viewport_end + if self.edit and not selected_is_visible then + self.edit = nil + end + + if self.edit then + self.edit.slot = selected_order_idx - viewport_start + 1 + end + + local orders = df.global.world.manager_orders.all + for slot, field in ipairs(self.position_fields) do + local order_idx = viewport_start + slot - 1 + local has_order = slot <= viewport_size and order_idx <= viewport_end + local order_id = has_order and orders[order_idx].id or nil + self.slot_order_ids[slot] = order_id + + local text = '' + if has_order then + if self.edit and order_id == self.edit.order_id then + text = self.edit.text + else + text = tostring(order_idx + 1) + end + end + if field.text ~= text then field:setText(text) end + end + + local selected_field = self:get_selected_field() + if selected_was_focused and selected_field then + selected_field:setFocus(true) + elseif selected_was_focused and old_selected_field then + old_selected_field:setFocus(false) + end + + self.syncing_fields = false +end + +---@param keys table +---@return boolean +function PositionOverlay:onInput(keys) + if are_order_details_open() then + self:clear_selection() + return false + end + + self:sync_position_fields() + local previous_order_id = self.edit and self.edit.order_id or nil + + if self.edit and (keys._MOUSE_R or keys.LEAVESCREEN) then + self:clear_selection() + return true + end + + -- Position fields only need unmodified numeric editing keys. Cancel the + -- proposal and let DFHack or vanilla DF handle any modified shortcut. + if self.edit and is_modifier_active() then + self:clear_selection() + return false + end + + -- Let an existing row field receive the complete click sequence. This is + -- the same widget-first ordering used by OrdersSearchOverlay. + if PositionOverlay.super.onInput(self, keys) then + local selected_field = self:get_selected_field() + if keys._MOUSE_L and self.edit + and self.edit.order_id ~= previous_order_id + and selected_field then + select_all_field_text(selected_field) + elseif keys._MOUSE_L_DOWN and selected_field + and gui.View.getMousePos(selected_field) then + -- Do not let the remainder of the activation click turn into a + -- partial drag-selection. + select_all_field_text(selected_field) + end + return true + end + + -- Cancel an active proposal on an outside click, but let vanilla handle + -- the click itself. + if keys._MOUSE_L or keys._MOUSE_L_DOWN then + if self.edit then self:clear_selection() end + return false + end + + -- Keep keyboard input in the focused field while allowing all mouse and + -- Work Orders scrolling events through to vanilla. + local selected_field = self:get_selected_field() + if selected_field and selected_field.focus + and not is_mouse_or_scroll_key(keys) then + return true + end + + return false +end + +---@param dc gui.Painter +function PositionOverlay:render(dc) + if are_order_details_open() then + self:clear_selection() + return + end + + self:sync_position_fields() + PositionOverlay.super.render(self, dc) +end + +return _ENV diff --git a/test/plugins/orders.lua b/test/plugins/orders.lua index 74d3720159..3b1e1d84f0 100644 --- a/test/plugins/orders.lua +++ b/test/plugins/orders.lua @@ -2,6 +2,10 @@ config.mode = 'fortress' config.target = 'orders' local workOrderList = require('plugins.orders.work_order_list') +local position = require('plugins.orders.position') +local position_overlay = require('plugins.orders.position_overlay') +local gui = require('gui') +local json = require('json') local FILE_PATH_PATTERN = dfhack.getConfigPath() .. '/orders/%s.json' @@ -296,3 +300,156 @@ function test.work_order_list_geometry() expect.eq(0, viewport_start) expect.eq(9, viewport_end) end + +local FakeManagerOrderVector = {} +FakeManagerOrderVector.__index = function(self, key) + if type(key) == 'number' then return self.values[key + 1] end + return FakeManagerOrderVector[key] +end +FakeManagerOrderVector.__len = function(self) return #self.values end + +function FakeManagerOrderVector:erase(index) + table.remove(self.values, index + 1) +end + +function FakeManagerOrderVector:insert(index, order) + table.insert(self.values, index + 1, order) +end + +local function make_order(id) + return { id = id } +end + +local function make_vector() + return setmetatable({ values = { + make_order(10), + make_order(20), + make_order(30), + } }, FakeManagerOrderVector) +end + +local function get_vector_ids(vector) + local ids = {} + for _, order in ipairs(vector.values) do table.insert(ids, order.id) end + return ids +end + +function test.position_move_vector_preserves_pointer() + local vector = make_vector() + local first = vector[0] + local order, error_message = + position.unit_test_hooks.move_in_vector(vector, 1, 3) + if not order then expect.fail(error_message) return end + + expect.eq(first, order) + expect.eq(first, vector[2]) + expect.table_eq({ 20, 30, 10 }, get_vector_ids(vector)) + + order, error_message = + position.unit_test_hooks.move_in_vector(vector, 3, 1) + if not order then expect.fail(error_message) return end + expect.eq(first, order) + expect.eq(first, vector[0]) + expect.table_eq({ 10, 20, 30 }, get_vector_ids(vector)) +end + +function test.position_move_vector_rejects_invalid_and_noop_is_atomic() + local invalid_values = { 'one', '1.5', 0 } + for _, value in ipairs(invalid_values) do + local vector = make_vector() + local result = position.unit_test_hooks.move_in_vector(vector, 1, value) + expect.nil_(result, ('value %q should be rejected'):format(value)) + expect.table_eq({ 10, 20, 30 }, get_vector_ids(vector)) + end + + local vector = make_vector() + local second = vector[1] + local order, error_message = + position.unit_test_hooks.move_in_vector(vector, 2, 2) + if not order then expect.fail(error_message) return end + expect.eq(second, order) + expect.table_eq({ 10, 20, 30 }, get_vector_ids(vector)) + + order = position.unit_test_hooks.move_in_vector(vector, 1, 4) + expect.nil_(order) + expect.table_eq({ 10, 20, 30 }, get_vector_ids(vector)) + + vector = make_vector() + local ok, result = pcall(position.unit_test_hooks.move_in_vector, + vector, 'one', 2) + expect.true_(ok, 'a malformed source position should not throw') + expect.nil_(result) + expect.table_eq({ 10, 20, 30 }, get_vector_ids(vector)) +end + +function test.position_overlay_focus_lifecycle() + local position_widget = position_overlay.PositionOverlay {} + local screen_rect = gui.mkdims_wh(0, 0, dfhack.screen.getWindowSize()) + position_widget:updateLayout(gui.ViewRect { rect = screen_rect }) + + local first_row = position_widget.position_rows[1] + local first_field = position_widget.position_fields[1] + position_widget.slot_order_ids[1] = 123 + first_field:setFocus(true) + expect.eq(123, position_widget.edit.order_id) + + position_overlay.clear_active_edit() + expect.nil_(position_widget.edit) + expect.false_(first_field.focus) + + first_field:setFocus(true) + position_widget:set_positions_visible(false) + expect.nil_(position_widget.edit) + expect.false_(first_field.focus) + expect.false_(first_row.visible()) +end + +function test.position_overlay_edit_follows_reordered_order() + local orders_to_import = {} + for _, id in ipairs { 10, 20, 30 } do + table.insert(orders_to_import, { + amount_left = 1, + amount_total = 1, + frequency = 'OneTime', + id = id, + is_active = false, + is_validated = true, + job = 'ConstructTable', + }) + end + local output, status = run_orders_import(json.encode(orders_to_import)) + expect.eq(CR_OK, status, output) + + local work_orders = + df.global.game.main_interface.info.work_orders + local original_scroll_position = + work_orders.scroll_position_work_orders + dfhack.with_finalize( + function() + work_orders.scroll_position_work_orders = original_scroll_position + end, + function() + work_orders.scroll_position_work_orders = 0 + local position_widget = position_overlay.PositionOverlay {} + local screen_rect = + gui.mkdims_wh(0, 0, dfhack.screen.getWindowSize()) + position_widget:updateLayout(gui.ViewRect { rect = screen_rect }) + position_widget:sync_position_fields() + + local orders = df.global.world.manager_orders.all + local edited_order = orders[0] + local first_field = position_widget.position_fields[1] + first_field:setFocus(true) + first_field:setText('3') + + local moved_order, error_message = position.move(1, 2) + if not moved_order then expect.fail(error_message) return end + position_widget:sync_position_fields() + + local selected_field = position_widget:get_selected_field() + expect.eq(edited_order, orders[1]) + expect.eq(position_widget.position_fields[2], selected_field) + expect.eq('3', selected_field.text) + expect.true_(selected_field.focus) + end) +end From 24bf92fd04b3b2fbdc6295939bf33e96c660a74e Mon Sep 17 00:00:00 2001 From: Rodrigo Cardoso Buske Date: Tue, 8 Sep 2026 21:40:49 -0300 Subject: [PATCH 4/6] orders: document position editing overlay --- docs/changelog.txt | 2 ++ docs/plugins/orders.rst | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/docs/changelog.txt b/docs/changelog.txt index 5a18e4be5e..353472353a 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -61,6 +61,8 @@ Template for new versions: ## New Features - `autodump`: new ``undestroy`` option reverts pending item destruction while the game is still paused +- `orders`: add an inline overlay for viewing and changing manager-order positions + ## Fixes - Fix broken weather lookup in ``World::ReadCurrentWeather`` - Fixed a possible hang or assertion failure when pressing a hotkey while a DFHack GUI window was open but unfocused diff --git a/docs/plugins/orders.rst b/docs/plugins/orders.rst index d89475a328..a45d89403b 100644 --- a/docs/plugins/orders.rst +++ b/docs/plugins/orders.rst @@ -72,6 +72,16 @@ useful for when the conditions were true when the order started, but they have become false and now you're just getting repeated cancellation spam as the order cannot be fulfilled. +orders.position +~~~~~~~~~~~~~~~ + +Displays one-based positions beside fort-wide work orders. Use the positions +toggle to show or hide the position fields. Click a position, enter a one-based +destination, and press Enter to move the order. +Modified shortcuts cancel the edit and pass through to DFHack or Dwarf +Fortress. The inline operation is silent when successful and shows invalid +input in an error dialog. + orders.skillrestrictions and orders.laborrestrictions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 90c8d5e50822c6ba232fb37eb07dc5e50aebd128 Mon Sep 17 00:00:00 2001 From: Rodrigo Cardoso Buske Date: Tue, 15 Sep 2026 23:06:36 -0300 Subject: [PATCH 5/6] orders: use camel case work order list API --- plugins/lua/orders/position_overlay.lua | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/lua/orders/position_overlay.lua b/plugins/lua/orders/position_overlay.lua index 3139e7ecc8..e5d2e62559 100644 --- a/plugins/lua/orders/position_overlay.lua +++ b/plugins/lua/orders/position_overlay.lua @@ -4,7 +4,7 @@ local dialogs = require('gui.dialogs') local gui = require('gui') local position = require('plugins.orders.position') local overlay = require('plugins.overlay') -local work_order_list = require('plugins.orders.work_order_list') +local workOrderList = require('plugins.orders.work_order_list') local widgets = require('gui.widgets') -- Keep the bracketed positions in the left-side gutter beside each order. @@ -23,7 +23,7 @@ local TOGGLE_PANEL_BOTTOM = 6 local TOGGLE_PANEL_WIDTH = 15 local TOGGLE_PANEL_HEIGHT = 3 -local ORDER_HEIGHT = work_order_list.ORDER_HEIGHT +local ORDER_HEIGHT = workOrderList.ORDER_HEIGHT ---@param order_id integer ---@return integer|nil order_idx @@ -163,7 +163,7 @@ function PositionOverlay:init() local interface_rect = gui.get_interface_rect() self.frame.w = interface_rect.width self.frame.h = interface_rect.height - local viewport_size = work_order_list.get_viewport_size() + local viewport_size = workOrderList.getViewportSize() self:addviews { widgets.Panel { view_id = 'positions_panel', @@ -282,7 +282,7 @@ function PositionOverlay:ensure_position_field_count(count) local row = widgets.Panel { frame = { l = POSITION_X - 1, - t = work_order_list.get_list_start_y() + t = workOrderList.getListStartY() + (slot - 1) * ORDER_HEIGHT, w = get_editor_width() + FIELD_BRACKETS_WIDTH, h = 1, @@ -420,9 +420,9 @@ end --- Synchronizes the fixed row fields with the current scroll position. function PositionOverlay:sync_position_fields() - local viewport_size = work_order_list.get_viewport_size() + local viewport_size = workOrderList.getViewportSize() local viewport_start, viewport_end = - work_order_list.get_visible_order_indices() + workOrderList.getVisibleOrderIndices() local editor_width = get_editor_width() local old_selected_slot = self.edit and self.edit.slot or nil local old_selected_field = old_selected_slot @@ -446,7 +446,7 @@ function PositionOverlay:sync_position_fields() for slot, row in ipairs(self.position_rows) do local row_frame = { l = POSITION_X - 1, - t = work_order_list.get_list_start_y() + t = workOrderList.getListStartY() + (slot - 1) * ORDER_HEIGHT, w = editor_width + FIELD_BRACKETS_WIDTH, h = 1, From 865021bf561a403a1f431390025b8d25e45cf3ad Mon Sep 17 00:00:00 2001 From: Rodrigo Cardoso Buske Date: Tue, 15 Sep 2026 23:20:39 -0300 Subject: [PATCH 6/6] orders: standardize position overlay API names --- plugins/lua/orders.lua | 8 +-- plugins/lua/orders/position.lua | 4 +- plugins/lua/orders/position_overlay.lua | 76 ++++++++++++------------- test/plugins/orders.lua | 28 ++++----- 4 files changed, 58 insertions(+), 58 deletions(-) diff --git a/plugins/lua/orders.lua b/plugins/lua/orders.lua index 75e1a90404..982a577153 100644 --- a/plugins/lua/orders.lua +++ b/plugins/lua/orders.lua @@ -4,7 +4,7 @@ local dialogs = require('gui.dialogs') local gui = require('gui') local overlay = require('plugins.overlay') local workOrderList = require('plugins.orders.work_order_list') -local position_overlay = require('plugins.orders.position_overlay') +local positionOverlay = require('plugins.orders.position_overlay') local textures = require('gui.textures') local utils = require('utils') local widgets = require('gui.widgets') @@ -777,7 +777,7 @@ function OrdersSearchOverlay:init() frame={t=0, l=0}, key='CUSTOM_ALT_S', on_change=self:callback('update_filter'), - on_focus=position_overlay.clear_active_edit, + on_focus=positionOverlay.clearActiveEdit, on_submit=self:callback('on_submit'), on_submit2=self:callback('on_submit2'), }, @@ -829,7 +829,7 @@ function OrdersSearchOverlay:init() main_panel, minimized_panel, } - position_overlay.bind_orders_search_field( + positionOverlay.bindOrdersSearchField( self.subviews.filter) self.minimized = false @@ -991,7 +991,7 @@ end OVERLAY_WIDGETS = { recheck=RecheckOverlay, importexport=OrdersOverlay, - position=position_overlay.PositionOverlay, + position=positionOverlay.PositionOverlay, search=OrdersSearchOverlay, skillrestrictions=SkillRestrictionOverlay, laborrestrictions=LaborRestrictionsOverlay, diff --git a/plugins/lua/orders/position.lua b/plugins/lua/orders/position.lua index 80c194a884..57b3414c80 100644 --- a/plugins/lua/orders/position.lua +++ b/plugins/lua/orders/position.lua @@ -68,8 +68,8 @@ function move(current_position, new_value) df.global.world.manager_orders.all, current_position, new_value) end -unit_test_hooks = { - move_in_vector = move_in_vector, +unitTestHooks = { + moveInVector = move_in_vector, } return _ENV diff --git a/plugins/lua/orders/position_overlay.lua b/plugins/lua/orders/position_overlay.lua index e5d2e62559..bb3d40030c 100644 --- a/plugins/lua/orders/position_overlay.lua +++ b/plugins/lua/orders/position_overlay.lua @@ -59,13 +59,13 @@ local active_position_overlay --- Registers the search field that should lose focus when position editing begins. ---@param field widgets.EditField|nil -function bind_orders_search_field(field) +function bindOrdersSearchField(field) orders_search_field = field end --- Cancels the active position edit when the Orders search field gains focus. -function clear_active_edit() - if active_position_overlay then active_position_overlay:clear_selection() end +function clearActiveEdit() + if active_position_overlay then active_position_overlay:clearSelection() end end local function unfocus_orders_search() @@ -137,7 +137,7 @@ end ---@class plugins.orders.position_overlay.PositionOverlay: dfhack.class, widgets.Panel ---@field super widgets.Panel ---@field edit plugins.orders.position_overlay.PositionEdit|nil ----@field clear_selection fun(self: plugins.orders.position_overlay.PositionOverlay) +---@field clearSelection fun(self: plugins.orders.position_overlay.PositionOverlay) ---@overload fun(init_table: table): self PositionOverlay = defclass(PositionOverlay, overlay.OverlayWidget) PositionOverlay.ATTRS { @@ -183,13 +183,13 @@ function PositionOverlay:init() text_pen = COLOR_WHITE, text_hpen = POSITION_HOVER_PEN, auto_width = true, - on_click = self:callback('toggle_positions'), + on_click = self:callback('togglePositions'), }, widgets.RadioButton { view_id = 'positions_toggle', frame = { l = 10, t = 0 }, initial_state = true, - on_change = self:callback('set_positions_visible'), + on_change = self:callback('setPositionsVisible'), }, }, }, @@ -220,7 +220,7 @@ function PositionOverlay:init() bg = COLOR_GREY, }, text_hpen = POSITION_HOVER_PEN, - on_click = self:callback('toggle_panel_minimized'), + on_click = self:callback('togglePanelMinimized'), }, widgets.Label { frame = { r = 0, w = 1, h = 1 }, @@ -231,13 +231,13 @@ function PositionOverlay:init() }, }, } - self:ensure_position_field_count(viewport_size) + self:ensurePositionFieldCount(viewport_size) self.syncing_fields = false end --- Adds fields when a larger interface makes more order rows visible. ---@param count integer -function PositionOverlay:ensure_position_field_count(count) +function PositionOverlay:ensurePositionFieldCount(count) while #self.position_fields < count do local slot = #self.position_fields + 1 local field @@ -252,16 +252,16 @@ function PositionOverlay:ensure_position_field_count(count) end, text_pen = EDITOR_TEXT_PEN, on_char = accept_position_digit, - on_change = function(text) self:on_field_change(slot, text) end, - on_focus = function() self:on_field_focus(slot) end, + on_change = function(text) self:onFieldChange(slot, text) end, + on_focus = function() self:onFieldFocus(slot) end, on_unfocus = function() local position_field = self.position_fields[slot] if position_field then clear_field_text_selection(position_field) end end, - on_submit = function() self:on_field_submit(slot) end, - on_submit2 = function() self:on_field_submit(slot) end, + on_submit = function() self:onFieldSubmit(slot) end, + on_submit2 = function() self:onFieldSubmit(slot) end, } local position_label = widgets.Label { frame = { @@ -317,28 +317,28 @@ function PositionOverlay:ensure_position_field_count(count) end --- Toggles whether the position rows are visible. -function PositionOverlay:toggle_positions() +function PositionOverlay:togglePositions() local toggle = self.subviews.positions_toggle toggle:setState(not toggle.toggle_state) end --- Shows or hides the position rows. ---@param visible boolean -function PositionOverlay:set_positions_visible(visible) +function PositionOverlay:setPositionsVisible(visible) if self.positions_visible == visible then return end - if not visible then self:clear_selection() end + if not visible then self:clearSelection() end self.positions_visible = visible - self:sync_position_fields() + self:syncPositionFields() end --- Shows or hides the bottom control panel. -function PositionOverlay:toggle_panel_minimized() +function PositionOverlay:togglePanelMinimized() self.panel_minimized = not self.panel_minimized end --- Starts editing the order currently assigned to a visible row field. ---@param slot integer -function PositionOverlay:on_field_focus(slot) +function PositionOverlay:onFieldFocus(slot) if self.syncing_fields then return end local order_id = self.slot_order_ids[slot] @@ -355,7 +355,7 @@ end --- Records text only from the field that owns the active edit. ---@param slot integer ---@param text string -function PositionOverlay:on_field_change(slot, text) +function PositionOverlay:onFieldChange(slot, text) local edit = self.edit if self.syncing_fields or not edit or self.slot_order_ids[slot] ~= edit.order_id then @@ -367,13 +367,13 @@ end --- Moves the selected order to the entered one-based position. ---@param slot integer -function PositionOverlay:on_field_submit(slot) +function PositionOverlay:onFieldSubmit(slot) local edit = self.edit if not edit or self.slot_order_ids[slot] ~= edit.order_id then return end local current_order_idx = find_order_index(edit.order_id) if current_order_idx == nil then - self:clear_selection() + self:clearSelection() dialogs.showMessage('Error', 'orders: The selected manager order no longer exists.', COLOR_LIGHTRED) @@ -391,11 +391,11 @@ function PositionOverlay:on_field_submit(slot) return end - self:clear_selection() + self:clearSelection() end ---@return widgets.EditField|nil field -function PositionOverlay:get_selected_field() +function PositionOverlay:getSelectedField() local edit = self.edit if not edit or self.slot_order_ids[edit.slot] ~= edit.order_id then return nil @@ -404,7 +404,7 @@ function PositionOverlay:get_selected_field() end --- Cancels the current proposal and clears its field focus. -function PositionOverlay:clear_selection() +function PositionOverlay:clearSelection() local field = self.edit and self.position_fields[self.edit.slot] or nil self.edit = nil @@ -415,11 +415,11 @@ function PositionOverlay:clear_selection() end function PositionOverlay:overlay_ondisable() - self:clear_selection() + self:clearSelection() end --- Synchronizes the fixed row fields with the current scroll position. -function PositionOverlay:sync_position_fields() +function PositionOverlay:syncPositionFields() local viewport_size = workOrderList.getViewportSize() local viewport_start, viewport_end = workOrderList.getVisibleOrderIndices() @@ -431,7 +431,7 @@ function PositionOverlay:sync_position_fields() and old_selected_field.focus or false self.syncing_fields = true - self:ensure_position_field_count(viewport_size) + self:ensurePositionFieldCount(viewport_size) local interface_rect = gui.get_interface_rect() local overlay_frame = { @@ -490,7 +490,7 @@ function PositionOverlay:sync_position_fields() if field.text ~= text then field:setText(text) end end - local selected_field = self:get_selected_field() + local selected_field = self:getSelectedField() if selected_was_focused and selected_field then selected_field:setFocus(true) elseif selected_was_focused and old_selected_field then @@ -504,29 +504,29 @@ end ---@return boolean function PositionOverlay:onInput(keys) if are_order_details_open() then - self:clear_selection() + self:clearSelection() return false end - self:sync_position_fields() + self:syncPositionFields() local previous_order_id = self.edit and self.edit.order_id or nil if self.edit and (keys._MOUSE_R or keys.LEAVESCREEN) then - self:clear_selection() + self:clearSelection() return true end -- Position fields only need unmodified numeric editing keys. Cancel the -- proposal and let DFHack or vanilla DF handle any modified shortcut. if self.edit and is_modifier_active() then - self:clear_selection() + self:clearSelection() return false end -- Let an existing row field receive the complete click sequence. This is -- the same widget-first ordering used by OrdersSearchOverlay. if PositionOverlay.super.onInput(self, keys) then - local selected_field = self:get_selected_field() + local selected_field = self:getSelectedField() if keys._MOUSE_L and self.edit and self.edit.order_id ~= previous_order_id and selected_field then @@ -543,13 +543,13 @@ function PositionOverlay:onInput(keys) -- Cancel an active proposal on an outside click, but let vanilla handle -- the click itself. if keys._MOUSE_L or keys._MOUSE_L_DOWN then - if self.edit then self:clear_selection() end + if self.edit then self:clearSelection() end return false end -- Keep keyboard input in the focused field while allowing all mouse and -- Work Orders scrolling events through to vanilla. - local selected_field = self:get_selected_field() + local selected_field = self:getSelectedField() if selected_field and selected_field.focus and not is_mouse_or_scroll_key(keys) then return true @@ -561,11 +561,11 @@ end ---@param dc gui.Painter function PositionOverlay:render(dc) if are_order_details_open() then - self:clear_selection() + self:clearSelection() return end - self:sync_position_fields() + self:syncPositionFields() PositionOverlay.super.render(self, dc) end diff --git a/test/plugins/orders.lua b/test/plugins/orders.lua index 3b1e1d84f0..e726509f8b 100644 --- a/test/plugins/orders.lua +++ b/test/plugins/orders.lua @@ -3,7 +3,7 @@ config.target = 'orders' local workOrderList = require('plugins.orders.work_order_list') local position = require('plugins.orders.position') -local position_overlay = require('plugins.orders.position_overlay') +local positionOverlay = require('plugins.orders.position_overlay') local gui = require('gui') local json = require('json') @@ -338,7 +338,7 @@ function test.position_move_vector_preserves_pointer() local vector = make_vector() local first = vector[0] local order, error_message = - position.unit_test_hooks.move_in_vector(vector, 1, 3) + position.unitTestHooks.moveInVector(vector, 1, 3) if not order then expect.fail(error_message) return end expect.eq(first, order) @@ -346,7 +346,7 @@ function test.position_move_vector_preserves_pointer() expect.table_eq({ 20, 30, 10 }, get_vector_ids(vector)) order, error_message = - position.unit_test_hooks.move_in_vector(vector, 3, 1) + position.unitTestHooks.moveInVector(vector, 3, 1) if not order then expect.fail(error_message) return end expect.eq(first, order) expect.eq(first, vector[0]) @@ -357,7 +357,7 @@ function test.position_move_vector_rejects_invalid_and_noop_is_atomic() local invalid_values = { 'one', '1.5', 0 } for _, value in ipairs(invalid_values) do local vector = make_vector() - local result = position.unit_test_hooks.move_in_vector(vector, 1, value) + local result = position.unitTestHooks.moveInVector(vector, 1, value) expect.nil_(result, ('value %q should be rejected'):format(value)) expect.table_eq({ 10, 20, 30 }, get_vector_ids(vector)) end @@ -365,17 +365,17 @@ function test.position_move_vector_rejects_invalid_and_noop_is_atomic() local vector = make_vector() local second = vector[1] local order, error_message = - position.unit_test_hooks.move_in_vector(vector, 2, 2) + position.unitTestHooks.moveInVector(vector, 2, 2) if not order then expect.fail(error_message) return end expect.eq(second, order) expect.table_eq({ 10, 20, 30 }, get_vector_ids(vector)) - order = position.unit_test_hooks.move_in_vector(vector, 1, 4) + order = position.unitTestHooks.moveInVector(vector, 1, 4) expect.nil_(order) expect.table_eq({ 10, 20, 30 }, get_vector_ids(vector)) vector = make_vector() - local ok, result = pcall(position.unit_test_hooks.move_in_vector, + local ok, result = pcall(position.unitTestHooks.moveInVector, vector, 'one', 2) expect.true_(ok, 'a malformed source position should not throw') expect.nil_(result) @@ -383,7 +383,7 @@ function test.position_move_vector_rejects_invalid_and_noop_is_atomic() end function test.position_overlay_focus_lifecycle() - local position_widget = position_overlay.PositionOverlay {} + local position_widget = positionOverlay.PositionOverlay {} local screen_rect = gui.mkdims_wh(0, 0, dfhack.screen.getWindowSize()) position_widget:updateLayout(gui.ViewRect { rect = screen_rect }) @@ -393,12 +393,12 @@ function test.position_overlay_focus_lifecycle() first_field:setFocus(true) expect.eq(123, position_widget.edit.order_id) - position_overlay.clear_active_edit() + positionOverlay.clearActiveEdit() expect.nil_(position_widget.edit) expect.false_(first_field.focus) first_field:setFocus(true) - position_widget:set_positions_visible(false) + position_widget:setPositionsVisible(false) expect.nil_(position_widget.edit) expect.false_(first_field.focus) expect.false_(first_row.visible()) @@ -430,11 +430,11 @@ function test.position_overlay_edit_follows_reordered_order() end, function() work_orders.scroll_position_work_orders = 0 - local position_widget = position_overlay.PositionOverlay {} + local position_widget = positionOverlay.PositionOverlay {} local screen_rect = gui.mkdims_wh(0, 0, dfhack.screen.getWindowSize()) position_widget:updateLayout(gui.ViewRect { rect = screen_rect }) - position_widget:sync_position_fields() + position_widget:syncPositionFields() local orders = df.global.world.manager_orders.all local edited_order = orders[0] @@ -444,9 +444,9 @@ function test.position_overlay_edit_follows_reordered_order() local moved_order, error_message = position.move(1, 2) if not moved_order then expect.fail(error_message) return end - position_widget:sync_position_fields() + position_widget:syncPositionFields() - local selected_field = position_widget:get_selected_field() + local selected_field = position_widget:getSelectedField() expect.eq(edited_order, orders[1]) expect.eq(position_widget.position_fields[2], selected_field) expect.eq('3', selected_field.text)