ui: slider shimmer sans shader (#37640)

* actually epic

* use child

* inside label

* revert other stuff

* no reset_shimmer: bool

* try 2 char

* not worth dynamic chunking

* bring back

* rm

* no emoji support on shimmer
This commit is contained in:
Shane Smiskol
2026-03-10 20:53:17 -07:00
committed by GitHub
parent bea040095c
commit 50f0cf25a6
2 changed files with 86 additions and 15 deletions
+68 -2
View File
@@ -1,3 +1,4 @@
import math
from enum import IntEnum
from collections.abc import Callable
from itertools import zip_longest
@@ -241,6 +242,13 @@ class UnifiedLabel(Widget):
- Proper multiline vertical alignment
- Height calculation for layout purposes
"""
# 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],
font_size: int = DEFAULT_TEXT_SIZE,
@@ -254,7 +262,8 @@ class UnifiedLabel(Widget):
wrap_text: bool = True,
scroll: bool = False,
line_height: float = 1.0,
letter_spacing: float = 0.0):
letter_spacing: float = 0.0,
shimmer: bool = False):
super().__init__()
self._text = text
self._font_size = font_size
@@ -272,6 +281,10 @@ class UnifiedLabel(Widget):
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
# Scroll state
self._scroll = scroll
self._needs_scroll = False
@@ -365,6 +378,15 @@ 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):
"""Set the maximum width constraint for wrapping/eliding."""
if self._max_width != max_width:
@@ -618,6 +640,24 @@ class UnifiedLabel(Widget):
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:
@@ -630,7 +670,13 @@ class UnifiedLabel(Widget):
line_x = self._rect.x + self._text_padding
line_x += self._scroll_offset + x_offset
# Render line with emojis
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)
def _render_line_normal(self, line, emojis, line_x, current_y):
line_pos = rl.Vector2(line_x, current_y)
prev_index = 0
@@ -654,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
+18 -13
View File
@@ -19,9 +19,10 @@ class SliderBase(Widget, abc.ABC):
_circle_bg_pressed_txt: rl.Texture
_circle_arrow_txt: rl.Texture
def __init__(self, title: str, confirm_callback: Callable | None = None):
def __init__(self, title: str, confirm_callback: Callable | None = None, shimmer_offset: float = 0.0):
super().__init__()
self._confirm_callback = confirm_callback
self._shimmer_offset = shimmer_offset
self._load_assets()
@@ -39,9 +40,9 @@ class SliderBase(Widget, abc.ABC):
self._is_dragging_circle = False
self._label = UnifiedLabel(title, font_size=36, font_weight=FontWeight.SEMI_BOLD, text_color=rl.Color(255, 255, 255, int(255 * 0.65)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9)
self._label = self._child(UnifiedLabel(title, font_size=36, font_weight=FontWeight.SEMI_BOLD, text_color=rl.WHITE,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9, shimmer=True))
@abc.abstractmethod
def _load_assets(self):
@@ -51,12 +52,17 @@ class SliderBase(Widget, abc.ABC):
def confirmed(self) -> bool:
return self._confirmed_time > 0.0
def show_event(self):
super().show_event()
self.reset()
def reset(self):
# reset all slider state
self._is_dragging_circle = False
self._circle_press_time = None
self._confirmed_time = 0.0
self._confirm_callback_called = False
self._label.reset_shimmer(self._shimmer_offset)
def set_opacity(self, opacity: float, smooth: bool = False):
if smooth:
@@ -123,8 +129,6 @@ class SliderBase(Widget, abc.ABC):
self._scroll_x_circle_filter.x = self._scroll_x_circle
def _render(self, _):
# TODO: iOS text shimmering animation
white = rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))
bg_txt_x = self._rect.x + (self._rect.width - self._bg_txt.width) / 2
@@ -134,8 +138,9 @@ class SliderBase(Widget, abc.ABC):
btn_x = bg_txt_x + self._bg_txt.width - self._circle_bg_txt.width + self._scroll_x_circle_filter.x
btn_y = self._rect.y + (self._rect.height - self._circle_bg_txt.height) / 2
if not self.confirmed:
self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.65 * (1.0 - self.slider_percentage) * self._opacity_filter.x)))
label_alpha = int(255 * (1.0 - self.slider_percentage) * self._opacity_filter.x)
if label_alpha > 0:
self._label.set_text_color(rl.Color(255, 255, 255, label_alpha))
label_rect = rl.Rectangle(
self._rect.x + 20,
self._rect.y,
@@ -158,9 +163,9 @@ class SliderBase(Widget, abc.ABC):
class LargerSlider(SliderBase):
def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True):
def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True, shimmer_offset: float = 0.0):
self._green = green
super().__init__(title, confirm_callback=confirm_callback)
super().__init__(title, confirm_callback=confirm_callback, shimmer_offset=shimmer_offset)
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 115))
@@ -176,9 +181,9 @@ class BigSlider(SliderBase):
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable | None = None):
self._icon = icon
super().__init__(title, confirm_callback=confirm_callback)
self._label = UnifiedLabel(title, font_size=48, font_weight=FontWeight.DISPLAY, text_color=rl.Color(255, 255, 255, int(255 * 0.65)),
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
line_height=0.875)
self._label.set_font_size(48)
self._label.set_font_weight(FontWeight.DISPLAY)
self._label.set_line_height(0.875)
def _load_assets(self):
self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180))