mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-07-06 14:16:39 +08:00
UI
This commit is contained in:
+75
-202
@@ -27,166 +27,6 @@ class ScrollState(IntEnum):
|
||||
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), round(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), round(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,
|
||||
@@ -233,7 +73,7 @@ def gui_label(
|
||||
|
||||
# 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(round(text_x), round(text_y)), font_size, 0, color)
|
||||
rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color)
|
||||
|
||||
|
||||
def gui_text_box(
|
||||
@@ -393,7 +233,7 @@ class Label(Widget):
|
||||
|
||||
class UnifiedLabel(Widget):
|
||||
"""
|
||||
Unified label widget that combines functionality from gui_label, gui_text_box, Label, and MiciLabel.
|
||||
Unified label widget that combines functionality from gui_label, gui_text_box, and Label.
|
||||
|
||||
Supports:
|
||||
- Emoji rendering
|
||||
@@ -402,11 +242,12 @@ class UnifiedLabel(Widget):
|
||||
- Proper multiline vertical alignment
|
||||
- Height calculation for layout purposes
|
||||
"""
|
||||
SHIMMER_BAND_WIDTH = 0.3
|
||||
SHIMMER_BLUR_RADIUS = 0.12
|
||||
SHIMMER_CYCLE_PERIOD = 2.5
|
||||
SHIMMER_SWEEP_FRACTION = 0.9
|
||||
SHIMMER_LOW_OPACITY = 0.65
|
||||
# Shimmer constants
|
||||
SHIMMER_BAND_WIDTH = 0.3 # shimmer width as fraction of text width
|
||||
SHIMMER_BLUR_RADIUS = 0.12 # gaussian blur as fraction of text width
|
||||
SHIMMER_CYCLE_PERIOD = 2.5 # seconds per full shimmer cycle
|
||||
SHIMMER_SWEEP_FRACTION = 0.9 # fraction of cycle spent sweeping (rest is pause)
|
||||
SHIMMER_LOW_OPACITY = 0.65 # text opacity at rest, shimmer brings to 1.0
|
||||
|
||||
def __init__(self,
|
||||
text: str | Callable[[], str],
|
||||
@@ -439,6 +280,8 @@ class UnifiedLabel(Widget):
|
||||
self._line_height = line_height * 0.9
|
||||
self._letter_spacing = letter_spacing # 0.1 = 10%
|
||||
self._spacing_pixels = font_size * letter_spacing
|
||||
|
||||
# Shimmer state
|
||||
self._shimmer = shimmer
|
||||
self._shimmer_start_time = 0.0
|
||||
|
||||
@@ -477,6 +320,14 @@ class UnifiedLabel(Widget):
|
||||
"""Get the current text content."""
|
||||
return str(_resolve_value(self._text))
|
||||
|
||||
@property
|
||||
def font_size(self) -> int:
|
||||
return self._font_size
|
||||
|
||||
@property
|
||||
def text_width(self) -> float:
|
||||
return max((s.x for s in self._cached_line_sizes), default=0.0)
|
||||
|
||||
def set_text_color(self, color: rl.Color):
|
||||
"""Update the text color."""
|
||||
self._text_color = color
|
||||
@@ -504,7 +355,7 @@ class UnifiedLabel(Widget):
|
||||
new_line_height = line_height * 0.9
|
||||
if self._line_height != new_line_height:
|
||||
self._line_height = new_line_height
|
||||
self._cached_text = None
|
||||
self._cached_text = None # Invalidate cache (affects total height)
|
||||
|
||||
def set_font_weight(self, font_weight: FontWeight):
|
||||
"""Update the font weight."""
|
||||
@@ -527,7 +378,13 @@ class UnifiedLabel(Widget):
|
||||
self._scroll_pause_t = None
|
||||
self._scroll_state = ScrollState.STARTING
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
if self._shimmer:
|
||||
self.reset_shimmer()
|
||||
|
||||
def reset_shimmer(self, offset: float = 0.0):
|
||||
"""Reset shimmer animation timing."""
|
||||
self._shimmer_start_time = rl.get_time() + offset
|
||||
|
||||
def set_max_width(self, max_width: int | None):
|
||||
@@ -647,25 +504,6 @@ class UnifiedLabel(Widget):
|
||||
return self._cached_total_height
|
||||
return 0.0
|
||||
|
||||
def _compute_shimmer_alpha(self, char_center_x: float, text_left: float, text_width: float) -> float:
|
||||
if text_width <= 0:
|
||||
return self.SHIMMER_LOW_OPACITY
|
||||
|
||||
elapsed = rl.get_time() - self._shimmer_start_time
|
||||
sigma = text_width * self.SHIMMER_BLUR_RADIUS
|
||||
|
||||
t_raw = (elapsed % self.SHIMMER_CYCLE_PERIOD) / self.SHIMMER_CYCLE_PERIOD
|
||||
t_clamped = max(0.0, min(t_raw / self.SHIMMER_SWEEP_FRACTION, 1.0))
|
||||
t = t_clamped * t_clamped * (3.0 - 2.0 * t_clamped)
|
||||
|
||||
margin = text_width * self.SHIMMER_BAND_WIDTH
|
||||
text_right = text_left + text_width
|
||||
center = text_right + margin - t * (text_width + 2.0 * margin)
|
||||
|
||||
d = char_center_x - center
|
||||
shimmer = math.exp(-0.5 * d * d / (sigma * sigma)) if sigma > 0 else 0.0
|
||||
return self.SHIMMER_LOW_OPACITY + (1.0 - self.SHIMMER_LOW_OPACITY) * shimmer
|
||||
|
||||
def _render(self, _):
|
||||
"""Render the label."""
|
||||
if self._rect.width <= 0 or self._rect.height <= 0:
|
||||
@@ -792,11 +630,34 @@ class UnifiedLabel(Widget):
|
||||
# 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:
|
||||
|
||||
# stop drawing left fade once text scrolls past
|
||||
text_width = visible_sizes[0].x if visible_sizes else 0
|
||||
first_copy_in_view = self._scroll_offset + text_width > 0
|
||||
draw_left_fade = self._scroll_state != ScrollState.STARTING and first_copy_in_view
|
||||
if draw_left_fade:
|
||||
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 _shimmer_alpha(self, char_x: float, shimmer_left: float, shimmer_width: float) -> float:
|
||||
"""Compute shimmer opacity multiplier for a character at the given x position."""
|
||||
sigma = shimmer_width * self.SHIMMER_BLUR_RADIUS
|
||||
if sigma <= 0:
|
||||
return self.SHIMMER_LOW_OPACITY
|
||||
|
||||
elapsed = rl.get_time() - self._shimmer_start_time
|
||||
t_raw = (elapsed % self.SHIMMER_CYCLE_PERIOD) / self.SHIMMER_CYCLE_PERIOD
|
||||
t_clamped = max(0.0, min(t_raw / self.SHIMMER_SWEEP_FRACTION, 1.0))
|
||||
t = t_clamped * t_clamped * (3.0 - 2.0 * t_clamped) # smoothstep
|
||||
|
||||
margin = shimmer_width * self.SHIMMER_BAND_WIDTH
|
||||
center = shimmer_left + shimmer_width + margin - t * (shimmer_width + 2.0 * margin)
|
||||
|
||||
d = char_x - center
|
||||
shimmer = math.exp(-0.5 * d * d / (sigma * sigma))
|
||||
return self.SHIMMER_LOW_OPACITY + (1.0 - self.SHIMMER_LOW_OPACITY) * shimmer
|
||||
|
||||
def _render_line(self, line, size, emojis, current_y, x_offset=0.0):
|
||||
# Calculate horizontal position
|
||||
if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
|
||||
@@ -809,21 +670,13 @@ class UnifiedLabel(Widget):
|
||||
line_x = self._rect.x + self._text_padding
|
||||
line_x += self._scroll_offset + x_offset
|
||||
|
||||
if self._shimmer and not emojis and line:
|
||||
base_alpha = self._text_color.a / 255.0
|
||||
text_width = max(size.x, 1.0)
|
||||
cursor_x = line_x
|
||||
for char in line:
|
||||
char_width = measure_text_cached(self._font, char, self._font_size, self._spacing_pixels).x
|
||||
char_center_x = cursor_x + char_width / 2.0
|
||||
shimmer_alpha = self._compute_shimmer_alpha(char_center_x, line_x, text_width)
|
||||
char_alpha = int(255 * base_alpha * shimmer_alpha)
|
||||
char_color = rl.Color(self._text_color.r, self._text_color.g, self._text_color.b, char_alpha)
|
||||
rl.draw_text_ex(self._font, char, rl.Vector2(cursor_x, current_y), self._font_size, self._spacing_pixels, char_color)
|
||||
cursor_x += char_width
|
||||
return
|
||||
if self._shimmer:
|
||||
self._render_line_shimmer(line, line_x, current_y)
|
||||
else:
|
||||
# Render line with emojis
|
||||
self._render_line_normal(line, emojis, line_x, current_y)
|
||||
|
||||
# Render line with emojis
|
||||
def _render_line_normal(self, line, emojis, line_x, current_y):
|
||||
line_pos = rl.Vector2(line_x, current_y)
|
||||
prev_index = 0
|
||||
|
||||
@@ -847,3 +700,23 @@ class UnifiedLabel(Widget):
|
||||
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)
|
||||
|
||||
def _render_line_shimmer(self, line, line_x, current_y):
|
||||
# Shimmer range based on widest line so sweep is even across all lines
|
||||
max_width = self.text_width
|
||||
if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
|
||||
shimmer_left = self._rect.x + self._rect.width - self._text_padding - max_width
|
||||
elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
|
||||
shimmer_left = self._rect.x + (self._rect.width - max_width) / 2
|
||||
else:
|
||||
shimmer_left = self._rect.x + self._text_padding
|
||||
|
||||
base_a = self._text_color.a / 255.0
|
||||
cursor_x = line_x
|
||||
for ch in line:
|
||||
char_width = measure_text_cached(self._font, ch, self._font_size, self._spacing_pixels).x
|
||||
char_center_x = cursor_x + char_width / 2.0
|
||||
alpha = int(255 * self._shimmer_alpha(char_center_x, shimmer_left, max_width) * base_a)
|
||||
color = rl.Color(self._text_color.r, self._text_color.g, self._text_color.b, alpha)
|
||||
rl.draw_text_ex(self._font, ch, rl.Vector2(cursor_x, current_y), self._font_size, 0, color)
|
||||
cursor_x += char_width + self._spacing_pixels
|
||||
|
||||
Reference in New Issue
Block a user