mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-02-19 00:43:54 +08:00
* fix * can also do this * but then y is off. this is from font_scale I think * fix * cmt
797 lines
30 KiB
Python
797 lines
30 KiB
Python
from enum import IntEnum
|
|
from collections.abc import Callable
|
|
from itertools import zip_longest
|
|
from typing import Union
|
|
import pyray as rl
|
|
|
|
from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR, FONT_SCALE
|
|
from openpilot.system.ui.widgets import Widget
|
|
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.lib.wrap_text import wrap_text
|
|
|
|
ICON_PADDING = 15
|
|
|
|
|
|
# TODO: make this common
|
|
def _resolve_value(value, default=""):
|
|
if callable(value):
|
|
return value()
|
|
return value if value is not None else default
|
|
|
|
|
|
class ScrollState(IntEnum):
|
|
STARTING = 0
|
|
SCROLLING = 1
|
|
|
|
|
|
# TODO: merge anything new here to master
|
|
class MiciLabel(Widget):
|
|
def __init__(self,
|
|
text: str,
|
|
font_size: int = DEFAULT_TEXT_SIZE,
|
|
width: int = None,
|
|
color: rl.Color = DEFAULT_TEXT_COLOR,
|
|
font_weight: FontWeight = FontWeight.NORMAL,
|
|
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
|
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
|
spacing: int = 0,
|
|
line_height: int = None,
|
|
elide_right: bool = True,
|
|
wrap_text: bool = False,
|
|
scroll: bool = False):
|
|
super().__init__()
|
|
self.text = text
|
|
self.wrapped_text: list[str] = []
|
|
self.font_size = font_size
|
|
self.width = width
|
|
self.color = color
|
|
self.font_weight = font_weight
|
|
self.alignment = alignment
|
|
self.alignment_vertical = alignment_vertical
|
|
self.spacing = spacing
|
|
self.line_height = line_height if line_height is not None else font_size
|
|
self.elide_right = elide_right
|
|
self.wrap_text = wrap_text
|
|
self._height = 0
|
|
|
|
# Scroll state
|
|
self.scroll = scroll
|
|
self._needs_scroll = False
|
|
self._scroll_offset = 0
|
|
self._scroll_pause_t: float | None = None
|
|
self._scroll_state: ScrollState = ScrollState.STARTING
|
|
|
|
assert not (self.scroll and self.wrap_text), "Cannot enable both scroll and wrap_text"
|
|
assert not (self.scroll and self.elide_right), "Cannot enable both scroll and elide_right"
|
|
|
|
self.set_text(text)
|
|
|
|
@property
|
|
def text_height(self):
|
|
return self._height
|
|
|
|
def set_font_size(self, font_size: int):
|
|
self.font_size = font_size
|
|
self.set_text(self.text)
|
|
|
|
def set_width(self, width: int):
|
|
self.width = width
|
|
self._rect.width = width
|
|
self.set_text(self.text)
|
|
|
|
def set_text(self, txt: str):
|
|
self.text = txt
|
|
text_size = measure_text_cached(gui_app.font(self.font_weight), self.text, self.font_size, self.spacing)
|
|
if self.width is not None:
|
|
self._rect.width = self.width
|
|
else:
|
|
self._rect.width = text_size.x
|
|
|
|
if self.wrap_text:
|
|
self.wrapped_text = wrap_text(gui_app.font(self.font_weight), self.text, self.font_size, int(self._rect.width))
|
|
self._height = len(self.wrapped_text) * self.line_height
|
|
elif self.scroll:
|
|
self._needs_scroll = self.scroll and text_size.x > self._rect.width
|
|
self._rect.height = text_size.y
|
|
|
|
def set_color(self, color: rl.Color):
|
|
self.color = color
|
|
|
|
def set_font_weight(self, font_weight: FontWeight):
|
|
self.font_weight = font_weight
|
|
self.set_text(self.text)
|
|
|
|
def _render(self, rect: rl.Rectangle):
|
|
# Only scissor when we know there is a single scrolling line
|
|
if self._needs_scroll:
|
|
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
|
|
|
|
font = gui_app.font(self.font_weight)
|
|
|
|
text_y_offset = 0
|
|
# Draw the text in the specified rectangle
|
|
lines = self.wrapped_text or [self.text]
|
|
if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
|
|
lines = lines[::-1]
|
|
|
|
for display_text in lines:
|
|
text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)
|
|
|
|
# Elide text to fit within the rectangle
|
|
if self.elide_right and text_size.x > rect.width:
|
|
ellipsis = "..."
|
|
left, right = 0, len(display_text)
|
|
while left < right:
|
|
mid = (left + right) // 2
|
|
candidate = display_text[:mid] + ellipsis
|
|
candidate_size = measure_text_cached(font, candidate, self.font_size, self.spacing)
|
|
if candidate_size.x <= rect.width:
|
|
left = mid + 1
|
|
else:
|
|
right = mid
|
|
display_text = display_text[: left - 1] + ellipsis if left > 0 else ellipsis
|
|
text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)
|
|
|
|
# Handle scroll state
|
|
elif self.scroll and self._needs_scroll:
|
|
if self._scroll_state == ScrollState.STARTING:
|
|
if self._scroll_pause_t is None:
|
|
self._scroll_pause_t = rl.get_time() + 2.0
|
|
if rl.get_time() >= self._scroll_pause_t:
|
|
self._scroll_state = ScrollState.SCROLLING
|
|
self._scroll_pause_t = None
|
|
|
|
elif self._scroll_state == ScrollState.SCROLLING:
|
|
self._scroll_offset -= 0.8 / 60. * gui_app.target_fps
|
|
# don't fully hide
|
|
if self._scroll_offset <= -text_size.x - self._rect.width / 3:
|
|
self._scroll_offset = 0
|
|
self._scroll_state = ScrollState.STARTING
|
|
self._scroll_pause_t = None
|
|
|
|
# Calculate horizontal position based on alignment
|
|
text_x = rect.x + {
|
|
rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0,
|
|
rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2,
|
|
rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x,
|
|
}.get(self.alignment, 0) + self._scroll_offset
|
|
|
|
# Calculate vertical position based on alignment
|
|
text_y = rect.y + {
|
|
rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0,
|
|
rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2,
|
|
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y,
|
|
}.get(self.alignment_vertical, 0)
|
|
text_y += text_y_offset
|
|
|
|
rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x), text_y), self.font_size, self.spacing, self.color)
|
|
# Draw 2nd instance for scrolling
|
|
if self._needs_scroll and self._scroll_state != ScrollState.STARTING:
|
|
text2_scroll_offset = text_size.x + self._rect.width / 3
|
|
rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x + text2_scroll_offset), text_y), self.font_size, self.spacing, self.color)
|
|
if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
|
|
text_y_offset -= self.line_height
|
|
else:
|
|
text_y_offset += self.line_height
|
|
|
|
if self._needs_scroll:
|
|
# draw black fade on left and right
|
|
fade_width = 20
|
|
rl.draw_rectangle_gradient_h(int(rect.x + rect.width - fade_width), int(rect.y), fade_width, int(rect.height), rl.BLANK, rl.BLACK)
|
|
if self._scroll_state != ScrollState.STARTING:
|
|
rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), fade_width, int(rect.height), rl.BLACK, rl.BLANK)
|
|
|
|
rl.end_scissor_mode()
|
|
|
|
|
|
# TODO: This should be a Widget class
|
|
def gui_label(
|
|
rect: rl.Rectangle,
|
|
text: str,
|
|
font_size: int = DEFAULT_TEXT_SIZE,
|
|
color: rl.Color = DEFAULT_TEXT_COLOR,
|
|
font_weight: FontWeight = FontWeight.NORMAL,
|
|
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
|
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
|
|
elide_right: bool = True
|
|
):
|
|
font = gui_app.font(font_weight)
|
|
text_size = measure_text_cached(font, text, font_size)
|
|
display_text = text
|
|
|
|
# Elide text to fit within the rectangle
|
|
if elide_right and text_size.x > rect.width:
|
|
_ellipsis = "..."
|
|
left, right = 0, len(text)
|
|
while left < right:
|
|
mid = (left + right) // 2
|
|
candidate = text[:mid] + _ellipsis
|
|
candidate_size = measure_text_cached(font, candidate, font_size)
|
|
if candidate_size.x <= rect.width:
|
|
left = mid + 1
|
|
else:
|
|
right = mid
|
|
display_text = text[: left - 1] + _ellipsis if left > 0 else _ellipsis
|
|
text_size = measure_text_cached(font, display_text, font_size)
|
|
|
|
# Calculate horizontal position based on alignment
|
|
text_x = rect.x + {
|
|
rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0,
|
|
rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2,
|
|
rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x,
|
|
}.get(alignment, 0)
|
|
|
|
# Calculate vertical position based on alignment
|
|
text_y = rect.y + {
|
|
rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0,
|
|
rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2,
|
|
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y,
|
|
}.get(alignment_vertical, 0)
|
|
|
|
# Draw the text in the specified rectangle
|
|
# TODO: add wrapping and proper centering for multiline text
|
|
rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color)
|
|
|
|
|
|
def gui_text_box(
|
|
rect: rl.Rectangle,
|
|
text: str,
|
|
font_size: int = DEFAULT_TEXT_SIZE,
|
|
color: rl.Color = DEFAULT_TEXT_COLOR,
|
|
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
|
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
|
font_weight: FontWeight = FontWeight.NORMAL,
|
|
line_scale: float = 1.0,
|
|
):
|
|
styles = [
|
|
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(color)),
|
|
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, round(font_size * FONT_SCALE)),
|
|
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE * line_scale)),
|
|
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_ALIGNMENT, alignment),
|
|
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, alignment_vertical),
|
|
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_WRAP_MODE, rl.GuiTextWrapMode.TEXT_WRAP_WORD)
|
|
]
|
|
if font_weight != FontWeight.NORMAL:
|
|
rl.gui_set_font(gui_app.font(font_weight))
|
|
|
|
with GuiStyleContext(styles):
|
|
rl.gui_label(rect, text)
|
|
|
|
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 | Callable[[], str],
|
|
font_size: int = DEFAULT_TEXT_SIZE,
|
|
font_weight: FontWeight = FontWeight.NORMAL,
|
|
text_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
|
text_alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
|
|
text_padding: int = 0,
|
|
text_color: rl.Color = DEFAULT_TEXT_COLOR,
|
|
icon: Union[rl.Texture, None] = None,
|
|
elide_right: bool = False,
|
|
line_scale=1.0,
|
|
):
|
|
|
|
super().__init__()
|
|
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_alignment_vertical = text_alignment_vertical
|
|
self._text_padding = text_padding
|
|
self._text_color = text_color
|
|
self._icon = icon
|
|
self._elide_right = elide_right
|
|
self._line_scale = line_scale
|
|
|
|
self._text = text
|
|
self.set_text(text)
|
|
|
|
def set_text(self, text):
|
|
self._text = text
|
|
self._update_text(self._text)
|
|
|
|
def set_text_color(self, color):
|
|
self._text_color = color
|
|
|
|
def set_font_size(self, size):
|
|
self._font_size = size
|
|
self._update_text(self._text)
|
|
|
|
def _update_text(self, text):
|
|
self._emojis = []
|
|
self._text_size = []
|
|
text = _resolve_value(text)
|
|
|
|
if self._elide_right:
|
|
display_text = text
|
|
|
|
# Elide text to fit within the rectangle
|
|
text_size = measure_text_cached(self._font, text, self._font_size)
|
|
content_width = self._rect.width - self._text_padding * 2
|
|
if self._icon:
|
|
content_width -= self._icon.width + ICON_PADDING
|
|
if text_size.x > content_width:
|
|
_ellipsis = "..."
|
|
left, right = 0, len(text)
|
|
while left < right:
|
|
mid = (left + right) // 2
|
|
candidate = text[:mid] + _ellipsis
|
|
candidate_size = measure_text_cached(self._font, candidate, self._font_size)
|
|
if candidate_size.x <= content_width:
|
|
left = mid + 1
|
|
else:
|
|
right = mid
|
|
display_text = text[: left - 1] + _ellipsis if left > 0 else _ellipsis
|
|
|
|
self._text_wrapped = [display_text]
|
|
else:
|
|
self._text_wrapped = wrap_text(self._font, text, self._font_size, round(self._rect.width - (self._text_padding * 2)))
|
|
|
|
for t in self._text_wrapped:
|
|
self._emojis.append(find_emoji(t))
|
|
self._text_size.append(measure_text_cached(self._font, t, self._font_size))
|
|
|
|
def _render(self, _):
|
|
# Text can be a callable
|
|
# TODO: cache until text changed
|
|
self._update_text(self._text)
|
|
|
|
text_size = self._text_size[0] if self._text_size else rl.Vector2(0.0, 0.0)
|
|
if self._text_alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE:
|
|
total_text_height = sum(ts.y for ts in self._text_size) or self._font_size * FONT_SCALE
|
|
text_pos = rl.Vector2(self._rect.x, (self._rect.y + (self._rect.height - total_text_height) // 2))
|
|
else:
|
|
text_pos = rl.Vector2(self._rect.x, self._rect.y)
|
|
|
|
if self._icon:
|
|
icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2
|
|
if len(self._text_wrapped) > 0:
|
|
if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
|
|
icon_x = self._rect.x + self._text_padding
|
|
text_pos.x = self._icon.width + ICON_PADDING
|
|
elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
|
|
total_width = self._icon.width + ICON_PADDING + text_size.x
|
|
icon_x = self._rect.x + (self._rect.width - total_width) / 2
|
|
text_pos.x = self._icon.width + ICON_PADDING
|
|
else:
|
|
icon_x = (self._rect.x + self._rect.width - text_size.x - self._text_padding) - 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)
|
|
|
|
for text, text_size, emojis in zip_longest(self._text_wrapped, self._text_size, self._emojis, fillvalue=[]):
|
|
line_pos = rl.Vector2(text_pos.x, text_pos.y)
|
|
if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
|
|
line_pos.x += self._text_padding
|
|
elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
|
|
line_pos.x += (self._rect.width - text_size.x) // 2
|
|
elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
|
|
line_pos.x += self._rect.width - text_size.x - self._text_padding
|
|
|
|
prev_index = 0
|
|
for start, end, emoji in emojis:
|
|
text_before = text[prev_index:start]
|
|
width_before = measure_text_cached(self._font, text_before, self._font_size)
|
|
rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, 0, self._text_color)
|
|
line_pos.x += width_before.x
|
|
|
|
tex = emoji_tex(emoji)
|
|
rl.draw_texture_ex(tex, line_pos, 0.0, self._font_size / tex.height * FONT_SCALE, self._text_color)
|
|
line_pos.x += self._font_size * FONT_SCALE
|
|
prev_index = end
|
|
rl.draw_text_ex(self._font, text[prev_index:], line_pos, self._font_size, 0, self._text_color)
|
|
text_pos.y += (text_size.y or self._font_size * FONT_SCALE) * self._line_scale
|
|
|
|
|
|
class UnifiedLabel(Widget):
|
|
"""
|
|
Unified label widget that combines functionality from gui_label, gui_text_box, Label, and MiciLabel.
|
|
|
|
Supports:
|
|
- Emoji rendering
|
|
- Text wrapping
|
|
- Automatic eliding (single-line or multiline)
|
|
- Proper multiline vertical alignment
|
|
- Height calculation for layout purposes
|
|
"""
|
|
def __init__(self,
|
|
text: str | Callable[[], str],
|
|
font_size: int = DEFAULT_TEXT_SIZE,
|
|
font_weight: FontWeight = FontWeight.NORMAL,
|
|
text_color: rl.Color = DEFAULT_TEXT_COLOR,
|
|
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
|
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
|
text_padding: int = 0,
|
|
max_width: int | None = None,
|
|
elide: bool = True,
|
|
wrap_text: bool = True,
|
|
scroll: bool = False,
|
|
line_height: float = 1.0,
|
|
letter_spacing: float = 0.0):
|
|
super().__init__()
|
|
self._text = text
|
|
self._font_size = font_size
|
|
self._font_weight = font_weight
|
|
self._font = gui_app.font(self._font_weight)
|
|
self._text_color = text_color
|
|
self._alignment = alignment
|
|
self._alignment_vertical = alignment_vertical
|
|
self._text_padding = text_padding
|
|
self._max_width = max_width
|
|
self._elide = elide
|
|
self._wrap_text = wrap_text
|
|
self._scroll = scroll
|
|
self._line_height = line_height * 0.9
|
|
self._letter_spacing = letter_spacing # 0.1 = 10%
|
|
self._spacing_pixels = font_size * letter_spacing
|
|
|
|
# Scroll state
|
|
self._scroll = scroll
|
|
self._needs_scroll = False
|
|
self._scroll_offset = 0
|
|
self._scroll_pause_t: float | None = None
|
|
self._scroll_state: ScrollState = ScrollState.STARTING
|
|
|
|
# Scroll mode does not support eliding or multiline wrapping
|
|
if self._scroll:
|
|
self._elide = False
|
|
self._wrap_text = False
|
|
|
|
# Cached data
|
|
self._cached_text: str | None = None
|
|
self._cached_wrapped_lines: list[str] = []
|
|
self._cached_line_sizes: list[rl.Vector2] = []
|
|
self._cached_line_emojis: list[list[tuple[int, int, str]]] = []
|
|
self._cached_total_height: float | None = None
|
|
self._cached_width: int = -1
|
|
|
|
# If max_width is set, initialize rect size for Scroller support
|
|
if max_width is not None:
|
|
self._rect.width = max_width
|
|
self._rect.height = self.get_content_height(max_width)
|
|
|
|
def set_text(self, text: str | Callable[[], str]):
|
|
"""Update the text content."""
|
|
self._text = text
|
|
# No need to update cache here, will be done on next render if needed
|
|
|
|
@property
|
|
def text(self) -> str:
|
|
"""Get the current text content."""
|
|
return str(_resolve_value(self._text))
|
|
|
|
def set_text_color(self, color: rl.Color):
|
|
"""Update the text color."""
|
|
self._text_color = color
|
|
|
|
def set_color(self, color: rl.Color):
|
|
"""Update the text color (alias for set_text_color)."""
|
|
self.set_text_color(color)
|
|
|
|
def set_font_size(self, size: int):
|
|
"""Update the font size."""
|
|
if self._font_size != size:
|
|
self._font_size = size
|
|
self._spacing_pixels = size * self._letter_spacing # Recalculate spacing
|
|
self._cached_text = None # Invalidate cache
|
|
|
|
def set_letter_spacing(self, letter_spacing: float):
|
|
"""Update letter spacing (as percentage, e.g., 0.1 = 10%)."""
|
|
if self._letter_spacing != letter_spacing:
|
|
self._letter_spacing = letter_spacing
|
|
self._spacing_pixels = self._font_size * letter_spacing
|
|
self._cached_text = None # Invalidate cache
|
|
|
|
def set_font_weight(self, font_weight: FontWeight):
|
|
"""Update the font weight."""
|
|
if self._font_weight != font_weight:
|
|
self._font_weight = font_weight
|
|
self._font = gui_app.font(self._font_weight)
|
|
self._cached_text = None # Invalidate cache
|
|
|
|
def set_alignment(self, alignment: int):
|
|
"""Update the horizontal text alignment."""
|
|
self._alignment = alignment
|
|
|
|
def set_alignment_vertical(self, alignment_vertical: int):
|
|
"""Update the vertical text alignment."""
|
|
self._alignment_vertical = alignment_vertical
|
|
|
|
def reset_scroll(self):
|
|
"""Reset scroll state to initial position."""
|
|
self._scroll_offset = 0
|
|
self._scroll_pause_t = None
|
|
self._scroll_state = ScrollState.STARTING
|
|
|
|
def set_max_width(self, max_width: int | None):
|
|
"""Set the maximum width constraint for wrapping/eliding."""
|
|
if self._max_width != max_width:
|
|
self._max_width = max_width
|
|
self._cached_text = None # Invalidate cache
|
|
# Update rect size for Scroller support
|
|
if max_width is not None:
|
|
self._rect.width = max_width
|
|
self._rect.height = self.get_content_height(max_width)
|
|
|
|
def _update_text_cache(self, available_width: int):
|
|
"""Update cached text processing data."""
|
|
text = self.text
|
|
|
|
# Check if cache is still valid
|
|
if (self._cached_text == text and
|
|
self._cached_width == available_width and
|
|
self._cached_wrapped_lines):
|
|
return
|
|
|
|
self._cached_text = text
|
|
self._cached_width = available_width
|
|
|
|
# Determine wrapping width
|
|
content_width = available_width - (self._text_padding * 2)
|
|
if content_width <= 0:
|
|
content_width = 1
|
|
|
|
# Wrap text if enabled
|
|
if self._wrap_text:
|
|
self._cached_wrapped_lines = wrap_text(self._font, text, self._font_size, content_width, self._spacing_pixels)
|
|
else:
|
|
# Split by newlines but don't wrap
|
|
self._cached_wrapped_lines = text.split('\n') if text else [""]
|
|
|
|
# Elide lines if needed (for width constraint)
|
|
self._cached_wrapped_lines = [self._elide_line(line, content_width) for line in self._cached_wrapped_lines]
|
|
|
|
if self._scroll:
|
|
self._cached_wrapped_lines = self._cached_wrapped_lines[:1] # Only first line for scrolling
|
|
|
|
# Process each line: measure and find emojis
|
|
self._cached_line_sizes = []
|
|
self._cached_line_emojis = []
|
|
|
|
for line in self._cached_wrapped_lines:
|
|
emojis = find_emoji(line)
|
|
self._cached_line_emojis.append(emojis)
|
|
# Empty lines should still have height (use font size as line height)
|
|
if not line:
|
|
size = rl.Vector2(0, self._font_size * FONT_SCALE)
|
|
else:
|
|
size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
|
|
|
|
# This is the only line
|
|
if self._scroll:
|
|
self._needs_scroll = size.x > content_width
|
|
|
|
self._cached_line_sizes.append(size)
|
|
|
|
# Calculate total height
|
|
# Each line contributes its measured height * line_height (matching Label's behavior)
|
|
# This includes spacing to the next line
|
|
if self._cached_line_sizes:
|
|
# Match the rendering logic: first line doesn't get line_height scaling
|
|
total_height = 0.0
|
|
for idx, size in enumerate(self._cached_line_sizes):
|
|
if idx == 0:
|
|
total_height += size.y
|
|
else:
|
|
total_height += size.y * self._line_height
|
|
self._cached_total_height = total_height
|
|
else:
|
|
self._cached_total_height = 0.0
|
|
|
|
def _elide_line(self, line: str, max_width: int, force: bool = False) -> str:
|
|
"""Elide a single line if it exceeds max_width. If force is True, always elide even if it fits."""
|
|
if not self._elide and not force:
|
|
return line
|
|
|
|
text_size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
|
|
if text_size.x <= max_width and not force:
|
|
return line
|
|
|
|
ellipsis = "..."
|
|
# If force=True and line fits, just append ellipsis without truncating
|
|
if force and text_size.x <= max_width:
|
|
ellipsis_size = measure_text_cached(self._font, ellipsis, self._font_size, self._spacing_pixels)
|
|
if text_size.x + ellipsis_size.x <= max_width:
|
|
return line + ellipsis
|
|
# If line + ellipsis doesn't fit, need to truncate
|
|
# Fall through to binary search below
|
|
|
|
left, right = 0, len(line)
|
|
while left < right:
|
|
mid = (left + right) // 2
|
|
candidate = line[:mid] + ellipsis
|
|
candidate_size = measure_text_cached(self._font, candidate, self._font_size, self._spacing_pixels)
|
|
if candidate_size.x <= max_width:
|
|
left = mid + 1
|
|
else:
|
|
right = mid
|
|
return line[:left - 1] + ellipsis if left > 0 else ellipsis
|
|
|
|
def get_content_height(self, max_width: int) -> float:
|
|
"""
|
|
Returns the height needed for text at given max_width.
|
|
Similar to HtmlRenderer.get_total_height().
|
|
"""
|
|
# Use max_width if provided, otherwise use self._max_width or a default
|
|
width = max_width if max_width > 0 else (self._max_width if self._max_width else 1000)
|
|
self._update_text_cache(width)
|
|
|
|
if self._cached_total_height is not None:
|
|
return self._cached_total_height
|
|
return 0.0
|
|
|
|
def _render(self, _):
|
|
"""Render the label."""
|
|
if self._rect.width <= 0 or self._rect.height <= 0:
|
|
return
|
|
|
|
# Determine available width
|
|
available_width = self._rect.width
|
|
if self._max_width is not None:
|
|
available_width = min(available_width, self._max_width)
|
|
|
|
# Update text cache
|
|
self._update_text_cache(int(available_width))
|
|
|
|
if not self._cached_wrapped_lines:
|
|
return
|
|
|
|
# Calculate which lines fit in the available height
|
|
visible_lines: list[str] = []
|
|
visible_sizes: list[rl.Vector2] = []
|
|
visible_emojis: list[list[tuple[int, int, str]]] = []
|
|
|
|
current_height = 0.0
|
|
broke_early = False
|
|
for line, size, emojis in zip(
|
|
self._cached_wrapped_lines,
|
|
self._cached_line_sizes,
|
|
self._cached_line_emojis,
|
|
strict=True):
|
|
|
|
# Calculate height needed for this line
|
|
# Each line contributes its height * line_height (matching Label's behavior)
|
|
line_height_needed = size.y * self._line_height
|
|
|
|
# Check if this line fits
|
|
if current_height + line_height_needed > self._rect.height:
|
|
# This line doesn't fit
|
|
if len(visible_lines) == 0:
|
|
# First line doesn't fit by height - still show it (will be clipped by scissor if needed)
|
|
# Continue to add this line below
|
|
pass
|
|
else:
|
|
# We have visible lines and this one doesn't fit - mark that we broke early
|
|
broke_early = True
|
|
break
|
|
|
|
visible_lines.append(line)
|
|
visible_sizes.append(size)
|
|
visible_emojis.append(emojis)
|
|
|
|
current_height += line_height_needed
|
|
|
|
# If we broke early (there are more lines that don't fit) and elide is enabled, elide the last visible line
|
|
if broke_early and len(visible_lines) > 0 and self._elide:
|
|
content_width = int(available_width - (self._text_padding * 2))
|
|
if content_width <= 0:
|
|
content_width = 1
|
|
|
|
last_line_idx = len(visible_lines) - 1
|
|
last_line = visible_lines[last_line_idx]
|
|
# Force elide the last line to show "..." even if it fits in width (to indicate more content)
|
|
elided = self._elide_line(last_line, content_width, force=True)
|
|
visible_lines[last_line_idx] = elided
|
|
visible_sizes[last_line_idx] = measure_text_cached(self._font, elided, self._font_size, self._spacing_pixels)
|
|
|
|
if not visible_lines:
|
|
return
|
|
|
|
# Calculate total visible text block height
|
|
# First line is not changed by line_height scaling
|
|
total_visible_height = 0.0
|
|
for idx, size in enumerate(visible_sizes):
|
|
if idx == 0:
|
|
total_visible_height += size.y
|
|
else:
|
|
total_visible_height += size.y * self._line_height
|
|
|
|
# Calculate vertical alignment offset
|
|
if self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP:
|
|
start_y = self._rect.y
|
|
elif self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
|
|
start_y = self._rect.y + self._rect.height - total_visible_height
|
|
else: # TEXT_ALIGN_MIDDLE
|
|
start_y = self._rect.y + (self._rect.height - total_visible_height) / 2
|
|
|
|
# Only scissor when we know there is a single scrolling line
|
|
# Pad a little since descenders like g or j may overflow below rect from font_scale
|
|
if self._needs_scroll:
|
|
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y - self._font_size / 2), int(self._rect.width), int(self._rect.height + self._font_size))
|
|
|
|
# Render each line
|
|
current_y = start_y
|
|
for idx, (line, size, emojis) in enumerate(zip(visible_lines, visible_sizes, visible_emojis, strict=True)):
|
|
if self._needs_scroll:
|
|
if self._scroll_state == ScrollState.STARTING:
|
|
if self._scroll_pause_t is None:
|
|
self._scroll_pause_t = rl.get_time() + 2.0
|
|
if rl.get_time() >= self._scroll_pause_t:
|
|
self._scroll_state = ScrollState.SCROLLING
|
|
self._scroll_pause_t = None
|
|
|
|
elif self._scroll_state == ScrollState.SCROLLING:
|
|
self._scroll_offset -= 0.8 / 60. * gui_app.target_fps
|
|
# don't fully hide
|
|
if self._scroll_offset <= -size.x - self._rect.width / 3:
|
|
self._scroll_offset = 0
|
|
self._scroll_state = ScrollState.STARTING
|
|
self._scroll_pause_t = None
|
|
else:
|
|
self.reset_scroll()
|
|
|
|
self._render_line(line, size, emojis, current_y)
|
|
|
|
# Draw 2nd instance for scrolling
|
|
if self._needs_scroll and self._scroll_state != ScrollState.STARTING:
|
|
text2_scroll_offset = size.x + self._rect.width / 3
|
|
self._render_line(line, size, emojis, current_y, text2_scroll_offset)
|
|
|
|
# Move to next line (if not last line)
|
|
if idx < len(visible_lines) - 1:
|
|
# Use current line's height * line_height for spacing to next line
|
|
current_y += size.y * self._line_height
|
|
|
|
if self._needs_scroll:
|
|
# draw black fade on left and right
|
|
fade_width = 20
|
|
rl.draw_rectangle_gradient_h(int(self._rect.x + self._rect.width - fade_width), int(self._rect.y), fade_width, int(self._rect.height), rl.BLANK, rl.BLACK)
|
|
if self._scroll_state != ScrollState.STARTING:
|
|
rl.draw_rectangle_gradient_h(int(self._rect.x), int(self._rect.y), fade_width, int(self._rect.height), rl.BLACK, rl.BLANK)
|
|
|
|
rl.end_scissor_mode()
|
|
|
|
def _render_line(self, line, size, emojis, current_y, x_offset=0.0):
|
|
# Calculate horizontal position
|
|
if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
|
|
line_x = self._rect.x + self._text_padding
|
|
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
|
|
line_x = self._rect.x + (self._rect.width - size.x) / 2
|
|
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
|
|
line_x = self._rect.x + self._rect.width - size.x - self._text_padding
|
|
else:
|
|
line_x = self._rect.x + self._text_padding
|
|
line_x += self._scroll_offset + x_offset
|
|
|
|
# Render line with emojis
|
|
line_pos = rl.Vector2(line_x, current_y)
|
|
prev_index = 0
|
|
|
|
for start, end, emoji in emojis:
|
|
# Draw text before emoji
|
|
text_before = line[prev_index:start]
|
|
if text_before:
|
|
rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, self._spacing_pixels, self._text_color)
|
|
width_before = measure_text_cached(self._font, text_before, self._font_size, self._spacing_pixels)
|
|
line_pos.x += width_before.x
|
|
|
|
# Draw emoji
|
|
tex = emoji_tex(emoji)
|
|
emoji_scale = self._font_size / tex.height * FONT_SCALE
|
|
rl.draw_texture_ex(tex, line_pos, 0.0, emoji_scale, self._text_color)
|
|
# Emoji width is font_size * FONT_SCALE (as per measure_text_cached)
|
|
line_pos.x += self._font_size * FONT_SCALE
|
|
prev_index = end
|
|
|
|
# Draw remaining text after last emoji
|
|
text_after = line[prev_index:]
|
|
if text_after:
|
|
rl.draw_text_ex(self._font, text_after, line_pos, self._font_size, self._spacing_pixels, self._text_color)
|