Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions docs/squads.rst
Original file line number Diff line number Diff line change
@@ -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.
156 changes: 156 additions & 0 deletions squads.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
-- 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 gui = require('gui')
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

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
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=-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
overlay_onupdate_max_freq_seconds=0,
frame={w=20, h=1},
}

function SquadsOverlay:init()
self:addviews{
widgets.HotkeyLabel{
view_id='select_all',
frame={t=0, l=0},
key='CUSTOM_CTRL_A',
label='Select all',
on_activate=toggle_all,
},
}
end

function SquadsOverlay:onRenderBody(dc)
-- 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

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
-- 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
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 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}

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.')
139 changes: 139 additions & 0 deletions test/overlay/squads.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
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)
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_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()
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()
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
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_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
local label = widget.subviews.select_all
widget:onRenderBody()
expect.eq('Select all', label.label)
if_squads.squad_selected[0] = true
widget:onRenderBody()
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.eq('Deselect all', label.label)
end)
end