Merge branch 'develop' into myk_gray

develop
Myk 2023-10-07 19:33:10 -07:00 committed by GitHub
commit fcdb148bf2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 392 additions and 50 deletions

@ -61,6 +61,7 @@ Template for new versions:
## Fixes ## Fixes
## Misc Improvements ## Misc Improvements
- `overlay`: allow ``overlay_onupdate_max_freq_seconds`` to be dynamically set to 0 for a burst of high-frequency updates
## Documentation ## Documentation
@ -68,8 +69,10 @@ Template for new versions:
## Lua ## Lua
- added ``GRAY`` color aliases for ``GREY`` colors - added ``GRAY`` color aliases for ``GREY`` colors
- ``utils.search_text``: text search routine (generalized from ``widgets.FilteredList``)
## Removed ## Removed
- ``FILTER_FULL_TEXT``: moved from ``gui.widgets`` to ``utils``; if your full text search preference is lost, please reset it in `gui/control-panel`
# 50.11-r1 # 50.11-r1

@ -3340,6 +3340,20 @@ utils
Exactly like ``erase_sorted_key``, but if field is specified, takes the key from ``item[field]``. Exactly like ``erase_sorted_key``, but if field is specified, takes the key from ``item[field]``.
* ``utils.search_text(text,search_tokens)``
Returns true if all the search tokens are found within ``text``. The text and
search tokens are normalized to lower case and special characters (e.g. ``A``
with a circle on it) are converted to their "basic" forms (e.g. ``a``).
``search_tokens`` can be a string or a table of strings. If it is a string,
it is split into space-separated tokens before matching. The search tokens
are treated literally, so any special regular expression characters do not
need to be escaped. If ``utils.FILTER_FULL_TEXT`` is ``true``, then the
search tokens can match any part of ``text``. If it is ``false``, then the
matches must happen at the beginning of words within ``text``. You can change
the value of ``utils.FILTER_FULL_TEXT`` in `gui/control-panel` on the
"Preferences" tab.
* ``utils.call_with_string(obj,methodname,...)`` * ``utils.call_with_string(obj,methodname,...)``
Allocates a temporary string object, calls ``obj:method(tmp,...)``, and Allocates a temporary string object, calls ``obj:method(tmp,...)``, and
@ -5294,12 +5308,11 @@ FilteredList class
------------------ ------------------
This widget combines List, EditField and Label into a combo-box like This widget combines List, EditField and Label into a combo-box like
construction that allows filtering the list by subwords of its items. construction that allows filtering the list.
In addition to passing through all attributes supported by List, it In addition to passing through all attributes supported by List, it
supports: supports:
:case_sensitive: If ``true``, matching is case sensitive. Defaults to ``false``.
:edit_pen: If specified, used instead of ``cursor_pen`` for the edit field. :edit_pen: If specified, used instead of ``cursor_pen`` for the edit field.
:edit_below: If true, the edit field is placed below the list instead of above. :edit_below: If true, the edit field is placed below the list instead of above.
:edit_key: If specified, the edit field is disabled until this key is pressed. :edit_key: If specified, the edit field is disabled until this key is pressed.
@ -5348,9 +5361,9 @@ Filter behavior:
By default, the filter matches substrings that start at the beginning of a word By default, the filter matches substrings that start at the beginning of a word
(or after any punctuation). You can instead configure filters to match any (or after any punctuation). You can instead configure filters to match any
substring with a command like:: substring across the full text with a command like::
:lua require('gui.widgets').FILTER_FULL_TEXT=true :lua require('utils').FILTER_FULL_TEXT=true
TabBar class TabBar class
------------ ------------

@ -135,7 +135,10 @@ The ``overlay.OverlayWidget`` superclass defines the following class attributes:
seconds) that your widget can take to react to changes in information and seconds) that your widget can take to react to changes in information and
not annoy the player. Set to 0 to be called at the maximum rate. Be aware not annoy the player. Set to 0 to be called at the maximum rate. Be aware
that running more often than you really need to will impact game FPS, that running more often than you really need to will impact game FPS,
especially if your widget can run while the game is unpaused. especially if your widget can run while the game is unpaused. If you change
the value of this attribute dynamically, it may not be noticed until the
previous timeout expires. However, if you need a burst of high-frequency
updates, set it to ``0`` and it will be noticed immediately.
Registering a widget with the overlay framework Registering a widget with the overlay framework
*********************************************** ***********************************************

