From 00a19ea51a0d57727bbe4b31bcd60cbe4b897cee Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Fri, 18 Sep 2026 06:36:56 +0200 Subject: [PATCH 1/5] squads: add 'select all' toggle to squads sidebar Adds an overlay widget to the squads sidebar panel that selects all squads with one click (or the Ctrl-A hotkey). Activating it again when all squads are selected deselects them all. The widget anchors itself below the 'Create new squad' button and tracks external selection changes so its state stays accurate when the user clicks individual squad checkboxes. User-set positions (via gui/overlay) are respected. Implements DFHack/dfhack#5762 --- changelog.txt | 1 + docs/squads.rst | 23 ++++++++ squads.lua | 124 ++++++++++++++++++++++++++++++++++++++++ test/overlay/squads.lua | 109 +++++++++++++++++++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 docs/squads.rst create mode 100644 squads.lua create mode 100644 test/overlay/squads.lua diff --git a/changelog.txt b/changelog.txt index 1ed30ae501..6a8e1397b4 100644 --- a/changelog.txt +++ b/changelog.txt @@ -29,6 +29,7 @@ Template for new versions: ## New Tools - `gui/export-world-map`: New GUI tool to configure world map exports from the embark selection screen. +- `squads`: adds a "Select all" toggle (and a ``Ctrl-A`` hotkey) to the squads sidebar for selecting all squads at once ## New Features diff --git a/docs/squads.rst b/docs/squads.rst new file mode 100644 index 0000000000..732a64d7c1 --- /dev/null +++ b/docs/squads.rst @@ -0,0 +1,23 @@ +squads +====== + +.. dfhack-tool:: + :summary: Add a "select all" toggle to the squads sidebar. + :tags: fort interface military + +When you open the squads sidebar, you can click the checkbox next to each squad +to select which squads will receive your orders. This tool adds a "Select all" +toggle below the squad list so you can select (or deselect) all of your squads +with a single click instead of clicking each squad individually. + +The toggle can also be triggered with the ``Ctrl-A`` hotkey while the squads +sidebar is open. + +The widget is enabled by default. You can disable or reposition it with +`gui/overlay`. + +Usage +----- + +There is no commandline interface; the toggle appears automatically in the +squads sidebar. diff --git a/squads.lua b/squads.lua new file mode 100644 index 0000000000..296cce9d99 --- /dev/null +++ b/squads.lua @@ -0,0 +1,124 @@ +-- Adds a "select all" toggle to the squads sidebar +--@ module=true + +local help = [====[ + +squads +====== +Adds a "Ctrl+a: Select all" toggle button to the squads sidebar panel. When +activated, all squads are selected; when all squads are already selected, +activating it deselects them all. + +The button is implemented as an overlay widget and can be repositioned or +disabled via `gui/overlay`. + +Usage:: + + squads +]====] + +local overlay = require('plugins.overlay') +local widgets = require('gui.widgets') + +local if_squads = df.global.game.main_interface.squads + +local function all_selected() + local sel = if_squads.squad_selected + for i = 0, #sel - 1 do + if not sel[i] then return false end + end + return #sel > 0 +end + +local function set_all(value) + -- don't hijack the hotkey while the user is typing a name + if if_squads.entering_squad_nickname or if_squads.entering_cell_nickname then + return + end + local sel = if_squads.squad_selected + for i = 0, #sel - 1 do + sel[i] = value + end +end + +-- the squads sidebar is docked to the right edge of the screen, but its +-- vertical layout shifts with the window height, so we anchor ourselves to the +-- "Create new squad" button, which sits right below the squad list +local function find_create_squad_button() + local dscreen = dfhack.screen + local sw, sh = dscreen.getWindowSize() + for y = 4, sh - 6 do + local line = {} + for x = sw - 45, sw - 1 do + local tile = dscreen.readTile(x, y) + line[#line+1] = tile and tile.ch > 0 and tile.ch < 128 + and string.char(tile.ch) or ' ' + end + local s, e = table.concat(line):find('Create new squad') + if s then + return y, sw - 46 + s, sw - 46 + e + end + end +end + +SquadsOverlay = defclass(SquadsOverlay, overlay.OverlayWidget) +SquadsOverlay.ATTRS{ + desc='Adds a "select all" toggle to the squads sidebar.', + default_pos={x=-7, y=44}, + viewscreens='dwarfmode/Squads', + default_enabled=true, + overlay_onupdate_max_freq_seconds=0.5, + frame={w=23, h=1}, +} + +function SquadsOverlay:init() + self:addviews{ + widgets.ToggleHotkeyLabel{ + view_id='select_all', + frame={t=0, l=0}, + key='CUSTOM_CTRL_A', + label='Select all', + initial_option=false, + on_change=set_all, + }, + } +end + +function SquadsOverlay:onRenderBody(dc) + -- keep the displayed state in sync with the actual selection so the toggle + -- is accurate when the user clicks individual squad checkboxes + self.subviews.select_all:setOption(all_selected()) + SquadsOverlay.super.onRenderBody(self, dc) +end + +function SquadsOverlay:overlay_onupdate() + -- the overlay framework persists user-set positions in config.pos; if the + -- user has repositioned us away from the default, don't fight them + local config = overlay.get_state().config[self.name] + if config and config.pos and + (config.pos.x ~= self.default_pos.x or + config.pos.y ~= self.default_pos.y) then + return + end + local y, x1, x2 = find_create_squad_button() + if not y then return end + local sw = dfhack.screen.getWindowSize() + local w = self.frame.w or 23 + self.frame.t = y + 2 + self.frame.r = sw - math.floor((x1 + x2) / 2) - math.ceil(w / 2) +end + +OVERLAY_WIDGETS = {select_all=SquadsOverlay} + +if dfhack_flags.module then + return +end + +local args = {...} +if args[1] == 'help' or #args > 0 then + print(dfhack.script_help()) + return +end + +print('The squads sidebar select-all toggle is managed by the overlay framework.') +print('Use "gui/overlay" to reposition or disable it.') diff --git a/test/overlay/squads.lua b/test/overlay/squads.lua new file mode 100644 index 0000000000..1356f4b434 --- /dev/null +++ b/test/overlay/squads.lua @@ -0,0 +1,109 @@ +local gui = require('gui') +local overlay = require('plugins.overlay') + +local if_squads = df.global.game.main_interface.squads + +config = { + target = 'squads', + mode = 'fortress', +} + +local was_overlay_enabled = overlay.isEnabled() + +local function get_widget() + overlay.setEnabled(true) + overlay.rescan() + overlay.overlay_command({'enable', 'squads.select_all'}) + if not overlay.isOverlayEnabled('squads.select_all') then + qerror('could not enable squads.select_all overlay') + end + return overlay.get_state().db['squads.select_all'].widget +end + +local function count_selected() + local n = 0 + for i = 0, #if_squads.squad_selected - 1 do + if if_squads.squad_selected[i] then n = n + 1 end + end + return n +end + +local function feed_keys(keys) + overlay.feed_viewscreen_widgets('viewscreen_dwarfmodest', + dfhack.gui.getCurViewscreen(true), keys) +end + +local function with_squads_panel(test_fn) + expect.gt(#if_squads.squad_id, 0, + 'test fort must have at least one squad') + local saved_open = if_squads.open + local saved_sel = {} + for i = 0, #if_squads.squad_selected - 1 do + saved_sel[i] = if_squads.squad_selected[i] + end + dfhack.with_finalize( + function() + if_squads.open = saved_open + for i = 0, #saved_sel - 1 do + if_squads.squad_selected[i] = saved_sel[i] + end + overlay.setEnabled(was_overlay_enabled) + end, + function() + if_squads.open = true + for i = 0, #if_squads.squad_selected - 1 do + if_squads.squad_selected[i] = false + end + test_fn(get_widget()) + end) +end + +function test.select_all_hotkey_selects_all_squads() + with_squads_panel(function() + expect.eq(0, count_selected()) + feed_keys{CUSTOM_CTRL_A=true} + expect.eq(#if_squads.squad_id, count_selected()) + end) +end + +function test.select_all_hotkey_deselects_all_squads() + with_squads_panel(function() + feed_keys{CUSTOM_CTRL_A=true} + expect.eq(#if_squads.squad_id, count_selected()) + feed_keys{CUSTOM_CTRL_A=true} + expect.eq(0, count_selected()) + end) +end + +function test.select_all_click_toggles() + with_squads_panel(function(widget) + -- compute the widget's frame rect so mouse hit-testing works + local sw, sh = dfhack.screen.getWindowSize() + widget:updateLayout(gui.ViewRect{rect=gui.mkdims_wh(0, 0, sw, sh)}) + -- position the mouse inside the widget frame and click + local rect = widget.frame_rect + df.global.gps.mouse_x = rect.x1 + (widget.frame_parent_rect and widget.frame_parent_rect.x1 or 0) + 1 + df.global.gps.mouse_y = rect.y1 + (widget.frame_parent_rect and widget.frame_parent_rect.y1 or 0) + feed_keys{_MOUSE_L=true} + expect.eq(#if_squads.squad_id, count_selected()) + feed_keys{_MOUSE_L=true} + expect.eq(0, count_selected()) + end) +end + +function test.widget_tracks_external_selection_changes() + with_squads_panel(function(widget) + -- widget state should reflect selection changes made outside the widget + local label = widget.subviews.select_all + widget:onRenderBody() + expect.false_(label:getOptionValue()) + if_squads.squad_selected[0] = true + widget:onRenderBody() + expect.false_(label:getOptionValue()) + for i = 0, #if_squads.squad_selected - 1 do + if_squads.squad_selected[i] = true + end + widget:onRenderBody() + expect.true_(label:getOptionValue()) + end) +end From ff12c276fec1f059eb917f8051713945192f0d53 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Fri, 18 Sep 2026 06:55:49 +0200 Subject: [PATCH 2/5] squads: place toggle above 'Create new squad', switch to dynamic label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the widget from below the button to directly above it, centered on the button — the space below collides with the order icon row (shown when squads are selected) and the button's hover help text. Replace ToggleHotkeyLabel's On/Off suffix with a plain HotkeyLabel whose text switches between 'Select all' and 'Deselect all'. Fix the anchor never updating: frame.t/r changes only take effect after updateLayout(), which now runs when the anchored position changes. Also gate rescans on squad count/scroll/window-height so repositioning is near-instant while staying cheap, and retry while unanchored so a transient scan miss doesn't stick. --- squads.lua | 51 ++++++++++++++++++++++++++++++----------- test/overlay/squads.lua | 29 +++++++++++++++-------- 2 files changed, 58 insertions(+), 22 deletions(-) diff --git a/squads.lua b/squads.lua index 296cce9d99..24b7701743 100644 --- a/squads.lua +++ b/squads.lua @@ -17,6 +17,7 @@ Usage:: squads ]====] +local gui = require('gui') local overlay = require('plugins.overlay') local widgets = require('gui.widgets') @@ -41,6 +42,10 @@ local function set_all(value) end end +local function toggle_all() + set_all(not all_selected()) +end + -- the squads sidebar is docked to the right edge of the screen, but its -- vertical layout shifts with the window height, so we anchor ourselves to the -- "Create new squad" button, which sits right below the squad list @@ -64,30 +69,31 @@ end SquadsOverlay = defclass(SquadsOverlay, overlay.OverlayWidget) SquadsOverlay.ATTRS{ desc='Adds a "select all" toggle to the squads sidebar.', - default_pos={x=-7, y=44}, + default_pos={x=-4, y=44}, + version=1, viewscreens='dwarfmode/Squads', default_enabled=true, - overlay_onupdate_max_freq_seconds=0.5, - frame={w=23, h=1}, + overlay_onupdate_max_freq_seconds=0.1, + frame={w=20, h=1}, } function SquadsOverlay:init() self:addviews{ - widgets.ToggleHotkeyLabel{ + widgets.HotkeyLabel{ view_id='select_all', frame={t=0, l=0}, key='CUSTOM_CTRL_A', label='Select all', - initial_option=false, - on_change=set_all, + on_activate=toggle_all, }, } end function SquadsOverlay:onRenderBody(dc) - -- keep the displayed state in sync with the actual selection so the toggle - -- is accurate when the user clicks individual squad checkboxes - self.subviews.select_all:setOption(all_selected()) + -- keep the label in sync with the actual selection so it stays accurate + -- when the user clicks individual squad checkboxes + self.subviews.select_all:setLabel( + all_selected() and 'Deselect all' or 'Select all') SquadsOverlay.super.onRenderBody(self, dc) end @@ -100,12 +106,31 @@ function SquadsOverlay:overlay_onupdate() config.pos.y ~= self.default_pos.y) then return end + -- cheap bail-out: only rescan when something that can move the "Create new + -- squad" button changes (squad count, list scroll, window height), and + -- only after a successful anchor so a transient miss doesn't stick + local _, sh = dfhack.screen.getWindowSize() + local scan_key = #if_squads.squad_id * 1000 + + if_squads.scroll_position * 10 + sh + if scan_key == self.scan_key and self.anchored then return end + self.scan_key = scan_key local y, x1, x2 = find_create_squad_button() - if not y then return end + if not y then + self.anchored = false + return + end + -- sit directly above the button, centered on it and nudged right so the + -- label text lines up under the squad checkmark column + local t = y - 2 local sw = dfhack.screen.getWindowSize() - local w = self.frame.w or 23 - self.frame.t = y + 2 - self.frame.r = sw - math.floor((x1 + x2) / 2) - math.ceil(w / 2) + local w = self.frame.w or 20 + local r = sw - math.floor((x1 + x2) / 2) - math.ceil(w / 2) - 2 + if self.anchored and self.frame.t == t and self.frame.r == r then return end + self.frame.t = t + self.frame.l = nil + self.frame.r = r + self.anchored = true + self:updateLayout(gui.ViewRect{rect=gui.get_interface_rect()}) end OVERLAY_WIDGETS = {select_all=SquadsOverlay} diff --git a/test/overlay/squads.lua b/test/overlay/squads.lua index 1356f4b434..8a22e2824d 100644 --- a/test/overlay/squads.lua +++ b/test/overlay/squads.lua @@ -29,28 +29,39 @@ local function count_selected() end local function feed_keys(keys) - overlay.feed_viewscreen_widgets('viewscreen_dwarfmodest', - dfhack.gui.getCurViewscreen(true), keys) + gui.simulateInput(dfhack.gui.getCurViewscreen(true), keys) +end + +local function panel_is_open() + return dfhack.gui.matchFocusString('dwarfmode/Squads', + dfhack.gui.getDFViewscreen(true)) +end + +local function set_panel_open(open) + if panel_is_open() ~= open then + feed_keys'D_SQUADS' + end end local function with_squads_panel(test_fn) expect.gt(#if_squads.squad_id, 0, 'test fort must have at least one squad') - local saved_open = if_squads.open local saved_sel = {} for i = 0, #if_squads.squad_selected - 1 do saved_sel[i] = if_squads.squad_selected[i] end + local was_open = panel_is_open() dfhack.with_finalize( function() - if_squads.open = saved_open + set_panel_open(was_open) for i = 0, #saved_sel - 1 do if_squads.squad_selected[i] = saved_sel[i] end overlay.setEnabled(was_overlay_enabled) end, function() - if_squads.open = true + set_panel_open(true) + expect.true_(panel_is_open()) for i = 0, #if_squads.squad_selected - 1 do if_squads.squad_selected[i] = false end @@ -93,17 +104,17 @@ end function test.widget_tracks_external_selection_changes() with_squads_panel(function(widget) - -- widget state should reflect selection changes made outside the widget + -- the label should reflect selection changes made outside the widget local label = widget.subviews.select_all widget:onRenderBody() - expect.false_(label:getOptionValue()) + expect.eq('Select all', label.label) if_squads.squad_selected[0] = true widget:onRenderBody() - expect.false_(label:getOptionValue()) + expect.eq('Select all', label.label) for i = 0, #if_squads.squad_selected - 1 do if_squads.squad_selected[i] = true end widget:onRenderBody() - expect.true_(label:getOptionValue()) + expect.eq('Deselect all', label.label) end) end From d923474d3fb5fbe3dd6a16de8848de130d2e4fb4 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Fri, 18 Sep 2026 07:36:49 +0200 Subject: [PATCH 3/5] squads: anchor select-all toggle on first tick With a 0.1s update throttle the widget rendered at its default position before the anchor scan ran, visibly jumping to its final spot. Run the anchor check every tick (gated by the existing cheap scan-key bail) so it is positioned before the first visible frame. --- squads.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/squads.lua b/squads.lua index 24b7701743..13ced43360 100644 --- a/squads.lua +++ b/squads.lua @@ -73,7 +73,9 @@ SquadsOverlay.ATTRS{ version=1, viewscreens='dwarfmode/Squads', default_enabled=true, - overlay_onupdate_max_freq_seconds=0.1, + -- anchor on the first tick the panel opens so the widget never flashes + -- at the default position; the rescan is gated by a cheap key check + overlay_onupdate_max_freq_seconds=0, frame={w=20, h=1}, } From af8aa582ff7603ee2caa91625370fdfe987c9f0f Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Fri, 18 Sep 2026 07:38:49 +0200 Subject: [PATCH 4/5] squads: hide select-all toggle during disband confirmation The disband prompt shares the dwarfmode/Squads focus string, so the widget stayed visible (and kept consuming its hotkey) over it. Gate widget visibility on the squads interface's disband_confirmation flag. --- squads.lua | 5 +++++ test/overlay/squads.lua | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/squads.lua b/squads.lua index 13ced43360..c10da37b18 100644 --- a/squads.lua +++ b/squads.lua @@ -72,6 +72,11 @@ SquadsOverlay.ATTRS{ default_pos={x=-4, y=44}, version=1, viewscreens='dwarfmode/Squads', + -- hide while the disband confirmation is showing; it shares the + -- dwarfmode/Squads focus string so it can't be filtered by viewscreens + visible=function() + return not df.global.game.main_interface.squads.disband_confirmation + end, default_enabled=true, -- anchor on the first tick the panel opens so the widget never flashes -- at the default position; the rescan is gated by a cheap key check diff --git a/test/overlay/squads.lua b/test/overlay/squads.lua index 8a22e2824d..3790141467 100644 --- a/test/overlay/squads.lua +++ b/test/overlay/squads.lua @@ -102,6 +102,25 @@ function test.select_all_click_toggles() end) end +function test.widget_hidden_during_disband_confirmation() + with_squads_panel(function(widget) + local getval = require('utils').getval + expect.false_(if_squads.disband_confirmation) + expect.true_(getval(widget.visible)) + dfhack.with_finalize( + function() + if_squads.disband_confirmation = false + end, + function() + if_squads.disband_confirmation = true + expect.false_(getval(widget.visible)) + -- hidden widgets must not consume the hotkey + feed_keys{CUSTOM_CTRL_A=true} + expect.eq(0, count_selected()) + end) + end) +end + function test.widget_tracks_external_selection_changes() with_squads_panel(function(widget) -- the label should reflect selection changes made outside the widget From 05e4ca01764aa818e27a6244170b883a18f0f3b4 Mon Sep 17 00:00:00 2001 From: Alistair-Afton Date: Tue, 22 Sep 2026 03:32:10 +0200 Subject: [PATCH 5/5] squads: create a test squad when the fort has none The squads panel only populates squad_id/squad_selected while open, and the CI test fort may not have any squads at all. Create one on a free squad position during setup instead of assuming the fixture provides one, and use if_squads.open for the open check since focus strings lag a frame behind simulated input. --- test/overlay/squads.lua | 56 +++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/test/overlay/squads.lua b/test/overlay/squads.lua index 3790141467..78758d18c4 100644 --- a/test/overlay/squads.lua +++ b/test/overlay/squads.lua @@ -32,37 +32,61 @@ local function feed_keys(keys) gui.simulateInput(dfhack.gui.getCurViewscreen(true), keys) end -local function panel_is_open() - return dfhack.gui.matchFocusString('dwarfmode/Squads', - dfhack.gui.getDFViewscreen(true)) -end - +-- if_squads.open toggles synchronously on D_SQUADS; focus strings for the +-- squads tab only update on the next frame, so they cannot be used here local function set_panel_open(open) - if panel_is_open() ~= open then + if if_squads.open ~= open then feed_keys'D_SQUADS' end end -local function with_squads_panel(test_fn) - expect.gt(#if_squads.squad_id, 0, - 'test fort must have at least one squad') - local saved_sel = {} - for i = 0, #if_squads.squad_selected - 1 do - saved_sel[i] = if_squads.squad_selected[i] +-- the CI test fort may not have any squads; create one on a free squad +-- position (positions with squad_size > 0) so the panel lists something +local function ensure_test_squad() + local fort = df.historical_entity.find(df.global.plotinfo.group_id) + if not fort then return end + local free_aid + for _, a in ipairs(fort.positions.assignments) do + if a.squad_id ~= -1 then return end + if not free_aid then + for _, p in ipairs(fort.positions.own) do + if p.id == a.position_id and p.squad_size > 0 then + free_aid = a.id + break + end + end + end + end + if free_aid then + dfhack.military.makeSquad(free_aid) end - local was_open = panel_is_open() +end + +local function with_squads_panel(test_fn) + ensure_test_squad() + local was_open = if_squads.open + local saved_sel dfhack.with_finalize( function() set_panel_open(was_open) - for i = 0, #saved_sel - 1 do - if_squads.squad_selected[i] = saved_sel[i] + if saved_sel then + for i = 0, #saved_sel - 1 do + if i < #if_squads.squad_selected then + if_squads.squad_selected[i] = saved_sel[i] + end + end end overlay.setEnabled(was_overlay_enabled) end, function() + -- squad_id/squad_selected are only populated while the panel is open set_panel_open(true) - expect.true_(panel_is_open()) + expect.true_(if_squads.open) + expect.gt(#if_squads.squad_id, 0, + 'test fort must have at least one squad') + saved_sel = {} for i = 0, #if_squads.squad_selected - 1 do + saved_sel[i] = if_squads.squad_selected[i] if_squads.squad_selected[i] = false end test_fn(get_widget())