Merge pull request #2367 from myk002/myk_overlay_v2

[overlay] implement overlay v2 framework
develop
Myk 2022-11-14 16:44:41 -08:00 committed by GitHub
commit 63410d63c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 954 additions and 23 deletions

@ -89,6 +89,7 @@ enable automaterial
# Other interface improvement tools # Other interface improvement tools
enable \ enable \
overlay \
confirm \ confirm \
dwarfmonitor \ dwarfmonitor \
mousequery \ mousequery \

@ -3898,6 +3898,8 @@ gui.widgets
This module implements some basic widgets based on the View infrastructure. This module implements some basic widgets based on the View infrastructure.
.. _widget:
Widget class Widget class
------------ ------------

@ -34,6 +34,7 @@ changelog.txt uses a syntax similar to RST, with a few special sequences:
# Future # Future
## New Plugins ## 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 ## Fixes
- `automaterial`: fix the cursor jumping up a z level when clicking quickly after box select - `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: 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 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 - 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. - 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-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. - `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 ## Documentation
- `spectate`: improved documentation of features and functionality - `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 ## API
- ``Gui::anywhere_hotkey``: for plugin commands bound to keybindings that can be invoked on any screen - ``Gui::anywhere_hotkey``: for plugin commands bound to keybindings that can be invoked on any screen

@ -8,6 +8,7 @@ These pages are detailed guides covering DFHack tools.
:maxdepth: 1 :maxdepth: 1
/docs/guides/examples-guide /docs/guides/examples-guide
/docs/guides/overlay-dev-guide
/docs/guides/modding-guide /docs/guides/modding-guide
/docs/guides/quickfort-library-guide /docs/guides/quickfort-library-guide
/docs/guides/quickfort-user-guide /docs/guides/quickfort-user-guide

@ -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 <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 <overlay>` 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

