From 3a78eee2f9c65fb12200d01138795e419b581ce7 Mon Sep 17 00:00:00 2001 From: Maxime Desroches Date: Wed, 6 Aug 2025 16:04:19 -0700 Subject: [PATCH] ui: emoji (#35913) * emoji * label * back * default * type * more * ico * device * clean * brew --- system/ui/lib/emoji.py | 47 +++++++++++++++++++++ system/ui/widgets/button.py | 69 ++++++++---------------------- system/ui/widgets/label.py | 83 ++++++++++++++++++++++++++++++++++++- tools/mac_setup.sh | 1 + 4 files changed, 148 insertions(+), 52 deletions(-) create mode 100644 system/ui/lib/emoji.py diff --git a/system/ui/lib/emoji.py b/system/ui/lib/emoji.py new file mode 100644 index 0000000000..28139158a1 --- /dev/null +++ b/system/ui/lib/emoji.py @@ -0,0 +1,47 @@ +import io +import re + +from PIL import Image, ImageDraw, ImageFont +import pyray as rl + +_cache: dict[str, rl.Texture] = {} + +EMOJI_REGEX = re.compile( +"""[\U0001F600-\U0001F64F +\U0001F300-\U0001F5FF +\U0001F680-\U0001F6FF +\U0001F1E0-\U0001F1FF +\U00002700-\U000027BF +\U0001F900-\U0001F9FF +\U00002600-\U000026FF +\U00002300-\U000023FF +\U00002B00-\U00002BFF +\U0001FA70-\U0001FAFF +\U0001F700-\U0001F77F +\u2640-\u2642 +\u2600-\u2B55 +\u200d +\u23cf +\u23e9 +\u231a +\ufe0f +\u3030 +]+""", + flags=re.UNICODE +) + +def find_emoji(text): + return [(m.start(), m.end(), m.group()) for m in EMOJI_REGEX.finditer(text)] + +def emoji_tex(emoji): + if emoji not in _cache: + img = Image.new("RGBA", (128, 128), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + font = ImageFont.truetype("NotoColorEmoji", 109) + draw.text((0, 0), emoji, font=font, embedded_color=True) + buffer = io.BytesIO() + img.save(buffer, format="PNG") + l = buffer.tell() + buffer.seek(0) + _cache[emoji] = rl.load_texture_from_image(rl.load_image_from_memory(".png", buffer.getvalue(), l)) + return _cache[emoji] diff --git a/system/ui/widgets/button.py b/system/ui/widgets/button.py index 04fed82b34..5113b61db2 100644 --- a/system/ui/widgets/button.py +++ b/system/ui/widgets/button.py @@ -6,6 +6,7 @@ import pyray as rl from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import TextAlignment, Label class ButtonStyle(IntEnum): @@ -20,12 +21,6 @@ class ButtonStyle(IntEnum): FORGET_WIFI = 8 -class TextAlignment(IntEnum): - LEFT = 0 - CENTER = 1 - RIGHT = 2 - - ICON_PADDING = 15 DEFAULT_BUTTON_FONT_SIZE = 60 BUTTON_DISABLED_TEXT_COLOR = rl.Color(228, 228, 228, 51) @@ -183,25 +178,19 @@ class Button(Widget): ): super().__init__() - self._text = text - self._click_callback = click_callback - self._label_font = gui_app.font(FontWeight.SEMI_BOLD) self._button_style = button_style self._border_radius = border_radius - self._font_size = font_size - self._font_weight = font_weight - self._text_color = BUTTON_TEXT_COLOR[button_style] - self._background_color = BUTTON_BACKGROUND_COLORS[button_style] - self._text_alignment = text_alignment - self._text_padding = text_padding - self._text_size = measure_text_cached(gui_app.font(self._font_weight), self._text, self._font_size) - self._icon = icon + self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style] + + self._label = Label(text, font_size, font_weight, text_alignment, text_padding, + BUTTON_TEXT_COLOR[self._button_style], icon=icon) + + self._click_callback = click_callback self._multi_touch = multi_touch self.enabled = enabled def set_text(self, text): - self._text = text - self._text_size = measure_text_cached(gui_app.font(self._font_weight), self._text, self._font_size) + self._label.set_text(text) def _handle_mouse_release(self, mouse_pos: MousePos): if self._click_callback and self.enabled: @@ -209,44 +198,20 @@ class Button(Widget): def _update_state(self): if self.enabled: - self._text_color = BUTTON_TEXT_COLOR[self._button_style] + self._label.set_text_color(BUTTON_TEXT_COLOR[self._button_style]) if self.is_pressed: self._background_color = BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style] else: self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style] elif self._button_style != ButtonStyle.NO_EFFECT: self._background_color = BUTTON_DISABLED_BACKGROUND_COLOR - self._text_color = BUTTON_DISABLED_TEXT_COLOR + self._label.set_text_color(BUTTON_DISABLED_TEXT_COLOR) def _render(self, _): roundness = self._border_radius / (min(self._rect.width, self._rect.height) / 2) rl.draw_rectangle_rounded(self._rect, roundness, 10, self._background_color) + self._label.render(self._rect) - text_pos = rl.Vector2(0, self._rect.y + (self._rect.height - self._text_size.y) // 2) - if self._icon: - icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2 - if self._text: - if self._text_alignment == TextAlignment.LEFT: - icon_x = self._rect.x + self._text_padding - text_pos.x = icon_x + self._icon.width + ICON_PADDING - elif self._text_alignment == TextAlignment.CENTER: - total_width = self._icon.width + ICON_PADDING + self._text_size.x - icon_x = self._rect.x + (self._rect.width - total_width) / 2 - text_pos.x = icon_x + self._icon.width + ICON_PADDING - else: - text_pos.x = self._rect.x + self._rect.width - self._text_size.x - self._text_padding - icon_x = text_pos.x - ICON_PADDING - self._icon.width - else: - icon_x = self._rect.x + (self._rect.width - self._icon.width) / 2 - rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE if self.enabled else rl.Color(255, 255, 255, 100)) - else: - if self._text_alignment == TextAlignment.LEFT: - text_pos.x = self._rect.x + self._text_padding - elif self._text_alignment == TextAlignment.CENTER: - text_pos.x = self._rect.x + (self._rect.width - self._text_size.x) // 2 - elif self._text_alignment == TextAlignment.RIGHT: - text_pos.x = self._rect.x + self._rect.width - self._text_size.x - self._text_padding - rl.draw_text_ex(self._label_font, self._text, text_pos, self._font_size, 0, self._text_color) class ButtonRadio(Button): def __init__(self, @@ -254,11 +219,16 @@ class ButtonRadio(Button): icon, click_callback: Callable[[], None] = None, font_size: int = DEFAULT_BUTTON_FONT_SIZE, + text_alignment: TextAlignment = TextAlignment.LEFT, border_radius: int = 10, text_padding: int = 20, ): - super().__init__(text, click_callback=click_callback, font_size=font_size, border_radius=border_radius, text_padding=text_padding, icon=icon) + super().__init__(text, click_callback=click_callback, font_size=font_size, + border_radius=border_radius, text_padding=text_padding, + text_alignment=text_alignment) + self._text_padding = text_padding + self._icon = icon self.selected = False def _handle_mouse_release(self, mouse_pos: MousePos): @@ -275,10 +245,7 @@ class ButtonRadio(Button): def _render(self, _): roundness = self._border_radius / (min(self._rect.width, self._rect.height) / 2) rl.draw_rectangle_rounded(self._rect, roundness, 10, self._background_color) - - text_pos = rl.Vector2(0, self._rect.y + (self._rect.height - self._text_size.y) // 2) - text_pos.x = self._rect.x + self._text_padding - rl.draw_text_ex(self._label_font, self._text, text_pos, self._font_size, 0, self._text_color) + self._label.render(self._rect) if self._icon and self.selected: icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2 diff --git a/system/ui/widgets/label.py b/system/ui/widgets/label.py index e840e8586a..f73b72cc59 100644 --- a/system/ui/widgets/label.py +++ b/system/ui/widgets/label.py @@ -1,11 +1,21 @@ +from enum import IntEnum + import pyray as rl + from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.utils import GuiStyleContext +from openpilot.system.ui.lib.emoji import find_emoji, emoji_tex +from openpilot.system.ui.widgets import Widget +ICON_PADDING = 15 -# TODO: This should be a Widget class +class TextAlignment(IntEnum): + LEFT = 0 + CENTER = 1 + RIGHT = 2 +# TODO: This should be a Widget class def gui_label( rect: rl.Rectangle, text: str, @@ -78,3 +88,74 @@ def gui_text_box( if font_weight != FontWeight.NORMAL: rl.gui_set_font(gui_app.font(FontWeight.NORMAL)) + + +# Non-interactive text area. Can render emojis and an optional specified icon. +class Label(Widget): + def __init__(self, + text: str, + font_size: int = DEFAULT_TEXT_SIZE, + font_weight: FontWeight = FontWeight.NORMAL, + text_alignment: TextAlignment = TextAlignment.CENTER, + text_padding: int = 20, + text_color: rl.Color = DEFAULT_TEXT_COLOR, + icon = None, + ): + + super().__init__() + self._text = text + self._font_weight = font_weight + self._font = gui_app.font(self._font_weight) + self._font_size = font_size + self._text_alignment = text_alignment + self._text_padding = text_padding + self._text_size = measure_text_cached(self._font, self._text, self._font_size) + self._text_color = text_color + self._icon = icon + self.emojis = find_emoji(self._text) + + def set_text(self, text): + self._text = text + self._text_size = measure_text_cached(self._font, self._text, self._font_size) + + def set_text_color(self, color): + self._text_color = color + + def _render(self, _): + text_pos = rl.Vector2(0, self._rect.y + (self._rect.height - self._text_size.y) // 2) + if self._icon: + icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2 + if self._text: + if self._text_alignment == TextAlignment.LEFT: + icon_x = self._rect.x + self._text_padding + text_pos.x = icon_x + self._icon.width + ICON_PADDING + elif self._text_alignment == TextAlignment.CENTER: + total_width = self._icon.width + ICON_PADDING + self._text_size.x + icon_x = self._rect.x + (self._rect.width - total_width) / 2 + text_pos.x = icon_x + self._icon.width + ICON_PADDING + else: + text_pos.x = self._rect.x + self._rect.width - self._text_size.x - self._text_padding + icon_x = text_pos.x - ICON_PADDING - self._icon.width + else: + icon_x = self._rect.x + (self._rect.width - self._icon.width) / 2 + rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE) + else: + if self._text_alignment == TextAlignment.LEFT: + text_pos.x = self._rect.x + self._text_padding + elif self._text_alignment == TextAlignment.CENTER: + text_pos.x = self._rect.x + (self._rect.width - self._text_size.x) // 2 + elif self._text_alignment == TextAlignment.RIGHT: + text_pos.x = self._rect.x + self._rect.width - self._text_size.x - self._text_padding + + prev_index = 0 + for start, end, emoji in self.emojis: + text_before = self._text[prev_index:start] + width_before = measure_text_cached(self._font, text_before, self._font_size) + rl.draw_text_ex(self._font, text_before, text_pos, self._font_size, 0, self._text_color) + text_pos.x += width_before.x + + tex = emoji_tex(emoji) + rl.draw_texture_ex(tex, text_pos, 0.0, self._font_size / tex.height, self._text_color) + text_pos.x += self._font_size + prev_index = end + rl.draw_text_ex(self._font, self._text[prev_index:], text_pos, self._font_size, 0, self._text_color) diff --git a/tools/mac_setup.sh b/tools/mac_setup.sh index d23052d0f0..2a7f83baae 100755 --- a/tools/mac_setup.sh +++ b/tools/mac_setup.sh @@ -50,6 +50,7 @@ brew "zeromq" cask "gcc-arm-embedded" brew "portaudio" brew "gcc@13" +brew "font-noto-color-emoji" EOS echo "[ ] finished brew install t=$SECONDS"