@ -739,12 +739,6 @@ function EditField:onInput(keys)
end end
end end
return not not self.key return not not self.key
elseif keys._MOUSE_L_DOWN then
local mouse_x = self:getMousePos()
if mouse_x then
self:setCursor(self.start_pos + mouse_x - (self.text_offset or 0))
return true
end
elseif keys._STRING then elseif keys._STRING then
local old = self.text local old = self.text
if keys._STRING == 0 then if keys._STRING == 0 then
@ -795,6 +789,12 @@ function EditField:onInput(keys)
elseif keys.CUSTOM_CTRL_V then elseif keys.CUSTOM_CTRL_V then
self:insert(dfhack.internal.getClipboardTextCp437()) self:insert(dfhack.internal.getClipboardTextCp437())
return true return true
elseif keys._MOUSE_L_DOWN then
local mouse_x = self:getMousePos()
if mouse_x then
self:setCursor(self.start_pos + mouse_x - (self.text_offset or 0))
return true
end
end end
-- if we're modal, then unconditionally eat all the input -- if we're modal, then unconditionally eat all the input
@ -2017,12 +2017,9 @@ end
-- Filtered List -- -- Filtered List --
------------------- -------------------
FILTER_FULL_TEXT = false
FilteredList = defclass(FilteredList, Widget) FilteredList = defclass(FilteredList, Widget)
FilteredList.ATTRS { FilteredList.ATTRS {
case_sensitive = false,
edit_below = false, edit_below = false,
edit_key = DEFAULT_NIL, edit_key = DEFAULT_NIL,
edit_ignore_keys = DEFAULT_NIL, edit_ignore_keys = DEFAULT_NIL,
@ -2172,7 +2169,6 @@ function FilteredList:setFilter(filter, pos)
pos = nil pos = nil
for i,v in ipairs(self.choices) do for i,v in ipairs(self.choices) do
local ok = true
local search_key = v.search_key local search_key = v.search_key
if not search_key then if not search_key then
if type(v.text) ~= 'table' then if type(v.text) ~= 'table' then
@ -2187,30 +2183,7 @@ function FilteredList:setFilter(filter, pos)
search_key = table.concat(texts, ' ') search_key = table.concat(texts, ' ')
end end
end end
for _,key in ipairs(tokens) do if utils.search_text(search_key, tokens) then
key = key:escape_pattern()
if key ~= '' then
search_key = dfhack.toSearchNormalized(search_key)
key = dfhack.toSearchNormalized(key)
if not self.case_sensitive then
search_key = string.lower(search_key)
key = string.lower(key)
end
-- the separate checks for non-space or non-punctuation allows
-- punctuation itself to be matched if that is useful (e.g.
-- filenames or parameter names)
if not FILTER_FULL_TEXT and not search_key:match('%f[^%p\x00]'..key)
and not search_key:match('%f[^%s\x00]'..key) then
ok = false
break
elseif FILTER_FULL_TEXT and not search_key:find(key) then
ok = false
break
end
end
end
if ok then
table.insert(choices, v) table.insert(choices, v)
cidx[#choices] = i cidx[#choices] = i
if ipos == i then if ipos == i then

@ -460,6 +460,32 @@ function erase_sorted(vector,item,field,cmp)
return erase_sorted_key(vector,key,field,cmp) return erase_sorted_key(vector,key,field,cmp)
end end
FILTER_FULL_TEXT = false
function search_text(text, search_tokens)
text = dfhack.toSearchNormalized(text)
if type(search_tokens) ~= 'table' then
search_tokens = search_tokens:split()
end
for _,search_token in ipairs(search_tokens) do
if search_token == '' then goto continue end
search_token = dfhack.toSearchNormalized(search_token:escape_pattern())
-- the separate checks for non-space or non-punctuation allows
-- punctuation itself to be matched if that is useful (e.g.
-- filenames or parameter names)
if not FILTER_FULL_TEXT and not text:match('%f[^%p\x00]'..search_token)
and not text:match('%f[^%s\x00]'..search_token) then
return false
elseif FILTER_FULL_TEXT and not text:find(search_token) then
return false
end
::continue::
end
return true
end
-- Calls a method with a string temporary -- Calls a method with a string temporary
function call_with_string(obj,methodname,...) function call_with_string(obj,methodname,...)
return dfhack.with_temp_object( return dfhack.with_temp_object(

@ -240,7 +240,14 @@ DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode)
switch(game->main_interface.info.current_mode) { switch(game->main_interface.info.current_mode) {
case df::enums::info_interface_mode_type::CREATURES: case df::enums::info_interface_mode_type::CREATURES:
newFocusString += '/' + enum_item_key(game->main_interface.info.creatures.current_mode); if (game->main_interface.info.creatures.showing_overall_training)
newFocusString += "/OverallTraining";
else if (game->main_interface.info.creatures.showing_activity_details)
newFocusString += "/ActivityDetails";
else if (game->main_interface.info.creatures.adding_trainer)
newFocusString += "/AddingTrainer";
else
newFocusString += '/' + enum_item_key(game->main_interface.info.creatures.current_mode);
break; break;
case df::enums::info_interface_mode_type::BUILDINGS: case df::enums::info_interface_mode_type::BUILDINGS:
newFocusString += '/' + enum_item_key(game->main_interface.info.buildings.mode); newFocusString += '/' + enum_item_key(game->main_interface.info.buildings.mode);
@ -556,7 +563,13 @@ DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode)
} }
if (game->main_interface.location_selector.open) { if (game->main_interface.location_selector.open) {
newFocusString = baseFocus; newFocusString = baseFocus;
newFocusString += "/LocationSelector"; newFocusString += "/LocationSelector/";
if (game->main_interface.location_selector.choosing_temple_religious_practice)
newFocusString += "Temple";
else if (game->main_interface.location_selector.choosing_craft_guild)
newFocusString += "Guildhall";
else
newFocusString += "Default";
focusStrings.push_back(newFocusString); focusStrings.push_back(newFocusString);
} }
if (game->main_interface.location_details.open) { if (game->main_interface.location_details.open) {

@ -151,7 +151,6 @@ function ItemSelection:init()
widgets.FilteredList{ widgets.FilteredList{
view_id='flist', view_id='flist',
frame={t=0, b=0}, frame={t=0, b=0},
case_sensitive=false,
choices=choices, choices=choices,
icon_width=2, icon_width=2,
on_submit=self:callback('toggle_group'), on_submit=self:callback('toggle_group'),

@ -433,8 +433,12 @@ end
-- reduces the next call by a small random amount to introduce jitter into the -- reduces the next call by a small random amount to introduce jitter into the
-- widget processing timings -- widget processing timings
local function do_update(name, db_entry, now_ms, vs) local function do_update(name, db_entry, now_ms, vs)
if db_entry.next_update_ms > now_ms then return end
local w = db_entry.widget local w = db_entry.widget
if w.overlay_onupdate_max_freq_seconds ~= 0 and
db_entry.next_update_ms > now_ms
then
return
end
db_entry.next_update_ms = get_next_onupdate_timestamp(now_ms, w) db_entry.next_update_ms = get_next_onupdate_timestamp(now_ms, w)
if detect_frame_change(w, function() return w:overlay_onupdate(vs) end) then if detect_frame_change(w, function() return w:overlay_onupdate(vs) end) then
if register_trigger_lock_screen(w:overlay_trigger(), name) then if register_trigger_lock_screen(w:overlay_trigger(), name) then

@ -1,5 +1,6 @@
local _ENV = mkmodule('plugins.sort') local _ENV = mkmodule('plugins.sort')
local creatures = require('plugins.sort.creatures')
local gui = require('gui') local gui = require('gui')
local overlay = require('plugins.overlay') local overlay = require('plugins.overlay')
local setbelief = reqscript('modtools/set-belief') local setbelief = reqscript('modtools/set-belief')
@ -275,29 +276,29 @@ local function get_ranged_skill_effectiveness_rating(unit)
return get_rating(ranged_skill_effectiveness(unit), 0, 800000, 72, 52, 31, 11) return get_rating(ranged_skill_effectiveness(unit), 0, 800000, 72, 52, 31, 11)
end end
local function make_sort_by_ranged_skill_effectiveness_desc(list) local function make_sort_by_ranged_skill_effectiveness_desc()
return function(unit_id_1, unit_id_2) return function(unit_id_1, unit_id_2)
if unit_id_1 == unit_id_2 then return 0 end if unit_id_1 == unit_id_2 then return 0 end
local unit1 = df.unit.find(unit_id_1) local unit1 = df.unit.find(unit_id_1)
local unit2 = df.unit.find(unit_id_2) local unit2 = df.unit.find(unit_id_2)
if not unit1 then return -1 end if not unit1 then return -1 end
if not unit2 then return 1 end if not unit2 then return 1 end
local rating1 = ranged_skill_effectiveness(unit1, list) local rating1 = ranged_skill_effectiveness(unit1)
local rating2 = ranged_skill_effectiveness(unit2, list) local rating2 = ranged_skill_effectiveness(unit2)
if rating1 == rating2 then return sort_by_name_desc(unit_id_1, unit_id_2) end if rating1 == rating2 then return sort_by_name_desc(unit_id_1, unit_id_2) end
return utils.compare(rating2, rating1) return utils.compare(rating2, rating1)
end end
end end
local function make_sort_by_ranged_skill_effectiveness_asc(list) local function make_sort_by_ranged_skill_effectiveness_asc()
return function(unit_id_1, unit_id_2) return function(unit_id_1, unit_id_2)
if unit_id_1 == unit_id_2 then return 0 end if unit_id_1 == unit_id_2 then return 0 end
local unit1 = df.unit.find(unit_id_1) local unit1 = df.unit.find(unit_id_1)
local unit2 = df.unit.find(unit_id_2) local unit2 = df.unit.find(unit_id_2)
if not unit1 then return -1 end if not unit1 then return -1 end
if not unit2 then return 1 end if not unit2 then return 1 end
local rating1 = ranged_skill_effectiveness(unit1, list) local rating1 = ranged_skill_effectiveness(unit1)
local rating2 = ranged_skill_effectiveness(unit2, list) local rating2 = ranged_skill_effectiveness(unit2)
if rating1 == rating2 then return sort_by_name_desc(unit_id_1, unit_id_2) end if rating1 == rating2 then return sort_by_name_desc(unit_id_1, unit_id_2) end
return utils.compare(rating1, rating2) return utils.compare(rating1, rating2)
end end
@ -1261,6 +1262,7 @@ end
OVERLAY_WIDGETS = { OVERLAY_WIDGETS = {
squad_assignment=SquadAssignmentOverlay, squad_assignment=SquadAssignmentOverlay,
squad_annotation=SquadAnnotationOverlay, squad_annotation=SquadAnnotationOverlay,
creatures=creatures.InfoOverlay,
} }
dfhack.onStateChange[GLOBAL_KEY] = function(sc) dfhack.onStateChange[GLOBAL_KEY] = function(sc)

@ -0,0 +1,306 @@
local _ENV = mkmodule('plugins.sort.creatures')
local overlay = require('plugins.overlay')
local widgets = require('gui.widgets')
local utils = require('utils')
local creatures = df.global.game.main_interface.info.creatures
-- these sort functions attempt to match the vanilla sort behavior, which is not
-- quite the same as the rest of DFHack. For example, in other DFHack sorts,
-- we'd always sort by name descending as a secondary sort. To match vanilla sorting,
-- if the primary sort is ascending, the secondary name sort will also be ascending.
--
-- also note that vanilla sorts are not stable, so there might still be some jitter
-- if the player clicks one of the vanilla sort widgets after searching
local function sort_by_name_desc(a, b)
return a.sort_name < b.sort_name
end
local function sort_by_name_asc(a, b)
return a.sort_name > b.sort_name
end
local function sort_by_prof_desc(a, b)
if a.profession_list_order1 == b.profession_list_order1 then
return sort_by_name_desc(a, b)
end
return a.profession_list_order1 < b.profession_list_order1
end
local function sort_by_prof_asc(a, b)
if a.profession_list_order1 == b.profession_list_order1 then
return sort_by_name_asc(a, b)
end
return a.profession_list_order1 > b.profession_list_order1
end
local function sort_by_job_name_desc(a, b)
if a.job_sort_name == b.job_sort_name then
return sort_by_name_desc(a, b)
end
return a.job_sort_name < b.job_sort_name
end
local function sort_by_job_name_asc(a, b)
if a.job_sort_name == b.job_sort_name then
-- use descending tertiary sort for visual stability
return sort_by_name_desc(a, b)
end
return a.job_sort_name > b.job_sort_name
end
local function sort_by_job_desc(a, b)
if not not a.jb == not not b.jb then
return sort_by_job_name_desc(a, b)
end
return not not a.jb
end
local function sort_by_job_asc(a, b)
if not not a.jb == not not b.jb then
return sort_by_job_name_asc(a, b)
end
return not not b.jb
end
local function sort_by_stress_desc(a, b)
if a.stress == b.stress then
return sort_by_name_desc(a, b)
end
return a.stress > b.stress
end
local function sort_by_stress_asc(a, b)
if a.stress == b.stress then
return sort_by_name_asc(a, b)
end
return a.stress < b.stress
end
local function get_sort()
if creatures.sorting_cit_job then
return creatures.sorting_cit_job_is_ascending and sort_by_job_asc or sort_by_job_desc
elseif creatures.sorting_cit_stress then
return creatures.sorting_cit_stress_is_ascending and sort_by_stress_asc or sort_by_stress_desc
elseif creatures.sorting_cit_nameprof_doing_prof then
return creatures.sorting_cit_nameprof_is_ascending and sort_by_prof_asc or sort_by_prof_desc
else
return creatures.sorting_cit_nameprof_is_ascending and sort_by_name_asc or sort_by_name_desc
end
end
local function copy_to_lua_table(vec)
local tab = {}
for k,v in ipairs(vec) do
tab[k+1] = v
end
return tab
end
local function general_search(vec, get_search_key_fn, get_sort_fn, data, filter, incremental)
if not data.saved_original then
data.saved_original = copy_to_lua_table(vec)
elseif not incremental then
vec:assign(data.saved_original)
end
if filter ~= '' then
local search_tokens = filter:split()
for idx = #vec-1,0,-1 do
local search_key = get_search_key_fn(vec[idx])
if search_key and not utils.search_text(search_key, search_tokens) then
vec:erase(idx)
end
end
end
data.saved_visible = copy_to_lua_table(vec)
if get_sort_fn then
table.sort(data.saved_visible, get_sort_fn())
vec:assign(data.saved_visible)
end
end
-- add dynamically allocated elements that were not visible at the time of
-- closure back to the vector so they can be cleaned up when it is next initialized
local function cri_unitst_cleanup(vec, data)
if not data.saved_visible or not data.saved_original then return end
for _,elem in ipairs(data.saved_original) do
if not utils.linear_index(data.saved_visible, elem) then
vec:insert('#', elem)
end
end
end
local function make_cri_unitst_handlers(vec)
return {
search_fn=curry(general_search, vec,
function(elem) return elem.sort_name end,
get_sort),
cleanup_fn=curry(cri_unitst_cleanup, vec),
}
end
local function overall_training_search(data, filter, incremental)
general_search(creatures.atk_index, function(elem)
local raw = df.creature_raw.find(elem)
if not raw then return '' end
return raw.name[1]
end, nil, data, filter, incremental)
end
local function assign_trainer_search(data, filter, incremental)
general_search(creatures.trainer, function(elem)
if not elem then return nil end
return ('%s %s'):format(dfhack.TranslateName(elem.name), dfhack.units.getProfessionName(elem))
end, nil, data, filter, incremental)
end
local HANDLERS = {
CITIZEN=make_cri_unitst_handlers(creatures.cri_unit.CITIZEN),
PET=make_cri_unitst_handlers(creatures.cri_unit.PET),
OTHER=make_cri_unitst_handlers(creatures.cri_unit.OTHER),
DECEASED=make_cri_unitst_handlers(creatures.cri_unit.DECEASED),
PET_OT={search_fn=overall_training_search},
PET_AT={search_fn=assign_trainer_search},
}
-- ----------------------
-- InfoOverlay
--
InfoOverlay = defclass(InfoOverlay, overlay.OverlayWidget)
InfoOverlay.ATTRS{
default_pos={x=64, y=9},
default_enabled=true,
viewscreens={
'dwarfmode/Info/CREATURES/CITIZEN',
'dwarfmode/Info/CREATURES/PET',
'dwarfmode/Info/CREATURES/OverallTraining',
'dwarfmode/Info/CREATURES/AddingTrainer',
'dwarfmode/Info/CREATURES/OTHER',
'dwarfmode/Info/CREATURES/DECEASED',
},
hotspot=true,
overlay_onupdate_max_freq_seconds=0,
frame={w=40, h=3},
}
function InfoOverlay:init()
self.state = {}
self:addviews{
widgets.BannerPanel{
view_id='panel',
frame={l=0, t=0, r=0, h=1},
subviews={
widgets.EditField{
view_id='search',
frame={l=1, t=0, r=1},
label_text="Search: ",
key='CUSTOM_ALT_S',
on_change=self:callback('text_input'),
},
},
},
}
end
local function cleanup(state)
for k,v in pairs(state) do
local cleanup_fn = safe_index(HANDLERS, k, 'cleanup_fn')
if cleanup_fn then cleanup_fn(v) end
end
end
function InfoOverlay:overlay_onupdate()
if next(self.state) and
not dfhack.gui.matchFocusString('dwarfmode/Info', dfhack.gui.getDFViewscreen(true))
then
cleanup(self.state)
self.state = {}
self.subviews.search:setText('')
self.subviews.search:setFocus(false)
self.overlay_onupdate_max_freq_seconds = 60
end
end
local function are_tabs_in_two_rows()
local pen = dfhack.screen.readTile(64, 6, false) -- tile is occupied iff tabs are in one row
return pen.ch == 0
end
local function resize_overlay(self)
local sw = dfhack.screen.getWindowSize()
local overlay_width = math.min(40, sw-(self.frame_rect.x1 + 30))
if overlay_width ~= self.frame.w then
self.frame.w = overlay_width
return true
end
end
function InfoOverlay:updateFrames()
local ret = resize_overlay(self)
local two_rows = are_tabs_in_two_rows()
if (self.two_rows == two_rows) then return ret end
self.two_rows = two_rows
self.subviews.panel.frame.t = two_rows and 2 or 0
return true
end
local function get_key()
if creatures.current_mode == df.unit_list_mode_type.PET then
if creatures.showing_overall_training then
return 'PET_OT'
elseif creatures.adding_trainer then
return 'PET_AT'
end
end
return df.unit_list_mode_type[creatures.current_mode]
end
local function check_context(self)
local key = get_key()
if self.state.prev_key ~= key then
self.state.prev_key = key
local prev_text = ensure_key(self.state, key).prev_text
self.subviews.search:setText(prev_text or '')
end
end
function InfoOverlay:onRenderBody(dc)
if next(self.state) then
check_context(self)
end
if self:updateFrames() then
self:updateLayout()
end
self.overlay_onupdate_max_freq_seconds = 0
InfoOverlay.super.onRenderBody(self, dc)
end
function InfoOverlay:text_input(text)
if not next(self.state) and text == '' then return end
-- the EditField state is guaranteed to be consistent with the current
-- context since when clicking to switch tabs, onRenderBody is always called
-- before this text_input callback, even if a key is pressed before the next
-- graphical frame would otherwise be printed. if this ever becomes untrue,
-- then we can add an on_char handler to the EditField that also calls
-- check_context.
local key = get_key()
local prev_text = ensure_key(self.state, key).prev_text
-- some screens reset their contents between context switches; regardless
-- a switch back to the context should results in an incremental search
local incremental = prev_text and text:startswith(prev_text)
HANDLERS[key].search_fn(self.state[key], text, incremental)
self.state[key].prev_text = text
end
function InfoOverlay:onInput(keys)
if keys._MOUSE_R and self.subviews.search.focus then
self.subviews.search:setFocus(false)
return true
end
return InfoOverlay.super.onInput(self, keys)
end
return _ENV

@ -1 +1 @@
Subproject commit 728d902712655592ec4385e88fd36077641ccfb1 Subproject commit 0759b6b7dde184c3bf36669f92138748a0e2382b