From 6ebf01ed931586cd79040b7a98fd9d2e27904246 Mon Sep 17 00:00:00 2001 From: Nick Peterson Date: Sun, 1 Mar 2026 20:47:59 -0800 Subject: [PATCH] Add cross-mod recent items panel in titlebar When other mods (BA, RecipeBook, Cybersyn) broadcast item selections via the interop spec, show those items as clickable buttons in the FS titlebar. Clicking a recent item sets the selector and triggers a search. Max 5 items, newest first, deduplicated. Also implements the interop spec v1 (publish + subscribe) so FS broadcasts its own selections and receives from other mods. Co-Authored-By: Claude Opus 4.6 --- control.lua | 31 ++++++++++++- scripts/remote.lua | 102 +++++++++++++++++++++++++++++++++++++---- scripts/search-gui.lua | 68 +++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 11 deletions(-) diff --git a/control.lua b/control.lua index 2a0e384..292b645 100644 --- a/control.lua +++ b/control.lua @@ -8,7 +8,7 @@ Search = require "scripts.search" SearchResults = require "scripts.search-results" ResultLocation = require "scripts.result-location" SearchGui = require "scripts.search-gui" -require "scripts.remote" +Interop = require "scripts.remote" ---@alias ItemName string ---@alias EntityName string @@ -36,6 +36,7 @@ require "scripts.remote" ---@field searching_label LuaGuiElement label ---@field search_progressbar LuaGuiElement progressbar ---@field result_flow LuaGuiElement flow +---@field recent_items_flow LuaGuiElement flow ---@field highlighted_button? LuaGuiElement sprite-button ---@class (exact) PlayerData @@ -237,6 +238,19 @@ local function generate_item_to_entity_table() storage.item_to_entities = item_to_entities end +local function setup_interop_callback() + Interop.set_on_external_item_callback(function(player_index, item_name, source) + Interop.add_recent_item(player_index, item_name, source) + local player = game.get_player(player_index) + if player then + local player_data = storage.players[player_index] + if player_data and player_data.refs.frame.valid and player_data.refs.frame.visible then + SearchGui.update_recent_panel(player_data) + end + end + end) +end + local function on_init() ---@type table storage.players = {} @@ -244,8 +258,17 @@ local function on_init() storage.current_searches = {} ---@type boolean storage.multiple_surfaces = false + ---@type table + storage.recent_external_items = {} update_surface_count() generate_item_to_entity_table() + Interop.subscribe_to_events() + setup_interop_callback() +end + +local function on_load() + Interop.subscribe_to_events() + setup_interop_callback() end local function on_configuration_changed() @@ -263,11 +286,17 @@ local function on_configuration_changed() storage.current_searches = {} storage.multiple_surfaces = false + if not storage.recent_external_items then + storage.recent_external_items = {} + end update_surface_count() generate_item_to_entity_table() + Interop.subscribe_to_events() + setup_interop_callback() end Control.on_init = on_init +Control.on_load = on_load Control.on_configuration_changed = on_configuration_changed Control.events = { [defines.events.on_surface_created] = update_surface_count, diff --git a/scripts/remote.lua b/scripts/remote.lua index b3ca04c..028ae23 100644 --- a/scripts/remote.lua +++ b/scripts/remote.lua @@ -1,10 +1,92 @@ -remote.add_interface("factory-search", { - ---@param player LuaPlayer - ---@param search_value SignalID - search = function(player, search_value) - SearchGui.open(player, storage.players[player.index]) - local player_data = storage.players[player.index] - player_data.refs.item_select.elem_value = search_value - SearchGui.start_search(player, player_data) - end -}) +local remote_interface = {} + +local on_item_selected = script.generate_event_name() + +local on_external_item_callback = nil + +--- Open the search GUI for a player with the given item pre-selected. +---@param player LuaPlayer +---@param search_value SignalID +function remote_interface.search(player, search_value) + SearchGui.open(player, storage.players[player.index]) + local player_data = storage.players[player.index] + player_data.refs.item_select.elem_value = search_value + SearchGui.start_search(player, player_data) +end + +function remote_interface.get_on_item_selected() + return on_item_selected +end + +function remote_interface.interop_version() + return 1 +end + +remote.add_interface("factory-search", remote_interface) + +local function raise_item_selected(player_index, item_name) + script.raise_event(on_item_selected, { + player_index = player_index, + item_name = item_name, + }) +end + +local function subscribe_to_events() + for iface, functions in pairs(remote.interfaces) do + if iface == "factory-search" then goto continue end + + if functions["get_on_item_selected"] then + local event_id = remote.call(iface, "get_on_item_selected") + if event_id then + local source_iface = iface + script.on_event(event_id, function(e) + local player = game.get_player(e.player_index) + if not player then return end + local item_name = e.item_name + if item_name and (prototypes.item[item_name] or prototypes.fluid[item_name]) then + if on_external_item_callback then + on_external_item_callback(e.player_index, item_name, source_iface) + end + end + end) + log("FactorySearch: subscribed to " .. iface .. ".get_on_item_selected") + end + end + + ::continue:: + end +end + +local function add_recent_item(player_index, item_name, source) + if not storage.recent_external_items then + storage.recent_external_items = {} + end + local list = storage.recent_external_items[player_index] + if not list then + list = {} + storage.recent_external_items[player_index] = list + end + + -- Deduplicate: remove existing entry for this item + for i = #list, 1, -1 do + if list[i].item_name == item_name then + table.remove(list, i) + end + end + + table.insert(list, 1, { item_name = item_name, source = source }) + if #list > 5 then + list[6] = nil + end +end + +local function set_on_external_item_callback(cb) + on_external_item_callback = cb +end + +return { + raise_item_selected = raise_item_selected, + subscribe_to_events = subscribe_to_events, + add_recent_item = add_recent_item, + set_on_external_item_callback = set_on_external_item_callback, +} diff --git a/scripts/search-gui.lua b/scripts/search-gui.lua index cfdfced..ca5a1f6 100644 --- a/scripts/search-gui.lua +++ b/scripts/search-gui.lua @@ -1,3 +1,5 @@ +local interop = require("scripts.remote") + local SearchGui = {} ---@param signal SignalID @@ -390,6 +392,12 @@ function SearchGui.build(player) ignored_by_interaction = true, }, {type = "empty-widget", style = "fs_flib_titlebar_drag_handle", ignored_by_interaction = true}, + { + type = "flow", + direction = "horizontal", + ref = {"recent_items_flow"}, + style_mods = {horizontal_spacing = 2}, + }, { type = "sprite-button", style = "frame_action_button", @@ -668,6 +676,7 @@ function SearchGui.open(player, player_data) refs.frame.visible = true refs.frame.bring_to_front() player.set_shortcut_toggled("search-factory", true) + SearchGui.update_recent_panel(player_data) end ---@param player LuaPlayer @@ -791,6 +800,10 @@ function SearchGui.start_search(player, player_data, _, _, immediate) local elem_button = refs.item_select local item = elem_button.elem_value --[[@as SignalID]] if item then + -- Broadcast item/fluid selection to other mods (interop spec v1) + if item.name and (item.type == "item" or item.type == "fluid") then + interop.raise_item_selected(player.index, item.name) + end local force = player.force --[[@as LuaForce]] local state = generate_state(refs) local state_valid = is_valid_state(state) @@ -836,6 +849,61 @@ function SearchGui.sort_results_dropdown_changed(player, player_data) player_data.sort_results_by = sort_results_by_options[dropdown.selected_index] end +---@param player_data PlayerData +function SearchGui.update_recent_panel(player_data) + local flow = player_data.refs.recent_items_flow + if not flow or not flow.valid then return end + + flow.clear() + + local items = storage.recent_external_items and storage.recent_external_items[player_data.refs.frame.player_index] + if not items or #items == 0 then return end + + for _, entry in ipairs(items) do + local sprite_path + if prototypes.item[entry.item_name] then + sprite_path = "item/" .. entry.item_name + elseif prototypes.fluid[entry.item_name] then + sprite_path = "fluid/" .. entry.item_name + end + + if sprite_path then + local proto = prototypes.item[entry.item_name] or prototypes.fluid[entry.item_name] + local tooltip = proto and proto.localised_name or entry.item_name + gui.add(flow, { + { + type = "sprite-button", + sprite = sprite_path, + tooltip = tooltip, + style = "frame_action_button", + tags = { fs_recent_item_name = entry.item_name }, + handler = {[defines.events.on_gui_click] = SearchGui.on_recent_item_clicked}, + } + }) + end + end +end + +---@param player LuaPlayer +---@param player_data PlayerData +---@param element LuaGuiElement +function SearchGui.on_recent_item_clicked(player, player_data, element) + local item_name = element.tags.fs_recent_item_name + if not item_name then return end + + local sig_type + if prototypes.item[item_name] then + sig_type = "item" + elseif prototypes.fluid[item_name] then + sig_type = "fluid" + else + return + end + + player_data.refs.item_select.elem_value = { type = sig_type, name = item_name } + SearchGui.start_search(player, player_data) +end + gui.add_handlers(SearchGui, function(event, handler) local player = game.get_player(event.player_index) ---@cast player -?