Skip to content

Icon support #38

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions TODO
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
favicon request content type: ["image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5"]
2 changes: 1 addition & 1 deletion contrib/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ url="https://github.com/pvsr/qbpm"
license=('GPL-3.0-or-later')
sha512sums=('SKIP')
arch=('any')
depends=('python' 'python-click' 'python-xdg-base-dirs')
depends=('python' 'python-click' 'python-xdg-base-dirs' 'python-httpx' 'python-pillow')
makedepends=('git' 'python-build' 'python-installer' 'python-wheel' 'python-flit-core' 'scdoc')
provides=('qbpm')
source=("git+https://github.com/pvsr/qbpm")
Expand Down
2 changes: 2 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,14 @@
packages = [
pkgs.ruff
pkgs.nixfmt-rfc-style
pkgs.xdg-utils
(pyprojectEnv (
ps: with ps; [
flit
pytest
mypy
pylsp-mypy
# types-pillow
]
))
];
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ classifiers = [
"Typing :: Typed",
]
requires-python = ">= 3.11"
dependencies = ["click", "xdg-base-dirs"]
dependencies = ["click", "xdg-base-dirs", "httpx", "pillow"]

[project.urls]
homepage = "https://github.com/pvsr/qbpm"
Expand Down
18 changes: 18 additions & 0 deletions qbpm.1.scd
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ appropriate \--basedir, or more conveniently using the qbpm launch and qbpm choo
qbpm launch -n qb-dev --debug --json-logging
```

*icon* [options] <profile> <icon>
Install an icon to _profile_. _icon_ may be a url, a path to an image file,
or, if --by-name is passed, the name of an xdg icon installed on your
system. If _icon_ is a url, qbpm will fetch the page and attempt to find a
suitable favicon.

Options:

*-n, --by-name*
Interpret _icon_ as the name of an xdg icon file according to the
freedesktop.org icon specification. Likely to only work on Linux.

*choose* [options]
Open a menu to choose a qutebrowser profile to launch. On linux this defaults
to dmenu or another compatible menu program such as rofi, and on macOS this
Expand Down Expand Up @@ -131,3 +143,9 @@ Peter Rice
_https://github.com/pvsr/qbpm_

_https://codeberg.org/pvsr/qbpm_

# LICENSE

src/qbpm/favicon.py: MIT

all other code and qbpm as a whole: GPLv3+
27 changes: 25 additions & 2 deletions src/qbpm/choose.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@


def choose_profile(
profile_dir: Path, menu: str | None, foreground: bool, qb_args: tuple[str, ...]
profile_dir: Path,
menu: str | None,
foreground: bool,
qb_args: tuple[str, ...],
force_icon: bool,
) -> bool:
dmenu = find_menu(menu)
if not dmenu:
Expand All @@ -19,11 +23,13 @@ def choose_profile(
error("no profiles")
return False
profiles = [*real_profiles, "qutebrowser"]
use_icon = force_icon
# use_icon = dmenu.icon_support or force_icon
command = dmenu.command(sorted(profiles), "qutebrowser", " ".join(qb_args))
selection_cmd = subprocess.run(
command,
text=True,
input="\n".join(sorted(profiles)),
input=build_menu_items(profiles, use_icon),
stdout=subprocess.PIPE,
stderr=None,
check=False,
Expand All @@ -39,3 +45,20 @@ def choose_profile(
else:
error("no profile selected")
return False


def build_menu_items(profiles: list[str], icon: bool) -> str:
# TODO build profile before passing to icons
if icon and any(profile_icons := [icons.icon_for_profile(p) for p in profiles]):
menu_items = [
icon_entry(profile, icon)
for (profile, icon) in zip(profiles, profile_icons)
]
else:
menu_items = profiles

return "\n".join(sorted(menu_items))


def icon_entry(name: str, icon: str | None) -> str:
return f"{name}\0icon\x1f{icon or config.default_icon}"
3 changes: 3 additions & 0 deletions src/qbpm/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# TODO real config file
default_icon = "qutebrowser"
application_name_suffix = " (qutebrowser profile)"
18 changes: 16 additions & 2 deletions src/qbpm/desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@


# TODO expose application_dir through config
def create_desktop_file(profile: Profile, application_dir: Path | None = None) -> None:
def create_desktop_file(
profile: Profile,
application_dir: Path | None = None,
icon: str | None = None,
) -> None:
text = textwrap.dedent(f"""\
[Desktop Entry]
Name={profile.name} (qutebrowser profile)
StartupWMClass=qutebrowser
GenericName={profile.name}
Icon=qutebrowser
Icon={icon or "qutebrowser"}
Type=Application
Categories=Network;WebBrowser;
Exec={" ".join([*profile.cmdline(), "--untrusted-args", "%u"])}
Expand All @@ -46,3 +50,13 @@ def create_desktop_file(profile: Profile, application_dir: Path | None = None) -
""")
application_dir = application_dir or default_qbpm_application_dir()
(application_dir / f"{profile.name}.desktop").write_text(text)


# TODO
# def add_to_desktop_file(profile: Profile, key: str, value: str) -> None:
# desktop_file = application_dir / f"{profile.name}.desktop"
# if not desktop_file.exists():
# return
# desktop = DesktopEntry(str(application_dir / f"{profile.name}.desktop"))
# desktop.set(key, value)
# desktop.write()
118 changes: 118 additions & 0 deletions src/qbpm/favicon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""
SPDX-License-Identifier: MIT
derived from favicon.py by Scott Werner
https://github.com/scottwernervt/favicon/tree/123e431f53b2c4903b540246a85db0b1633d4786
"""

import re
from collections import defaultdict, namedtuple
from html.parser import HTMLParser
from pathlib import Path
from typing import Any

import httpx

LINK_RELS = [
"icon",
"shortcut icon",
]

SIZE_RE = re.compile(r"(?P<width>\d{2,4})x(?P<height>\d{2,4})", flags=re.IGNORECASE)

Icon = namedtuple("Icon", ["url", "width", "height", "format", "src"])


def get(client: httpx.Client) -> list[Icon]:
response = client.get("")
response.raise_for_status()
client.base_url = response.url

icons = {icon.url: icon for icon in tags(response.text)}

fallback_icon = fallback(client)
if fallback_icon and fallback_icon.src not in icons:
icons[fallback_icon.url] = fallback_icon

# print(f"{icons=}")
return list(icons.values())
# return sorted(icons, key=lambda i: i.width + i.height, reverse=True)


def fallback(client: httpx.Client) -> Icon | None:
response = client.head("favicon.ico")
if response.status_code == 200 and response.headers["Content-Type"].startswith(
"image"
):
return Icon(response.url, 0, 0, ".ico", "default")
return None


class LinkRelParser(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.icons: dict[str, set[str]] = defaultdict(set)

def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag == "link":
data = dict(attrs)
rel = data.get("rel")
if rel in LINK_RELS and (href := data.get("href") or data.get("content")):
# TODO replace with data
self.icons[rel].add(href)


def tags(html: str) -> set[Icon]:
parser = LinkRelParser()
parser.feed(html[0 : html.find("</head>")])
hrefs = {link.strip() for links in parser.icons.values() for link in links}

icons = set()
for href in hrefs:
if not href or href.startswith("data:image/"):
continue

# url_parsed = urlparse(url)
# repair '//cdn.network.com/favicon.png' or `icon.png?v2`
href_parsed = httpx.URL(href)

width, height = (0, 0) # dimensions(tag)
ext = Path(href_parsed.path).suffix

icon = Icon(
href_parsed,
width,
height,
ext.lower(),
"TODO",
)
icons.add(icon)

return icons


def dimensions(tag: Any) -> tuple[int, int]:
"""Get icon dimensions from size attribute or icon filename.

:param tag: Link or meta tag.
:type tag: :class:`bs4.element.Tag`

:return: If found, width and height, else (0,0).
:rtype: tuple(int, int)
"""
sizes = tag.get("sizes", "")
if sizes and sizes != "any":
size = sizes.split(" ") # '16x16 32x32 64x64'
size.sort(reverse=True)
width, height = re.split(r"[x\xd7]", size[0])
else:
filename = tag.get("href") or tag.get("content")
size = SIZE_RE.search(filename)
if size:
width, height = size.group("width"), size.group("height")
else:
width, height = "0", "0"

# repair bad html attribute values: sizes='192x192+'
width = "".join(c for c in width if c.isdigit())
height = "".join(c for c in height if c.isdigit())
return int(width), int(height)
Loading