From 8dc50e159a0d4959510dd4033d4f3f278f3f2219 Mon Sep 17 00:00:00 2001 From: unboundlopez <47876628+unboundlopez@users.noreply.github.com> Date: Sun, 25 Jan 2026 17:14:19 -0600 Subject: [PATCH 1/9] Document external TTS usage --- flavor-text.lua | 176 ++++++++++++++++++++++++++++++++++++++++++++++++ flavor-text.rst | 27 ++++++++ 2 files changed, 203 insertions(+) create mode 100644 flavor-text.lua create mode 100644 flavor-text.rst diff --git a/flavor-text.lua b/flavor-text.lua new file mode 100644 index 000000000..9d171e9b7 --- /dev/null +++ b/flavor-text.lua @@ -0,0 +1,176 @@ +-- Writes the currently viewed unit or item flavor text to a file. +-- +-- Usage: +-- flavor-text + +local folder = 'flavor text' +local filename = 'read flavor.txt' +local filepath = folder .. '/' .. filename + +local function clear_output_file() + local ok, err = pcall(dfhack.filesystem.mkdir_recursive, folder) + if not ok then + qerror(('Failed to create folder "%s": %s'):format(folder, err)) + end + local file, open_err = io.open(filepath, 'w') + if not file then + qerror(('Failed to open file "%s" for writing: %s'):format(filepath, open_err)) + end + file:write('') + file:close() +end + +local function reformat(str) + local cleaned = str:gsub('%[B%]', '') + :gsub('%[P%]', '') + :gsub('%[R%]', '') + :gsub('%[C:%d+:%d+:%d+%]', '') + :gsub('%s+', ' ') + :gsub('^%s+', '') + :gsub('%s+$', '') + return cleaned +end + +local function collect_lines(entries) + local lines = {} + for _, entry in ipairs(entries) do + if entry.value ~= '' then + local cleaned = reformat(dfhack.df2utf(entry.value)) + if cleaned ~= '' then + table.insert(lines, cleaned) + end + end + end + return lines +end + +local function get_health_text(view_sheets) + if #view_sheets.unit_health_raw_str == 0 then + return nil + end + local lines = collect_lines(view_sheets.unit_health_raw_str) + if #lines == 0 then + return nil + end + return table.concat(lines, '\n') +end + +local function get_personality_text(view_sheets) + if #view_sheets.personality_raw_str == 0 then + return nil + end + local lines = collect_lines(view_sheets.personality_raw_str) + if #lines == 0 then + return nil + end + return table.concat(lines, '\n') +end + +local UNIT_SHEET_SUBTAB = { + HEALTH = 2, + PERSONALITY = 10, +} + +local HEALTH_ACTIVE_TAB = { + STATUS = 0, + WOUNDS = 1, + TREATMENT = 2, + HISTORY = 3, + DESCRIPTION = 4, +} + +local PERSONALITY_ACTIVE_TAB = { + TRAITS = 0, + VALUES = 1, + PREFERENCES = 2, + NEEDS = 3, +} + +local function get_unit_flavor_text(view_sheets) + local unit = df.unit.find(view_sheets.active_id) + if not unit then + qerror('Unable to resolve the active unit.') + end + + if view_sheets.active_sub_tab == UNIT_SHEET_SUBTAB.HEALTH then + local health_text = get_health_text(view_sheets) + if health_text then + return unit, 'Health', health_text + end + clear_output_file() + qerror('No text found on the Health tab.') + end + + if view_sheets.active_sub_tab == UNIT_SHEET_SUBTAB.PERSONALITY + and (view_sheets.personality_active_tab == PERSONALITY_ACTIVE_TAB.TRAITS + or view_sheets.personality_active_tab == PERSONALITY_ACTIVE_TAB.VALUES + or view_sheets.personality_active_tab == PERSONALITY_ACTIVE_TAB.PREFERENCES + or view_sheets.personality_active_tab == PERSONALITY_ACTIVE_TAB.NEEDS) + then + local text = get_personality_text(view_sheets) + if text then + return unit, 'Personality', text + end + clear_output_file() + qerror('No text found on the Personality subtab (Traits/Values/Preferences/Needs).') + end + + clear_output_file() + qerror('Open Health, Personality or an item window before running this script.') +end + +local function get_item_flavor_text(view_sheets) + local item = dfhack.gui.getSelectedItem(true) + if not item then + qerror('Select an item or open an item view sheet before running this script.') + end + + local description = view_sheets.raw_description or '' + if description == '' then + qerror('No item description text found on the item view sheet.') + end + + return item, 'Item', reformat(dfhack.df2utf(description)) +end + +local view_sheets = df.global.game.main_interface.view_sheets +if not view_sheets.open then + clear_output_file() + qerror('Open a unit or item view sheet before running this script.') +end + +local screen = dfhack.gui.getDFViewscreen() +local is_unit_sheet = dfhack.gui.matchFocusString('dwarfmode/ViewSheets/UNIT', screen) +local is_item_sheet = dfhack.gui.matchFocusString('dwarfmode/ViewSheets/ITEM', screen) + +local subject, flavor_type, text +if is_unit_sheet then + subject, flavor_type, text = get_unit_flavor_text(view_sheets) +elseif is_item_sheet then + subject, flavor_type, text = get_item_flavor_text(view_sheets) +else + clear_output_file() + qerror('Open a unit or item view sheet before running this script.') +end + +local ok, err = pcall(dfhack.filesystem.mkdir_recursive, folder) +if not ok then + qerror(('Failed to create folder "%s": %s'):format(folder, err)) +end + +local file, open_err = io.open(filepath, 'w') +if not file then + qerror(('Failed to open file "%s" for writing: %s'):format(filepath, open_err)) +end + +file:write(text) +file:close() + +local name +if is_unit_sheet then + name = dfhack.df2console(dfhack.units.getReadableName(subject)) +else + name = dfhack.df2console(dfhack.items.getDescription(subject, 0, true)) +end + +print(('Wrote %s flavor text for %s to "%s".'):format(flavor_type, name, filepath)) diff --git a/flavor-text.rst b/flavor-text.rst new file mode 100644 index 000000000..9a6a5a4c0 --- /dev/null +++ b/flavor-text.rst @@ -0,0 +1,27 @@ +flavor-text +=========== + +Overview +-------- +The ``flavor-text`` script writes the currently viewed unit or item flavor text to +``flavor text/read flavor.txt``. + +Usage +----- +Run the script from DFHack: + +:: + + flavor-text + +Notes +----- +- The file is overwritten each time the script runs. +- If the wrong window is open, the script clears the output file. +- Supported unit tabs: + - Health (Status/Wounds/Treatment/History/Description) + - Personality (Traits/Values/Preferences/Needs) +- Supported item window: item view sheets. +- You must use your own text-to-speech (TTS) program to read the output. + On Windows 11, Voice Attack works well, and a profile with the launch command + can be included for others to use. From b2c63f5fb4f46110f927174de372ba9ee172c617 Mon Sep 17 00:00:00 2001 From: unboundlopez <47876628+unboundlopez@users.noreply.github.com> Date: Sun, 25 Jan 2026 17:25:08 -0600 Subject: [PATCH 2/9] Delete flavor-text.lua --- flavor-text.lua | 176 ------------------------------------------------ 1 file changed, 176 deletions(-) delete mode 100644 flavor-text.lua diff --git a/flavor-text.lua b/flavor-text.lua deleted file mode 100644 index 9d171e9b7..000000000 --- a/flavor-text.lua +++ /dev/null @@ -1,176 +0,0 @@ --- Writes the currently viewed unit or item flavor text to a file. --- --- Usage: --- flavor-text - -local folder = 'flavor text' -local filename = 'read flavor.txt' -local filepath = folder .. '/' .. filename - -local function clear_output_file() - local ok, err = pcall(dfhack.filesystem.mkdir_recursive, folder) - if not ok then - qerror(('Failed to create folder "%s": %s'):format(folder, err)) - end - local file, open_err = io.open(filepath, 'w') - if not file then - qerror(('Failed to open file "%s" for writing: %s'):format(filepath, open_err)) - end - file:write('') - file:close() -end - -local function reformat(str) - local cleaned = str:gsub('%[B%]', '') - :gsub('%[P%]', '') - :gsub('%[R%]', '') - :gsub('%[C:%d+:%d+:%d+%]', '') - :gsub('%s+', ' ') - :gsub('^%s+', '') - :gsub('%s+$', '') - return cleaned -end - -local function collect_lines(entries) - local lines = {} - for _, entry in ipairs(entries) do - if entry.value ~= '' then - local cleaned = reformat(dfhack.df2utf(entry.value)) - if cleaned ~= '' then - table.insert(lines, cleaned) - end - end - end - return lines -end - -local function get_health_text(view_sheets) - if #view_sheets.unit_health_raw_str == 0 then - return nil - end - local lines = collect_lines(view_sheets.unit_health_raw_str) - if #lines == 0 then - return nil - end - return table.concat(lines, '\n') -end - -local function get_personality_text(view_sheets) - if #view_sheets.personality_raw_str == 0 then - return nil - end - local lines = collect_lines(view_sheets.personality_raw_str) - if #lines == 0 then - return nil - end - return table.concat(lines, '\n') -end - -local UNIT_SHEET_SUBTAB = { - HEALTH = 2, - PERSONALITY = 10, -} - -local HEALTH_ACTIVE_TAB = { - STATUS = 0, - WOUNDS = 1, - TREATMENT = 2, - HISTORY = 3, - DESCRIPTION = 4, -} - -local PERSONALITY_ACTIVE_TAB = { - TRAITS = 0, - VALUES = 1, - PREFERENCES = 2, - NEEDS = 3, -} - -local function get_unit_flavor_text(view_sheets) - local unit = df.unit.find(view_sheets.active_id) - if not unit then - qerror('Unable to resolve the active unit.') - end - - if view_sheets.active_sub_tab == UNIT_SHEET_SUBTAB.HEALTH then - local health_text = get_health_text(view_sheets) - if health_text then - return unit, 'Health', health_text - end - clear_output_file() - qerror('No text found on the Health tab.') - end - - if view_sheets.active_sub_tab == UNIT_SHEET_SUBTAB.PERSONALITY - and (view_sheets.personality_active_tab == PERSONALITY_ACTIVE_TAB.TRAITS - or view_sheets.personality_active_tab == PERSONALITY_ACTIVE_TAB.VALUES - or view_sheets.personality_active_tab == PERSONALITY_ACTIVE_TAB.PREFERENCES - or view_sheets.personality_active_tab == PERSONALITY_ACTIVE_TAB.NEEDS) - then - local text = get_personality_text(view_sheets) - if text then - return unit, 'Personality', text - end - clear_output_file() - qerror('No text found on the Personality subtab (Traits/Values/Preferences/Needs).') - end - - clear_output_file() - qerror('Open Health, Personality or an item window before running this script.') -end - -local function get_item_flavor_text(view_sheets) - local item = dfhack.gui.getSelectedItem(true) - if not item then - qerror('Select an item or open an item view sheet before running this script.') - end - - local description = view_sheets.raw_description or '' - if description == '' then - qerror('No item description text found on the item view sheet.') - end - - return item, 'Item', reformat(dfhack.df2utf(description)) -end - -local view_sheets = df.global.game.main_interface.view_sheets -if not view_sheets.open then - clear_output_file() - qerror('Open a unit or item view sheet before running this script.') -end - -local screen = dfhack.gui.getDFViewscreen() -local is_unit_sheet = dfhack.gui.matchFocusString('dwarfmode/ViewSheets/UNIT', screen) -local is_item_sheet = dfhack.gui.matchFocusString('dwarfmode/ViewSheets/ITEM', screen) - -local subject, flavor_type, text -if is_unit_sheet then - subject, flavor_type, text = get_unit_flavor_text(view_sheets) -elseif is_item_sheet then - subject, flavor_type, text = get_item_flavor_text(view_sheets) -else - clear_output_file() - qerror('Open a unit or item view sheet before running this script.') -end - -local ok, err = pcall(dfhack.filesystem.mkdir_recursive, folder) -if not ok then - qerror(('Failed to create folder "%s": %s'):format(folder, err)) -end - -local file, open_err = io.open(filepath, 'w') -if not file then - qerror(('Failed to open file "%s" for writing: %s'):format(filepath, open_err)) -end - -file:write(text) -file:close() - -local name -if is_unit_sheet then - name = dfhack.df2console(dfhack.units.getReadableName(subject)) -else - name = dfhack.df2console(dfhack.items.getDescription(subject, 0, true)) -end - -print(('Wrote %s flavor text for %s to "%s".'):format(flavor_type, name, filepath)) From 178cfe15e16dc9baf1a5591bdca5b6d9590931b2 Mon Sep 17 00:00:00 2001 From: unboundlopez <47876628+unboundlopez@users.noreply.github.com> Date: Sun, 25 Jan 2026 17:25:24 -0600 Subject: [PATCH 3/9] Delete flavor-text.rst --- flavor-text.rst | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 flavor-text.rst diff --git a/flavor-text.rst b/flavor-text.rst deleted file mode 100644 index 9a6a5a4c0..000000000 --- a/flavor-text.rst +++ /dev/null @@ -1,27 +0,0 @@ -flavor-text -=========== - -Overview --------- -The ``flavor-text`` script writes the currently viewed unit or item flavor text to -``flavor text/read flavor.txt``. - -Usage ------ -Run the script from DFHack: - -:: - - flavor-text - -Notes ------ -- The file is overwritten each time the script runs. -- If the wrong window is open, the script clears the output file. -- Supported unit tabs: - - Health (Status/Wounds/Treatment/History/Description) - - Personality (Traits/Values/Preferences/Needs) -- Supported item window: item view sheets. -- You must use your own text-to-speech (TTS) program to read the output. - On Windows 11, Voice Attack works well, and a profile with the launch command - can be included for others to use. From 6ae37b2ebd782720f026da1216d548aacb71f828 Mon Sep 17 00:00:00 2001 From: unboundlopez <47876628+unboundlopez@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:36:54 -0600 Subject: [PATCH 4/9] Add files via upload --- hauling-route-search-filter.lua | 306 ++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 hauling-route-search-filter.lua diff --git a/hauling-route-search-filter.lua b/hauling-route-search-filter.lua new file mode 100644 index 000000000..56140ce5d --- /dev/null +++ b/hauling-route-search-filter.lua @@ -0,0 +1,306 @@ +-- Search/filter hauling routes from the Hauling menu. +--[====[ + +gui/hauling-search +================== +Activate in the :guilabel:`Hauling` menu (press :kbd:`h`) to +filter the native hauling route list by name or id. The filter +hides non-matching routes in the in-game list and restores the +full list when cleared. + +]====] +--@ module = true + +local overlay = require 'plugins.overlay' +local widgets = require 'gui.widgets' + +local last_filter = '' +local STOP_KEY = {} + +local function safe_field(obj, field) + local ok, value = pcall(function() return obj[field] end) + if ok then + return value + end + return nil +end + +local function resolve_route(hauling, route_ref) + if not hauling or not route_ref then return nil end + local name = safe_field(route_ref, 'name') + local id = safe_field(route_ref, 'id') + if name ~= nil or id ~= nil then + return route_ref + end + local route_id = safe_field(route_ref, 'route_id') + if route_id ~= nil then + local routes = safe_field(hauling, 'routes') + if routes then return routes[route_id] end + end + return nil +end + +local function resolve_route_id(hauling, route_ref, stop_ref) + local route = resolve_route(hauling, route_ref) + if route and route.id ~= nil then + return route.id + end + local stop_route_id = safe_field(stop_ref, 'route_id') + if stop_route_id ~= nil then + return stop_route_id + end + return nil +end + +local function get_route_name(route) + if not route then return 'Route ?' end + return route.name and #route.name > 0 and route.name or ('Route '..route.id) +end + +local function is_match(filter, route) + if not route then return false end + if filter == '' then return true end + local needle = filter:lower() + local name = get_route_name(route):lower() + if name:find(needle, 1, true) then return true end + if tostring(route.id):find(needle, 1, true) then return true end + return false +end + +local function build_matching_route_ids(hauling, filter) + local matching_route_ids = {} + local routes = safe_field(hauling, 'routes') + if not routes then return matching_route_ids end + for i = 0, #routes - 1 do + local route = routes[i] + if is_match(filter, route) then + if route and route.id ~= nil then + matching_route_ids[route.id] = true + end + end + end + return matching_route_ids +end + +local function snapshot_rows(hauling) + local view_routes = safe_field(hauling, 'view_routes') + local view_stops = safe_field(hauling, 'view_stops') + if not view_routes or not view_stops then return nil end + local rows = {} + for i = 0, #view_routes - 1 do + local route_ref = view_routes[i] + local stop_ref = view_stops[i] + local route_id = resolve_route_id(hauling, route_ref, stop_ref) + local stop_id = safe_field(stop_ref, 'id') + table.insert(rows, { + route=route_ref, + stop=stop_ref, + route_id=route_id, + stop_id=stop_id, + }) + end + return rows +end + +local function snapshot_routes(hauling) + local routes = safe_field(hauling, 'routes') + if not routes then return nil end + local rows = {} + for i = 0, #routes - 1 do + local route = routes[i] + if route then + table.insert(rows, { + route=route, + stop=nil, + route_id=route.id, + stop_id=nil, + }) + local stops = safe_field(route, 'stops') + if stops then + for j = 0, #stops - 1 do + local stop = stops[j] + table.insert(rows, { + route=route, + stop=stop, + route_id=route.id, + stop_id=safe_field(stop, 'id'), + }) + end + end + end + end + return rows +end + +local function get_route_signature(hauling) + local routes = safe_field(hauling, 'routes') + if not routes then return nil end + local parts = {} + for i = 0, #routes - 1 do + local route = routes[i] + local id = route and route.id or 'nil' + local stops = route and safe_field(route, 'stops') + local stop_count = stops and #stops or 0 + table.insert(parts, tostring(id) .. ':' .. tostring(stop_count)) + end + return table.concat(parts, '|') +end + +local function rebuild_rows(hauling, rows) + local view_routes = safe_field(hauling, 'view_routes') + local view_stops = safe_field(hauling, 'view_stops') + if not view_routes or not view_stops then return end + view_routes:resize(0) + view_stops:resize(0) + for _, row in ipairs(rows) do + view_routes:insert('#', row.route) + view_stops:insert('#', row.stop) + end +end + +local function merge_rows(existing_rows, hauling) + local view_routes = safe_field(hauling, 'view_routes') + local view_stops = safe_field(hauling, 'view_stops') + if not view_routes or not view_stops then return existing_rows end + local seen = {} + for idx, row in ipairs(existing_rows) do + local route_key = row.route_id or row.route or idx + local stop_key = row.stop_id or row.stop or STOP_KEY + seen[tostring(route_key) .. ':' .. tostring(stop_key)] = true + end + for i = 0, #view_routes - 1 do + local route_ref = view_routes[i] + local stop_ref = view_stops[i] + local route_id = resolve_route_id(hauling, route_ref, stop_ref) + local stop_id = safe_field(stop_ref, 'id') + local route_key = route_id or route_ref or i + local stop_key = stop_id or stop_ref or STOP_KEY + local key = tostring(route_key) .. ':' .. tostring(stop_key) + if not seen[key] then + table.insert(existing_rows, { + route=route_ref, + stop=stop_ref, + route_id=route_id, + stop_id=stop_id, + }) + seen[key] = true + end + end + return existing_rows +end + +HaulingRouteFilterOverlay = defclass(HaulingRouteFilterOverlay, overlay.OverlayWidget) +HaulingRouteFilterOverlay.ATTRS{ + desc='Adds an inline filter box to the hauling routes list.', + default_enabled=true, + default_pos={x=8, y=6}, + frame={w=46, h=1}, + viewscreens='dwarfmode/Hauling', +} + +function HaulingRouteFilterOverlay:init() + self.hauling = df.global.plotinfo.hauling + self:addviews{ + widgets.Panel{ + subviews={ + widgets.EditField{ + view_id='filter', + frame={t=0, l=1, r=1}, + key='CUSTOM_ALT_S', + label_text='Filter: ', + text=last_filter, + on_change=self:callback('on_filter_change'), + }, + }, + }, + } +end + +function HaulingRouteFilterOverlay:overlay_onupdate() + if self.filter_text then + self:apply_filter(self.filter_text) + end +end + +function HaulingRouteFilterOverlay:snapshot_rows() + return snapshot_rows(self.hauling) +end + +function HaulingRouteFilterOverlay:restore_rows() + if not self.unfiltered_rows then return end + local refreshed = snapshot_routes(self.hauling) + if refreshed then + self.unfiltered_rows = refreshed + end + self.unfiltered_rows = merge_rows(self.unfiltered_rows, self.hauling) + rebuild_rows(self.hauling, self.unfiltered_rows) + self.unfiltered_rows = nil + self.route_signature = nil +end + +function HaulingRouteFilterOverlay:apply_filter(filter) + if filter == '' then + self:restore_rows() + return + end + if not self.unfiltered_rows then + self.unfiltered_rows = self:snapshot_rows() or snapshot_routes(self.hauling) + self.route_signature = get_route_signature(self.hauling) + else + local signature = get_route_signature(self.hauling) + if signature and signature ~= self.route_signature then + local refreshed = snapshot_routes(self.hauling) + if refreshed then + self.unfiltered_rows = refreshed + self.route_signature = signature + end + end + end + if not self.unfiltered_rows then return end + local matching_route_ids = build_matching_route_ids(self.hauling, filter) + local filtered = {} + for _, row in ipairs(self.unfiltered_rows) do + local route_id = row.route_id or resolve_route_id(self.hauling, row.route, row.stop) + local resolved_route = resolve_route(self.hauling, row.route) + local is_match_id = route_id ~= nil and matching_route_ids[route_id] + local is_match_route = is_match(filter, resolved_route) + if is_match_id or is_match_route then + table.insert(filtered, row) + end + end + rebuild_rows(self.hauling, filtered) +end + +function HaulingRouteFilterOverlay:on_filter_change(text) + self.filter_text = text + last_filter = text + self:apply_filter(text) +end + +function HaulingRouteFilterOverlay:overlay_onenable() + local filter = self.subviews.filter + if filter then + filter:setFocus(false) + end +end + +function HaulingRouteFilterOverlay:onInput(keys) + if keys.SELECT then return false end + return HaulingRouteFilterOverlay.super.onInput(self, keys) +end + +function HaulingRouteFilterOverlay:overlay_ondisable() + self:restore_rows() +end + +OVERLAY_WIDGETS = {filter=HaulingRouteFilterOverlay} + +if dfhack_flags.module then + return +end + +if not dfhack.gui.matchFocusString('dwarfmode/Hauling') then + qerror('This script must be run from the Hauling screen.') +end + +overlay.overlay_command({'enable', 'hauling-search.filter'}) From a5aa756fc83994899f36ffb4eb31a7046fb3a775 Mon Sep 17 00:00:00 2001 From: unboundlopez <47876628+unboundlopez@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:37:17 -0600 Subject: [PATCH 5/9] Add files via upload --- docs/hauling-route-search-filter.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/hauling-route-search-filter.rst diff --git a/docs/hauling-route-search-filter.rst b/docs/hauling-route-search-filter.rst new file mode 100644 index 000000000..5e19cc548 --- /dev/null +++ b/docs/hauling-route-search-filter.rst @@ -0,0 +1,12 @@ +gui/hauling-search +================== + +.. dfhack-tool:: + :summary: Filter hauling routes in the Hauling menu with an inline search box. + :tags: dfhack interface + +This overlay adds a :guilabel:`Filter:` field to the :guilabel:`Hauling` menu +(opened with :kbd:`h`) and filters the hauling route list as you type. +Activate the filter by clicking the field or pressing :kbd:`Alt`\ +\ :kbd:`S`. +Clearing the field restores the full list, including any routes or stops added +while the filter was active. \ No newline at end of file From d20254d24d024e957669593ba1a201dd0232192c Mon Sep 17 00:00:00 2001 From: unboundlopez <47876628+unboundlopez@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:38:41 -0600 Subject: [PATCH 6/9] Update hauling-route-search-filter.rst --- docs/hauling-route-search-filter.rst | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/hauling-route-search-filter.rst b/docs/hauling-route-search-filter.rst index 5e19cc548..914f95bb9 100644 --- a/docs/hauling-route-search-filter.rst +++ b/docs/hauling-route-search-filter.rst @@ -1,12 +1,8 @@ -gui/hauling-search -================== - -.. dfhack-tool:: :summary: Filter hauling routes in the Hauling menu with an inline search box. - :tags: dfhack interface This overlay adds a :guilabel:`Filter:` field to the :guilabel:`Hauling` menu (opened with :kbd:`h`) and filters the hauling route list as you type. Activate the filter by clicking the field or pressing :kbd:`Alt`\ +\ :kbd:`S`. Clearing the field restores the full list, including any routes or stops added -while the filter was active. \ No newline at end of file + +while the filter was active. From 3969cddbd70d72198aa603564c62dbcdbdc24e25 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:40:34 +0000 Subject: [PATCH 7/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/hauling-route-search-filter.rst | 16 +- hauling-route-search-filter.lua | 612 +++++++++++++-------------- 2 files changed, 314 insertions(+), 314 deletions(-) diff --git a/docs/hauling-route-search-filter.rst b/docs/hauling-route-search-filter.rst index 914f95bb9..3fc7b484d 100644 --- a/docs/hauling-route-search-filter.rst +++ b/docs/hauling-route-search-filter.rst @@ -1,8 +1,8 @@ - :summary: Filter hauling routes in the Hauling menu with an inline search box. - -This overlay adds a :guilabel:`Filter:` field to the :guilabel:`Hauling` menu -(opened with :kbd:`h`) and filters the hauling route list as you type. -Activate the filter by clicking the field or pressing :kbd:`Alt`\ +\ :kbd:`S`. -Clearing the field restores the full list, including any routes or stops added - -while the filter was active. + :summary: Filter hauling routes in the Hauling menu with an inline search box. + +This overlay adds a :guilabel:`Filter:` field to the :guilabel:`Hauling` menu +(opened with :kbd:`h`) and filters the hauling route list as you type. +Activate the filter by clicking the field or pressing :kbd:`Alt`\ +\ :kbd:`S`. +Clearing the field restores the full list, including any routes or stops added + +while the filter was active. diff --git a/hauling-route-search-filter.lua b/hauling-route-search-filter.lua index 56140ce5d..94d53b00e 100644 --- a/hauling-route-search-filter.lua +++ b/hauling-route-search-filter.lua @@ -1,306 +1,306 @@ --- Search/filter hauling routes from the Hauling menu. ---[====[ - -gui/hauling-search -================== -Activate in the :guilabel:`Hauling` menu (press :kbd:`h`) to -filter the native hauling route list by name or id. The filter -hides non-matching routes in the in-game list and restores the -full list when cleared. - -]====] ---@ module = true - -local overlay = require 'plugins.overlay' -local widgets = require 'gui.widgets' - -local last_filter = '' -local STOP_KEY = {} - -local function safe_field(obj, field) - local ok, value = pcall(function() return obj[field] end) - if ok then - return value - end - return nil -end - -local function resolve_route(hauling, route_ref) - if not hauling or not route_ref then return nil end - local name = safe_field(route_ref, 'name') - local id = safe_field(route_ref, 'id') - if name ~= nil or id ~= nil then - return route_ref - end - local route_id = safe_field(route_ref, 'route_id') - if route_id ~= nil then - local routes = safe_field(hauling, 'routes') - if routes then return routes[route_id] end - end - return nil -end - -local function resolve_route_id(hauling, route_ref, stop_ref) - local route = resolve_route(hauling, route_ref) - if route and route.id ~= nil then - return route.id - end - local stop_route_id = safe_field(stop_ref, 'route_id') - if stop_route_id ~= nil then - return stop_route_id - end - return nil -end - -local function get_route_name(route) - if not route then return 'Route ?' end - return route.name and #route.name > 0 and route.name or ('Route '..route.id) -end - -local function is_match(filter, route) - if not route then return false end - if filter == '' then return true end - local needle = filter:lower() - local name = get_route_name(route):lower() - if name:find(needle, 1, true) then return true end - if tostring(route.id):find(needle, 1, true) then return true end - return false -end - -local function build_matching_route_ids(hauling, filter) - local matching_route_ids = {} - local routes = safe_field(hauling, 'routes') - if not routes then return matching_route_ids end - for i = 0, #routes - 1 do - local route = routes[i] - if is_match(filter, route) then - if route and route.id ~= nil then - matching_route_ids[route.id] = true - end - end - end - return matching_route_ids -end - -local function snapshot_rows(hauling) - local view_routes = safe_field(hauling, 'view_routes') - local view_stops = safe_field(hauling, 'view_stops') - if not view_routes or not view_stops then return nil end - local rows = {} - for i = 0, #view_routes - 1 do - local route_ref = view_routes[i] - local stop_ref = view_stops[i] - local route_id = resolve_route_id(hauling, route_ref, stop_ref) - local stop_id = safe_field(stop_ref, 'id') - table.insert(rows, { - route=route_ref, - stop=stop_ref, - route_id=route_id, - stop_id=stop_id, - }) - end - return rows -end - -local function snapshot_routes(hauling) - local routes = safe_field(hauling, 'routes') - if not routes then return nil end - local rows = {} - for i = 0, #routes - 1 do - local route = routes[i] - if route then - table.insert(rows, { - route=route, - stop=nil, - route_id=route.id, - stop_id=nil, - }) - local stops = safe_field(route, 'stops') - if stops then - for j = 0, #stops - 1 do - local stop = stops[j] - table.insert(rows, { - route=route, - stop=stop, - route_id=route.id, - stop_id=safe_field(stop, 'id'), - }) - end - end - end - end - return rows -end - -local function get_route_signature(hauling) - local routes = safe_field(hauling, 'routes') - if not routes then return nil end - local parts = {} - for i = 0, #routes - 1 do - local route = routes[i] - local id = route and route.id or 'nil' - local stops = route and safe_field(route, 'stops') - local stop_count = stops and #stops or 0 - table.insert(parts, tostring(id) .. ':' .. tostring(stop_count)) - end - return table.concat(parts, '|') -end - -local function rebuild_rows(hauling, rows) - local view_routes = safe_field(hauling, 'view_routes') - local view_stops = safe_field(hauling, 'view_stops') - if not view_routes or not view_stops then return end - view_routes:resize(0) - view_stops:resize(0) - for _, row in ipairs(rows) do - view_routes:insert('#', row.route) - view_stops:insert('#', row.stop) - end -end - -local function merge_rows(existing_rows, hauling) - local view_routes = safe_field(hauling, 'view_routes') - local view_stops = safe_field(hauling, 'view_stops') - if not view_routes or not view_stops then return existing_rows end - local seen = {} - for idx, row in ipairs(existing_rows) do - local route_key = row.route_id or row.route or idx - local stop_key = row.stop_id or row.stop or STOP_KEY - seen[tostring(route_key) .. ':' .. tostring(stop_key)] = true - end - for i = 0, #view_routes - 1 do - local route_ref = view_routes[i] - local stop_ref = view_stops[i] - local route_id = resolve_route_id(hauling, route_ref, stop_ref) - local stop_id = safe_field(stop_ref, 'id') - local route_key = route_id or route_ref or i - local stop_key = stop_id or stop_ref or STOP_KEY - local key = tostring(route_key) .. ':' .. tostring(stop_key) - if not seen[key] then - table.insert(existing_rows, { - route=route_ref, - stop=stop_ref, - route_id=route_id, - stop_id=stop_id, - }) - seen[key] = true - end - end - return existing_rows -end - -HaulingRouteFilterOverlay = defclass(HaulingRouteFilterOverlay, overlay.OverlayWidget) -HaulingRouteFilterOverlay.ATTRS{ - desc='Adds an inline filter box to the hauling routes list.', - default_enabled=true, - default_pos={x=8, y=6}, - frame={w=46, h=1}, - viewscreens='dwarfmode/Hauling', -} - -function HaulingRouteFilterOverlay:init() - self.hauling = df.global.plotinfo.hauling - self:addviews{ - widgets.Panel{ - subviews={ - widgets.EditField{ - view_id='filter', - frame={t=0, l=1, r=1}, - key='CUSTOM_ALT_S', - label_text='Filter: ', - text=last_filter, - on_change=self:callback('on_filter_change'), - }, - }, - }, - } -end - -function HaulingRouteFilterOverlay:overlay_onupdate() - if self.filter_text then - self:apply_filter(self.filter_text) - end -end - -function HaulingRouteFilterOverlay:snapshot_rows() - return snapshot_rows(self.hauling) -end - -function HaulingRouteFilterOverlay:restore_rows() - if not self.unfiltered_rows then return end - local refreshed = snapshot_routes(self.hauling) - if refreshed then - self.unfiltered_rows = refreshed - end - self.unfiltered_rows = merge_rows(self.unfiltered_rows, self.hauling) - rebuild_rows(self.hauling, self.unfiltered_rows) - self.unfiltered_rows = nil - self.route_signature = nil -end - -function HaulingRouteFilterOverlay:apply_filter(filter) - if filter == '' then - self:restore_rows() - return - end - if not self.unfiltered_rows then - self.unfiltered_rows = self:snapshot_rows() or snapshot_routes(self.hauling) - self.route_signature = get_route_signature(self.hauling) - else - local signature = get_route_signature(self.hauling) - if signature and signature ~= self.route_signature then - local refreshed = snapshot_routes(self.hauling) - if refreshed then - self.unfiltered_rows = refreshed - self.route_signature = signature - end - end - end - if not self.unfiltered_rows then return end - local matching_route_ids = build_matching_route_ids(self.hauling, filter) - local filtered = {} - for _, row in ipairs(self.unfiltered_rows) do - local route_id = row.route_id or resolve_route_id(self.hauling, row.route, row.stop) - local resolved_route = resolve_route(self.hauling, row.route) - local is_match_id = route_id ~= nil and matching_route_ids[route_id] - local is_match_route = is_match(filter, resolved_route) - if is_match_id or is_match_route then - table.insert(filtered, row) - end - end - rebuild_rows(self.hauling, filtered) -end - -function HaulingRouteFilterOverlay:on_filter_change(text) - self.filter_text = text - last_filter = text - self:apply_filter(text) -end - -function HaulingRouteFilterOverlay:overlay_onenable() - local filter = self.subviews.filter - if filter then - filter:setFocus(false) - end -end - -function HaulingRouteFilterOverlay:onInput(keys) - if keys.SELECT then return false end - return HaulingRouteFilterOverlay.super.onInput(self, keys) -end - -function HaulingRouteFilterOverlay:overlay_ondisable() - self:restore_rows() -end - -OVERLAY_WIDGETS = {filter=HaulingRouteFilterOverlay} - -if dfhack_flags.module then - return -end - -if not dfhack.gui.matchFocusString('dwarfmode/Hauling') then - qerror('This script must be run from the Hauling screen.') -end - -overlay.overlay_command({'enable', 'hauling-search.filter'}) +-- Search/filter hauling routes from the Hauling menu. +--[====[ + +gui/hauling-search +================== +Activate in the :guilabel:`Hauling` menu (press :kbd:`h`) to +filter the native hauling route list by name or id. The filter +hides non-matching routes in the in-game list and restores the +full list when cleared. + +]====] +--@ module = true + +local overlay = require 'plugins.overlay' +local widgets = require 'gui.widgets' + +local last_filter = '' +local STOP_KEY = {} + +local function safe_field(obj, field) + local ok, value = pcall(function() return obj[field] end) + if ok then + return value + end + return nil +end + +local function resolve_route(hauling, route_ref) + if not hauling or not route_ref then return nil end + local name = safe_field(route_ref, 'name') + local id = safe_field(route_ref, 'id') + if name ~= nil or id ~= nil then + return route_ref + end + local route_id = safe_field(route_ref, 'route_id') + if route_id ~= nil then + local routes = safe_field(hauling, 'routes') + if routes then return routes[route_id] end + end + return nil +end + +local function resolve_route_id(hauling, route_ref, stop_ref) + local route = resolve_route(hauling, route_ref) + if route and route.id ~= nil then + return route.id + end + local stop_route_id = safe_field(stop_ref, 'route_id') + if stop_route_id ~= nil then + return stop_route_id + end + return nil +end + +local function get_route_name(route) + if not route then return 'Route ?' end + return route.name and #route.name > 0 and route.name or ('Route '..route.id) +end + +local function is_match(filter, route) + if not route then return false end + if filter == '' then return true end + local needle = filter:lower() + local name = get_route_name(route):lower() + if name:find(needle, 1, true) then return true end + if tostring(route.id):find(needle, 1, true) then return true end + return false +end + +local function build_matching_route_ids(hauling, filter) + local matching_route_ids = {} + local routes = safe_field(hauling, 'routes') + if not routes then return matching_route_ids end + for i = 0, #routes - 1 do + local route = routes[i] + if is_match(filter, route) then + if route and route.id ~= nil then + matching_route_ids[route.id] = true + end + end + end + return matching_route_ids +end + +local function snapshot_rows(hauling) + local view_routes = safe_field(hauling, 'view_routes') + local view_stops = safe_field(hauling, 'view_stops') + if not view_routes or not view_stops then return nil end + local rows = {} + for i = 0, #view_routes - 1 do + local route_ref = view_routes[i] + local stop_ref = view_stops[i] + local route_id = resolve_route_id(hauling, route_ref, stop_ref) + local stop_id = safe_field(stop_ref, 'id') + table.insert(rows, { + route=route_ref, + stop=stop_ref, + route_id=route_id, + stop_id=stop_id, + }) + end + return rows +end + +local function snapshot_routes(hauling) + local routes = safe_field(hauling, 'routes') + if not routes then return nil end + local rows = {} + for i = 0, #routes - 1 do + local route = routes[i] + if route then + table.insert(rows, { + route=route, + stop=nil, + route_id=route.id, + stop_id=nil, + }) + local stops = safe_field(route, 'stops') + if stops then + for j = 0, #stops - 1 do + local stop = stops[j] + table.insert(rows, { + route=route, + stop=stop, + route_id=route.id, + stop_id=safe_field(stop, 'id'), + }) + end + end + end + end + return rows +end + +local function get_route_signature(hauling) + local routes = safe_field(hauling, 'routes') + if not routes then return nil end + local parts = {} + for i = 0, #routes - 1 do + local route = routes[i] + local id = route and route.id or 'nil' + local stops = route and safe_field(route, 'stops') + local stop_count = stops and #stops or 0 + table.insert(parts, tostring(id) .. ':' .. tostring(stop_count)) + end + return table.concat(parts, '|') +end + +local function rebuild_rows(hauling, rows) + local view_routes = safe_field(hauling, 'view_routes') + local view_stops = safe_field(hauling, 'view_stops') + if not view_routes or not view_stops then return end + view_routes:resize(0) + view_stops:resize(0) + for _, row in ipairs(rows) do + view_routes:insert('#', row.route) + view_stops:insert('#', row.stop) + end +end + +local function merge_rows(existing_rows, hauling) + local view_routes = safe_field(hauling, 'view_routes') + local view_stops = safe_field(hauling, 'view_stops') + if not view_routes or not view_stops then return existing_rows end + local seen = {} + for idx, row in ipairs(existing_rows) do + local route_key = row.route_id or row.route or idx + local stop_key = row.stop_id or row.stop or STOP_KEY + seen[tostring(route_key) .. ':' .. tostring(stop_key)] = true + end + for i = 0, #view_routes - 1 do + local route_ref = view_routes[i] + local stop_ref = view_stops[i] + local route_id = resolve_route_id(hauling, route_ref, stop_ref) + local stop_id = safe_field(stop_ref, 'id') + local route_key = route_id or route_ref or i + local stop_key = stop_id or stop_ref or STOP_KEY + local key = tostring(route_key) .. ':' .. tostring(stop_key) + if not seen[key] then + table.insert(existing_rows, { + route=route_ref, + stop=stop_ref, + route_id=route_id, + stop_id=stop_id, + }) + seen[key] = true + end + end + return existing_rows +end + +HaulingRouteFilterOverlay = defclass(HaulingRouteFilterOverlay, overlay.OverlayWidget) +HaulingRouteFilterOverlay.ATTRS{ + desc='Adds an inline filter box to the hauling routes list.', + default_enabled=true, + default_pos={x=8, y=6}, + frame={w=46, h=1}, + viewscreens='dwarfmode/Hauling', +} + +function HaulingRouteFilterOverlay:init() + self.hauling = df.global.plotinfo.hauling + self:addviews{ + widgets.Panel{ + subviews={ + widgets.EditField{ + view_id='filter', + frame={t=0, l=1, r=1}, + key='CUSTOM_ALT_S', + label_text='Filter: ', + text=last_filter, + on_change=self:callback('on_filter_change'), + }, + }, + }, + } +end + +function HaulingRouteFilterOverlay:overlay_onupdate() + if self.filter_text then + self:apply_filter(self.filter_text) + end +end + +function HaulingRouteFilterOverlay:snapshot_rows() + return snapshot_rows(self.hauling) +end + +function HaulingRouteFilterOverlay:restore_rows() + if not self.unfiltered_rows then return end + local refreshed = snapshot_routes(self.hauling) + if refreshed then + self.unfiltered_rows = refreshed + end + self.unfiltered_rows = merge_rows(self.unfiltered_rows, self.hauling) + rebuild_rows(self.hauling, self.unfiltered_rows) + self.unfiltered_rows = nil + self.route_signature = nil +end + +function HaulingRouteFilterOverlay:apply_filter(filter) + if filter == '' then + self:restore_rows() + return + end + if not self.unfiltered_rows then + self.unfiltered_rows = self:snapshot_rows() or snapshot_routes(self.hauling) + self.route_signature = get_route_signature(self.hauling) + else + local signature = get_route_signature(self.hauling) + if signature and signature ~= self.route_signature then + local refreshed = snapshot_routes(self.hauling) + if refreshed then + self.unfiltered_rows = refreshed + self.route_signature = signature + end + end + end + if not self.unfiltered_rows then return end + local matching_route_ids = build_matching_route_ids(self.hauling, filter) + local filtered = {} + for _, row in ipairs(self.unfiltered_rows) do + local route_id = row.route_id or resolve_route_id(self.hauling, row.route, row.stop) + local resolved_route = resolve_route(self.hauling, row.route) + local is_match_id = route_id ~= nil and matching_route_ids[route_id] + local is_match_route = is_match(filter, resolved_route) + if is_match_id or is_match_route then + table.insert(filtered, row) + end + end + rebuild_rows(self.hauling, filtered) +end + +function HaulingRouteFilterOverlay:on_filter_change(text) + self.filter_text = text + last_filter = text + self:apply_filter(text) +end + +function HaulingRouteFilterOverlay:overlay_onenable() + local filter = self.subviews.filter + if filter then + filter:setFocus(false) + end +end + +function HaulingRouteFilterOverlay:onInput(keys) + if keys.SELECT then return false end + return HaulingRouteFilterOverlay.super.onInput(self, keys) +end + +function HaulingRouteFilterOverlay:overlay_ondisable() + self:restore_rows() +end + +OVERLAY_WIDGETS = {filter=HaulingRouteFilterOverlay} + +if dfhack_flags.module then + return +end + +if not dfhack.gui.matchFocusString('dwarfmode/Hauling') then + qerror('This script must be run from the Hauling screen.') +end + +overlay.overlay_command({'enable', 'hauling-search.filter'}) From 51fff2b3d7eb9725375cdeae9637ad9085ae7e8a Mon Sep 17 00:00:00 2001 From: unboundlopez <47876628+unboundlopez@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:41:36 -0600 Subject: [PATCH 8/9] Update hauling-route-search-filter.rst --- docs/hauling-route-search-filter.rst | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/docs/hauling-route-search-filter.rst b/docs/hauling-route-search-filter.rst index 3fc7b484d..41cb30dac 100644 --- a/docs/hauling-route-search-filter.rst +++ b/docs/hauling-route-search-filter.rst @@ -1,8 +1,3 @@ - :summary: Filter hauling routes in the Hauling menu with an inline search box. - -This overlay adds a :guilabel:`Filter:` field to the :guilabel:`Hauling` menu -(opened with :kbd:`h`) and filters the hauling route list as you type. -Activate the filter by clicking the field or pressing :kbd:`Alt`\ +\ :kbd:`S`. -Clearing the field restores the full list, including any routes or stops added - -while the filter was active. +Summary: +Add a search box to the Hauling menu to filter hauling routes. +This overlay adds a Filter field to the Hauling menu (opened with h). The list of hauling routes updates as you type. You can activate the filter by clicking the field or pressing Alt+S. Clearing the field shows the full list again, including any routes or stops added while the filter was active. From aa39cbfdfed4d92d15dfd0985976d392e1073ae7 Mon Sep 17 00:00:00 2001 From: unboundlopez <47876628+unboundlopez@users.noreply.github.com> Date: Wed, 28 Jan 2026 21:35:18 -0600 Subject: [PATCH 9/9] Update hauling-route-search-filter.lua prevent nil self access when the overlay enable hook fires without a widget instance --- hauling-route-search-filter.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hauling-route-search-filter.lua b/hauling-route-search-filter.lua index 94d53b00e..e149fbdf9 100644 --- a/hauling-route-search-filter.lua +++ b/hauling-route-search-filter.lua @@ -278,7 +278,8 @@ function HaulingRouteFilterOverlay:on_filter_change(text) end function HaulingRouteFilterOverlay:overlay_onenable() - local filter = self.subviews.filter + if not self then return end + local filter = self.subviews.filter if filter then filter:setFocus(false) end