Files
blender/tests/python/ui_simulate/modules/easy_keys.py
Sean Kim f1c87d1bd2 Fix: Multi-window UI tests can produce inconsistent results
After creating a new scene in a separate window when performing UI
tests, the respective view layer for the window may not be updated
immediately in the event loop.

Previously, this was mitigated with a single `yield` statement that
would delay processing by a single tick. To fix this issue, this commit
adds the capability to yield for a specific `timedelta` and waits this
amount of time for the two affected tests.

Pull Request: https://projects.blender.org/blender/blender/pulls/136012
2025-04-02 21:38:05 +02:00

367 lines
11 KiB
Python

# SPDX-FileCopyrightText: 2019-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import datetime
import string
import bpy
event_types = tuple(
e.identifier.lower()
for e in bpy.types.Event.bl_rna.properties["type"].enum_items_static
)
del bpy
# We don't normally care about which one.
event_types_alias = {
"ctrl": "left_ctrl",
"shift": "left_shift",
"alt": "left_alt",
# Collides with Python keywords.
"delete": "del",
}
# Note, we could add support for other keys using control characters,
# for example: `\xF12` could be used for the F12 key.
#
# Besides this, we could encode symbols into a regular string using our own syntax
# which can mix regular text and key symbols.
#
# At the moment this doesn't seem necessary, no need to add it.
event_types_text = (
('ZERO', "0", False),
('ONE', "1", False),
('TWO', "2", False),
('THREE', "3", False),
('FOUR', "4", False),
('FIVE', "5", False),
('SIX', "6", False),
('SEVEN', "7", False),
('EIGHT', "8", False),
('NINE', "9", False),
('ONE', "!", True),
('TWO', "@", True),
('THREE', "#", True),
('FOUR', "$", True),
('FIVE', "%", True),
('SIX', "^", True),
('SEVEN', "&", True),
('EIGHT', "*", True),
('NINE', "(", True),
('ZERO', ")", True),
('MINUS', "-", False),
('MINUS', "_", True),
('EQUAL', "=", False),
('EQUAL', "+", True),
('ACCENT_GRAVE', "`", False),
('ACCENT_GRAVE', "~", True),
('LEFT_BRACKET', "[", False),
('LEFT_BRACKET', "{", True),
('RIGHT_BRACKET', "]", False),
('RIGHT_BRACKET', "}", True),
('SEMI_COLON', ";", False),
('SEMI_COLON', ":", True),
('PERIOD', ".", False),
('PERIOD', ">", True),
('COMMA', ",", False),
('COMMA', "<", True),
('QUOTE', "'", False),
('QUOTE', '"', True),
('SLASH', "/", False),
('SLASH', "?", True),
('BACK_SLASH', "\\", False),
('BACK_SLASH', "|", True),
*((ch_upper, ch, False) for (ch_upper, ch) in zip(string.ascii_uppercase, string.ascii_lowercase)),
*((ch, ch, True) for ch in string.ascii_uppercase),
('SPACE', " ", False),
('RET', "\n", False),
('TAB', "\t", False),
)
event_types_text_from_char = {ch: (ty, is_shift) for (ty, ch, is_shift) in event_types_text}
event_types_text_from_event = {(ty, is_shift): ch for (ty, ch, is_shift) in event_types_text}
class _EventBuilder:
__slots__ = (
"_shared_event_gen",
"_event_type",
"_parent",
)
def __init__(self, event_gen, ty):
self._shared_event_gen = event_gen
self._event_type = ty
self._parent = None
def __call__(self, count=1):
assert count >= 0
for _ in range(count):
self.tap()
return self._shared_event_gen
def _key_press_release(self, do_press=False, do_release=False, unicode_override=None):
assert (do_press or do_release)
keys_held = self._shared_event_gen._event_types_held
build_keys = []
e = self
while e is not None:
build_keys.append(e._event_type.upper())
e = e._parent
build_keys.reverse()
events = [None, None]
for i, value in enumerate(('PRESS', 'RELEASE')):
if value == 'RELEASE':
build_keys.reverse()
for event_type in build_keys:
if value == 'PRESS':
keys_held.add(event_type)
else:
keys_held.remove(event_type)
if (not do_press) and value == 'PRESS':
continue
if (not do_release) and value == 'RELEASE':
continue
shift = 'LEFT_SHIFT' in keys_held or 'RIGHT_SHIFT' in keys_held
ctrl = 'LEFT_CTRL' in keys_held or 'RIGHT_CTRL' in keys_held
shift = 'LEFT_SHIFT' in keys_held or 'RIGHT_SHIFT' in keys_held
alt = 'LEFT_ALT' in keys_held or 'RIGHT_ALT' in keys_held
oskey = 'OSKEY' in keys_held
hyper = 'HYPER' in keys_held
unicode = None
if value == 'PRESS':
if ctrl is False and alt is False and oskey is False:
if unicode_override is not None:
unicode = unicode_override
else:
unicode = event_types_text_from_event.get((event_type, shift))
if unicode is None and shift:
# Some keys don't care about shift
unicode = event_types_text_from_event.get((event_type, False))
event = self._shared_event_gen.window.event_simulate(
type=event_type,
value=value,
unicode=unicode,
shift=shift,
ctrl=ctrl,
alt=alt,
oskey=oskey,
hyper=hyper,
x=self._shared_event_gen._mouse_co[0],
y=self._shared_event_gen._mouse_co[1],
)
events[i] = event
return tuple(events)
def tap(self):
return self._key_press_release(do_press=True, do_release=True)
def press(self):
return self._key_press_release(do_press=True)[0]
def release(self):
return self._key_press_release(do_release=True)[1]
def cursor_motion(self, coords):
coords = list(coords)
self._shared_event_gen.cursor_position_set(*coords[0], move=True)
yield
event = self.press()
shift = event.shift
ctrl = event.ctrl
shift = event.shift
alt = event.alt
oskey = event.oskey
hyper = event.hyper
yield
for x, y in coords:
self._shared_event_gen.window.event_simulate(
type='MOUSEMOVE',
value='NOTHING',
unicode=None,
shift=shift,
ctrl=ctrl,
alt=alt,
oskey=oskey,
hyper=hyper,
x=x,
y=y
)
yield
self._shared_event_gen.cursor_position_set(x, y, move=False)
self.release()
yield
def __getattr__(self, attr):
attr = event_types_alias.get(attr, attr)
if attr in event_types:
e = _EventBuilder(self._shared_event_gen, attr)
e._parent = self
return e
raise Exception(f"{attr!r} not found in {event_types!r}")
class EventGenerate:
__slots__ = (
"window",
"_mouse_co",
"_event_types_held",
)
def __init__(self, window):
self.window = window
self._mouse_co = [0, 0]
self._event_types_held = set()
self.cursor_position_set(window.width // 2, window.height // 2)
def cursor_position_set(self, x, y, move=False):
self._mouse_co[:] = x, y
if move:
self.window.event_simulate(
type='MOUSEMOVE',
value='NOTHING',
x=x,
y=y,
)
def text(self, text):
""" Type in entire phrases. """
for ch in text:
ty, shift = event_types_text_from_char[ch]
ty = ty.lower()
if shift:
eb = getattr(_EventBuilder(self, 'left_shift'), ty)
else:
eb = _EventBuilder(self, ty)
eb.tap()
return self
def text_unicode(self, text):
# Since the only purpose of this key-press is to enter text
# the key can be almost anything, use a key which isn't likely to be assigned ot any other action.
#
# If it were possible `EVT_UNKNOWNKEY` would be most correct
# as dead keys map to this and still enter text.
ty_dummy = 'F24'
for ch in text:
eb = _EventBuilder(self, ty_dummy)
eb._key_press_release(do_press=True, do_release=True, unicode_override=ch)
return self
def __getattr__(self, attr):
attr = event_types_alias.get(attr, attr)
if attr in event_types:
return _EventBuilder(self, attr)
raise Exception(f"{attr!r} not found in {event_types!r}")
def __del__(self):
if self._event_types_held:
print("'__del__' with keys held:", repr(self._event_types_held))
def run(
event_iter, *,
on_error=None,
on_exit=None,
on_step_command_pre=None,
on_step_command_post=None,
):
import bpy
TICKS = 4 # 3 works, 4 to be on the safe side.
def event_step():
# Run once 'TICKS' is reached.
if event_step._ticks < TICKS:
event_step._ticks += 1
return 0.0
event_step._ticks = 0
if on_step_command_pre:
if event_step.run_events.gi_frame is not None:
import shlex
import subprocess
subprocess.call(
shlex.split(
on_step_command_pre.replace(
"{file}", event_step.run_events.gi_frame.f_code.co_filename,
).replace(
"{line}", str(event_step.run_events.gi_frame.f_lineno),
)
)
)
try:
val = next(event_step.run_events, Ellipsis)
except Exception:
import traceback
traceback.print_exc()
if on_error is not None:
on_error()
if on_exit is not None:
on_exit()
return None
if on_step_command_post:
if event_step.run_events.gi_frame is not None:
import shlex
import subprocess
subprocess.call(
shlex.split(
on_step_command_post.replace(
"{file}", event_step.run_events.gi_frame.f_code.co_filename,
).replace(
"{line}", str(event_step.run_events.gi_frame.f_lineno),
)
)
)
if isinstance(val, EventGenerate) or val is None:
return 0.0
elif isinstance(val, datetime.timedelta):
return val.total_seconds()
elif val is Ellipsis:
if on_exit is not None:
on_exit()
return None
else:
raise Exception(f"{val!r} of type {type(val)!r} not supported")
event_step.run_events = iter(event_iter)
event_step._ticks = 0
bpy.app.timers.register(event_step, first_interval=0.0)
def setup_default_preferences(preferences):
""" Set preferences useful for automation.
"""
preferences.view.show_splash = False
preferences.view.smooth_view = 0
preferences.view.use_save_prompt = False
preferences.filepaths.use_auto_save_temporary_files = False