diff --git a/autotraining.lua b/autotraining.lua new file mode 100644 index 0000000000..2f6b03f644 --- /dev/null +++ b/autotraining.lua @@ -0,0 +1,309 @@ +-- Based on the original code by RNGStrategist (who also got some help from Uncle Danny) +--@ enable = true +--@ module = true + +local repeatUtil = require('repeat-util') +local utils=require('utils') + +validArgs = utils.invert({ + 't' +}) + +local args = utils.processArgs({...}, validArgs) +local GLOBAL_KEY = "autotraining" +local need_id = df.need_type['MartialTraining'] +local ignore_count = 0 + +local function get_default_state() + return { + enabled=false, + threshold=-5000, + ignored={}, + ignored_nobles={}, + training_squads = {}, + } +end + +state = state or get_default_state() + +function isEnabled() + return state.enabled +end + +-- persisting a table with numeric keys results in a json array with a huge number of null entries +-- therefore, we convert the keys to strings for persistence +local function to_persist(persistable) + local persistable_ignored = {} + for k, v in pairs(persistable) do + persistable_ignored[tostring(k)] = v + end + return persistable_ignored +end + +-- loads both from the older array format and the new string table format +local function from_persist(persistable) + if not persistable then + return + end + local ret = {} + for k, v in pairs(persistable) do + ret[tonumber(k)] = v + end + return ret +end + +function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, { + enabled=state.enabled, + threshold=state.threshold, + ignored=to_persist(state.ignored), + ignored_nobles=state.ignored_nobles, + training_squads=to_persist(state.training_squads) + }) +end + +--- Load the saved state of the script +local function load_state() + -- load persistent data + local persisted_data = dfhack.persistent.getSiteData(GLOBAL_KEY, {}) + state.enabled = persisted_data.enabled or state.enabled + state.threshold = persisted_data.threshold or state.threshold + state.ignored = from_persist(persisted_data.ignored) or state.ignored + state.ignored_nobles = persisted_data.ignored_nobles or state.ignored_nobles + state.training_squads = from_persist(persisted_data.training_squads) or state.training_squads + return state +end + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + state.enabled = false + return + end + -- the state changed, is a map loaded and is that map in fort mode? + if sc ~= SC_MAP_LOADED or df.global.gamemode ~= df.game_mode.DWARF then + -- no its isnt, so bail + return + end + -- yes it was, so: + + -- retrieve state saved in game. merge with default state so config + -- saved from previous versions can pick up newer defaults. + load_state() + if ( state.enabled ) then + start() + else + stop() + end + -- start can change the enabled state if the squad cant be found + if state.enabled then + dfhack.print(GLOBAL_KEY .." was persisted with the following data:\nThreshold: ".. state.threshold .. '\n') + end + persist_state() +end + + +--###### +--Functions +--###### +function getTrainingCandidates() + local ret = {} + local citizen = dfhack.units.getCitizens(true) + ignore_count = 0 + for _, unit in ipairs(citizen) do + if dfhack.units.isAdult(unit) then + local noblePos = dfhack.units.getNoblePositions(unit) + local isIgnNoble = false + if ( not state.ignored[unit.id] ) then + if noblePos ~=nil then + for _, position in ipairs(noblePos) do + if state.ignored_nobles[position.position.code] then + isIgnNoble = true + break + end + end + end + if not isIgnNoble then + table.insert(ret, unit) + else + removeTraining(unit) + ignore_count = ignore_count +1 + end + else + removeTraining(unit) + ignore_count = ignore_count +1 + end + end + end + return ret +end + +function getTrainingSquads() + local squads = {} + for squad_id, _ in pairs(state.training_squads) do + local squad = df.squad.find(squad_id) + if squad then + table.insert(squads, squad) + else + -- setting to nil during iteration is permitted by lua + state.training_squads[squad_id] = nil + end + end + return squads +end + +function findNeed(unit) + local needs = unit.status.current_soul.personality.needs + for _, need in ipairs(needs) do + if need.id == need_id then + return need + end + end + return nil +end + +--###### +--Main +--###### + +function getByID(id) + for _, unit in ipairs(getTrainingCandidates()) do + if (unit.hist_figure_id == id) then + return unit + end + end + + return nil +end + +-- Find all training squads +-- Abort if no squads found +function checkSquads() + local squads = {} + for _, squad in ipairs(getTrainingSquads()) do + if squad.entity_id == df.global.plotinfo.group_id then + local leader = squad.positions[0].occupant + if ( leader ~= -1) then + table.insert(squads,squad) + end + end + end + + if (#squads == 0) then + return nil + end + + return squads +end + +function addTraining(unit) + if (unit.military.squad_id ~= -1) then + for _, squad in ipairs(getTrainingSquads()) do + if unit.military.squad_id == squad.id then + return true + end + end + return false + end + for _, squad in ipairs(getTrainingSquads()) do + for i=1,9,1 do + if ( squad.positions[i].occupant == -1 ) then + dfhack.military.addToSquad(unit.id,squad.id,i) + -- squad.positions[i].occupant = unit.hist_figure_id + -- unit.military.squad_id = squad.id + -- unit.military.squad_position = i + return true + end + end + end + + return false +end + +function removeTraining(unit) + for _, squad in ipairs(getTrainingSquads()) do + for i=1,9,1 do + if ( unit.hist_figure_id == squad.positions[i].occupant ) then + dfhack.military.removeFromSquad(unit.id) + -- unit.military.squad_id = -1 + -- unit.military.squad_position = -1 + -- squad.positions[i].occupant = -1 + return true + end + end + end + return false +end + +function removeAll() + if ( state.training_squads == nil) then return end + for _, squad in ipairs(getTrainingSquads()) do + for i=1,9,1 do + local dwarf = getByID(squad.positions[i].occupant) + if (dwarf ~= nil) then + removeTraining(dwarf) + end + end + end +end + + +function check() + local squads = checkSquads() + local intraining_count = 0 + local inque_count = 0 + if ( squads == nil) then return end + for _, unit in ipairs(getTrainingCandidates()) do + local need = findNeed(unit) + if ( need ~= nil ) then + if ( need.focus_level < state.threshold ) then + local bol = addTraining(unit) + if ( bol ) then + intraining_count = intraining_count +1 + else + inque_count = inque_count +1 + end + else + removeTraining(unit) + end + end + end + + dfhack.println(GLOBAL_KEY .. " | IGN: " .. ignore_count .. " TRAIN: " .. intraining_count .. " QUE: " ..inque_count ) +end + +function start() + dfhack.println(GLOBAL_KEY .. " | START") + + if (args.t) then + state.threshold = 0-tonumber(args.t) + end + repeatUtil.scheduleEvery(GLOBAL_KEY, 1, 'days', check) -- 997 is the closest prime to 1000 +end + +function stop() + removeAll() + repeatUtil.cancel(GLOBAL_KEY) + dfhack.println(GLOBAL_KEY .. " | STOP") +end + +if dfhack_flags.enable then + if dfhack_flags.enable_state then + state.enabled = true + else + state.enabled = false + end + persist_state() +end + +if dfhack_flags.module then + return +end + +if ( state.enabled ) then + start() + dfhack.println(GLOBAL_KEY .." | Enabled") +else + stop() + dfhack.println(GLOBAL_KEY .." | Disabled") +end +persist_state() diff --git a/changelog.txt b/changelog.txt index bc4da721cd..a308669168 100644 --- a/changelog.txt +++ b/changelog.txt @@ -29,6 +29,8 @@ Template for new versions: ## New Tools ## New Features +- `gui/spectate`: added "Prefer nicknamed" to the list of options +- `gui/mod-manager`: when run in a loaded world, opens a copyable list of active mods. ## Fixes - `list-agreements`: fix date math when determining petition age @@ -48,6 +50,7 @@ Template for new versions: - `gui/mass-remove`: add a button to the bottom toolbar when eraser mode is active for launching `gui/mass-remove` - `idle-crafting`: default to only considering happy and ecstatic units for the highest need threshold - `gui/sitemap`: add a button to the toolbar at the bottom left corner of the screen for launching `gui/sitemap` +- `gui/design`: add a button to the toolbar at the bottom left corner of the screen for launching `gui/design` ## Fixes - `idle-crafting`: check that units still have crafting needs before creating a job for them diff --git a/docs/autotraining.rst b/docs/autotraining.rst new file mode 100644 index 0000000000..c647905516 --- /dev/null +++ b/docs/autotraining.rst @@ -0,0 +1,41 @@ +autotraining +============ + +.. dfhack-tool:: + :summary: Assigns citizens to a military squad until they have fulfilled their need for Martial Training + :tags: fort auto bugfix units + +Automation script for citizens to hit the gym when they yearn for the gains. Also passively builds military skills and physical stats. + +You need to have at least one squad that is set up for training. This should be a new non-military-use squad. The uniform should be +set to "No Uniform" and the squad should be set to "Constant Training" in the military screen. Edit the squad's schedule to full time training with around 8 units training. +The squad doesn't need months off. The members leave the squad once they have gotten their gains. + +Once you have made squads for training use `gui/autotraining` to select the squads and ignored units, as well as the needs threshhold. + +Usage +----- + + ``autotraining []`` + +Examples +-------- + +``autotraining`` + Current status of script + +``enable autotraining`` + Checks to see if you have fullfilled the creation of a training gym. + If there is no squad marked for training use, a clickable notification will appear letting you know to set one up/ + Searches your fort for dwarves with a need to go to the gym, and begins assigning them to said gym. + Once they have fulfilled their need they will be removed from the gym squad to be replaced by the next dwarf in the list. + +``disable autotraining`` + Stops adding new units to the squad. + +Options +------- + ``-t`` + Use integer values. (Default 5000) + The negative need threshhold to trigger for each citizen + The greater the number the longer before a dwarf is added to the waiting list. diff --git a/docs/gui/autotraining.rst b/docs/gui/autotraining.rst new file mode 100644 index 0000000000..a86b28adf9 --- /dev/null +++ b/docs/gui/autotraining.rst @@ -0,0 +1,15 @@ +gui/autotraining +================ + +.. dfhack-tool:: + :summary: GUI interface for ``autotraining`` + :tags: fort auto interface + +This is an in-game configuration interface for `autotraining`. You can pick squads for training, select ignored units, and set the needs threshold. + +Usage +----- + +:: + + gui/autotraining diff --git a/docs/gui/mod-manager.rst b/docs/gui/mod-manager.rst index 8972fece72..0b957cd2a7 100644 --- a/docs/gui/mod-manager.rst +++ b/docs/gui/mod-manager.rst @@ -2,11 +2,11 @@ gui/mod-manager =============== .. dfhack-tool:: - :summary: Save and restore lists of active mods. + :summary: Manange your active mods. :tags: dfhack interface -Adds an optional overlay to the mod list screen that allows you to save and -load mod list presets, as well as set a default mod list preset for new worlds. +In a loaded world, shows a list of active mods with the ability to copy to clipboard. + Usage ----- @@ -14,3 +14,20 @@ Usage :: gui/mod-manager + +Overlay +------- + +This tool also provides two overlays that are managed by the `overlay` +framework. + +gui/mod-manager.button +~~~~~~~~~~~~~~~~~~~~~~ + +Adds an optional overlay to the mod list screen that allows you to save and +load mod list presets, as well as set a default mod list preset for new worlds. + +gui/mod-manager.notification +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Displays a message when a mod preset has been auto-applied. diff --git a/gui/autotraining.lua b/gui/autotraining.lua new file mode 100644 index 0000000000..03ac183267 --- /dev/null +++ b/gui/autotraining.lua @@ -0,0 +1,245 @@ +---@diagnostic disable: missing-fields + +local gui = require('gui') +local widgets = require('gui.widgets') + +local autotraining = reqscript('autotraining') + +local training_squads = autotraining.state.training_squads +local ignored_units = autotraining.state.ignored +local ignored_nobles = autotraining.state.ignored_nobles + +AutoTrain = defclass(AutoTrain, widgets.Window) +AutoTrain.ATTRS { + frame_title='Training Setup', + frame={w=55, h=45}, + resizable=true, -- if resizing makes sense for your dialog + resize_min={w=55, h=20}, -- try to allow users to shrink your windows +} + +local SELECTED_ICON = dfhack.pen.parse{ch=string.char(251), fg=COLOR_LIGHTGREEN} +function AutoTrain:getSquadIcon(squad_id) + if training_squads[squad_id] then + return SELECTED_ICON + end + return nil +end + +function AutoTrain:getSquads() + local squads = {} + for _, squad in ipairs(df.global.world.squads.all) do + if not (squad.entity_id == df.global.plotinfo.group_id) then + goto continue + end + table.insert(squads, { + text = dfhack.translation.translateName(squad.name, true)..' ('..squad.alias..')', + icon = self:callback("getSquadIcon", squad.id ), + id = squad.id + }) + + ::continue:: + end + return squads +end + +function AutoTrain:toggleSquad(_, choice) + training_squads[choice.id] = not training_squads[choice.id] + autotraining.persist_state() + self:updateLayout() +end + +local IGNORED_ICON = dfhack.pen.parse{ch='x', fg=COLOR_RED} +function AutoTrain:getUnitIcon(unit_id) + if ignored_units[unit_id] then + return IGNORED_ICON + end + return nil +end + +function AutoTrain:getNobleIcon(noble_code) + if ignored_nobles[noble_code] then + return IGNORED_ICON + end + return nil +end + +function AutoTrain:getUnits() + local unit_choices = {} + for _, unit in ipairs(dfhack.units.getCitizens(true,false)) do + if not dfhack.units.isAdult(unit) then + goto continue + end + + table.insert(unit_choices, { + text = dfhack.units.getReadableName(unit), + icon = self:callback("getUnitIcon", unit.id ), + id = unit.id + }) + ::continue:: + end + return unit_choices +end + +function AutoTrain:toggleUnit(_, choice) + ignored_units[choice.id] = not ignored_units[choice.id] + autotraining.persist_state() + self:updateLayout() +end + +local function to_title_case(str) + return dfhack.capitalizeStringWords(dfhack.lowerCp437(str:gsub('_', ' '))) +end + +function toSet(list) + local set = {} + for _, v in ipairs(list) do + set[v] = true + end + return set +end + +local function add_positions(positions, entity) + if not entity then return end + for _,position in pairs(entity.positions.own) do + positions[position.id] = { + id=position.id+1, + code=position.code, + } + end +end + +function AutoTrain:getPositions() + local positions = {} + local excludedPositions = toSet({ + 'MILITIA_CAPTAIN', + 'MILITIA_COMMANDER', + 'OUTPOST_LIAISON', + 'CAPTAIN_OF_THE_GUARD', + }) + + add_positions(positions, df.historical_entity.find(df.global.plotinfo.civ_id)) + add_positions(positions, df.historical_entity.find(df.global.plotinfo.group_id)) + + -- Step 1: Extract values into a sortable array + local sortedPositions = {} + for _, val in pairs(positions) do + if val and not excludedPositions[val.code] then + table.insert(sortedPositions, val) + end + end + + -- Step 2: Sort the positions (optional, adjust sorting criteria) + table.sort(sortedPositions, function(a, b) + return a.id < b.id -- Sort alphabetically by code + end) + + -- Step 3: Rebuild the table without gaps + positions = {} -- Reset positions table + for i, val in ipairs(sortedPositions) do + positions[i] = { + text = to_title_case(val.code), + value = val.code, + pen = COLOR_LIGHTCYAN, + icon = self:callback("getNobleIcon", val.code), + id = val.id + } + end + + return positions +end + + + +function AutoTrain:toggleNoble(_, choice) + ignored_nobles[choice.value] = not ignored_nobles[choice.value] + autotraining.persist_state() + self:updateLayout() +end + +function AutoTrain:init() + self:addviews{ + widgets.Label{ + frame={ t = 0 , h = 1 }, + text = "Select squads for automatic training:", + }, + widgets.List{ + view_id = "squad_list", + icon_width = 2, + frame = { t = 1, h = 5 }, + choices = self:getSquads(), + on_submit=self:callback("toggleSquad") + }, + widgets.Divider{ frame={t=6, h=1}, frame_style_l = false, frame_style_r = false}, + widgets.Label{ + frame={ t = 7 , h = 1 }, + text = "General options:", + }, + widgets.EditField { + view_id = "threshold", + frame={ t = 8 , h = 1 }, + key = "CUSTOM_T", + label_text = "Need threshold for training: ", + text = tostring(-autotraining.state.threshold), + on_char = function (char, _) + return tonumber(char,10) + end, + on_submit = function (text) + -- still necessary, because on_char does not check pasted text + local entered_number = tonumber(text,10) or 5000 + autotraining.state.threshold = -entered_number + autotraining.persist_state() + -- make sure that the auto correction is reflected in the EditField + self.subviews.threshold:setText(tostring(entered_number)) + end + }, + widgets.Divider{ frame={t=9, h=1}, frame_style_l = false, frame_style_r = false}, + widgets.Label{ + frame={ t = 10 , h = 1 }, + text = "Ignored noble positions:", + }, + widgets.List{ + frame = { t = 11 , h = 11}, + view_id = "nobles_list", + icon_width = 2, + choices = self:getPositions(), + on_submit=self:callback("toggleNoble") + }, + widgets.Divider{ frame={t=22, h=1}, frame_style_l = false, frame_style_r = false}, + widgets.Label{ + frame={ t = 23 , h = 1 }, + text = "Select units to exclude from automatic training:" + }, + widgets.FilteredList{ + frame = { t = 24 }, + view_id = "unit_list", + edit_key = "CUSTOM_CTRL_F", + icon_width = 2, + choices = self:getUnits(), + on_submit=self:callback("toggleUnit") + } + } + --self.subviews.unit_list:setChoices(unit_choices) +end + +function AutoTrain:onDismiss() + view = nil +end + +AutoTrainScreen = defclass(AutoTrainScreen, gui.ZScreen) +AutoTrainScreen.ATTRS { + focus_path='autotrain', +} + +function AutoTrainScreen:init() + self:addviews{AutoTrain{}} +end + +function AutoTrainScreen:onDismiss() + view = nil +end + +if not dfhack.world.isFortressMode() or not dfhack.isMapLoaded() then + qerror('gui/autotraining requires a fortress map to be loaded') +end + +view = view and view:raise() or AutoTrainScreen{}:show() diff --git a/gui/design.lua b/gui/design.lua index 103ab59c8e..42432a4c43 100644 --- a/gui/design.lua +++ b/gui/design.lua @@ -38,6 +38,13 @@ local util = reqscript('internal/design/util') local utils = require('utils') local widgets = require('gui.widgets') +local toolbar_textures = dfhack.textures.loadTileset('hack/data/art/design_toolbar.png', 8, 12) + +function launch_design() + dfhack.run_script('gui/design') +end + + local Point = util.Point local getMousePoint = util.getMousePoint @@ -168,11 +175,6 @@ function RightClickOverlay:onInput(keys) end end -OVERLAY_WIDGETS = { - dimensions=DimensionsOverlay, - rightclick=RightClickOverlay, -} - --- --- HelpWindow --- @@ -1565,6 +1567,145 @@ function DesignScreen:onDismiss() view = nil end + +-- -------------------------------- +-- DesignToolbarOverlay +-- + +local tb = reqscript('internal/df-bottom-toolbars') + +local BP_BUTTON_WIDTH = 4 +local BP_BUTTON_HEIGHT = 3 +local BP_TOOLTIP_WIDTH = 20 +local BP_TOOLTIP_HEIGHT = 6 +local BP_WIDTH = math.max(BP_BUTTON_WIDTH, BP_TOOLTIP_WIDTH) +local BP_HEIGHT = BP_TOOLTIP_HEIGHT + 1 --[[empty line]] + BP_BUTTON_HEIGHT + +local function design_button_offsets(interface_rect) + local center_bar = tb.fort.center:frame(interface_rect) + return { + l = center_bar.l + center_bar.w, + r = center_bar.r - BP_BUTTON_WIDTH, + t = center_bar.t, + b = center_bar.b, + } +end + +local BP_MIN_OFFSETS = design_button_offsets(tb.MINIMUM_INTERFACE_RECT) + + + +DesignToolbarOverlay = defclass(DesignToolbarOverlay, overlay.OverlayWidget) +DesignToolbarOverlay.ATTRS{ + desc='Adds a button to the toolbar at the bottom of the screen for launching gui/design.', + default_pos={x=BP_MIN_OFFSETS.l+1, y=-(BP_MIN_OFFSETS.b+1)}, + default_enabled=true, + viewscreens='dwarfmode/Default', + frame={w=BP_WIDTH, h=BP_HEIGHT}, +} + +function DesignToolbarOverlay:init() + local button_chars = { + {218, 196, 196, 191}, + {179, '[', ']', 179}, + {192, 196, 196, 217}, + } + + self:addviews{ + widgets.Panel{ + view_id='tooltip', + frame={t=0, r=0, w=BP_WIDTH, h=BP_TOOLTIP_HEIGHT}, + frame_style=gui.FRAME_PANEL, + frame_background=gui.CLEAR_PEN, + frame_inset={l=1, r=1}, + visible=function() return self.subviews.icon:getMousePos() end, + subviews={ + widgets.Label{ + text={ + 'Open the design', NEWLINE, + 'interface.', NEWLINE, + NEWLINE, + {text='Hotkey: ', pen=COLOR_GRAY}, {key='CUSTOM_CTRL_D'}, + }, + }, + }, + }, + widgets.Panel{ + view_id='icon', + frame={b=0, r=BP_WIDTH-BP_BUTTON_WIDTH, w=BP_BUTTON_WIDTH, h=tb.TOOLBAR_HEIGHT}, + subviews={ + widgets.Label{ + text=widgets.makeButtonLabelText{ + chars=button_chars, + pens={ + {COLOR_GRAY, COLOR_GRAY, COLOR_GRAY, COLOR_GRAY}, + {COLOR_GRAY, COLOR_BLUE, COLOR_BLUE, COLOR_GRAY}, + {COLOR_GRAY, COLOR_GRAY, COLOR_GRAY, COLOR_GRAY}, + }, + tileset=toolbar_textures, + tileset_offset=1, + tileset_stride=8, + }, + on_click=launch_design, + visible=function () return not self.subviews.icon:getMousePos() end, + }, + widgets.Label{ + text=widgets.makeButtonLabelText{ + chars=button_chars, + pens={ + {COLOR_WHITE, COLOR_WHITE, COLOR_WHITE, COLOR_WHITE}, + {COLOR_WHITE, COLOR_BLUE, COLOR_BLUE, COLOR_WHITE}, + {COLOR_WHITE, COLOR_WHITE, COLOR_WHITE, COLOR_WHITE}, + }, + tileset=toolbar_textures, + tileset_offset=5, + tileset_stride=8, + }, + on_click=launch_design, + visible=function() return self.subviews.icon:getMousePos() end, + }, + }, + }, + } +end + +function DesignToolbarOverlay:preUpdateLayout(parent_rect) + self.frame.w = (parent_rect.width+1)//2 - 16 + local extra_width + local offsets = design_button_offsets(parent_rect) + if self.frame.l then + extra_width = offsets.l - BP_MIN_OFFSETS.l + self.subviews.tooltip.frame.l = nil + self.subviews.tooltip.frame.r = 0 + self.subviews.icon.frame.l = nil + self.subviews.icon.frame.r = BP_WIDTH-BP_BUTTON_WIDTH + else + extra_width = offsets.r - BP_MIN_OFFSETS.r + self.subviews.tooltip.frame.r = nil + self.subviews.tooltip.frame.l = 0 + self.subviews.icon.frame.r = nil + self.subviews.icon.frame.l = 0 + end + local extra_height + if self.frame.b then + extra_height = offsets.b - BP_MIN_OFFSETS.b + else + extra_height = offsets.t - BP_MIN_OFFSETS.t + end + self.frame.w = BP_WIDTH + extra_width + self.frame.h = BP_HEIGHT + extra_height +end + +function DesignToolbarOverlay:onInput(keys) + return DesignToolbarOverlay.super.onInput(self, keys) +end + +OVERLAY_WIDGETS = { + dimensions=DimensionsOverlay, + rightclick=RightClickOverlay, + toolbar=DesignToolbarOverlay +} + if dfhack_flags.module then return end if not dfhack.isMapLoaded() then diff --git a/gui/mass-remove.lua b/gui/mass-remove.lua index ca3cb8aab6..abeecde864 100644 --- a/gui/mass-remove.lua +++ b/gui/mass-remove.lua @@ -399,13 +399,54 @@ end -- MassRemoveToolbarOverlay -- +-- The overlay is positioned so that (by default) its button is placed next to +-- the right-most button of the secondary toolbar displayed for the "erase" tool +-- (used to remove designations). + +-- This would be straightforward in a full_interface overlay (just set its +-- l/r/t/b frame positioning fields). However, to preserve player-positioning, +-- we take a more circuitous route. + +-- In a minimum-size interface area, the player is allowed to place the overlay +-- (a combination of the button and the tooltip) anywhere inside the interface +-- area. When the interface area is resized (usually through window resizes, but +-- also through interface percentage changes), placement is maintained relative +-- to the "ideal" position of the button (i.e. positions are relative to the +-- "ideal" position). When repositioning the overlay in a larger-than-minimum +-- interface area the overlays size is artificially inflated so that the overlay +-- can not be positioned (with respect to the "ideal" button position) farther +-- away than is possible in a minimum-size interface area. This limits the +-- positioning, but keeps the (relative) position consistent across all possible +-- resizes. + +local tb = reqscript('internal/df-bottom-toolbars') + +local MR_BUTTON_WIDTH = 4 +local MR_BUTTON_HEIGHT = 3 +local MR_TOOLTIP_WIDTH = 26 +local MR_TOOLTIP_HEIGHT = 6 +local MR_WIDTH = math.max(MR_BUTTON_WIDTH, MR_TOOLTIP_WIDTH) +local MR_HEIGHT = MR_TOOLTIP_HEIGHT + 1 --[[empty line]] + MR_BUTTON_HEIGHT + +local function mass_remove_button_offsets(interface_rect) + local remove_buttons = tb.fort.center:secondary_toolbar_frame(interface_rect, 'erase') + return { + l = remove_buttons.l + remove_buttons.w, + r = remove_buttons.r - MR_BUTTON_WIDTH, + t = remove_buttons.t, + b = remove_buttons.b, + } +end + +local MR_MIN_OFFSETS = mass_remove_button_offsets(tb.MINIMUM_INTERFACE_RECT) + MassRemoveToolbarOverlay = defclass(MassRemoveToolbarOverlay, overlay.OverlayWidget) MassRemoveToolbarOverlay.ATTRS{ desc='Adds a button to the erase toolbar to open the mass removal tool.', - default_pos={x=42, y=-4}, + default_pos={x=MR_MIN_OFFSETS.l+1, y=-(MR_MIN_OFFSETS.b+1)}, default_enabled=true, viewscreens='dwarfmode/Designate/ERASE', - frame={w=26, h=10}, + frame={w=MR_WIDTH, h=MR_HEIGHT}, } function MassRemoveToolbarOverlay:init() @@ -417,51 +458,57 @@ function MassRemoveToolbarOverlay:init() self:addviews{ widgets.Panel{ - frame={t=0, r=0, w=26, h=6}, - frame_style=gui.FRAME_PANEL, - frame_background=gui.CLEAR_PEN, - frame_inset={l=1, r=1}, - visible=function() return self.subviews.icon:getMousePos() end, - subviews={ - widgets.Label{ - text={ - 'Open mass removal', NEWLINE, - 'interface.', NEWLINE, - NEWLINE, - {text='Hotkey: ', pen=COLOR_GRAY}, {key='CUSTOM_M'}, - }, - }, - }, - }, - widgets.Panel{ - view_id='icon', - frame={b=0, r=22, w=4, h=3}, - subviews={ - widgets.Label{ - text=widgets.makeButtonLabelText{ - chars=button_chars, - pens=COLOR_GRAY, - tileset=toolbar_textures, - tileset_offset=1, - tileset_stride=8, + view_id='tt_and_icon', + frame={ r=0, t=0, w=MR_WIDTH, h=MR_HEIGHT }, + subviews={ + widgets.Panel{ + frame={t=0, l=0, w=MR_WIDTH, h=MR_TOOLTIP_HEIGHT}, + frame_style=gui.FRAME_PANEL, + frame_background=gui.CLEAR_PEN, + frame_inset={l=1, r=1}, + visible=function() return self.subviews.icon:getMousePos() end, + subviews={ + widgets.Label{ + text={ + 'Open mass removal', NEWLINE, + 'interface.', NEWLINE, + NEWLINE, + {text='Hotkey: ', pen=COLOR_GRAY}, {key='CUSTOM_M'}, + }, + }, }, - on_click=launch_mass_remove, - visible=function () return not self.subviews.icon:getMousePos() end, }, - widgets.Label{ - text=widgets.makeButtonLabelText{ - chars=button_chars, - pens={ - {COLOR_WHITE, COLOR_WHITE, COLOR_WHITE, COLOR_WHITE}, - {COLOR_WHITE, COLOR_GRAY, COLOR_GRAY, COLOR_WHITE}, - {COLOR_WHITE, COLOR_WHITE, COLOR_WHITE, COLOR_WHITE}, + widgets.Panel{ + view_id='icon', + frame={b=0, l=0, w=MR_BUTTON_WIDTH, h=tb.SECONDARY_TOOLBAR_HEIGHT}, + subviews={ + widgets.Label{ + text=widgets.makeButtonLabelText{ + chars=button_chars, + pens=COLOR_GRAY, + tileset=toolbar_textures, + tileset_offset=1, + tileset_stride=8, + }, + on_click=launch_mass_remove, + visible=function () return not self.subviews.icon:getMousePos() end, + }, + widgets.Label{ + text=widgets.makeButtonLabelText{ + chars=button_chars, + pens={ + {COLOR_WHITE, COLOR_WHITE, COLOR_WHITE, COLOR_WHITE}, + {COLOR_WHITE, COLOR_GRAY, COLOR_GRAY, COLOR_WHITE}, + {COLOR_WHITE, COLOR_WHITE, COLOR_WHITE, COLOR_WHITE}, + }, + tileset=toolbar_textures, + tileset_offset=5, + tileset_stride=8, + }, + on_click=launch_mass_remove, + visible=function() return self.subviews.icon:getMousePos() end, }, - tileset=toolbar_textures, - tileset_offset=5, - tileset_stride=8, }, - on_click=launch_mass_remove, - visible=function() return self.subviews.icon:getMousePos() end, }, }, }, @@ -469,12 +516,17 @@ function MassRemoveToolbarOverlay:init() end function MassRemoveToolbarOverlay:preUpdateLayout(parent_rect) - local w = parent_rect.width - if w <= 130 then - self.frame.w = 50 - else - self.frame.w = (parent_rect.width+1)//2 - 15 - end + local offsets = mass_remove_button_offsets(parent_rect) + local r = offsets.r - MR_MIN_OFFSETS.r + local l = offsets.l - MR_MIN_OFFSETS.l + local t = offsets.t - MR_MIN_OFFSETS.t + local b = offsets.b - MR_MIN_OFFSETS.b + self.frame.w = MR_WIDTH + l + r + self.frame.h = MR_HEIGHT + t + b + self.subviews.tt_and_icon.frame.l = l + self.subviews.tt_and_icon.frame.r = r + self.subviews.tt_and_icon.frame.t = t + self.subviews.tt_and_icon.frame.b = b end function MassRemoveToolbarOverlay:onInput(keys) diff --git a/gui/mod-manager.lua b/gui/mod-manager.lua index 51df00d677..6284eb1c73 100644 --- a/gui/mod-manager.lua +++ b/gui/mod-manager.lua @@ -8,6 +8,8 @@ local dialogs = require('gui.dialogs') local json = require('json') local utils = require('utils') +local scriptmanager = require('script-manager') + local presets_file = json.open("dfhack-config/mod-manager.json") local GLOBAL_KEY = 'mod-manager' @@ -401,6 +403,107 @@ function ModmanageScreen:init() } end +ModlistMenu = defclass(ModlistMenu, widgets.Window) +ModlistMenu.ATTRS { + frame_title = "Active Modlist", + + resize_min = { w = 30, h = 15 }, + frame = { w = 40, h = 20 }, + + resizable = true, +} + +local function getWorldModlist(detailed, include_vanilla) + -- ordered map of mod id -> {handled=bool, versions=map of version -> path} + local mods = utils.OrderedTable() + local mod_paths = {} + + -- if a world is loaded, process active mods first, and lock to active version + if dfhack.isWorldLoaded() then + scriptmanager.getAllModsInfo(include_vanilla, mods, mod_paths) + local modlist = {} + for _,mod in ipairs(mod_paths) do + if detailed then + local url + if mods[mod.id].steam_id then + url = ': https://steamcommunity.com/sharedfiles/filedetails/?id='.. mods[mod.id].steam_id + end + table.insert(modlist,('%s %s (%s)%s'):format(mods[mod.id].name or mod.id, mods[mod.id].version or '', mod.id, url or '')) + else + table.insert(modlist,mods[mod.id].name or mod.id) + end + end + return modlist + end + qerror('No world is loaded') +end + +function ModlistMenu:init() + self.include_vanilla = self.include_vanilla or false + self:addviews{ + widgets.Label{ + frame = { l=0, t=0 }, + text = {'Active mods:'}, + }, + widgets.HotkeyLabel{ + view_id='copy_names', + frame={t=1, r=1}, + label='Copy mod names to clipboard', + text_pen=COLOR_YELLOW, + auto_width=true, + on_activate=function() + local mods = table.concat(getWorldModlist(false, self.include_vanilla), ', ') + dfhack.internal.setClipboardTextCp437(mods) + end, + enabled=function() return #self.subviews.modlist:getChoices() > 0 end, + }, + widgets.HotkeyLabel{ + view_id='copy_list', + frame={t=2, r=1}, + label='Copy list to clipboard', + text_pen=COLOR_YELLOW, + auto_width=true, + on_activate=function() + local mods = table.concat(getWorldModlist(true, self.include_vanilla), NEWLINE) + dfhack.internal.setClipboardTextCp437Multiline(mods) + end, + enabled=function() return #self.subviews.modlist:getChoices() > 0 end, + }, + widgets.List{ + view_id='modlist', + frame = {t=4,b=2}, + choices = getWorldModlist(true,self.include_vanilla) + }, + widgets.HotkeyLabel{ + view_id='include_vanilla', + frame={b=0}, + key='CUSTOM_V', + label='Include Vanilla Mods: ' .. ((self.include_vanilla and 'Yes') or 'No'), + on_activate=function () + self.include_vanilla = not self.include_vanilla + self.subviews.include_vanilla:setLabel('Include Vanilla Mods: ' .. ((self.include_vanilla and 'Yes') or 'No')) + self.subviews.modlist:setChoices(getWorldModlist(true,self.include_vanilla)) + end + } + } +end + + +ModlistScreen = defclass(ModlistScreen, gui.ZScreen) +ModlistScreen.ATTRS { + focus_path = "modlist", +} + +function ModlistScreen:init() + self:addviews{ + ModlistMenu{} + } +end + +function ModlistScreen:onDismiss() + view = nil +end + ModmanageOverlay = defclass(ModmanageOverlay, overlay.OverlayWidget) ModmanageOverlay.ATTRS { frame = { w=16, h=3 }, @@ -494,5 +597,4 @@ if dfhack_flags.module then return end --- TODO: when invoked as a command, should show information on which mods are loaded --- and give the player the option to export the list (or at least copy it to the clipboard) +view = view and view:raise() or ModlistScreen{}:show() diff --git a/gui/sitemap.lua b/gui/sitemap.lua index 576966f44c..6146175b93 100644 --- a/gui/sitemap.lua +++ b/gui/sitemap.lua @@ -365,13 +365,34 @@ end -- SitemapToolbarOverlay -- +local tb = reqscript('internal/df-bottom-toolbars') + +local BP_BUTTON_WIDTH = 4 +local BP_BUTTON_HEIGHT = 3 +local BP_TOOLTIP_WIDTH = 27 +local BP_TOOLTIP_HEIGHT = 6 +local BP_WIDTH = math.max(BP_BUTTON_WIDTH, BP_TOOLTIP_WIDTH) +local BP_HEIGHT = BP_TOOLTIP_HEIGHT + 1 --[[empty line]] + BP_BUTTON_HEIGHT + +local function design_button_offsets(interface_rect) + local center_bar = tb.fort.left:frame(interface_rect) + return { + l = center_bar.l + center_bar.w, + r = center_bar.r - BP_BUTTON_WIDTH, + t = center_bar.t, + b = center_bar.b, + } +end + +local BP_MIN_OFFSETS = design_button_offsets(tb.MINIMUM_INTERFACE_RECT) + SitemapToolbarOverlay = defclass(SitemapToolbarOverlay, overlay.OverlayWidget) SitemapToolbarOverlay.ATTRS{ desc='Adds a button to the toolbar at the bottom left corner of the screen for launching gui/sitemap.', - default_pos={x=34, y=-1}, + default_pos={x=BP_MIN_OFFSETS.l+1, y=-(BP_MIN_OFFSETS.b+1)}, default_enabled=true, viewscreens='dwarfmode', - frame={w=28, h=10}, + frame={w=BP_WIDTH, h=BP_HEIGHT}, } function SitemapToolbarOverlay:init() @@ -383,7 +404,8 @@ function SitemapToolbarOverlay:init() self:addviews{ widgets.Panel{ - frame={t=0, l=0, w=27, h=6}, + view_id='tooltip', + frame={t=0, r=0, w=BP_WIDTH, h=BP_TOOLTIP_HEIGHT}, frame_style=gui.FRAME_PANEL, frame_background=gui.CLEAR_PEN, frame_inset={l=1, r=1}, @@ -401,7 +423,7 @@ function SitemapToolbarOverlay:init() }, widgets.Panel{ view_id='icon', - frame={b=0, l=0, w=4, h=3}, + frame={b=0, r=BP_WIDTH-BP_BUTTON_WIDTH, w=BP_BUTTON_WIDTH, h=tb.TOOLBAR_HEIGHT}, subviews={ widgets.Label{ text=widgets.makeButtonLabelText{ @@ -434,6 +456,33 @@ function SitemapToolbarOverlay:init() } end +function SitemapToolbarOverlay:preUpdateLayout(parent_rect) + self.frame.w = (parent_rect.width+1)//2 - 16 + local extra_width + local offsets = design_button_offsets(parent_rect) + if self.frame.l then + extra_width = offsets.l - BP_MIN_OFFSETS.l + self.subviews.tooltip.frame.l = nil + self.subviews.tooltip.frame.r = 0 + self.subviews.icon.frame.l = nil + self.subviews.icon.frame.r = BP_WIDTH-BP_BUTTON_WIDTH + else + extra_width = offsets.r - BP_MIN_OFFSETS.r + self.subviews.tooltip.frame.r = nil + self.subviews.tooltip.frame.l = 0 + self.subviews.icon.frame.r = nil + self.subviews.icon.frame.l = 0 + end + local extra_height + if self.frame.b then + extra_height = offsets.b - BP_MIN_OFFSETS.b + else + extra_height = offsets.t - BP_MIN_OFFSETS.t + end + self.frame.w = BP_WIDTH + extra_width + self.frame.h = BP_HEIGHT + extra_height +end + function SitemapToolbarOverlay:onInput(keys) return SitemapToolbarOverlay.super.onInput(self, keys) end diff --git a/gui/spectate.lua b/gui/spectate.lua index 3e7bbd09b8..558e281b5f 100644 --- a/gui/spectate.lua +++ b/gui/spectate.lua @@ -46,7 +46,7 @@ end Spectate = defclass(Spectate, widgets.Window) Spectate.ATTRS { frame_title='Spectate', - frame={l=5, t=5, w=36, h=41}, + frame={l=5, t=5, w=36, h=42}, } local function create_toggle_button(frame, cfg_elem, hotkey, label, cfg_elem_key) @@ -199,36 +199,37 @@ function Spectate:init() create_toggle_button({t=11}, 'include-wildlife', 'CUSTOM_ALT_W', rpad('Include wildlife', lWidth)), create_toggle_button({t=12}, 'prefer-conflict', 'CUSTOM_ALT_B', rpad('Prefer conflict', lWidth)), create_toggle_button({t=13}, 'prefer-new-arrivals', 'CUSTOM_ALT_N', rpad('Prefer new arrivals', lWidth)), + create_toggle_button({t=14}, 'prefer-nicknamed', 'CUSTOM_ALT_I', rpad('Prefer nicknamed', lWidth)), widgets.Divider{ - frame={t=15, h=1}, + frame={t=16, h=1}, frame_style=gui.FRAME_THIN, frame_style_l=false, frame_style_r=false, }, widgets.Label{ - frame={t=17, l=0}, + frame={t=18, l=0}, text="Tooltips:" }, ToggleLabel{ - frame={t=17, l=12}, + frame={t=18, l=12}, initial_option=overlay.isOverlayEnabled(OVERLAY_NAME), on_change=function(val) dfhack.run_command('overlay', val and 'enable' or 'disable', OVERLAY_NAME) end, key='CUSTOM_ALT_O', label="Overlay ", }, widgets.Label{ - frame={t=19, l=colFollow}, + frame={t=20, l=colFollow}, text='Follow', }, widgets.Label{ - frame={t=19, l=colHover}, + frame={t=20, l=colHover}, text='Hover', }, - create_row({t=21}, 'Enabled', 'E', '', colFollow, colHover), + create_row({t=22}, 'Enabled', 'E', '', colFollow, colHover), - create_numeric_edit_field({t=23}, 'tooltip-follow-blink-milliseconds', 'CUSTOM_B', 'Blink period (ms): '), + create_numeric_edit_field({t=24}, 'tooltip-follow-blink-milliseconds', 'CUSTOM_B', 'Blink period (ms): '), widgets.CycleHotkeyLabel{ - frame={t=24}, + frame={t=25}, key='CUSTOM_C', label="Hold to show:", options={ @@ -241,11 +242,11 @@ function Spectate:init() on_change=function(new, _) dfhack.run_command('spectate', 'set', 'tooltip-follow-hold-to-show', new) end }, - create_row({t=26}, 'Job', 'J', 'job', colFollow, colHover), - create_row({t=27}, 'Activity', 'A', 'activity', colFollow, colHover), - create_row({t=28}, 'Name', 'N', 'name', colFollow, colHover), - create_row({t=29}, 'Stress', 'S', 'stress', colFollow, colHover), - create_stress_list({t=30}, colFollow, colHover), + create_row({t=27}, 'Job', 'J', 'job', colFollow, colHover), + create_row({t=28}, 'Activity', 'A', 'activity', colFollow, colHover), + create_row({t=29}, 'Name', 'N', 'name', colFollow, colHover), + create_row({t=30}, 'Stress', 'S', 'stress', colFollow, colHover), + create_stress_list({t=31}, colFollow, colHover), } end diff --git a/internal/control-panel/registry.lua b/internal/control-panel/registry.lua index 37cd56c4e2..6cd4229fc2 100644 --- a/internal/control-panel/registry.lua +++ b/internal/control-panel/registry.lua @@ -34,6 +34,8 @@ COMMANDS_BY_IDX = { desc='Automatically shear creatures that are ready for shearing.', params={'--time', '14', '--timeUnits', 'days', '--command', '[', 'workorder', 'ShearCreature', ']'}}, {command='autoslab', group='automation', mode='enable'}, + {command='autotraining', group='automation', mode='enable', + desc='Automation script for citizens to hit the gym when they yearn for the gains.'}, {command='ban-cooking all', group='automation', mode='run'}, {command='buildingplan set boulders false', group='automation', mode='run', desc='Enable if you usually don\'t want to use boulders for construction.'}, diff --git a/internal/df-bottom-toolbars.lua b/internal/df-bottom-toolbars.lua new file mode 100644 index 0000000000..59e78c58de --- /dev/null +++ b/internal/df-bottom-toolbars.lua @@ -0,0 +1,631 @@ +-- Provide data-driven locations for the DF toolbars at the bottom of the +-- screen. Not quite as nice as getting the data from DF directly, but better +-- than hand-rolling calculations for each "interesting" button. + +--[[ + +DF bottom toolbars module +------------------------- + +This module provides a (black box) reproduction of how DF places its toolbar +buttons at the bottom of the screen. Several DFHack overlay UI elements are +placed with respect to (parts of) the toolbars that DF puts at the bottom of the +screen. + +The three primary toolbars are placed flush left, "centered", and flush right in +the interface area (which varies based on configured interface percentage and +the width (in UI tiles) of the DF window itself). + +In narrow interfaces areas (114-130 UI columns) the center toolbar isn't exactly +centered to avoid overlapping with the left toolbar. Furthermore the center +toolbar has secondary toolbars that can be opened above it. These secondary +toolbars are nominally opened directly above the button for the "tool" they +belong to. If there is not room for a secondary toolbar placed above its +"opening button", it will be pushed to the left to fit (even when most of the +width of the toolbar is currently hidden because its advanced options are +disabled). For example, the following secondary toolbars are "pushed left" when +the interface area is narrow enough: + +* dig: 114-175 UI columns +* chop, gather: 114-120 UI columns +* smooth, 114-151 UI columns +* traffic, 114-195 UI columns + +This nearly-centering and width-constrained placement can be derived manually +for specific toolbars, but this often results in using the above-mentioned +widths as "magic numbers". Instead of internalizing these values, this module +implicitly derives them based on the static toolbar widths and dynamic interface +sizes (combined with a few directly observable "padding" widths and a specific +rounding method used for the center toolbar). + +The module provides the following fields: + +* ``MINIMUM_INTERFACE_RECT`` + + The UI tile dimensions (from ``gui.mkdims_wh``) of the minimum-size DF window: + 114x46 UI tiles. + +* ``TOOLBAR_HEIGHT`` + + The UI tile height of the primary toolbars at the bottom of the DF window. + +* ``SECONDARY_TOOLBAR_HEIGHT`` + + The UI tile height of the secondary toolbars that are sometimes placed above + the primary toolbar. + +* ``fort`` + + The static "definitions" of the fortress mode toolbars. Each of + ``fort.right``, ``fort.center``, and ``fort.left`` has the following fields: + + * ``toolbar.width`` + + The overall width of the DF toolbar. + + * ``toolbar.buttons`` table that can be indexed by "button names" provided by + this module: + + * ``fort.left.buttons`` has ``citizens``, ``tasks``, ``places``, ``labor``, ``orders``, + ``nobles``, ``objects``, and ``justice`` buttons + + * ``fort.center.buttons`` has ``dig``, ``chop``, ``gather``, ``smooth``, ``erase``, + ``build``, ``stockpile``, ``zone``, ``burrow``, ``cart``, ``traffic``, and + ``mass_designation`` buttons + + * ``fort.right.buttons`` has ``squads``, and ``world`` buttons + + Each button (``toolbar.buttons[name]``) has two fields: + + * ``button.offset`` + + The offset of the button from the left-most column of the toolbar. + + * ``button.width`` + + The width of the button in UI tiles. + + * ``toolbar:frame(interface_rect)`` method + + The ``interface_rect`` parameter must have ``width`` and ``height`` fields, + and should represent the size of an interface area (e.g., from the + ``parent_rect`` parameter given to a non-fullscreen overlay's + ``updateLayout`` method, or directly from ``gui.get_interface_rect()``). + + Returns a Widget-style "frame" table that has fields for the offsets (``l``, + ``r``, ``t``, ``b``) and size (``w``, ``h``) of the toolbar when it is + displayed in an interface area of ``interface_rect`` size. + + The interface area left-offset of a specific button can be found by adding a + toolbar's frame's ``l`` field and the ``offset`` of one of its buttons:: + + local tb = reqscript('internal/df-bottom-toolbars') + ... + local right = tb.fort.right + local squads_offset = right:frame(interface_size) + right.buttons.squads.offset + + The ``center`` definition has an additional field and method: + + * ``fort.center.secondary_toolbars`` + + Provides the definitions of secondary toolbar for several "tools": ``dig``, + ``chop``, ``gather``, ``smooth``, ``erase``, ``stockpile``, + ``stockpile_paint``, ``burrow_paint``, ``traffic``, and + ``mass_designation``. See the complete list of secondary toolbar buttons in + the module's code. + + The definition of a secondary toolbar uses the same ``width`` & ``buttons`` + fields as the primary toolbars. + + * ``fort.center:secondary_toolbar_frame(interface_rect, secondary_name)`` + + Provides the frame (like ``toolbar:frame()``) for the specified secondary + toolbar when displayed in the specified interface size. + + Again, a button's ``offset`` can be combined with its secondary toolbar's + frame's ``l`` to find the location of a specific button:: + + local tb = reqscript('internal/df-bottom-toolbars') + ... + local center = tb.fort.center + local dig_advanced_offset = center:secondary_toolbar_frame(interface_size, 'dig') + + center.secondary_toolbars.dig.advanced_toggle.offset + +]] + +--@module = true + +TOOLBAR_HEIGHT = 3 +SECONDARY_TOOLBAR_HEIGHT = 3 +MINIMUM_INTERFACE_RECT = require('gui').mkdims_wh(0, 0, 114, 46) + +---@generic T +---@param sequences T[][] +---@return T[] +local function concat_sequences(sequences) + local collected = {} + for _, sequence in ipairs(sequences) do + table.move(sequence, 1, #sequence, #collected + 1, collected) + end + return collected +end + +---@alias NamedWidth table -- single entry, value is width +---@alias NamedOffsets table -- multiple entries, values are offsets +---@alias Button { offset: integer, width: integer } +---@alias NamedButtons table -- multiple entries + +---@class Toolbar +---@field button_offsets NamedOffsets deprecated, use buttons[name].offset +---@field buttons NamedButtons +---@field width integer + +---@class Toolbar.Widget.frame: widgets.Widget.frame +---@field l integer Gap between the left edge of the frame and the parent. +---@field t integer Gap between the top edge of the frame and the parent. +---@field r integer Gap between the right edge of the frame and the parent. +---@field b integer Gap between the bottom edge of the frame and the parent. +---@field w integer Width +---@field h integer Height + +---@param widths NamedWidth[] single-name entries only! +---@return Toolbar +local function button_widths_to_toolbar(widths) + local offsets = {} + local buttons = {} + local offset = 0 + for _, ww in ipairs(widths) do + local name, w = next(ww) + if name then + if not name:startswith('_') then + offsets[name] = offset + buttons[name] = { offset = offset, width = w } + end + offset = offset + w + end + end + return { button_offsets = offsets, buttons = buttons, width = offset } +end + +---@param buttons string[] +---@return NamedWidth[] +local function buttons_to_widths(buttons) + local widths = {} + for _, button_name in ipairs(buttons) do + table.insert(widths, { [button_name] = 4 }) + end + return widths +end + +---@param buttons string[] +---@return Toolbar +local function buttons_to_toolbar(buttons) + return button_widths_to_toolbar(buttons_to_widths(buttons)) +end + +-- Fortress mode toolbar definitions +fort = {} + +---@class LeftToolbar : Toolbar +fort.left = buttons_to_toolbar{ + 'citizens', 'tasks', 'places', 'labor', + 'orders', 'nobles', 'objects', 'justice', +} + +---@param interface_rect gui.dimension +---@return Toolbar.Widget.frame +function fort.left:frame(interface_rect) + return { + l = 0, + w = self.width, + r = interface_rect.width - self.width, + + t = interface_rect.height - TOOLBAR_HEIGHT, + h = TOOLBAR_HEIGHT, + b = 0, + } +end + +fort.left_center_gap_minimum = 7 + +---@class CenterToolbar: Toolbar +fort.center = button_widths_to_toolbar{ + { _left_border = 1 }, + { dig = 4 }, { chop = 4 }, { gather = 4 }, { smooth = 4 }, { erase = 4 }, + { _divider = 1 }, + { build = 4 }, { stockpile = 4 }, { zone = 4 }, + { _divider = 1 }, + { burrow = 4 }, { cart = 4 }, { traffic = 4 }, + { _divider = 1 }, + { mass_designation = 4 }, + { _right_border = 1 }, +} + +---@param interface_rect gui.dimension +---@return Toolbar.Widget.frame +function fort.center:frame(interface_rect) + -- center toolbar is "centered" in interface area, but never closer to the + -- left toolbar than fort.left_center_gap_minimum + + local interface_offset_centered = math.ceil((interface_rect.width - self.width + 1) / 2) + local interface_offset_min = fort.left.width + fort.left_center_gap_minimum + local interface_offset = math.max(interface_offset_min, interface_offset_centered) + + return { + l = interface_offset, + w = self.width, + r = interface_rect.width - interface_offset - self.width, + + t = interface_rect.height - TOOLBAR_HEIGHT, + h = TOOLBAR_HEIGHT, + b = 0, + } +end + +---@alias CenterToolbarToolNames 'dig' | 'chop' | 'gather' | 'smooth' | 'erase' | 'build' | 'stockpile' | 'zone' | 'burrow' | 'cart' | 'traffic' | 'mass_designation' +---@alias CenterToolbarSecondaryToolbarNames 'dig' | 'chop' | 'gather' | 'smooth' | 'erase' | 'stockpile' | 'stockpile_paint' | 'burrow_paint' | 'traffic' | 'mass_designation' + +---@param interface_rect gui.dimension +---@param toolbar_name CenterToolbarSecondaryToolbarNames +---@return Toolbar.Widget.frame +function fort.center:secondary_toolbar_frame(interface_rect, toolbar_name) + local secondary_toolbar = self.secondary_toolbars[toolbar_name] or + dfhack.error('invalid toolbar name: ' .. toolbar_name) + + ---@type CenterToolbarToolNames + local tool_name + if toolbar_name == 'stockpile_paint' then + tool_name = 'stockpile' + elseif toolbar_name == 'burrow_paint' then + tool_name = 'burrow' + else + tool_name = toolbar_name --[[@as CenterToolbarToolNames]] + end + local toolbar_offset = self:frame(interface_rect).l + local toolbar_button = self.buttons[tool_name] or dfhack.error('invalid tool name: ' .. tool_name) + + -- Ideally, the secondary toolbar is positioned directly above the (main) toolbar button + local ideal_offset = toolbar_offset + toolbar_button.offset + + -- In "narrow" interfaces conditions, a wide secondary toolbar (pretty much + -- any tool that has "advanced" options) that was ideally positioned above + -- its tool's button would extend past the right edge of the interface area. + -- Such wide secondary toolbars are instead right justified with a bit of + -- padding. + + -- padding necessary to line up width-constrained secondaries + local secondary_padding = 5 + local width_constrained_offset = math.max(0, interface_rect.width - (secondary_toolbar.width + secondary_padding)) + + -- Use whichever position is left-most. + local l = math.min(ideal_offset, width_constrained_offset) + return { + l = l, + w = secondary_toolbar.width, + r = interface_rect.width - l - secondary_toolbar.width, + + t = interface_rect.height - TOOLBAR_HEIGHT - SECONDARY_TOOLBAR_HEIGHT, + h = SECONDARY_TOOLBAR_HEIGHT, + b = TOOLBAR_HEIGHT, + } +end + +---@type table +fort.center.secondary_toolbars = { + dig = buttons_to_toolbar{ + 'dig', 'stairs', 'ramp', 'channel', 'remove_construction', '_gap', + 'rectangle', 'draw', '_gap', + 'advanced_toggle', '_gap', + 'all', 'auto', 'ore_gem', 'gem', '_gap', + 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', '_gap', + 'blueprint', 'blueprint_to_standard', 'standard_to_blueprint', + }, + chop = buttons_to_toolbar{ + 'chop', '_gap', + 'rectangle', 'draw', '_gap', + 'advanced_toggle', '_gap', + 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', '_gap', + 'blueprint', 'blueprint_to_standard', 'standard_to_blueprint', + }, + gather = buttons_to_toolbar{ + 'gather', '_gap', + 'rectangle', 'draw', '_gap', + 'advanced_toggle', '_gap', + 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', '_gap', + 'blueprint', 'blueprint_to_standard', 'standard_to_blueprint', + }, + smooth = buttons_to_toolbar{ + 'smooth', 'engrave', 'carve_track', 'carve_fortification', '_gap', + 'rectangle', 'draw', '_gap', + 'advanced_toggle', '_gap', + 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', '_gap', + 'blueprint', 'blueprint_to_standard', 'standard_to_blueprint', + }, + erase = buttons_to_toolbar{ + 'rectangle', + 'draw', + }, + -- build -- completely different and quite variable + stockpile = buttons_to_toolbar{ 'add_stockpile' }, + stockpile_paint = buttons_to_toolbar{ + 'rectangle', 'draw', 'erase_toggle', 'remove', + }, + -- zone -- no secondary toolbar + -- burrow -- no direct secondary toolbar + burrow_paint = buttons_to_toolbar{ + 'rectangle', 'draw', 'erase_toggle', 'remove', + }, + -- cart -- no secondary toolbar + traffic = button_widths_to_toolbar( + concat_sequences{ buttons_to_widths{ + 'high', 'normal', 'low', 'restricted', '_gap', + 'rectangle', 'draw', '_gap', + 'advanced_toggle', '_gap', + }, { + { weight_which = 4 }, + { weight_slider = 26 }, + { weight_input = 6 }, + } } + ), + mass_designation = buttons_to_toolbar{ + 'claim', 'forbid', 'dump', 'no_dump', 'melt', 'no_melt', 'hidden', 'visible', '_gap', + 'rectangle', 'draw', + }, +} + +---@class RightToolbar: Toolbar +fort.right = buttons_to_toolbar{ + 'squads', 'world', +} + +---@param interface_rect gui.dimension +---@return Toolbar.Widget.frame +function fort.right:frame(interface_rect) + return { + l = interface_rect.width - self.width, + w = self.width, + r = 0, + + t = interface_rect.height - TOOLBAR_HEIGHT, + h = TOOLBAR_HEIGHT, + b = 0, + } +end + +if dfhack_flags.module then return end + +if not dfhack.world.isFortressMode() then + qerror('Demo only supports fort mode.') +end + +local gui = require('gui') +local Panel = require('gui.widgets.containers.panel') +local Label = require('gui.widgets.labels.label') +local utils = require('utils') +local Window = require('gui.widgets.containers.window') +local Toggle = require('gui.widgets.labels.toggle_hotkey_label') + +local screen + +local visible_when_not_focused = true + +local function visible() + return visible_when_not_focused or screen and screen:isActive() and not screen.defocused +end + +ToolbarDemoPanel = defclass(ToolbarDemoPanel, Panel) +ToolbarDemoPanel.ATTRS{ + frame_style = function(...) + local style = gui.FRAME_THIN(...) + style.signature_pen = false + return style + end, + visible_override = true, + visible = visible, + frame_background = { ch = 32, bg = COLOR_BLACK }, +} + +local temp_x, temp_y = 10, 10 +local label_width = 9 -- max len of words left, right, center, secondary +local demo_panel_width = label_width + 4 +local demo_panel_height = 3 + +local left_toolbar_demo = ToolbarDemoPanel{ + frame_title = 'left toolbar', + frame = { l = temp_x, t = temp_y, w = demo_panel_width, h = demo_panel_height }, + subviews = { Label{ view_id = 'buttons', frame = { l = 0, r = 0 } } }, +} +local center_toolbar_demo = ToolbarDemoPanel{ + frame_title = 'center toolbar', + frame = { l = temp_x + demo_panel_width, t = temp_y, w = demo_panel_width, h = demo_panel_height }, + subviews = { Label{ view_id = 'buttons', frame = { l = 0, r = 0 } } }, +} +local right_toolbar_demo = ToolbarDemoPanel{ + frame_title = 'right toolbar', + frame = { l = temp_x + 2 * demo_panel_width, t = temp_y, w = demo_panel_width, h = demo_panel_height }, + subviews = { Label{ view_id = 'buttons', frame = { l = 0, r = 0 } } }, +} +local secondary_visible = false +local secondary_toolbar_demo +secondary_toolbar_demo = ToolbarDemoPanel{ + frame_title = 'secondary toolbar', + frame = { l = temp_x + demo_panel_width, t = temp_y - demo_panel_height, w = demo_panel_width, h = demo_panel_height }, + subviews = { Label{ view_id = 'buttons', frame = { l = 0, r = 0 } } }, + visible = function() return visible() and secondary_visible end, +} + +---@param secondary? CenterToolbarSecondaryToolbarNames +local function update_demonstrations(secondary) + -- by default, draw primary toolbar demonstrations right above the primary toolbars: + -- {l demo} {c demo} {r demo} + -- [l tool] [c tool] [r tool] (bottom of UI) + local toolbar_demo_dy = -TOOLBAR_HEIGHT + local ir = gui.get_interface_rect() + ---@param v widgets.Panel + ---@param frame widgets.Widget.frame + ---@param buttons NamedButtons + local function update(v, frame, buttons) + v.frame = { + w = frame.w, + h = frame.h, + l = frame.l + ir.x1, + t = frame.t + ir.y1 + toolbar_demo_dy, + } + local sorted = {} + for _, button in pairs(buttons) do + utils.insert_sorted(sorted, button, 'offset') + end + local buttons = '' + for i, o in ipairs(sorted) do + if o.offset > #buttons then + buttons = buttons .. (' '):rep(o.offset - #buttons) + end + if o.width == 1 then + buttons = buttons .. '|' + elseif o.width > 1 then + buttons = buttons .. '/'..('-'):rep(o.width - 2)..'\\' + end + end + v.subviews.buttons:setText( + buttons:sub(2) -- the demo panel border is at offset 0, so trim first character to start at offset 1 + ) + end + if secondary then + -- a secondary toolbar is active, move the primary demonstration up to + -- let the secondary be demonstrated right above the actual secondary: + -- {l demo} {c demo} {r demo} + -- {s demo} + -- [s tool] + -- [l tool] [c tool] [r tool] (bottom of UI) + update(secondary_toolbar_demo, fort.center:secondary_toolbar_frame(ir, secondary), + fort.center.secondary_toolbars[secondary].buttons) + secondary_visible = true + toolbar_demo_dy = toolbar_demo_dy - 2 * SECONDARY_TOOLBAR_HEIGHT + else + secondary_visible = false + end + + update(left_toolbar_demo, fort.left:frame(ir), fort.left.buttons) + update(right_toolbar_demo, fort.right:frame(ir), fort.right.buttons) + update(center_toolbar_demo, fort.center:frame(ir), fort.center.buttons) +end + +local tool_from_designation = { + -- df.main_designation_type.NONE -- not a tool + [df.main_designation_type.DIG_DIG] = 'dig', + [df.main_designation_type.DIG_REMOVE_STAIRS_RAMPS] = 'dig', + [df.main_designation_type.DIG_STAIR_UP] = 'dig', + [df.main_designation_type.DIG_STAIR_UPDOWN] = 'dig', + [df.main_designation_type.DIG_STAIR_DOWN] = 'dig', + [df.main_designation_type.DIG_RAMP] = 'dig', + [df.main_designation_type.DIG_CHANNEL] = 'dig', + [df.main_designation_type.CHOP] = 'chop', + [df.main_designation_type.GATHER] = 'gather', + [df.main_designation_type.SMOOTH] = 'smooth', + [df.main_designation_type.TRACK] = 'smooth', + [df.main_designation_type.ENGRAVE] = 'smooth', + [df.main_designation_type.FORTIFY] = 'smooth', + -- df.main_designation_type.REMOVE_CONSTRUCTION -- not used? + [df.main_designation_type.CLAIM] = 'mass_designation', + [df.main_designation_type.UNCLAIM] = 'mass_designation', + [df.main_designation_type.MELT] = 'mass_designation', + [df.main_designation_type.NO_MELT] = 'mass_designation', + [df.main_designation_type.DUMP] = 'mass_designation', + [df.main_designation_type.NO_DUMP] = 'mass_designation', + [df.main_designation_type.HIDE] = 'mass_designation', + [df.main_designation_type.NO_HIDE] = 'mass_designation', + -- df.main_designation_type.TOGGLE_ENGRAVING -- not used? + [df.main_designation_type.DIG_FROM_MARKER] = 'dig', + [df.main_designation_type.DIG_TO_MARKER] = 'dig', + [df.main_designation_type.CHOP_FROM_MARKER] = 'chop', + [df.main_designation_type.CHOP_TO_MARKER] = 'chop', + [df.main_designation_type.GATHER_FROM_MARKER] = 'gather', + [df.main_designation_type.GATHER_TO_MARKER] = 'gather', + [df.main_designation_type.SMOOTH_FROM_MARKER] = 'smooth', + [df.main_designation_type.SMOOTH_TO_MARKER] = 'smooth', + [df.main_designation_type.DESIGNATE_TRAFFIC_HIGH] = 'traffic', + [df.main_designation_type.DESIGNATE_TRAFFIC_NORMAL] = 'traffic', + [df.main_designation_type.DESIGNATE_TRAFFIC_LOW] = 'traffic', + [df.main_designation_type.DESIGNATE_TRAFFIC_RESTRICTED] = 'traffic', + [df.main_designation_type.ERASE] = 'erase', +} +local tool_from_bottom = { + -- df.main_bottom_mode_type.NONE + -- df.main_bottom_mode_type.BUILDING + -- df.main_bottom_mode_type.BUILDING_PLACEMENT + -- df.main_bottom_mode_type.BUILDING_PICK_MATERIALS + -- df.main_bottom_mode_type.ZONE + -- df.main_bottom_mode_type.ZONE_PAINT + [df.main_bottom_mode_type.STOCKPILE] = 'stockpile', + [df.main_bottom_mode_type.STOCKPILE_PAINT] = 'stockpile_paint', + -- df.main_bottom_mode_type.BURROW + [df.main_bottom_mode_type.BURROW_PAINT] = 'burrow_paint' + -- df.main_bottom_mode_type.HAULING + -- df.main_bottom_mode_type.ARENA_UNIT + -- df.main_bottom_mode_type.ARENA_TREE + -- df.main_bottom_mode_type.ARENA_WATER_PAINT + -- df.main_bottom_mode_type.ARENA_MAGMA_PAINT + -- df.main_bottom_mode_type.ARENA_SNOW_PAINT + -- df.main_bottom_mode_type.ARENA_MUD_PAINT + -- df.main_bottom_mode_type.ARENA_REMOVE_PAINT +} +---@return CenterToolbarSecondaryToolbarNames? +local function active_secondary() + local designation = df.global.game.main_interface.main_designation_selected + if designation ~= df.main_designation_type.NONE then + return tool_from_designation[designation] + end + local bottom = df.global.game.main_interface.bottom_mode_selected + if bottom ~= df.main_bottom_mode_type.NONE then + return tool_from_bottom[bottom] + end +end + +DemoWindow = defclass(DemoWindow, Window) +DemoWindow.ATTRS{ + frame_title = 'DF "bottom toolbars" module demo', + frame = { w = 39, h = 5 }, + resizable = true, +} + +function DemoWindow:init() + self:addviews{ + Toggle{ + label = 'Demos visible when not focused?', + initial_option = visible_when_not_focused, + on_change = function(new, old) + visible_when_not_focused = new + end + } + } +end + +DemoScreen = defclass(DemoScreen, gui.ZScreen) +function DemoScreen:init() + self:addviews{ + DemoWindow{}, + left_toolbar_demo, + center_toolbar_demo, + right_toolbar_demo, + secondary_toolbar_demo, + } +end + +local secondary, if_percentage +function DemoScreen:render(...) + if visible_when_not_focused then + local new_secondary = active_secondary() + local new_if_percentage = df.global.init.display.max_interface_percentage + if new_secondary ~= secondary or new_if_percentage ~= if_percentage then + secondary = new_secondary + self:updateLayout() + end + end + return DemoScreen.super.render(self, ...) +end + +function DemoScreen:postComputeFrame(frame_body) + update_demonstrations(active_secondary()) +end + +screen = DemoScreen{}:show() diff --git a/internal/notify/notifications.lua b/internal/notify/notifications.lua index 8af7c2c187..653d3887d5 100644 --- a/internal/notify/notifications.lua +++ b/internal/notify/notifications.lua @@ -366,6 +366,24 @@ NOTIFICATIONS_BY_IDX = { dlg.showMessage('Rescue stuck squads', message, COLOR_WHITE) end, }, + { + name='auto_train', + desc='Notifies when there are no squads set up for training', + default=true, + dwarf_fn=function() + local at = reqscript('autotraining') + if (at.isEnabled() and at.checkSquads() == nil) then + return {{text="autotraining: no squads selected",pen=COLOR_LIGHTRED}} + end + end, + on_click=function() + local message = + "You have no squads selected for training.\n".. + "You should have a squad set up to be constantly training with about 8 units needed for training.\n".. + "Then you can select that squad for training in the config.\n\nWould you like to open the config? Alternatively, simply close this popup to go create a squad." + dlg.showYesNoPrompt('Training Squads not configured', message, COLOR_WHITE, function () dfhack.run_command('gui/autotraining') end) + end, + }, { name='traders_ready', desc='Notifies when traders are ready to trade at the depot.',