diff --git a/pyproject.toml b/pyproject.toml index 28383bb..7dd0506 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ where = ["src"] "" = "src" [tool.setuptools.package-data] -imwald = ["ui/assets/vendor/*.js"] +imwald = ["ui/assets/vendor/*.js", "ui/assets/*.png"] [tool.pytest.ini_options] pythonpath = ["src"] diff --git a/src/imwald/app.py b/src/imwald/app.py index 629b40d..e09456c 100644 --- a/src/imwald/app.py +++ b/src/imwald/app.py @@ -5,12 +5,13 @@ from __future__ import annotations import logging import sys -from PySide6.QtGui import QFont +from PySide6.QtGui import QFont, QIcon, QPixmap from PySide6.QtWidgets import QApplication from imwald.config import db_path from imwald.core.database import Database from imwald.core.nostr_engine import NostrEngine +from imwald.ui.asset_paths import favicon_png_path from imwald.ui.main_window import MainWindow from imwald.ui.theme import apply_application_theme @@ -35,6 +36,9 @@ def main() -> None: app = QApplication(sys.argv) app.setApplicationName("imwald") app.setOrganizationName("imwald") + fav_pm = QPixmap(str(favicon_png_path())) + if not fav_pm.isNull(): + app.setWindowIcon(QIcon(fav_pm)) apply_application_theme(app) _set_comfortable_default_font(app) diff --git a/src/imwald/ui/asset_paths.py b/src/imwald/ui/asset_paths.py new file mode 100644 index 0000000..c2416b4 --- /dev/null +++ b/src/imwald/ui/asset_paths.py @@ -0,0 +1,19 @@ +"""Filesystem paths to shipped assets under ``imwald/ui/assets``.""" + +from __future__ import annotations + +from pathlib import Path + +_UI_DIR = Path(__file__).resolve().parent + + +def ui_assets_dir() -> Path: + return _UI_DIR / "assets" + + +def favicon_png_path() -> Path: + return ui_assets_dir() / "favicon.png" + + +def default_banner_png_path() -> Path: + return ui_assets_dir() / "banner.png" diff --git a/src/imwald/ui/assets/banner.png b/src/imwald/ui/assets/banner.png new file mode 100644 index 0000000..b42fe2d Binary files /dev/null and b/src/imwald/ui/assets/banner.png differ diff --git a/src/imwald/ui/assets/favicon.png b/src/imwald/ui/assets/favicon.png new file mode 100644 index 0000000..31e7395 Binary files /dev/null and b/src/imwald/ui/assets/favicon.png differ diff --git a/src/imwald/ui/profile_page.py b/src/imwald/ui/profile_page.py index 8c93f52..0f20758 100644 --- a/src/imwald/ui/profile_page.py +++ b/src/imwald/ui/profile_page.py @@ -7,6 +7,7 @@ import json from datetime import datetime, timezone from typing import cast +from PySide6 import Shiboken from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal, QUrl from PySide6.QtGui import QDesktopServices, QMouseEvent, QPainter, QPainterPath, QPixmap, QResizeEvent from PySide6.QtWidgets import QFrame, QLabel, QSizePolicy, QSplitter, QTabWidget, QVBoxLayout, QWidget @@ -19,6 +20,7 @@ from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary from imwald.core.nip19 import encode_npub from imwald.core.nostr_engine import NostrEngine from imwald.core.relay_list import parse_kind10002_tags +from imwald.ui.asset_paths import default_banner_png_path from imwald.ui.media_viewer_dialog import try_open_media_url_in_app from imwald.ui.note_text_browser import NoteTextBrowser from imwald.ui.theme import ACCENT_SOFT, BG_CARD, BG_CODE, BORDER, FEED_DOC_CSS, TEXT, TEXT_DIM, TEXT_MUTED @@ -210,6 +212,11 @@ class _CoverImageSignals(QObject): avatar_ready = Signal(object, int) +def _qobject_alive(obj: QObject) -> bool: + """False after the wrapped Qt object has been destroyed (e.g. profile tab closed).""" + return Shiboken.isValid(obj) + + class _ProfileHttpImageRunnable(QRunnable): def __init__(self, url: str, gen: int, mode: str, out: _CoverImageSignals) -> None: super().__init__() @@ -233,6 +240,8 @@ class _ProfileHttpImageRunnable(QRunnable): except OSError: self._emit_fail() return + if not _qobject_alive(self._out): + return if self._mode == "banner": self._out.banner_ready.emit(pm, self._gen) elif self._mode == "avatar": @@ -252,6 +261,8 @@ class _ProfileHttpImageRunnable(QRunnable): self._emit_fail() def _emit_fail(self) -> None: + if not _qobject_alive(self._out): + return if self._mode == "banner": self._out.banner_ready.emit(QPixmap(), self._gen) else: @@ -291,7 +302,8 @@ class _ProfileLnurlRunnable(QRunnable): def run(self) -> None: html = build_merged_lnurl_pay_section(self._urls) - self._out.finished.emit(html, self._gen) + if _qobject_alive(self._out): + self._out.finished.emit(html, self._gen) class ProfilePage(QWidget): @@ -337,9 +349,11 @@ class ProfilePage(QWidget): split.setChildrenCollapsible(False) split.addWidget(self._left_column) split.addWidget(self._feed_body) - split.setStretchFactor(0, 100) - split.setStretchFactor(1, 58) - split.setSizes([720, 400]) + # Narrower metadata column, wider posts — inverse of FeedPage (thread wide, OP narrower). + split.setStretchFactor(0, 2) + split.setStretchFactor(1, 3) + split.setSizes([400, 780]) + self._left_column.setMinimumWidth(300) self._feed_body.setMinimumWidth(260) lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) @@ -356,6 +370,10 @@ class ProfilePage(QWidget): self._cov_sigs.avatar_ready.connect(self._on_cover_avatar_http) self._cov_pool = QThreadPool(self) self._cov_pool.setMaxThreadCount(3) + # Last banner/picture HTTPS URLs we applied to the hero (including ""). Used to avoid + # resetting placeholders + bumping fetch generations on every refresh() while ingest runs. + self._cover_banner_http_key: str | None = None + self._cover_avatar_http_key: str | None = None self.refresh() def tab_title(self) -> str: @@ -437,28 +455,40 @@ class ProfilePage(QWidget): else "" ) forest_seed = int(pk[:16], 16) % (2**31) if len(pk) >= 16 else 0 - ph_b = QPixmap() - bh_bytes = build_forest_banner_png( - width=2000, height=ProfileHeroFrame.BANNER_H + 48, seed=forest_seed - ) - if not ph_b.loadFromData(bh_bytes): - ph_b.loadFromData( - build_forest_banner_png(width=2000, height=ProfileHeroFrame.BANNER_H + 48, seed=42) - ) - self._hero.set_banner_source(ph_b if not ph_b.isNull() else QPixmap()) - ph_a = QPixmap() - if not ph_a.loadFromData(build_forest_avatar_png(size=384, seed=forest_seed)): - ph_a.loadFromData(build_forest_avatar_png(size=384, seed=42)) - av_circ = _circle_pixmap(ph_a, ProfileHeroFrame.AVATAR) - self._hero.set_avatar_pixmap(av_circ) - self._cover_banner_gen += 1 - b_gen = self._cover_banner_gen - self._cover_avatar_gen += 1 - a_gen = self._cover_avatar_gen - if banner_https: - self._cov_pool.start(_ProfileHttpImageRunnable(banner_https, b_gen, "banner", self._cov_sigs)) - if picture_https: - self._cov_pool.start(_ProfileHttpImageRunnable(picture_https, a_gen, "avatar", self._cov_sigs)) + b_key = banner_https or "" + banner_dirty = self._cover_banner_http_key is None or self._cover_banner_http_key != b_key + p_key = picture_https or "" + avatar_dirty = self._cover_avatar_http_key is None or self._cover_avatar_http_key != p_key + + if banner_dirty: + self._cover_banner_http_key = b_key + ph_b = QPixmap(str(default_banner_png_path())) + if ph_b.isNull(): + bh_bytes = build_forest_banner_png( + width=2000, height=ProfileHeroFrame.BANNER_H + 48, seed=forest_seed + ) + if not ph_b.loadFromData(bh_bytes): + ph_b.loadFromData( + build_forest_banner_png(width=2000, height=ProfileHeroFrame.BANNER_H + 48, seed=42) + ) + self._hero.set_banner_source(ph_b if not ph_b.isNull() else QPixmap()) + self._cover_banner_gen += 1 + b_gen = self._cover_banner_gen + if banner_https: + self._cov_pool.start(_ProfileHttpImageRunnable(banner_https, b_gen, "banner", self._cov_sigs)) + + if avatar_dirty: + self._cover_avatar_http_key = p_key + ph_a = QPixmap() + if not ph_a.loadFromData(build_forest_avatar_png(size=384, seed=forest_seed)): + ph_a.loadFromData(build_forest_avatar_png(size=384, seed=42)) + av_circ = _circle_pixmap(ph_a, ProfileHeroFrame.AVATAR) + self._hero.set_avatar_pixmap(av_circ) + self._cover_avatar_gen += 1 + a_gen = self._cover_avatar_gen + if picture_https: + self._cov_pool.start(_ProfileHttpImageRunnable(picture_https, a_gen, "avatar", self._cov_sigs)) + self._hero.set_banner_open_url(banner_https or None) k0_meta_plain = "" if k0_ev: