From 86dcafb86c3e88d73be1381772a0bb013101fc31 Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Thu, 4 Jan 2024 04:25:00 -0800 Subject: [PATCH 1/7] refactor confirm to put the registry in a module --- confirm.lua | 344 ++----------------------------------- internal/confirm/specs.lua | 324 ++++++++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+), 331 deletions(-) create mode 100644 internal/confirm/specs.lua diff --git a/confirm.lua b/confirm.lua index 4205a173fa..55885868b5 100644 --- a/confirm.lua +++ b/confirm.lua @@ -1,341 +1,23 @@ --@ module = true local gui = require('gui') -local json = require('json') local overlay = require('plugins.overlay') +local specs = reqscript('internal/confirm/specs') local widgets = require("gui.widgets") -local CONFIG_FILE = 'dfhack-config/confirm.json' - -registry = registry or {} - ------------------------- --- Confirmation configs - -ConfirmConf = defclass(ConfirmConf) -ConfirmConf.ATTRS{ - id=DEFAULT_NIL, - title='DFHack confirm', - message='Are you sure?', - intercept_keys={}, - intercept_frame=DEFAULT_NIL, - context=DEFAULT_NIL, - predicate=DEFAULT_NIL, - pausable=false, -} - -function ConfirmConf:init() - if not self.id then - error('must set id to a unique string') - end - if type(self.intercept_keys) ~= 'table' then - self.intercept_keys = {self.intercept_keys} - end - for _, key in ipairs(self.intercept_keys) do - if key ~= '_MOUSE_L' and key ~= '_MOUSE_R' and not df.interface_key[key] then - error('Invalid key: ' .. tostring(key)) - end - end - if not self.context then - error('context must be set to a bounding focus string') - end - - -- auto-register - registry[self.id] = self -end - -local function trade_goods_selected() - local function goods_selected(vec) - for _, sel in ipairs(vec) do - if sel == 1 then return true end - end - end - - return goods_selected(df.global.game.main_interface.trade.goodflag[0]) or - goods_selected(df.global.game.main_interface.trade.goodflag[1]) -end - -local function trade_agreement_items_selected() - local diplomacy = df.global.game.main_interface.diplomacy - for _, tab in ipairs(diplomacy.environment.meeting.sell_requests.priority) do - for _, priority in ipairs(tab) do - if priority ~= 0 then - return true - end - end - end -end - -local function has_caravans() - for _, caravan in pairs(df.global.plotinfo.caravans) do - if caravan.time_remaining > 0 then - return true - end - end -end - -ConfirmConf{ - id='trade-cancel', - title='Cancel trade', - message='Are you sure you want leave this screen? Selected items will not be saved.', - intercept_keys={'LEAVESCREEN', '_MOUSE_R'}, - context='dwarfmode/Trade', - predicate=trade_goods_selected, -} - -ConfirmConf{ - id='diplomacy-request', - title='Cancel trade agreement', - message='Are you sure you want to leave this screen? The trade agreement selection will not be saved until you hit the "Done" button at the bottom of the screen.', - intercept_keys={'LEAVESCREEN', '_MOUSE_R'}, - context='dwarfmode/Diplomacy/Requests', - predicate=trade_agreement_items_selected, -} - -ConfirmConf{ - id='haul-delete-route', - title='Delete hauling route', - message='Are you sure you want to delete this route?', - intercept_keys='_MOUSE_L', - context='dwarfmode/Hauling', - predicate=function() return df.global.game.main_interface.current_hover == 180 end, - pausable=true, -} - -ConfirmConf{ - id='haul-delete-stop', - title='Delete hauling stop', - message='Are you sure you want to delete this stop?', - intercept_keys='_MOUSE_L', - context='dwarfmode/Hauling', - predicate=function() return df.global.game.main_interface.current_hover == 185 end, - pausable=true, -} - -ConfirmConf{ - id='depot-remove', - title='Remove depot', - message='Are you sure you want to remove this depot? Merchants are present and will lose profits.', - intercept_keys='_MOUSE_L', - context='dwarfmode/ViewSheets/BUILDING/TradeDepot', - predicate=function() - return df.global.game.main_interface.current_hover == 301 and has_caravans() - end, -} - -ConfirmConf{ - id='squad-disband', - title='Disband squad', - message='Are you sure you want to disband this squad?', - intercept_keys='_MOUSE_L', - context='dwarfmode/Squads', - predicate=function() return df.global.game.main_interface.current_hover == 343 end, - pausable=true, -} - -ConfirmConf{ - id='order-remove', - title='Remove manger order', - message='Are you sure you want to remove this manager order?', - intercept_keys='_MOUSE_L', - context='dwarfmode/Info/WORK_ORDERS/Default', - predicate=function() return df.global.game.main_interface.current_hover == 222 end, - pausable=true, -} - -ConfirmConf{ - id='zone-remove', - title='Remove zone', - message='Are you sure you want to remove this zone?', - intercept_keys='_MOUSE_L', - context='dwarfmode/Zone', - intercept_frame={l=40, t=8, w=4, h=3}, - pausable=true, -} - -ConfirmConf{ - id='burrow-remove', - title='Remove burrow', - message='Are you sure you want to remove this burrow?', - intercept_keys='_MOUSE_L', - context='dwarfmode/Burrow', - predicate=function() - return df.global.game.main_interface.current_hover == 171 or - df.global.game.main_interface.current_hover == 168 - end, - pausable=true, -} - -ConfirmConf{ - id='stockpile-remove', - title='Remove stockpile', - message='Are you sure you want to remove this stockpile?', - intercept_keys='_MOUSE_L', - context='dwarfmode/Stockpile', - predicate=function() return df.global.game.main_interface.current_hover == 118 end, - pausable=true, -} - --- these confirmations have more complex button detection requirements ---[[ -trade = defconf('trade') -function trade.intercept_key(key) - dfhack.gui.matchFocusString("dwarfmode/Trade") and key == MOUSE_LEFT and hovering over trade button? -end -trade.title = "Confirm trade" -function trade.get_message() - if trader_goods_selected() and broker_goods_selected() then - return "Are you sure you want to trade the selected goods?" - elseif trader_goods_selected() then - return "You are not giving any items. This is likely\n" .. - "to irritate the merchants.\n" .. - "Attempt to trade anyway?" - elseif broker_goods_selected() then - return "You are not receiving any items. You may want to\n" .. - "offer these items instead or choose items to receive.\n" .. - "Attempt to trade anyway?" - else - return "No items are selected. This is likely\n" .. - "to irritate the merchants.\n" .. - "Attempt to trade anyway?" - end -end - -trade_seize = defconf('trade-seize') -function trade_seize.intercept_key(key) - return screen.in_edit_count == 0 and - trader_goods_selected() and - key == keys.TRADE_SEIZE -end -trade_seize.title = "Confirm seize" -trade_seize.message = "Are you sure you want to seize these goods?" - -trade_offer = defconf('trade-offer') -function trade_offer.intercept_key(key) - return screen.in_edit_count == 0 and - broker_goods_selected() and - key == keys.TRADE_OFFER -end -trade_offer.title = "Confirm offer" -trade_offer.message = "Are you sure you want to offer these goods?\nYou will receive no payment." - -trade_select_all = defconf('trade-select-all') -function trade_select_all.intercept_key(key) - if screen.in_edit_count == 0 and key == keys.SEC_SELECT then - if screen.in_right_pane and broker_goods_selected() and not broker_goods_all_selected() then - return true - elseif not screen.in_right_pane and trader_goods_selected() and not trader_goods_all_selected() then - return true - end - end - return false -end -trade_select_all.title = "Confirm selection" -trade_select_all.message = "Selecting all goods will overwrite your current selection\n" .. - "and cannot be undone. Continue?" - -uniform_delete = defconf('uniform-delete') -function uniform_delete.intercept_key(key) - return key == keys.D_MILITARY_DELETE_UNIFORM and - screen.page == screen._type.T_page.Uniforms and - #screen.equip.uniforms > 0 and - not screen.equip.in_name_uniform -end -uniform_delete.title = "Delete uniform" -uniform_delete.message = "Are you sure you want to delete this uniform?" - -note_delete = defconf('note-delete') -function note_delete.intercept_key(key) - return key == keys.D_NOTE_DELETE and - ui.main.mode == df.ui_sidebar_mode.NotesPoints and - not ui.waypoints.in_edit_name_mode and - not ui.waypoints.in_edit_text_mode -end -note_delete.title = "Delete note" -note_delete.message = "Are you sure you want to delete this note?" - -route_delete = defconf('route-delete') -function route_delete.intercept_key(key) - return key == keys.D_NOTE_ROUTE_DELETE and - ui.main.mode == df.ui_sidebar_mode.NotesRoutes and - not ui.waypoints.in_edit_name_mode -end -route_delete.title = "Delete route" -route_delete.message = "Are you sure you want to delete this route?" - -convict = defconf('convict') -convict.title = "Confirm conviction" -function convict.intercept_key(key) - return key == keys.SELECT and - screen.cur_column == df.viewscreen_justicest.T_cur_column.ConvictChoices -end -function convict.get_message() - name = dfhack.TranslateName(dfhack.units.getVisibleName(screen.convict_choices[screen.cursor_right])) - if name == "" then - name = "this creature" - end - return "Are you sure you want to convict " .. name .. "?\n" .. - "This action is irreversible." -end -]]-- - --- locations cannot be retired currently ---[[ -location_retire = defconf('location-retire') -function location_retire.intercept_key(key) - return key == keys.LOCATION_RETIRE and - (screen.menu == df.viewscreen_locationsst.T_menu.Locations or - screen.menu == df.viewscreen_locationsst.T_menu.Occupations) and - screen.in_edit == df.viewscreen_locationsst.T_in_edit.None and - screen.locations[screen.location_idx] -end -location_retire.title = "Retire location" -location_retire.message = "Are you sure you want to retire this location?" -]]-- - --- End of confirmation definitions - ------------------------ -- API -local function get_config() - local f = json.open(CONFIG_FILE) - local updated = false - -- scrub any invalid data - for id, conf in pairs(f.data) do - if not registry[id] then - updated = true - f.data[id] = nil - end - end - -- add any missing confirmation ids - for id in pairs(registry) do - if not f.data[id] then - updated = true - f.data[id] = { - id=id, - enabled=true, - } - end - end - if updated then - f:write() - end - return f -end - -config = config or get_config() - function get_state() - return config.data + return specs.config.data end function set_enabled(id, enabled) - for _, conf in pairs(config.data) do + for _, conf in pairs(specs.config.data) do if conf.id == id then if conf.enabled ~= enabled then conf.enabled = enabled - config:write() + specs.config:write() end break end @@ -417,7 +99,7 @@ end local function get_contexts() local contexts, contexts_set = {}, {} - for id, conf in pairs(registry) do + for id, conf in pairs(specs.REGISTRY) do if not contexts_set[id] then contexts_set[id] = true table.insert(contexts, conf.context) @@ -438,7 +120,7 @@ ConfirmOverlay.ATTRS{ } function ConfirmOverlay:init() - for id, conf in pairs(registry) do + for id, conf in pairs(specs.REGISTRY) do if conf.intercept_frame then self:addviews{ widgets.Panel{ @@ -485,8 +167,8 @@ function ConfirmOverlay:onInput(keys) return false end local scr = dfhack.gui.getDFViewscreen(true) - for id, conf in pairs(registry) do - if config.data[id].enabled and self:matches_conf(conf, keys, scr) then + for id, conf in pairs(specs.REGISTRY) do + if specs.config.data[id].enabled and self:matches_conf(conf, keys, scr) then local mouse_pos = xy2pos(dfhack.screen.getMousePos()) local propagate_fn = function(pause) if pause then @@ -517,25 +199,25 @@ OVERLAY_WIDGETS = { local function do_list() print('Available confirmation prompts:') local max_len = 10 - for id in pairs(registry) do + for id in pairs(specs.REGISTRY) do max_len = math.max(max_len, #id) end - for id, conf in pairs(registry) do + for id, conf in pairs(specs.REGISTRY) do local fmt = '%' .. tostring(max_len) .. 's: (%s) %s' print((fmt):format(id, - config.data[id].enabled and 'enabled' or 'disabled', + specs.config.data[id].enabled and 'enabled' or 'disabled', conf.title)) end end local function do_enable_disable(args, enable) if args[1] == 'all' then - for id in pairs(registry) do + for id in pairs(specs.REGISTRY) do set_enabled(id, enable) end else for _, id in ipairs(args) do - if not registry[id] then + if not specs.REGISTRY[id] then qerror('confirmation prompt id not found: ' .. tostring(id)) end set_enabled(id, enable) diff --git a/internal/confirm/specs.lua b/internal/confirm/specs.lua new file mode 100644 index 0000000000..b5c55366d9 --- /dev/null +++ b/internal/confirm/specs.lua @@ -0,0 +1,324 @@ +--@module = true + +-- remember to reload the overlay when adding/changing specs that have +-- intercept_frames defined + +local json = require('json') + +local CONFIG_FILE = 'dfhack-config/confirm.json' + +REGISTRY = {} + +ConfirmSpec = defclass(ConfirmSpec) +ConfirmSpec.ATTRS{ + id=DEFAULT_NIL, + title='DFHack confirm', + message='Are you sure?', + intercept_keys={}, + intercept_frame=DEFAULT_NIL, + context=DEFAULT_NIL, + predicate=DEFAULT_NIL, + pausable=false, +} + +function ConfirmSpec:init() + if not self.id then + error('must set id to a unique string') + end + if type(self.intercept_keys) ~= 'table' then + self.intercept_keys = {self.intercept_keys} + end + for _, key in ipairs(self.intercept_keys) do + if key ~= '_MOUSE_L' and key ~= '_MOUSE_R' and not df.interface_key[key] then + error('Invalid key: ' .. tostring(key)) + end + end + if not self.context then + error('context must be set to a bounding focus string') + end + + -- protect against copy-paste errors when defining new specs + if REGISTRY[self.id] then + error('id already registered: ' .. tostring(self.id)) + end + + -- auto-register + REGISTRY[self.id] = self +end + +local function trade_goods_selected() + local function goods_selected(vec) + for _, sel in ipairs(vec) do + if sel == 1 then return true end + end + end + + return goods_selected(df.global.game.main_interface.trade.goodflag[0]) or + goods_selected(df.global.game.main_interface.trade.goodflag[1]) +end + +local function trade_agreement_items_selected() + local diplomacy = df.global.game.main_interface.diplomacy + for _, tab in ipairs(diplomacy.environment.meeting.sell_requests.priority) do + for _, priority in ipairs(tab) do + if priority ~= 0 then + return true + end + end + end +end + +local function has_caravans() + for _, caravan in pairs(df.global.plotinfo.caravans) do + if caravan.time_remaining > 0 then + return true + end + end +end + +ConfirmSpec{ + id='trade-cancel', + title='Cancel trade', + message='Are you sure you want leave this screen? Selected items will not be saved.', + intercept_keys={'LEAVESCREEN', '_MOUSE_R'}, + context='dwarfmode/Trade', + predicate=trade_goods_selected, +} + +ConfirmSpec{ + id='diplomacy-request', + title='Cancel trade agreement', + message='Are you sure you want to leave this screen? The trade agreement selection will not be saved until you hit the "Done" button at the bottom of the screen.', + intercept_keys={'LEAVESCREEN', '_MOUSE_R'}, + context='dwarfmode/Diplomacy/Requests', + predicate=trade_agreement_items_selected, +} + +ConfirmSpec{ + id='haul-delete-route', + title='Delete hauling route', + message='Are you sure you want to delete this route?', + intercept_keys='_MOUSE_L', + context='dwarfmode/Hauling', + predicate=function() return df.global.game.main_interface.current_hover == 180 end, + pausable=true, +} + +ConfirmSpec{ + id='haul-delete-stop', + title='Delete hauling stop', + message='Are you sure you want to delete this stop?', + intercept_keys='_MOUSE_L', + context='dwarfmode/Hauling', + predicate=function() return df.global.game.main_interface.current_hover == 185 end, + pausable=true, +} + +ConfirmSpec{ + id='depot-remove', + title='Remove depot', + message='Are you sure you want to remove this depot? Merchants are present and will lose profits.', + intercept_keys='_MOUSE_L', + context='dwarfmode/ViewSheets/BUILDING/TradeDepot', + predicate=function() + return df.global.game.main_interface.current_hover == 301 and has_caravans() + end, +} + +ConfirmSpec{ + id='squad-disband', + title='Disband squad', + message='Are you sure you want to disband this squad?', + intercept_keys='_MOUSE_L', + context='dwarfmode/Squads', + predicate=function() return df.global.game.main_interface.current_hover == 343 end, + pausable=true, +} + +ConfirmSpec{ + id='order-remove', + title='Remove manger order', + message='Are you sure you want to remove this manager order?', + intercept_keys='_MOUSE_L', + context='dwarfmode/Info/WORK_ORDERS/Default', + predicate=function() return df.global.game.main_interface.current_hover == 222 end, + pausable=true, +} + +ConfirmSpec{ + id='zone-remove', + title='Remove zone', + message='Are you sure you want to remove this zone?', + intercept_keys='_MOUSE_L', + context='dwarfmode/Zone', + intercept_frame={l=40, t=8, w=4, h=3}, + pausable=true, +} + +ConfirmSpec{ + id='burrow-remove', + title='Remove burrow', + message='Are you sure you want to remove this burrow?', + intercept_keys='_MOUSE_L', + context='dwarfmode/Burrow', + predicate=function() + return df.global.game.main_interface.current_hover == 171 or + df.global.game.main_interface.current_hover == 168 + end, + pausable=true, +} + +ConfirmSpec{ + id='stockpile-remove', + title='Remove stockpile', + message='Are you sure you want to remove this stockpile?', + intercept_keys='_MOUSE_L', + context='dwarfmode/Stockpile', + predicate=function() return df.global.game.main_interface.current_hover == 118 end, + pausable=true, +} + +-- these confirmations have more complex button detection requirements +--[[ +trade = defconf('trade') +function trade.intercept_key(key) + dfhack.gui.matchFocusString("dwarfmode/Trade") and key == MOUSE_LEFT and hovering over trade button? +end +trade.title = "Confirm trade" +function trade.get_message() + if trader_goods_selected() and broker_goods_selected() then + return "Are you sure you want to trade the selected goods?" + elseif trader_goods_selected() then + return "You are not giving any items. This is likely\n" .. + "to irritate the merchants.\n" .. + "Attempt to trade anyway?" + elseif broker_goods_selected() then + return "You are not receiving any items. You may want to\n" .. + "offer these items instead or choose items to receive.\n" .. + "Attempt to trade anyway?" + else + return "No items are selected. This is likely\n" .. + "to irritate the merchants.\n" .. + "Attempt to trade anyway?" + end +end + +trade_seize = defconf('trade-seize') +function trade_seize.intercept_key(key) + return screen.in_edit_count == 0 and + trader_goods_selected() and + key == keys.TRADE_SEIZE +end +trade_seize.title = "Confirm seize" +trade_seize.message = "Are you sure you want to seize these goods?" + +trade_offer = defconf('trade-offer') +function trade_offer.intercept_key(key) + return screen.in_edit_count == 0 and + broker_goods_selected() and + key == keys.TRADE_OFFER +end +trade_offer.title = "Confirm offer" +trade_offer.message = "Are you sure you want to offer these goods?\nYou will receive no payment." + +trade_select_all = defconf('trade-select-all') +function trade_select_all.intercept_key(key) + if screen.in_edit_count == 0 and key == keys.SEC_SELECT then + if screen.in_right_pane and broker_goods_selected() and not broker_goods_all_selected() then + return true + elseif not screen.in_right_pane and trader_goods_selected() and not trader_goods_all_selected() then + return true + end + end + return false +end +trade_select_all.title = "Confirm selection" +trade_select_all.message = "Selecting all goods will overwrite your current selection\n" .. + "and cannot be undone. Continue?" + +uniform_delete = defconf('uniform-delete') +function uniform_delete.intercept_key(key) + return key == keys.D_MILITARY_DELETE_UNIFORM and + screen.page == screen._type.T_page.Uniforms and + #screen.equip.uniforms > 0 and + not screen.equip.in_name_uniform +end +uniform_delete.title = "Delete uniform" +uniform_delete.message = "Are you sure you want to delete this uniform?" + +note_delete = defconf('note-delete') +function note_delete.intercept_key(key) + return key == keys.D_NOTE_DELETE and + ui.main.mode == df.ui_sidebar_mode.NotesPoints and + not ui.waypoints.in_edit_name_mode and + not ui.waypoints.in_edit_text_mode +end +note_delete.title = "Delete note" +note_delete.message = "Are you sure you want to delete this note?" + +route_delete = defconf('route-delete') +function route_delete.intercept_key(key) + return key == keys.D_NOTE_ROUTE_DELETE and + ui.main.mode == df.ui_sidebar_mode.NotesRoutes and + not ui.waypoints.in_edit_name_mode +end +route_delete.title = "Delete route" +route_delete.message = "Are you sure you want to delete this route?" + +convict = defconf('convict') +convict.title = "Confirm conviction" +function convict.intercept_key(key) + return key == keys.SELECT and + screen.cur_column == df.viewscreen_justicest.T_cur_column.ConvictChoices +end +function convict.get_message() + name = dfhack.TranslateName(dfhack.units.getVisibleName(screen.convict_choices[screen.cursor_right])) + if name == "" then + name = "this creature" + end + return "Are you sure you want to convict " .. name .. "?\n" .. + "This action is irreversible." +end +]]-- + +-- locations cannot be retired currently +--[[ +location_retire = defconf('location-retire') +function location_retire.intercept_key(key) + return key == keys.LOCATION_RETIRE and + (screen.menu == df.viewscreen_locationsst.T_menu.Locations or + screen.menu == df.viewscreen_locationsst.T_menu.Occupations) and + screen.in_edit == df.viewscreen_locationsst.T_in_edit.None and + screen.locations[screen.location_idx] +end +location_retire.title = "Retire location" +location_retire.message = "Are you sure you want to retire this location?" +]]-- + +local function get_config() + local f = json.open(CONFIG_FILE) + local updated = false + -- scrub any invalid data + for id in pairs(f.data) do + if not REGISTRY[id] then + updated = true + f.data[id] = nil + end + end + -- add any missing confirmation ids + for id in pairs(REGISTRY) do + if not f.data[id] then + updated = true + f.data[id] = { + id=id, + enabled=true, + } + end + end + if updated then + f:write() + end + return f +end + +config = get_config() From 196bcf2dcf7b50b664514a0efac559a71d748466 Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Thu, 4 Jan 2024 05:22:14 -0800 Subject: [PATCH 2/7] add frame debugging, sort list output, add mark all confirms --- confirm.lua | 17 ++++++---- internal/confirm/specs.lua | 66 +++++++++++++++++++++++--------------- 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/confirm.lua b/confirm.lua index 55885868b5..d35fde25cb 100644 --- a/confirm.lua +++ b/confirm.lua @@ -126,6 +126,7 @@ function ConfirmOverlay:init() widgets.Panel{ view_id=id, frame=conf.intercept_frame, + frame_style=conf.debug_frame and gui.FRAME_INTERIOR or nil, } } end @@ -155,7 +156,7 @@ function ConfirmOverlay:matches_conf(conf, keys, scr) end end if not matched_keys then return false end - if conf.intercept_frame and not self.subviews[conf.id]:getMousePos() then + if conf.intercept_frame and not self.subviews[conf.id]:getMouseFramePos() then return false end if not dfhack.gui.matchFocusString(conf.context, scr) then return false end @@ -198,14 +199,16 @@ OVERLAY_WIDGETS = { local function do_list() print('Available confirmation prompts:') - local max_len = 10 - for id in pairs(specs.REGISTRY) do + local confs, max_len = {}, 10 + for id, conf in pairs(specs.REGISTRY) do max_len = math.max(max_len, #id) + table.insert(confs, conf) end - for id, conf in pairs(specs.REGISTRY) do - local fmt = '%' .. tostring(max_len) .. 's: (%s) %s' - print((fmt):format(id, - specs.config.data[id].enabled and 'enabled' or 'disabled', + table.sort(confs, function(a,b) return a.id < b.id end) + for _, conf in ipairs(confs) do + local fmt = '%' .. tostring(max_len) .. 's: %s %s' + print((fmt):format(conf.id, + specs.config.data[conf.id].enabled and '(enabled) ' or '(disabled)', conf.title)) end end diff --git a/internal/confirm/specs.lua b/internal/confirm/specs.lua index b5c55366d9..88c7f6410a 100644 --- a/internal/confirm/specs.lua +++ b/internal/confirm/specs.lua @@ -1,5 +1,7 @@ --@module = true +-- if adding a new spec, just run `confirm` to load it and make it live +-- -- remember to reload the overlay when adding/changing specs that have -- intercept_frames defined @@ -7,6 +9,7 @@ local json = require('json') local CONFIG_FILE = 'dfhack-config/confirm.json' +-- populated by ConfirmSpec constructor below REGISTRY = {} ConfirmSpec = defclass(ConfirmSpec) @@ -16,6 +19,7 @@ ConfirmSpec.ATTRS{ message='Are you sure?', intercept_keys={}, intercept_frame=DEFAULT_NIL, + debug_frame=false, -- set to true when doing original positioning context=DEFAULT_NIL, predicate=DEFAULT_NIL, pausable=false, @@ -46,18 +50,22 @@ function ConfirmSpec:init() REGISTRY[self.id] = self end -local function trade_goods_selected() - local function goods_selected(vec) - for _, sel in ipairs(vec) do - if sel == 1 then return true end - end +local mi = df.global.game.main_interface + +local function trade_goods_any_selected(which) + for _, sel in ipairs(mi.trade.goodflag[which]) do + if sel == 1 then return true end end +end - return goods_selected(df.global.game.main_interface.trade.goodflag[0]) or - goods_selected(df.global.game.main_interface.trade.goodflag[1]) +local function trade_goods_all_selected(which) + for _, sel in ipairs(mi.trade.goodflag[which]) do + if sel ~= 1 then return false end + end + return true end -local function trade_agreement_items_selected() +local function trade_agreement_items_any_selected() local diplomacy = df.global.game.main_interface.diplomacy for _, tab in ipairs(diplomacy.environment.meeting.sell_requests.priority) do for _, priority in ipairs(tab) do @@ -82,7 +90,30 @@ ConfirmSpec{ message='Are you sure you want leave this screen? Selected items will not be saved.', intercept_keys={'LEAVESCREEN', '_MOUSE_R'}, context='dwarfmode/Trade', - predicate=trade_goods_selected, + predicate=function() return trade_goods_any_selected(0) or trade_goods_any_selected(1) end, +} + +ConfirmSpec{ + id='trade-mark-all-fort', + title='Mark all fortress goods', + message='Are you sure you want mark all fortress goods at the depot? Your current fortress goods selections will be lost.', + intercept_keys='_MOUSE_L', + intercept_frame={r=46, b=7, w=14, h=3}, + context='dwarfmode/Trade', + predicate=function() return trade_goods_any_selected(1) and not trade_goods_all_selected(1) end, + pausable=true, +} + +ConfirmSpec{ + id='trade-mark-all-merchant', + title='Mark all merchant goods', + message='Are you sure you want mark all merchant goods at the depot? Your current merchant goods selections will be lost.', + intercept_keys='_MOUSE_L', + intercept_frame={l=0, r=72, b=7, w=14, h=3}, + debug_frame=true, + context='dwarfmode/Trade', + predicate=function() return trade_goods_any_selected(0) and not trade_goods_all_selected(0) end, + pausable=true, } ConfirmSpec{ @@ -91,7 +122,7 @@ ConfirmSpec{ message='Are you sure you want to leave this screen? The trade agreement selection will not be saved until you hit the "Done" button at the bottom of the screen.', intercept_keys={'LEAVESCREEN', '_MOUSE_R'}, context='dwarfmode/Diplomacy/Requests', - predicate=trade_agreement_items_selected, + predicate=trade_agreement_items_any_selected, } ConfirmSpec{ @@ -221,21 +252,6 @@ end trade_offer.title = "Confirm offer" trade_offer.message = "Are you sure you want to offer these goods?\nYou will receive no payment." -trade_select_all = defconf('trade-select-all') -function trade_select_all.intercept_key(key) - if screen.in_edit_count == 0 and key == keys.SEC_SELECT then - if screen.in_right_pane and broker_goods_selected() and not broker_goods_all_selected() then - return true - elseif not screen.in_right_pane and trader_goods_selected() and not trader_goods_all_selected() then - return true - end - end - return false -end -trade_select_all.title = "Confirm selection" -trade_select_all.message = "Selecting all goods will overwrite your current selection\n" .. - "and cannot be undone. Continue?" - uniform_delete = defconf('uniform-delete') function uniform_delete.intercept_key(key) return key == keys.D_MILITARY_DELETE_UNIFORM and From 0d5b97e29ef4082de598dbb37c9552cf56ea9cfd Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Thu, 4 Jan 2024 05:25:57 -0800 Subject: [PATCH 3/7] add unmark all confirms --- internal/confirm/specs.lua | 56 +++++++++++++++----------------------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/internal/confirm/specs.lua b/internal/confirm/specs.lua index 88c7f6410a..e9b4528d17 100644 --- a/internal/confirm/specs.lua +++ b/internal/confirm/specs.lua @@ -104,13 +104,34 @@ ConfirmSpec{ pausable=true, } +ConfirmSpec{ + id='trade-unmark-all-fort', + title='Unmark all fortress goods', + message='Are you sure you want unmark all fortress goods at the depot? Your current fortress goods selections will be lost.', + intercept_keys='_MOUSE_L', + intercept_frame={r=30, b=7, w=14, h=3}, + context='dwarfmode/Trade', + predicate=function() return trade_goods_any_selected(1) and not trade_goods_all_selected(1) end, + pausable=true, +} + ConfirmSpec{ id='trade-mark-all-merchant', title='Mark all merchant goods', message='Are you sure you want mark all merchant goods at the depot? Your current merchant goods selections will be lost.', intercept_keys='_MOUSE_L', intercept_frame={l=0, r=72, b=7, w=14, h=3}, - debug_frame=true, + context='dwarfmode/Trade', + predicate=function() return trade_goods_any_selected(0) and not trade_goods_all_selected(0) end, + pausable=true, +} + +ConfirmSpec{ + id='trade-unmark-all-merchant', + title='Mark all merchant goods', + message='Are you sure you want mark all merchant goods at the depot? Your current merchant goods selections will be lost.', + intercept_keys='_MOUSE_L', + intercept_frame={l=0, r=40, b=7, w=14, h=3}, context='dwarfmode/Trade', predicate=function() return trade_goods_any_selected(0) and not trade_goods_all_selected(0) end, pausable=true, @@ -262,25 +283,6 @@ end uniform_delete.title = "Delete uniform" uniform_delete.message = "Are you sure you want to delete this uniform?" -note_delete = defconf('note-delete') -function note_delete.intercept_key(key) - return key == keys.D_NOTE_DELETE and - ui.main.mode == df.ui_sidebar_mode.NotesPoints and - not ui.waypoints.in_edit_name_mode and - not ui.waypoints.in_edit_text_mode -end -note_delete.title = "Delete note" -note_delete.message = "Are you sure you want to delete this note?" - -route_delete = defconf('route-delete') -function route_delete.intercept_key(key) - return key == keys.D_NOTE_ROUTE_DELETE and - ui.main.mode == df.ui_sidebar_mode.NotesRoutes and - not ui.waypoints.in_edit_name_mode -end -route_delete.title = "Delete route" -route_delete.message = "Are you sure you want to delete this route?" - convict = defconf('convict') convict.title = "Confirm conviction" function convict.intercept_key(key) @@ -297,20 +299,6 @@ function convict.get_message() end ]]-- --- locations cannot be retired currently ---[[ -location_retire = defconf('location-retire') -function location_retire.intercept_key(key) - return key == keys.LOCATION_RETIRE and - (screen.menu == df.viewscreen_locationsst.T_menu.Locations or - screen.menu == df.viewscreen_locationsst.T_menu.Occupations) and - screen.in_edit == df.viewscreen_locationsst.T_in_edit.None and - screen.locations[screen.location_idx] -end -location_retire.title = "Retire location" -location_retire.message = "Are you sure you want to retire this location?" -]]-- - local function get_config() local f = json.open(CONFIG_FILE) local updated = false From 275fd68f1a9a56c3b857bc78a14a21a5c40321de Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Thu, 4 Jan 2024 05:53:47 -0800 Subject: [PATCH 4/7] add remaining trade confirms I simplified the trade-confirm-trade logic since many of the options warned about damaging merchant mood, but none of the cases actually did (e.g. attempting to trade without items selected on both sides) --- internal/confirm/specs.lua | 93 ++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 50 deletions(-) diff --git a/internal/confirm/specs.lua b/internal/confirm/specs.lua index e9b4528d17..7587ecbadd 100644 --- a/internal/confirm/specs.lua +++ b/internal/confirm/specs.lua @@ -66,7 +66,7 @@ local function trade_goods_all_selected(which) end local function trade_agreement_items_any_selected() - local diplomacy = df.global.game.main_interface.diplomacy + local diplomacy = mi.diplomacy for _, tab in ipairs(diplomacy.environment.meeting.sell_requests.priority) do for _, priority in ipairs(tab) do if priority ~= 0 then @@ -98,7 +98,7 @@ ConfirmSpec{ title='Mark all fortress goods', message='Are you sure you want mark all fortress goods at the depot? Your current fortress goods selections will be lost.', intercept_keys='_MOUSE_L', - intercept_frame={r=46, b=7, w=14, h=3}, + intercept_frame={r=47, b=7, w=12, h=3}, context='dwarfmode/Trade', predicate=function() return trade_goods_any_selected(1) and not trade_goods_all_selected(1) end, pausable=true, @@ -120,7 +120,7 @@ ConfirmSpec{ title='Mark all merchant goods', message='Are you sure you want mark all merchant goods at the depot? Your current merchant goods selections will be lost.', intercept_keys='_MOUSE_L', - intercept_frame={l=0, r=72, b=7, w=14, h=3}, + intercept_frame={l=0, r=72, b=7, w=12, h=3}, context='dwarfmode/Trade', predicate=function() return trade_goods_any_selected(0) and not trade_goods_all_selected(0) end, pausable=true, @@ -137,6 +137,39 @@ ConfirmSpec{ pausable=true, } +ConfirmSpec{ + id='trade-confirm-trade', + title='Confirm trade', + message="Are you sure you want to trade the selected goods?", + intercept_keys='_MOUSE_L', + intercept_frame={l=0, r=23, b=4, w=11, h=3}, + context='dwarfmode/Trade', + predicate=function() return trade_goods_any_selected(0) and trade_goods_any_selected(1) end, + pausable=true, +} + +ConfirmSpec{ + id='trade-sieze', + title='Sieze merchant goods', + message='Are you sure you want size marked merchant goods? This will make the merchant unwilling to trade further and will damage relations with the merchant\'s civilization.', + intercept_keys='_MOUSE_L', + intercept_frame={l=0, r=73, b=4, w=11, h=3}, + context='dwarfmode/Trade', + predicate=function() return mi.trade.mer.mood > 0 and trade_goods_any_selected(0) end, + pausable=true, +} + +ConfirmSpec{ + id='trade-offer', + title='Offer fortress goods', + message='Are you sure you want to offer these goods? You will receive no payment.', + intercept_keys='_MOUSE_L', + intercept_frame={l=40, r=5, b=4, w=19, h=3}, + context='dwarfmode/Trade', + predicate=function() return trade_goods_any_selected(1) end, + pausable=true, +} + ConfirmSpec{ id='diplomacy-request', title='Cancel trade agreement', @@ -152,7 +185,7 @@ ConfirmSpec{ message='Are you sure you want to delete this route?', intercept_keys='_MOUSE_L', context='dwarfmode/Hauling', - predicate=function() return df.global.game.main_interface.current_hover == 180 end, + predicate=function() return mi.current_hover == 180 end, pausable=true, } @@ -162,7 +195,7 @@ ConfirmSpec{ message='Are you sure you want to delete this stop?', intercept_keys='_MOUSE_L', context='dwarfmode/Hauling', - predicate=function() return df.global.game.main_interface.current_hover == 185 end, + predicate=function() return mi.current_hover == 185 end, pausable=true, } @@ -173,7 +206,7 @@ ConfirmSpec{ intercept_keys='_MOUSE_L', context='dwarfmode/ViewSheets/BUILDING/TradeDepot', predicate=function() - return df.global.game.main_interface.current_hover == 301 and has_caravans() + return mi.current_hover == 301 and has_caravans() end, } @@ -183,7 +216,7 @@ ConfirmSpec{ message='Are you sure you want to disband this squad?', intercept_keys='_MOUSE_L', context='dwarfmode/Squads', - predicate=function() return df.global.game.main_interface.current_hover == 343 end, + predicate=function() return mi.current_hover == 343 end, pausable=true, } @@ -193,7 +226,7 @@ ConfirmSpec{ message='Are you sure you want to remove this manager order?', intercept_keys='_MOUSE_L', context='dwarfmode/Info/WORK_ORDERS/Default', - predicate=function() return df.global.game.main_interface.current_hover == 222 end, + predicate=function() return mi.current_hover == 222 end, pausable=true, } @@ -214,8 +247,7 @@ ConfirmSpec{ intercept_keys='_MOUSE_L', context='dwarfmode/Burrow', predicate=function() - return df.global.game.main_interface.current_hover == 171 or - df.global.game.main_interface.current_hover == 168 + return mi.current_hover == 171 or mi.current_hover == 168 end, pausable=true, } @@ -226,52 +258,13 @@ ConfirmSpec{ message='Are you sure you want to remove this stockpile?', intercept_keys='_MOUSE_L', context='dwarfmode/Stockpile', - predicate=function() return df.global.game.main_interface.current_hover == 118 end, + predicate=function() return mi.current_hover == 118 end, pausable=true, } -- these confirmations have more complex button detection requirements --[[ -trade = defconf('trade') -function trade.intercept_key(key) - dfhack.gui.matchFocusString("dwarfmode/Trade") and key == MOUSE_LEFT and hovering over trade button? -end -trade.title = "Confirm trade" -function trade.get_message() - if trader_goods_selected() and broker_goods_selected() then - return "Are you sure you want to trade the selected goods?" - elseif trader_goods_selected() then - return "You are not giving any items. This is likely\n" .. - "to irritate the merchants.\n" .. - "Attempt to trade anyway?" - elseif broker_goods_selected() then - return "You are not receiving any items. You may want to\n" .. - "offer these items instead or choose items to receive.\n" .. - "Attempt to trade anyway?" - else - return "No items are selected. This is likely\n" .. - "to irritate the merchants.\n" .. - "Attempt to trade anyway?" - end -end -trade_seize = defconf('trade-seize') -function trade_seize.intercept_key(key) - return screen.in_edit_count == 0 and - trader_goods_selected() and - key == keys.TRADE_SEIZE -end -trade_seize.title = "Confirm seize" -trade_seize.message = "Are you sure you want to seize these goods?" - -trade_offer = defconf('trade-offer') -function trade_offer.intercept_key(key) - return screen.in_edit_count == 0 and - broker_goods_selected() and - key == keys.TRADE_OFFER -end -trade_offer.title = "Confirm offer" -trade_offer.message = "Are you sure you want to offer these goods?\nYou will receive no payment." uniform_delete = defconf('uniform-delete') function uniform_delete.intercept_key(key) From 2b6a311db5aafcd795930fd864c8305bbf851fca Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Thu, 4 Jan 2024 06:38:51 -0800 Subject: [PATCH 5/7] add prompt for delete uniform --- confirm.lua | 19 +++++++++++++---- internal/confirm/specs.lua | 42 ++++++++++++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/confirm.lua b/confirm.lua index d35fde25cb..52d18a2978 100644 --- a/confirm.lua +++ b/confirm.lua @@ -125,7 +125,7 @@ function ConfirmOverlay:init() self:addviews{ widgets.Panel{ view_id=id, - frame=conf.intercept_frame, + frame=copyall(conf.intercept_frame), frame_style=conf.debug_frame and gui.FRAME_INTERIOR or nil, } } @@ -135,6 +135,12 @@ end function ConfirmOverlay:preUpdateLayout() self.frame.w, self.frame.h = dfhack.screen.getWindowSize() + -- reset frames if any of them have been pushed out of position + for id, conf in pairs(specs.REGISTRY) do + if conf.intercept_frame then + self.subviews[id].frame = copyall(conf.intercept_frame) + end + end end function ConfirmOverlay:overlay_onupdate() @@ -156,11 +162,16 @@ function ConfirmOverlay:matches_conf(conf, keys, scr) end end if not matched_keys then return false end - if conf.intercept_frame and not self.subviews[conf.id]:getMouseFramePos() then - return false + local mouse_offset + if conf.intercept_frame then + local mousex, mousey = self.subviews[conf.id]:getMouseFramePos() + if not mousex then + return false + end + mouse_offset = xy2pos(mousex, mousey) end if not dfhack.gui.matchFocusString(conf.context, scr) then return false end - return not conf.predicate or conf.predicate() + return not conf.predicate or conf.predicate(mouse_offset) end function ConfirmOverlay:onInput(keys) diff --git a/internal/confirm/specs.lua b/internal/confirm/specs.lua index 7587ecbadd..16ba492bb9 100644 --- a/internal/confirm/specs.lua +++ b/internal/confirm/specs.lua @@ -84,6 +84,17 @@ local function has_caravans() end end +local function get_num_uniforms() + local site = df.global.world.world_data.active_site[0] + for _, entity_site_link in ipairs(site.entity_links) do + local he = df.historical_entity.find(entity_site_link.entity_id) + if he and he.type == df.historical_entity_type.SiteGovernment then + return #he.uniforms + end + end + return 0 +end + ConfirmSpec{ id='trade-cancel', title='Cancel trade', @@ -220,6 +231,28 @@ ConfirmSpec{ pausable=true, } +ConfirmSpec{ + id='uniform-delete', + title='Delete uniform', + message="Are you sure you want to delete this uniform?", + intercept_keys='_MOUSE_L', + intercept_frame={r=131, t=23, w=6, h=27}, + context='dwarfmode/AssignUniform', + predicate=function(mouse_offset) + local num_uniforms = get_num_uniforms() + if num_uniforms == 0 then return false end + -- adjust detection area depending on presence of scrollbar + if num_uniforms > 8 and mouse_offset.x > 2 then + return false + elseif num_uniforms <= 8 and mouse_offset.x <= 1 then + return false + end + -- exclude the "No uniform" option (which has no delete button) + return mouse_offset.y // 3 < num_uniforms - mi.assign_uniform.scroll_position + end, + pausable=true, +} + ConfirmSpec{ id='order-remove', title='Remove manger order', @@ -266,15 +299,6 @@ ConfirmSpec{ --[[ -uniform_delete = defconf('uniform-delete') -function uniform_delete.intercept_key(key) - return key == keys.D_MILITARY_DELETE_UNIFORM and - screen.page == screen._type.T_page.Uniforms and - #screen.equip.uniforms > 0 and - not screen.equip.in_name_uniform -end -uniform_delete.title = "Delete uniform" -uniform_delete.message = "Are you sure you want to delete this uniform?" convict = defconf('convict') convict.title = "Confirm conviction" From 3fba9768d75cb58e6712209ef3249e0da12f6f7e Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Thu, 4 Jan 2024 07:34:49 -0800 Subject: [PATCH 6/7] implement conviction confirmation --- confirm.lua | 2 +- gui/confirm.lua | 2 +- internal/confirm/specs.lua | 63 +++++++++++++++++++++++++------------- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/confirm.lua b/confirm.lua index 52d18a2978..0d1bb324e4 100644 --- a/confirm.lua +++ b/confirm.lua @@ -163,7 +163,7 @@ function ConfirmOverlay:matches_conf(conf, keys, scr) end if not matched_keys then return false end local mouse_offset - if conf.intercept_frame then + if keys._MOUSE_L and conf.intercept_frame then local mousex, mousey = self.subviews[conf.id]:getMouseFramePos() if not mousex then return false diff --git a/gui/confirm.lua b/gui/confirm.lua index 36e55b0d40..12b5e7adbe 100644 --- a/gui/confirm.lua +++ b/gui/confirm.lua @@ -7,7 +7,7 @@ local widgets = require('gui.widgets') Confirm = defclass(Confirm, widgets.Window) Confirm.ATTRS{ frame_title='Confirmation dialogs', - frame={w=37, h=17}, + frame={w=42, h=17}, initial_id=DEFAULT_NIL, } diff --git a/internal/confirm/specs.lua b/internal/confirm/specs.lua index 16ba492bb9..2bc0dc71e4 100644 --- a/internal/confirm/specs.lua +++ b/internal/confirm/specs.lua @@ -234,7 +234,7 @@ ConfirmSpec{ ConfirmSpec{ id='uniform-delete', title='Delete uniform', - message="Are you sure you want to delete this uniform?", + message='Are you sure you want to delete this uniform?', intercept_keys='_MOUSE_L', intercept_frame={r=131, t=23, w=6, h=27}, context='dwarfmode/AssignUniform', @@ -253,6 +253,44 @@ ConfirmSpec{ pausable=true, } +local selected_convict_name = 'this creature' +ConfirmSpec{ + id='convict', + title='Confirm conviction', + message=function() + return ('Are you sure you want to convict %s? This action is irreversible.'):format(selected_convict_name) + end, + intercept_keys='_MOUSE_L', + intercept_frame={r=31, t=14, w=11, b=5}, + debug_frame=true, + context='dwarfmode/Info/JUSTICE/Convicting', + predicate=function(mouse_offset) + local justice = mi.info.justice + local num_choices = #justice.conviction_list + if num_choices == 0 then return false end + local sw, sh = dfhack.screen.getWindowSize() + local y_offset = sw >= 155 and 0 or 4 + local max_visible_buttons = (sh - (19 + y_offset)) // 3 + -- adjust detection area depending on presence of scrollbar + if num_choices > max_visible_buttons and mouse_offset.x > 9 then + return false + elseif num_choices <= max_visible_buttons and mouse_offset.x <= 1 then + return false + end + local num_visible_buttons = math.min(num_choices, max_visible_buttons) + local selected_button_offset = (mouse_offset.y - y_offset) // 3 + if selected_button_offset >= num_visible_buttons then + return false + end + local unit = justice.conviction_list[selected_button_offset + justice.scroll_position_conviction] + selected_convict_name = dfhack.TranslateName(dfhack.units.getVisibleName(unit)) + if selected_convict_name == '' then + selected_convict_name = 'this creature' + end + return true + end, +} + ConfirmSpec{ id='order-remove', title='Remove manger order', @@ -295,26 +333,9 @@ ConfirmSpec{ pausable=true, } --- these confirmations have more complex button detection requirements ---[[ - - - -convict = defconf('convict') -convict.title = "Confirm conviction" -function convict.intercept_key(key) - return key == keys.SELECT and - screen.cur_column == df.viewscreen_justicest.T_cur_column.ConvictChoices -end -function convict.get_message() - name = dfhack.TranslateName(dfhack.units.getVisibleName(screen.convict_choices[screen.cursor_right])) - if name == "" then - name = "this creature" - end - return "Are you sure you want to convict " .. name .. "?\n" .. - "This action is irreversible." -end -]]-- +-------------------------- +-- Config file management +-- local function get_config() local f = json.open(CONFIG_FILE) From c1d93ebbe131498e6bc63b90d7a5d499f13fb9b1 Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Thu, 4 Jan 2024 07:38:32 -0800 Subject: [PATCH 7/7] update changelog --- changelog.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index c79493f8bb..a3f4315439 100644 --- a/changelog.txt +++ b/changelog.txt @@ -43,7 +43,10 @@ Template for new versions: - `gui/control-panel`: reduce frequency for `warn-stranded` check to once every 2 days - `gui/control-panel`: tools are now organized by type: automation, bugfix, and gameplay - `confirm`: updated confirmation dialogs to use clickable widgets and draggable windows -- `confirm`: added confirmation dialog for right clicking out of the trade agreement screen (so your trade agreement selections aren't lost) +- `confirm`: added confirmation prompt for right clicking out of the trade agreement screen (so your trade agreement selections aren't lost) +- `confirm`: added confirmation prompts for irreversible actions on the trade screen +- `confirm`: added confirmation prompt for deleting a uniform +- `confirm`: added confirmation prompt for convicting a criminal - `gui/autobutcher`: interface redesigned to better support mouse control - `gui/launcher`: now persists the most recent 32KB of command output even if you close it and bring it back up - `gui/quickcmd`: clickable buttons for command add/remove/edit operations