Skip to content

Commit c1301f8

Browse files
committed
download favicons for menus that support icons
1 parent 6f19894 commit c1301f8

File tree

15 files changed

+405
-8
lines changed

15 files changed

+405
-8
lines changed

TODO

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
favicon request content type: ["image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5"]

contrib/PKGBUILD

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ url="https://github.com/pvsr/qbpm"
88
license=('GPL-3.0-or-later')
99
sha512sums=('SKIP')
1010
arch=('any')
11-
depends=('python' 'python-click' 'python-xdg-base-dirs' 'python-dacite')
11+
depends=(
12+
'python'
13+
'python-click'
14+
'python-xdg-base-dirs'
15+
'python-dacite'
16+
'python-httpx'
17+
'python-pillow'
18+
)
1219
makedepends=('git' 'python-build' 'python-installer' 'python-wheel' 'python-flit-core' 'scdoc')
1320
provides=('qbpm')
1421
source=("git+https://github.com/pvsr/qbpm")

flake.nix

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,14 @@
6969
default = pkgs.mkShell {
7070
packages = [
7171
pkgs.ruff
72+
pkgs.xdg-utils
7273
(pyprojectEnv pkgs.python3 (ps: [
7374
ps.flit
7475
ps.pytest
7576
ps.pytest-cov
7677
ps.mypy
7778
ps.pylsp-mypy
79+
ps.types-pillow
7880
]))
7981
];
8082
};

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ dependencies = [
1919
"click",
2020
"xdg-base-dirs",
2121
"dacite",
22+
"httpx",
23+
"pillow",
2224
]
2325

2426
[project.urls]

