Skip to content

Commit cf0b010

Browse files
committed
Version 1.1.0: Add some quality of life improvements:
- Move HELP [command] output into a floating popup, so it gets out of the way and doesn't fill the chat log. - Add completer so you can press Tab to complete commands. - Start adding nice colors :).
1 parent 0ac9754 commit cf0b010

3 files changed

Lines changed: 145 additions & 49 deletions

File tree

‎Cyclus2-PyCmd.py‎

Lines changed: 134 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,12 @@
2222
from prompt_toolkit.application import Application
2323
from prompt_toolkit.document import Document
2424
from 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
2626
from 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+
288317
class 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"\nCommand> {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"\nCommand> {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()

‎README.md‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ SPDX-FileCopyrightText: Johannes Keyser <johannes.keyser@uni-hamburg.de>
33
SPDX-License-Identifier: EUPL-1.2
44
-->
55

6-
# Cyclus2-PyCmd
6+
# Cyclus2-PyCmd 🧑‍💻 ⇄ 🚲
77

8-
🧑‍💻 ⇄ 🚲 Interactively send commands to [Cyclus2 ergometers](https://www.cyclus2.com/en/).
8+
Interactively send commands to [Cyclus2 ergometers](https://www.cyclus2.com/en/).
99

1010
![logo](./materials/logo-Cyclus2-PyCmd.svg)
1111

@@ -63,10 +63,11 @@ Welcome to
6363
/ ___/_ ______/ /_ _____ |_ |___/ _ \__ __/ ___/_ _ ___/ /
6464
/ /__/ // / __/ / // (_-</ __/___/ ___/ // / /__/ ' \/ _ /
6565
\___/\_, /\__/_/\_,_/___/____/ /_/ \_, /\___/_/_/_/\_,_/
66-
/___/ /___/ version 1.0.0
66+
/___/ /___/ version 1.1.0
6767
6868
Type any Cyclus2 command or use HELP [command] for command reference.
69-
To end the session, type DISCONNECT to disconnect from the Cyclus2.
69+
Press Tab to 'cycle through' or complete half-typed commands.
70+
To end the session, type QUIT to disconnect from the Cyclus2.
7071
7172
Command> vers?
7273
Cyclus2> vers:Cyclus2, Version 5.0.9083.30724
@@ -77,8 +78,8 @@ Cyclus2> data:0,0,0.00,0.00,0.00,0.00,0.00,0.00,8.61,0.00,0.00,0.00,0.00
7778
Command> something-wrong
7879
Cyclus2> error:unknown command
7980
80-
Command> DISCONNECT
81-
Disconnecting and ending the session. Bye.
81+
Command> QUIT
82+
Disconnecting and quitting the session. Bye.
8283
```
8384

8485
> [!NOTE]
@@ -187,6 +188,6 @@ See English license text in [LICENSES/CC0-1.0.txt](./LICENSES/CC0-1.0.txt); for
187188

188189
## Project status
189190

190-
Experimental:
191-
This project is currently being prototyped as a lightweight experimental tool for interactive protocol exploration, not as a general-purpose end-user product.
192-
For now, expect breaking changes in each revision.
191+
The paint is still fresh, but the main functions should be usable.
192+
If need be, there still will be breaking changes, but hopefully nothing major.
193+

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.0.0
1+
1.1.0

0 commit comments

Comments
 (0)