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..9f29ac3bb1 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -59,6 +59,8 @@ Template for new versions: ## New Features +- `orders`: add an inline overlay for viewing and changing manager-order positions + ## Fixes - Fix broken weather lookup in ``World::ReadCurrentWeather`` @@ -78,6 +80,8 @@ Template for new versions: ## Lua +- `orders`: make work-order list geometry available to Lua overlays + ## Removed # 53.16-r1.1 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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/plugins/lua/orders.lua b/plugins/lua/orders.lua index a470e3e2ce..84ae5b5352 100644 --- a/plugins/lua/orders.lua +++ b/plugins/lua/orders.lua @@ -3,6 +3,8 @@ 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 position_overlay = require('plugins.orders.position_overlay') local textures = require('gui.textures') local utils = require('utils') local widgets = require('gui.widgets') @@ -714,11 +716,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 +753,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.', @@ -837,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'), }, @@ -888,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 = {} @@ -952,7 +895,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 +974,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 +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/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..7df13704d4 100644 --- a/test/plugins/orders.lua +++ b/test/plugins/orders.lua @@ -1,6 +1,12 @@ config.mode = 'fortress' config.target = 'orders' +local work_order_list = 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' local BACKUP_FILE_NAME = 'tmp-backup' @@ -266,3 +272,184 @@ 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 + +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