Skip to content

Commit bb3a9b3

Browse files
Merge pull request #214 from jcollins1983/revamp-ui
Revamp UI interactions
2 parents 3f86e4e + 5ad6ce2 commit bb3a9b3

16 files changed

Lines changed: 793 additions & 284 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ venv37
2929
.vscode/settings.json
3030
docs/_build/
3131
.DS_Store
32+
.env

docs/release-notes.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ Improvements
2626
- Created a new FixateError base class for all exceptions raised by fixate to use. It inherits from Exception instead of BaseExcepetion to improve error handling.
2727
- DSO Driver function 'waveform_values' now returns a single channels x and y data as two separate lists, without re-acquiring the signal. This function should
2828
now be called after performing signal acquisition.
29-
- Invert channel and vtime functions implemented in the DSO driver.
29+
- UI has been revamped a little. User input methods will no longer return ("Resp", value) but will return just the value - see Issue #177. To use these, from fixate import user_info etc.
30+
- Fixed a bug where falsy values such as 0 or 0.0 were not accepted as valid user input - see Issue #213
31+
- Invert channel and vtime funcitons implemented in the DSO driver.
3032
- Fxconfig fixes for cmd2 4.0.0
3133
- Jig switching fix to force sending the reset signal regardless of presumed jig state
3234

src/fixate/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,25 @@
3030
generate_relay_matrix_pin_list as generate_relay_matrix_pin_list,
3131
)
3232

33+
from fixate._ui import (
34+
Validator as Validator,
35+
UiColour as UiColour,
36+
user_input as user_input,
37+
user_input_float as user_input_float,
38+
user_serial as user_serial,
39+
user_yes_no as user_yes_no,
40+
user_info as user_info,
41+
user_info_important as user_info_important,
42+
user_ok as user_ok,
43+
user_action as user_action,
44+
user_image as user_image,
45+
user_image_clear as user_image_clear,
46+
user_gif as user_gif,
47+
user_post_sequence_info_pass as user_post_sequence_info_pass,
48+
user_post_sequence_info_fail as user_post_sequence_info_fail,
49+
user_post_sequence_info as user_post_sequence_info,
50+
)
51+
3352
from fixate.main import run_main_program as run
3453

3554
__version__ = "0.6.5"

