2222from prompt_toolkit .application import Application
2323from prompt_toolkit .document import Document
2424from prompt_toolkit .key_binding import KeyBindings
25- from prompt_toolkit .layout .containers import HSplit , Window
25+ from prompt_toolkit .layout .containers import HSplit , Window , Float , FloatContainer
2626from prompt_toolkit .layout .layout import Layout
27- from prompt_toolkit .widgets import TextArea
27+ from prompt_toolkit .widgets import TextArea , Dialog
28+ from prompt_toolkit .layout .dimension import Dimension
29+ from prompt_toolkit .completion import Completer , Completion
30+ from prompt_toolkit .layout .controls import FormattedTextControl
2831
2932
3033
@@ -201,8 +204,11 @@ def local_reply_for(command_catalog: dict, text: str):
201204
202205
203206
204- def recv_stream (sock , timeout = 2.0 , chunk_size = 1024 ):
205- """Read until the socket becomes idle for a short moment."""
207+ def receive_stream (sock , timeout = 2.0 , chunk_size = 1024 ):
208+ """
209+ Read until the socket becomes idle for a short moment.
210+
211+ """
206212 sock .settimeout (timeout )
207213 chunks = []
208214 deadline = time .monotonic () + timeout
@@ -245,8 +251,8 @@ class Cyclus2Session:
245251 send_line() sends a message to the Cyclus2; any text the Cyclus2 replies
246252 is delivered to a callback as soon as it arrives, whenever that is.
247253 A background thread does the actual socket reading, because the Cyclus2 does
248- not always wait to be asked: For example, after command ` data=7` ,
249- it keeps streaming data on its own .
254+ not always wait to be asked: For example, after command data=7,
255+ it keeps streaming data until command data=0 .
250256 """
251257
252258 def __init__ (self , host : str , port : int , timeout : float = 2.0 ):
@@ -264,7 +270,7 @@ def set_message_handler(self, callback):
264270 def _read_loop (self ):
265271 while self ._running :
266272 try :
267- chunk = recv_stream (self .sock , timeout = self .timeout )
273+ chunk = receive_stream (self .sock , timeout = self .timeout )
268274 except OSError :
269275 break # the socket was closed, e.g. via close() below.
270276
@@ -285,35 +291,64 @@ def close(self):
285291 self .sock .close ()
286292
287293
294+ class Cyclus2Completer (Completer ):
295+ """Create a completer for the Cyclus2 commands."""
296+ def __init__ (self , command_catalog : dict ):
297+ self .command_catalog = command_catalog
298+
299+ def get_completions (self , document , complete_event ):
300+ text = document .text_before_cursor
301+
302+ # Complete HELP commands with the command reference.
303+ if text .upper ().startswith ("HELP " ):
304+ prefix = text [5 :].strip ().lower ()
305+ for name in sorted (self .command_catalog ):
306+ if name .lower ().startswith (prefix ):
307+ yield Completion (name , start_position = - len (prefix ))
308+ return
309+
310+ # Complete command names at the start of the line.
311+ prefix = text .strip ().lower ()
312+ for name in sorted (self .command_catalog ):
313+ if name .lower ().startswith (prefix ):
314+ yield Completion (name , start_position = - len (prefix ))
315+
316+
288317class ChatSession :
289318 """
290319 Runs the interactive chat: Everything the user sends and everything the
291320 Cyclus2 replies appears in the same scrolling transcript, with a single input
292- line always available below it. The layout is directly adapted from
293- prompt_toolkit's example calculator.py ("inspiration for a REPL"):
294- A scrollable TextArea for the transcript, a one-line input TextArea below that,
295- and a static horizontal line in between that visually separates the two,
296- as part of the fixed screen layout.
321+ line always available below it.
322+ HELP commands trigger a popup dialog to get out of the way of the chat log.
323+ Tab lets user complete the Cyclus2 commands from the reference.
297324 """
298325
299326 def __init__ (self , session : Cyclus2Session , command_catalog : dict ):
300327 self .session = session
301328 self .command_catalog = command_catalog
329+ c2completer = Cyclus2Completer (command_catalog )
330+ self ._active_popup = None
302331 session .set_message_handler (self ._on_device_message )
303332
304333 intro = (ASCII_BANNER + "\n " +
305- "Type any Cyclus2 command or use HELP [command] for command reference.\n "
306- "To end the session, type DISCONNECT to disconnect from the Cyclus2.\n " )
307- self .log_area = TextArea (text = intro , read_only = True , wrap_lines = True , scrollbar = True )
308- input_area = TextArea (height = 1 , prompt = "Command> " , multiline = False , wrap_lines = False )
309- input_area .accept_handler = self ._on_submit
310-
311- layout = Layout (
312- HSplit ([self .log_area ,
313- Window (height = 1 , char = "─" ), # static separator, fixed in the layout
314- input_area ]),
315- focused_element = input_area ,
316- )
334+ "Type any Cyclus2 command or use HELP [command] for command reference.\n " +
335+ "Press Tab to 'cycle through' or complete half-typed commands.\n "
336+ "To end the session, type QUIT to disconnect from the Cyclus2.\n " )
337+ self .chatlog_area = TextArea (text = intro , read_only = True ,
338+ wrap_lines = True , scrollbar = True )
339+ self .input_area = TextArea (height = 1 , prompt = "Command> " , multiline = False ,
340+ wrap_lines = False , style = "bg:darkgreen" ,
341+ completer = c2completer )
342+ self .input_area .accept_handler = self ._on_submit
343+
344+ self .root_container = FloatContainer (
345+ content = HSplit ([self .chatlog_area ,
346+ Window (height = 1 , char = "─" ),
347+ self .input_area ]),
348+ floats = [])
349+
350+ layout = Layout (self .root_container ,
351+ focused_element = self .input_area )
317352
318353 bindings = KeyBindings ()
319354
@@ -322,43 +357,103 @@ def _quit(event):
322357 self .session .close ()
323358 event .app .exit ()
324359
325- self .app = Application (layout = layout , key_bindings = bindings , full_screen = True )
360+ def _dismiss_if_popup (event ):
361+ if self ._active_popup is not None :
362+ self ._close_popup ()
363+ event .app .invalidate ()
364+
365+ bindings .add ("c-g" )(_dismiss_if_popup )
366+ bindings .add ("escape" )(_dismiss_if_popup )
367+
368+ self .app = Application (layout = layout , key_bindings = bindings , full_screen = True ,
369+ mouse_support = True )
326370
327371 def _append (self , line : str ):
328- # Keep the cursor at the end so the log area auto-scrolls down to the
329- # newest line whenever it grows beyond the visible height.
330- # NOTE: log_area is read_only, so writing its document needs
331- # bypass_readonly=True; otherwise this silently raises
332- # EditReadOnlyBuffer (caught by prompt_toolkit without a stack trace)
333- # and neither the log update nor whatever runs after it takes effect.
334- new_text = self .log_area .text + line + "\n "
335- self .log_area .buffer .set_document (
372+ new_text = self .chatlog_area .text + line + "\n "
373+ self .chatlog_area .buffer .set_document (
336374 Document (new_text , cursor_position = len (new_text )),
337375 bypass_readonly = True )
338376
377+ def _close_popup (self ):
378+ if self ._active_popup is not None :
379+ try :
380+ self .root_container .floats .remove (self ._active_popup )
381+ except ValueError :
382+ pass
383+ self ._active_popup = None
384+
385+ # restore focus to input area, otherwise typing doesn't work
386+ self .app .layout .focus (self .input_area )
387+ self .app .invalidate ()
388+
389+
390+ def _show_popup (self , title : str , text : str ):
391+
392+ help_area = TextArea (
393+ text = text ,
394+ read_only = True ,
395+ wrap_lines = True ,
396+ scrollbar = True ,
397+ height = Dimension (min = 10 , max = 20 ))
398+
399+ tip_area = Window (
400+ height = 1 ,
401+ content = FormattedTextControl (
402+ [("fg:ansicyan italic" ,
403+ "Press Esc or Ctrl-G to close this help window." )]),
404+ dont_extend_height = True ,
405+ always_hide_cursor = True )
406+
407+ body = HSplit ([help_area , Window (height = 1 , char = "─" ), tip_area ])
408+
409+ dialog = Dialog (
410+ title = title ,
411+ body = body ,
412+ buttons = [],
413+ width = Dimension (preferred = 80 ),
414+ modal = False )
415+
416+ self ._active_popup = Float (content = dialog )
417+ self .root_container .floats .append (self ._active_popup )
418+ self .app .layout .focus (help_area )
419+ self .app .invalidate ()
420+
339421 def _on_submit (self , buffer ):
340422 text = buffer .text .strip ()
341423 if not text :
342424 return
343425
344- self ._append (f"\n Command> { text } " )
426+ # Don't add HELP commands in the chat transcript.
427+ if not text .startswith ("HELP" ):
428+ # If a popup is open, close it as soon as the user continues normally
429+ if self ._active_popup is not None :
430+ self ._close_popup ()
431+ self ._append (f"\n Command> { text } " )
345432
346- if text == "DISCONNECT " :
433+ if text == "QUIT " :
347434 self ._append ("Disconnecting and ending the session. Bye." )
348435 self .session .close ()
349436 self .app .exit ()
350437 return
351438
352- local_reply = local_reply_for (self .command_catalog , text )
353- if local_reply is not None :
354- self ._append (local_reply )
439+ if text == "HELP" :
440+ self ._show_popup (
441+ "Available commands" ,
442+ list_command_names (self .command_catalog ))
443+ return
444+
445+ if text .startswith ("HELP " ):
446+ cmd = text [5 :].strip ()
447+ self ._show_popup (
448+ f"Help: { cmd } " ,
449+ format_command_help (self .command_catalog , cmd ))
355450 return
356451
357452 self .session .send_line (text )
358453
359454 def _on_device_message (self , text : str ):
360455 self ._append (f"Cyclus2> { text } " )
361- self .app .invalidate () # invalidate() is documented as thread-safe.
456+ self .app .invalidate ()
362457
363458 def run (self ):
364459 self .app .run ()
0 commit comments