Files
IQ.Pilot/iqpilot/selfdrive/ui/layouts/nav.py
2026-08-22 23:42:41 -05:00

563 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import threading
import time
import pyray as rl
from collections.abc import Callable
import requests
from iqpilot.common.params import Params
from iqpilot.selfdrive.ui.lib.nav_helpers import (has_mapbox_token, resolve_mapbox_token,
current_or_last_gps_position)
from iqpilot.ui.onroad.nav_map_panel import NavMapPanel
from iqpilot.ui.onroad.nav_map_utils import build_mapbox_static_url
from iqpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT
from iqpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, GL_VERSION
from iqpilot.system.ui.lib.multilang import tr
from iqpilot.system.ui.lib.text_measure import measure_text_cached
from iqpilot.system.ui.widgets import Widget
from iqpilot.system.ui.widgets.keyboard import Keyboard
from iqpilot.selfdrive.ui.lib import nav_search
from iqpilot.selfdrive.ui.lib.nav_search import NavSearch, SearchResult
from iqpilot.selfdrive.ui.widgets.interactive_map import InteractiveNavMap
MARGIN = 40
SPACING = 25
SEARCH_HEIGHT = 120
PILL_HEIGHT = 120
MAP_ZOOM = 15.0
MAP_RETRY_INTERVAL = 5.0
PANEL_BG = rl.Color(38, 40, 46, 255)
PANEL_BORDER = rl.Color(255, 255, 255, 38)
MUTED = rl.Color(165, 165, 170, 255)
ROUNDED_TEXTURE_VERTEX_SHADER = GL_VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
uniform mat4 mvp;
out vec2 fragTexCoord;
void main() {
fragTexCoord = vertexTexCoord;
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
ROUNDED_TEXTURE_FRAGMENT_SHADER = GL_VERSION + """
in vec2 fragTexCoord;
uniform sampler2D texture0;
uniform vec4 clipRect;
uniform float cornerRadius;
uniform float viewportHeight;
out vec4 fragColor;
float roundedRectDistance(vec2 p, vec2 center, vec2 halfSize, float radius) {
vec2 d = abs(p - center) - (halfSize - vec2(radius));
return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - radius;
}
void main() {
vec2 p = vec2(gl_FragCoord.x, viewportHeight - gl_FragCoord.y);
vec2 center = clipRect.xy + clipRect.zw * 0.5;
vec2 halfSize = clipRect.zw * 0.5;
float radius = min(cornerRadius, min(halfSize.x, halfSize.y));
float dist = roundedRectDistance(p, center, halfSize, radius);
float alpha = 1.0 - smoothstep(0.0, 1.25, dist);
vec4 sampled = texture(texture0, fragTexCoord);
fragColor = vec4(sampled.rgb, sampled.a * alpha);
}
"""
class _MapPreview:
"""Fetches a Mapbox static map (background thread) and caches it as a texture."""
def __init__(self):
self._params = Params()
self._session = requests.Session()
self._texture: rl.Texture | None = None
self._pending: tuple[tuple, bytes] | None = None
self._fetching = False
self._key: tuple | None = None
self._status = "idle"
self._last_attempt = 0.0
self._rounded_shader = None
self._rounded_shader_locs: dict[str, int] = {}
self._clip_rect = rl.ffi.new("float[]", [0.0, 0.0, 0.0, 0.0])
self._corner_radius = rl.ffi.new("float[]", [0.0])
self._viewport_height = rl.ffi.new("float[]", [0.0])
def has_token(self) -> bool:
return has_mapbox_token(self._params)
def _fetch(self, url: str, token: str, key: tuple):
try:
r = self._session.get(url, params={"access_token": token}, timeout=4.0)
if r.status_code == 200 and r.content:
self._pending = (key, r.content)
self._status = "ready"
else:
self._key = None
self._status = "error"
except requests.RequestException:
self._key = None
self._status = "error"
finally:
self._fetching = False
def request(self, lat: float, lon: float, bearing: float, w: float, h: float):
token = resolve_mapbox_token(self._params)
if not token or w < 20 or h < 20:
self._status = "token_missing" if not token else "idle"
return
key = (round(lat, 4), round(lon, 4))
if self._fetching or key == self._key:
return
now = time.monotonic()
if self._status == "error" and now - self._last_attempt < MAP_RETRY_INTERVAL:
return
self._last_attempt = now
self._key = key
self._fetching = True
self._status = "loading"
scale = min(1.0, 1000.0 / max(w, h))
url = build_mapbox_static_url(lat, lon, MAP_ZOOM, bearing, max(1, int(w * scale)), max(1, int(h * scale)))
threading.Thread(target=self._fetch, args=(url, token, key), daemon=True).start()
def _consume(self):
if self._pending is None:
return
_key, data = self._pending
self._pending = None
try:
ext = ".png" if data[:4] == b"\x89PNG" else ".jpg"
img = rl.load_image_from_memory(ext, data, len(data))
tex = rl.load_texture_from_image(img)
rl.unload_image(img)
rl.set_texture_filter(tex, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
if self._texture is not None:
rl.unload_texture(self._texture)
self._texture = tex
except Exception:
pass
def _ensure_rounded_shader(self):
if self._rounded_shader is not None:
return
self._rounded_shader = rl.load_shader_from_memory(ROUNDED_TEXTURE_VERTEX_SHADER, ROUNDED_TEXTURE_FRAGMENT_SHADER)
self._rounded_shader_locs = {
"clipRect": rl.get_shader_location(self._rounded_shader, "clipRect"),
"cornerRadius": rl.get_shader_location(self._rounded_shader, "cornerRadius"),
"viewportHeight": rl.get_shader_location(self._rounded_shader, "viewportHeight"),
}
def _draw_texture(self, src: rl.Rectangle, rect: rl.Rectangle, roundness: float):
if roundness <= 0:
rl.draw_texture_pro(self._texture, src, rect, rl.Vector2(0, 0), 0, rl.WHITE)
return
self._ensure_rounded_shader()
self._clip_rect[0:4] = [rect.x, rect.y, rect.width, rect.height]
self._corner_radius[0] = max(0.0, min(rect.width, rect.height) * roundness * 0.5)
self._viewport_height[0] = gui_app.height
rl.set_shader_value(
self._rounded_shader,
self._rounded_shader_locs["clipRect"],
self._clip_rect,
rl.ShaderUniformDataType.SHADER_UNIFORM_VEC4,
)
rl.set_shader_value(
self._rounded_shader,
self._rounded_shader_locs["cornerRadius"],
self._corner_radius,
rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT,
)
rl.set_shader_value(
self._rounded_shader,
self._rounded_shader_locs["viewportHeight"],
self._viewport_height,
rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT,
)
rl.begin_shader_mode(self._rounded_shader)
rl.draw_texture_pro(self._texture, src, rect, rl.Vector2(0, 0), 0, rl.WHITE)
rl.end_shader_mode()
def draw(self, rect: rl.Rectangle, roundness: float = 0.0) -> bool:
self._consume()
if self._texture is None or self._texture.id == 0:
return False
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
src = rl.Rectangle(0, 0, self._texture.width, self._texture.height)
self._draw_texture(src, rect, roundness)
rl.end_scissor_mode()
return True
def status(self) -> str:
if self._fetching:
return "loading"
return self._status
class _Pill(Widget):
"""A rounded destination shortcut: icon + label (Home / Work / Recent)."""
BG = rl.Color(38, 40, 46, 255)
BG_PRESSED = rl.Color(54, 57, 65, 255)
def __init__(self, icon_path: str, label: str, on_click: Callable[[], None] | None = None):
super().__init__()
self._label = label
self._icon = gui_app.texture(icon_path, 56, 56, keep_aspect_ratio=True)
if on_click is not None:
self.set_click_callback(on_click)
def _render(self, rect: rl.Rectangle):
rl.draw_rectangle_rounded(rect, 0.5, 20, self.BG_PRESSED if self.is_pressed else self.BG)
rl.draw_rectangle_rounded_lines_ex(rect, 0.5, 20, 2, PANEL_BORDER)
cy = rect.y + rect.height / 2
x = rect.x + 32
rl.draw_texture(self._icon, int(x), int(cy - self._icon.height / 2), rl.WHITE)
x += self._icon.width + 20
font = gui_app.font(FontWeight.MEDIUM)
ts = measure_text_cached(font, self._label, 40)
rl.draw_text_ex(font, self._label, rl.Vector2(int(x), int(cy - ts.y / 2)), 40, 0, rl.WHITE)
ROW_HEIGHT = 116
ROW_GAP = 16
RESULT_NAME = rl.Color(240, 240, 244, 255)
SEARCH_DEBOUNCE = 0.25
class NavLayout(Widget):
"""Offroad Navigate screen: destination search with live Mapbox autocomplete, Home/Work/Recent
shortcuts, and a location map preview. Picking a place writes NavigationDestination (navd routes)."""
def __init__(self):
super().__init__()
self._header = self._child(ScreenHeader(lambda: tr("Navigation")))
self._search_icon = gui_app.texture("icons/iq/search.png", 52, 52, keep_aspect_ratio=True)
self._pin_icon = gui_app.texture("icons/iq/pin.png", 90, 90, keep_aspect_ratio=True)
self._home_icon = gui_app.texture("icons/iq/home.png", 52, 52, keep_aspect_ratio=True)
self._work_icon = gui_app.texture("icons/iq/work.png", 52, 52, keep_aspect_ratio=True)
self._recent_icon = gui_app.texture("icons/iq/recent.png", 52, 52, keep_aspect_ratio=True)
self._keyboard = Keyboard(max_text_size=128, min_text_size=0)
self._map = _MapPreview()
self._imap = self._child(InteractiveNavMap())
self._search = NavSearch()
self._on_back_cb: Callable[[], None] | None = None
self._mode = "browse" # "browse" | "results"
self._purpose = "navigate" # "navigate" | "set_home" | "set_work"
self._query = ""
self._selecting = False
self._status_ts = 0.0
self._pending_exit = False
self._status_msg = ""
self._home: SearchResult | None = None
self._work: SearchResult | None = None
self._recents: list[SearchResult] = []
self._tap_targets: list[tuple[rl.Rectangle, Callable[[], None]]] = []
self._reload_favorites()
def _reload_favorites(self):
self._home = nav_search.get_home()
self._work = nav_search.get_work()
self._recents = nav_search.get_recents()
def set_on_back(self, cb: Callable[[], None]) -> None:
self._on_back_cb = cb
self._header.set_on_back(self._handle_back)
def show_event(self):
super().show_event()
self._mode = "browse"
self._status_msg = ""
self._reload_favorites()
def hide_event(self):
super().hide_event()
# Free the tile cache's GPU memory while the screen is away.
self._imap.release()
def _handle_back(self):
if self._mode == "results":
self._exit_search()
elif self._on_back_cb is not None:
self._on_back_cb()
# --- search lifecycle -------------------------------------------------------
def _open_search(self, purpose: str = "navigate"):
# Full-screen modal keyboard (same as before) — search runs when you hit Done.
self._purpose = purpose
self._search.new_session()
self._keyboard.reset(min_text_size=1)
title = {"set_home": tr("Set Home"), "set_work": tr("Set Work")}.get(purpose, tr("Search"))
self._keyboard.set_title(title, tr("Enter an address or place"))
self._keyboard.set_text(self._query if purpose == "navigate" else "")
gui_app.set_modal_overlay(self._keyboard, callback=self._on_search_done)
def _on_search_done(self, result: int):
if result != 1:
return
self._query = self._keyboard.text.strip()
if not self._query:
return
self._search.search(self._query)
self._mode = "results"
self._status_msg = ""
self._selecting = False
def _exit_search(self):
self._mode = "browse"
self._selecting = False
self._status_msg = ""
self._reload_favorites()
# --- selection (threaded: retrieve coords, then persist) --------------------
def _select_result(self, r: SearchResult):
if self._selecting:
return
self._selecting = True
self._status_msg = tr("Locating...")
threading.Thread(target=self._finish_select, args=(r, self._purpose), daemon=True).start()
def _finish_select(self, r: SearchResult, purpose: str):
full = self._search.retrieve(r)
if full is None or not full.has_coords:
self._status_msg = tr("Couldn't locate that place")
self._selecting = False
return
if purpose == "set_home":
nav_search.save_home(full)
elif purpose == "set_work":
nav_search.save_work(full)
else:
nav_search.set_destination(full.lat, full.lon, full.name)
nav_search.add_recent(full)
self._selecting = False
self._pending_exit = True
def _navigate_place(self, place: SearchResult):
if place is not None and place.has_coords:
nav_search.set_destination(place.lat, place.lon, place.name)
nav_search.add_recent(place)
self._reload_favorites()
self._dest_check_time = 0.0
self._set_status(tr("Destination set"))
def _set_status(self, msg: str):
self._status_msg = msg
self._status_ts = time.monotonic()
def _cancel_nav(self):
nav_search.cancel_navigation()
self._dest_check_time = 0.0
self._set_status(tr("Navigation canceled"))
def _remove_home(self):
nav_search.remove_home()
self._reload_favorites()
def _remove_work(self):
nav_search.remove_work()
self._reload_favorites()
def _remove_recent(self, r: SearchResult):
nav_search.remove_recent(r)
self._reload_favorites()
def _on_home(self):
self._navigate_place(self._home) if self._home is not None else self._open_search("set_home")
def _on_work(self):
self._navigate_place(self._work) if self._work is not None else self._open_search("set_work")
# --- render -----------------------------------------------------------------
def _render(self, rect: rl.Rectangle):
self._tap_targets = []
if self._pending_exit:
self._pending_exit = False
self._exit_search()
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
self._header.render(header_rect)
body = rl.Rectangle(rect.x + MARGIN, header_rect.y + HEADER_HEIGHT + SPACING,
rect.width - 2 * MARGIN, rect.y + rect.height - (header_rect.y + HEADER_HEIGHT + SPACING) - MARGIN)
if self._mode == "results":
self._render_results(body)
else:
self._render_browse(body)
def _row(self, rect: rl.Rectangle, icon, title: str, subtitle: str, on_tap, on_delete=None, pressed_hint=True):
hit = rl.check_collision_point_rec(rl.get_mouse_position(), rect)
bg = rl.Color(54, 57, 65, 255) if (hit and pressed_hint and rl.is_mouse_button_down(0)) else PANEL_BG
rl.draw_rectangle_rounded(rect, 0.35, 20, bg)
rl.draw_rectangle_rounded_lines_ex(rect, 0.35, 20, 2, PANEL_BORDER)
x = rect.x + 32
if icon is not None:
rl.draw_texture(icon, int(x), int(rect.y + rect.height / 2 - icon.height / 2), rl.WHITE)
x += icon.width + 24
text_w = rect.width - (x - rect.x) - (110 if on_delete is not None else 32)
font = gui_app.font(FontWeight.MEDIUM)
if subtitle:
rl.draw_text_ex(font, title, rl.Vector2(int(x), int(rect.y + 22)), 40, 0, RESULT_NAME)
sub = self._ellipsize(font, subtitle, 30, text_w)
rl.draw_text_ex(font, sub, rl.Vector2(int(x), int(rect.y + 66)), 30, 0, MUTED)
else:
ts = measure_text_cached(font, title, 42)
rl.draw_text_ex(font, title, rl.Vector2(int(x), int(rect.y + rect.height / 2 - ts.y / 2)), 42, 0, RESULT_NAME)
# Delete (×) button — appended first so a tap on it wins over the row's navigate tap.
if on_delete is not None:
cx, cy = rect.x + rect.width - 60, rect.y + rect.height / 2
del_r = rl.Rectangle(cx - 34, cy - 34, 68, 68)
dhit = rl.check_collision_point_rec(rl.get_mouse_position(), del_r)
rl.draw_circle(int(cx), int(cy), 30, rl.Color(90, 62, 66, 255) if dhit else rl.Color(60, 62, 70, 255))
rl.draw_line_ex(rl.Vector2(cx - 13, cy - 13), rl.Vector2(cx + 13, cy + 13), 4, rl.Color(230, 120, 120, 255))
rl.draw_line_ex(rl.Vector2(cx - 13, cy + 13), rl.Vector2(cx + 13, cy - 13), 4, rl.Color(230, 120, 120, 255))
self._tap_targets.append((del_r, on_delete))
if on_tap is not None:
self._tap_targets.append((rect, on_tap))
@staticmethod
def _ellipsize(font, text: str, size: int, max_w: float) -> str:
if measure_text_cached(font, text, size).x <= max_w:
return text
while text and measure_text_cached(font, text + "", size).x > max_w:
text = text[:-1]
return text + ""
def _render_browse(self, rect: rl.Rectangle):
font = gui_app.font(FontWeight.MEDIUM)
x, w = rect.x, rect.width
y = rect.y
# Search bar
bar = rl.Rectangle(x, y, w, SEARCH_HEIGHT)
rl.draw_rectangle_rounded(bar, 0.4, 20, PANEL_BG)
rl.draw_rectangle_rounded_lines_ex(bar, 0.4, 20, 2, PANEL_BORDER)
cy = bar.y + bar.height / 2
rl.draw_texture(self._search_icon, int(bar.x + 36), int(cy - self._search_icon.height / 2), MUTED)
ph = tr("Search address or place")
rl.draw_text_ex(font, ph, rl.Vector2(int(bar.x + 36 + self._search_icon.width + 24),
int(cy - 22)), 44, 0, MUTED)
self._tap_targets.append((bar, lambda: self._open_search("navigate")))
y += SEARCH_HEIGHT + SPACING
# Cancel active route (param read throttled)
now = time.monotonic()
if now - getattr(self, "_dest_check_time", 0.0) > 1.0:
self._has_dest = nav_search.has_active_destination()
self._dest_check_time = now
if getattr(self, "_has_dest", False):
cr = rl.Rectangle(x, y, w, ROW_HEIGHT)
chit = rl.check_collision_point_rec(rl.get_mouse_position(), cr)
rl.draw_rectangle_rounded(cr, 0.35, 20, rl.Color(96, 46, 48, 255) if chit else rl.Color(74, 40, 42, 255))
rl.draw_rectangle_rounded_lines_ex(cr, 0.35, 20, 2, rl.Color(210, 90, 90, 120))
label = tr("Cancel navigation")
ls = measure_text_cached(font, label, 42)
rl.draw_text_ex(font, label, rl.Vector2(int(x + 32), int(cr.y + cr.height / 2 - ls.y / 2)), 42, 0,
rl.Color(240, 180, 180, 255))
self._tap_targets.append((cr, self._cancel_nav))
y += ROW_HEIGHT + SPACING
# Home / Work
hw_gap = ROW_GAP
hw_w = (w - hw_gap) / 2
self._row(rl.Rectangle(x, y, hw_w, ROW_HEIGHT), self._home_icon, tr("Home"),
self._home.address if self._home else tr("Set home address"), self._on_home,
on_delete=(self._remove_home if self._home else None))
self._row(rl.Rectangle(x + hw_w + hw_gap, y, hw_w, ROW_HEIGHT), self._work_icon, tr("Work"),
self._work.address if self._work else tr("Set work address"), self._on_work,
on_delete=(self._remove_work if self._work else None))
y += ROW_HEIGHT + SPACING
# Recents (fit as many as room allows, leaving space for the map)
map_min = 300
for r in self._recents:
if y + ROW_HEIGHT > rect.y + rect.height - map_min - SPACING:
break
self._row(rl.Rectangle(x, y, w, ROW_HEIGHT), self._recent_icon, r.name, r.address,
(lambda r=r: self._navigate_place(r)), on_delete=(lambda r=r: self._remove_recent(r)))
y += ROW_HEIGHT + ROW_GAP
# Map preview of current location
map_rect = rl.Rectangle(x, y, w, rect.y + rect.height - y)
if map_rect.height > 120:
self._imap.render(map_rect)
if self._status_msg and time.monotonic() - self._status_ts < 2.5:
self._draw_toast(map_rect, self._status_msg)
def _draw_toast(self, area: rl.Rectangle, text: str):
font = gui_app.font(FontWeight.MEDIUM)
ts = measure_text_cached(font, text, 36)
pad = 32
pw = ts.x + pad * 2
pill = rl.Rectangle(area.x + (area.width - pw) / 2, area.y + 24, pw, 66)
rl.draw_rectangle_rounded(pill, 0.5, 20, rl.Color(20, 22, 26, 235))
rl.draw_rectangle_rounded_lines_ex(pill, 0.5, 20, 2, PANEL_BORDER)
rl.draw_text_ex(font, text, rl.Vector2(int(pill.x + pad), int(pill.y + 33 - ts.y / 2)), 36, 0, rl.WHITE)
def _render_map(self, rect: rl.Rectangle):
lat, lon, bearing, fix = current_or_last_gps_position()
if fix and self._map.has_token:
self._map.request(lat, lon, 0.0, rect.width, rect.height)
if self._map.draw(rect, roundness=0.03):
# The static map is centered on the fix, so the current location is the panel center.
cx, cy = int(rect.x + rect.width / 2), int(rect.y + rect.height / 2)
rl.draw_circle(cx, cy, 26, rl.Color(255, 255, 255, 235))
rl.draw_circle(cx, cy, 18, rl.Color(23, 134, 246, 255)) # blue location dot
return
rl.draw_rectangle_rounded(rect, 0.03, 20, rl.Color(18, 18, 20, 255))
rl.draw_rectangle_rounded_lines_ex(rect, 0.03, 20, 2, PANEL_BORDER)
pin_x = int(rect.x + (rect.width - self._pin_icon.width) / 2)
rl.draw_texture(self._pin_icon, pin_x, int(rect.y + rect.height / 2 - self._pin_icon.height), MUTED)
self._draw_center_note(rect, tr("Waiting for GPS fix..."), dy=16)
def _draw_center_note(self, rect: rl.Rectangle, text: str, dy: float = 0):
font = gui_app.font(FontWeight.MEDIUM)
ns = measure_text_cached(font, text, 40)
rl.draw_text_ex(font, text, rl.Vector2(int(rect.x + (rect.width - ns.x) / 2),
int(rect.y + rect.height / 2 + dy)), 40, 0, MUTED)
def _render_results(self, rect: rl.Rectangle):
font = gui_app.font(FontWeight.MEDIUM)
x, w = rect.x, rect.width
y = rect.y
# Query bar — tap to reopen the keyboard and refine the search.
bar = rl.Rectangle(x, y, w, SEARCH_HEIGHT)
rl.draw_rectangle_rounded(bar, 0.4, 20, PANEL_BG)
rl.draw_rectangle_rounded_lines_ex(bar, 0.4, 20, 2, PANEL_BORDER)
cy = bar.y + bar.height / 2
rl.draw_texture(self._search_icon, int(bar.x + 36), int(cy - self._search_icon.height / 2), MUTED)
rl.draw_text_ex(font, self._query or tr("Search"),
rl.Vector2(int(bar.x + 36 + self._search_icon.width + 24), int(cy - 22)), 44, 0, rl.WHITE)
self._tap_targets.append((bar, lambda: self._open_search(self._purpose)))
y += SEARCH_HEIGHT + SPACING
list_rect = rl.Rectangle(x, y, w, rect.y + rect.height - y)
results = self._search.results()
if self._selecting:
self._draw_center_note(list_rect, self._status_msg or tr("Locating..."))
return
if not results:
note = tr("Searching...") if self._search.searching else (self._status_msg or tr("No results"))
self._draw_center_note(list_rect, note)
return
for r in results:
if y + ROW_HEIGHT > list_rect.y + list_rect.height:
break
dist = f"{r.distance_m / 1609.34:.1f} mi" if r.distance_m else ""
sub = f"{r.address} · {dist}" if dist else r.address
self._row(rl.Rectangle(x, y, w, ROW_HEIGHT), self._pin_icon, r.name, sub,
(lambda r=r: self._select_result(r)))
y += ROW_HEIGHT + ROW_GAP
def _handle_mouse_release(self, mouse_pos: MousePos):
for rect, cb in self._tap_targets:
if rl.check_collision_point_rec(mouse_pos, rect):
cb()
return