From f5e854f8ce63f09fd9cf37408545a601329c9bae Mon Sep 17 00:00:00 2001 From: Rodrigo Cardoso Buske Date: Tue, 1 Sep 2026 19:42:19 -0300 Subject: [PATCH 1/3] orders: add position management commands and overlay --- plugins/lua/orders.lua | 99 ++--- plugins/lua/orders/position.lua | 284 ++++++++++++++ plugins/lua/orders/position_overlay.lua | 478 ++++++++++++++++++++++++ plugins/lua/orders/work_order_list.lua | 85 +++++ plugins/orders.cpp | 59 +++ 5 files changed, 934 insertions(+), 71 deletions(-) create mode 100644 plugins/lua/orders/position.lua create mode 100644 plugins/lua/orders/position_overlay.lua create mode 100644 plugins/lua/orders/work_order_list.lua diff --git a/plugins/lua/orders.lua b/plugins/lua/orders.lua index a470e3e2ce..ae1d37f081 100644 --- a/plugins/lua/orders.lua +++ b/plugins/lua/orders.lua @@ -3,10 +3,21 @@ local _ENV = mkmodule('plugins.orders') local dialogs = require('gui.dialogs') local gui = require('gui') local overlay = require('plugins.overlay') +local position = require('plugins.orders.position') +local position_overlay = require('plugins.orders.position_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') +--- Handles Lua-backed subcommands forwarded by the native orders plugin. +---@param args string[] +---@return boolean success +---@return string? error_message +function parse_commandline(args) + return position.parse_commandline(args) +end + -- -- OrdersOverlay -- @@ -714,12 +725,10 @@ 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 +-- Immediately follows the position overlay at its four-character minimum. +-- Very large position values may expand back into this fixed indicator column. +local ARROW_X = 11 +local SEARCH_FILTER_VIEW_ID = 'filter' local SELECTED_PEN = dfhack.pen.parse{fg=COLOR_BLACK, bg=COLOR_WHITE, bold=true} local MATCH_PEN = dfhack.pen.parse{fg=COLOR_WHITE, bg=COLOR_BLACK, bold=true} @@ -756,63 +765,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.', @@ -833,10 +785,11 @@ function OrdersSearchOverlay:init() visible=function() return not self.minimized end, subviews={ widgets.EditField{ - view_id='filter', + view_id=SEARCH_FILTER_VIEW_ID, 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'), }, @@ -888,6 +841,8 @@ function OrdersSearchOverlay:init() main_panel, minimized_panel, } + position_overlay.bind_orders_search_field( + self.subviews[SEARCH_FILTER_VIEW_ID]) self.minimized = false self.matched_indices = {} @@ -906,7 +861,7 @@ function OrdersSearchOverlay:overlay_onupdate() end function OrdersSearchOverlay:update_filter() - local text = self.subviews.filter.text + local text = self.subviews[SEARCH_FILTER_VIEW_ID].text self.matched_indices = perform_search(text) self.current_match_idx = 0 @@ -919,16 +874,16 @@ end function OrdersSearchOverlay:on_submit() self:cycle_match(1) - self.subviews.filter:setFocus(true) + self.subviews[SEARCH_FILTER_VIEW_ID]:setFocus(true) end function OrdersSearchOverlay:on_submit2() self:cycle_match(-1) - self.subviews.filter:setFocus(true) + self.subviews[SEARCH_FILTER_VIEW_ID]:setFocus(true) end function OrdersSearchOverlay:cycle_match(direction) - local search_text = self.subviews.filter.text + local search_text = self.subviews[SEARCH_FILTER_VIEW_ID].text local new_matches = perform_search(search_text) @@ -952,7 +907,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 @@ -987,7 +943,7 @@ end function OrdersSearchOverlay:onInput(keys) if mi.job_details.open then return end - local filter_field = self.subviews.filter + local filter_field = self.subviews[SEARCH_FILTER_VIEW_ID] if not filter_field then return false end -- Unfocus search on right-click @@ -1030,7 +986,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 @@ -1047,6 +1003,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..e6b334e7aa --- /dev/null +++ b/plugins/lua/orders/position.lua @@ -0,0 +1,284 @@ +local _ENV = mkmodule('plugins.orders.position') + +---@alias MoveOrderStatus 'moved'|'unchanged' + +---@class MoveOrderResult +---@field status MoveOrderStatus +---@field order df.manager_order Existing order pointer that was selected. +---@field current_position integer Original one-based position. +---@field new_position integer Final one-based position. +---@field previous_order? df.manager_order Order preceding the moved order. + +---@class ManagerOrderPositionRow +---@field position string +---@field id? string +---@field frequency string +---@field progress string +---@field name string + +local MOVE_USAGE = + 'Usage: orders move [--show-id]' +local POSITIONS_USAGE = 'Usage: orders positions [--show-id]' + +---@param args string[] +---@return boolean|nil show_id +---@return string[]|nil positionals +---@return string|nil error_message +local function parse_show_id(args) + local show_id = false + local positionals = {} + for _, arg in ipairs(args) do + if arg == '--show-id' then + show_id = true + elseif arg:sub(1, 2) == '--' then + return nil, nil, ('Unknown option: %s'):format(arg) + else + table.insert(positionals, arg) + end + end + return show_id, positionals +end + +---@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 + +---@param order df.manager_order|table +---@param show_id boolean +---@param get_order_name fun(order: df.manager_order|table): string +---@return string description +local function describe_order(order, show_id, get_order_name) + local order_name = get_order_name(order) + if show_id then + return ('order ID %d %q'):format(order.id, order_name) + end + return ('order %q'):format(order_name) +end + +--- Validates and moves an existing order pointer within a zero-based vector. +---@param orders df.manager_order[] +---@param current_value string|number One-based current position. +---@param new_value string|number One-based destination position. +---@return MoveOrderResult|nil result +---@return string|nil error_message +local function move_in_vector(orders, current_value, new_value) + local current_position, current_error = + parse_position(current_value, 'Current position') + if not 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 order_count == 0 then + return nil, 'There are no manager orders to move.' + end + + if current_position > order_count then + return nil, ('Current position %d is outside the valid range 1..%d.'): + format(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 = current_position - 1 + local order = orders[current_index] + if current_position == new_position then + return { + status = 'unchanged', + order = order, + current_position = current_position, + new_position = new_position, + } + end + + -- DF containers use zero-based indices. Erasing a pointer-vector cell does + -- not delete its pointee, so retain and reinsert the existing order. + local new_index = new_position - 1 + orders:erase(current_index) + orders:insert(new_index, order) + + return { + status = 'moved', + order = order, + current_position = current_position, + new_position = new_position, + previous_order = new_position > 1 and orders[new_index - 1] or nil, + } +end + +--- Moves an existing manager-order pointer to a one-based vector position. +---@param current_value string|number +---@param new_value string|number +---@return MoveOrderResult|nil result +---@return string|nil error_message +function move(current_value, new_value) + if not dfhack.isMapLoaded() or not dfhack.world.isFortressMode() then + return nil, 'A fortress map must be loaded to move manager orders.' + end + + return move_in_vector( + df.global.world.manager_orders.all, current_value, new_value) +end + +---@param result MoveOrderResult +---@param show_id boolean +---@param get_order_name fun(order: df.manager_order|table): string +---@return string +local function format_move_result(result, show_id, get_order_name) + local order_description = describe_order( + result.order, show_id, get_order_name) + if result.status == 'unchanged' then + return ('No changes made; %s is already at position %d.'):format( + order_description, result.current_position) + end + if result.new_position == 1 then + return ('Moved %s to the first position.'):format(order_description) + end + return ('Moved %s to the position after %s.'):format( + order_description, + describe_order(result.previous_order, show_id, get_order_name)) +end + +--- Prints a compact table of current fort-wide manager orders. +---@param show_id? boolean +---@return boolean success +---@return string? error_message +function print_positions(show_id) + if not dfhack.isMapLoaded() or not dfhack.world.isFortressMode() then + return false, 'A fortress map must be loaded to list manager orders.' + end + + local orders = df.global.world.manager_orders.all + if #orders == 0 then + print('No manager orders.') + return true + end + + ---@type ManagerOrderPositionRow[] + local rows = {} + local position_width = #'POS' + local id_width = #'ID' + local frequency_width = #'FREQ' + local progress_width = #'QTY' + + for order_index = 0, #orders - 1 do + local order = orders[order_index] + local frequency = df.workquota_frequency_type[order.frequency] + or ('Unknown(%d)'):format(order.frequency) + local row = { + position = tostring(order_index + 1), + frequency = frequency, + progress = ('%d/%d'):format(order.amount_left, order.amount_total), + name = dfhack.job.getManagerOrderName(order), + } + if show_id then row.id = tostring(order.id) end + table.insert(rows, row) + position_width = math.max(position_width, #row.position) + if row.id then id_width = math.max(id_width, #row.id) end + frequency_width = math.max(frequency_width, #row.frequency) + progress_width = math.max(progress_width, #row.progress) + end + + if show_id then + local row_format = ('%%%ds %%%ds %%-%ds %%%ds %%s'):format( + position_width, id_width, frequency_width, progress_width) + print(row_format:format('POS', 'ID', 'FREQ', 'QTY', 'NAME')) + for _, row in ipairs(rows) do + print(row_format:format( + row.position, row.id, row.frequency, row.progress, row.name)) + end + return true + end + + local row_format = ('%%%ds %%-%ds %%%ds %%s'):format( + position_width, frequency_width, progress_width) + print(row_format:format('POS', 'FREQ', 'QTY', 'NAME')) + for _, row in ipairs(rows) do + print(row_format:format( + row.position, row.frequency, row.progress, row.name)) + end + return true +end + +---@param args string[] +---@return boolean success +---@return string? error_message +local function run_positions(args) + local show_id, positionals, option_error = parse_show_id(args) + if option_error then + return false, option_error .. '\n' .. POSITIONS_USAGE + end + positionals = positionals or {} + if #positionals ~= 0 then + return false, + ('Expected no positional arguments for positions, but received ' + .. '%d.\n%s'):format(#positionals, POSITIONS_USAGE) + end + return print_positions(show_id) +end + +---@param args string[] +---@return boolean success +---@return string? error_message +local function run_move(args) + local show_id, positionals, option_error = parse_show_id(args) + if option_error then return false, option_error .. '\n' .. MOVE_USAGE end + positionals = positionals or {} + if #positionals ~= 2 then + return false, + ('Expected exactly two positions, but received %d.\n%s'): + format(#positionals, MOVE_USAGE) + end + + local result, error_message = move(positionals[1], positionals[2]) + if not result then return false, error_message or 'Could not move order.' end + print(format_move_result( + result, show_id or false, dfhack.job.getManagerOrderName)) + return true +end + +--- Handles the Lua-backed subcommands forwarded by the orders plugin. +---@param args string[] +---@return boolean success +---@return string? error_message +function parse_commandline(args) + local command = table.remove(args, 1) + if command == 'positions' then + return run_positions(args) + elseif command == 'move' then + return run_move(args) + end + return false, + ('Unknown Lua orders subcommand: %s'):format(tostring(command)) +end + +unit_test_hooks = { + format_move_result = function(result, show_id) + return format_move_result( + result, show_id, function(order) return order.name end) + end, + move_in_vector = move_in_vector, + parse_show_id = parse_show_id, +} + +return _ENV diff --git a/plugins/lua/orders/position_overlay.lua b/plugins/lua/orders/position_overlay.lua new file mode 100644 index 0000000000..0cc6944fcb --- /dev/null +++ b/plugins/lua/orders/position_overlay.lua @@ -0,0 +1,478 @@ +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 POSITION_TEXT_PEN = COLOR_LIGHTCYAN +local POSITION_HOVER_PEN = dfhack.pen.parse { + fg = COLOR_BLACK, + bg = COLOR_WHITE, +} + +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 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_position_width() + return math.max(3, #tostring(#df.global.world.manager_orders.all)) +end + +---@return integer +local function get_editor_width() + return math.max(MIN_EDITOR_WIDTH, get_position_width()) +end + +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 a fixed gutter and are not player-repositionable. + full_interface = true, + frame = { w = MIN_EDITOR_WIDTH + FIELD_BRACKETS_WIDTH, h = 1 }, + version = 1, +} + +function PositionOverlay:init() + active_position_overlay = self + self.position_rows = {} + self.position_fields = {} + self.slot_order_ids = {} + self.selected_order_id = nil + self.selected_slot = nil + self.edit_text = nil + self.syncing_fields = true + + local viewport_size = work_order_list.get_viewport_size() + self.frame.w = get_editor_width() + FIELD_BRACKETS_WIDTH + self.frame.h = math.max(1, viewport_size * ORDER_HEIGHT) + 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.selected_slot == slot end, + text_pen = POSITION_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.selected_slot ~= slot end, + text = { { + text = function() return field.text end, + } }, + text_pen = POSITION_TEXT_PEN, + text_hpen = POSITION_HOVER_PEN, + on_click = function() field:setFocus(true) end, + } + local row = widgets.Panel { + frame = { + l = 0, + t = (slot - 1) * ORDER_HEIGHT, + w = get_editor_width() + FIELD_BRACKETS_WIDTH, + h = 1, + }, + visible = function() return 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 + +--- 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.selected_order_id = order_id + self.selected_slot = slot + self.edit_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) + if self.syncing_fields + or self.slot_order_ids[slot] ~= self.selected_order_id then + return + end + + self.edit_text = text +end + +--- Moves the selected order to the entered one-based position. +---@param slot integer +function PositionOverlay:on_field_submit(slot) + if self.slot_order_ids[slot] ~= self.selected_order_id then return end + + local current_order_idx = find_order_index(self.selected_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 result, error_message = position.move( + current_order_idx + 1, field.text) + if not result 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 slot = self.selected_slot + if slot == nil or self.slot_order_ids[slot] ~= self.selected_order_id then + return nil + end + return self.position_fields[slot] +end + +--- Cancels the current proposal and restores the displayed position. +function PositionOverlay:clear_selection() + local order_id = self.selected_order_id + local field = self:get_selected_field() + + self.selected_order_id = nil + self.selected_slot = nil + self.edit_text = nil + + if field then + if field.focus then field:setFocus(false) end + clear_field_text_selection(field) + end + + local order_idx = order_id and find_order_index(order_id) or nil + if field and order_idx ~= nil then + local was_syncing = self.syncing_fields + self.syncing_fields = true + field:setText(tostring(order_idx + 1)) + self.syncing_fields = was_syncing + 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.selected_slot + 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 overlay_frame = { + l = POSITION_X - 1, + t = work_order_list.get_list_start_y(), + w = editor_width + FIELD_BRACKETS_WIDTH, + h = math.max(1, viewport_size * ORDER_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 = 0, + t = (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.selected_order_id + and find_order_index(self.selected_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.selected_order_id ~= nil and not selected_is_visible then + self.selected_order_id = nil + self.edit_text = nil + end + + self.selected_slot = selected_is_visible + and selected_order_idx - viewport_start + 1 or nil + + 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 order_id == self.selected_order_id then + text = self.edit_text or tostring(order_idx + 1) + 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.selected_order_id + + if self.selected_order_id ~= nil 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.selected_order_id ~= nil 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.selected_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.selected_order_id ~= nil 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 + +unit_test_hooks = { + accept_position_digit = accept_position_digit, + clear_field_text_selection = clear_field_text_selection, + get_orders_search_field = function() return orders_search_field end, + has_modifier = has_modifier, + select_all_field_text = select_all_field_text, + unfocus_orders_search = unfocus_orders_search, +} + +return _ENV diff --git a/plugins/lua/orders/work_order_list.lua b/plugins/lua/orders/work_order_list.lua new file mode 100644 index 0000000000..15468428aa --- /dev/null +++ b/plugins/lua/orders/work_order_list.lua @@ -0,0 +1,85 @@ +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.max(0, 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 or viewport_size <= 0 then return 0, -1 end + + local final_page_start = math.max(0, order_count - viewport_size) + local viewport_start = math.max( + 0, math.min(requested_start, final_page_start)) + local viewport_end = math.min( + order_count - 1, viewport_start + viewport_size - 1) + 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() + 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/plugins/orders.cpp b/plugins/orders.cpp index 174c645454..103338b4a0 100644 --- a/plugins/orders.cpp +++ b/plugins/orders.cpp @@ -1,6 +1,7 @@ #include "Console.h" #include "DataDefs.h" #include "Export.h" +#include "LuaTools.h" #include "PluginManager.h" #include "modules/Filesystem.h" @@ -79,6 +80,59 @@ static command_result orders_sort_command(color_ostream & out); static command_result orders_recheck_command(color_ostream & out); static command_result orders_recheck_current_command(color_ostream & out); +/** + * Dispatches a Lua-backed orders subcommand through plugins.orders. + * + * parse_commandline returns (success, error_message), where success is a + * boolean and error_message is either a string or nil. Expected validation + * failures are returned instead of raised so they can be reported without + * CallLuaModuleFunction appending its "Failed Lua call" diagnostic. The + * response shape is checked here so a broken Lua implementation cannot be + * mistaken for a successful command. Actual Lua exceptions still follow the + * normal CallLuaModuleFunction error path. + */ +static command_result orders_lua_command(color_ostream & out, + std::vector & parameters) +{ + bool response_valid = false; + bool command_succeeded = false; + std::string error_message; + if (!Lua::CallLuaModuleFunction(out, "plugins.orders", "parse_commandline", + std::make_tuple(parameters), 2, [&](lua_State *L) { + response_valid = lua_isboolean(L, -2) && + (lua_isnil(L, -1) || lua_type(L, -1) == LUA_TSTRING); + if (!response_valid) + return; + + command_succeeded = lua_toboolean(L, -2); + if (lua_type(L, -1) == LUA_TSTRING) + { + const char *message = lua_tostring(L, -1); + error_message = message; + } + })) + { + return CR_FAILURE; + } + + if (!response_valid) + { + out.printerr("orders: Lua subcommand returned an invalid response.\n"); + return CR_FAILURE; + } + + if (!command_succeeded) + { + if (error_message.empty()) + out.printerr("orders: Lua subcommand failed without an error message.\n"); + else + out.printerr("{}\n", error_message); + return CR_FAILURE; + } + + return CR_OK; +} + static command_result orders_command(color_ostream & out, std::vector & parameters) { class color_ostream_resetter @@ -105,6 +159,11 @@ static command_result orders_command(color_ostream & out, std::vector Date: Tue, 1 Sep 2026 19:42:27 -0300 Subject: [PATCH 2/3] orders: test position management --- test/plugins/orders.lua | 425 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 425 insertions(+) diff --git a/test/plugins/orders.lua b/test/plugins/orders.lua index dab396ba96..265067f4c6 100644 --- a/test/plugins/orders.lua +++ b/test/plugins/orders.lua @@ -1,6 +1,13 @@ config.mode = 'fortress' config.target = 'orders' +local orders_plugin = require('plugins.orders') +local position = require('plugins.orders.position') +local position_overlay = require('plugins.orders.position_overlay') +local work_order_list = require('plugins.orders.work_order_list') +local mock = require('test_util.mock') +local widgets = require('gui.widgets') + local FILE_PATH_PATTERN = dfhack.getConfigPath() .. '/orders/%s.json' local BACKUP_FILE_NAME = 'tmp-backup' @@ -266,3 +273,421 @@ function test.list() expect.eq(CR_OK, status) expect.str_find(BACKUP_FILE_NAME:gsub('%-', '%%-'), output) end + +local TEST_ORDERS = [[ + [ + { + "amount_left": 1, + "amount_total": 1, + "frequency": "OneTime", + "id": 0, + "is_active": false, + "is_validated": true, + "job": "ConstructTable", + "material": "INORGANIC" + }, + { + "amount_left": 2, + "amount_total": 2, + "frequency": "Daily", + "id": 1, + "is_active": false, + "is_validated": true, + "job": "ConstructTable", + "material": "INORGANIC" + }, + { + "amount_left": 3, + "amount_total": 3, + "frequency": "Monthly", + "id": 2, + "is_active": false, + "is_validated": true, + "job": "ConstructTable", + "material": "INORGANIC" + } + ] +]] + +local function import_test_orders() + local output, status = run_orders_import(TEST_ORDERS) + expect.eq(CR_OK, status, output) + expect.eq(3, #df.global.world.manager_orders.all) +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, name) + return { id = id, name = name } +end + +local function make_vector() + return setmetatable({ values = { + make_order(10, 'first'), + make_order(20, 'second'), + make_order(30, 'third'), + } }, 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 third = vector[2] + local result, error_message = + position.unit_test_hooks.move_in_vector(vector, 1, 3) + if not result then expect.fail(error_message) return end + + expect.eq('moved', result.status) + expect.eq(first, result.order) + expect.eq(third, result.previous_order) + expect.eq(first, vector[2]) + expect.table_eq({ 20, 30, 10 }, get_vector_ids(vector)) + + result, error_message = + position.unit_test_hooks.move_in_vector(vector, 3, 1) + if not result then expect.fail(error_message) return end + 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', '-1', 0, 1.5 } + for _, value in ipairs(invalid_values) do + local vector = make_vector() + local result = position.unit_test_hooks.move_in_vector(vector, value, 1) + 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 result, error_message = + position.unit_test_hooks.move_in_vector(vector, 2, 2) + if not result then expect.fail(error_message) return end + expect.eq('unchanged', result.status) + expect.eq(second, result.order) + expect.table_eq({ 10, 20, 30 }, get_vector_ids(vector)) + + result = position.unit_test_hooks.move_in_vector(vector, 1, 4) + expect.nil_(result) + expect.table_eq({ 10, 20, 30 }, get_vector_ids(vector)) +end + +function test.position_move_formatting_and_options() + local result = { + status = 'moved', + order = make_order(10, 'first'), + current_position = 1, + new_position = 2, + previous_order = make_order(20, 'second'), + } + expect.eq('Moved order "first" to the position after order "second".', + position.unit_test_hooks.format_move_result(result, false)) + expect.eq( + 'Moved order ID 10 "first" to the position after order ID 20 "second".', + position.unit_test_hooks.format_move_result(result, true)) + + result = { + status = 'moved', + order = make_order(10, 'first'), + current_position = 3, + new_position = 1, + } + expect.eq('Moved order "first" to the first position.', + position.unit_test_hooks.format_move_result(result, false)) + expect.eq('Moved order ID 10 "first" to the first position.', + position.unit_test_hooks.format_move_result(result, true)) + + result = { + status = 'unchanged', + order = make_order(20, 'second'), + current_position = 2, + new_position = 2, + } + expect.eq('No changes made; order "second" is already at position 2.', + position.unit_test_hooks.format_move_result(result, false)) + expect.eq( + 'No changes made; order ID 20 "second" is already at position 2.', + position.unit_test_hooks.format_move_result(result, true)) + + local show_id, positionals, error_message = + position.unit_test_hooks.parse_show_id { '5', '--show-id', '3' } + expect.true_(show_id) + expect.table_eq({ '5', '3' }, positionals) + expect.nil_(error_message) + + show_id, positionals, error_message = + position.unit_test_hooks.parse_show_id { '--unknown' } + expect.nil_(show_id) + expect.nil_(positionals) + expect.eq('Unknown option: --unknown', error_message) +end + +function test.position_list_validation_returns_errors() + mock.patch(dfhack, 'isMapLoaded', mock.func(false), function() + local success, error_message = position.print_positions() + expect.false_(success) + expect.eq( + 'A fortress map must be loaded to list manager orders.', + error_message) + end) + + mock.patch({ + {dfhack, 'isMapLoaded', mock.func(true)}, + {dfhack.world, 'isFortressMode', mock.func(false)}, + }, function() + local success, error_message = position.print_positions() + expect.false_(success) + expect.eq( + 'A fortress map must be loaded to list manager orders.', + error_message) + end) +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)) + expect.eq(0, hooks.calculate_viewport_size(16, 10)) + + 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, 0, 0) + expect.eq(0, viewport_start) + expect.eq(-1, 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 + +function test.position_commands_dispatch_to_lua() + import_test_orders() + local orders = df.global.world.manager_orders.all + local first = orders[0] + local second = orders[1] + local third = orders[2] + + local names = {} + for order_index = 0, #orders - 1 do + names[order_index + 1] = + dfhack.job.getManagerOrderName(orders[order_index]) + end + + local output, status = dfhack.run_command_silent { 'orders', 'positions' } + expect.eq(CR_OK, status) + expect.eq(normalize_whitespace(([[ + POS FREQ QTY NAME + 1 OneTime 1/1 %s + 2 Daily 2/2 %s + 3 Monthly 3/3 %s + ]]):format(table.unpack(names))), normalize_whitespace(output)) + + output, status = + dfhack.run_command_silent { 'orders', 'positions', '--show-id' } + expect.eq(CR_OK, status) + expect.eq(normalize_whitespace(([[ + POS ID FREQ QTY NAME + 1 0 OneTime 1/1 %s + 2 1 Daily 2/2 %s + 3 2 Monthly 3/3 %s + ]]):format(table.unpack(names))), normalize_whitespace(output)) + + output, status = dfhack.run_command_silent { 'orders', 'move', '1', '3' } + expect.eq(CR_OK, status) + expect.eq(second, orders[0]) + expect.eq(third, orders[1]) + expect.eq(first, orders[2]) + expect.str_find('Moved order ', output) + + output, status = dfhack.run_command_silent { 'orders', 'move', '3', '3' } + expect.eq(CR_OK, status) + expect.eq(first, orders[2]) + expect.str_find('No changes made;', output) + + output, status = dfhack.run_command_silent { 'orders', 'move', '1', '4' } + expect.eq(CR_FAILURE, status) + expect.str_find('New position 4 is outside the valid range 1%.%.3%.', output) + expect.nil_(output:find('Failed Lua call', 1, true)) + expect.eq(second, orders[0]) + expect.eq(third, orders[1]) + expect.eq(first, orders[2]) +end + +local function expect_position_command_failure(arguments, expected_message) + local orders = df.global.world.manager_orders.all + local original_orders = { orders[0], orders[1], orders[2] } + local output, status = dfhack.run_command_silent(arguments) + expect.eq(CR_FAILURE, status, output) + expect.true_(output:find(expected_message, 1, true) ~= nil, + ('expected %q in %q'):format(expected_message, output)) + expect.nil_(output:find('Failed Lua call', 1, true)) + expect.eq(#original_orders, #orders) + for order_index = 0, #orders - 1 do + expect.eq(original_orders[order_index + 1], orders[order_index]) + end +end + +function test.position_commands_validate_arguments_atomically() + import_test_orders() + + local cases = { + { + { 'orders', 'move' }, + 'Expected exactly two positions, but received 0.', + }, + { + { 'orders', 'move', '1' }, + 'Expected exactly two positions, but received 1.', + }, + { + { 'orders', 'move', '1', '2', '3' }, + 'Expected exactly two positions, but received 3.', + }, + { + { 'orders', 'move', 'one', '2' }, + 'Current position must be a positive integer; got "one".', + }, + { + { 'orders', 'move', '1.5', '2' }, + 'Current position must be a positive integer; got "1.5".', + }, + { + { 'orders', 'move', '0', '1' }, + 'Current position must be a positive integer; got "0".', + }, + { + { 'orders', 'move', '-1', '1' }, + 'Current position must be a positive integer; got "-1".', + }, + { + { 'orders', 'move', '1', 'one' }, + 'New position must be a positive integer; got "one".', + }, + { + { 'orders', 'move', '1', '0' }, + 'New position must be a positive integer; got "0".', + }, + { + { 'orders', 'move', '4', '1' }, + 'Current position 4 is outside the valid range 1..3.', + }, + { + { 'orders', 'move', '1', '4' }, + 'New position 4 is outside the valid range 1..3.', + }, + { + { 'orders', 'move', '1', '2', '--unknown' }, + 'Unknown option: --unknown', + }, + { + { 'orders', 'positions', '1' }, + 'Expected no positional arguments for positions, but received 1.', + }, + { + { 'orders', 'positions', '--unknown' }, + 'Unknown option: --unknown', + }, + } + + for _, case in ipairs(cases) do + expect_position_command_failure(table.unpack(case)) + end +end + +function test.position_commands_reject_invalid_lua_responses() + mock.patch(position, 'parse_commandline', mock.func(), function() + local output, status = + dfhack.run_command_silent { 'orders', 'positions' } + expect.eq(CR_FAILURE, status) + expect.str_find( + 'orders: Lua subcommand returned an invalid response%.', output) + expect.nil_(output:find('Failed Lua call', 1, true)) + end) + + mock.patch(position, 'parse_commandline', mock.func(false), function() + local output, status = + dfhack.run_command_silent { 'orders', 'positions' } + expect.eq(CR_FAILURE, status) + expect.str_find( + 'orders: Lua subcommand failed without an error message%.', output) + expect.nil_(output:find('Failed Lua call', 1, true)) + end) +end + +function test.position_overlay_edit_helpers() + local hooks = position_overlay.unit_test_hooks + expect.false_(hooks.has_modifier {}) + expect.true_(hooks.has_modifier { ctrl = true }) + expect.true_(hooks.has_modifier { shift = true }) + expect.true_(hooks.has_modifier { alt = true }) + expect.true_(hooks.has_modifier { super = true }) + + local field = widgets.EditField { + text = '12', + on_char = hooks.accept_position_digit, + } + field:setFocus(true) + hooks.select_all_field_text(field) + expect.true_(field:onInput { _STRING = string.byte('3') }) + expect.eq('3', field.text) + + hooks.select_all_field_text(field) + expect.true_(field.text_area.text_area:hasSelection()) + hooks.clear_field_text_selection(field) + expect.false_(field.text_area.text_area:hasSelection()) + expect.eq('3', field.text) + + local original_search_field = hooks.get_orders_search_field() + dfhack.with_finalize( + function() + position_overlay.bind_orders_search_field(original_search_field) + end, + function() + local search_field = widgets.EditField { text = 'query' } + search_field:setFocus(true) + position_overlay.bind_orders_search_field(search_field) + hooks.unfocus_orders_search() + expect.false_(search_field.focus) + + local clear_count = 0 + mock.patch(position_overlay, 'clear_active_edit', function() + clear_count = clear_count + 1 + end, function() + local search_overlay = orders_plugin.OrdersSearchOverlay {} + search_overlay.subviews.filter:setFocus(true) + expect.eq(1, clear_count) + end) + end) +end From 43e4ee316b9203b694eb6063ec4f2a4662199e9e Mon Sep 17 00:00:00 2001 From: Rodrigo Cardoso Buske Date: Tue, 1 Sep 2026 19:42:35 -0300 Subject: [PATCH 3/3] orders: document position management --- docs/about/Authors.rst | 1 + docs/changelog.txt | 2 ++ docs/plugins/orders.rst | 22 ++++++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/docs/about/Authors.rst b/docs/about/Authors.rst index a154d314b6..f67f666e90 100644 --- a/docs/about/Authors.rst +++ b/docs/about/Authors.rst @@ -200,6 +200,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 2535ca86d7..a37f5e3cac 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -59,6 +59,8 @@ Template for new versions: ## New Features +- `orders`: add commands and an inline overlay for listing and changing manager-order positions + ## Fixes - Fix broken weather lookup in ``World::ReadCurrentWeather`` diff --git a/docs/plugins/orders.rst b/docs/plugins/orders.rst index d89475a328..d9ab36c228 100644 --- a/docs/plugins/orders.rst +++ b/docs/plugins/orders.rst @@ -10,6 +10,15 @@ Usage ``orders list`` Shows the list of previously exported orders, including the orders library. +``orders positions [--show-id]`` + Lists fort-wide manager orders in their displayed priority order, including + each order's position, frequency, remaining and total quantities, and name. + Pass ``--show-id`` to also include manager-order IDs. +``orders move [--show-id]`` + Moves the existing manager order at ``current-position`` to + ``new-position``. Positions are one-based, matching those listed by + ``orders positions``. Pass ``--show-id`` to include manager-order IDs in + the result. ``orders export `` Saves all the current manager orders in a file. ``orders import `` @@ -47,6 +56,10 @@ Examples ``orders import library/basic`` Import manager orders from the library that keep your fort stocked with basic essentials. +``orders positions --show-id`` + List the current manager orders and their IDs. +``orders move 5 3`` + Move the order currently displayed at position 5 to position 3. Overlays -------- @@ -72,6 +85,15 @@ 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 the one-based positions listed by ``orders positions`` beside +fort-wide work orders. 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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~