local _ENV = mkmodule('plugins.stockpiles') local argparse = require('argparse') local dialogs = require('gui.dialogs') local gui = require('gui') local logistics = require('plugins.logistics') local overlay = require('plugins.overlay') local widgets = require('gui.widgets') local STOCKPILES_DIR = 'dfhack-config/stockpiles' local STOCKPILES_LIBRARY_DIR = 'hack/data/stockpiles' local BAD_FILENAME_REGEX = '[^%w._]' -------------------- -- plugin logic -------------------- local function get_sp_name(name, num) if #name > 0 then return name end return ('Stockpile %d'):format(num) end local STATUS_FMT = '%6s %s' local function print_status() local sps = df.global.world.buildings.other.STOCKPILE print(('Current stockpiles: %d'):format(#sps)) if #sps > 0 then print() print(STATUS_FMT:format('ID', 'Name')) print(STATUS_FMT:format('------', '----------')) end for _, sp in ipairs(sps) do print(STATUS_FMT:format(sp.id, get_sp_name(sp.name, sp.stockpile_number))) end end local function list_dir(path, prefix, filters, cb) local paths = dfhack.filesystem.listdir_recursive(path, 0, false) if not paths then dfhack.printerr(('Cannot find stockpile settings directory: "%s"'):format(path)) return end local normalized_filters = {} for _, filter in ipairs(filters or {}) do table.insert(normalized_filters, filter:lower()) end for _, v in ipairs(paths) do local normalized_path = prefix .. v.path:lower() if v.isdir or not normalized_path:endswith('.dfstock') then goto continue end normalized_path = normalized_path:sub(1, -9) if #normalized_filters > 0 then local matched = false for _, filter in ipairs(normalized_filters) do if normalized_path:find(filter, 1, true) then matched = true break end end if not matched then goto continue end end cb(('%s%s'):format(prefix, v.path:sub(1, -9))) ::continue:: end end local function list_settings_files(filters, cb) cb = cb or print list_dir(STOCKPILES_DIR, '', filters, cb) list_dir(STOCKPILES_LIBRARY_DIR, 'library/', filters, cb) end local function assert_safe_name(name) if not name or #name == 0 then qerror('name missing or empty') end if name:find(BAD_FILENAME_REGEX) then qerror('name can only contain numbers, letters, periods, and underscores') end end local function get_sp_id(opts) if opts.id then return opts.id end local sp = dfhack.gui.getSelectedStockpile(true) if not sp then qerror('Please select a stockpile or specify the stockpile ID') end return sp.id end local included_elements = {containers=1, general=2, categories=4, types=8} function export_settings(name, opts) opts = opts or {} assert_safe_name(name) local fname = STOCKPILES_DIR .. '/' .. name local includedElements = 0 for _, inc in ipairs(opts.includes or {}) do includedElements = includedElements | included_elements[inc] end if includedElements == 0 then for _, v in pairs(included_elements) do includedElements = includedElements | v end end if opts.route_id then stockpiles_route_export(fname, opts.route_id, opts.stop_id, includedElements) else stockpiles_export(fname, get_sp_id(opts), includedElements) end end local function normalize_name(name) local is_library = false if name:startswith('library/') then name = name:sub(9) is_library = true end assert_safe_name(name) if not is_library and dfhack.filesystem.exists(STOCKPILES_DIR .. '/' .. name .. '.dfstock') then return STOCKPILES_DIR .. '/' .. name end return STOCKPILES_LIBRARY_DIR .. '/' .. name end function import_settings(name, opts) local fname = normalize_name(name) opts = opts or {} local mode = opts.mode or 'set' local filters = table.concat(opts.filters or {}, ',') if opts.route_id then stockpiles_route_import(fname, opts.route_id, opts.stop_id, mode, filters) else stockpiles_import(fname, get_sp_id(opts), mode, filters) end end local function parse_include(arg) local includes = argparse.stringList(arg, 'include') for _, v in ipairs(includes) do if not included_elements[v] then qerror(('invalid included element: "%s"'):format(v)) end end return includes end local valid_modes = {set=true, enable=true, disable=true} local function parse_mode(arg) if not valid_modes[arg] then qerror(('invalid mode: "%s"'):format(arg)) end return arg end local function parse_stockpile_opt(id_or_name) if tonumber(id_or_name) then return argparse.nonnegativeInt(id_or_name, 'stockpile') end for _, sp in ipairs(df.global.world.buildings.other.STOCKPILE) do if sp.name == id_or_name then return sp.id end end qerror('could not find stockpile with name: ' .. tostring(id_or_name)) end local function get_route(id_or_name) local id = tonumber(id_or_name) if id then return df.hauling_route.find(id) end for _, route in ipairs(df.global.plotinfo.hauling.routes) do if route.name == id_or_name then return route end end qerror('could not find route with name: ' .. tostring(id_or_name)) end local function get_stop_id(route, id_or_name) local id = tonumber(id_or_name) if tonumber(id_or_name) then return argparse.nonnegativeInt(id_or_name, 'stop id') end if not id_or_name and #route.stops > 0 then return route.stops[0].id end for _, stop in ipairs(route.stops) do if stop.name == id_or_name then return stop.id end end qerror('could not find stop with name: ' .. tostring(id_or_name)) end local function parse_route_opt(arg) local ids = argparse.stringList(arg, 'route') or {} local route = get_route(ids[1]) return route.id, get_stop_id(route, ids[2]) end local function process_args(opts, args) if args[1] == 'help' then opts.help = true return end opts.includes = {} opts.mode = 'set' opts.filters = {} return argparse.processArgsGetopt(args, { {'h', 'help', handler=function() opts.help = true end}, {'m', 'mode', hasArg=true, handler=function(arg) opts.mode = parse_mode(arg) end}, {'f', 'filter', hasArg=true, handler=function(arg) opts.filters = argparse.stringList(arg) end}, {'i', 'include', hasArg=true, handler=function(arg) opts.includes = parse_include(arg) end}, {'s', 'stockpile', hasArg=true, handler=function(arg) opts.id = parse_stockpile_opt(arg) end}, {'r', 'route', hasArg=true, handler=function(arg) opts.route_id, opts.stop_id = parse_route_opt(arg) end}, }) end function parse_commandline(args) local opts = {} local positionals = process_args(opts, args) if opts.help or not positionals then return false end local command = table.remove(positionals, 1) if not command or command == 'status' then print_status() elseif command == 'list' then list_settings_files(positionals) elseif command == 'export' then export_settings(positionals[1], opts) elseif command == 'import' then import_settings(positionals[1], opts) else return false end return true end ------------------------- -- import/export dialogs ------------------------- local function get_import_choices() local filenames = {} list_settings_files({}, function(fname) table.insert(filenames, fname) end) return filenames end local function do_import() local sp = dfhack.gui.getSelectedStockpile(true) local dlg local function get_dlg() return dlg end dlg = dialogs.ListBox{ frame_title='Import/Delete Stockpile Settings', with_filter=true, choices=get_import_choices(), on_select=function(_, choice) import_settings(choice.text, {id=sp and sp.id}) end, dismiss_on_select2=false, on_select2=function(_, choice) if choice.text:startswith('library/') then return end local fname = ('%s/%s.dfstock'):format(STOCKPILES_DIR, choice.text) if not dfhack.filesystem.isfile(fname) then return end dialogs.showYesNoPrompt('Delete saved stockpile settings file?', 'Are you sure you want to delete "' .. fname .. '"?', nil, function() print('deleting ' .. fname) os.remove(fname) local list = get_dlg().subviews.list local filter = list:getFilter() list:setChoices(get_import_choices(), list:getSelected()) list:setFilter(filter) end) end, select2_hint='Delete file', }:show() end local function do_export() local sp = dfhack.gui.getSelectedStockpile(true) dialogs.InputBox{ frame_title='Export Stockpile Settings', text='Please enter a filename', on_input=function(text) export_settings(text, {id=sp and sp.id}) end, }:show() end -------------------- -- ConfigModal -------------------- ConfigModal = defclass(ConfigModal, gui.ZScreenModal) ConfigModal.ATTRS{ focus_path='stockpiles_config', on_close=DEFAULT_NIL, } function ConfigModal:init() local sp = dfhack.gui.getSelectedStockpile(true) local cur_setting = false if sp then local config = logistics.logistics_getStockpileConfigs(sp.stockpile_number)[1] cur_setting = config.melt_masterworks == 1 end self:addviews{ widgets.Window{ frame={w=35, h=10}, frame_title='Advanced logistics settings', subviews={ widgets.ToggleHotkeyLabel{ view_id='melt_masterworks', frame={l=0, t=0}, key='CUSTOM_M', label='Melt masterworks', initial_option=cur_setting, }, }, }, } end function ConfigModal:onDismiss() self.on_close{melt_masterworks=self.subviews.melt_masterworks:getOptionValue()} end -------------------- -- MinimizeButton -------------------- MinimizeButton = defclass(MinimizeButton, widgets.Widget) MinimizeButton.ATTRS{ label_unminimized='minimize', label_minimized='restore', label_pos='left', symbol_minimize=string.char(31), symbol_restore=string.char(30), get_minimized_fn=DEFAULT_NIL, on_click=DEFAULT_NIL, } function MinimizeButton:init() self.hovered = false ensure_key(self, 'frame').h = 1 local is_hovered = function() return self.hovered end local is_not_hovered = function() return not self.hovered end local get_action_symbol = function() return self.get_minimized_fn() and self.symbol_minimize or self.symbol_restore end local get_label = function() local label = self.get_minimized_fn() and self.label_minimized or self.label_unminimized return (' %s '):format(label) end local hovered_text = {'[', {text=get_action_symbol}, ']'} table.insert(hovered_text, self.label_pos == 'left' and 1 or #hovered_text + 1, {text=get_label, hpen=dfhack.pen.parse{fg=COLOR_BLACK, bg=COLOR_WHITE}}) self:addviews{ widgets.Label{ view_id='unhovered_label', frame={t=0, w=3, h=1}, text={'[', {text=get_action_symbol}, ']'}, text_pen=dfhack.pen.parse{fg=COLOR_BLACK, bg=COLOR_LIGHTRED}, text_hpen=dfhack.pen.parse{fg=COLOR_WHITE, bg=COLOR_RED}, on_click=function() self.on_click() self:updateLayout() end, visible=is_not_hovered, }, widgets.Label{ view_id='hovered_label', frame={t=0, h=1}, text=hovered_text, auto_width=true, text_pen=dfhack.pen.parse{fg=COLOR_BLACK, bg=COLOR_LIGHTRED}, text_hpen=dfhack.pen.parse{fg=COLOR_WHITE, bg=COLOR_RED}, on_click=function() self.on_click() self:updateLayout() end, visible=is_hovered, }, } if self.label_pos == 'left' then self.subviews.unhovered_label.frame.r = 0 self.subviews.hovered_label.frame.r = 0 else self.subviews.unhovered_label.frame.l = 0 self.subviews.hovered_label.frame.l = 0 end end function MinimizeButton:onRenderFrame() local prev_hovered = self.hovered if self.hovered then self.hovered = self.subviews.hovered_label:getMousePos() else self.hovered = self.subviews.unhovered_label:getMousePos() end if self.hovered ~= prev_hovered then self:updateLayout() df.global.gps.force_full_display_count = 1 end end -------------------- -- StockpilesOverlay -------------------- StockpilesOverlay = defclass(StockpilesOverlay, overlay.OverlayWidget) StockpilesOverlay.ATTRS{ desc='Shows a panel when a stockpile is selected for stockpile automation.', default_pos={x=5, y=43}, default_enabled=true, version=2, viewscreens='dwarfmode/Stockpile/Some/Default', frame={w=49, h=6}, } function StockpilesOverlay:init() self.minimized = false local function is_expanded() return not self.minimized end local main_panel = widgets.Panel{ view_id='main', frame_style=gui.MEDIUM_FRAME, frame_background=gui.CLEAR_PEN, visible=is_expanded, subviews={ widgets.HotkeyLabel{ frame={t=0, l=0}, label='import', auto_width=true, key='CUSTOM_CTRL_I', on_activate=do_import, }, widgets.HotkeyLabel{ frame={t=0, l=16}, label='export', auto_width=true, key='CUSTOM_CTRL_E', on_activate=do_export, }, widgets.Panel{ frame={t=1, l=0}, subviews={ widgets.Label{ frame={t=0, l=0, h=1}, auto_height=false, text={'Auto-designate stockpile items/animals for:'}, text_pen=COLOR_DARKGREY, }, widgets.ToggleHotkeyLabel{ view_id='melt', frame={t=1, l=0}, auto_width=true, key='CUSTOM_CTRL_M', option_gap=-1, options={{label='Melting', value=true, pen=COLOR_RED}, {label='Melting', value=false}}, initial_option=false, on_change=self:callback('toggleLogisticsFeature', 'melt'), }, widgets.ToggleHotkeyLabel{ view_id='trade', frame={t=1, l=16}, auto_width=true, key='CUSTOM_CTRL_T', option_gap=-1, options={{label='Trading', value=true, pen=COLOR_YELLOW}, {label='Trading', value=false}}, initial_option=false, on_change=self:callback('toggleLogisticsFeature', 'trade'), }, widgets.ToggleHotkeyLabel{ view_id='dump', frame={t=2, l=0}, auto_width=true, key='CUSTOM_CTRL_U', option_gap=-1, options={{label='Dumping', value=true, pen=COLOR_LIGHTMAGENTA}, {label='Dumping', value=false}}, initial_option=false, on_change=self:callback('toggleLogisticsFeature', 'dump'), }, widgets.ToggleHotkeyLabel{ view_id='train', frame={t=1, l=32}, auto_width=true, key='CUSTOM_CTRL_A', option_gap=-1, options={{label='Training', value=true, pen=COLOR_LIGHTBLUE}, {label='Training', value=false}}, initial_option=false, on_change=self:callback('toggleLogisticsFeature', 'train'), }, widgets.CycleHotkeyLabel{ view_id='forbid', frame={t=2, l=16, w=16}, key='CUSTOM_CTRL_F', option_gap=-1, options={{label='Forbid', value=0}, {label='Forbid', value=1, pen=COLOR_LIGHTRED}, {label='Claim', value=2, pen=COLOR_LIGHTBLUE}}, initial_option=0, on_change=self:callback('toggleLogisticsFeature', 'forbid'), }, }, }, }, } self:addviews{ main_panel, MinimizeButton{ frame={t=0, r=9}, get_minimized_fn=function() return self.minimized end, on_click=self:callback('toggleMinimized'), }, widgets.ConfigureButton{ frame={t=0, r=5}, on_click=function() ConfigModal{on_close=self:callback('on_custom_config')}:show() end, visible=is_expanded, }, widgets.HelpButton{ command='stockpiles', visible=is_expanded, }, } end function StockpilesOverlay:overlay_onupdate() -- periodically pick up changes made from other interfaces self.cur_stockpile = nil end function StockpilesOverlay:onRenderFrame() local sp = dfhack.gui.getSelectedStockpile(true) if sp and self.cur_stockpile ~= sp then local config = logistics.logistics_getStockpileConfigs(sp.stockpile_number)[1] self.subviews.melt:setOption(config.melt == 1) self.subviews.trade:setOption(config.trade == 1) self.subviews.dump:setOption(config.dump == 1) self.subviews.forbid:setOption(config.forbid) self.subviews.train:setOption(config.train == 1) self.cur_stockpile = sp end end function StockpilesOverlay:toggleLogisticsFeature(feature) self.cur_stockpile = nil local sp = dfhack.gui.getSelectedStockpile(true) if not sp then return end local config = logistics.logistics_getStockpileConfigs(sp.stockpile_number)[1] -- logical xor logistics.logistics_setStockpileConfig(config.stockpile_number, (feature == 'melt') ~= (config.melt == 1), (feature == 'trade') ~= (config.trade == 1), (feature == 'dump') ~= (config.dump == 1), (feature == 'train') ~= (config.train == 1), self.subviews.forbid:getOptionValue(), config.melt_masterworks == 1) end function StockpilesOverlay:on_custom_config(custom) local sp = dfhack.gui.getSelectedStockpile(true) if not sp then return end local config = logistics.logistics_getStockpileConfigs(sp.stockpile_number)[1] logistics.logistics_setStockpileConfig(config.stockpile_number, config.melt == 1, config.trade == 1, config.dump == 1, config.train == 1, config.forbid, custom.melt_masterworks) end function StockpilesOverlay:toggleMinimized() self.minimized = not self.minimized self.cur_stockpile = nil end function StockpilesOverlay:onInput(keys) if keys.CUSTOM_ALT_M then self:toggleMinimized() return true end return StockpilesOverlay.super.onInput(self, keys) end OVERLAY_WIDGETS = {overlay=StockpilesOverlay} return _ENV