Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/hauling-route-search-filter.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
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.
306 changes: 306 additions & 0 deletions hauling-route-search-filter.lua
Original file line number Diff line number Diff line change
@@ -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'})