diff --git a/docs/gui/manipulator.rst b/docs/gui/manipulator.rst new file mode 100644 index 000000000..353d894c7 --- /dev/null +++ b/docs/gui/manipulator.rst @@ -0,0 +1,18 @@ +gui/manipulator +=============== + +.. dfhack-tool:: + :summary: Multi-function unit management interface. + :tags: unavailable + +This spreadsheet-like UI allows you to see all your units, their skills, +properties, assignments, etc. at a glance. You can search, sort, and filter to +see exactly the information that you're looking for. Moreover, you can assign +units to burrows, squads, work details, and workshops. + +Usage +----- + +:: + + gui/manipulator diff --git a/gui/manipulator.lua b/gui/manipulator.lua new file mode 100644 index 000000000..2e2c901d0 --- /dev/null +++ b/gui/manipulator.lua @@ -0,0 +1,1354 @@ +--@module = true + +local gui = require("gui") +local json = require('json') +local overlay = require('plugins.overlay') +local presets = reqscript('internal/manipulator/presets') +local textures = require('gui.textures') +local utils = require('utils') +local widgets = require("gui.widgets") + +------------------------ +-- persistent state +-- + +local GLOBAL_KEY = 'manipulator' +local CONFIG_FILE = 'dfhack-config/manipulator.json' + +-- persistent player (global) state schema +local function get_default_config() + return { + tags={}, + presets={}, + } +end + +-- persistent per-fort state schema +local function get_default_state() + return { + favorites={}, + tagged={}, + } +end + +-- preset schema +local function get_default_preset() + return { + cols={}, + pinned={}, + } +end + +local function get_config() + local data = get_default_config() + local cfg = json.open(CONFIG_FILE) + utils.assign(data, cfg.data) + cfg.data = data + return cfg +end + +config = config or get_config() +state = state or get_default_state() +preset = preset or get_default_preset() + +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, state) +end + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + state = get_default_state() + return + end + if sc ~= SC_MAP_LOADED or not dfhack.world.isFortressMode() then + return + end + state = get_default_state() + utils.assign(state, dfhack.persistent.getSiteData(GLOBAL_KEY, state)) +end + +------------------------ +-- ColumnMenu +-- + +ColumnMenu = defclass(ColumnMenu, widgets.Panel) +ColumnMenu.ATTRS{ + frame_style=gui.FRAME_INTERIOR, + frame_background=gui.CLEAR_PEN, + visible=false, + col=DEFAULT_NIL, +} + +function ColumnMenu:init() + local choices = {} + + table.insert(choices, { + text='Sort', + fn=self.col:callback('sort', true), + }) + table.insert(choices, { + text='Hide column', + fn=self.col:callback('hide_column'), + }) + table.insert(choices, { + text='Hide group', + fn=self.col:callback('hide_group'), + }) + if self.col.zoom_fn then + table.insert(choices, { + text='Zoom to', + fn=self.col.zoom_fn, + }) + end + + self:addviews{ + widgets.List{ + choices=choices, + on_submit=function(_, choice) + choice.fn() + self:hide() + end, + }, + } +end + +function ColumnMenu:show() + self.prev_focus_owner = self.focus_group.cur + self.visible = true + self:setFocus(true) +end + +function ColumnMenu:hide() + self.visible = false + if self.prev_focus_owner then + self.prev_focus_owner:setFocus(true) + end +end + +function ColumnMenu:onInput(keys) + if ColumnMenu.super.onInput(self, keys) then + return true + end + if keys._MOUSE_R then + self:hide() + elseif keys._MOUSE_L and not self:getMouseFramePos() then + self:hide() + end + return true +end + +------------------------ +-- Column +-- + +local DEFAULT_DATA_WIDTH = 4 +local DEFAULT_COL_OVERSCAN = 14 + +Column = defclass(Column, widgets.Panel) +Column.ATTRS{ + label=DEFAULT_NIL, + group='', + label_inset=0, + data_width=DEFAULT_DATA_WIDTH, + hidden=DEFAULT_NIL, + shared=DEFAULT_NIL, + data_fn=DEFAULT_NIL, + count_fn=DEFAULT_NIL, + cmp_fn=DEFAULT_NIL, + choice_fn=DEFAULT_NIL, + zoom_fn=DEFAULT_NIL, +} + +local CH_DOT = string.char(15) +local CH_UP = string.char(30) +local CH_DN = string.char(31) + +function Column:init() + self.frame = utils.assign({t=0, b=0, l=0, w=DEFAULT_COL_OVERSCAN}, self.frame or {}) + + local function show_menu() + self.subviews.col_menu:show() + end + + self:addviews{ + widgets.TextButton{ + view_id='col_group', + frame={t=0, l=0, h=1, w=#self.group+2}, + label=self.group, + visible=#self.group > 0, + }, + widgets.Label{ + view_id='col_current', + frame={t=7, l=1+self.label_inset, w=4}, + auto_height=false, + }, + widgets.Label{ + view_id='col_total', + frame={t=8, l=1+self.label_inset, w=4}, + auto_height=false, + }, + widgets.List{ + view_id='col_list', + frame={t=10, l=0, w=self.data_width+2}, -- +2 for the invisible scrollbar + on_submit=self:callback('on_select'), + }, + widgets.Panel{ + frame={t=2, l=0, h=10}, + subviews={ + widgets.Divider{ + view_id='col_stem', + frame={l=self.label_inset, t=4, w=1, h=1}, + frame_style=gui.FRAME_INTERIOR, + frame_style_b=false, + }, + widgets.Panel{ + view_id='col_label', + frame={l=self.label_inset, t=4}, + subviews={ + widgets.HotkeyLabel{ + frame={l=0, t=0, w=1}, + label=CH_DN, + text_pen=COLOR_LIGHTGREEN, + on_activate=show_menu, + visible=function() + local sort_spec = self.shared.sort_stack[#self.shared.sort_stack] + return sort_spec.col == self and not sort_spec.rev + end, + }, + widgets.HotkeyLabel{ + frame={l=0, t=0, w=1}, + label=CH_UP, + text_pen=COLOR_LIGHTGREEN, + on_activate=show_menu, + visible=function() + local sort_spec = self.shared.sort_stack[#self.shared.sort_stack] + return sort_spec.col == self and sort_spec.rev + end, + }, + widgets.HotkeyLabel{ + frame={l=0, t=0, w=1}, + label=CH_DOT, + text_pen=COLOR_GRAY, + on_activate=show_menu, + visible=function() + local sort_spec = self.shared.sort_stack[#self.shared.sort_stack] + return sort_spec.col ~= self + end, + }, + widgets.HotkeyLabel{ + frame={l=1, t=0}, + label=self.label, + on_activate=self:callback('on_click'), + }, + ColumnMenu{ + view_id='col_menu', + frame={l=0, t=1, h=self.zoom_fn and 6 or 5}, + col=self, + }, + }, + }, + }, + }, + } + + self.subviews.col_list.scrollbar.visible = false + self.col_data = {} + self.dirty = true +end + +-- extended by subclasses +function Column:on_select(idx, choice) + -- conveniently, this will be nil for the namelist column itself, + -- avoiding an infinite loop + local namelist = self.parent_view.parent_view.namelist + if namelist then + self.shared.set_cursor_col_fn(self) + namelist:setSelected(idx) + end +end + +function Column:hide_column() + self.hidden = true + self.shared.refresh_headers = true +end + +function Column:unhide_column() + self.hidden = false + self.shared.refresh_headers = true +end + +function Column:hide_group() + for _,col in ipairs(self.parent_view.subviews) do + if col.group == self.group then + col.hidden = true + end + end + self.shared.refresh_headers = true +end + +function Column:on_click() + local modifiers = dfhack.internal.getModifiers() + if modifiers.shift then + self:hide_group() + elseif modifiers.ctrl then + self:hide_column() + else + self:sort(true) + end +end + +function Column:sort(make_primary) + local sort_stack = self.shared.sort_stack + if make_primary then + -- we are newly sorting by this column: reverse sort if we're already on top of the + -- stack; otherwise put us on top of the stack + local top = sort_stack[#sort_stack] + if top.col == self then + top.rev = not top.rev + else + for idx,sort_spec in ipairs(sort_stack) do + if sort_spec.col == self then + table.remove(sort_stack, idx) + break + end + end + table.insert(sort_stack, {col=self, rev=false}) + end + end + for _,sort_spec in ipairs(sort_stack) do + local col = sort_spec.col + if col.dirty then + col:refresh() + end + end + local compare = function(a, b) + for idx=#sort_stack,1,-1 do + local sort_spec = sort_stack[idx] + local col = sort_spec.col + local first, second + if sort_spec.rev then + first, second = col.col_data[b], col.col_data[a] + else + first, second = col.col_data[a], col.col_data[b] + end + if first == second then goto continue end + if not first then return 1 end + if not second then return -1 end + local ret = (col.cmp_fn or utils.compare)(first, second) + if ret ~= 0 then return ret end + ::continue:: + end + return 0 + end + local order = utils.tabulate(function(i) return i end, 1, #self.shared.filtered_unit_ids) + local spec = {compare=compare} + self.shared.sort_order = utils.make_sort_order(order, {spec}) +end + +function Column:get_units() + if self.shared.cache.units then return self.shared.cache.units end + local units = {} + for _, unit_id in ipairs(self.shared.unit_ids) do + local unit = df.unit.find(unit_id) + if unit then + table.insert(units, unit) + else + self.shared.fault = true + end + end + self.shared.cache.units = units + return units +end + +function Column:get_sorted_unit_id(idx) + return self.shared.filtered_unit_ids[self.shared.sort_order[idx]] +end + +function Column:get_sorted_data(idx) + return self.col_data[self.shared.sort_order[idx]] +end + +function Column:refresh() + local col_data, choices = {}, {} + local current, total = 0, 0 + local next_id_idx = 1 + for _, unit in ipairs(self:get_units()) do + local data = self.data_fn(unit) + local val = self.count_fn(data) + if unit.id == self.shared.filtered_unit_ids[next_id_idx] then + local idx = next_id_idx + table.insert(col_data, data) + table.insert(choices, self.choice_fn(function() return self:get_sorted_data(idx) end)) + current = current + val + next_id_idx = next_id_idx + 1 + end + total = total + val + end + + self.col_data = col_data + self.subviews.col_current:setText(tostring(current)) + self.subviews.col_total:setText(tostring(total)) + self.subviews.col_list:setChoices(choices) + + self.dirty = false +end + +function Column:render(dc) + if self.dirty then + self:refresh() + end + Column.super.render(self, dc) +end + +function Column:set_stem_height(h) + self.subviews.col_label.frame.t = 4 - h + self.subviews.col_stem.frame.t = 4 - h + self.subviews.col_stem.frame.h = h + 1 +end + +------------------------ +-- DataColumn +-- + +local function data_cmp(a, b) + if type(a) == 'number' then return -utils.compare(a, b) end + return utils.compare(a, b) +end + +local function data_count(data) + if not data then return 0 end + if type(data) == 'number' then return data > 0 and 1 or 0 end + return 1 +end + +local function data_choice(get_ordered_data_fn) + return { + text={ + { + text=function() + local ordered_data = get_ordered_data_fn() + return (not ordered_data or ordered_data == 0) and '-' or tostring(ordered_data) + end, + }, + }, + } +end + +DataColumn = defclass(DataColumn, Column) +DataColumn.ATTRS{ + cmp_fn=data_cmp, + count_fn=data_count, + choice_fn=data_choice, +} + +------------------------ +-- ToggleColumn +-- + +local ENABLED_PEN_LEFT = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 1), ch=string.byte('[')} +local ENABLED_PEN_CENTER = dfhack.pen.parse{fg=COLOR_LIGHTGREEN, + tile=curry(textures.tp_control_panel, 2) or nil, ch=251} -- check +local ENABLED_PEN_RIGHT = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 3) or nil, ch=string.byte(']')} +local DISABLED_PEN_LEFT = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 4) or nil, ch=string.byte('[')} +local DISABLED_PEN_CENTER = dfhack.pen.parse{fg=COLOR_RED, + tile=curry(textures.tp_control_panel, 5) or nil, ch=string.byte('x')} +local DISABLED_PEN_RIGHT = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 6) or nil, ch=string.byte(']')} + +local function toggle_count(data) + return data and 1 or 0 +end + +local function toggle_choice(get_ordered_data_fn) + local function get_enabled_button_token(enabled_tile, disabled_tile) + return { + tile=function() return get_ordered_data_fn() and enabled_tile or disabled_tile end, + } + end + return { + text={ + get_enabled_button_token(ENABLED_PEN_LEFT, DISABLED_PEN_LEFT), + get_enabled_button_token(ENABLED_PEN_CENTER, DISABLED_PEN_CENTER), + get_enabled_button_token(ENABLED_PEN_RIGHT, DISABLED_PEN_RIGHT), + ' ', + }, + } +end + +local function toggle_sorted_vec_data(vec, unit) + return utils.binsearch(vec, unit.id) and true or false +end + +local function toggle_sorted_vec(vec, unit_id, prev_val) + if prev_val then + utils.erase_sorted(vec, unit_id) + else + utils.insert_sorted(vec, unit_id) + end +end + +ToggleColumn = defclass(ToggleColumn, Column) +ToggleColumn.ATTRS{ + count_fn=toggle_count, + choice_fn=toggle_choice, + toggle_fn=DEFAULT_NIL, +} + +function ToggleColumn:on_select(idx, choice) + ToggleColumn.super.on_select(self, idx, choice) + if not self.toggle_fn then return end + local unit_id = self:get_sorted_unit_id(idx) + local prev_val = self:get_sorted_data(idx) + self.toggle_fn(unit_id, prev_val) + self.dirty = true +end + +------------------------ +-- Cols +-- + +Cols = defclass(Cols, widgets.Panel) + +function Cols:renderSubviews(dc) + -- allow labels of columns to the left to overwrite the stems of columns on the right + for idx=#self.subviews,1,-1 do + local child = self.subviews[idx] + if utils.getval(child.visible) then + child:render(dc) + end + end + -- but group labels and popup menus on the right should overwrite long group names on the left + for _,child in ipairs(self.subviews) do + if utils.getval(child.visible) then + if utils.getval(child.subviews.col_group.visible) then + child.subviews.col_group:render(dc) + end + if utils.getval(child.subviews.col_menu.visible) then + child:render(dc) + end + end + end +end + +------------------------ +-- Spreadsheet +-- + +Spreadsheet = defclass(Spreadsheet, widgets.Panel) +Spreadsheet.ATTRS{ + get_units_fn=DEFAULT_NIL, +} + +local function get_workshop_label(workshop, type_enum, bld_defs) + if #workshop.name > 0 then + return workshop.name + end + local type_name = type_enum[workshop.type] + if type_name == 'Custom' then + local bld_def = bld_defs[workshop.custom_type] + if bld_def then return bld_def.code end + end + return type_name +end + +function Spreadsheet:init() + self.left_col = 1 + self.prev_filter = '' + + self.shared = { + unit_ids={}, + filtered_unit_ids={}, + sort_stack={}, + sort_order={}, -- list of indices into filtered_unit_ids (or cache.filtered_units) + cache={}, -- cached pointers; reset at end of frame + cursor_col=nil, + set_cursor_col_fn=self:callback('set_cursor_col'), + refresh_units=true, + refresh_headers=true, + } + + local cols = Cols{} + self.cols = cols + + cols:addviews{ + ToggleColumn{ + view_id='favorites', + group='tags', + label='Favorites', + shared=self.shared, + data_fn=curry(toggle_sorted_vec_data, state.favorites), + toggle_fn=function(unit_id, prev_val) + toggle_sorted_vec(state.favorites, unit_id, prev_val) + persist_state() + end, + }, + DataColumn{ + group='summary', + label='Stress', + shared=self.shared, + data_fn=function(unit) return unit.status.current_soul.personality.stress end, + choice_fn=function(get_ordered_data_fn) + return { + text={ + { + text=function() + local ordered_data = get_ordered_data_fn() + if ordered_data > 99999 then + return '>99k' + elseif ordered_data > 9999 then + return ('%3dk'):format(ordered_data // 1000) + elseif ordered_data < -99999 then + return ' -' .. string.char(236) -- -∞ + elseif ordered_data < -999 then + return ('%3dk'):format(-(-ordered_data // 1000)) + end + return ('%4d'):format(ordered_data) + end, + pen=function() + local ordered_data = get_ordered_data_fn() + local level = dfhack.units.getStressCategoryRaw(ordered_data) + local is_graphics = dfhack.screen.inGraphicsMode() + -- match colors of stress faces depending on mode + if level == 0 then return COLOR_RED end + if level == 1 then return COLOR_LIGHTRED end + if level == 2 then return is_graphics and COLOR_BROWN or COLOR_YELLOW end + if level == 3 then return is_graphics and COLOR_YELLOW or COLOR_WHITE end + if level == 4 then return is_graphics and COLOR_CYAN or COLOR_GREEN end + if level == 5 then return is_graphics and COLOR_GREEN or COLOR_LIGHTGREEN end + return is_graphics and COLOR_LIGHTGREEN or COLOR_LIGHTCYAN + end, + }, + }, + } + end, + } + } + + for i in ipairs(df.job_skill) do + local caption = df.job_skill.attrs[i].caption + if caption then + cols:addviews{ + DataColumn{ + group='skills', + label=caption, + shared=self.shared, + data_fn=function(unit) + return (utils.binsearch(unit.status.current_soul.skills, i, 'id') or {rating=0}).rating + end, + } + } + end + end + + local work_details = df.global.plotinfo.labor_info.work_details + for _, wd in ipairs(work_details) do + cols:addviews{ + ToggleColumn{ + group='work details', + label=wd.name, + shared=self.shared, + data_fn=curry(toggle_sorted_vec_data, wd.assigned_units), + toggle_fn=function(unit_id, prev_val) + toggle_sorted_vec(wd.assigned_units, unit_id, prev_val) + -- TODO: poke DF to actually apply the work details to units + end, + } + } + end + + local function add_workshops(vec, type_enum, type_defs) + for _, bld in ipairs(vec) do + cols:addviews{ + ToggleColumn{ + group='workshops', + label=get_workshop_label(bld, type_enum, type_defs), + shared=self.shared, + data_fn=curry(toggle_sorted_vec_data, bld.profile.permitted_workers), + toggle_fn=function(unit_id, prev_val) + if not prev_val then + -- there can be only one + bld.profile.permitted_workers:resize(0) + end + toggle_sorted_vec(bld.profile.permitted_workers, unit_id, prev_val) + end, + zoom_fn=function() + dfhack.gui.revealInDwarfmodeMap( + xyz2pos(bld.centerx, bld.centery, bld.z), true, true) + end, + } + } + end + end + add_workshops(df.global.world.buildings.other.FURNACE_ANY, df.furnace_type, df.global.world.raws.buildings.furnaces) + add_workshops(df.global.world.buildings.other.WORKSHOP_ANY, df.workshop_type, df.global.world.raws.buildings.workshops) + + self:addviews{ + widgets.TextButton{ + view_id='left_group', + frame={t=1, l=0, h=1}, + key='CUSTOM_CTRL_Y', + visible=false, + }, + widgets.TextButton{ + view_id='right_group', + frame={t=1, r=0, h=1}, + key='CUSTOM_CTRL_T', + visible=false, + }, + widgets.Label{ + frame={t=7, l=0}, + text='Shown:', + }, + widgets.Label{ + frame={t=8, l=0}, + text='Total:', + }, + DataColumn{ + view_id='name', + frame={w=30}, + label='Name', + label_inset=8, + data_fn=dfhack.units.getReadableName, + data_width=30, + shared=self.shared, + }, + cols, + } + + -- teach each column about its relative position so we can track cursor movement + for idx, col in ipairs(cols) do + col.idx = idx + end + + -- set up initial sort: primary favorites, secondary name + self.shared.sort_stack[1] = {col=self.subviews.name, rev=false} + self.shared.sort_stack[2] = {col=self.subviews.favorites, rev=false} + + -- set initial keyboard cursor position + self:set_cursor_col(self.subviews.favorites) + + self.namelist = self.subviews.name.subviews.col_list + self:addviews{ + widgets.Scrollbar{ + view_id='scrollbar', + frame={t=7, r=0}, + on_scroll=self.namelist:callback('on_scrollbar'), + } + } + self.namelist.scrollbar = self.subviews.scrollbar + self.namelist:setFocus(true) +end + +local CURSOR_PEN = dfhack.pen.parse{fg=COLOR_GREY, bg=COLOR_CYAN} + +function Spreadsheet:set_cursor_col(col) + if self.shared.cursor_col then + self.shared.cursor_col.subviews.col_list.cursor_pen = COLOR_LIGHTCYAN + end + self.shared.cursor_col = col + col.subviews.col_list.cursor_pen = CURSOR_PEN +end + +function Spreadsheet:zoom_to_unit() + local idx = self.namelist:getSelected() + if not idx then return end + local unit = df.unit.find(self.subviews.name:get_sorted_unit_id(idx)) + if not unit then return end + dfhack.gui.revealInDwarfmodeMap( + xyz2pos(dfhack.units.getPosition(unit)), true, true) +end + +function Spreadsheet:zoom_to_col_source() + if not self.shared.cursor_col or not self.shared.cursor_col.zoom_fn then return end + self.shared.cursor_col.zoom_fn() +end + +function Spreadsheet:sort_by_current_col() + if not self.shared.cursor_col then return end + self.shared.cursor_col:sort(true) +end + +function Spreadsheet:hide_current_col() + if not self.shared.cursor_col then return end + self.shared.cursor_col:hide_column() +end + +function Spreadsheet:hide_current_col_group() + if not self.shared.cursor_col then return end + self.shared.cursor_col:hide_group() +end + +-- utf8-ize and, if needed, quote and escape +local function make_csv_cell(fmt, ...) + local str = fmt:format(...) + str = dfhack.df2utf(str) + if str:find('[,"]') then + str = str:gsub('"', '""') + str = ('"%s"'):format(str) + end + return str +end + +-- exports visible data, in the current sort, to a .csv file +function Spreadsheet:export() + local file = io.open('manipulator.csv', 'a+') + if not file then + dfhack.printerr('could not open export file: manipulator.csv') + return + end + file:write(make_csv_cell('%s,', self.subviews.name.label)) + for _, col in ipairs(self.cols.subviews) do + if col.hidden then goto continue end + file:write(make_csv_cell('%s/%s,', col.group, col.label)) + ::continue:: + end + file:write(NEWLINE) + for row=1,#self.shared.filtered_unit_ids do + file:write(make_csv_cell('%s', self.subviews.name:get_sorted_data(row))) + file:write(',') + for _, col in ipairs(self.cols.subviews) do + if col.hidden then goto continue end + file:write(make_csv_cell('%s,', col:get_sorted_data(row) or '')) + ::continue:: + end + file:write(NEWLINE) + end + file:close() +end + +function Spreadsheet:jump_to_group(group) + for i, col in ipairs(self.cols.subviews) do + if not col.hidden and col.group == group then + self:jump_to_col(i) + break + end + end +end + +function Spreadsheet:jump_to_col(idx) + idx = math.min(idx, #self.cols.subviews) + idx = math.max(idx, 1) + self.left_col = idx + if self.cols.subviews[idx].hidden then + local found = false + for shifted_idx=self.left_col-1,1,-1 do + if not self.cols.subviews[shifted_idx].hidden then + self.left_col = shifted_idx + found = true + break + end + end + if not found then + for shifted_idx=self.left_col+1,#self.cols.subviews do + if not self.cols.subviews[shifted_idx].hidden then + self.left_col = shifted_idx + break + end + end + end + end + self:updateLayout() +end + +function Spreadsheet:update_headers() + local ord = 1 + for _, col in ipairs(self.cols.subviews) do + if not col.hidden then + col:set_stem_height((5-ord)%5) + ord = ord + 1 + end + end + self.shared.refresh_headers = false +end + +-- TODO: support column addressing for searching/filtering (e.g. "skills/Armoring:>10") +function Spreadsheet:refresh(filter, full_refresh) + local shared = self.shared + shared.fault = false + self.subviews.name.dirty = true + for _, col in ipairs(self.cols.subviews) do + col.dirty = true + end + local incremental = not full_refresh and self.prev_filter and filter:startswith(self.prev_filter) + if not incremental then + local units = self.get_units_fn() + shared.cache.units = units + shared.unit_ids = utils.tabulate(function(idx) return units[idx].id end, 1, #units) + end + shared.filtered_unit_ids = copyall(shared.unit_ids) + if #filter > 0 then + local col = self.subviews.name + col:refresh() + for idx=#col.col_data,1,-1 do + local data = col.col_data[idx] + if (not utils.search_text(data, filter)) then + table.remove(shared.filtered_unit_ids, idx) + end + end + if #col.col_data ~= #shared.filtered_unit_ids then + col.dirty = true + end + end + shared.sort_stack[#shared.sort_stack].col:sort() + self.shared.refresh_units = false +end + +function Spreadsheet:update_col_layout(idx, col, width, group, max_width) + col.visible = not col.hidden and idx >= self.left_col and width + col.frame.w <= max_width + col.frame.l = width + if not col.visible then + return width, group + end + local col_group = col.subviews.col_group + col_group.label.on_activate=self:callback('jump_to_group', col.group) + col_group.visible = group ~= col.group + return width + col.data_width + 1, col.group +end + +function Spreadsheet:preUpdateLayout(parent_rect) + local left_group, right_group = self.subviews.left_group, self.subviews.right_group + left_group.visible, right_group.visible = false, false + + local width, group, cur_col_group = self.subviews.name.data_width + 1, '', '' + local prev_col_group, next_col_group + for idx, col in ipairs(self.cols.subviews) do + local prev_group = group + width, group = self:update_col_layout(idx, col, width, group, parent_rect.width) + if col.hidden then goto continue end + if not next_col_group and group ~= '' and not col.visible and col.group ~= cur_col_group then + next_col_group = col.group + local str = next_col_group .. string.char(26) -- right arrow + right_group.frame.w = #str + 10 + right_group.label.on_activate=self:callback('jump_to_group', next_col_group) + right_group.visible = true + right_group:setLabel(str) + end + if cur_col_group ~= col.group then + prev_col_group = cur_col_group + end + cur_col_group = col.group + if prev_group == '' and group ~= '' and prev_col_group and prev_col_group ~= '' then + local str = string.char(27) .. prev_col_group -- left arrow + left_group.frame.w = #str + 10 + left_group.label.on_activate=self:callback('jump_to_group', prev_col_group) + left_group.visible = true + left_group:setLabel(str) + end + ::continue:: + end + self.shared.layout_changed = false +end + +function Spreadsheet:render(dc) + if self.shared.refresh_headers or self.shared.refresh_units then + if self.shared.refresh_units then + self:refresh(self.prev_filter, true) + end + if self.shared.refresh_headers then + self:update_headers() + end + if self.cols.subviews[self.left_col].hidden then + self:jump_to_col(self.left_col) + else + self:updateLayout() + end + end + local page_top = self.namelist.page_top + local selected = self.namelist:getSelected() + for _, col in ipairs(self.cols.subviews) do + col.subviews.col_list.page_top = page_top + col.subviews.col_list:setSelected(selected) + end + Spreadsheet.super.render(self, dc) + self.shared.cache = {} +end + +function Spreadsheet:get_num_visible_cols() + local rect = self.frame_rect + if not rect then return 1 end + local other_width = self.subviews.name.data_width + (DEFAULT_COL_OVERSCAN - DEFAULT_DATA_WIDTH) + local width = rect.width - other_width + return width // (DEFAULT_DATA_WIDTH + 1) +end + +function Spreadsheet:onInput(keys) + if keys.KEYBOARD_CURSOR_LEFT then + self:jump_to_col(self.left_col-1) + elseif keys.KEYBOARD_CURSOR_LEFT_FAST then + local remaining = self:get_num_visible_cols() + local target_col = self.left_col + for idx=self.left_col-1,1,-1 do + if not self.cols.subviews[idx].hidden then + remaining = remaining - 1 + target_col = idx + if remaining == 0 then + break + end + end + end + self:jump_to_col(target_col) + elseif keys.KEYBOARD_CURSOR_RIGHT then + for idx=self.left_col+1,#self.cols.subviews do + if not self.cols.subviews[idx].hidden then + self:jump_to_col(idx) + break + end + end + elseif keys.KEYBOARD_CURSOR_RIGHT_FAST then + local remaining = self:get_num_visible_cols() + local target_col = self.left_col + for idx=self.left_col+1,#self.cols.subviews do + if not self.cols.subviews[idx].hidden then + remaining = remaining - 1 + target_col = idx + if remaining == 0 then + break + end + end + end + self:jump_to_col(target_col) + end + return Spreadsheet.super.onInput(self, keys) +end + +------------------------ +-- QuickMenu +-- + +QuickMenu = defclass(QuickMenu, widgets.Panel) +QuickMenu.ATTRS{ + frame_style=gui.FRAME_INTERIOR, + frame_background=gui.CLEAR_PEN, + visible=false, + multiselect=false, + label=DEFAULT_NIL, + choices_fn=DEFAULT_NIL, +} + +function QuickMenu:init() + self:addviews{ + widgets.Label{ + frame={t=0, l=0}, + text=self.label, + }, + widgets.FilteredList{ + view_id='list', + frame={t=2, l=0, b=self.multiselect and 3 or 0}, + on_submit=function(_, choice) + choice.fn() + self:hide() + end, + on_submit2=self.multiselect and function(_, choice) + choice.fn() + local list = self.subviews.list + local filter = list:getFilter() + list:setChoices(self.choices_fn(), list:getSelected()) + list:setFilter(filter) + end or nil, + }, + widgets.Label{ + frame={b=1, l=0}, + text='Shift click to select multiple.', + visible=self.multiselect, + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + key='CUSTOM_CTRL_A', + label='Select all', + visible=self.multiselect, + on_activate=function() + local list = self.subviews.list + for _,choice in ipairs(list:getVisibleChoices()) do + choice.fn() + end + local filter = list:getFilter() + list:setChoices(self.choices_fn(), list:getSelected()) + list:setFilter(filter) + end, + }, + } +end + +function QuickMenu:show() + self.prev_focus_owner = self.focus_group.cur + self.visible = true + local list = self.subviews.list + list.edit:setText('') + list.edit:setFocus(true) + list:setChoices(self.choices_fn()) +end + +function QuickMenu:hide() + self.visible = false + if self.prev_focus_owner then + self.prev_focus_owner:setFocus(true) + end +end + +function QuickMenu:onInput(keys) + if ColumnMenu.super.onInput(self, keys) then + return true + end + if keys._MOUSE_R then + self:hide() + elseif keys._MOUSE_L and not self:getMouseFramePos() then + self:hide() + end + return true +end + +------------------------ +-- Manipulator +-- + +local REFRESH_MS = 1000 + +Manipulator = defclass(Manipulator, widgets.Window) +Manipulator.ATTRS{ + frame_title='Unit Overview and Manipulator', + frame={w=110, h=40}, + frame_inset={t=1, l=1, r=1, b=0}, + resizable=true, + resize_min={w=70, h=30}, +} + +function Manipulator:init() + if dfhack.world.isFortressMode() then + self.get_units_fn = dfhack.units.getCitizens + elseif dfhack.world.isAdventureMode() then + self.get_units_fn = qerror('get party members') + else + self.get_units_fn = function() return utils.clone(df.global.world.units.active) end + end + + self.needs_refresh, self.prev_unit_count, self.prev_last_unit_id = false, 0, -1 + self:update_needs_refresh(true) + + self:addviews{ + widgets.EditField{ + view_id='search', + frame={l=0, t=0}, + key='FILTER', + label_text='Search: ', + on_change=function(text) self.subviews.sheet:refresh(text, false) end, + on_unfocus=function() self.subviews.sheet.namelist:setFocus(true) end, + }, + widgets.Divider{ + frame={l=0, r=0, t=2, h=1}, + frame_style=gui.FRAME_INTERIOR, + frame_style_l=false, + frame_style_r=false, + }, + Spreadsheet{ + view_id='sheet', + frame={l=0, t=3, r=0, b=7}, + get_units_fn=self.get_units_fn, + }, + widgets.Divider{ + frame={l=0, r=0, b=6, h=1}, + frame_style=gui.FRAME_INTERIOR, + frame_style_l=false, + frame_style_r=false, + }, + widgets.Panel{ + frame={l=0, r=0, b=0, h=5}, + subviews={ + widgets.WrappedLabel{ + frame={t=0, l=0}, + text_to_wrap='Use arrow keys or middle click drag to navigate cells. Left click or ENTER to toggle cell.', + }, + widgets.HotkeyLabel{ + frame={b=2, l=0}, + auto_width=true, + label='Sort/reverse sort', + key='CUSTOM_SHIFT_S', + on_activate=function() self.subviews.sheet:sort_by_current_col() end, + }, + widgets.HotkeyLabel{ + frame={b=2, l=22}, + auto_width=true, + label='Jump to column', + key='CUSTOM_CTRL_G', + on_activate=function() self.subviews.quick_jump_menu:show() end, + }, + widgets.HotkeyLabel{ + frame={b=2, l=46}, + auto_width=true, + label='Export to csv', + key='CUSTOM_SHIFT_E', + on_activate=function() self.subviews.sheet:export() end, + }, + widgets.HotkeyLabel{ + frame={b=1, l=0}, + auto_width=true, + label='Hide column', + key='CUSTOM_SHIFT_H', + on_activate=function() self.subviews.sheet:hide_current_col() end, + }, + widgets.HotkeyLabel{ + frame={b=1, l=22}, + auto_width=true, + label='Hide group', + key='CUSTOM_CTRL_H', + on_activate=function() self.subviews.sheet:hide_current_col_group() end, + }, + widgets.HotkeyLabel{ + frame={b=1, l=46}, + auto_width=true, + label='Unhide column', + key='CUSTOM_SHIFT_U', + on_activate=function() self.subviews.unhide_menu:show() end, + }, + widgets.HotkeyLabel{ + frame={b=0, l=0}, + auto_width=true, + label='Zoom to unit', + key='CUSTOM_SHIFT_Z', + on_activate=function() self.subviews.sheet:zoom_to_unit() end, + }, + widgets.HotkeyLabel{ + frame={b=0, l=22}, + auto_width=true, + label='Zoom to source', + key='CUSTOM_CTRL_Z', + on_activate=function() self.subviews.sheet:zoom_to_col_source() end, + }, + widgets.HotkeyLabel{ + frame={b=0, l=46}, + auto_width=true, + label=function() + return self.needs_refresh and 'Refresh units (new units have arrived)' or 'Refresh units' + end, + text_pen=function() + return self.needs_refresh and COLOR_LIGHTRED or COLOR_WHITE + end, + key='CUSTOM_SHIFT_R', + on_activate=function() + self.subviews.sheet:refresh(self.subviews.search.text, true) + end, + }, + }, + }, + QuickMenu{ + view_id='quick_jump_menu', + frame={b=0, w=35, h=25}, + label='Jump to column:', + choices_fn=function() + local choices = {} + for idx,col in ipairs(self.subviews.sheet.cols.subviews) do + if col.hidden then goto continue end + table.insert(choices, { + text=('%s/%s'):format(col.group, col.label), + fn=function() self.subviews.sheet:jump_to_col(idx) end, + }) + ::continue:: + end + return choices + end, + }, + QuickMenu{ + view_id='unhide_menu', + frame={b=0, w=35, h=25}, + multiselect=true, + label='Unhide column:', + choices_fn=function() + local choices = {} + for idx,col in ipairs(self.subviews.sheet.cols.subviews) do + if not col.hidden then goto continue end + table.insert(choices, { + text=('%s/%s'):format(col.group, col.label), + fn=function() self.subviews.sheet.cols.subviews[idx]:unhide_column() end, + }) + ::continue:: + end + return choices + end, + }, + } +end + +function Manipulator:update_needs_refresh(initialize) + self.next_refresh_ms = dfhack.getTickCount() + REFRESH_MS + + local units = self.get_units_fn() + local unit_count = #units + if unit_count ~= self.prev_unit_count then + self.needs_refresh = true + self.prev_unit_count = unit_count + end + if unit_count <= 0 then + self.prev_last_unit_id = -1 + else + local last_unit_id = units[#units] + if last_unit_id ~= self.prev_last_unit_id then + self.needs_refresh = true + self.prev_last_unit_id = last_unit_id + end + end + if initialize then + self.needs_refresh = false + end +end + +function Manipulator:render(dc) + if self.next_refresh_ms <= dfhack.getTickCount() then + self:update_needs_refresh() + end + Manipulator.super.render(self, dc) +end + +------------------------ +-- ManipulatorScreen +-- + +ManipulatorScreen = defclass(ManipulatorScreen, gui.ZScreen) +ManipulatorScreen.ATTRS{ + focus_path='manipulator', +} + +function ManipulatorScreen:init() + self:addviews{Manipulator{}} +end + +function ManipulatorScreen:onDismiss() + view = nil +end + +------------------------ +-- ManipulatorOverlay +-- + +ManipulatorOverlay = defclass(ManipulatorOverlay, overlay.OverlayWidget) +ManipulatorOverlay.ATTRS{ + desc='Adds a hotkey to the vanilla units screen to launch the DFHack units interface.', + default_pos={x=50, y=-6}, + default_enabled=true, + viewscreens='dwarfmode/Info/CREATURES/CITIZEN', + frame={w=35, h=1}, +} + +function ManipulatorOverlay:init() + self:addviews{ + widgets.TextButton{ + frame={t=0, l=0}, + label='DFHack citizen management', + key='CUSTOM_CTRL_N', + on_activate=function() dfhack.run_script('gui/manipulator') end, + }, + } +end + +OVERLAY_WIDGETS = { + launcher=ManipulatorOverlay, +} + +if dfhack_flags.module then return end + +if not dfhack.isMapLoaded() then + qerror("This script requires a map to be loaded") +end + +view = view and view:raise() or ManipulatorScreen{}:show() diff --git a/internal/manipulator/presets.lua b/internal/manipulator/presets.lua new file mode 100644 index 000000000..259d4a43e --- /dev/null +++ b/internal/manipulator/presets.lua @@ -0,0 +1,10 @@ +--@module=true + +PRESETS = { + { + name='', + groups={ + + }, + }, +}