qbpm.1.scd

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,18 @@ appropriate \--basedir, or more conveniently using the qbpm launch and qbpm choo
8383
qbpm launch -n qb-dev --debug --json-logging
8484
```
8585

86+
*icon* [options] <profile> <icon>
87+
Install an icon to _profile_. _icon_ may be a url, a path to an image file,
88+
or, if --by-name is passed, the name of an xdg icon installed on your
89+
system. If _icon_ is a url, qbpm will fetch the page and attempt to find a
90+
suitable favicon.
91+
92+
Options:
93+
94+
*-n, --by-name*
95+
Interpret _icon_ as the name of an xdg icon file according to the
96+
freedesktop.org icon specification. Likely to only work on Linux.
97+
8698
*choose* [options] [arguments...]
8799
Open a menu to choose a qutebrowser profile to launch. On linux this defaults
88100
to dmenu or another compatible menu program such as rofi, and on macOS this
@@ -134,3 +146,9 @@ Peter Rice
134146
_https://github.com/pvsr/qbpm_
135147

136148
_https://codeberg.org/pvsr/qbpm_
149+
150+
# LICENSE
151+
152+
src/qbpm/favicon.py: MIT
153+
154+
all other code and qbpm as a whole: GPLv3+

src/qbpm/choose.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,19 @@
33

44
from . import Profile
55
from .launch import launch_qutebrowser
6+
from .icons import icon_for_profile
67
from .log import error
78
from .menus import find_menu
89

910

11+
# TODO take config arg
1012
def choose_profile(
1113
profile_dir: Path,
1214
menu: str | list[str],
1315
prompt: str,
1416
foreground: bool,
1517
qb_args: tuple[str, ...],
18+
force_icon: bool = False,
1619
) -> bool:
1720
dmenu = find_menu(menu)
1821
if not dmenu:
@@ -23,11 +26,15 @@ def choose_profile(
2326
error("no profiles")
2427
return False
2528
profiles = [*real_profiles, "qutebrowser"]
29+
use_icon = force_icon
30+
# TODO check config
31+
# TODO get menu icon support
32+
# use_icon = dmenu.icon_support or force_icon
2633
command = dmenu.command(sorted(profiles), prompt, " ".join(qb_args))
2734
selection_cmd = subprocess.run(
2835
command,
2936
text=True,
30-
input="\n".join(sorted(profiles)),
37+
input=build_menu_items(profiles, use_icon),
3138
stdout=subprocess.PIPE,
3239
stderr=None,
3340
check=False,
@@ -43,3 +50,20 @@ def choose_profile(
4350
else:
4451
error("no profile selected")
4552
return False
53+
54+
55+
def build_menu_items(profiles: list[str], icon: bool) -> str:
56+
# TODO build profile before passing to icons
57+
if icon and any(profile_icons := [icon_for_profile(p) for p in profiles]):
58+
menu_items = [
59+
icon_entry(profile, icon)
60+
for (profile, icon) in zip(profiles, profile_icons)
61+
]
62+
else:
63+
menu_items = profiles
64+
65+
return "\n".join(sorted(menu_items))
66+
67+
68+
def icon_entry(name: str, icon: str | None) -> str:
69+
return f"{name}\0icon\x1f{icon or 'qutebrowser'}"

src/qbpm/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ class Config:
2525
)
2626
menu: str | list[str] = field(default_factory=list)
2727
menu_prompt: str = "qutebrowser"
28+
# TODO full support, tests, etc.
29+
icon: bool = False
30+
# TODO necessary? will be either qutebrowser or None
31+
# default_icon = "qutebrowser"
2832

2933
@classmethod
3034
def load(cls, config_file: Path | None) -> "Config":

src/qbpm/desktop.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@
1818
]
1919

2020

21-
def create_desktop_file(profile: Profile, application_dir: Path) -> None:
21+
def create_desktop_file(
22+
profile: Profile,
23+
application_dir: Path,
24+
icon: str | None = None,
25+
) -> None:
2226
text = textwrap.dedent(f"""\
2327
[Desktop Entry]
2428
Name={profile.name} (qutebrowser profile)
2529
StartupWMClass=qutebrowser
2630
GenericName={profile.name}
27-
Icon=qutebrowser
31+
Icon={icon or "qutebrowser"}
2832
Type=Application
2933
Categories=Network;WebBrowser;
3034
Exec={" ".join([*profile.cmdline(), "--untrusted-args", "%u"])}
@@ -44,3 +48,14 @@ def create_desktop_file(profile: Profile, application_dir: Path) -> None:
4448
""")
4549
application_dir.mkdir(parents=True, exist_ok=True)
4650
(application_dir / f"{profile.name}.desktop").write_text(text)
51+
52+
53+
# TODO
54+
def add_to_desktop_file(profile: Profile, key: str, value: str) -> None:
55+
pass
56+
# desktop_file = application_dir / f"{profile.name}.desktop"
57+
# if not desktop_file.exists():
58+
# return
59+
# desktop = DesktopEntry(str(application_dir / f"{profile.name}.desktop"))
60+
# desktop.set(key, value)
61+
# desktop.write()

src/qbpm/favicon.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""
2+
SPDX-License-Identifier: MIT
3+
derived from favicon.py by Scott Werner
4+
https://github.com/scottwernervt/favicon/tree/123e431f53b2c4903b540246a85db0b1633d4786
5+
"""
6+
7+
import re
8+
from collections import defaultdict, namedtuple
9+
from html.parser import HTMLParser
10+
from pathlib import Path
11+
from typing import Any
12+
13+
import httpx
14+
15+
LINK_RELS = [
16+
"icon",
17+
"shortcut icon",
18+
]
19+
20+
SIZE_RE = re.compile(r"(?P<width>\d{2,4})x(?P<height>\d{2,4})", flags=re.IGNORECASE)
21+
22+
Icon = namedtuple("Icon", ["url", "width", "height", "format", "src"])
23+
24+
25+
def get(client: httpx.Client) -> list[Icon]:
26+
response = client.get("")
27+
response.raise_for_status()
28+
client.base_url = response.url
29+
30+
icons = {icon.url: icon for icon in tags(response.text)}
31+
32+
fallback_icon = fallback(client)
33+
if fallback_icon and fallback_icon.src not in icons:
34+
icons[fallback_icon.url] = fallback_icon
35+
36+
# print(f"{icons=}")
37+
return list(icons.values())
38+
# return sorted(icons, key=lambda i: i.width + i.height, reverse=True)
39+
40+
41+
def fallback(client: httpx.Client) -> Icon | None:
42+
response = client.head("favicon.ico")
43+
if response.status_code == 200 and response.headers["Content-Type"].startswith(
44+
"image"
45+
):
46+
return Icon(response.url, 0, 0, ".ico", "default")
47+
return None
48+
49+
50+
class LinkRelParser(HTMLParser):
51+
def __init__(self) -> None:
52+
super().__init__()
53+
self.icons: dict[str, set[str]] = defaultdict(set)
54+
55+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
56+
if tag == "link":
57+
data = dict(attrs)
58+
rel = data.get("rel")
59+
if rel in LINK_RELS and (href := data.get("href") or data.get("content")):
60+
# TODO replace with data
61+
self.icons[rel].add(href)
62+
63+
64+
def tags(html: str) -> set[Icon]:
65+
parser = LinkRelParser()
66+
parser.feed(html[0 : html.find("</head>")])
67+
hrefs = {link.strip() for links in parser.icons.values() for link in links}
68+
69+
icons = set()
70+
for href in hrefs:
71+
if not href or href.startswith("data:image/"):
72+
continue
73+
74+
# url_parsed = urlparse(url)
75+
# repair '//cdn.network.com/favicon.png' or `icon.png?v2`
76+
href_parsed = httpx.URL(href)
77+
78+
width, height = (0, 0) # dimensions(tag)
79+
ext = Path(href_parsed.path).suffix
80+
81+
icon = Icon(
82+
href_parsed,
83+
width,
84+
height,
85+
ext.lower(),
86+
"TODO",
87+
)
88+
icons.add(icon)
89+
90+
return icons
91+
92+
93+
def dimensions(tag: Any) -> tuple[int, int]:
94+
"""Get icon dimensions from size attribute or icon filename.
95+
96+
:param tag: Link or meta tag.
97+
:type tag: :class:`bs4.element.Tag`
98+
99+
:return: If found, width and height, else (0,0).
100+
:rtype: tuple(int, int)
101+
"""
102+
sizes = tag.get("sizes", "")
103+
if sizes and sizes != "any":
104+
size = sizes.split(" ") # '16x16 32x32 64x64'
105+
size.sort(reverse=True)
106+
width, height = re.split(r"[x\xd7]", size[0])
107+
else:
108+
filename = tag.get("href") or tag.get("content")
109+
size = SIZE_RE.search(filename)
110+
if size:
111+
width, height = size.group("width"), size.group("height")
112+
else:
113+
width, height = "0", "0"
114+
115+
# repair bad html attribute values: sizes='192x192+'
116+
width = "".join(c for c in width if c.isdigit())
117+
height = "".join(c for c in height if c.isdigit())
118+
return int(width), int(height)

0 commit comments

Comments
 (0)