forked from IQ.Lvbs/IQ.Pilot
563 lines
22 KiB
Python
563 lines
22 KiB
Python
import threading
|
||
import time
|
||
import pyray as rl
|
||
from collections.abc import Callable
|
||
|
||
import requests
|
||
|
||
from openpilot.common.params import Params
|
||
from openpilot.selfdrive.ui.lib.nav_helpers import (has_mapbox_token, resolve_mapbox_token,
|
||
current_or_last_gps_position)
|
||
from openpilot.iqpilot.ui.onroad.nav_map_panel import NavMapPanel
|
||
from openpilot.iqpilot.ui.onroad.nav_map_utils import build_mapbox_static_url
|
||
from openpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT
|
||
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, GL_VERSION
|
||
from openpilot.system.ui.lib.multilang import tr
|
||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||
from openpilot.system.ui.widgets import Widget
|
||
from openpilot.system.ui.widgets.keyboard import Keyboard
|
||
from openpilot.selfdrive.ui.lib import nav_search
|
||
from openpilot.selfdrive.ui.lib.nav_search import NavSearch, SearchResult
|
||
from openpilot.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(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
|