Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add textwrap, multiline show_text #3

Merged
merged 5 commits into from
Jul 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/garmi_gui/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 14 additions & 1 deletion src/garmi_gui/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <gui-hostname> --port <gui-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"
Expand Down Expand Up @@ -40,7 +46,14 @@
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)

Check warning on line 56 in src/garmi_gui/control.py

View check run for this annotation

Codecov / codecov/patch

src/garmi_gui/control.py#L49-L56

Added lines #L49 - L56 were not covered by tests
color_str = input("Enter the color (default is 0,255,255 cyan): ")
font_size_str = input("Enter the font size (default is 100): ")
color = (
Expand Down
80 changes: 66 additions & 14 deletions src/garmi_gui/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import argparse
import pathlib
import textwrap
import threading
from xmlrpc import server

Expand All @@ -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
Expand All @@ -23,6 +26,7 @@
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()
Expand All @@ -35,6 +39,8 @@
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()
Expand All @@ -46,21 +52,49 @@
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("")

Check warning on line 74 in src/garmi_gui/gui.py

View check run for this annotation

Codecov / codecov/patch

src/garmi_gui/gui.py#L74

Added line #L74 was not covered by tests
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,
Expand All @@ -69,7 +103,11 @@
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)
Expand All @@ -81,27 +119,34 @@
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():
Expand All @@ -126,16 +171,18 @@

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)

cap.release()
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()
Expand Down Expand Up @@ -165,22 +212,27 @@
and event.key == pygame.K_ESCAPE
):
self.running = False
pygame.display.flip()
with self.mux:
pygame.display.flip()

Check warning on line 216 in src/garmi_gui/gui.py

View check run for this annotation

Codecov / codecov/patch

src/garmi_gui/gui.py#L215-L216

Added lines #L215 - L216 were not covered by tests
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

Check warning on line 220 in src/garmi_gui/gui.py

View check run for this annotation

Codecov / codecov/patch

src/garmi_gui/gui.py#L220

Added line #L220 was not covered by tests

self.stop()

def stop(self) -> None:
"""Closes the GUI and stops any connected threads."""
self.stop_video()
self.server.shutdown()
self.server_thread.join()
pygame.quit()


def start_gui() -> None:
"""This function is installed as an executable and can be run as
``garmi-gui --port <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."
Expand Down
14 changes: 13 additions & 1 deletion tests/test_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()