From 71a1a32972a92fd97e7487cf40f18f07d15a43e6 Mon Sep 17 00:00:00 2001 From: pajawojciech Date: Tue, 2 Dec 2025 20:55:00 +0100 Subject: [PATCH 1/3] orders: add search overlay for manager orders Adds search overlay to find and navigate manager orders with arrow indicators showing current search result. Search uses Alt+S to focus, Alt+P/N for prev/next navigation. Overlays are disabled by default. --- docs/changelog.txt | 1 + plugins/lua/orders.lua | 398 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 398 insertions(+), 1 deletion(-) diff --git a/docs/changelog.txt b/docs/changelog.txt index 91c50db23a..54e33d712d 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -57,6 +57,7 @@ Template for new versions: ## New Tools ## New Features +- `orders`: added search overlay to find and navigate to matching manager orders with arrow indicators ## Fixes diff --git a/plugins/lua/orders.lua b/plugins/lua/orders.lua index 2774bd80ee..c99e24fd21 100644 --- a/plugins/lua/orders.lua +++ b/plugins/lua/orders.lua @@ -6,6 +6,11 @@ local overlay = require('plugins.overlay') local textures = require('gui.textures') local utils = require('utils') local widgets = require('gui.widgets') +local stockflow = reqscript('internal/quickfort/stockflow') + +-- Shared state for search cursor visibility +local search_cursor_visible = false +local search_last_scroll_position = -1 -- -- OrdersOverlay @@ -74,7 +79,7 @@ local mi = df.global.game.main_interface OrdersOverlay = defclass(OrdersOverlay, overlay.OverlayWidget) OrdersOverlay.ATTRS{ desc='Adds import, export, and other functions to the manager orders screen.', - default_pos={x=53,y=-6}, + default_pos={x=41,y=-6}, default_enabled=true, viewscreens='dwarfmode/Info/WORK_ORDERS/Default', frame={w=43, h=4}, @@ -709,11 +714,401 @@ function QuantityRightClickOverlay:onInput(keys) end end +-- +-- OrdersSearchOverlay +-- + +local search_cursor_visible = false +local search_last_scroll_position = -1 + +local function make_order_key(order) + local mat_cat_str = '' + if order.material_category then + local keys = {} + for k in pairs(order.material_category) do + if type(k) == 'string' then + table.insert(keys, k) + end + end + table.sort(keys) + for _, k in ipairs(keys) do + mat_cat_str = mat_cat_str .. k .. '=' .. tostring(order.material_category[k]) .. ';' + end + end + + local encrust_str = '' + if order.specflag and order.specflag.encrust_flags then + local flags = {'finished_goods', 'furniture', 'ammo'} + for _, flag in ipairs(flags) do + if order.specflag.encrust_flags[flag] then + encrust_str = encrust_str .. flag .. ';' + end + end + end + + return string.format('%d:%d:%d:%d:%d:%s:%s:%s', + order.job_type, + order.item_type, + order.item_subtype, + order.mat_type, + order.mat_index, + order.reaction_name or '', + mat_cat_str, + encrust_str) +end + +local function build_reaction_map() + local map = {} + local reactions = stockflow.collect_reactions() + + for _, reaction in ipairs(reactions) do + local key = make_order_key(reaction.order) + map[key] = reaction.name:lower() + end + + return map +end + +local reaction_map_cache = nil + +local function get_cached_reaction_map() + if not reaction_map_cache then + reaction_map_cache = build_reaction_map() + end + return reaction_map_cache +end + +local function get_order_search_key(order) + local reaction_map = get_cached_reaction_map() + local key = make_order_key(order) + if reaction_map[key] then + return reaction_map[key] + end + return "" +end + +local function matches_all_search_words(search_key, filter_text) + local search_words = {} + for word in filter_text:gmatch('%S+') do + table.insert(search_words, word) + end + + -- Check if all search words are found in search_key (order-independent) + for _, search_word in ipairs(search_words) do + if not search_key:find(search_word, 1, true) then + return false + end + end + return true +end + +OrdersSearchOverlay = defclass(OrdersSearchOverlay, overlay.OverlayWidget) +OrdersSearchOverlay.ATTRS{ + desc='Adds a search box to find and navigate to matching manager orders.', + default_pos={x=85, y=-6}, + default_enabled=false, + viewscreens='dwarfmode/Info/WORK_ORDERS/Default', + frame={w=34, h=4}, +} + +function OrdersSearchOverlay:init() + local main_panel = widgets.Panel{ + view_id='main_panel', + frame={t=0, l=0, r=0, h=4}, + frame_style=gui.MEDIUM_FRAME, + frame_background=gui.CLEAR_PEN, + frame_title='Search', + visible=function() return not self.minimized end, + subviews={ + widgets.EditField{ + view_id='filter', + frame={t=0, l=0}, + key='CUSTOM_ALT_S', + on_change=self:callback('update_filter'), + }, + widgets.HotkeyLabel{ + view_id='prev_match', + frame={t=1, l=0}, + label='prev', + key='CUSTOM_ALT_P', + auto_width=true, + on_activate=self:callback('jump_to_previous_match'), + enabled=function() return self:has_matches() end, + }, + widgets.HotkeyLabel{ + view_id='next_match', + frame={t=1, l=17}, + label='next', + key='CUSTOM_ALT_N', + auto_width=true, + on_activate=self:callback('jump_to_next_match'), + enabled=function() return self:has_matches() end, + }, + }, + } + + local minimized_panel = widgets.Panel{ + frame={t=0, r=0, w=3, h=1}, + subviews={ + widgets.Label{ + frame={t=0, l=0, w=1, h=1}, + text='[', + text_pen=COLOR_RED, + visible=function() return self.minimized end, + }, + widgets.Label{ + frame={t=0, l=1, w=1, h=1}, + text={{text=function() return self.minimized and string.char(31) or string.char(30) end}}, + text_pen=dfhack.pen.parse{fg=COLOR_BLACK, bg=COLOR_GREY}, + text_hpen=dfhack.pen.parse{fg=COLOR_BLACK, bg=COLOR_WHITE}, + on_click=function() self.minimized = not self.minimized end, + }, + widgets.Label{ + frame={t=0, r=0, w=1, h=1}, + text=']', + text_pen=COLOR_RED, + visible=function() return self.minimized end, + }, + }, + } + + self:addviews{ + main_panel, + minimized_panel, + } + + -- Initialize search state + self.filter_text = '' + self.matched_indices = {} + self.current_match_idx = 0 + self.minimized = false +end + +function OrdersSearchOverlay:update_filter(text) + self.filter_text = text:lower() + self.matched_indices = {} + self.current_match_idx = 0 + + if self.filter_text == '' then + self.subviews.main_panel.frame_title = 'Search' + return + end + + local orders = df.global.world.manager_orders.all + for i = 0, #orders - 1 do + local order = orders[i] + local search_key = get_order_search_key(order) + if matches_all_search_words(search_key, self.filter_text) then + table.insert(self.matched_indices, i) + end + end + + self.subviews.main_panel.frame_title = 'Search: ' .. self:get_match_text() +end + +function OrdersSearchOverlay:jump_to_next_match() + if #self.matched_indices == 0 then return end + + self.current_match_idx = self.current_match_idx + 1 + if self.current_match_idx > #self.matched_indices then + self.current_match_idx = 1 + end + + local order_idx = self.matched_indices[self.current_match_idx] + mi.info.work_orders.scroll_position_work_orders = order_idx + search_last_scroll_position = order_idx + search_cursor_visible = true + + self.subviews.main_panel.frame_title = 'Search: ' .. self:get_match_text() +end + +function OrdersSearchOverlay:jump_to_previous_match() + if #self.matched_indices == 0 then return end + + self.current_match_idx = self.current_match_idx - 1 + if self.current_match_idx < 1 then + self.current_match_idx = #self.matched_indices + end + + local order_idx = self.matched_indices[self.current_match_idx] + mi.info.work_orders.scroll_position_work_orders = order_idx + search_last_scroll_position = order_idx + search_cursor_visible = true + + self.subviews.main_panel.frame_title = 'Search: ' .. self:get_match_text() +end + +function OrdersSearchOverlay:get_match_text() + if self.filter_text == '' then + return '' + end + + local total_matches = #self.matched_indices + + if self.current_match_idx == 0 then + return string.format('%d matches', total_matches) + end + + return string.format('%d of %d', self.current_match_idx, total_matches) +end + +function OrdersSearchOverlay:has_matches() + return #self.matched_indices > 0 +end + +local function is_mouse_key(keys) + return keys._MOUSE_L + or keys._MOUSE_R + or keys._MOUSE_M + or keys.CONTEXT_SCROLL_UP + or keys.CONTEXT_SCROLL_DOWN + or keys.CONTEXT_SCROLL_PAGEUP + or keys.CONTEXT_SCROLL_PAGEDOWN +end + +function OrdersSearchOverlay:onInput(keys) + local filter_field = self.subviews.filter + if not filter_field then return false end + + -- Unfocus search on right-click + if keys._MOUSE_R and filter_field.focus then + filter_field:setFocus(false) + return true + end + + -- Let parent handle input first (for HotkeyLabel clicks and widget interactions) + if OrdersSearchOverlay.super.onInput(self, keys) then + return true + end + + -- Unfocus search on left-click when focused (for workshop and number of times changes) + -- And let the click pass through + if keys._MOUSE_L and filter_field.focus then + filter_field:setFocus(false) + return false + end + + -- Only consume input if search field has focus and it's not a mouse key + -- This allows scrolling, navigation, and mouse interaction in the orders list + if filter_field.focus and not is_mouse_key(keys) then + return true + end + + return false +end + +-- ------------------- +-- OrderHighlightOverlay +-- ------------------- + +OrderHighlightOverlay = defclass(OrderHighlightOverlay, overlay.OverlayWidget) +OrderHighlightOverlay.ATTRS{ + desc='Shows arrows next to the work order found by orders.search', + default_enabled=false, + viewscreens='dwarfmode/Info/WORK_ORDERS/Default', + frame={w=80, h=3}, +} + +function OrderHighlightOverlay:init() + self.ORDER_HEIGHT = 3 + self.TABS_WIDTH_THRESHOLD = 155 + self.LIST_START_Y_ONE_TABS_ROW = 8 + self.LIST_START_Y_TWO_TABS_ROWS = 10 + self.BOTTOM_MARGIN = 9 + self.ARROW_X_FIRST = 5 + self.ARROW_X_SECOND = 6 + self.ARROW_CHAR = '>' + + self.cached_list_start_y = nil + self.cached_viewport_size = nil + self.cached_screen_width = nil + self.cached_screen_height = nil +end + +function OrderHighlightOverlay:getListStartY() + local rect = gui.get_interface_rect() + + if rect.width >= self.TABS_WIDTH_THRESHOLD then + return self.LIST_START_Y_ONE_TABS_ROW + else + return self.LIST_START_Y_TWO_TABS_ROWS + end +end + +function OrderHighlightOverlay:getViewportSize() + local rect = gui.get_interface_rect() + local list_start_y = self:getListStartY() + + local available_height = rect.height - list_start_y - self.BOTTOM_MARGIN + return math.floor(available_height / self.ORDER_HEIGHT) +end + +function OrderHighlightOverlay:calculateSelectedOrderY() + local orders = df.global.world.manager_orders.all + local scroll_pos = mi.info.work_orders.scroll_position_work_orders + + if #orders == 0 or scroll_pos < 0 or scroll_pos >= #orders then + return nil + end + + local list_start_y = self:getListStartY() + local viewport_size = self:getViewportSize() + + local viewport_start = scroll_pos + local viewport_end = scroll_pos + viewport_size - 1 + + -- Selected order tries to be at the top unless we're at the end of the list + if viewport_end >= #orders then + viewport_end = #orders - 1 + viewport_start = math.max(0, viewport_end - viewport_size + 1) + end + + local pos_in_viewport = scroll_pos - viewport_start + + local selected_y = list_start_y + (pos_in_viewport * self.ORDER_HEIGHT) + + return selected_y +end + +function OrderHighlightOverlay:render(dc) + if search_cursor_visible then + local current_scroll = mi.info.work_orders.scroll_position_work_orders + + -- Hide cursor when user manually scrolls + if search_last_scroll_position ~= -1 and current_scroll ~= search_last_scroll_position then + search_cursor_visible = false + end + search_last_scroll_position = current_scroll + end + + OrderHighlightOverlay.super.render(self, dc) +end + +function OrderHighlightOverlay:onRenderFrame(dc, rect) + OrderHighlightOverlay.super.onRenderFrame(self, dc, rect) + + if not search_cursor_visible then return end + + local selected_y = self:calculateSelectedOrderY() + if not selected_y then return end + + local highlight_pen = dfhack.pen.parse{ + fg=COLOR_LIGHTGREEN, + bold=true, + } + + local y = selected_y + 1 -- Middle line of the 3-line order + dc:seek(self.ARROW_X_FIRST, y):string(self.ARROW_CHAR, highlight_pen) + dc:seek(self.ARROW_X_SECOND, y):string(self.ARROW_CHAR, highlight_pen) +end + -- ------------------- OVERLAY_WIDGETS = { recheck=RecheckOverlay, importexport=OrdersOverlay, + search=OrdersSearchOverlay, + highlight=OrderHighlightOverlay, skillrestrictions=SkillRestrictionOverlay, laborrestrictions=LaborRestrictionsOverlay, conditionsrightclick=ConditionsRightClickOverlay, @@ -722,3 +1117,4 @@ OVERLAY_WIDGETS = { } return _ENV + From cfaa14062102150eb59e6be6d5b338c3941896db Mon Sep 17 00:00:00 2001 From: pajawojciech Date: Tue, 2 Dec 2025 21:39:56 +0100 Subject: [PATCH 2/3] Fix trailing whitespace --- plugins/lua/orders.lua | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/lua/orders.lua b/plugins/lua/orders.lua index c99e24fd21..0095fe8c27 100644 --- a/plugins/lua/orders.lua +++ b/plugins/lua/orders.lua @@ -1056,7 +1056,7 @@ function OrderHighlightOverlay:calculateSelectedOrderY() local viewport_start = scroll_pos local viewport_end = scroll_pos + viewport_size - 1 - + -- Selected order tries to be at the top unless we're at the end of the list if viewport_end >= #orders then viewport_end = #orders - 1 @@ -1116,5 +1116,4 @@ OVERLAY_WIDGETS = { quantityrightclick=QuantityRightClickOverlay, } -return _ENV - +return _ENV \ No newline at end of file From 16ab556bb04bfb1558a70ad48d16e069b4f31a35 Mon Sep 17 00:00:00 2001 From: pajawojciech Date: Tue, 2 Dec 2025 21:45:39 +0100 Subject: [PATCH 3/3] Newline at the end --- plugins/lua/orders.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/lua/orders.lua b/plugins/lua/orders.lua index 0095fe8c27..22236e4769 100644 --- a/plugins/lua/orders.lua +++ b/plugins/lua/orders.lua @@ -1116,4 +1116,4 @@ OVERLAY_WIDGETS = { quantityrightclick=QuantityRightClickOverlay, } -return _ENV \ No newline at end of file +return _ENV