-
Notifications
You must be signed in to change notification settings - Fork 496
Expand file tree
/
Copy pathoverlay.lua
More file actions
667 lines (599 loc) · 20.7 KB
/
overlay.lua
File metadata and controls
667 lines (599 loc) · 20.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
local _ENV = mkmodule('plugins.overlay')
local gui = require('gui')
local json = require('json')
local scriptmanager = require('script-manager')
local utils = require('utils')
local widgets = require('gui.widgets')
local OVERLAY_CONFIG_FILE = 'dfhack-config/overlay.json'
local OVERLAY_WIDGETS_VAR = 'OVERLAY_WIDGETS'
local GLOBAL_KEY = 'OVERLAY'
local DEFAULT_X_POS, DEFAULT_Y_POS = -2, -2
-- ---------------- --
-- state and config --
-- ---------------- --
local trigger_lock_holder_description = nil
local trigger_lock_holder_screen = nil -- if non-nil, no triggering allowed
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
-- for use by gui/overlay
function get_state()
return {index=widget_index, config=overlay_config, db=widget_db}
end
function register_trigger_lock_screen(scr, desc)
if trigger_lock_holder_screen then
if not trigger_lock_holder_screen:isActive() then
trigger_lock_holder_screen:dismiss()
end
trigger_lock_holder_description = nil
end
trigger_lock_holder_screen = scr
if trigger_lock_holder_screen then
trigger_lock_holder_description = desc
return true
end
end
local function triggered_screen_has_lock()
if not trigger_lock_holder_screen then return false end
if trigger_lock_holder_screen:isActive() then
if trigger_lock_holder_screen.raise then
trigger_lock_holder_screen:raise()
end
return true
end
return register_trigger_lock_screen(nil, nil)
end
local function reset()
register_trigger_lock_screen(nil, 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
function isOverlayEnabled(name)
if not overlay_config[name] then return false end
return overlay_config[name].enabled
end
-- ----------- --
-- utility fns --
-- ----------- --
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" and remove any focus
local function normalize_viewscreen_name(vs_name)
if vs_name == 'all' or vs_name:match('^viewscreen_.*st') then
return vs_name:match('^[^/]+')
end
return 'viewscreen_' .. vs_name:match('^[^/]+') .. 'st'
end
-- reduce "long form" viewscreen names to "short form"; keep focus
function simplify_viewscreen_name(vs_name)
local short_name = vs_name:match('^viewscreen_([^/]+)st')
return short_name or 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_interface_rects()
local full = gui.ViewRect{rect=gui.mkdims_wh(0, 0, dfhack.screen.getWindowSize())}
local scaled = gui.ViewRect{rect=gui.get_interface_rect()}
return full, scaled
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 db_entry.widget.overlay_onenable then
db_entry.widget.overlay_onenable()
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, quiet)
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
if db_entry.widget.overlay_ondisable then
db_entry.widget.overlay_ondisable()
end
if not quiet then
print(('disabled widget %s'):format(name))
end
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 get_focus_strings(viewscreens)
local focus_strings = nil
for _,vs in ipairs(viewscreens) do
if vs:match('/') then
focus_strings = focus_strings or {}
vs = simplify_viewscreen_name(vs)
table.insert(focus_strings, vs)
end
end
return focus_strings
end
local function load_widget(name, widget_class)
local widget = widget_class{name=name}
widget_db[name] = {
widget=widget,
focus_strings=get_focus_strings(normalize_list(widget.viewscreens)),
next_update_ms=widget.overlay_onupdate and 0 or math.huge,
}
if not overlay_config[name] then overlay_config[name] = {} end
if widget.version ~= overlay_config[name].version then
overlay_config[name] = {}
end
local config = overlay_config[name]
config.version = widget.version
if config.enabled == nil then
config.enabled = widget.default_enabled
end
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_name, env)
local overlay_widgets = env[OVERLAY_WIDGETS_VAR]
if not overlay_widgets then return end
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 = env_name .. '.' .. 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 rescan()
reset()
for _,plugin in ipairs(dfhack.internal.listPlugins()) do
local env_name = 'plugins.' .. plugin
local ok, plugin_env = pcall(require, env_name)
if ok then
load_widgets(plugin, plugin_env)
end
end
scriptmanager.foreach_module_script(load_widgets)
for name in pairs(widget_db) do
table.insert(widget_index, name)
end
table.sort(widget_index)
reposition_widgets()
end
dfhack.onStateChange[GLOBAL_KEY] = function(sc)
if sc ~= SC_WORLD_LOADED then
return
end
-- pick up widgets from active mods
rescan()
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, quiet)
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)
local full, scaled = get_interface_rects()
widget:updateLayout(widget.fullscreen and full or scaled)
save_config()
if not quiet then
print(('repositioned widget %s to x=%d, y=%d'):format(name, pos.x, pos.y))
end
end
-- note that the widget does not have to be enabled to be triggered
local function do_trigger(args, quiet)
if triggered_screen_has_lock() then
dfhack.printerr(('cannot trigger widget; widget "%s" is already active')
:format(trigger_lock_holder_description))
return
end
do_by_names_or_numbers(args[1], function(name, db_entry)
local widget = db_entry.widget
if widget.overlay_trigger then
register_trigger_lock_screen(
widget:overlay_trigger(table.unpack(args, 2)),
name
)
if not quiet then
print(('triggered widget %s'):format(name))
end
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, quiet)
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, quiet)
return true
end
-- ---------------- --
-- event management --
-- ---------------- --
local function detect_frame_change(widget, fn)
local frame = widget.frame
local w, h = frame.w, frame.h
local now_ms = dfhack.getTickCount()
local ret = fn()
record_widget_runtime(widget.name, now_ms)
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)
local w = db_entry.widget
if w.overlay_onupdate_max_freq_seconds ~= 0 and
db_entry.next_update_ms > now_ms
then
return
end
if not utils.getval(w.active) then return end
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
if register_trigger_lock_screen(w:overlay_trigger(), name) then
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
local function matches_focus_strings(db_entry, vs_name, vs)
if not db_entry.focus_strings then return true end
local matched = true
local simple_vs_name = simplify_viewscreen_name(vs_name)
for _,fs in ipairs(db_entry.focus_strings) do
if fs:startswith(simple_vs_name) then
matched = false
if dfhack.gui.matchFocusString(fs, vs) then
return true
end
end
end
return matched
end
local function _update_viewscreen_widgets(vs_name, vs, now_ms)
local vs_widgets = active_viewscreen_widgets[vs_name]
if not vs_widgets then return end
local is_all = vs_name == 'all'
now_ms = now_ms or dfhack.getTickCount()
for name,db_entry in pairs(vs_widgets) do
if (is_all or matches_focus_strings(db_entry, vs_name, vs)) and
do_update(name, db_entry, now_ms, vs) then
return
end
end
return now_ms
end
function update_viewscreen_widgets(vs_name, vs)
if triggered_screen_has_lock() then return end
local now_ms = _update_viewscreen_widgets(vs_name, vs, nil)
if now_ms then
_update_viewscreen_widgets('all', vs, now_ms)
end
end
local function _feed_viewscreen_widgets(vs_name, vs, 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 (not vs or matches_focus_strings(db_entry, vs_name, vs)) and
utils.getval(w.active) and
utils.getval(w.visible) and
detect_frame_change(w, function() return w:onInput(keys) end)
then
--print('widget handled input:', w.name)
return true
end
end
return false
end
function feed_viewscreen_widgets(vs_name, vs, keys)
if not _feed_viewscreen_widgets(vs_name, vs, keys) and
not _feed_viewscreen_widgets('all', nil, keys) then
return false
end
return true
end
local function _render_viewscreen_widgets(vs_name, vs)
local vs_widgets = active_viewscreen_widgets[vs_name]
if not vs_widgets then return end
local full, scaled = get_interface_rects()
for _,db_entry in pairs(vs_widgets) do
local w = db_entry.widget
if (not vs or matches_focus_strings(db_entry, vs_name, vs)) and utils.getval(w.visible) then
detect_frame_change(w, function()
w:render(w.fullscreen and gui.Painter.new(full) or gui.Painter.new(scaled))
end)
end
end
return full_dc, scaled_dc
end
local force_refresh
function render_viewscreen_widgets(vs_name, vs)
_render_viewscreen_widgets(vs_name, vs)
_render_viewscreen_widgets('all', nil)
if force_refresh then
force_refresh = nil
df.global.gps.force_full_display_count = 1
end
end
-- called when the DF window is resized
function reposition_widgets()
local full, scaled = get_interface_rects()
for _,db_entry in pairs(widget_db) do
local widget = db_entry.widget
widget:updateLayout(widget.fullscreen and full or scaled)
end
force_refresh = true
end
-- ------------------------------------------------- --
-- OverlayWidget (base class of all overlay widgets) --
-- ------------------------------------------------- --
OverlayWidget = defclass(OverlayWidget, widgets.Panel)
OverlayWidget.ATTRS{
name=DEFAULT_NIL, -- this is set by the framework to the widget name
desc=DEFAULT_NIL, -- add a short description (<100 chars); displays in control panel
default_pos={x=DEFAULT_X_POS, y=DEFAULT_Y_POS}, -- 1-based widget screen pos
default_enabled=false, -- initial enabled state if not in config
fullscreen=false, -- true if widget covers entire screen
full_interface=false, -- true if widget covers entire interface area
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
-- ------------------- --
-- TitleVersionOverlay --
-- ------------------- --
TitleVersionOverlay = defclass(TitleVersionOverlay, OverlayWidget)
TitleVersionOverlay.ATTRS{
desc='Show DFHack version number and quick links on the DF title page.',
default_pos={x=11, y=1},
version=2,
default_enabled=true,
viewscreens='title/Default',
frame={w=35, h=5},
autoarrange_subviews=1,
}
function TitleVersionOverlay:init()
local text = {}
table.insert(text, 'DFHack ' .. dfhack.getDFHackVersion() ..
(dfhack.isRelease() and '' or (' (git: %s)'):format(dfhack.getGitCommit(true))))
if #dfhack.getDFHackBuildID() > 0 then
table.insert(text, NEWLINE)
table.insert(text, 'Build ID: ' .. dfhack.getDFHackBuildID())
end
if dfhack.isPrerelease() then
table.insert(text, NEWLINE)
table.insert(text, {text='Pre-release build', pen=COLOR_LIGHTRED})
end
for _,t in ipairs(text) do
self.frame.w = math.max(self.frame.w, #t)
end
self:addviews{
widgets.Label{
frame={t=0, l=0},
text=text,
text_pen=COLOR_WHITE,
},
widgets.HotkeyLabel{
frame={l=0},
label='Quickstart guide',
auto_width=true,
key='STRING_A063',
on_activate=function() dfhack.run_script('quickstart-guide') end,
},
widgets.HotkeyLabel{
frame={l=0},
label='Control panel',
auto_width=true,
key='STRING_A047',
on_activate=function() dfhack.run_script('gui/control-panel') end,
},
}
end
OVERLAY_WIDGETS = {
title_version = TitleVersionOverlay,
}
return _ENV