diff --git a/data/init/dfhack.tools.init b/data/init/dfhack.tools.init index aab17ebbe..0887f80ba 100644 --- a/data/init/dfhack.tools.init +++ b/data/init/dfhack.tools.init @@ -89,6 +89,7 @@ enable automaterial # Other interface improvement tools enable \ + overlay \ confirm \ dwarfmonitor \ mousequery \ diff --git a/docs/Lua API.rst b/docs/Lua API.rst index 5b6a30d93..cfebdd792 100644 --- a/docs/Lua API.rst +++ b/docs/Lua API.rst @@ -3898,6 +3898,8 @@ gui.widgets This module implements some basic widgets based on the View infrastructure. +.. _widget: + Widget class ------------ diff --git a/docs/changelog.txt b/docs/changelog.txt index 9eb067d50..4465a0f12 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -34,6 +34,7 @@ changelog.txt uses a syntax similar to RST, with a few special sequences: # Future ## New Plugins +- `overlay`: plugin is transformed from a single line of text that runs `gui/launcher` on click to a fully-featured overlay injection framework. See `overlay-dev-guide` for details. ## Fixes - `automaterial`: fix the cursor jumping up a z level when clicking quickly after box select @@ -58,7 +59,6 @@ changelog.txt uses a syntax similar to RST, with a few special sequences: - UX: List widgets now have mouse-interactive scrollbars - UX: You can now hold down the mouse button on a scrollbar to make it scroll multiple times. - UX: You can now drag the scrollbar to scroll to a specific spot -- `overlay`: reduce the size of the "DFHack Launcher" button - Constructions module: ``findAtTile`` now uses a binary search intead of a linear search. - `spectate`: new ``auto-unpause`` option for auto-dismissal of announcement pause events (e.g. sieges). - `spectate`: new ``auto-disengage`` option for auto-disengagement of plugin through player interaction whilst unpaused. @@ -69,6 +69,7 @@ changelog.txt uses a syntax similar to RST, with a few special sequences: ## Documentation - `spectate`: improved documentation of features and functionality +- `overlay-dev-guide`: documentation and guide for injecting functionality into DF viewscreens from Lua scripts and creating overlay widgets ## API - ``Gui::anywhere_hotkey``: for plugin commands bound to keybindings that can be invoked on any screen diff --git a/docs/guides/index.rst b/docs/guides/index.rst index 96c5688d2..a3d9a0482 100644 --- a/docs/guides/index.rst +++ b/docs/guides/index.rst @@ -8,6 +8,7 @@ These pages are detailed guides covering DFHack tools. :maxdepth: 1 /docs/guides/examples-guide + /docs/guides/overlay-dev-guide /docs/guides/modding-guide /docs/guides/quickfort-library-guide /docs/guides/quickfort-user-guide diff --git a/docs/guides/overlay-dev-guide.rst b/docs/guides/overlay-dev-guide.rst new file mode 100644 index 000000000..8c84f27c7 --- /dev/null +++ b/docs/guides/overlay-dev-guide.rst @@ -0,0 +1,302 @@ +.. _overlay-dev-guide: + +DFHack overlay dev guide +========================= + +.. highlight:: lua + +This guide walks you through how to build overlay widgets and register them with +the `overlay` framework for injection into Dwarf Fortress viewscreens. + +Why would I want to create an overlay widget? +--------------------------------------------- + +There are both C++ and Lua APIs for creating viewscreens and drawing to the +screen. If you need very specific low-level control, those APIs might be the +right choice for you. However, here are some reasons you might want to implement +an overlay widget instead: + +1. You can draw directly to an existing viewscreen instead of creating an + entirely new screen on the viewscreen stack. This allows the original + viewscreen to continue processing uninterrupted and keybindings bound to + that viewscreen will continue to function. This was previously only + achievable by C++ plugins. + +1. You'll get a free UI for enabling/disabling your widget and repositioning it + on the screen. Widget state is saved for you and is automatically restored + when the game is restarted. + +1. You don't have to manage the C++ interposing logic yourself and can focus on + the business logic, writing purely in Lua if desired. + +In general, if you are writing a plugin or script and have anything you'd like +to add to an existing screen (including live updates of map tiles while the game +is unpaused), an overlay widget is probably your easiest path to get it done. If +your plugin or script doesn't otherwise need to be enabled to function, using +the overlay allows you to avoid writing any of the enable management code that +would normally be required for you to show info in the UI. + +Overlay widget API +------------------ + +Overlay widgets are Lua classes that inherit from ``overlay.OverlayWidget`` +(which itself inherits from `widgets.Widget `). The regular +``onInput(keys)``, ``onRenderFrame(dc, frame_rect)``, and ``onRenderBody(dc)`` +functions work as normal, and they are called when the viewscreen that the +widget is associated with does its usual input and render processing. The widget +gets first dibs on input processing. If a widget returns ``true`` from its +``onInput()`` function, the viewscreen will not receive the input. + +Overlay widgets can contain other Widgets and be as simple or complex as you +need them to be, just like you're building a regular UI element. + +There are a few extra capabilities that overlay widgets have that take them +beyond your everyday ``Widget``: + +- If an ``overlay_onupdate(viewscreen)`` function is defined, it will be called + just after the associated viewscreen's ``logic()`` function is called (i.e. + a "tick" or a (non-graphical) "frame"). For hotspot widgets, this function + will also get called after the top viewscreen's ``logic()`` function is + called, regardless of whether the widget is associated with that viewscreen. + If this function returns ``true``, then the widget's ``overlay_trigger()`` + function is immediately called. Note that the ``viewscreen`` parameter will + be ``nil`` for hotspot widgets that are not also associated with the current + viewscreen. +- If an ``overlay_trigger()`` function is defined, will be called when the + widget's ``overlay_onupdate`` callback returns true or when the player uses + the CLI (or a keybinding calling the CLI) to trigger the widget. The + function must return either ``nil`` or the ``gui.Screen`` object that the + widget code has allocated, shown, and now owns. Hotspot widgets will receive + no callbacks from unassociated viewscreens until the returned screen is + dismissed. Unbound hotspot widgets **must** allocate a Screen with this + function if they want to react to the ``onInput()`` feed or be rendered. The + widgets owned by the overlay framework must not be attached to that new + screen, but the returned screen can instantiate and configure any new views + that it wants to. + +If the widget can take up a variable amount of space on the screen, and you want +the widget to adjust its position according to the size of its contents, you can +modify ``self.frame.w`` and ``self.frame.h`` at any time -- in ``init()`` or in +any of the callbacks -- to indicate a new size. The overlay framework will +detect the size change and adjust the widget position and layout. + +If you don't need to dynamically resize, just set ``self.frame.w`` and +``self.frame.h`` once in ``init()``. + +Widget attributes +***************** + +The ``overlay.OverlayWidget`` superclass defines the following class attributes: + +- ``name`` + This will be filled in with the display name of your widget, in case you + have multiple widgets with the same implementation but different + configurations. +- ``default_pos`` (default: ``{x=-2, y=-2}``) + Override this attribute with your desired default widget position. See + the `overlay` docs for information on what positive and negative numbers + mean for the position. Players can change the widget position at any time + via the `overlay position ` command, so don't assume that your + widget will always be at the default position. +- ``viewscreens`` (default: ``{}``) + The list of viewscreens that this widget should be associated with. When + one of these viewscreens is on top of the viewscreen stack, your widget's + callback functions for update, input, and render will be interposed into the + viewscreen's call path. The name of the viewscreen is the name of the DFHack + class that represents the viewscreen, minus the ``viewscreen_`` prefix and + ``st`` suffix. For example, the fort mode main map viewscreen would be + ``dwarfmode`` and the adventure mode map viewscreen would be + ``dungeonmode``. If there is only one viewscreen that this widget is + associated with, it can be specified as a string instead of a list of + strings with a single element. +- ``hotspot`` (default: ``false``) + If set to ``true``, your widget's ``overlay_onupdate`` function will be + called whenever the `overlay` plugin's ``plugin_onupdate()`` function is + called (which corresponds to one call per call to the current top + viewscreen's ``logic()`` function). This call to ``overlay_onupdate`` is in + addition to any calls initiated from associated interposed viewscreens and + will come after calls from associated viewscreens. +- ``overlay_onupdate_max_freq_seconds`` (default: ``5``) + This throttles how often a widget's ``overlay_onupdate`` function can be + called (from any source). Set this to the largest amount of time (in + 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 + that running more often than you really need to will impact game FPS, + especially if your widget can run while the game is unpaused. + +Registering a widget with the overlay framework +*********************************************** + +Anywhere in your code after the widget classes are declared, define a table +named ``OVERLAY_WIDGETS``. The keys are the display names for your widgets and +the values are the widget classes. For example, the `dwarfmonitor` widgets are +declared like this:: + + OVERLAY_WIDGETS = { + cursor=CursorWidget, + date=DateWidget, + misery=MiseryWidget, + weather=WeatherWidget, + } + +When the `overlay` plugin is enabled, it scans all plugins and scripts for +this table and registers the widgets on your behalf. The widget is enabled if it +was enabled the last time the `overlay` plugin was loaded and the widget's +position is restored according to the state saved in the +:file:`dfhack-config/overlay.json` file. + +The overlay framework will instantiate widgets from the named classes and own +the resulting objects. The instantiated widgets must not be added as subviews to +any other View, including the Screen views that can be returned from the +``overlay_trigger()`` function. + +Widget example 1: adding text to a DF screen +-------------------------------------------- + +This is a simple widget that displays a message at its position. The message +text is retrieved from the host script or plugin every ~20 seconds or when +the :kbd:`Alt`:kbd:`Z` hotkey is hit:: + + local overlay = require('plugins.overlay') + local widgets = require('gui.widgets') + + MessageWidget = defclass(MessageWidget, overlay.OverlayWidget) + MessageWidget.ATTRS{ + default_pos={x=5,y=-2}, + viewscreens={'dwarfmode', 'dungeonmode'}, + overlay_onupdate_max_freq_seconds=20, + } + + function MessageWidget:init() + self.label = widgets.Label{text=''} + self:addviews{self.label} + end + + function MessageWidget:overlay_onupdate() + local text = getImportantMessage() -- defined in the host script/plugin + self.label:setText(text) + self.frame.w = #text + end + + function MessageWidget:onInput(keys) + if keys.CUSTOM_ALT_Z then + self:overlay_onupdate() + return true + end + end + + OVERLAY_WIDGETS = {message=MessageWidget} + +Widget example 2: highlighting artifacts on the live game map +------------------------------------------------------------- + +This widget is not rendered at its "position" at all, but instead monitors the +map and overlays information about where artifacts are located. Scanning for +which artifacts are visible on the map can slow, so that is only done every 10 +seconds to avoid slowing down the entire game on every frame. + +:: + + local overlay = require('plugins.overlay') + local widgets = require('gui.widgets') + + ArtifactRadarWidget = defclass(ArtifactRadarWidget, overlay.OverlayWidget) + ArtifactRadarWidget.ATTRS{ + viewscreens={'dwarfmode', 'dungeonmode'}, + overlay_onupdate_max_freq_seconds=10, + } + + function ArtifactRadarWidget:overlay_onupdate() + self.visible_artifacts_coords = getVisibleArtifactCoords() + end + + function ArtifactRadarWidget:onRenderFrame() + for _,pos in ipairs(self.visible_artifacts_coords) do + -- highlight tile at given coordinates + end + end + + OVERLAY_WIDGETS = {radar=ArtifactRadarWidget} + +Widget example 3: corner hotspot +-------------------------------- + +This hotspot reacts to mouseover events and launches a screen that can react to +input events. The hotspot area is a 2x2 block near the lower right corner of the +screen (by default, but the player can move it wherever). + +:: + + local overlay = require('plugins.overlay') + local widgets = require('gui.widgets') + + HotspotMenuWidget = defclass(HotspotMenuWidget, overlay.OverlayWidget) + HotspotMenuWidget.ATTRS{ + default_pos={x=-3,y=-3}, + frame={w=2, h=2}, + hotspot=true, + viewscreens='dwarfmode', + overlay_onupdate_max_freq_seconds=0, -- check for mouseover every tick + } + + function HotspotMenuWidget:init() + -- note this label only gets rendered on the associated viewscreen + -- (dwarfmode), but the hotspot is active on all screens + self:addviews{widgets.Label{text={'!!', NEWLINE, '!!'}}} + self.mouseover = false + end + + function HotspotMenuWidget:overlay_onupdate() + local hasMouse = self:getMousePos() + if hasMouse and not self.mouseover then -- only trigger on mouse entry + self.mouseover = true + return true + end + self.mouseover = hasMouse + end + + function HotspotMenuWidget:overlay_trigger() + return MenuScreen{hotspot_frame=self.frame}:show() + end + + OVERLAY_WIDGETS = {menu=HotspotMenuWidget} + + MenuScreen = defclass(MenuScreen, gui.Screen) + MenuScreen.ATTRS{ + focus_path='hotspot/menu', + hotspot_frame=DEFAULT_NIL, + } + + function MenuScreen:init() + self.mouseover = false + + -- derrive the menu frame from the hotspot frame so it + -- can appear in a nearby location + local frame = copyall(self.hotspot_frame) + -- ... + + self:addviews{ + widgets.ResizingPanel{ + autoarrange_subviews=true, + frame=frame, + frame_style=gui.GREY_LINE_FRAME, + frame_background=gui.CLEAR_PEN, + subviews={ + -- ... + }, + }, + }, + } + end + + function MenuScreen:onInput(keys) + if keys.LEAVESCREEN then + self:dismiss() + return true + end + return self:inputToSubviews(keys) + end + + function MenuScreen:onRenderFrame(dc, rect) + self:renderParent() + end diff --git a/docs/plugins/overlay.rst b/docs/plugins/overlay.rst index 9416fba31..4d2a1be7f 100644 --- a/docs/plugins/overlay.rst +++ b/docs/plugins/overlay.rst @@ -2,19 +2,66 @@ overlay ======= .. dfhack-tool:: - :summary: Provide an on-screen clickable DFHack launcher button. + :summary: Manage on-screen overlay widgets. :tags: dfhack interface -This tool places a small button in the lower left corner of the screen that you -can click to run DFHack commands with `gui/launcher`. - -If you would rather always run `gui/launcher` with the hotkeys, or just don't -want the DFHack button on-screen, just disable the plugin with -``disable overlay``. +The overlay framework manages the on-screen widgets that other tools (including +3rd party plugins and scripts) can register for display. If you are a developer +who wants to write an overlay widget, please see the `overlay-dev-guide`. Usage ----- -:: +``enable overlay`` + Display enabled widgets. +``overlay enable|disable all| [ ...]`` + Enable/disable all or specified widgets. Widgets can be specified by either + their name or their number, as returned by ``overlay list``. +``overlay list []`` + Show a list of all the widgets that are registered with the overlay + framework, optionally filtered by the given filter string. +``overlay position [default| ]`` + Display configuration information for the given widget or change the + position where it is rendered. See the `Widget position`_ section below for + details. +``overlay trigger `` + Intended to be used by keybindings for manually triggering a widget. For + example, you could use an ``overlay trigger`` keybinding to show a menu that + normally appears when you hover the mouse over a screen hotspot. + +Examples +-------- + +``overlay enable all`` + Enable all widgets. Note that they will only be displayed on the screens + that they are associated with. You can see which screens a widget will be + displayed on, along with whether the widget is a hotspot, by calling + ``overlay position``. +``overlay position hotkeys.menu`` + Show the current configuration of the `hotkeys` menu widget. +``overlay position dwarfmonitor.cursor -2 -3`` + Display the `dwarfmonitor` cursor position reporting widget in the lower + right corner of the screen, 2 tiles from the left and 3 tiles from the + bottom. +``overlay position dwarfmonitor.cursor default`` + Reset the `dwarfmonitor` cursor position to its default. +``overlay trigger hotkeys.menu`` + Trigger the `hotkeys` menu widget so that it shows its popup menu. This is + what is run when you hit :kbd:`Ctrl`:kbd:`Shift`:kbd:`C`. + +Widget position +--------------- + +Widgets can be positioned at any (``x``, ``y``) position on the screen, and can +be specified relative to any edge. Coordinates are 1-based, which means that +``1`` is the far left column (for ``x``) or the top row (for ``y``). Negative +numbers are measured from the right of the screen to the right edge of the +widget or from the bottom of the screen to the bottom of the widget, +respectively. + +For easy reference, the corners can be found at the following coordinates: - enable overlay +:(1, 1): top left corner +:(-1, 1): top right corner +:(1, -1): lower left corner +:(-1, -1): lower right corner diff --git a/library/LuaTools.cpp b/library/LuaTools.cpp index 3cd6a1f7d..8d317c076 100644 --- a/library/LuaTools.cpp +++ b/library/LuaTools.cpp @@ -820,6 +820,29 @@ bool DFHack::Lua::SafeCall(color_ostream &out, lua_State *L, int nargs, int nres return ok; } +bool DFHack::Lua::CallLuaModuleFunction(color_ostream &out, lua_State *L, + const char *module_name, const char *fn_name, + int nargs, int nres, LuaLambda && args_lambda, LuaLambda && res_lambda, + bool perr){ + if (!lua_checkstack(L, 1 + nargs) || + !Lua::PushModulePublic(out, L, module_name, fn_name)) { + if (perr) + out.printerr("Failed to load %s Lua code\n", module_name); + return false; + } + + std::forward(args_lambda)(L); + + if (!Lua::SafeCall(out, L, nargs, nres, perr)) { + if (perr) + out.printerr("Failed Lua call to '%s.%s'\n", module_name, fn_name); + return false; + } + + std::forward(res_lambda)(L); + return true; +} + // Copied from lcorolib.c, with error handling modifications static int resume_helper(lua_State *L, lua_State *co, int narg, int nres) { diff --git a/library/include/LuaTools.h b/library/include/LuaTools.h index e4245f09a..9e1901f03 100644 --- a/library/include/LuaTools.h +++ b/library/include/LuaTools.h @@ -24,6 +24,7 @@ distribution. #pragma once +#include #include #include #include @@ -218,6 +219,20 @@ namespace DFHack {namespace Lua { */ DFHACK_EXPORT bool SafeCall(color_ostream &out, lua_State *state, int nargs, int nres, bool perr = true); + /** + * Load named module and function and invoke it via SafeCall. Returns true + * on success. If an error is signalled, and perr is true, it is printed and + * popped from the stack. + */ + typedef std::function LuaLambda; + static auto DEFAULT_LUA_LAMBDA = [](lua_State *){}; + DFHACK_EXPORT bool CallLuaModuleFunction(color_ostream &out, + lua_State *state, const char *module_name, const char *fn_name, + int nargs = 0, int nres = 0, + LuaLambda && args_lambda = DEFAULT_LUA_LAMBDA, + LuaLambda && res_lambda = DEFAULT_LUA_LAMBDA, + bool perr = true); + /** * Pops a function from the top of the stack, and pushes a new coroutine. */ diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index c9438e04e..7f22a47b3 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -144,7 +144,7 @@ if(BUILD_SUPPORTED) dfhack_plugin(mousequery mousequery.cpp) dfhack_plugin(nestboxes nestboxes.cpp) dfhack_plugin(orders orders.cpp LINK_LIBRARIES jsoncpp_static) - dfhack_plugin(overlay overlay.cpp) + dfhack_plugin(overlay overlay.cpp LINK_LIBRARIES lua) dfhack_plugin(pathable pathable.cpp LINK_LIBRARIES lua) dfhack_plugin(petcapRemover petcapRemover.cpp) dfhack_plugin(plants plants.cpp) diff --git a/plugins/lua/overlay.lua b/plugins/lua/overlay.lua new file mode 100644 index 000000000..ad20c2605 --- /dev/null +++ b/plugins/lua/overlay.lua @@ -0,0 +1,485 @@ +local _ENV = mkmodule('plugins.overlay') + +local gui = require('gui') +local json = require('json') +local utils = require('utils') +local widgets = require('gui.widgets') + +local OVERLAY_CONFIG_FILE = 'dfhack-config/overlay.json' +local OVERLAY_WIDGETS_VAR = 'OVERLAY_WIDGETS' + +local DEFAULT_X_POS, DEFAULT_Y_POS = -2, -2 + +-- ---------------- -- +-- state and config -- +-- ---------------- -- + +local active_triggered_widget = nil +local active_triggered_screen = nil -- if non-nil, hotspots will not get updates +local widget_db = {} -- map of widget name to ephermeral state +local widget_index = {} -- ordered list of widget names +local overlay_config = {} -- map of widget name to persisted state +local active_hotspot_widgets = {} -- map of widget names to the db entry +local active_viewscreen_widgets = {} -- map of vs_name to map of w.names -> db + +local function reset() + if active_triggered_screen then + active_triggered_screen:dismiss() + end + active_triggered_widget = nil + active_triggered_screen = nil + + widget_db = {} + widget_index = {} + + local ok, config = pcall(json.decode_file, OVERLAY_CONFIG_FILE) + overlay_config = ok and config or {} + + active_hotspot_widgets = {} + active_viewscreen_widgets = {} +end + +local function save_config() + if not safecall(json.encode_file, overlay_config, OVERLAY_CONFIG_FILE) then + dfhack.printerr(('failed to save overlay config file: "%s"') + :format(path)) + end +end + +local function triggered_screen_has_lock() + if not active_triggered_screen then return false end + if active_triggered_screen:isActive() then return true end + active_triggered_widget = nil + active_triggered_screen = nil + return false +end + +-- ----------- -- +-- utility fns -- +-- ----------- -- + +local function normalize_list(element_or_list) + if type(element_or_list) == 'table' then return element_or_list end + return {element_or_list} +end + +-- normalize "short form" viewscreen names to "long form" +local function normalize_viewscreen_name(vs_name) + if vs_name:match('viewscreen_.*st') then return vs_name end + return 'viewscreen_' .. vs_name .. 'st' +end + +-- reduce "long form" viewscreen names to "short form" +local function simplify_viewscreen_name(vs_name) + _,_,short_name = vs_name:find('^viewscreen_(.*)st$') + if short_name then return short_name end + return vs_name +end + +local function is_empty(tbl) + for _ in pairs(tbl) do + return false + end + return true +end + +local function sanitize_pos(pos) + local x = math.floor(tonumber(pos.x) or DEFAULT_X_POS) + local y = math.floor(tonumber(pos.y) or DEFAULT_Y_POS) + -- if someone accidentally uses 0-based instead of 1-based indexing, fix it + if x == 0 then x = 1 end + if y == 0 then y = 1 end + return {x=x, y=y} +end + +local function make_frame(pos, old_frame) + old_frame = old_frame or {} + local frame = {w=old_frame.w, h=old_frame.h} + if pos.x < 0 then frame.r = math.abs(pos.x) - 1 else frame.l = pos.x - 1 end + if pos.y < 0 then frame.b = math.abs(pos.y) - 1 else frame.t = pos.y - 1 end + return frame +end + +local function get_screen_rect() + local w, h = dfhack.screen.getWindowSize() + return gui.ViewRect{rect=gui.mkdims_wh(0, 0, w, h)} +end + +-- ------------- -- +-- CLI functions -- +-- ------------- -- + +local function get_name(name_or_number) + local num = tonumber(name_or_number) + if num and widget_index[num] then + return widget_index[num] + end + return tostring(name_or_number) +end + +local function do_by_names_or_numbers(args, fn) + local arglist = normalize_list(args) + if #arglist == 0 then + dfhack.printerr('please specify a widget name or list number') + return + end + for _,name_or_number in ipairs(arglist) do + local name = get_name(name_or_number) + local db_entry = widget_db[name] + if not db_entry then + dfhack.printerr(('widget not found: "%s"'):format(name)) + else + fn(name, db_entry) + end + end +end + +local function do_enable(args, quiet, skip_save) + local enable_fn = function(name, db_entry) + overlay_config[name].enabled = true + if db_entry.widget.hotspot then + active_hotspot_widgets[name] = db_entry + end + for _,vs_name in ipairs(normalize_list(db_entry.widget.viewscreens)) do + vs_name = normalize_viewscreen_name(vs_name) + ensure_key(active_viewscreen_widgets, vs_name)[name] = db_entry + end + if not quiet then + print(('enabled widget %s'):format(name)) + end + end + if args[1] == 'all' then + for name,db_entry in pairs(widget_db) do + if not overlay_config[name].enabled then + enable_fn(name, db_entry) + end + end + else + do_by_names_or_numbers(args, enable_fn) + end + if not skip_save then + save_config() + end +end + +local function do_disable(args) + local disable_fn = function(name, db_entry) + overlay_config[name].enabled = false + if db_entry.widget.hotspot then + active_hotspot_widgets[name] = nil + end + for _,vs_name in ipairs(normalize_list(db_entry.widget.viewscreens)) do + vs_name = normalize_viewscreen_name(vs_name) + ensure_key(active_viewscreen_widgets, vs_name)[name] = nil + if is_empty(active_viewscreen_widgets[vs_name]) then + active_viewscreen_widgets[vs_name] = nil + end + end + print(('disabled widget %s'):format(name)) + end + if args[1] == 'all' then + for name,db_entry in pairs(widget_db) do + if overlay_config[name].enabled then + disable_fn(name, db_entry) + end + end + else + do_by_names_or_numbers(args, disable_fn) + end + save_config() +end + +local function do_list(args) + local filter = args and #args > 0 + local num_filtered = 0 + for i,name in ipairs(widget_index) do + if filter then + local passes = false + for _,str in ipairs(args) do + if name:find(str) then + passes = true + break + end + end + if not passes then + num_filtered = num_filtered + 1 + goto continue + end + end + local db_entry = widget_db[name] + local enabled = overlay_config[name].enabled + dfhack.color(enabled and COLOR_LIGHTGREEN or COLOR_YELLOW) + dfhack.print(enabled and '[enabled] ' or '[disabled]') + dfhack.color() + print((' %d) %s'):format(i, name)) + ::continue:: + end + if num_filtered > 0 then + print(('(%d widgets filtered out)'):format(num_filtered)) + end +end + +local function load_widget(name, widget_class) + local widget = widget_class{name=name} + widget_db[name] = { + widget=widget, + next_update_ms=widget.overlay_onupdate and 0 or math.huge, + } + if not overlay_config[name] then overlay_config[name] = {} end + local config = overlay_config[name] + config.pos = sanitize_pos(config.pos or widget.default_pos) + widget.frame = make_frame(config.pos, widget.frame) + if config.enabled then + do_enable(name, true, true) + else + config.enabled = false + end +end + +local function load_widgets(env_prefix, provider, env_fn) + local env_name = env_prefix .. provider + local ok, provider_env = pcall(env_fn, env_name) + if not ok or not provider_env[OVERLAY_WIDGETS_VAR] then return end + local overlay_widgets = provider_env[OVERLAY_WIDGETS_VAR] + if type(overlay_widgets) ~= 'table' then + dfhack.printerr( + ('error loading overlay widgets from "%s": %s map is malformed') + :format(env_name, OVERLAY_WIDGETS_VAR)) + return + end + for widget_name,widget_class in pairs(overlay_widgets) do + local name = provider .. '.' .. widget_name + if not safecall(load_widget, name, widget_class) then + dfhack.printerr(('error loading overlay widget "%s"'):format(name)) + end + end +end + +-- called directly from cpp on plugin enable +function reload() + reset() + + for _,plugin in ipairs(dfhack.internal.listPlugins()) do + load_widgets('plugins.', plugin, require) + end + for _,script_path in ipairs(dfhack.internal.getScriptPaths()) do + local files = dfhack.filesystem.listdir_recursive( + script_path, nil, false) + if not files then goto skip_path end + for _,f in ipairs(files) do + if not f.isdir and + f.path:endswith('.lua') and + not f.path:startswith('test/') and + not f.path:startswith('internal/') then + local script_name = f.path:sub(1, #f.path - 4) -- remove '.lua' + load_widgets('', script_name, reqscript) + end + end + ::skip_path:: + end + + for name in pairs(widget_db) do + table.insert(widget_index, name) + end + table.sort(widget_index) + + reposition_widgets() +end + +local function dump_widget_config(name, widget) + local pos = overlay_config[name].pos + print(('widget %s is positioned at x=%d, y=%d'):format(name, pos.x, pos.y)) + local viewscreens = normalize_list(widget.viewscreens) + if #viewscreens > 0 then + print(' it will be attached to the following viewscreens:') + for _,vs in ipairs(viewscreens) do + print((' %s'):format(simplify_viewscreen_name(vs))) + end + end + if widget.hotspot then + print(' it will act as a hotspot on all screens') + end +end + +local function do_position(args) + local name_or_number, x, y = table.unpack(args) + local name = get_name(name_or_number) + if not widget_db[name] then + if not name_or_number then + dfhack.printerr('please specify a widget name or list number') + else + dfhack.printerr(('widget not found: "%s"'):format(name)) + end + return + end + local widget = widget_db[name].widget + local pos + if x == 'default' then + pos = sanitize_pos(widget.default_pos) + else + x, y = tonumber(x), tonumber(y) + if not x or not y then + dump_widget_config(name, widget) + return + end + pos = sanitize_pos{x=x, y=y} + end + overlay_config[name].pos = pos + widget.frame = make_frame(pos, widget.frame) + widget:updateLayout(get_screen_rect()) + save_config() + print(('repositioned widget %s to x=%d, y=%d'):format(name, pos.x, pos.y)) +end + +-- note that the widget does not have to be enabled to be triggered +local function do_trigger(args) + if triggered_screen_has_lock() then + dfhack.printerr(('cannot trigger widget; widget "%s" is already active') + :format(active_triggered_widget)) + return + end + do_by_names_or_numbers(args[1], function(name, db_entry) + local widget = db_entry.widget + if widget.overlay_trigger then + active_triggered_screen = widget:overlay_trigger() + if active_triggered_screen then + active_triggered_widget = name + end + print(('triggered widget %s'):format(name)) + end + end) +end + +local command_fns = { + enable=do_enable, + disable=do_disable, + list=do_list, + position=do_position, + trigger=do_trigger, +} + +local HELP_ARGS = utils.invert{'help', '--help', '-h'} + +function overlay_command(args) + local command = table.remove(args, 1) or 'help' + if HELP_ARGS[command] or not command_fns[command] then return false end + command_fns[command](args) + return true +end + +-- ---------------- -- +-- event management -- +-- ---------------- -- + +local function detect_frame_change(widget, fn) + local frame = widget.frame + local w, h = frame.w, frame.h + local ret = fn() + if w ~= frame.w or h ~= frame.h then + widget:updateLayout() + end + return ret +end + +local function get_next_onupdate_timestamp(now_ms, widget) + local freq_s = widget.overlay_onupdate_max_freq_seconds + if freq_s == 0 then + return now_ms + end + local freq_ms = math.floor(freq_s * 1000) + local jitter = math.random(0, freq_ms // 8) -- up to ~12% jitter + return now_ms + freq_ms - jitter +end + +-- reduces the next call by a small random amount to introduce jitter into the +-- widget processing timings +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 + 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 + active_triggered_screen = w:overlay_trigger() + if active_triggered_screen then + active_triggered_widget = name + return true + end + end +end + +function update_hotspot_widgets() + if triggered_screen_has_lock() then return end + local now_ms = dfhack.getTickCount() + for name,db_entry in pairs(active_hotspot_widgets) do + if do_update(name, db_entry, now_ms) then return end + end +end + +-- not subject to trigger lock since these widgets are already filtered by +-- viewscreen +function update_viewscreen_widgets(vs_name, vs) + local vs_widgets = active_viewscreen_widgets[vs_name] + if not vs_widgets then return end + local now_ms = dfhack.getTickCount() + for name,db_entry in pairs(vs_widgets) do + if do_update(name, db_entry, now_ms, vs) then return end + end +end + +function feed_viewscreen_widgets(vs_name, keys) + local vs_widgets = active_viewscreen_widgets[vs_name] + if not vs_widgets then return false end + for _,db_entry in pairs(vs_widgets) do + local w = db_entry.widget + if detect_frame_change(w, function() return w:onInput(keys) end) then + return true + end + end + return false +end + +function render_viewscreen_widgets(vs_name) + local vs_widgets = active_viewscreen_widgets[vs_name] + if not vs_widgets then return false end + local dc = gui.Painter.new() + for _,db_entry in pairs(vs_widgets) do + local w = db_entry.widget + detect_frame_change(w, function() w:render(dc) end) + end +end + +-- called when the DF window is resized +function reposition_widgets() + local sr = get_screen_rect() + for _,db_entry in pairs(widget_db) do + db_entry.widget:updateLayout(sr) + end +end + +-- ------------------------------------------------- -- +-- OverlayWidget (base class of all overlay widgets) -- +-- ------------------------------------------------- -- + +OverlayWidget = defclass(OverlayWidget, widgets.Widget) +OverlayWidget.ATTRS{ + name=DEFAULT_NIL, -- this is set by the framework to the widget name + default_pos={x=DEFAULT_X_POS, y=DEFAULT_Y_POS}, -- 1-based widget screen pos + hotspot=false, -- whether to call overlay_onupdate on all screens + viewscreens={}, -- override with associated viewscreen or list of viewscrens + overlay_onupdate_max_freq_seconds=5, -- throttle calls to overlay_onupdate +} + +function OverlayWidget:init() + if self.overlay_onupdate_max_freq_seconds < 0 then + error(('overlay_onupdate_max_freq_seconds must be >= 0: %s') + :format(tostring(self.overlay_onupdate_max_freq_seconds))) + end + + -- set defaults for frame. the widget is expected to keep these up to date + -- when display contents change so the widget position can shift if the + -- frame is relative to the right or bottom edges. + self.frame = self.frame or {} + self.frame.w = self.frame.w or 5 + self.frame.h = self.frame.h or 1 +end + +return _ENV diff --git a/plugins/overlay.cpp b/plugins/overlay.cpp index 0c63e53e8..c2a04ac8b 100644 --- a/plugins/overlay.cpp +++ b/plugins/overlay.cpp @@ -88,6 +88,9 @@ #include "PluginManager.h" #include "VTableInterpose.h" +#include "modules/Gui.h" +#include "modules/Screen.h" + using namespace DFHack; DFHACK_PLUGIN("overlay"); @@ -98,18 +101,58 @@ namespace DFHack { DBG_DECLARE(overlay, event, DebugCategory::LINFO); } +static df::coord2d screenSize; + +static void call_overlay_lua(color_ostream *out, const char *fn_name, + int nargs = 0, int nres = 0, + Lua::LuaLambda && args_lambda = Lua::DEFAULT_LUA_LAMBDA, + Lua::LuaLambda && res_lambda = Lua::DEFAULT_LUA_LAMBDA) { + DEBUG(event).print("calling overlay lua function: '%s'\n", fn_name); + + CoreSuspender guard; + + auto L = Lua::Core::State; + Lua::StackUnwinder top(L); + + if (!out) + out = &Core::getInstance().getConsole(); + + Lua::CallLuaModuleFunction(*out, L, "plugins.overlay", fn_name, nargs, nres, + std::forward(args_lambda), + std::forward(res_lambda)); +} + template struct viewscreen_overlay : T { typedef T interpose_base; DEFINE_VMETHOD_INTERPOSE(void, logic, ()) { INTERPOSE_NEXT(logic)(); + call_overlay_lua(NULL, "update_viewscreen_widgets", 2, 0, + [&](lua_State *L) { + Lua::Push(L, T::_identity.getName()); + Lua::Push(L, this); + }); } DEFINE_VMETHOD_INTERPOSE(void, feed, (std::set *input)) { - INTERPOSE_NEXT(feed)(input); + bool input_is_handled = false; + call_overlay_lua(NULL, "feed_viewscreen_widgets", 2, 1, + [&](lua_State *L) { + Lua::Push(L, T::_identity.getName()); + Lua::PushInterfaceKeys(L, *input); + }, [&](lua_State *L) { + input_is_handled = lua_toboolean(L, -1); + }); + if (!input_is_handled) + INTERPOSE_NEXT(feed)(input); } DEFINE_VMETHOD_INTERPOSE(void, render, ()) { INTERPOSE_NEXT(render)(); + call_overlay_lua(NULL, "render_viewscreen_widgets", 2, 0, + [&](lua_State *L) { + Lua::Push(L, T::_identity.getName()); + Lua::Push(L, this); + }); } }; @@ -207,10 +250,15 @@ IMPLEMENT_HOOKS(workshop_profile) !INTERPOSE_HOOK(screen##_overlay, feed).apply(enable) || \ !INTERPOSE_HOOK(screen##_overlay, render).apply(enable) -DFhackCExport command_result plugin_enable(color_ostream &, bool enable) { +DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) { if (is_enabled == enable) return CR_OK; + if (enable) { + screenSize = Screen::getWindowSize(); + call_overlay_lua(&out, "reload"); + } + DEBUG(control).print("%sing interpose hooks\n", enable ? "enabl" : "disabl"); if (INTERPOSE_HOOKS_FAILED(adopt_region) || @@ -302,15 +350,14 @@ DFhackCExport command_result plugin_enable(color_ostream &, bool enable) { #undef INTERPOSE_HOOKS_FAILED static command_result overlay_cmd(color_ostream &out, std::vector & parameters) { - if (DBG_NAME(control).isEnabled(DebugCategory::LDEBUG)) { - DEBUG(control).print("interpreting command with %zu parameters:\n", - parameters.size()); - for (auto ¶m : parameters) { - DEBUG(control).print(" %s\n", param.c_str()); - } - } + bool show_help = false; + call_overlay_lua(&out, "overlay_command", 1, 1, [&](lua_State *L) { + Lua::PushVector(L, parameters); + }, [&](lua_State *L) { + show_help = !lua_toboolean(L, -1); + }); - return CR_OK; + return show_help ? CR_WRONG_USAGE : CR_OK; } DFhackCExport command_result plugin_init(color_ostream &out, std::vector &commands) { @@ -318,9 +365,10 @@ DFhackCExport command_result plugin_init(color_ostream &out, std::vector