"""WelcomeScreen — Entry point for the SimVX editor.
Shows recent projects, template selection, and new/open project actions.
Emits ``project_opened`` or ``skipped`` to transition to the editor.
"""
from __future__ import annotations
import importlib.metadata
import logging
from datetime import UTC, datetime
from pathlib import Path
from simvx.core import (
Button,
FileDialog,
Label,
Node,
Panel,
ScrollContainer,
Signal,
TextEdit,
VBoxContainer,
Vec2,
)
from simvx.core.ui.menu import MenuItem, PopupMenu
from simvx.core.ui.theme import get_theme
from .project_registry import ProjectRegistry, RecentProject
from .templates import PROJECT_TEMPLATES, generate_project
from .theme import ACCENT_ACTIVE
log = logging.getLogger(__name__)
# Layout
_HEADER_H = 72.0
_SIDEBAR_W = 260.0
_PAD = 14.0
_CARD_H = 48.0
_CARD_GAP = 4.0
_TEMPLATE_CARD_H = 48.0
_OVERLAY_W = 440.0
_OVERLAY_H = 260.0
def _get_version() -> str:
try:
return "v" + importlib.metadata.version("simvx-editor")
except importlib.metadata.PackageNotFoundError:
return "v0.1.0-dev"
class _HoverPanel(Panel):
"""Panel with hover highlight and child clipping."""
def __init__(self, normal_bg=None, hover_bg=None, **kwargs):
super().__init__(**kwargs)
t = get_theme()
self._normal_bg = normal_bg or t.panel_bg
self._hover_bg = hover_bg or t.hover_bg
self.bg_colour = self._normal_bg
self.mouse_filter = True
def _draw_recursive(self, renderer):
if not self.visible:
return
self.bg_colour = self._hover_bg if self.mouse_over else self._normal_bg
self.draw(renderer)
x, y, w, h = self.get_global_rect()
renderer.push_clip(int(x), int(y), int(w), int(h))
for child in self.children.safe_iter():
child._draw_recursive(renderer)
renderer.pop_clip()
class _Divider(Panel):
"""Thin vertical or horizontal divider line."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.bg_colour = get_theme().border
self.border_width = 0
[docs]
class WelcomeScreen(Node):
"""Welcome screen shown at editor launch.
Signals:
project_opened(path: str) — user selected or created a project
skipped() — user clicked Skip
"""
project_opened = Signal()
skipped = Signal()
def __init__(self, **kwargs):
super().__init__(name="WelcomeScreen", **kwargs)
self._registry = ProjectRegistry()
self._selected_template: str | None = None
self._template_cards: dict[str, _HoverPanel] = {}
self._project_cards: list[tuple[_HoverPanel, RecentProject]] = []
self._overlay: Panel | None = None
self._file_dialog: FileDialog | None = None
self._error_label: Label | None = None
self._name_input: TextEdit | None = None
self._location_input: TextEdit | None = None
self._tmpl_label: Label | None = None
self._new_btn: Button | None = None
self._context_menu: PopupMenu | None = None
self._win_w = 1024.0
self._win_h = 680.0
self._error_clear_timer = 0.0
[docs]
def ready(self):
self._registry.load()
self._registry.refresh()
if self._tree:
ss = self._tree.screen_size
self._win_w = float(ss[0]) if not hasattr(ss, "x") else float(ss.x)
self._win_h = float(ss[1]) if not hasattr(ss, "y") else float(ss.y)
self._tree._shortcut_handler = self._handle_shortcut
self._build_ui()
def _exit_tree(self):
if self._tree and self._tree._shortcut_handler == self._handle_shortcut:
self._tree._shortcut_handler = None
super()._exit_tree()
[docs]
def process(self, dt: float):
if self._tree:
ss = self._tree.screen_size
w = float(ss.x) if hasattr(ss, "x") else float(ss[0])
h = float(ss.y) if hasattr(ss, "y") else float(ss[1])
if w != self._win_w or h != self._win_h:
self._win_w, self._win_h = w, h
self._resize()
if self._error_clear_timer > 0:
self._error_clear_timer -= dt
if self._error_clear_timer <= 0 and self._error_label:
self._error_label.visible = False
# ------------------------------------------------------------------
# Keyboard shortcuts
# ------------------------------------------------------------------
def _handle_shortcut(self, key: str) -> bool:
if self._overlay and self._overlay.visible:
if key == "escape":
self._on_cancel_overlay()
return True
if key == "enter":
self._on_create_project()
return True
return False
# ------------------------------------------------------------------
# UI construction
# ------------------------------------------------------------------
def _build_ui(self):
t = get_theme()
w, h = self._win_w, self._win_h
root = self.add_child(Panel(name="WelcomeRoot"))
root.bg_colour = t.bg_darker
root.border_width = 0
root.size = Vec2(w, h)
self._root = root
self._build_header(root, w)
body_y = _HEADER_H
body_h = h - _HEADER_H
main_w = w - _SIDEBAR_W
# Vertical divider between main and sidebar
divider = root.add_child(_Divider(name="VDivider"))
divider.size = Vec2(1, body_h)
divider.position = Vec2(main_w, body_y)
self._build_main_area(root, 0, body_y, main_w, body_h)
self._build_sidebar(root, main_w + 1, body_y, _SIDEBAR_W - 1, body_h)
# Default-select "Empty" template
if "Empty" in self._template_cards:
self._select_template("Empty")
# Dialogs (hidden)
self._build_new_project_overlay(root)
self._file_dialog = FileDialog(name="WelcomeFileDialog")
self._file_dialog.file_selected.connect(self._on_open_dir_selected)
root.add_child(self._file_dialog)
self._browse_dialog = FileDialog(name="BrowseDialog")
self._browse_dialog.file_selected.connect(self._on_browse_location_selected)
root.add_child(self._browse_dialog)
self._context_menu = PopupMenu(name="ProjectContextMenu")
root.add_child(self._context_menu)
def _build_header(self, parent: Panel, w: float):
t = get_theme()
header = parent.add_child(Panel(name="Header"))
header.bg_colour = t.bg_dark
header.border_width = 0
header.size = Vec2(w, _HEADER_H)
header.position = Vec2(0, 0)
# Accent bar at top
accent_bar = header.add_child(Panel(name="AccentBar"))
accent_bar.bg_colour = t.accent
accent_bar.border_width = 0
accent_bar.size = Vec2(w, 2)
accent_bar.position = Vec2(0, 0)
title = header.add_child(Label("SimVX Engine", name="Title"))
title.font_size = 18.0
title.text_colour = t.text_bright
title.alignment = "center"
title.size = Vec2(w, 26)
title.position = Vec2(0, 10)
subtitle = header.add_child(Label("Pure Python Game Engine", name="Subtitle"))
subtitle.font_size = 10.0
subtitle.text_colour = t.text_muted
subtitle.alignment = "center"
subtitle.size = Vec2(w, 14)
subtitle.position = Vec2(0, 36)
version = header.add_child(Label(_get_version(), name="Version"))
version.font_size = 9.0
version.text_colour = t.text_muted
version.alignment = "center"
version.size = Vec2(w, 12)
version.position = Vec2(0, 52)
# Bottom border
hline = header.add_child(_Divider(name="HeaderLine"))
hline.size = Vec2(w, 1)
hline.position = Vec2(0, _HEADER_H - 1)
def _build_sidebar(self, parent: Panel, x: float, y: float, w: float, h: float):
t = get_theme()
sidebar = parent.add_child(Panel(name="Sidebar"))
sidebar.bg_colour = t.bg_dark
sidebar.border_width = 0
sidebar.size = Vec2(w, h)
sidebar.position = Vec2(x, y)
self._sidebar = sidebar
pad = _PAD
cy = pad
# -- ACTIONS section --
lbl = sidebar.add_child(Label("ACTIONS", name="ActionsLabel"))
lbl.font_size = 10.0
lbl.text_colour = t.text_muted
lbl.position = Vec2(pad, cy)
cy += 18
btn_w = w - pad * 2
self._new_btn = sidebar.add_child(
Button("+ New Project", on_press=self._on_new_project, name="NewProjectBtn")
)
self._new_btn.size = Vec2(btn_w, 26)
self._new_btn.position = Vec2(pad, cy)
self._new_btn.font_size = 12.0
self._new_btn.bg_colour = t.accent
self._new_btn.hover_colour = ACCENT_ACTIVE
self._new_btn.border_width = 0
cy += 32
open_btn = sidebar.add_child(
Button("Open Project", on_press=self._on_open_project, name="OpenProjectBtn")
)
open_btn.size = Vec2(btn_w, 26)
open_btn.position = Vec2(pad, cy)
open_btn.font_size = 12.0
open_btn.bg_colour = t.btn_bg
open_btn.hover_colour = t.btn_hover
open_btn.border_width = 0
cy += 38
# -- TEMPLATES section --
sep = sidebar.add_child(_Divider(name="TemplateSep"))
sep.size = Vec2(btn_w, 1)
sep.position = Vec2(pad, cy)
cy += 10
lbl2 = sidebar.add_child(Label("TEMPLATES", name="TemplatesLabel"))
lbl2.font_size = 10.0
lbl2.text_colour = t.text_muted
lbl2.position = Vec2(pad, cy)
cy += 18
card_w = btn_w
for tname, tmpl in PROJECT_TEMPLATES.items():
card = sidebar.add_child(_HoverPanel(name=f"Tmpl_{tname}"))
card.border_width = 0
card.corner_radius = 3
card.size = Vec2(card_w, _TEMPLATE_CARD_H)
card.position = Vec2(pad, cy)
name_lbl = card.add_child(Label(tname, name="TmplName"))
name_lbl.font_size = 12.0
name_lbl.text_colour = t.text_bright
name_lbl.position = Vec2(8, 4)
name_lbl.size = Vec2(card_w - 16, 18)
# Truncate description to fit card width (~5px/char at font_size 9)
desc_text = tmpl.description
max_chars = int((card_w - 20) / 5.0)
if len(desc_text) > max_chars:
desc_text = desc_text[: max_chars - 2] + ".."
desc_lbl = card.add_child(Label(desc_text, name="TmplDesc"))
desc_lbl.font_size = 9.0
desc_lbl.text_colour = t.text_muted
desc_lbl.position = Vec2(8, 24)
desc_lbl.size = Vec2(card_w - 16, 14)
card._welcome_template = tname
def _make_card_input(template_name):
def handler(event):
if event.button == 1 and event.pressed:
self._select_template(template_name)
return handler
card._on_gui_input = _make_card_input(tname)
self._template_cards[tname] = card
cy += _TEMPLATE_CARD_H + _CARD_GAP
# Skip at bottom
skip = sidebar.add_child(Button("Skip", on_press=self._on_skip, name="SkipBtn"))
skip.size = Vec2(btn_w, 24)
skip.position = Vec2(pad, h - 36)
skip.font_size = 11.0
skip.bg_colour = (0, 0, 0, 0)
skip.hover_colour = t.btn_hover
skip.text_colour = t.text_muted
skip.border_width = 0
def _build_main_area(self, parent: Panel, x: float, y: float, w: float, h: float):
t = get_theme()
main = parent.add_child(Panel(name="MainArea"))
main.bg_colour = t.bg_darker
main.border_width = 0
main.size = Vec2(w, h)
main.position = Vec2(x, y)
self._main = main
pad = _PAD
# Header row
header_lbl = main.add_child(Label("RECENT PROJECTS", name="RecentHeader"))
header_lbl.font_size = 9.0
header_lbl.text_colour = t.text_muted
header_lbl.position = Vec2(pad, pad)
refresh_btn = main.add_child(
Button("Refresh", on_press=self._on_refresh, name="RefreshBtn")
)
refresh_btn.size = Vec2(54, 20)
refresh_btn.position = Vec2(w - pad - 54, pad - 2)
refresh_btn.bg_colour = t.btn_bg
refresh_btn.hover_colour = t.btn_hover
refresh_btn.text_colour = t.text_label
refresh_btn.border_width = 0
refresh_btn.font_size = 9.0
# Scroll area
scroll_y = pad + 24
self._scroll = main.add_child(ScrollContainer(name="RecentScroll"))
self._scroll.size = Vec2(w - pad * 2, h - scroll_y - 40)
self._scroll.position = Vec2(pad, scroll_y)
self._scroll.bg_colour = t.bg_darker
self._projects_vbox = self._scroll.add_child(VBoxContainer(name="ProjectsVBox"))
self._projects_vbox.separation = _CARD_GAP
self._projects_vbox.size = Vec2(w - pad * 2, 0)
# Error label
self._error_label = main.add_child(Label("", name="ErrorLabel"))
self._error_label.font_size = 11.0
self._error_label.text_colour = t.error
self._error_label.position = Vec2(pad, h - 32)
self._error_label.visible = False
self._populate_recent()
def _populate_recent(self):
for card, _ in self._project_cards:
self._projects_vbox.remove_child(card)
self._project_cards.clear()
# Remove previous empty state labels
for attr in ("_empty_title", "_empty_hint"):
lbl = getattr(self, attr, None)
if lbl and lbl.parent:
lbl.parent.remove_child(lbl)
if not self._registry.recent:
t = get_theme()
main_w = self._main.size.x
main_h = self._main.size.y
cy = main_h * 0.38
self._empty_title = self._main.add_child(Label("No Recent Projects", name="EmptyTitle"))
self._empty_title.font_size = 13.0
self._empty_title.text_colour = t.text_label
self._empty_title.alignment = "center"
self._empty_title.size = Vec2(main_w, 20)
self._empty_title.position = Vec2(0, cy)
self._empty_hint = self._main.add_child(Label(
"Create a new project or open an existing one to get started.",
name="EmptyHint",
))
self._empty_hint.font_size = 10.0
self._empty_hint.text_colour = t.text_muted
self._empty_hint.alignment = "center"
self._empty_hint.size = Vec2(main_w, 16)
self._empty_hint.position = Vec2(0, cy + 22)
return
card_w = self._scroll.size.x - 16
for entry in self._registry.recent:
card = self._make_project_card(entry, card_w)
self._projects_vbox.add_child(card)
self._project_cards.append((card, entry))
def _make_project_card(self, entry: RecentProject, width: float) -> _HoverPanel:
t = get_theme()
card = _HoverPanel(name=f"Proj_{entry.name}")
card.border_width = 0
card.corner_radius = 4
card.size = Vec2(width, _CARD_H)
card.clip_children = True
right_col = 100
left_w = width - right_col - 20
# Row 1: name + badge
name_lbl = card.add_child(Label(entry.name, name="ProjName"))
name_lbl.font_size = 12.0
name_lbl.text_colour = t.text_bright
name_lbl.position = Vec2(10, 4)
name_lbl.size = Vec2(left_w, 18)
badge = card.add_child(Label(entry.template_type, name="Badge"))
badge.font_size = 9.0
badge.text_colour = t.accent
badge.position = Vec2(width - right_col, 6)
badge.size = Vec2(right_col - 10, 14)
# Row 2: path + time
display_path = entry.path.replace(str(Path.home()), "~")
if len(display_path) > 55:
display_path = "..." + display_path[-52:]
path_lbl = card.add_child(Label(display_path, name="ProjPath"))
path_lbl.font_size = 9.0
path_lbl.text_colour = t.text_muted
path_lbl.position = Vec2(10, 24)
path_lbl.size = Vec2(left_w, 14)
time_str = _relative_time(entry.last_opened)
time_lbl = card.add_child(Label(time_str, name="ProjTime"))
time_lbl.font_size = 9.0
time_lbl.text_colour = t.text_muted
time_lbl.position = Vec2(width - right_col, 24)
time_lbl.size = Vec2(right_col - 10, 14)
def _make_handler(project_entry):
def handler(event):
if event.button == 1 and not event.pressed:
self._open_recent(project_entry)
elif event.button == 3 and event.pressed:
self._show_project_context_menu(project_entry, event.position)
return handler
card._on_gui_input = _make_handler(entry)
return card
def _build_new_project_overlay(self, parent: Panel):
t = get_theme()
w, h = self._win_w, self._win_h
overlay = parent.add_child(Panel(name="NewProjectOverlay"))
overlay.bg_colour = (0.0, 0.0, 0.0, 0.5)
overlay.border_width = 0
overlay.size = Vec2(w, h)
overlay.position = Vec2(0, 0)
overlay.visible = False
self._overlay = overlay
dx = (w - _OVERLAY_W) / 2
dy = (h - _OVERLAY_H) / 2
dialog = overlay.add_child(Panel(name="NewProjectDialog"))
dialog.bg_colour = t.bg_dark
dialog.border_colour = t.border
dialog.border_width = 1
dialog.corner_radius = 4
dialog.size = Vec2(_OVERLAY_W, _OVERLAY_H)
dialog.position = Vec2(dx, dy)
self._dialog = dialog
pad = 16.0
inner_w = _OVERLAY_W - pad * 2
cy = pad
title = dialog.add_child(Label("New Project", name="OverlayTitle"))
title.font_size = 14.0
title.text_colour = t.text_bright
title.position = Vec2(pad, cy)
cy += 28
# Name row
lbl_w = 60
dialog.add_child(Label("Name:", name="NameLabel")).position = Vec2(pad, cy + 3)
self._name_input = dialog.add_child(TextEdit(name="NameInput"))
self._name_input.text = "My Project"
self._name_input.size = Vec2(inner_w - lbl_w, 24)
self._name_input.position = Vec2(pad + lbl_w, cy)
cy += 32
# Location row
browse_w = 56
dialog.add_child(Label("Location:", name="LocLabel")).position = Vec2(pad, cy + 3)
self._location_input = dialog.add_child(TextEdit(name="LocationInput"))
self._location_input.text = str(Path.home() / "SimVX Projects")
self._location_input.size = Vec2(inner_w - lbl_w - browse_w - 6, 24)
self._location_input.position = Vec2(pad + lbl_w, cy)
browse_btn = dialog.add_child(
Button("Browse", on_press=self._on_browse_location, name="BrowseBtn")
)
browse_btn.size = Vec2(browse_w, 24)
browse_btn.position = Vec2(_OVERLAY_W - pad - browse_w, cy)
browse_btn.font_size = 11.0
browse_btn.bg_colour = t.btn_bg
browse_btn.hover_colour = t.btn_hover
browse_btn.border_width = 0
cy += 32
# Template display
dialog.add_child(Label("Template:", name="TmplDispLabel")).position = Vec2(pad, cy + 3)
self._tmpl_label = dialog.add_child(Label("(none)", name="TmplDisp"))
self._tmpl_label.text_colour = t.accent
self._tmpl_label.position = Vec2(pad + lbl_w, cy + 3)
cy += 32
# Button row
btn_y = _OVERLAY_H - pad - 26
create_btn = dialog.add_child(
Button("Create", on_press=self._on_create_project, name="CreateBtn")
)
create_btn.size = Vec2(76, 26)
create_btn.position = Vec2(_OVERLAY_W - pad - 158, btn_y)
create_btn.font_size = 12.0
create_btn.bg_colour = t.accent
create_btn.hover_colour = ACCENT_ACTIVE
create_btn.border_width = 0
cancel_btn = dialog.add_child(
Button("Cancel", on_press=self._on_cancel_overlay, name="CancelBtn")
)
cancel_btn.size = Vec2(76, 26)
cancel_btn.position = Vec2(_OVERLAY_W - pad - 76, btn_y)
cancel_btn.font_size = 12.0
cancel_btn.bg_colour = t.btn_bg
cancel_btn.hover_colour = t.btn_hover
cancel_btn.border_width = 0
# ------------------------------------------------------------------
# Resize
# ------------------------------------------------------------------
def _resize(self):
saved_template = self._selected_template
overlay_visible = self._overlay.visible if self._overlay else False
name_text = self._name_input.text if self._name_input else "My Project"
location_text = self._location_input.text if self._location_input else str(Path.home() / "SimVX Projects")
saved_error_timer = self._error_clear_timer
if hasattr(self, "_root"):
self.remove_child(self._root)
self._template_cards.clear()
self._project_cards.clear()
self._build_ui()
if saved_template:
self._select_template(saved_template)
if overlay_visible and self._overlay:
self._overlay.visible = True
if self._tmpl_label and saved_template:
self._tmpl_label.text = saved_template
if self._name_input:
self._name_input.text = name_text
if self._location_input:
self._location_input.text = location_text
self._error_clear_timer = saved_error_timer
# ------------------------------------------------------------------
# Template selection
# ------------------------------------------------------------------
def _select_template(self, name: str):
t = get_theme()
self._selected_template = name
for tname, card in self._template_cards.items():
if tname == name:
card._normal_bg = t.accent
card.bg_colour = t.accent
# Brighten text on selected card
for child in card.children.safe_iter():
if child.name == "TmplName":
child.text_colour = (1.0, 1.0, 1.0, 1.0)
elif child.name == "TmplDesc":
child.text_colour = (0.9, 0.9, 1.0, 0.85)
else:
card._normal_bg = t.panel_bg
card.bg_colour = t.panel_bg
for child in card.children.safe_iter():
if child.name == "TmplName":
child.text_colour = t.text_bright
elif child.name == "TmplDesc":
child.text_colour = t.text_muted
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def _on_new_project(self):
if not self._selected_template:
self._show_error("Select a template first.")
return
if self._tmpl_label:
self._tmpl_label.text = self._selected_template
if self._overlay:
self._overlay.visible = True
def _on_open_project(self):
if self._file_dialog:
self._file_dialog.show(mode="open_folder")
def _on_skip(self):
self.skipped.emit()
self._transition_to_editor(None)
def _on_refresh(self):
self._registry.refresh()
self._populate_recent()
def _on_create_project(self):
if not self._selected_template or not self._name_input or not self._location_input:
return
name = self._name_input.text.strip()
location = self._location_input.text.strip()
if not name:
self._show_error("Project name cannot be empty.")
return
if not location:
self._show_error("Location cannot be empty.")
return
try:
project_dir = generate_project(self._selected_template, name, location)
except Exception as exc:
self._show_error(f"Failed to create project: {exc}")
return
self._registry.add(str(project_dir))
self._registry.save()
if self._overlay:
self._overlay.visible = False
self.project_opened.emit(str(project_dir))
self._transition_to_editor(str(project_dir))
def _on_cancel_overlay(self):
if self._overlay:
self._overlay.visible = False
def _on_browse_location(self):
if self._browse_dialog:
self._browse_dialog.show(mode="open_folder")
def _on_browse_location_selected(self, path: str):
if self._location_input:
self._location_input.text = path
def _on_open_dir_selected(self, path: str):
d = Path(path)
if not d.is_dir():
d = d.parent
if d == Path.home():
self._show_error("No project selected.")
return
if not ProjectRegistry.has_project_file(d):
self._show_error(f"No project.simvx found in {d}")
return
self._registry.add(str(d))
self._registry.save()
self.project_opened.emit(str(d))
self._transition_to_editor(str(d))
def _open_recent(self, entry: RecentProject):
d = Path(entry.path)
if not d.is_dir():
self._show_error(f"Project directory not found: {d}")
return
if not ProjectRegistry.has_project_file(d):
self._show_error(f"No project file found in {d}")
return
self._registry.add(entry.path)
self._registry.save()
self.project_opened.emit(entry.path)
self._transition_to_editor(entry.path)
# ------------------------------------------------------------------
# Context menu
# ------------------------------------------------------------------
def _show_project_context_menu(self, entry: RecentProject, position):
if not self._context_menu:
return
px = position.x if hasattr(position, "x") else position[0]
py = position.y if hasattr(position, "y") else position[1]
self._context_menu.items = [
MenuItem("Open", callback=lambda: self._open_recent(entry)),
MenuItem(separator=True),
MenuItem("Remove from Recent", callback=lambda: self._remove_recent(entry)),
]
self._context_menu.show(px, py)
def _remove_recent(self, entry: RecentProject):
self._registry.remove(entry.path)
self._registry.save()
self._populate_recent()
# ------------------------------------------------------------------
# Transition
# ------------------------------------------------------------------
def _transition_to_editor(self, project_path: str | None):
"""Swap scene to EditorShell."""
if not self._tree:
return
platform_win = getattr(self._tree, "_platform_window", None)
if platform_win:
try:
import glfw
glfw.set_window_size(platform_win, 1600, 900)
glfw.set_window_title(platform_win, "SimVX Editor")
except Exception:
pass
from .app import EditorShell
self._tree.change_scene(EditorShell(project_path=project_path))
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _show_error(self, msg: str):
if self._error_label:
self._error_label.text = msg
self._error_label.visible = True
self._error_clear_timer = 4.0
def _relative_time(iso_str: str) -> str:
"""Convert ISO 8601 timestamp to relative time string."""
try:
dt = datetime.fromisoformat(iso_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
delta = datetime.now(UTC) - dt
secs = int(delta.total_seconds())
if secs < 60:
return "just now"
if secs < 3600:
m = secs // 60
return f"{m}m ago"
if secs < 86400:
h = secs // 3600
return f"{h}h ago"
days = secs // 86400
return f"{days}d ago"
except (ValueError, TypeError):
return ""