-
Notifications
You must be signed in to change notification settings - Fork 31
/
Copy pathdisplay.py
1047 lines (871 loc) · 46.9 KB
/
display.py
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
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from talon import Context, Module, actions, app, skia, cron, ctrl, scope, canvas, registry, settings, ui, fs
from talon.types.point import Point2d
import os
import time
import numpy
from typing import Any, Union
from .preferences import HeadUpDisplayUserPreferences
from .theme import HeadUpDisplayTheme
from .event_dispatch import HeadUpEventDispatch
from .widget_manager import HeadUpWidgetManager
from .content.content_builder import HudContentBuilder
from .layout_widget import LayoutWidget
from .widgets.textpanel import HeadUpTextPanel
from .widgets.choicepanel import HeadUpChoicePanel
from .widgets.contextmenu import HeadUpContextMenu
from .content.typing import HudPanelContent, HudButton, HudContentEvent, HudContentPage
from .content.poller import Poller
from .utils import string_to_speakable_string
# Taken from knausj/code/numbers to make Talon HUD standalone
# The numbers should realistically stay very low for choices, because you don't want choice overload for the user, up to 100
digits = "zero one two three four five six seven eight nine".split()
teens = "ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen".split()
tens = "twenty thirty forty fifty sixty seventy eighty ninety".split()
digits_without_zero = digits[1:]
numerical_choice_index_map = {}
numerical_choice_strings = []
numerical_choice_strings.extend(digits_without_zero)
numerical_choice_strings.extend(teens)
for digit_index, digit in enumerate(digits):
numerical_choice_index_map[digit] = digit_index
for digit_index, digit_plus_ten in enumerate(teens):
numerical_choice_index_map[digit_plus_ten] = digit_index + 10
for index, ten in enumerate(tens):
numerical_choice_strings.append(ten)
numerical_choice_index_map[ten] = (index + 1) * 10 + 10
for digit_index, digit in enumerate(digits_without_zero):
numerical_choice_strings.append(ten + " " + digit)
numerical_choice_index_map[ten + " " + digit] = ( index + 2 ) * 10 + digit_index + 1
numerical_choice_strings.append("one hundred")
numerical_choice_index_map["one hundred"] = 100
ctx = Context()
mod = Module()
mod.list("talon_hud_widget_names", desc="List of available widgets by name linked to their identifier")
mod.list("talon_hud_widget_options", desc="List of options available to the widgets")
mod.list("talon_hud_choices", desc="Available choices shown on screen")
mod.list("talon_hud_themes", desc="Available themes for the Talon HUD")
mod.list("talon_hud_numerical_choices", desc="Available choices shown on screen numbered")
mod.list("talon_hud_quick_choices", desc="List of widgets with their quick options")
mod.list("talon_hud_widget_enabled_voice_commands", desc="List of extra voice commands added by visible widgets")
mod.tag("talon_hud_available", desc="Tag that shows the availability of the Talon HUD repository for other scripts")
mod.tag("talon_hud_visible", desc="Tag that shows that the Talon HUD is visible")
mod.tag("talon_hud_choices_visible", desc="Tag that shows there are choices available on screen that can be chosen")
mod.setting("talon_hud_environment", type="string", desc="Which environment to set the HUD in - Useful for setting up a HUD for screen recording or other tasks")
ctx.tags = ["user.talon_hud_available"]
ctx.settings["user.talon_hud_environment"] = ""
ctx.lists["user.talon_hud_widget_enabled_voice_commands"] = []
# A list of Talon HUD versions that can be used to check for in other packages
TALON_HUD_RELEASE_030 = 3 # Walk through version
TALON_HUD_RELEASE_040 = 4 # Multi-monitor version
TALON_HUD_RELEASE_050 = 5 # Debugging / screen overlay release
TALON_HUD_RELEASE_060 = 6 # Persistent content release
TALON_HUD_RELEASE_070 = 7 # Keyboard control release
CURRENT_TALON_HUD_VERSION = TALON_HUD_RELEASE_070
@mod.scope
def scope():
return {"talon_hud_version": CURRENT_TALON_HUD_VERSION}
class HeadUpDisplay:
enabled = False
display_state = None
preferences = None
theme = None
event_dispatch = None
pollers = []
keep_alive_pollers = [] # These pollers will only deactivate when the hud deactivates
custom_themes = {}
disable_poller_job = None
show_animations = False
choices_visible = False
allowed_content_operations = ["*"]
allow_update_context = True
current_flow = ""
auto_focus = False
focus_grace_period = 0
start_idle_period = 0
prev_mouse_pos = None
mouse_poller = None
current_talon_hud_environment = ""
enabled_voice_commands = {}
update_preferences_debouncer = None
update_context_debouncer = None
update_environment_debouncer = None
watching_directories = False
def __init__(self, preferences):
self.preferences = preferences
self.pollers = {}
self.keep_alive_pollers = []
self.disable_poller_job = None
self.theme = HeadUpDisplayTheme(self.preferences.prefs["theme_name"])
self.event_dispatch = HeadUpEventDispatch()
self.show_animations = self.preferences.prefs["show_animations"]
self.widget_manager = HeadUpWidgetManager(self.preferences, self.theme, self.event_dispatch)
def start(self, current_flow="initialize"):
self.set_current_flow(current_flow)
self.current_talon_hud_environment = settings.get("user.talon_hud_environment", "")
if (self.preferences.prefs["enabled"]):
self.enable()
ctx.tags = ["user.talon_hud_available", "user.talon_hud_visible", "user.talon_hud_choices_visible"]
if actions.sound.active_microphone() == "None":
actions.user.hud_add_log("warning", "Microphone is set to \"None\"!\n\nNo voice commands will be registered.")
self.set_current_flow("manual")
self.distribute_content()
# Make sure auto focusing can only start a second after the HUD has started up
# To make sure the content updating does not fling the focus for the user everywhere during booting
cron.after("1s", lambda self=self: self.set_auto_focus(self.preferences.prefs["auto_focus"]))
def enable(self, persisted=False):
if not self.enabled:
self.enabled = True
self.display_state.register("broadcast_update", self.broadcast_update)
# Only reset the talon HUD environment after a user action
# And only set the visible tag
self.current_talon_hud_environment = settings.get("user.talon_hud_environment", "")
if persisted:
self.set_current_flow("enabled")
self.current_flow = "enable"
ctx.tags = ["user.talon_hud_available", "user.talon_hud_visible", "user.talon_hud_choices_visible"]
# Connect the events relating to non-content communication
self.event_dispatch.register("persist_preferences", self.debounce_widget_preferences)
self.event_dispatch.register("hide_context_menu", self.hide_context_menu)
self.event_dispatch.register("deactivate_poller", self.deactivate_poller)
self.event_dispatch.register("show_context_menu", self.move_context_menu)
self.event_dispatch.register("synchronize_poller", self.synchronize_widget_poller)
self.event_dispatch.register("detect_autofocus", self.update_focus_grace_period)
# Reload the preferences just in case a screen change happened in between the hidden state
if persisted or self.current_flow in ["repair", "initialize"]:
reload_theme = self.widget_manager.reload_preferences(True, self.current_talon_hud_environment)
if reload_theme != self.theme.name:
self.switch_theme(reload_theme, True)
for widget in self.widget_manager.widgets:
if widget.preferences.enabled and not widget.enabled:
widget.enable()
self.synchronize_pollers()
ui.register("screen_change", self.reload_preferences)
settings.register("user.talon_hud_environment", self.hud_environment_change)
self.determine_active_setup_mouse()
if persisted:
self.preferences.persist_preferences({"enabled": True})
self.set_current_flow("manual")
# Make sure context isn't updated in this thread because of automatic reloads
cron.cancel(self.update_context_debouncer)
self.update_context_debouncer = cron.after("50ms", self.update_context)
def disable(self, persisted=False):
if self.enabled:
self.set_current_flow("disabled" if persisted else "auto_disabled")
self.enabled = False
for widget in self.widget_manager.widgets:
if widget.enabled:
widget.disable()
self.synchronize_pollers()
# Disconnect the events relating to non-content communication
self.event_dispatch.unregister("persist_preferences", self.debounce_widget_preferences)
self.event_dispatch.unregister("hide_context_menu", self.hide_context_menu)
self.event_dispatch.unregister("deactivate_poller", self.deactivate_poller)
self.event_dispatch.unregister("show_context_menu", self.move_context_menu)
self.event_dispatch.unregister("synchronize_poller", self.synchronize_widget_poller)
self.event_dispatch.unregister("detect_autofocus", self.update_focus_grace_period)
self.disable_poller_job = cron.interval("30ms", self.disable_poller_check)
self.display_state.unregister("broadcast_update", self.broadcast_update)
ui.unregister("screen_change", self.reload_preferences)
settings.unregister("user.talon_hud_environment", self.hud_environment_change)
self.determine_active_setup_mouse()
# Only change the tags upon a user action - No automatic flow should set tags to prevent cascades
if persisted:
ctx.tags = ["user.talon_hud_available"]
self.preferences.persist_preferences({"enabled": False})
# Make sure context isn't updated in this thread because of automatic reloads
cron.cancel(self.update_context_debouncer)
self.update_context_debouncer = cron.after("50ms", self.update_context)
# Persist the preferences of all the widgets
def persist_widgets_preferences(self, _ = None):
dict = {}
for widget in self.widget_manager.widgets:
if widget.preferences.mark_changed:
dict = {**dict, **widget.preferences.export(widget.id)}
widget.preferences.mark_changed = False
self.preferences.persist_preferences(dict)
self.determine_active_setup_mouse()
# Debounce the widget preference persistence to make sure we do not get a ton of persisting operations
def debounce_widget_preferences(self, _ = None):
cron.cancel(self.update_preferences_debouncer)
self.update_preferences_debouncer = cron.after("100ms", self.persist_widgets_preferences)
def enable_id(self, id):
self.set_current_flow("widget_enabled")
if not self.enabled:
self.enable()
for widget in self.widget_manager.widgets:
if not widget.enabled and widget.id == id:
widget.enable(True)
for topic, poller in self.pollers.items():
if topic in widget.current_topics and (not hasattr(self.pollers[topic], "enabled") or not self.pollers[topic].enabled):
self.pollers[topic].enable()
self.update_context()
break
self.set_current_flow("manual")
def disable_id(self, id):
self.set_current_flow("widget_disabled")
for widget in self.widget_manager.widgets:
if widget.enabled and widget.id == id:
widget.disable(True)
self.synchronize_widget_poller(widget.id)
self.update_context()
break
self.determine_active_setup_mouse()
self.set_current_flow("manual")
def subscribe_content_id(self, id, content_key):
self.set_current_flow("content_changed")
for widget in self.widget_manager.widgets:
if widget.id == id:
if content_key not in widget.subscriptions:
widget.subscriptions.append(content_key)
self.set_current_flow("manual")
def unsubscribe_content_id(self, id, content_key):
self.set_current_flow("content_changed")
for widget in self.widget_manager.widgets:
if widget.id == id:
if content_key in widget.subscriptions:
widget.subscriptions.remove(content_key)
self.set_current_flow("manual")
def set_widget_preference(self, id, property, value, persisted=False):
self.set_current_flow("layout_changed")
for widget in self.widget_manager.widgets:
if widget.id == id:
widget.set_preference(property, value, persisted)
self.determine_active_setup_mouse()
self.set_current_flow("manual")
def add_theme(self, theme_name, theme_dir):
if os.path.exists(theme_dir):
self.custom_themes[theme_name] = theme_dir
else:
app.notify("Invalid directory for '" + theme_name + "': " + theme_dir)
# Manages the persistence and event flow depending on what flow we are changing to
def set_current_flow(self, flow):
# Restrict the flow of content events during environment transitions and widget disabling
if flow == "environment_changed":
self.allowed_content_operations = ["remove"]
elif flow == "widget_disabled":
self.allowed_content_operations = ["replace", "append", "patch", "dump"]
elif flow in ["disabled", "auto_disabled"]:
self.allowed_content_operations = []
else:
self.allowed_content_operations = ["*"]
# Temporarily disable preference persisting during the transition between environments
# As content updates during the transition can override previous files
if flow in ["repair", "initialize", "environment_changed"]:
self.preferences.disable()
else:
self.preferences.enable()
# For the repair flow - Turn all the pollers off and on to make sure up to date content is persisted
if flow == "repair":
self.synchronize_pollers(True, False)
elif self.current_flow == "repair":
self.synchronize_pollers(False, True)
self.current_flow = flow
def switch_theme(self, theme_name, disable_animation = False, forced = False):
if self.theme.name != theme_name and not disable_animation and not forced:
self.set_current_flow("theme_changed")
if self.theme.name != theme_name or forced:
should_reset_watch = self.watching_directories
if should_reset_watch:
self.unwatch_directories()
theme_dir = self.custom_themes[theme_name] if theme_name in self.custom_themes else None
self.theme = HeadUpDisplayTheme(theme_name, theme_dir)
for widget in self.widget_manager.widgets:
if disable_animation:
show_animations = widget.show_animations
widget.show_animations = False
widget.set_theme(self.theme)
widget.show_animations = show_animations
else:
widget.set_theme(self.theme)
if self.widget_manager.html_generator:
self.widget_manager.html_generator.set_theme(self.theme)
if should_reset_watch:
self.watch_directories()
self.preferences.persist_preferences({"theme_name": theme_name})
if self.current_flow == "theme_changed":
self.set_current_flow("manual")
def reload_theme(self, name=None, flags=None):
self.theme = HeadUpDisplayTheme(self.theme.name, self.theme.theme_dir)
for widget in self.widget_manager.widgets:
show_animations = widget.show_animations
widget.show_animations = False
widget.set_theme(self.theme)
widget.show_animations = show_animations
def watch_directories(self):
directories = self.theme.get_watch_directories()
for directory in directories:
fs.watch(directory, self.reload_theme)
directories = self.preferences.get_watch_directories()
for directory in directories:
fs.watch(directory, self.debounce_environment_change)
self.watching_directories = True
def unwatch_directories(self):
directories = self.theme.get_watch_directories()
for directory in directories:
fs.unwatch(directory, self.reload_theme)
directories = self.preferences.get_watch_directories()
for directory in directories:
fs.unwatch(directory, self.debounce_environment_change)
self.watching_directories = False
def set_widget_visibility(self, visible: bool = False):
for widget in self.widget_manager.widgets:
widget.set_visibility(visible)
def start_setup_id(self, id, setup_type, mouse_pos = None):
for widget in self.widget_manager.widgets:
if widget.enabled and ( id == "*" or widget.id == id ) and widget.setup_type != setup_type:
widget.start_setup(setup_type, mouse_pos)
self.determine_active_setup_mouse()
def reload_preferences(self, _= None):
"""Reload user preferences ( in case a monitor switches or something )"""
self.widget_manager.reload_preferences(False, self.current_talon_hud_environment)
def connect_internal(self, type: str, data: Any):
"""Connect classes after they are loaded in to make sure they can be reloaded after changes have been made"""
if type == "HeadUpDisplayContent":
if self.enabled:
self.display_state.unregister("broadcast_update", self.broadcast_update)
self.display_state = data
if self.enabled:
self.display_state.register("broadcast_update", self.broadcast_update)
# Reconnect the content object to the pollers
for topic in self.pollers:
if hasattr(self.pollers[topic], "content"):
poller_enabled = hasattr(self.pollers[topic], "enabled") and self.pollers[topic].enabled
if poller_enabled:
self.pollers[topic].disable()
self.pollers[topic].content = HudContentBuilder(self.display_state)
if poller_enabled:
self.pollers[topic].enable()
def distribute_content(self):
"""Distributes the content from the content types to the different widgets"""
self.allow_update_context = False
self.display_state.save_events()
content_dump = self.display_state.get_content_dump()
for widget in self.widget_manager.widgets:
widget.content_handler(content_dump)
self.update_context()
self.allow_update_context = True
self.display_state.flush_events()
def register_poller(self, topic: str, poller: Poller, keep_alive: bool):
self.remove_poller(topic)
self.pollers[topic] = poller
# Add a content builder to the poller
if hasattr(self.pollers[topic], "content") and self.display_state:
poller.content = HudContentBuilder(self.display_state)
# Keep the poller alive even if no widgets have subscribed to its topic
if keep_alive and not self.pollers[topic].enabled:
self.keep_alive_pollers.append(topic)
self.pollers[topic].enable()
# Automatically enable the poller if it was active on restart
else:
enabled = False
for widget in self.widget_manager.widgets:
if topic in widget.current_topics and widget.enabled and \
(not hasattr(self.pollers[topic], "enabled") or not self.pollers[topic].enabled):
self.pollers[topic].enable()
enabled = True
break
def remove_poller(self, topic: str):
if topic in self.pollers:
self.pollers[topic].disable()
if hasattr(self.pollers[topic], "content") and self.pollers[topic].content:
self.pollers[topic].content.content = None
self.pollers[topic].content = None
del self.pollers[topic]
def deactivate_poller(self, topic: str):
if topic in self.pollers:
self.pollers[topic].disable()
def activate_poller(self, topic: str):
# Enable the poller afterwards
if topic in self.pollers and \
topic not in self.keep_alive_pollers and \
(not hasattr(self.pollers[topic], "enabled") or not self.pollers[topic].enabled):
self.pollers[topic].enable()
def synchronize_pollers(self, disable_pollers = True, enable_pollers = True):
attached_topics = list(self.keep_alive_pollers)
if self.enabled:
for widget in self.widget_manager.widgets:
if widget.current_topics and widget.enabled:
attached_topics.extend(widget.current_topics)
# First - Disable all pollers from making content updates to prevent race conditions with content events from occurring
if disable_pollers:
for topic, poller in self.pollers.items():
if topic not in attached_topics and (hasattr(self.pollers[topic], "enabled") and self.pollers[topic].enabled):
self.pollers[topic].disable()
# Then - Automatically start pollers that are connected to widgets
if enable_pollers:
for topic, poller in self.pollers.items():
if topic in attached_topics and (not hasattr(self.pollers[topic], "enabled") or not self.pollers[topic].enabled):
self.pollers[topic].enable()
# Synchronize the pollers attached to a single widget
def synchronize_widget_poller(self, widget_id):
current_topics = []
for widget in self.widget_manager.widgets:
if widget.id == widget_id:
current_topics = widget.current_topics
break
for topic in current_topics:
if topic in self.pollers and topic not in self.keep_alive_pollers:
if widget.enabled and (not hasattr(self.pollers[topic], "enabled") or not self.pollers[topic].enabled):
self.pollers[topic].enable()
elif not widget.enabled and (hasattr(self.pollers[topic], "enabled") and self.pollers[topic].enabled):
self.pollers[topic].disable()
# Check if the widgets are finished unloading, then disable the poller
# This should only run when we have a state poller
def disable_poller_check(self):
enabled = False
for widget in self.widget_manager.widgets:
if not widget.cleared:
enabled = True
break
if not enabled:
for topic, poller in self.pollers.items():
poller.disable()
cron.cancel(self.disable_poller_job)
self.disable_poller_job = None
def broadcast_update(self, event: HudContentEvent):
# Do not force a reopen of Talon HUD without explicit user permission
updated = False
if not self.enabled:
event.show = False
# Restrict the content flow to only the allowed operations
if "*" not in self.allowed_content_operations and event.operation not in self.allowed_content_operations:
return
# Claim a widget and unregister its pollers
if event.claim > 0:
topic = event.topic
using_fallback = True
widget_to_claim = None
widgets_with_topic = []
for widget in self.widget_manager.widgets:
if event.topic_type in widget.topic_types and topic in widget.current_topics:
widgets_with_topic.append(widget)
if event.topic_type in widget.topic_types and ( topic in widget.subscriptions or ("*" in widget.subscriptions and using_fallback)):
if topic in widget.current_topics:
widget_to_claim = widget
else:
widget_to_claim = widget
if topic in widget.subscriptions:
using_fallback = False
if widget_to_claim:
# When a new topic is published it can lay claim to a widget
# So old pollers need to be deregistered in that case
for widget in widgets_with_topic:
if widget.id != widget_to_claim.id:
widget.clear_topic(event.topic)
updated = widget_to_claim.content_handler(event)
# Check if we need to autofocus the content
if event.show and time.time() < self.focus_grace_period:
if widget_to_claim.accessible_tree:
self.event_dispatch.focus_path(widget_to_claim.accessible_tree.path)
if not self.auto_focus:
self.focus_grace_period = 0
else:
for widget in self.widget_manager.widgets:
if event.topic_type == "variable" or (event.topic_type in widget.topic_types and \
(event.topic in widget.subscriptions or \
("*" in widget.subscriptions and "!" + event.topic not in widget.subscriptions))):
current_enabled_state = widget.enabled
updated = widget.content_handler(event)
if widget.enabled != current_enabled_state:
if event.topic in self.pollers and event.topic not in self.keep_alive_pollers:
if widget.enabled:
self.pollers[event.topic].enable()
else:
self.pollers[event.topic].disable()
# For certain flows that trigger a lot of events we do not allow the pollers to be updated
# As it can lead to faulty state
if self.current_flow not in ["repair", "initialize", "environment_changed"]:
self.synchronize_pollers()
if updated and self.allow_update_context:
self.update_context()
# Determine whether or not we need to have a global mouse poller
# This poller is needed for setup modes as not all canvases block the mouse
def determine_active_setup_mouse(self):
has_setup_modes = False
for widget in self.widget_manager.widgets:
if (widget.setup_type not in ["", "mouse_drag"]):
has_setup_modes = True
break
if has_setup_modes and not self.mouse_poller:
self.mouse_poller = cron.interval("16ms", self.poll_mouse_pos_for_setup)
if not has_setup_modes and self.mouse_poller:
cron.cancel(self.mouse_poller)
self.mouse_poller = None
# Send mouse events to enabled widgets that have an active setup going on
def poll_mouse_pos_for_setup(self):
pos = ctrl.mouse_pos()
if (self.prev_mouse_pos is None or numpy.linalg.norm(numpy.array(pos) - numpy.array(self.prev_mouse_pos)) > 1):
self.prev_mouse_pos = pos
for widget in self.widget_manager.widgets:
if widget.enabled and widget.setup_type != "":
widget.setup_move(self.prev_mouse_pos)
# Increase the page number by one on the widget if it is enabled
def increase_widget_page(self, widget_id: str):
for widget in self.widget_manager.widgets:
if widget.enabled and widget.id == widget_id and isinstance(widget, LayoutWidget):
widget.set_page_index(widget.page_index + 1)
# Decrease the page number by one on the widget if it is enabled
def decrease_widget_page(self, widget_id: str):
for widget in self.widget_manager.widgets:
if widget.enabled and widget.id == widget_id and isinstance(widget, LayoutWidget):
widget.set_page_index(widget.page_index - 1)
# Get the current page data
def get_widget_pagination(self, widget_id: str) -> HudContentPage:
page = HudContentPage(0, 0, 0)
for widget in self.widget_manager.widgets:
if widget.enabled and widget.id == widget_id and isinstance(widget, LayoutWidget):
page = widget.get_content_page()
return page
# Move the context menu over to the given location fitting within the screen
def move_context_menu(self, widget_id: str, pos: Point2d, buttons: list[HudButton]):
connected_widget = None
context_menu_widget = None
for widget in self.widget_manager.widgets:
if widget.enabled and widget.id == widget_id:
connected_widget = widget
elif widget.id == "context_menu":
context_menu_widget = widget
if connected_widget and context_menu_widget:
# Determine position based on available space - TODO Make sure context menu does not overlap
if pos is None:
pos_x = connected_widget.x + connected_widget.width / 2
pos_y = connected_widget.y + connected_widget.height if connected_widget.y < 500 else connected_widget.y - 10
pos = Point2d(pos_x, pos_y)
context_menu_widget.connect_widget(connected_widget, pos.x, pos.y, buttons)
self.update_context()
# Auto focus the context menu if we have auto focus enabled
if self.auto_focus and context_menu_widget.current_focus is None and connected_widget.accessible_tree:
for node in connected_widget.accessible_tree.nodes:
if node.role == "context_menu" and len(node.nodes) > 0:
context_menu_widget.current_focus = node
self.event_dispatch.focus_path(node.path)
# Connect the context menu using voice
def connect_context_menu(self, widget_id):
connected_widget = None
context_menu_widget = None
for widget in self.widget_manager.widgets:
if widget.enabled and widget.id == widget_id:
connected_widget = widget
buttons = []
if connected_widget and not isinstance(connected_widget, HeadUpContextMenu):
buttons = connected_widget.buttons
self.move_context_menu(connected_widget.id, None, buttons)
# Hide the context menu
# Generally you want to do this when you click outside of the menu itself
def hide_context_menu(self, _ = None):
context_menu_widget = None
for widget in self.widget_manager.widgets:
if widget.id == "context_menu" and widget.enabled:
context_menu_widget = widget
break
if context_menu_widget:
context_menu_widget.disconnect_widget()
self.update_context()
# Active a given choice for a given widget
def activate_choice(self, choice_string):
widget_id, choice_index = choice_string.split("|")
for widget in self.widget_manager.widgets:
if widget.id == widget_id:
if isinstance(widget, HeadUpChoicePanel):
widget.select_choice(int(choice_index))
else:
widget.click_button(int(choice_index))
self.update_context()
def activate_enabled_voice_command(self, voice_command):
if voice_command in self.enabled_voice_commands:
self.enabled_voice_commands[voice_command]()
# Updates the context based on the current HUD state
# This needs to be done on user actions - Automatic flows need higher scrutiny
def update_context(self):
widget_names = {}
choices = {}
quick_choices = {}
numerical_choices = {}
themes = {}
enabled_voice_commands = {}
themes_directory = os.path.dirname(os.path.abspath(__file__)) + "/themes"
themes_list = os.listdir(themes_directory)
for theme in themes_list:
if theme != "_base_theme":
themes[string_to_speakable_string(theme)] = theme
for custom_theme_name in self.custom_themes:
themes[string_to_speakable_string(custom_theme_name)] = custom_theme_name
for widget in self.widget_manager.widgets:
current_widget_names = [string_to_speakable_string(widget.id)]
if isinstance(widget, HeadUpTextPanel):
content_title = string_to_speakable_string(widget.panel_content.title)
if content_title:
current_widget_names.append(string_to_speakable_string(widget.panel_content.title))
for widget_name in current_widget_names:
widget_names[widget_name] = widget.id
# Add quick choices
for index, button in enumerate(widget.buttons):
choice_title = string_to_speakable_string(button.text)
if choice_title:
for widget_name in current_widget_names:
quick_choices[widget_name + " " + choice_title] = widget.id + "|" + str(index)
# Add context choices
if widget.enabled and isinstance(widget, HeadUpContextMenu):
for index, button in enumerate(widget.buttons):
choice_title = string_to_speakable_string(button.text)
if choice_title:
choices[choice_title] = widget.id + "|" + str(index)
# Add extra voice commands ( for instance, ones alluded to in text )
if widget.enabled and isinstance(widget, HeadUpTextPanel) and widget.panel_content.voice_commands:
for index, voice_command in enumerate(widget.panel_content.voice_commands):
enabled_voice_command = string_to_speakable_string(voice_command.command)
if enabled_voice_command:
enabled_voice_commands[enabled_voice_command] = voice_command.callback
# Add choice panel choices
if widget.enabled and isinstance(widget, HeadUpChoicePanel):
self.choices_visible = True
for index, choice in enumerate(widget.choices):
choice_title = string_to_speakable_string(choice.text)
if choice_title:
choices[choice_title] = widget.id + "|" + str(index)
numerical_choices[numerical_choice_strings[index]] = widget.id + "|" + str(index)
if widget.panel_content.choices and widget.panel_content.choices.multiple:
choices["confirm"] = widget.id + "|" + str(index + 1)
# Make sure the list is never empty to prevent Talon issue #495
# This workaround will be removed when the current beta is merged with the regular version
if len(choices) == 0:
choices["head up choice empty command"] = "|"
ctx.lists["user.talon_hud_numerical_choices"] = numerical_choices
ctx.lists["user.talon_hud_widget_names"] = widget_names
ctx.lists["user.talon_hud_choices"] = choices
ctx.lists["user.talon_hud_quick_choices"] = quick_choices
ctx.lists["user.talon_hud_themes"] = themes
self.enabled_voice_commands = enabled_voice_commands
ctx.lists["user.talon_hud_widget_enabled_voice_commands"] = enabled_voice_commands.keys()
def hud_environment_change(self, hud_environment: str):
if self.current_talon_hud_environment != hud_environment:
self.set_current_flow("environment_changed")
self.current_talon_hud_environment = hud_environment
# Add a debouncer for the environment change to reduce flickering on transitioning
cron.cancel(self.update_environment_debouncer)
self.update_environment_debouncer = cron.after("200ms", self.debounce_environment_change)
def debounce_environment_change(self, _=None, __=None):
reload_theme = self.widget_manager.reload_preferences(True, self.current_talon_hud_environment)
# Switch the theme and make sure there is no lengthy animation between modes
# as they can happen quite frequently
self.switch_theme(reload_theme, True)
# Re-enable the content flow including persistence after transitions have been made
self.synchronize_pollers(disable_pollers=True, enable_pollers=False)
self.set_current_flow("manual")
self.synchronize_pollers(disable_pollers=False, enable_pollers=True)
def destroy(self):
cron.cancel(self.disable_poller_job)
cron.cancel(self.update_environment_debouncer)
if self.event_dispatch is not None:
self.event_dispatch.unregister("persist_preferences", self.debounce_widget_preferences)
self.event_dispatch.unregister("hide_context_menu", self.hide_context_menu)
self.event_dispatch.unregister("deactivate_poller", self.deactivate_poller)
self.event_dispatch.unregister("show_context_menu", self.move_context_menu)
self.event_dispatch.unregister("synchronize_poller", self.synchronize_widget_poller)
self.event_dispatch = None
ui.unregister('screen_change', self.reload_preferences)
settings.unregister("user.talon_hud_environment", self.hud_environment_change)
if self.display_state:
self.display_state.unregister('broadcast_update', self.broadcast_update)
self.display_state = None
if self.enabled:
for widget in self.widget_manager.widgets:
show_animations = widget.show_animations
widget.show_animations = False
widget.disable()
widget.show_animations = show_animations
self.widget_manager.destroy()
self.widget_manager = None
# ---------- KEYBOARD FOCUS METHODS ---------- #
def focus(self):
if self.enabled:
self.widget_manager.focus()
def blur(self):
if self.enabled:
self.widget_manager.blur()
def toggle_focus(self):
if self.enabled:
if self.widget_manager.is_focused():
self.widget_manager.blur()
else:
self.widget_manager.focus()
def focus_widget(self, widget_id: str, node_id: int = -1):
if self.enabled:
self.widget_manager.focus(widget_id, node_id)
# Keep a grace period to automatically focus content that shows up in the next N seconds
def update_focus_grace_period(self):
if not self.auto_focus:
self.focus_grace_period = time.time() + 1
def set_auto_focus(self, auto_focus: bool, persisted = False):
self.auto_focus = auto_focus
# Set the grace period
self.focus_grace_period = time.time() * 50000 if auto_focus else 0
if persisted:
self.preferences.persist_preferences({"auto_focus": auto_focus})
preferences = HeadUpDisplayUserPreferences("", CURRENT_TALON_HUD_VERSION)
hud = HeadUpDisplay(preferences)
def hud_start():
global hud
actions.user.hud_internal_register("HeadUpDisplay", hud)
app.register('ready', hud_start)
@mod.action_class
class Actions:
def hud_enable():
"""Enables the HUD"""
global hud
hud.enable(True)
def hud_disable():
"""Disables the HUD"""
global hud
hud.disable(True)
def hud_persist_preferences():
"""Saves the HUDs preferences"""
global hud
hud.debounce_widget_preferences()
def hud_enable_id(id: str):
"""Enables a specific HUD element"""
global hud
hud.enable_id(id)
def hud_set_widget_preference(id: str, property: str, value: Any):
"""Set a specific widget preference"""
global hud
hud.set_widget_preference(id, property, value, True)
def hud_widget_subscribe_topic(id: str, topic: str):
"""Subscribe to a specific type of content on a widget"""
global hud
hud.subscribe_content_id(id, topic)
def hud_widget_unsubscribe_topic(id: str, topic: str):
"""Unsubscribe from a specific type of content on a widget"""
global hud
hud.unsubscribe_content_id(id, topic)
def hud_disable_id(id: str):
"""Disables a specific HUD element"""
global hud
hud.disable_id(id)
def hud_switch_theme(theme_name: str):
"""Switches the UI theme"""
global hud
hud.switch_theme(theme_name)
def hud_set_setup_mode(id: str, setup_mode: str):
"""Starts a setup mode which can change position"""
global hud
hud.start_setup_id(id, setup_mode)
def hud_set_setup_mode_multi(ids: list[str], setup_mode: str):
"""Starts a setup mode which can change position for multiple widgets at the same time"""
global hud
# In case we are dealing with drag, we can allow multiple widgets to be dragged at the same time
mouse_pos = None
if (len(ids) > 1 and setup_mode == "position"):
mouse_pos = ctrl.mouse_pos()
for id in ids:
hud.start_setup_id(id, setup_mode, mouse_pos)
def hud_show_context_menu(widget_id: str, pos_x: int, pos_y: int, buttons: list[HudButton]):
"""Show the context menu for a specific widget id"""
hud.move_context_menu(widget_id, Point2d(pos_x, pos_y), buttons)
def hud_hide_context_menu():
"""Show the context menu for a specific widget id"""
hud.hide_context_menu()
def hud_increase_widget_page(widget_id: str):
"""Increase the content page of the widget if it has pages available"""
global hud
hud.increase_widget_page(widget_id)
def hud_decrease_widget_page(widget_id: str):
"""Decrease the content page of the widget if it has pages available"""
global hud
hud.decrease_widget_page(widget_id)
def hud_get_widget_pagination(widget_id: str) -> HudContentPage:
"""Get the pagination information of the widget"""
global hud
return hud.get_widget_pagination(widget_id)
def hud_widget_options(widget_id: str):
"""Connect the widget to the context menu to show the options"""
global hud
hud.connect_context_menu(widget_id)
def hud_activate_choice(choice_string: str):
"""Activate a choice available on the screen"""
global hud
hud.activate_choice(choice_string)
def hud_activate_enabled_voice_command(enabled_voice_command: str):
"""Activate a defined voice command attached to an enabled widget"""
global hud
hud.activate_enabled_voice_command(enabled_voice_command)
def hud_activate_choices(choice_string_list: list[str]):
"""Activate multiple choices available on the screen"""
global hud
for choice_string in choice_string_list:
hud.activate_choice(choice_string)
def hud_add_poller(topic: str, poller: Poller, keep_alive: bool = False):
"""Add a content poller / listener to the HUD"""
global hud
if keep_alive and topic not in hud.keep_alive_pollers:
hud.keep_alive_pollers.append(topic)
actions.user.hud_internal_register("Poller", poller, topic)
def hud_remove_poller(topic: str):
"""Remove a content poller / listener to the HUD"""
global hud
hud.remove_poller(topic)
def hud_activate_poller(topic: str):
"""Enables a poller and claims a widget"""
global hud
hud.activate_poller(topic)
def hud_deactivate_poller(topic: str):
"""Disables a poller"""
global hud
hud.deactivate_poller(topic)
def hud_get_theme() -> HeadUpDisplayTheme:
"""Get the current theme object from the HUD"""
global hud
return hud.theme
def hud_register_theme(theme_name: str, theme_dir: str):
"""Add a theme directory from outside of the HUD to the possible themes"""
global hud
hud.add_theme(theme_name, theme_dir)
def hud_watch_directories():
"""Watch the theme and preferences directories for changes - This gives a performance penalty and should only be used during development"""
global hud
hud.watch_directories()
def hud_unwatch_directories():
"""Stop watching for changes in the theme directories"""
global hud
hud.unwatch_directories()
def hud_widget_focus(widget_id: str, node_id: int = -1):
"""Focus a specific widget available in the HUD"""
global hud