peterbrittain / asciimatics

A cross platform package to do curses-like operations, plus higher level APIs and widgets to create text UIs and ASCII art animations
Apache License 2.0
3.61k stars 238 forks source link

RayCaster sample "swallows" key when commenting out RayCaster effect #362

Closed ymyke closed 1 year ago

ymyke commented 1 year ago

When I run the RayCaster as it ships in the repository, everything works as expected, namely opening the help dialog with H and closing it with Return.

When I comment out / remove the RayCaster effect in this line: https://github.com/peterbrittain/asciimatics/blob/31b3fe579af6ef08bcd8e01e33aeaf8d46805353/samples/ray_casting.py#L424

Everything still works (without the main output obviously). But now when I open the help widget with H, I need to press Return twice to open it again. It's as if the first keypress gets swallowed somewhere.

If I use some simple effect like Print(screen, StaticRenderer(["something"]), 10, 10) the behavior remains the same, i.e., the first Return still gets swallowed.

Platform: Windows 11, Powershell 7, Python 3.9.10

peterbrittain commented 1 year ago

Ok... This was interesting. What you're seeing is confusing and not quite what you think.

Before I give the answer, you need some context... Asciimatics maintains a double buffer of what should be on the screen and then uses that to update everything when you call screen.refresh(). This happens inside the play loop in this sample.

Now, the problem is that once you close the popup help (on the first Return), there is nothing updating the double buffer (because there is nothing to draw apart from the minimap), so asciimatics doesn't think it needs to change. It therefore leaves the after image of the popup.

This stays until you press Return again, which is passed to the default handler. The default handler moves on to the next Scene, which resets the whole screen, removing the after image, giving the impression that the popup was closed (even though that already happened before).

The fix is to have some Effect drawing something on the screen instead of the ray tracer. For example, use Background(screen) instead of the ray tracer effect. This is normally required for form based scenes and what that Effect is designed to do, so should be fine.

ymyke commented 1 year ago

Interesting, thank you.

Can I ask an unrelated question in this thread because it is the reason I came up with the question above in the first place:

I was trying to get my hands dirty with asciimatics and tried to write a little app that would just draw some boxes and would highlight a box as the user presses the left/right arrow keys. I used the raycasting sample as my blueprint to learn about the event handling in asciimatics.

Here's what I came up with: See below (raw, no error handling etc.)

This works, basically. However, it feels rather sluggish and when I press left or right in quick succession, it misses one or even several intermediate steps. – Am I missing something here? Or is this simply a limitation of asciimatics and/or the libraries you're using under the hood?

Thanks!

Update: Adding the code mentioned above here instead of linking to the Gist. For future reference.

#!/usr/bin/env python3

# -*- coding: utf-8 -*-
import sys
from asciimatics.effects import Print
from asciimatics.event import KeyboardEvent
from asciimatics.exceptions import ResizeScreenError, StopApplication
from asciimatics.renderers import Box
from asciimatics.screen import Screen
from asciimatics.scene import Scene

class GameController(Scene):
    def __init__(self, screen):
        self._screen = screen
        self.cursor_pos = 0
        self.cursor_highlight = None
        boxes = [
            Print(
                screen=screen,
                renderer=Box(5, 5, uni=True, style=0),
                x=(5 + 2) * i + 2,
                y=10,
                colour=7,
                transparent=True,
            )
            for i in range(15)
        ]
        super(GameController, self).__init__(boxes, -1)
        self.update_cursor(self.cursor_pos)

    def update_cursor(self, new_pos: int):
        self.cursor_pos = new_pos
        if self.cursor_highlight:
            self.remove_effect(self.cursor_highlight)
        self.cursor_highlight = Print(
            screen=self._screen,
            renderer=Box(5, 5, uni=True, style=2),
            x=(5 + 2) * self.cursor_pos + 2,
            y=10,
            colour=7,
            transparent=True,
        )
        self.add_effect(self.cursor_highlight)

    def process_event(self, event):
        if super(GameController, self).process_event(event) is None:
            return

        if isinstance(event, KeyboardEvent):
            c = event.key_code
            if c in (ord("x"), ord("X")):
                raise StopApplication("User exit")
            elif c in (ord("a"), Screen.KEY_LEFT):
                self.update_cursor(self.cursor_pos - 1)
            elif c in (ord("d"), Screen.KEY_RIGHT):
                self.update_cursor(self.cursor_pos + 1)
            else:
                # Not a recognised key - pass on to other handlers.
                return event
        else:
            # Ignore other types of events.
            return event

def demo(screen):
    screen.play([GameController(screen)], stop_on_resize=True)

if __name__ == "__main__":
    while True:
        try:
            Screen.wrapper(demo, catch_interrupt=False)
            sys.exit(0)
        except ResizeScreenError:
            pass
peterbrittain commented 1 year ago

Another good question! You need some more context for this one...

The original package was designed to create ASCII animations and so didn't care much for input responsiveness. It simply updated the screen 20 times a second, doing any input handling between update cycles. When I started creating form based input widgets on top of this, it became apparent that this created a noticeable lag for users.

The fix was to allow keyboard input to interrupt the cycle and force an immediate update. However, this messed up the frame-based timing used by the original animation Effects, so to maintain backwards compatibility, I made this an option on play().

So... To make your app more responsive, use screen.play([GameController(screen)], stop_on_resize=True, allow_int=True) to allow input interrupts.

ymyke commented 1 year ago

Thanks for the quick help and explanation. The allow_int setting doesn't seem to make a huge difference in my example code, but it might help a little bit. I hope I'm not missing something else here.

peterbrittain commented 1 year ago

Could be your platform... With the allow_int option on, the speed of processing events is then down to how quickly the terminal/console can be updated.

Unfortunately, the Windows console API is slow. Can you try running on a Linux terminal (e.g. on a VM on your Windows machine)? Asciimatics will work on both without any changes.

ymyke commented 1 year ago

Looks like it. I tried on Pythonanywhere quickly and it feels snappier even though it's a web-based shell.

Thanks for your support!