diff --git a/src/garmi_gui/__init__.py b/src/garmi_gui/__init__.py index 9dda466..c3ad187 100644 --- a/src/garmi_gui/__init__.py +++ b/src/garmi_gui/__init__.py @@ -1,7 +1,8 @@ """ Copyright (c) 2024 Jean Elsner. All rights reserved. -garmi-gui: The GARMI gui. +garmi-gui: GUI for the GARMI robot's face screen with remote control +to show images, play videos and sounds, and render text. """ from __future__ import annotations diff --git a/src/garmi_gui/control.py b/src/garmi_gui/control.py index f07085a..836599c 100644 --- a/src/garmi_gui/control.py +++ b/src/garmi_gui/control.py @@ -6,6 +6,12 @@ def main() -> None: + """Simple terminal program that allows you to connect to a GARMI GUI remotely + and execute commands. If you installed the package this function is installed + as an executable that can be called as + ``garmi-gui --hostname --port `` to connect with a + remote GUI running on the given hostname and port respectively. + """ parser = argparse.ArgumentParser(description="Remote GUI Controller") parser.add_argument( "--hostname", type=str, default="localhost", help="Hostname of the remote GUI" @@ -40,7 +46,14 @@ def main() -> None: proxy.show_video(video_path) print(f"Video '{video_path}' displayed.") elif choice == "4": - text = input("Enter the text to display: ") + print("Enter the text to display: ") + lines = [] + while True: + line = input() + if line == "": + break + lines.append(line) + text = "\n".join(lines) color_str = input("Enter the color (default is 0,255,255 cyan): ") font_size_str = input("Enter the font size (default is 100): ") color = ( diff --git a/src/garmi_gui/gui.py b/src/garmi_gui/gui.py index c23c67c..2ea405d 100644 --- a/src/garmi_gui/gui.py +++ b/src/garmi_gui/gui.py @@ -2,6 +2,7 @@ import argparse import pathlib +import textwrap import threading from xmlrpc import server @@ -12,7 +13,9 @@ class GUI: - """GUI for the GARMI robot face display.""" + """GUI for the GARMI robot face display. + + The functions of this class are usually called remotely using xmlrpc.""" def __init__(self, port: int, fullscreen: bool = True, testing: bool = False): self.fullscreen = fullscreen @@ -23,6 +26,7 @@ def __init__(self, port: int, fullscreen: bool = True, testing: bool = False): self.sound: None | pygame.mixer.Sound = None self.stop_video_event = threading.Event() self.running = True + self.mux = threading.Lock() self.gui_thread = threading.Thread(target=self._run) self.gui_thread.start() @@ -35,6 +39,8 @@ def __init__(self, port: int, fullscreen: bool = True, testing: bool = False): self.server_thread.start() def play_sound(self, sound_path: str) -> None: + """Plays a local sound file. If only a filename is given, + the file is searched for in the local resources directory.""" self.stop_sound() self.sound = pygame.mixer.Sound(self.process_path(sound_path)) self.sound.play() @@ -46,21 +52,49 @@ def process_path(self, path: str) -> str: return str(RESOURCE_PATH / proc_path) def stop_sound(self) -> None: + """Stops playing sound if the GUI is currently playing a sound.""" if self.sound is not None: self.sound.stop() + def wrap_text(self, text: str, font_size: int) -> str: + """Wraps a textstring so it will fit into the screen when displayed. + :py:func:`render_text` and :py:func:`show_text` will apply this to + any text arguments before rendering.""" + proc: list[str] = [] + for line in text.splitlines(): + wrapped = textwrap.wrap( + line, + int(100 / font_size * 35 * self.screen.get_rect().width / 1280), + drop_whitespace=False, + replace_whitespace=False, + ) + if wrapped: + proc.extend(wrapped) + else: + proc.append("") + return str.join("\n", proc) + def show_text( self, text: str, color: tuple[int, int, int] = (0, 255, 255), font_size: int = 100, ) -> None: + """Displays a text on the GUI in the given color and font size. + The displayed text may contain newline but is also wrapped to fit the display.""" + text = self.wrap_text(text, font_size) self.stop_video() self.screen.fill((0, 0, 0)) font = pygame.font.Font(None, font_size) - text_surface = font.render(text, True, color) - text_rect = text_surface.get_rect(center=self.screen.get_rect().center) - self.screen.blit(text_surface, text_rect) + lines = text.splitlines() + bias_y = (len(lines) - 1) / 2 * font_size + with self.mux: + for line_number, line in enumerate(lines): + text_surface = font.render(line, True, color) + pos = list(self.screen.get_rect().center) + pos[1] += int(font_size * line_number - bias_y) + text_rect = text_surface.get_rect(center=pos) + self.screen.blit(text_surface, text_rect) def render_text( self, @@ -69,7 +103,11 @@ def render_text( color: tuple[int, int, int] = (0, 255, 255), font_size: int = 100, ) -> None: + """Renders text on the GUI character by character with the speed + given in characters per second. Wraps text as necessary. Otherwise + functions like :py:func:`show_text`.""" self.stop_video() + text = self.wrap_text(text, font_size) def render() -> None: font = pygame.font.Font(None, font_size) @@ -81,27 +119,34 @@ def render() -> None: for char in line: if not self.running: break - self.screen.fill((0, 0, 0)) - for surface, rect in rendered_lines: - self.screen.blit(surface, rect) current_text += char text_surface = font.render(current_text, True, color) pos = list(self.screen.get_rect().center) pos[1] += int(font_size * line_number - bias_y) text_rect = text_surface.get_rect(center=pos) - self.screen.blit(text_surface, text_rect) + with self.mux: + self.screen.fill((0, 0, 0)) + for surface, rect in rendered_lines: + self.screen.blit(surface, rect) + self.screen.blit(text_surface, text_rect) self.clock.tick(speed) rendered_lines.append((text_surface, text_rect)) threading.Thread(target=render).start() def show_image(self, image_path: str) -> None: + """Displays a local image on the GUI. Relative paths are evaluated + relative to the resources directory.""" self.stop_video() image = pygame.image.load(self.process_path(image_path)).convert() image = pygame.transform.smoothscale(image, self.screen.get_size()) - self.screen.blit(image, (0, 0)) + with self.mux: + self.screen.blit(image, (0, 0)) def show_video(self, video_path: str) -> None: + """Plays a local video and displays it on the GUI. + The video will loop until something else is displayed + or :py:func:`stop_video` is called.""" video_path = self.process_path(video_path) self.stop_video() if not pathlib.Path(video_path).exists(): @@ -126,9 +171,10 @@ def _play_video(self, video_path: str) -> None: frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) frame = pygame.image.frombuffer(frame.tobytes(), frame.shape[1::-1], "RGB") - self.screen.blit( - pygame.transform.smoothscale(frame, self.screen.get_size()), (0, 0) - ) + with self.mux: + self.screen.blit( + pygame.transform.smoothscale(frame, self.screen.get_size()), (0, 0) + ) self.clock.tick(fps) @@ -136,6 +182,7 @@ def _play_video(self, video_path: str) -> None: self.video_running = False def stop_video(self) -> None: + """Stops video if a video is currently shown on the GUI.""" if self.video_running and self.video_thread is not None: self.stop_video_event.set() self.video_thread.join() @@ -165,15 +212,17 @@ def _run(self) -> None: and event.key == pygame.K_ESCAPE ): self.running = False - pygame.display.flip() + with self.mux: + pygame.display.flip() except pygame.error: self.running = False - self.clock.tick(10) # Throttle loop to reduce CPU usage + self.clock.tick(30) # Throttle loop to reduce CPU usage self.stop() def stop(self) -> None: + """Closes the GUI and stops any connected threads.""" self.stop_video() self.server.shutdown() self.server_thread.join() @@ -181,6 +230,9 @@ def stop(self) -> None: def start_gui() -> None: + """This function is installed as an executable and can be run as + ``garmi-gui --port `` where an xmlrpc server is started, listening + on the given port.""" parser = argparse.ArgumentParser() parser.add_argument( "--port", "-p", type=int, default=8000, help="Port of the xmlrpc server." diff --git a/tests/test_gui.py b/tests/test_gui.py index 4830e7a..4eef540 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -13,7 +13,7 @@ def test_gui(): - gui_instance = gui.GUI(8000, testing=True) + gui_instance = gui.GUI(8000, fullscreen=False, testing=True) gui_instance.show_image("eyes.png") time.sleep(0.1) @@ -37,4 +37,16 @@ def test_gui(): with pytest.raises(FileNotFoundError): gui_instance.show_video("unknown-video-path") + assert ( + len( + gui_instance.wrap_text( + "This text should be wrapped.", font_size=200 + ).splitlines() + ) + > 1 + ) + + assert gui_instance.screen.get_rect().width == 1280 + assert gui_instance.screen.get_rect().height == 960 + gui_instance.stop()