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..c10da37b18 --- /dev/null +++ b/squads.lua @@ -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.') diff --git a/test/overlay/squads.lua b/test/overlay/squads.lua new file mode 100644 index 0000000000..3790141467 --- /dev/null +++ b/test/overlay/squads.lua @@ -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