1414from enum import IntEnum
1515from os import PathLike , path
1616from tempfile import NamedTemporaryFile
17- from typing import ContextManager , TextIO
17+ from typing import ContextManager , TextIO , Awaitable
1818
1919import argcomplete
2020import questionary
2121from argcomplete .completers import FilesCompleter
2222from packaging .version import Version
23+ from prompt_toolkit import Application
24+ from prompt_toolkit .key_binding import KeyBindings
25+ from prompt_toolkit .output import DummyOutput
2326
2427from pybricksdev import __name__ as MODULE_NAME
2528from pybricksdev import __version__ as MODULE_VERSION
3639)
3740
3841
42+ class CancelProgramError (RuntimeError ):
43+ """Exception raised when a user interrupts the hub's running program from the cli."""
44+
45+
3946class Tool (ABC ):
4047 """Common base class for tool implementations."""
4148
@@ -184,6 +191,55 @@ def add_parser(self, subparsers: argparse._SubParsersAction):
184191 default = False ,
185192 )
186193
194+ async def race_keypress (self , awaitable : Awaitable ) -> None :
195+ """
196+ Races an awaitable against a keypress.
197+ The awaitable is cancelled and a CancelProgramError is raised if the key
198+ is pressed before the awaitable completes.
199+ The output of the awaitable is not passed to the caller.
200+ """
201+
202+ async def stdin_monitor ():
203+ kb = KeyBindings ()
204+
205+ @kb .add ("q" )
206+ def _ (event ):
207+ event .app .exit ()
208+
209+ app = Application (
210+ key_bindings = kb ,
211+ full_screen = False ,
212+ mouse_support = False ,
213+ output = DummyOutput (),
214+ )
215+
216+ await app .run_async ()
217+
218+ stop_task = asyncio .ensure_future (stdin_monitor ())
219+ awaitable_task = asyncio .ensure_future (awaitable )
220+
221+ print ("------press q to cancel the program------" )
222+
223+ try :
224+ done , pending = await asyncio .wait (
225+ {awaitable_task , stop_task },
226+ return_when = asyncio .FIRST_COMPLETED ,
227+ )
228+ except BaseException :
229+ awaitable_task .cancel ()
230+ stop_task .cancel ()
231+ raise
232+
233+ for t in pending :
234+ t .cancel ()
235+
236+ # allow prompt-toolkit to unbind from the terminal
237+ await asyncio .sleep (0.1 )
238+
239+ if stop_task in done :
240+ print ("------aborting program------" )
241+ raise CancelProgramError
242+
187243 async def stay_connected_menu (self , hub : PybricksHub , args : argparse .Namespace ):
188244
189245 if args .conntype == "ble" :
@@ -277,7 +333,7 @@ async def reconnect_hub():
277333 response = await hub .race_disconnect (
278334 hub .race_power_button_press (
279335 questionary .select (
280- f"Would you like to re-compile { os . path .basename (args .file .name )} ?" ,
336+ f"Would you like to re-compile { path .basename (args .file .name )} ?" ,
281337 response_options ,
282338 default = (response_options [default_response_option ]),
283339 ).ask_async ()
@@ -290,7 +346,7 @@ async def reconnect_hub():
290346
291347 case ResponseOptions .RECOMPILE_RUN :
292348 with _get_script_path (args .file ) as script_path :
293- await hub .run (script_path , wait = True )
349+ await self . race_keypress ( hub .run (script_path , wait = True ) )
294350
295351 case ResponseOptions .RECOMPILE_DOWNLOAD :
296352 with _get_script_path (args .file ) as script_path :
@@ -303,7 +359,7 @@ async def reconnect_hub():
303359 )
304360 else :
305361 await hub .start_user_program ()
306- await hub ._wait_for_user_program_stop ()
362+ await self . race_keypress ( hub ._wait_for_user_program_stop () )
307363
308364 case ResponseOptions .CHANGE_TARGET_FILE :
309365 args .file .close ()
@@ -340,16 +396,22 @@ async def reconnect_hub():
340396 # the hub stdout until the user program ends on the hub.
341397 try :
342398 await hub ._wait_for_power_button_release ()
343- await hub ._wait_for_user_program_stop ()
399+ await self . race_keypress ( hub ._wait_for_user_program_stop () )
344400
345401 except HubDisconnectError :
346402 hub = await reconnect_hub ()
347403
404+ except CancelProgramError :
405+ await hub .stop_user_program ()
406+
348407 except HubDisconnectError :
349408 # let terminal cool off before making a new prompt
350409 await asyncio .sleep (0.3 )
351410 hub = await reconnect_hub ()
352411
412+ except CancelProgramError :
413+ await hub .stop_user_program ()
414+
353415 async def run (self , args : argparse .Namespace ):
354416
355417 # Pick the right connection
@@ -405,7 +467,9 @@ def is_pybricks_usb(dev):
405467 try :
406468 with _get_script_path (args .file ) as script_path :
407469 if args .start :
408- await hub .run (script_path , args .wait or args .stay_connected )
470+ await self .race_keypress (
471+ hub .run (script_path , args .wait or args .stay_connected )
472+ )
409473 else :
410474 if args .stay_connected :
411475 # if the user later starts the program by pressing the button on the hub,
@@ -424,6 +488,11 @@ def is_pybricks_usb(dev):
424488 if args .stay_connected :
425489 await self .stay_connected_menu (hub , args )
426490
491+ except CancelProgramError :
492+ await hub .stop_user_program ()
493+ if args .stay_connected :
494+ await self .stay_connected_menu (hub , args )
495+
427496 finally :
428497 await hub .disconnect ()
429498
0 commit comments