From 99288ddbf7f4f04075be318d2f8b5caf15bfd983 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Tue, 15 Sep 2026 03:02:10 +0200 Subject: [PATCH 1/3] stocks: fix blank item list after collapsing while scrolled down Collapsing all categories shrinks stocks.i_height but left scroll_position_item untouched, so the view pointed past the end of the list with no way to scroll back. Reset it when collapsing, and clamp scroll_position_type when removing empty categories shrinks the type list. Also fix a missing local on num_items in expand_all, and make the three actions module-level so tests can reach them. fixes DFHack/dfhack#5772 --- docs/changelog.txt | 1 + plugins/lua/stocks.lua | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/changelog.txt b/docs/changelog.txt index ff3943a28c..be0e49e027 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -62,6 +62,7 @@ Template for new versions: ## Fixes - Fix broken weather lookup in ``World::ReadCurrentWeather`` - `timestream`: deal properly with units who have breathing difficulties +- `stocks`: overlay now resets scroll position when collapsing categories so the item list is no longer left blank and unscrollable ## Misc Improvements - Added ``Coord2d`` and ``Coord3d`` C++ templates, providing a standard set operations for 2-tuples and 3-tuples of any numeric type diff --git a/plugins/lua/stocks.lua b/plugins/lua/stocks.lua index c1a7ad81de..f044b7091d 100644 --- a/plugins/lua/stocks.lua +++ b/plugins/lua/stocks.lua @@ -6,24 +6,28 @@ local widgets = require('gui.widgets') local stocks = df.global.game.main_interface.stocks -local function collapse_all() +-- module-level so unit tests can exercise them +function collapse_all() local num_sections = #stocks.current_type_a_expanded for idx=0,num_sections-1 do stocks.current_type_a_expanded[idx] = false end stocks.i_height = num_sections * 3 + -- the collapsed list is much shorter; reset the scroll position so the + -- view isn't left pointing past the end of the list + stocks.scroll_position_item = 0 end -local function expand_all() +function expand_all() local num_sections = #stocks.current_type_a_expanded for idx=0,num_sections-1 do stocks.current_type_a_expanded[idx] = true end - num_items = #stocks.current_type_i_list + local num_items = #stocks.current_type_i_list stocks.i_height = (num_items + num_sections) * 3 end -local function remove_empty() +function remove_empty() local empties = {} for itype,v in ipairs(stocks.storeamount) do if v == 0 and stocks.badamount[itype] == 0 then @@ -36,6 +40,10 @@ local function remove_empty() for idx=#stocks.filtered_type_list-1,0,-1 do if empties[stocks.filtered_type_list[idx]] then stocks.filtered_type_list:erase(idx) end end + -- removing types shortens the type list; keep the scroll position in bounds + if stocks.scroll_position_type >= #stocks.filtered_type_list then + stocks.scroll_position_type = math.max(0, #stocks.filtered_type_list - 1) + end end -- ------------------- From 823fbd9392545c9068b241a362ea004ce21027a9 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Wed, 16 Sep 2026 04:12:43 +0200 Subject: [PATCH 2/3] stocks: make the overlay's collapse hotkey a toggle There was no way to restore the item list after collapsing all categories without switching stocks pages or knowing about the separate expand hotkey. Ctrl-X now expands when every category is collapsed, and its label flips between 'collapse all' and 'expand all' to match. fixes DFHack/dfhack#5773 --- docs/changelog.txt | 1 + plugins/lua/stocks.lua | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/changelog.txt b/docs/changelog.txt index cfc1fe0526..2dc3e49474 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -60,6 +60,7 @@ Template for new versions: ## New Features - `autodump`: new ``undestroy`` option reverts pending item destruction while the game is still paused +- `stocks`: the overlay's ``collapse all`` hotkey now toggles, expanding all categories again when everything is collapsed ## Fixes - Fix broken weather lookup in ``World::ReadCurrentWeather`` diff --git a/plugins/lua/stocks.lua b/plugins/lua/stocks.lua index f044b7091d..75beff9699 100644 --- a/plugins/lua/stocks.lua +++ b/plugins/lua/stocks.lua @@ -27,6 +27,17 @@ function expand_all() stocks.i_height = (num_items + num_sections) * 3 end +function all_collapsed() + for idx=0,#stocks.current_type_a_expanded-1 do + if stocks.current_type_a_expanded[idx] then return false end + end + return true +end + +function toggle_all() + if all_collapsed() then expand_all() else collapse_all() end +end + function remove_empty() local empties = {} for itype,v in ipairs(stocks.storeamount) do @@ -65,9 +76,11 @@ function StocksOverlay:init() self:addviews{ widgets.HotkeyLabel{ frame={t=0, l=0}, - label='collapse all', + label=function() + return all_collapsed() and 'expand all' or 'collapse all' + end, key='CUSTOM_CTRL_X', - on_activate=collapse_all, + on_activate=toggle_all, }, widgets.HotkeyLabel{ frame={t=1, l=0}, From 8bc03995e1a3a867d0004fd2d3c969a057a5c244 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Wed, 16 Sep 2026 22:52:05 +0200 Subject: [PATCH 3/3] stocks overlay: keep helpers local, guard on stocks.open, add tests --- plugins/lua/stocks.lua | 18 ++++++--- test/plugins/stocks.lua | 87 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 test/plugins/stocks.lua diff --git a/plugins/lua/stocks.lua b/plugins/lua/stocks.lua index 75beff9699..a854311b84 100644 --- a/plugins/lua/stocks.lua +++ b/plugins/lua/stocks.lua @@ -6,8 +6,10 @@ local widgets = require('gui.widgets') local stocks = df.global.game.main_interface.stocks --- module-level so unit tests can exercise them -function collapse_all() +-- these are only safe to call while the stocks page is open; guard against +-- stray activations as the page closes +local function collapse_all() + if not stocks.open then return end local num_sections = #stocks.current_type_a_expanded for idx=0,num_sections-1 do stocks.current_type_a_expanded[idx] = false @@ -18,7 +20,8 @@ function collapse_all() stocks.scroll_position_item = 0 end -function expand_all() +local function expand_all() + if not stocks.open then return end local num_sections = #stocks.current_type_a_expanded for idx=0,num_sections-1 do stocks.current_type_a_expanded[idx] = true @@ -27,18 +30,21 @@ function expand_all() stocks.i_height = (num_items + num_sections) * 3 end -function all_collapsed() +local function all_collapsed() + if not stocks.open then return true end for idx=0,#stocks.current_type_a_expanded-1 do if stocks.current_type_a_expanded[idx] then return false end end return true end -function toggle_all() +local function toggle_all() + if not stocks.open then return end if all_collapsed() then expand_all() else collapse_all() end end -function remove_empty() +local function remove_empty() + if not stocks.open then return end local empties = {} for itype,v in ipairs(stocks.storeamount) do if v == 0 and stocks.badamount[itype] == 0 then diff --git a/test/plugins/stocks.lua b/test/plugins/stocks.lua new file mode 100644 index 0000000000..0dba35c00a --- /dev/null +++ b/test/plugins/stocks.lua @@ -0,0 +1,87 @@ +config.mode = 'fortress' +config.target = 'overlay' + +local gui = require('gui') +local overlay = require('plugins.overlay') + +local stocks = df.global.game.main_interface.stocks + +-- feed a key to the stocks overlay widget's input handler. This is the same +-- code path the viewscreen's interposed feed() takes; we invoke it directly +-- because the focus string cache only refreshes on game frames, which tests +-- cannot wait for. +local function send_key(key) + overlay.get_state().db['stocks.overlay'].widget:onInput({[key]=true}) +end + +local function set_stocks_open(open) + if stocks.open ~= open then + gui.simulateInput(dfhack.gui.getDFViewscreen(true), 'D_STOCKS') + end +end + +local function expanded_count() + local n = 0 + for i=0,#stocks.current_type_a_expanded-1 do + if stocks.current_type_a_expanded[i] then n = n + 1 end + end + return n +end + +local saved_open + +config.wrapper = function(test_fn) + saved_open = stocks.open + -- the widget db can be empty if a rescan is still in progress + overlay.rescan() + return dfhack.with_finalize(function() + -- close and reopen so the game repopulates any lists the test mutated + set_stocks_open(false) + set_stocks_open(saved_open) + end, test_fn) +end + +function test.toggle_collapses_then_expands() + set_stocks_open(true) + local num_sections = #stocks.current_type_a_expanded + expect.true_(num_sections > 0) + for i=0,num_sections-1 do + stocks.current_type_a_expanded[i] = true + end + stocks.scroll_position_item = 5 + send_key('CUSTOM_CTRL_X') + expect.eq(0, expanded_count()) + -- collapsing resets the item scroll position so it stays in bounds + expect.eq(0, stocks.scroll_position_item) + -- with everything collapsed, the same hotkey expands + send_key('CUSTOM_CTRL_X') + expect.eq(num_sections, expanded_count()) +end + +function test.expand_hotkey_expands_all() + set_stocks_open(true) + for i=0,#stocks.current_type_a_expanded-1 do + stocks.current_type_a_expanded[i] = false + end + send_key('CUSTOM_CTRL_Z') + expect.eq(#stocks.current_type_a_expanded, expanded_count()) +end + +function test.remove_empty_keeps_type_scroll_in_bounds() + set_stocks_open(true) + stocks.scroll_position_type = #stocks.filtered_type_list + 10 + send_key('CUSTOM_CTRL_E') + expect.true_(stocks.scroll_position_type >= 0) + expect.true_(stocks.scroll_position_type < #stocks.filtered_type_list) +end + +function test.closed_stocks_ignores_overlay_keys() + set_stocks_open(false) + -- the stocks.open guard must make every hotkey a no-op while the page is + -- closed, even if an activation slips through as the page closes + local n = expanded_count() + send_key('CUSTOM_CTRL_X') + send_key('CUSTOM_CTRL_Z') + send_key('CUSTOM_CTRL_E') + expect.eq(n, expanded_count()) +end