@ -2,19 +2,66 @@ overlay
======= =======
.. dfhack-tool:: .. dfhack-tool::
:summary: Provide an on-screen clickable DFHack launcher button. :summary: Manage on-screen overlay widgets.
:tags: dfhack interface :tags: dfhack interface
This tool places a small button in the lower left corner of the screen that you The overlay framework manages the on-screen widgets that other tools (including
can click to run DFHack commands with `gui/launcher`. 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`.
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``.
Usage Usage
----- -----
:: ``enable overlay``
Display enabled widgets.
``overlay enable|disable all|<name or list number> [<name or list number> ...]``
Enable/disable all or specified widgets. Widgets can be specified by either
their name or their number, as returned by ``overlay list``.
``overlay list [<filter>]``
Show a list of all the widgets that are registered with the overlay
framework, optionally filtered by the given filter string.
``overlay position <name or list number> [default|<x> <y>]``
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 <name or list number>``
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

@ -820,6 +820,29 @@ bool DFHack::Lua::SafeCall(color_ostream &out, lua_State *L, int nargs, int nres
return ok; 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<LuaLambda&&>(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<LuaLambda&&>(res_lambda)(L);
return true;
}
// Copied from lcorolib.c, with error handling modifications // Copied from lcorolib.c, with error handling modifications
static int resume_helper(lua_State *L, lua_State *co, int narg, int nres) static int resume_helper(lua_State *L, lua_State *co, int narg, int nres)
{ {

@ -24,6 +24,7 @@ distribution.
#pragma once #pragma once
#include <functional>
#include <string> #include <string>
#include <sstream> #include <sstream>
#include <vector> #include <vector>
@ -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); 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<void(lua_State *)> 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. * Pops a function from the top of the stack, and pushes a new coroutine.
*/ */

@ -144,7 +144,7 @@ if(BUILD_SUPPORTED)
dfhack_plugin(mousequery mousequery.cpp) dfhack_plugin(mousequery mousequery.cpp)
dfhack_plugin(nestboxes nestboxes.cpp) dfhack_plugin(nestboxes nestboxes.cpp)
dfhack_plugin(orders orders.cpp LINK_LIBRARIES jsoncpp_static) 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(pathable pathable.cpp LINK_LIBRARIES lua)
dfhack_plugin(petcapRemover petcapRemover.cpp) dfhack_plugin(petcapRemover petcapRemover.cpp)
dfhack_plugin(plants plants.cpp) dfhack_plugin(plants plants.cpp)

@ -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

@ -88,6 +88,9 @@
#include "PluginManager.h" #include "PluginManager.h"
#include "VTableInterpose.h" #include "VTableInterpose.h"
#include "modules/Gui.h"
#include "modules/Screen.h"
using namespace DFHack; using namespace DFHack;
DFHACK_PLUGIN("overlay"); DFHACK_PLUGIN("overlay");
@ -98,18 +101,58 @@ namespace DFHack {
DBG_DECLARE(overlay, event, DebugCategory::LINFO); 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<Lua::LuaLambda&&>(args_lambda),
std::forward<Lua::LuaLambda&&>(res_lambda));
}
template<class T> template<class T>
struct viewscreen_overlay : T { struct viewscreen_overlay : T {
typedef T interpose_base; typedef T interpose_base;
DEFINE_VMETHOD_INTERPOSE(void, logic, ()) { DEFINE_VMETHOD_INTERPOSE(void, logic, ()) {
INTERPOSE_NEXT(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<df::interface_key> *input)) { DEFINE_VMETHOD_INTERPOSE(void, feed, (std::set<df::interface_key> *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, ()) { DEFINE_VMETHOD_INTERPOSE(void, render, ()) {
INTERPOSE_NEXT(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, feed).apply(enable) || \
!INTERPOSE_HOOK(screen##_overlay, render).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) if (is_enabled == enable)
return CR_OK; return CR_OK;
if (enable) {
screenSize = Screen::getWindowSize();
call_overlay_lua(&out, "reload");
}
DEBUG(control).print("%sing interpose hooks\n", enable ? "enabl" : "disabl"); DEBUG(control).print("%sing interpose hooks\n", enable ? "enabl" : "disabl");
if (INTERPOSE_HOOKS_FAILED(adopt_region) || if (INTERPOSE_HOOKS_FAILED(adopt_region) ||
@ -302,15 +350,14 @@ DFhackCExport command_result plugin_enable(color_ostream &, bool enable) {
#undef INTERPOSE_HOOKS_FAILED #undef INTERPOSE_HOOKS_FAILED
static command_result overlay_cmd(color_ostream &out, std::vector <std::string> & parameters) { static command_result overlay_cmd(color_ostream &out, std::vector <std::string> & parameters) {
if (DBG_NAME(control).isEnabled(DebugCategory::LDEBUG)) { bool show_help = false;
DEBUG(control).print("interpreting command with %zu parameters:\n", call_overlay_lua(&out, "overlay_command", 1, 1, [&](lua_State *L) {
parameters.size()); Lua::PushVector(L, parameters);
for (auto &param : parameters) { }, [&](lua_State *L) {
DEBUG(control).print(" %s\n", param.c_str()); 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 <PluginCommand> &commands) { DFhackCExport command_result plugin_init(color_ostream &out, std::vector <PluginCommand> &commands) {
@ -318,9 +365,10 @@ DFhackCExport command_result plugin_init(color_ostream &out, std::vector <Plugin
PluginCommand( PluginCommand(
"overlay", "overlay",
"Manage onscreen widgets.", "Manage onscreen widgets.",
overlay_cmd)); overlay_cmd,
Gui::anywhere_hotkey));
return plugin_enable(out, true); return CR_OK;
} }
DFhackCExport command_result plugin_shutdown(color_ostream &out) { DFhackCExport command_result plugin_shutdown(color_ostream &out) {
@ -328,5 +376,11 @@ DFhackCExport command_result plugin_shutdown(color_ostream &out) {
} }
DFhackCExport command_result plugin_onupdate (color_ostream &out) { DFhackCExport command_result plugin_onupdate (color_ostream &out) {
df::coord2d newScreenSize = Screen::getWindowSize();
if (newScreenSize != screenSize) {
call_overlay_lua(&out, "reposition_widgets");
screenSize = newScreenSize;
}
call_overlay_lua(&out, "update_hotspot_widgets");
return CR_OK; return CR_OK;
} }