feat: add gitcompanion

This commit is contained in:
Rubin Bhandari committed 2026-07-05 19:34:11 +05:45
1 parent 3932ea8801
commit dce77befa2
13 files changed
+851 -7

No files matched your search

+12 -2
View File
@@ -5,6 +5,16 @@ icon = "view-app-grid-symbolic"
icon_size = 20
tooltip = true
[widgets.git_companion]
icon = ""
label = false
tooltip = true
username = "rubiin"
repository = "rubiin/tsumiki"
avatar_size = 44
default_tab = "issues"
cache_ttl = 300
[widgets.clipboard]
icon = ""
label = false
@@ -418,8 +428,8 @@ tooltip = true
[layout]
left_section = ["@group:0"]
middle_section = ["date_time", "weather", "mpris"]
right_section = [ "@group:1","@group:2", "system_tray", "power"]
middle_section = ["date_time", "weather", "git_companion"]
right_section = [ "@group:1","@group:2", "git_companion", "system_tray", "power"]
[[widget_groups]]
widgets = ["workspaces", "window_title"]
+12 -2
View File
@@ -5,6 +5,16 @@ icon = "view-app-grid-symbolic"
icon_size = 20
tooltip = true
[widgets.git_companion]
icon = ""
label = false
tooltip = true
username = "rubiin"
repository = "rubiin/tsumiki"
avatar_size = 44
default_tab = "issues"
cache_ttl = 300
[widgets.clipboard]
icon = ""
label = false
@@ -418,8 +428,8 @@ tooltip = true
[layout]
left_section = ["@group:0"]
middle_section = ["date_time", "weather", "mpris"]
right_section = [ "@group:1","@group:2", "system_tray", "power"]
middle_section = ["date_time", "weather", "git_companion"]
right_section = [ "@group:1","@group:2", "git_companion", "system_tray", "power"]
[[widget_groups]]
widgets = ["workspaces", "window_title"]
+1
View File
@@ -123,6 +123,7 @@ LAZY_WIDGETS_LIST = {
"window_title": "widgets.window_title.WindowTitleWidget",
"workspaces": "widgets.workspaces.WorkSpacesWidget",
"world_clock": "widgets.world_clock.WorldClockWidget",
"git_companion": "widgets.git_companion.GitCompanionWidget",
}
+1 -1
View File
@@ -7,7 +7,7 @@ pillow==12.3.0 # Consider: audit for lighter alternatives if memory footprint i
psutil==7.2.2 # Consider: audit for lighter alternatives if memory footprint is a concern
pycairo==1.29.0
PyGObject==3.50.0
PyGObject-stubs==2.17.0
PyGObject-stubs==2.16.0
pytomlpp==1.1.0
qrcode==8.2
rlottie-python==1.3.8
+153
View File
@@ -0,0 +1,153 @@
@use "sass:color";
@use "theme";
@use "common";
@use "variable";
#git_companion {
@include common.widget_border(
variable.$bar-widgets-git_companion-border-radius,
variable.$bar-widgets-git_companion-border-enabled,
variable.$bar-widgets-git_companion-border-width
);
@include common.nerd-icon(variable.$bar-widgets-git_companion-icon_size, null);
}
#git-companion-window {
padding: 14px;
border-radius: 26px;
color: theme.$text-main;
min-height: 630px;
min-width: 420px;
#git-companion-header {
padding: 0 2px 2px;
}
.git-companion-brand {
color: #1f6feb;
font-size: 18px;
}
.git-companion-title {
color: theme.$text-main;
font-size: 16px;
font-weight: 700;
}
.git-companion-action-btn {
min-width: 30px;
min-height: 30px;
border-radius: 999px;
background-color: #e9edf5;
border: 1px solid rgba(84, 104, 142, 0.18);
color: #5c6c89;
&:hover {
background-color: #f4f6fb;
}
}
.git-companion-action-icon {
font-size: 14px;
}
#git-companion-profile {
padding: 8px 4px 2px;
border-radius: 18px;
}
#git-companion-avatar-box {
min-width: 44px;
min-height: 44px;
border-radius: 999px;
background-color: #eef2f8;
border: 2px solid #2f6df6;
}
#git-companion-avatar {
border-radius: 999px;
}
#git-companion-avatar-fallback {
min-width: 44px;
min-height: 44px;
background-color: #eef2f8;
}
.git-companion-avatar-fallback-icon {
color: #2f6df6;
font-size: 18px;
}
.git-companion-name {
color: theme.$text-main;
font-size: 18px;
font-weight: 700;
}
.git-companion-bio {
color: #7d8698;
font-size: 12px;
}
#git-companion-tabs {
padding: 2px;
border-radius: 18px;
background-color: theme.$accent-purple;
border: 1px solid rgba(84, 104, 142, 0.18);
}
.git-companion-tab-btn {
min-height: 32px;
padding: 0 14px;
border-radius: 14px;
background-color: transparent;
color: #4d5c78;
font-weight: 600;
&.active {
background-color: theme.$accent-orange;
}
}
.git-companion-tab-label {
font-size: 12px;
}
#git-companion-scroller {
padding-right: 2px;
}
#git-companion-items {
padding-top: 2px;
}
.git-companion-item {
padding: 10px 12px;
border-radius: 14px;
background-color: theme.$background-alt;
border: 1px solid rgba(84, 104, 142, 0.12);
color: theme.$text-main;
min-height: 44px;
transition: background-color 120ms ease-in-out;
&:hover {
background-color: #eef3ff;
}
}
.git-companion-item-title {
color: theme.$text-main;
font-size: 12px;
font-weight: 700;
}
.git-companion-item-subtitle {
color: #6e7890;
font-size: 11px;
}
}
+5
View File
@@ -111,6 +111,11 @@ $bar-widgets-emoji_picker-border-radius: 16px;
$bar-widgets-emoji_picker-border-width: 1px;
$bar-widgets-emoji_picker-icon_size: 12px;
$bar-widgets-emoji_picker-spacing: 0.125em;
$bar-widgets-git_companion-border-enabled: false;
$bar-widgets-git_companion-border-radius: 16px;
$bar-widgets-git_companion-border-width: 1px;
$bar-widgets-git_companion-icon_size: 12px;
$bar-widgets-git_companion-spacing: 0.125em;
$bar-widgets-hypridle-border-enabled: false;
$bar-widgets-hypridle-border-radius: 16px;
$bar-widgets-hypridle-border-width: 1px;
+1
View File
@@ -22,6 +22,7 @@
@use "collapsible_group.scss";
@use "cheatsheet.scss";
@use "settings_gui.scss";
@use "git_companion.scss";
@use "pomodoro.scss";
@use "usb_manager.scss";
+8
View File
@@ -133,6 +133,14 @@ radius = "16px"
width = "1px"
[bar.widgets.git_companion]
icon_size = "12px"
spacing = "0.125em"
[bar.widgets.git_companion.border]
enabled = false
radius = "16px"
width = "1px"
[bar.widgets.pomodoro]
icon_size = "12px"
+55
View File
@@ -29,6 +29,57 @@
}
}
},
"git_companion": {
"type": "object",
"properties": {
"tooltip": {
"type": "boolean",
"default": true,
"description": "Determines whether the Git Companion widget has a tooltip."
},
"label": {
"type": "boolean",
"default": false,
"description": "Determines whether to show a text label beside the icon."
},
"icon": {
"type": "string",
"default": "",
"description": "Icon displayed in the Git Companion widget."
},
"username": {
"type": "string",
"default": "Thomas-Philippot",
"description": "GitHub username used for the profile card."
},
"repository": {
"type": "string",
"default": "Thomas-Philippot/removarr-with-angular",
"description": "Repository used for open issues and pull requests."
},
"token": {
"type": "string",
"default": "",
"description": "Optional GitHub token for authenticated API requests."
},
"avatar_size": {
"type": "integer",
"default": 44,
"description": "Avatar diameter in pixels."
},
"default_tab": {
"type": "string",
"enum": ["issues", "pull_requests"],
"default": "issues",
"description": "Tab shown first when popup opens."
},
"cache_ttl": {
"type": "integer",
"default": 300,
"description": "Cache time-to-live in seconds before refetching GitHub data."
}
}
},
"battery": {
"type": "object",
"properties": {
@@ -2169,6 +2220,7 @@
"type": "string",
"enum": [
"app_launcher_button",
"git_companion",
"battery",
"bluetooth",
"brightness",
@@ -2261,6 +2313,7 @@
"type": "string",
"enum": [
"app_launcher_button",
"git_companion",
"battery",
"bluetooth",
"brightness",
@@ -2353,6 +2406,7 @@
"type": "string",
"enum": [
"app_launcher_button",
"git_companion",
"battery",
"bluetooth",
"brightness",
@@ -2458,6 +2512,7 @@
"type": "string",
"enum": [
"app_launcher_button",
"git_companion",
"battery",
"bluetooth",
"brightness",
+12
View File
@@ -152,6 +152,18 @@ DEFAULT_CONFIG = {
},
"divider": {"size": 2},
"cheatsheet": {},
"git_companion": {
"icon": "",
"label": False,
"tooltip": True,
"username": "rubiin",
"repository": "rubiin/tsumiki",
"width": 420,
"height": 630,
"avatar_size": 44,
"default_tab": "issues",
"cache_ttl": 300,
},
"pomodoro": {
"icon": "🍅",
"label": True,
+6
View File
@@ -55,6 +55,12 @@ def register_temp_resource(path: str):
TEMP_PATHS.add(path)
def expand_env(value: str | None) -> str:
if not value:
return ""
return os.path.expanduser(os.path.expandvars(value))
def normalize_address(address: str | None) -> str | None:
if not address:
return None
+583
View File
@@ -0,0 +1,583 @@
from __future__ import annotations
import json
import os
import shlex
import subprocess
from contextlib import suppress
from time import monotonic
from urllib.request import urlopen
from fabric.utils import GdkPixbuf, exec_shell_command_async, idle_add, logger
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
import utils.functions as helpers
from shared.circle_image import CircularImage
from shared.mixins import PopoverMixin
from shared.widget_container import ButtonWidget
from utils.config import widget_config
from utils.icons import get_text_icon
from utils.widget_utils import nerd_font_icon
def _load_pixbuf_from_bytes(image_bytes: bytes, size: int):
loader = GdkPixbuf.PixbufLoader()
loader.write(image_bytes)
loader.close()
pixbuf = loader.get_pixbuf()
if pixbuf is None:
return None
return pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.BILINEAR)
class GitHubClient:
"""Small GitHub API client for public repo and profile data."""
def __init__(self, config: dict):
self.config = config
self.username = str(config.get("username", "Thomas-Philippot")).strip()
self.repository = str(
config.get("repository", "Thomas-Philippot/removarr-with-angular")
).strip()
self.token = helpers.expand_env(
str(config.get("token") or os.environ.get("GITHUB_TOKEN", ""))
)
@property
def repo_owner(self) -> str:
if "/" in self.repository:
return self.repository.split("/", 1)[0]
return self.username
@property
def repo_url(self) -> str:
return f"https://github.com/{self.repository}" if self.repository else ""
@property
def profile_url(self) -> str:
return f"https://github.com/{self.username}" if self.username else ""
def _run_gh_api(self, endpoint: str, params: dict | None = None) -> dict | list:
command = ["gh", "api", endpoint, "--jq", ".", "-X", "GET"]
if params:
for key, value in params.items():
command.extend(["-f", f"{key}={value}"])
env = os.environ.copy()
if self.token:
env["GH_TOKEN"] = self.token
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=12,
check=True,
env=env,
)
payload = result.stdout.strip()
if not payload:
return {}
return json.loads(payload)
def fetch_state(self, avatar_size: int) -> dict:
profile = {
"name": self.username,
"bio": "No bio",
"avatar_url": "",
"html_url": self.profile_url,
}
issues: list[dict] = []
pulls: list[dict] = []
with suppress(Exception):
profile_data = self._run_gh_api("user")
if isinstance(profile_data, dict):
profile["name"] = profile_data.get("name") or profile_data.get(
"login", self.username
)
profile["bio"] = profile_data.get("bio") or "No bio"
profile["avatar_url"] = profile_data.get("avatar_url", "")
profile["html_url"] = profile_data.get("html_url") or self.profile_url
if profile.get("avatar_url") == "":
with suppress(Exception):
profile_data = self._run_gh_api(f"users/{self.username}")
if isinstance(profile_data, dict):
profile["avatar_url"] = profile_data.get("avatar_url", "")
profile["html_url"] = (
profile_data.get("html_url") or self.profile_url
)
with suppress(Exception):
repo_data = self._run_gh_api(f"repos/{self.repository}")
if isinstance(repo_data, dict):
profile["repo_full_name"] = repo_data.get("full_name", self.repository)
profile["repo_description"] = repo_data.get("description", "") or ""
with suppress(Exception):
issues_data = self._run_gh_api(
f"repos/{self.repository}/issues",
params={"state": "open", "per_page": 100, "sort": "created"},
)
if isinstance(issues_data, list):
issues = [
{**item, "kind": "issue"}
for item in issues_data
if isinstance(item, dict) and "pull_request" not in item
]
with suppress(Exception):
pulls_data = self._run_gh_api(
f"repos/{self.repository}/pulls",
params={"state": "open", "per_page": 100, "sort": "created"},
)
if isinstance(pulls_data, list):
pulls = [
{**item, "kind": "pull_request"}
for item in pulls_data
if isinstance(item, dict)
]
avatar_pixbuf = None
avatar_url = str(profile.get("avatar_url", "")).strip()
if avatar_url:
with suppress(Exception):
with urlopen(avatar_url, timeout=8) as avatar_response:
avatar_bytes = avatar_response.read()
avatar_pixbuf = _load_pixbuf_from_bytes(
avatar_bytes,
avatar_size,
)
return {
"profile": profile,
"issues": issues,
"pull_requests": pulls,
"avatar_pixbuf": avatar_pixbuf,
}
class GitCompanionPopoverContent(Box):
"""Popover content that matches the Git Companion preview."""
def __init__(self, config: dict, parent=None, **kwargs):
self.widget_config = config
self.config = config.get("widgets", {}).get("git_companion", {})
self.client = GitHubClient(self.config)
self.avatar_size = int(self.config.get("avatar_size", 44))
self.default_tab = str(self.config.get("default_tab", "issues")).strip()
self.cache_ttl = int(self.config.get("cache_ttl", 300))
self._last_refresh_at = 0.0
self._request_generation = 0
self._active_tab = self._normalize_tab(self.default_tab)
self._state: dict = {
"profile": {
"name": self.client.username,
"bio": "No bio",
"avatar_url": "",
"html_url": self.client.profile_url,
},
"issues": [
{
"title": "test issue",
"html_url": self.client.repo_url,
"repository_url": self.client.repo_url,
"number": 1,
"repository": self.client.repository,
"subtitle": f"{self.client.repository}#1",
}
],
"pull_requests": [],
"avatar_pixbuf": None,
}
super().__init__(
name="git-companion-window",
orientation="v",
spacing=10,
**kwargs,
)
self._parent = parent
self._build_ui()
self._apply_state()
self.refresh(force=True)
def _normalize_tab(self, tab: str) -> str:
return tab if tab in {"issues", "pull_requests"} else "issues"
def _build_ui(self):
self.header = Box(
name="git-companion-header",
orientation="h",
h_expand=True,
spacing=8,
)
self.title_box = Box(
orientation="h",
spacing=8,
h_expand=True,
children=[
nerd_font_icon(
icon="",
props={"style_classes": ["git-companion-brand"]},
),
Label(
label="Git Companion",
style_classes=["git-companion-title"],
h_align="start",
),
],
)
self.header_actions = Box(
orientation="h",
spacing=6,
h_align="end",
children=[
self._make_action_button(
get_text_icon("ui.refresh"),
"Refresh",
self._on_refresh_clicked,
),
self._make_action_button(
"",
"Open repository",
self._on_repo_clicked,
),
],
)
self.header.children = [self.title_box, self.header_actions]
self.profile_card = Box(
name="git-companion-profile",
orientation="h",
spacing=10,
children=[],
)
self.avatar_box = Box(
name="git-companion-avatar-box",
style_classes=["git-companion-avatar-box"],
size_request=(self.avatar_size, self.avatar_size),
)
self.profile_text = Box(
orientation="v",
spacing=2,
h_expand=True,
children=[
Label(
label=self.client.username,
style_classes=["git-companion-name"],
h_align="start",
),
Label(
label="No bio",
style_classes=["git-companion-bio"],
h_align="start",
),
],
)
self.profile_card.children = [self.avatar_box, self.profile_text]
self.tabs = Box(
name="git-companion-tabs",
orientation="h",
spacing=0,
children=[
self._make_tab_button("issues", "Issues (0)"),
self._make_tab_button("pull_requests", "Pull Requests (0)"),
],
)
self.items_box = Box(
name="git-companion-items",
orientation="v",
spacing=8,
v_expand=True,
h_expand=True,
)
self.scrolled = ScrolledWindow(
name="git-companion-scroller",
h_scrollbar_policy="never",
v_scrollbar_policy="automatic",
child=self.items_box,
)
self.scrolled.set_min_content_height(420)
self.children = [
self.header,
self.profile_card,
self.tabs,
self.scrolled,
]
self.name_label = self.profile_text.get_children()[0]
self.bio_label = self.profile_text.get_children()[1]
def _make_action_button(self, icon: str, tooltip: str, callback):
return Button(
name="git-companion-action-btn",
style_classes=["git-companion-action-btn"],
child=nerd_font_icon(
icon=icon,
props={"style_classes": ["git-companion-action-icon"]},
),
tooltip_text=tooltip,
on_clicked=callback,
)
def _make_tab_button(self, tab: str, label: str):
button = Button(
name=f"git-companion-tab-{tab}",
style_classes=["git-companion-tab-btn"],
child=Label(label=label, style_classes=["git-companion-tab-label"]),
on_clicked=lambda *_: self.set_tab(tab),
)
button._tab_name = tab # type: ignore[attr-defined]
return button
def _on_refresh_clicked(self, *_):
self.refresh(force=True)
def _on_repo_clicked(self, *_):
target = self.client.repository or self.client.repo_url
if target:
exec_shell_command_async(f"gh repo view {shlex.quote(target)} --web")
def _set_tab_button_state(self):
for button in self.tabs.get_children():
tab_name = getattr(button, "_tab_name", None)
if tab_name == self._active_tab:
button.add_style_class("active")
else:
button.remove_style_class("active")
def _update_tab_labels(self):
issues_count = len(self._state.get("issues", []))
pulls_count = len(self._state.get("pull_requests", []))
for button in self.tabs.get_children():
tab_name = getattr(button, "_tab_name", None)
label = button.get_child()
if not isinstance(label, Label):
continue
if tab_name == "issues":
label.set_label(f"Issues ({issues_count})")
elif tab_name == "pull_requests":
label.set_label(f"Pull Requests ({pulls_count})")
def set_tab(self, tab: str):
self._active_tab = self._normalize_tab(tab)
self._set_tab_button_state()
self._render_items()
def _set_avatar(self, pixbuf):
if pixbuf is None:
self.avatar_box.children = [
Box(
name="git-companion-avatar-fallback",
orientation="v",
h_align="center",
v_align="center",
children=[
nerd_font_icon(
icon="",
props={
"style_classes": ["git-companion-avatar-fallback-icon"]
},
)
],
)
]
return
self.avatar_box.children = [
CircularImage(
pixbuf=pixbuf,
size=self.avatar_size,
name="git-companion-avatar",
)
]
def _apply_state(self):
profile = self._state.get("profile", {})
self.name_label.set_label(str(profile.get("name", self.client.username)))
bio_text = str(profile.get("bio", "No bio") or "No bio")
if len(bio_text) > 30:
bio_text = f"{bio_text[:29].rstrip()}…"
self.bio_label.set_label(bio_text)
self._set_avatar(self._state.get("avatar_pixbuf"))
self._set_tab_button_state()
self._update_tab_labels()
self._render_items()
def _truncate_text(self, text: str, max_length: int = 30) -> str:
if len(text) <= max_length:
return text
return f"{text[: max_length - 1].rstrip()}…"
def _normalize_description(self, text: str) -> str:
normalized = str(text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not normalized:
return ""
lines = [line.strip() for line in normalized.split("\n") if line.strip()]
if not lines:
return ""
excerpt = "\n".join(lines[:2])
if len(excerpt) > 140:
excerpt = f"{excerpt[:139].rstrip()}…"
return excerpt
def _make_item_button(self, item: dict):
title = self._truncate_text(str(item.get("title", "Untitled")).strip())
subtitle = str(
item.get("subtitle")
or (
f"{item.get('repository', self.client.repository)}"
f"#{item.get('number', '')}"
)
).strip()
description = self._normalize_description(
item.get("body") or item.get("description")
)
number = str(item.get("number", "")).strip()
kind = str(item.get("kind", "issue"))
command = (
f"gh pr view {shlex.quote(number)} --repo "
f"{shlex.quote(self.client.repository)} --web"
if kind == "pull_request"
else f"gh issue view {shlex.quote(number)} --repo "
f"{shlex.quote(self.client.repository)} --web"
)
item_children = [
Label(
label=title,
style_classes=["git-companion-item-title"],
h_align="start",
),
Label(
label=subtitle,
style_classes=["git-companion-item-subtitle"],
h_align="start",
),
]
if description:
description_label = Label(
label=description,
style_classes=["git-companion-item-description"],
h_align="start",
line_wrap="word-char",
max_chars_width=34,
)
description_label.set_lines(2)
description_label.set_ellipsize(3)
item_children.append(description_label)
item_box = Box(
orientation="v",
spacing=1,
children=item_children,
)
return Button(
name="git-companion-item",
style_classes=["git-companion-item"],
child=item_box,
on_clicked=(lambda *_: exec_shell_command_async(command))
if number
else None,
)
def _render_items(self):
items = self._state.get(self._active_tab, [])
if not isinstance(items, list):
items = []
self.items_box.children = [self._make_item_button(item) for item in items]
@helpers.run_in_thread
def _refresh_async(self, generation: int):
try:
state = self.client.fetch_state(self.avatar_size)
idle_add(self._apply_refresh_result, generation, state)
except Exception as error:
logger.warning(f"[GitCompanion] Refresh failed: {error}")
idle_add(self._mark_refresh_complete, generation)
def _apply_refresh_result(self, generation: int, state: dict):
if generation != self._request_generation:
return False
self._state.update(state)
self._last_refresh_at = monotonic()
self._apply_state()
return False
def _mark_refresh_complete(self, generation: int):
if generation != self._request_generation:
return False
self._last_refresh_at = 0.0
return False
def _is_cache_valid(self) -> bool:
if self.cache_ttl <= 0 or self._last_refresh_at <= 0:
return False
return (monotonic() - self._last_refresh_at) < self.cache_ttl
def refresh(self, force: bool = False):
if not force and self._is_cache_valid():
return
self._request_generation += 1
generation = self._request_generation
self._refresh_async(generation)
class GitCompanionWidget(ButtonWidget, PopoverMixin):
"""Bar button that opens Git Companion popup."""
def __init__(self, **kwargs):
super().__init__(name="git_companion", **kwargs)
self.container_box.children = nerd_font_icon(
icon=self.config.get("icon", ""),
props={"style_classes": ["panel-font-icon"]},
)
if self.config.get("label", False):
self.container_box.add(Label(label="Git", style_classes=["panel-text"]))
if self.config.get("tooltip", True) and self.tooltips_enabled:
self.set_tooltip_text(self.config.get("tooltip_text", "Open Git Companion"))
self.setup_popover(
lambda: GitCompanionPopoverContent(widget_config, parent=self),
connect_clicked=False,
)
self.connect("clicked", self.on_click)
def on_click(self, *_):
self.toggle_popover()
def toggle_popover(self) -> None:
popup = self.popup
if popup and hasattr(popup, "content") and hasattr(popup.content, "refresh"):
popup.content.refresh(force=False)
super().toggle_popover()
+2 -2
View File
@@ -18,7 +18,7 @@ from shared.dialog import Dialog
from shared.mixins import PopoverMixin
from shared.widget_container import ButtonWidget
from utils.constants import ASSETS_DIR
from utils.functions import lazy_load_class, safe_disconnect
from utils.functions import expand_env, lazy_load_class, safe_disconnect
from utils.icons import get_text_icon, network_icon_to_text_icons
from utils.widget_utils import (
get_audio_icon_name,
@@ -171,7 +171,7 @@ class QuickSettingsMenu(Box):
controls_config = self.config.get("controls", {})
raw_avatar_path = user_config.get("avatar", "$HOME/.face")
avatar_path = os.path.expanduser(os.path.expandvars(raw_avatar_path))
avatar_path = expand_env(raw_avatar_path)
default_image = f"{ASSETS_DIR}/images/banner.jpg"
user_image = avatar_path if os.path.exists(avatar_path) else default_image
if user_image == default_image: