Merge pull request #2367 from myk002/myk_overlay_v2
[overlay] implement overlay v2 frameworkdevelop
commit
63410d63c7
@ -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
|
@ -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
|
Loading…
Reference in New Issue