src/fixate/_ui.py

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
"""
2+
This module provides the user interface for fixate. It is agnostic of the
3+
actual implementation of the UI and provides a standard set of functions used
4+
to obtain or display information from/to the user.
5+
"""
6+
7+
from typing import Callable, Literal
8+
from queue import Queue, Empty
9+
from enum import StrEnum
10+
import time
11+
from pubsub import pub
12+
13+
# going to honour the post sequence info display from `ui.py`
14+
from fixate.config import RESOURCES
15+
from fixate.core.exceptions import UserInputError
16+
from collections import OrderedDict
17+
18+
19+
class Validator[T]:
20+
"""
21+
Defines a validator object that can be used to validate user input.
22+
"""
23+
24+
def __init__(self, func: Callable[[T], bool], error_msg: str = "Invalid input"):
25+
"""
26+
Args:
27+
func (function): The function to validate the input
28+
error_msg (str): The message to display if the input is invalid
29+
"""
30+
self.func = func
31+
self.error_msg = error_msg
32+
33+
def __call__(self, resp: T) -> bool:
34+
"""
35+
Args:
36+
resp (Any): The response to validate
37+
38+
Returns:
39+
bool: True if the response is valid, False otherwise
40+
"""
41+
return self.func(resp)
42+
43+
def __str__(self) -> str:
44+
return self.error_msg
45+
46+
47+
class UiColour(StrEnum):
48+
RED = "red"
49+
GREEN = "green"
50+
BLUE = "blue"
51+
YELLOW = "yellow"
52+
WHITE = "white"
53+
BLACK = "black"
54+
CYAN = "cyan"
55+
MAGENTA = "magenta"
56+
GREY = "grey"
57+
58+
59+
def _user_request_input(msg: str) -> str:
60+
q: Queue[str] = Queue()
61+
pub.sendMessage("UI_block_start")
62+
pub.sendMessage("UI_req_input", msg=msg, q=q)
63+
resp = q.get()
64+
pub.sendMessage("UI_block_end")
65+
return resp
66+
67+
68+
def user_input(msg: str) -> str:
69+
"""
70+
A blocking function that asks the UI to ask the user for raw input.
71+
72+
Args:
73+
msg (str): A message that will be shown to the user
74+
75+
Returns:
76+
resp (str): The user response from the UI
77+
"""
78+
return _user_request_input(msg)
79+
80+
81+
def user_input_float(msg: str, attempts: int = 5) -> float:
82+
"""
83+
A blocking function that asks the UI to ask the user for input and converts the response to a float.
84+
85+
Args:
86+
msg (str): A message that will be shown to the user
87+
attempts (int): Number of attempts the user has to get the input right
88+
89+
Returns:
90+
resp (float): The converted user response from the UI
91+
92+
Raises:
93+
UserInputError: If the user fails to enter a number after the specified number of attempts
94+
"""
95+
for _ in range(attempts):
96+
resp = _user_request_input(msg)
97+
try:
98+
return float(resp)
99+
except ValueError:
100+
pub.sendMessage(
101+
"UI_display_important", msg="Invalid input, please enter a number"
102+
)
103+
raise UserInputError("User failed to enter a number")
104+
105+
106+
def _ten_digit_int_serial(serial: str) -> bool:
107+
return len(serial) == 10 and serial.isdigit()
108+
109+
110+
_ten_digit_int_serial_v = Validator(
111+
_ten_digit_int_serial, "Please enter a 10 digit serial number"
112+
)
113+
114+
115+
def user_serial(
116+
msg: str,
117+
validator: Validator = _ten_digit_int_serial_v,
118+
return_type: type[int] | type[str] = int,
119+
attempts: int = 5,
120+
) -> int | str:
121+
"""
122+
A blocking function that asks the UI to ask the user for a serial number.
123+
124+
Args:
125+
msg (str): A message that will be shown to the user
126+
validator (Validator): An optional function to validate the serial number,
127+
defaults to checking for a 10 digit integer. This function shall return
128+
True if the serial number is valid, False otherwise.
129+
return_type (int | str): The type to return the serial number as, defaults to int
130+
131+
Returns:
132+
resp (str): The user response from the UI
133+
"""
134+
for _ in range(attempts):
135+
resp = _user_request_input(msg)
136+
if validator(resp):
137+
return return_type(resp)
138+
pub.sendMessage("UI_display_important", msg=f"Invalid input: {validator}")
139+
raise UserInputError("User failed to enter the correct format serial number")
140+
141+
142+
def _user_req_choices(msg: str, choices: tuple[str, ...]) -> str:
143+
assert len(choices) >= 2, "There must be at least 2 choices"
144+
q: Queue[str] = Queue()
145+
pub.sendMessage("UI_block_start")
146+
pub.sendMessage("UI_req_choices", msg=msg, q=q, choices=choices)
147+
resp = q.get()
148+
pub.sendMessage("UI_block_end")
149+
return resp
150+
151+
152+
def _choice_from_response(choices: tuple[str, ...], resp: str) -> str | Literal[False]:
153+
for choice in choices:
154+
if choice.startswith(resp):
155+
return choice
156+
return False
157+
158+
159+
def _user_choices(msg: str, choices: tuple[str, ...], attempts: int = 5) -> str:
160+
for _ in range(attempts):
161+
resp = _user_req_choices(msg, choices).upper()
162+
choice = _choice_from_response(choices, resp)
163+
# because of how _choice_from_response works, choice will only be False if the user provided an invalid response
164+
# which is only possible in the command line UI, otherwise choice will be one of the responses.
165+
if choice:
166+
return choice
167+
pub.sendMessage(
168+
"UI_display_important",
169+
msg="Invalid input, please enter a valid choice; first letter or full word",
170+
)
171+
raise UserInputError("User failed to enter a valid response")
172+
173+
174+
def user_yes_no(msg: str, attempts: int = 1) -> str:
175+
"""
176+
A blocking function that asks the UI to ask the user for a yes or no response.
177+
178+
Args:
179+
msg (str): A message that will be shown to the user
180+
181+
Returns:
182+
resp (str): 'YES' or 'NO'
183+
"""
184+
CHOICES = ("YES", "NO")
185+
return _user_choices(msg, CHOICES, attempts)
186+
187+
188+
def user_retry_abort_fail(msg: str, attempts: int = 1) -> str:
189+
"""This should only ever be used by the sequencer, as such is should never be included in the public API via src/fixate/__init__.py"""
190+
CHOICES = ("RETRY", "ABORT", "FAIL")
191+
return _user_choices(msg, CHOICES, attempts)
192+
193+
194+
def user_info(msg: str):
195+
pub.sendMessage("UI_display", msg=msg)
196+
197+
198+
def user_info_important(
199+
msg: str, colour: UiColour = UiColour.RED, bg_colour: UiColour = UiColour.WHITE
200+
):
201+
pub.sendMessage("UI_display_important", msg=msg, colour=colour, bg_colour=bg_colour)
202+
203+
204+
def user_ok(msg: str):
205+
"""
206+
A blocking function that asks the UI to display a message and waits for the user to press OK/Enter.
207+
"""
208+
pub.sendMessage("UI_block_start")
209+
pub.sendMessage("UI_req", msg=msg)
210+
pub.sendMessage("UI_block_end")
211+
212+
213+
def user_action(msg: str, action_monitor: Callable[[], bool]) -> bool:
214+
"""
215+
Prompts the user to complete an action.
216+
Actively monitors the target infinitely until the event is detected or a user fail event occurs
217+
218+
Args:
219+
msg (str): Message to display to the user
220+
action_monitor (function): A function that will be called until the user action is cancelled. The function
221+
should return False if it hasn't completed. If the action is finished return True.
222+
223+
Returns:
224+
bool: True if the action is finished, False otherwise
225+
"""
226+
227+
# UserActionCallback is used to handle the cancellation of the action either by the user or by the action itself
228+
class UserActionCallback:
229+
def __init__(self):
230+
# The UI implementation must provide queue.Queue object. We
231+
# monitor that object. If it is non-empty, we get the message
232+
# in the q and cancel the target call.
233+
self.user_cancel_queue: Queue | None = None
234+
235+
# In the case that the target exits the user action instead
236+
# of the user, we need to tell the UI to do any clean up that
237+
# might be required. (e.g. return GUI buttons to the default state
238+
# Does not need to be implemented by the UI.
239+
# Function takes no args and should return None.
240+
self.target_finished_callback: Callable[[], None] = lambda: None
241+
242+
def set_user_cancel_queue(self, cancel_queue: Queue):
243+
self.user_cancel_queue = cancel_queue
244+
245+
def set_target_finished_callback(self, callback: Callable[[], None]):
246+
self.target_finished_callback = callback
247+
248+
callback_obj = UserActionCallback()
249+
pub.sendMessage("UI_action", msg=msg, callback_obj=callback_obj)
250+
# at this point we should have a cancel queue and a target finished callback, if not, the developer has not implemented the UI_action topic correctly.
251+
assert (
252+
callback_obj.user_cancel_queue is not None
253+
and callback_obj.target_finished_callback is not None
254+
), "user_cancel_queue and target_finished_callback must be set in the UI call for UI_action topic"
255+
try:
256+
while True:
257+
try:
258+
callback_obj.user_cancel_queue.get_nowait()
259+
return False
260+
except Empty:
261+
pass
262+
263+
if action_monitor():
264+
return True
265+
266+
# Yield control for other threads but don't slow down target
267+
time.sleep(0)
268+
finally:
269+
# No matter what, if we exit, we want to reset the UI
270+
callback_obj.target_finished_callback()
271+
272+
273+
def user_image(path: str):
274+
"""
275+
Display an image to the user
276+
277+
Args:
278+
path (str): The path to the image file. The underlying library does not take a pathlib.Path object.
279+
"""
280+
pub.sendMessage("UI_image", path=path)
281+
282+
283+
def user_image_clear():
284+
"""
285+
Clear the image canvas
286+
"""
287+
pub.sendMessage("UI_image_clear")
288+
289+
290+
def user_gif(path: str):
291+
"""
292+
Display a gif to the user
293+
294+
Args:
295+
path (str): The path to the gif file. The underlying library does not take a pathlib.Path object.
296+
"""
297+
pub.sendMessage("UI_gif", path=path)
298+
299+
300+
def _user_post_sequence_info(msg: str, status: str):
301+
if "_post_sequence_info" not in RESOURCES["SEQUENCER"].context_data:
302+
RESOURCES["SEQUENCER"].context_data["_post_sequence_info"] = OrderedDict()
303+
RESOURCES["SEQUENCER"].context_data["_post_sequence_info"][msg] = status
304+
305+
306+
def user_post_sequence_info_pass(msg: str):
307+
"""
308+
Adds information to be displayed to the user at the end if the sequence passes
309+
This information will be displayed in the order that this function is called.
310+
Multiple calls with the same message will result in the previous being overwritten.
311+
312+
This is useful for providing a summary of the sequence to the user at the end.
313+
314+
Args:
315+
msg (str): The message to display.
316+
"""
317+
_user_post_sequence_info(msg, "PASSED")
318+
319+
320+
def user_post_sequence_info_fail(msg: str):
321+
"""
322+
Adds information to be displayed to the user at the end if the sequence fails.
323+
This information will be displayed in the order that this function is called.
324+
Multiple calls with the same message will result in the previous being overwritten.
325+
326+
This is useful for providing a summary of the sequence to the user at the end.
327+
328+
Args:
329+
msg (str): The message to display.
330+
"""
331+
_user_post_sequence_info(msg, "FAILED")
332+
333+
334+
def user_post_sequence_info(msg: str):
335+
"""
336+
Adds information to be displayed to the user at the end of the sequence.
337+
This information will be displayed in the order that this function is called.
338+
Multiple calls with the same message will result in the previous being overwritten.
339+
340+
This is useful for providing a summary of the sequence to the user at the end.
341+
342+
Args:
343+
msg (str): The message to display.
344+
"""
345+
_user_post_sequence_info(msg, "ALL")

0 commit comments

Comments
 (0)